packages/ flattens into: backends/ server protocol adapters (mizan-django, mizan-ts) frontends/ client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte) workers/ runtime workers (mizan-ssr) cores/ shared language-level primitives (empty for now; mizan-python forthcoming) The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is renamed to reflect its role — it's the shared base that frontend adapters depend on directly. Reflects the substrate position that per-framework adapters wrap a single shared kernel; codegen targets the adapter, not the raw kernel. Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four example/harness config files, .claude/settings.local.json, four docs (CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 + react/vue/svelte adapters), and three package.jsons (the mizan-base rename plus mizan-vue/svelte peerDeps). Generated files under examples/django-react-site/harness/src/api/ still reference @mizan/runtime — left as-is; they're regenerated artifacts and the harness is non-functional pending the React wrapper-layer codegen. Also folded in a pre-existing fix: the Gitea workflows had working-directory: react / django pointing at a layout that predates packages/, never updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
/**
|
|
* Request dispatch — context GET and mutation POST handlers.
|
|
*
|
|
* Framework-agnostic. Returns plain objects. The router adapter
|
|
* (Express, Hono, etc.) converts to framework-specific responses.
|
|
*/
|
|
|
|
import { getFunction, getContextGroups } from './registry'
|
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
|
|
|
let _cacheSecret: string | null = null
|
|
|
|
/** Set the cache secret for origin-side caching. */
|
|
export function setCacheSecret(secret: string | null): void {
|
|
_cacheSecret = secret
|
|
}
|
|
|
|
export interface MizanResponse {
|
|
status: number
|
|
body: any
|
|
headers: Record<string, string>
|
|
}
|
|
|
|
function sortedStringify(data: any): string {
|
|
return JSON.stringify(data, Object.keys(data).sort())
|
|
}
|
|
|
|
/**
|
|
* Handle GET /api/mizan/ctx/:contextName/
|
|
*
|
|
* Bundles all functions in a named context into one response.
|
|
*/
|
|
export async function handleContextFetch(
|
|
contextName: string,
|
|
params: Record<string, string>,
|
|
): Promise<MizanResponse> {
|
|
const groups = getContextGroups()
|
|
const fnNames = groups[contextName]
|
|
|
|
if (!fnNames) {
|
|
return {
|
|
status: 404,
|
|
body: { error: true, code: 'NOT_FOUND', message: `Context '${contextName}' not found` },
|
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
}
|
|
}
|
|
|
|
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
|
let effectiveRev = 0
|
|
for (const fnName of fnNames) {
|
|
const entry = getFunction(fnName)
|
|
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
|
|
}
|
|
|
|
// Origin-side cache lookup
|
|
const cacheBackend = getCache()
|
|
const cacheSecret = _cacheSecret
|
|
if (cacheBackend && cacheSecret) {
|
|
try {
|
|
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
|
|
if (cached !== null) {
|
|
return {
|
|
status: 200,
|
|
body: JSON.parse(cached),
|
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
|
}
|
|
}
|
|
} catch { /* cache miss on error */ }
|
|
}
|
|
|
|
const results: Record<string, any> = {}
|
|
|
|
for (const fnName of fnNames) {
|
|
const entry = getFunction(fnName)
|
|
if (!entry) continue
|
|
|
|
// Filter params to only those this function declares
|
|
const fnParams: Record<string, any> = {}
|
|
for (const p of entry.params) {
|
|
if (p.name in params) fnParams[p.name] = params[p.name]
|
|
}
|
|
|
|
try {
|
|
const argValues = entry.params.map(p => fnParams[p.name])
|
|
const result = await entry.fn(...argValues)
|
|
|
|
// View path — skip (context GET is for RPC data)
|
|
if (result instanceof Response) continue
|
|
|
|
results[fnName] = result
|
|
} catch (e: any) {
|
|
return {
|
|
status: 500,
|
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve effective cache policy for origin-side cache decision
|
|
let effectiveCache: number | boolean = true
|
|
for (const fnName of fnNames) {
|
|
const entry = getFunction(fnName)
|
|
if (!entry) continue
|
|
if (entry.cache === false) { effectiveCache = false; break }
|
|
if (typeof entry.cache === 'number') {
|
|
effectiveCache = effectiveCache === true
|
|
? entry.cache
|
|
: Math.min(effectiveCache as number, entry.cache)
|
|
}
|
|
}
|
|
|
|
// Store in origin-side cache (skip if cache=False)
|
|
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
|
try {
|
|
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
|
} catch { /* cache store failure is non-fatal */ }
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: results,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle POST /api/mizan/call/
|
|
*
|
|
* Dispatches to a named function. Returns result + invalidation.
|
|
*/
|
|
export async function handleMutationCall(
|
|
fnName: string,
|
|
args: Record<string, any>,
|
|
): Promise<MizanResponse> {
|
|
const entry = getFunction(fnName)
|
|
|
|
if (!entry) {
|
|
return {
|
|
status: 404,
|
|
body: { error: true, code: 'NOT_FOUND', message: `Function '${fnName}' not found` },
|
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
}
|
|
}
|
|
|
|
// Reject private functions from RPC dispatch
|
|
if (entry.private) {
|
|
return {
|
|
status: 403,
|
|
body: { error: true, code: 'FORBIDDEN', message: 'Function is not client-callable' },
|
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
}
|
|
}
|
|
|
|
try {
|
|
const argValues = entry.params.map(p => args[p.name])
|
|
const result = await entry.fn(...argValues)
|
|
|
|
// View path — return Response directly with invalidation header
|
|
if (result instanceof Response) {
|
|
const invalidate = resolveInvalidation(entry, args)
|
|
if (invalidate) {
|
|
result.headers.set('X-Mizan-Invalidate', formatInvalidateHeader(invalidate))
|
|
}
|
|
result.headers.set('Cache-Control', 'no-store')
|
|
return {
|
|
status: result.status,
|
|
body: result,
|
|
headers: Object.fromEntries(result.headers.entries()),
|
|
}
|
|
}
|
|
|
|
// RPC path — JSON response with invalidation
|
|
const invalidate = resolveInvalidation(entry, args)
|
|
const responseData: Record<string, any> = { result }
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
}
|
|
|
|
if (invalidate) {
|
|
responseData.invalidate = invalidate
|
|
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
|
|
|
|
// Purge origin-side cache
|
|
const cb = getCache()
|
|
if (cb) {
|
|
try {
|
|
for (const entry of invalidate) {
|
|
if (typeof entry === 'string') {
|
|
cachePurge(cb, entry)
|
|
} else {
|
|
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
|
}
|
|
}
|
|
} catch { /* purge failure is non-fatal */ }
|
|
}
|
|
}
|
|
|
|
return { status: 200, body: responseData, headers }
|
|
} catch (e: any) {
|
|
return {
|
|
status: 500,
|
|
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
|
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
}
|
|
}
|
|
}
|