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:
19
packages/mizan-ts/bun.lock
Normal file
19
packages/mizan-ts/bun.lock
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@mizan/ts",
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/mizan-ts/package.json
Normal file
14
packages/mizan-ts/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@mizan/ts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mizan TypeScript backend adapter — server functions, context bundling, invalidation protocol.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
140
packages/mizan-ts/src/decorator.ts
Normal file
140
packages/mizan-ts/src/decorator.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/mizan-ts/src/dispatch.ts
Normal file
148
packages/mizan-ts/src/dispatch.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/mizan-ts/src/index.ts
Normal file
13
packages/mizan-ts/src/index.ts
Normal 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'
|
||||||
102
packages/mizan-ts/src/invalidation.ts
Normal file
102
packages/mizan-ts/src/invalidation.ts
Normal 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(', ')
|
||||||
|
}
|
||||||
89
packages/mizan-ts/src/manifest.ts
Normal file
89
packages/mizan-ts/src/manifest.ts
Normal 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
|
||||||
|
}
|
||||||
49
packages/mizan-ts/src/registry.ts
Normal file
49
packages/mizan-ts/src/registry.ts
Normal 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()
|
||||||
|
}
|
||||||
61
packages/mizan-ts/src/types.ts
Normal file
61
packages/mizan-ts/src/types.ts
Normal 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>
|
||||||
|
}
|
||||||
238
packages/mizan-ts/tests/edge-compat.test.ts
Normal file
238
packages/mizan-ts/tests/edge-compat.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
|
||||||
|
*
|
||||||
|
* These prove that a Cloudflare Worker (Edge) can sit in front of a
|
||||||
|
* TypeScript backend and behave identically to sitting in front of Django.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||||
|
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src'
|
||||||
|
|
||||||
|
const UserCtx = new ReactContext('user')
|
||||||
|
|
||||||
|
function setupUserContext() {
|
||||||
|
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||||
|
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||||
|
return { count: userId * 10 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||||
|
return { name, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
|
||||||
|
client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
|
||||||
|
return { name, email: `user${userId}@test.com` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Edge Compatibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRegistry()
|
||||||
|
setupUserContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Deterministic JSON ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('deterministic JSON output', async () => {
|
||||||
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
const r2 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('different params produce different responses', async () => {
|
||||||
|
const r1 = await handleContextFetch('user', { userId: '5' })
|
||||||
|
const r2 = await handleContextFetch('user', { userId: '6' })
|
||||||
|
expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
|
||||||
|
expect(r1.body.userProfile.name).toBe('user_5')
|
||||||
|
expect(r2.body.userProfile.name).toBe('user_6')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cache-Control correctness ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('context GET is cacheable', async () => {
|
||||||
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r.headers['Cache-Control']).toContain('public')
|
||||||
|
expect(r.headers['Cache-Control']).toContain('stale-while-revalidate')
|
||||||
|
expect(r.headers['Cache-Control']).not.toContain('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mutation POST not cacheable', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('error response not cacheable', async () => {
|
||||||
|
const r = await handleContextFetch('nonexistent', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Vary header ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Vary header present on context GET', async () => {
|
||||||
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r.headers['Vary']).toContain('Authorization')
|
||||||
|
expect(r.headers['Vary']).toContain('Cookie')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── X-Mizan-Invalidate header ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('mutation response includes invalidation header', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auto-scoped invalidation in header', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalidation header matches JSON body', async () => {
|
||||||
|
const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
|
||||||
|
const body = r.body
|
||||||
|
expect(body.invalidate[0].context).toBe('user')
|
||||||
|
expect(body.invalidate[0].params.userId).toBe(5)
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('function-level invalidation in header', async () => {
|
||||||
|
const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no invalidation header on context GET', async () => {
|
||||||
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Header format edge cases ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('special characters in param values are URL-encoded', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'search', params: { q: 'a;b' } },
|
||||||
|
])
|
||||||
|
expect(header).not.toContain(';b')
|
||||||
|
expect(header).toContain('a%3Bb')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spaces in param values are URL-encoded', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'search', params: { q: 'hello world' } },
|
||||||
|
])
|
||||||
|
expect(header).not.toContain(' ')
|
||||||
|
expect(header).toContain('hello%20world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('header round-trip with special chars', () => {
|
||||||
|
const header = formatInvalidateHeader([
|
||||||
|
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Parse (what Edge does)
|
||||||
|
const segments = header.split(';')
|
||||||
|
const ctx = segments[0]
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
for (const seg of segments.slice(1)) {
|
||||||
|
const [k, v] = seg.split('=', 2)
|
||||||
|
params[decodeURIComponent(k)] = decodeURIComponent(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ctx).toBe('data')
|
||||||
|
expect(params.name).toBe("O'Brien")
|
||||||
|
expect(params.tag).toBe('a;b;c')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Empty invalidation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('no affects = no header, no body key', async () => {
|
||||||
|
client({ context: new ReactContext('plain') }, async function plainFn() {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// A context function called via mutation dispatch (shouldn't have invalidation)
|
||||||
|
// Actually test a function without affects
|
||||||
|
clearRegistry()
|
||||||
|
client({}, async function noAffects() { return { ok: true } })
|
||||||
|
const r = await handleMutationCall('noAffects', {})
|
||||||
|
expect(r.body.invalidate).toBeUndefined()
|
||||||
|
expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Private functions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('private functions rejected from RPC', async () => {
|
||||||
|
clearRegistry()
|
||||||
|
client({ affects: 'subscription', private: true }, async function webhook() {
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
const r = await handleMutationCall('webhook', {})
|
||||||
|
expect(r.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Unknown function ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('unknown function returns 404', async () => {
|
||||||
|
const r = await handleMutationCall('doesNotExist', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown context returns 404', async () => {
|
||||||
|
const r = await handleContextFetch('doesNotExist', {})
|
||||||
|
expect(r.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Manifest', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRegistry()
|
||||||
|
setupUserContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manifest matches expected structure', () => {
|
||||||
|
const m = generateManifest()
|
||||||
|
|
||||||
|
expect(m.contexts.user).toBeDefined()
|
||||||
|
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
|
||||||
|
expect(m.contexts.user.params).toContain('userId')
|
||||||
|
expect(m.contexts.user.user_scoped).toBe(true)
|
||||||
|
expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mutations section includes auto-scoped params', () => {
|
||||||
|
const m = generateManifest()
|
||||||
|
|
||||||
|
expect(m.mutations.updateProfile).toBeDefined()
|
||||||
|
expect(m.mutations.updateProfile.affects).toEqual(['user'])
|
||||||
|
expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PSR strategy for non-user-scoped context', () => {
|
||||||
|
clearRegistry()
|
||||||
|
const ProductCtx = new ReactContext('products')
|
||||||
|
client({ context: ProductCtx }, async function productDetail(productId: number) {
|
||||||
|
return { id: productId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
expect(m.contexts.products.user_scoped).toBe(false)
|
||||||
|
expect(m.contexts.products.render_strategy).toBe('psr')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('private mutation in manifest', () => {
|
||||||
|
clearRegistry()
|
||||||
|
client(
|
||||||
|
{ affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
|
||||||
|
async function stripeWebhook() { return new Response('ok') },
|
||||||
|
)
|
||||||
|
|
||||||
|
const m = generateManifest()
|
||||||
|
expect(m.mutations.stripeWebhook).toBeDefined()
|
||||||
|
expect(m.mutations.stripeWebhook.private).toBe(true)
|
||||||
|
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
||||||
|
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
||||||
|
})
|
||||||
|
})
|
||||||
16
packages/mizan-ts/tsconfig.json
Normal file
16
packages/mizan-ts/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user