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>
This commit is contained in:
213
backends/mizan-ts/src/dispatch.ts
Normal file
213
backends/mizan-ts/src/dispatch.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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' },
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user