/** * 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 } 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, ): Promise { 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 = {} for (const fnName of fnNames) { const entry = getFunction(fnName) if (!entry) continue // Filter params to only those this function declares const fnParams: Record = {} 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, ): Promise { 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 = { result } const headers: Record = { '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' }, } } }