Add mizan-ts: TypeScript backend adapter proving AFI is language-agnostic

The TypeScript adapter produces the same manifest, the same
X-Mizan-Invalidate headers, the same JSON invalidation protocol,
and the same CDN-ready response headers as mizan-django.

One Edge Worker. Two backend languages. Same protocol.

Features:
- @client decorator (function wrapper + class method decorator)
- ReactContext class (same API as Django adapter)
- Registry with context groups and param tracking
- Context bundled GET: /api/mizan/ctx/<name>/
- Mutation POST: /api/mizan/call/ with server-driven invalidation
- Three-tier auto-scoping (argument name matching → broad fallback)
- Function-level affects targeting
- private=True (rejected from RPC, in manifest for Edge)
- X-Mizan-Invalidate header with URL-encoded params
- Edge manifest generation (identical format to Django's)
- render_strategy + user_scoped derivation

22 edge compatibility tests pass (Bun, 21ms):
- Deterministic JSON, sorted keys
- Cache-Control: public on GETs, no-store on mutations/errors
- Vary: Authorization, Cookie
- Header round-trip with special characters
- Auto-scoped invalidation matches body and header
- Function-level invalidation
- Private function rejection
- Manifest structure with PSR/dynamic_cached strategies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:19:48 -04:00
parent d228c7ab1b
commit 97237ed1a4
11 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
/**
* Mizan @client decorator and function wrapper.
*
* Two registration styles:
*
* 1. Function wrapper (standalone functions):
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*
* 2. Class decorator (methods):
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
if (ctx instanceof ReactContext) return ctx.name
return ctx
}
function normalizeAffects(
affects: ClientOptions['affects'],
): RegistryEntry['affects'] | undefined {
if (!affects) return undefined
const items = Array.isArray(affects) ? affects : [affects]
return items.map(item => {
if (item instanceof ReactContext) {
return { type: 'context' as const, name: item.name }
}
return { type: 'context' as const, name: item }
})
}
function extractParams(fn: Function): ParamDef[] {
// Extract parameter names from function.toString()
const source = fn.toString()
const match = source.match(/\(([^)]*)\)/)
if (!match || !match[1].trim()) return []
return match[1]
.split(',')
.map(p => p.trim())
.filter(p => p && !p.startsWith('...'))
.map(p => {
// Handle destructured defaults: name = default, name: type
const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') }
})
}
function isResponseReturn(result: any): boolean {
return result instanceof Response
}
/**
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
export function client<T extends (...args: any[]) => Promise<any>>(
options: ClientOptions,
fn: T,
): T
/**
* Class method decorator.
*
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const name = fn.name || 'anonymous'
const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = {
name,
fn: fn as any,
context,
affects,
params,
private: options.private ?? false,
viewPath: isView,
route: options.route,
methods: options.methods,
auth: options.auth,
}
register(entry)
return fn
}
// Decorator form: @client(options)
const options = optionsOrFn as ClientOptions
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const params = extractParams(originalMethod)
const entry: RegistryEntry = {
name: propertyKey,
fn: originalMethod,
context,
affects,
params,
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: options.auth,
}
register(entry)
return descriptor
}
}

View File

@@ -0,0 +1,148 @@
/**
* 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'
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' },
}
}
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' },
}
}
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=0, stale-while-revalidate=300',
'Vary': 'Authorization, Cookie',
},
}
}
/**
* 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)
}
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' },
}
}
}

View File

@@ -0,0 +1,13 @@
export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
export { client } from './decorator'
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
export { handleContextFetch, handleMutationCall } from './dispatch'
export type { MizanResponse } from './dispatch'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'

View File

@@ -0,0 +1,102 @@
/**
* Invalidation protocol — header formatting, auto-scoping.
*
* Matches Django's implementation exactly. Same format. Same rules.
*/
import type { RegistryEntry } from './types'
import { getContextGroups, getContextParamNames, getFunction } from './registry'
type InvalidateEntry = string | { context: string; params: Record<string, any> }
/**
* Resolve invalidation targets with three-tier auto-scoping.
*
* Tier 1: Argument name matching
* Tier 2: Auth inference (Edge-side, not handled here)
* Tier 3: Broad fallback
*/
export function resolveInvalidation(
entry: RegistryEntry,
callArgs: Record<string, any> | null,
): InvalidateEntry[] | null {
if (!entry.affects) return null
const result: InvalidateEntry[] = []
const seen = new Set<string>()
for (const target of entry.affects) {
const targetName = target.name
if (seen.has(targetName)) continue
seen.add(targetName)
// Resolve which context the target belongs to (for param lookup)
const resolved = resolveAffectsTarget(targetName)
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
// Tier 1: argument name matching
if (callArgs && ctxForParams) {
const contextParams = getContextParamNames(ctxForParams)
const matched: Record<string, any> = {}
for (const [k, v] of Object.entries(callArgs)) {
if (contextParams.has(k)) matched[k] = v
}
if (Object.keys(matched).length > 0) {
result.push({ context: targetName, params: matched })
continue
}
}
// Tier 3: broad fallback
result.push(targetName)
}
return result.length > 0 ? result : null
}
/**
* Determine whether an affects target is a context name or function name.
*/
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
const groups = getContextGroups()
if (name in groups) {
return { type: 'context', name }
}
for (const [ctxName, fnNames] of Object.entries(groups)) {
if (fnNames.includes(name)) {
return { type: 'function', name, context: ctxName }
}
}
return { type: 'context', name }
}
/**
* Format invalidation targets as X-Mizan-Invalidate header value.
*
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
*/
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const parts: string[] = []
for (const entry of invalidate) {
if (typeof entry === 'string') {
parts.push(entry)
} else {
const { context, params } = entry
if (params && Object.keys(params).length > 0) {
const paramStr = Object.entries(params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
.join(';')
parts.push(`${context};${paramStr}`)
} else {
parts.push(context)
}
}
}
return parts.join(', ')
}

