Files
mizan/backends/mizan-ts/src/dispatch.ts
Ryth Azhur fe39fcb229 Restructure tree by role; rename mizan-runtime → mizan-base
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>
2026-05-05 20:55:37 -04:00

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' },
}
}
}