View File

@@ -0,0 +1,89 @@
/**
* Edge Manifest Generator
*
* Produces the same JSON format as mizan-django. One Edge Worker.
* Two backend languages. Same manifest.
*/
import type { EdgeManifest } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
const groups = getContextGroups()
const allFunctions = getAllFunctions()
const manifest: EdgeManifest = { contexts: {}, mutations: {} }
// Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) {
const paramNames = new Set<string>()
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
const pageRoutes: string[] = []
for (const fnName of fnNames) {
const entry = allFunctions.get(fnName)
if (!entry) continue
for (const p of entry.params) paramNames.add(p.name)
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
if (entry.route) {
fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
functions.push(fnEntry)
}
const sortedParams = [...paramNames].sort()
const userScoped = [...paramNames].some(p => USER_SCOPED_PARAMS.has(p))
const ctxEntry: EdgeManifest['contexts'][string] = {
functions,
endpoints: [`${baseUrl}/ctx/${ctxName}/`],
params: sortedParams,
user_scoped: userScoped,
render_strategy: userScoped ? 'dynamic_cached' : 'psr',
}
if (pageRoutes.length > 0) {
ctxEntry.page_routes = pageRoutes
}
manifest.contexts[ctxName] = ctxEntry
}
// Mutations
for (const [fnName, entry] of allFunctions) {
if (!entry.affects) continue
const affectedContexts = [...new Set(entry.affects.map(a => a.name))]
// Auto-scoped params
const fnParamNames = new Set(entry.params.map(p => p.name))
const autoScoped: string[] = []
for (const ctxName of affectedContexts) {
const ctxParams = getContextParamNames(ctxName)
for (const p of fnParamNames) {
if (ctxParams.has(p) && !autoScoped.includes(p)) {
autoScoped.push(p)
}
}
}
const mutation: EdgeManifest['mutations'][string] = {
affects: affectedContexts,
}
if (autoScoped.length > 0) mutation.auto_scoped_params = autoScoped.sort()
if (entry.private) mutation.private = true
if (entry.route) {
mutation.route = entry.route
mutation.methods = entry.methods || ['POST']
}
manifest.mutations[fnName] = mutation
}
return manifest
}

View File

@@ -0,0 +1,49 @@
/**
* Mizan Registry — Central registration for server functions.
*/
import type { RegistryEntry } from './types'
const _functions: Map<string, RegistryEntry> = new Map()
export function register(entry: RegistryEntry): void {
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
throw new Error(`Function '${entry.name}' already registered`)
}
_functions.set(entry.name, entry)
}
export function getFunction(name: string): RegistryEntry | undefined {
return _functions.get(name)
}
export function getAllFunctions(): Map<string, RegistryEntry> {
return new Map(_functions)
}
export function getContextGroups(): Record<string, string[]> {
const groups: Record<string, string[]> = {}
for (const [name, entry] of _functions) {
if (entry.context) {
if (!groups[entry.context]) groups[entry.context] = []
groups[entry.context].push(name)
}
}
return groups
}
export function getContextParamNames(contextName: string): Set<string> {
const params = new Set<string>()
for (const [, entry] of _functions) {
if (entry.context === contextName) {
for (const p of entry.params) {
params.add(p.name)
}
}
}
return params
}
export function clearRegistry(): void {
_functions.clear()
}

View File

@@ -0,0 +1,61 @@
/**
* Mizan TypeScript Adapter — Shared Types
*/
export class ReactContext {
constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty')
}
}
export type AffectsTarget = ReactContext | string
export interface ClientOptions {
context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[]
private?: boolean
route?: string
methods?: string[]
auth?: boolean
}
export interface ParamDef {
name: string
type: string
required: boolean
}
export interface RegistryEntry {
name: string
fn: (...args: any[]) => Promise<any>
context?: string
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
params: ParamDef[]
private: boolean
viewPath: boolean
route?: string
methods?: string[]
auth?: boolean
}
export interface ManifestContext {
functions: Array<{ name: string; path: 'rpc' | 'view' }>
endpoints: string[]
params: string[]
user_scoped: boolean
render_strategy: 'psr' | 'dynamic_cached'
page_routes?: string[]
}
export interface ManifestMutation {
affects: string[]
auto_scoped_params?: string[]
private?: boolean
route?: string
methods?: string[]
}
export interface EdgeManifest {
contexts: Record<string, ManifestContext>
mutations: Record<string, ManifestMutation>
}