AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -13,7 +13,7 @@
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types'
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
@@ -21,6 +21,12 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
return ctx
}
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
if (!merge) return undefined
const items = Array.isArray(merge) ? merge : [merge]
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
}
/**
* Normalize the public auth option into the stored requirement.
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
@@ -65,6 +71,36 @@ function extractParams(fn: Function): ParamDef[] {
})
}
function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry {
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
return {
name,
fn: fn as any,
context,
affects,
merge: normalizeMerge(options.merge),
params: extractParams(fn),
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: normalizeAuth(options.auth),
websocket: options.websocket,
rev: options.rev,
cache: options.cache,
ir: options.ir,
form: options.form,
formName: options.formName,
formRole: options.formRole,
}
}
/**
* Function wrapper — registers a standalone function.
*
@@ -85,69 +121,19 @@ export function client<T extends (...args: any[]) => Promise<any>>(
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
export function client(optionsOrFn: 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: normalizeAuth(options.auth),
rev: options.rev,
cache: options.cache,
}
register(entry)
register(buildEntry(options, name, fn))
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: normalizeAuth(options.auth),
rev: options.rev,
cache: options.cache,
}
register(entry)
register(buildEntry(options, propertyKey, descriptor.value))
return descriptor
}
}

View File

@@ -10,6 +10,7 @@ import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
import { ANONYMOUS, type Identity } from './identity'
import type { AuthRequirement } from './types'
import { UploadedFile, bindUploads } from './upload'
let _cacheSecret: string | null = null
@@ -186,9 +187,10 @@ export async function handleContextFetch(
}
/**
* Handle POST /api/mizan/call/
* Handle POST /api/mizan/call/ — JSON body form.
*
* Dispatches to a named function. Returns result + invalidation.
* Dispatches to a named function. Returns result + invalidation. The multipart
* form (`handleMultipartCall`) binds file parts first, then routes here.
*/
export async function handleMutationCall(
fnName: string,
@@ -272,3 +274,63 @@ export async function handleMutationCall(
}
}
}
function badRequest(message: string): MizanResponse {
return {
status: 400,
body: { error: true, code: 'BAD_REQUEST', message },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/**
* Handle POST /api/mizan/call/ — multipart/form-data form.
*
* Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields
* arrive in a JSON `args` part, and each file part binds into the function's
* Upload-typed inputs (by field name) with declared `File(...)` constraints
* enforced. After binding, execution is identical to the JSON path.
*
* A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other
* parts that share an Upload field name are accepted too.
*/
export async function handleMultipartCall(
form: FormData,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> {
const fnRaw = form.get('fn')
if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field")
const fnName = fnRaw
const argsRaw = form.get('args')
let args: Record<string, any>
try {
args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {}
} catch {
return badRequest("Invalid JSON in 'args' field")
}
if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object")
const entry = getFunction(fnName)
if (entry) {
// Collect file parts by field name into UploadedFile buckets.
const files = new Map<string, UploadedFile[]>()
for (const key of new Set(form.keys())) {
if (key === 'fn' || key === 'args') continue
const bucket: UploadedFile[] = []
for (const part of form.getAll(key)) {
if (part instanceof Blob) {
const data = new Uint8Array(await part.arrayBuffer())
const filename = part instanceof File ? part.name : null
bucket.push(new UploadedFile(filename, part.type || null, data))
}
}
if (bucket.length > 0) files.set(key, bucket)
}
const err = bindUploads(entry, args, files)
if (err !== null) return badRequest(err)
}
return handleMutationCall(fnName, args, identity)
}

View File

@@ -0,0 +1,170 @@
/**
* Forms — schema / validate / submit, AFI-common.
*
* The binding is per-framework (Django Forms on Django; the project's form
* layer elsewhere). The TypeScript binding registers the same three `@client`
* functions `create_form_functions` registers, carrying the same
* `{ form, form_name, form_role }` meta the IR reads — `<name>-schema`,
* `<name>-validate`, and (when a submit handler is given) `<name>-submit`.
*
* schema → { fields: FieldSchema[] } — field definitions
* validate → { valid: boolean, errors: {field: [..]} } — per-field validation
* submit → the handler's return value — validate-then-handle
*
* A `FormField` declares its type/required/label and an optional `validate`
* predicate; `validateForm` runs every field's validator over the submitted
* data, mirroring Django's `form.is_valid()` / `form.errors`.
*/
import { client } from './decorator'
import type { FormRole } from './types'
export interface FormField {
name: string
type?: string
required?: boolean
label?: string
helpText?: string
choices?: Array<{ value: string; label: string }>
initial?: unknown
/**
* Field validator. Return an error message (or array of messages) to
* reject, or null/undefined to accept. Required-ness is enforced before
* the validator runs.
*/
validate?: (value: unknown, data: Record<string, unknown>) => string | string[] | null | undefined
}
export interface FormDefinition {
fields: FormField[]
}
export interface FieldSchema {
name: string
type: string
required: boolean
label: string
helpText: string
choices: Array<{ value: string; label: string }> | null
initial: unknown
}
export interface FormSchemaOutput {
fields: FieldSchema[]
}
export interface FormValidationOutput {
valid: boolean
errors: Record<string, string[]>
}
function titleize(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Build the field-definition schema for a form. Mirrors `build_form_schema`. */
export function formSchema(def: FormDefinition): FormSchemaOutput {
return {
fields: def.fields.map((f) => ({
name: f.name,
type: f.type ?? 'text',
required: f.required ?? true,
label: f.label ?? titleize(f.name),
helpText: f.helpText ?? '',
choices: f.choices ?? null,
initial: f.initial ?? null,
})),
}
}
/**
* Validate submitted `data` against a form. Required fields missing/empty and
* any field whose `validate` returns a message produce per-field errors.
* Mirrors Django's `is_valid()` → `{valid, errors}`.
*/
export function validateForm(def: FormDefinition, data: Record<string, unknown>): FormValidationOutput {
const errors: Record<string, string[]> = {}
for (const field of def.fields) {
const value = data[field.name]
const missing = value === undefined || value === null || value === ''
if ((field.required ?? true) && missing) {
errors[field.name] = ['This field is required.']
continue
}
if (missing) continue
const result = field.validate?.(value, data)
if (result !== null && result !== undefined) {
errors[field.name] = Array.isArray(result) ? result : [result]
}
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** A submit handler runs after validation passes. */
export type FormSubmitHandler = (data: Record<string, unknown>) => unknown | Promise<unknown>
export interface FormRegistration {
schema: string
validate: string
submit?: string
}
/**
* Register a form's schema / validate / submit functions with the registry.
*
* Equivalent to Python's `register_form`: three `@client` functions named
* `<name>-schema`, `<name>-validate`, `<name>-submit`, each carrying
* `{ form, formName, formRole }` so the IR emits `is-form`/`form-name`/
* `form-role`. `submit` is registered only when a handler is supplied.
*
* Returns the registered wire names.
*/
export function registerForm(
def: FormDefinition,
name: string,
options: { submit?: FormSubmitHandler } = {},
): FormRegistration {
const role = (r: FormRole) => ({ form: true, formName: name, formRole: r })
const schemaName = `${name}-schema`
const validateName = `${name}-validate`
const submitName = `${name}-submit`
// schema — returns the field definitions.
const schemaFn = async function () {
return formSchema(def)
}
Object.defineProperty(schemaFn, 'name', { value: schemaName })
client(role('schema'), schemaFn)
// validate — runs per-field validation over the submitted data.
const validateFn = async function (data: Record<string, unknown>) {
return validateForm(def, data)
}
Object.defineProperty(validateFn, 'name', { value: validateName })
client(role('validate'), validateFn)
const registration: FormRegistration = { schema: schemaName, validate: validateName }
// submit — validate, then hand off. Registered only with a handler.
if (options.submit) {
const handler = options.submit
const submitFn = async function (data: Record<string, unknown>) {
const validation = validateForm(def, data)
if (!validation.valid) {
return { ok: false, errors: validation.errors }
}
const result = await handler(data)
return { ok: true, result }
}
Object.defineProperty(submitFn, 'name', { value: submitName })
client(role('submit'), submitFn)
registration.submit = submitName
}
return registration
}

View File

@@ -1,23 +1,63 @@
export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement, FormRole } from './types'
export { ANONYMOUS } from './identity'
export type { Identity, AuthPredicate } from './identity'
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
export type { MwtPayload } from './token'
export {
decodeMwt,
decodeJwtBearer,
identityFromMwt,
signHs256,
signMwt,
mintMwt,
computePermissionKey,
signJwt,
createAccessToken,
createRefreshToken,
mintJwt,
} from './token'
export type { MwtPayload, MintUser, JwtConfig, JwtMintClaims, JwtTokenPair } from './token'
export { client } from './decorator'
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
export { handleContextFetch, handleMutationCall } from './dispatch'
export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch'
export type { MizanResponse } from './dispatch'
export { UploadedFile, parseSize, validateUpload, bindUploads, uploadFields } from './upload'
export type { File as UploadFile } from './upload'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { handleSessionInit, sessionInitRoute, SESSION_INIT_PATH, SESSION_INIT_METHOD } from './session'
export { SSRBridge } from './ssr'
export type { SSRBridgeOptions, RenderResult } from './ssr'
export { handleWebSocketMessage, serveWebSocket } from './websocket'
export type { MizanWsFrame, MizanWsReply, WebSocketLike } from './websocket'
export { buildIr, snakeToCamel } from './ir'
export type { IrSchema, TypeShape, NamedType, StructField, Primitive, DefaultValue } from './ir'
export { Shape, project, projectRecord } from './shapes'
export type { QueryProjection } from './shapes'
export { registerForm, formSchema, validateForm } from './forms'
export type {
FormField,
FormDefinition,
FieldSchema,
FormSchemaOutput,
FormValidationOutput,
FormSubmitHandler,
FormRegistration,
} from './forms'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'

View File

@@ -0,0 +1,409 @@
/**
* KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
*
* The Python emitter is the spec; this is a second implementation under the
* same contract. `buildIr()` walks the registry, resolves the canonical named
* types each function references (`_collect_named_types`), and emits KDL the
* Rust codegen consumes. Any divergence is a bug here, not a contract change —
* `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`.
*/
import { getAllFunctions, getContextGroups, getFunction } from '../registry'
import type { RegistryEntry } from '../types'
import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types'
const INDENT = ' '
// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ────────────────────────────
function kdlString(s: string): string {
const escaped = s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
return `"${escaped}"`
}
function kdlBool(b: boolean): string {
return b ? '#true' : '#false'
}
function kdlDefault(v: DefaultValue): string {
switch (v.kind) {
case 'null':
return '#null'
case 'boolean':
return kdlBool(v.value)
case 'integer':
return String(v.value)
case 'number':
// Match Python's repr(float): whole-number floats render as "1.0".
return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value)
case 'string':
return kdlString(v.value)
}
}
/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */
export function snakeToCamel(name: string): string {
const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_')
return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('')
}
function primitiveName(p: Primitive): string {
return p
}
// ─── Emitter ──────────────────────────────────────────────────────────────
class Emitter {
lines: string[] = []
private prefix(indent: number): string {
return INDENT.repeat(indent)
}
leaf(indent: number, ...parts: string[]): void {
this.lines.push(this.prefix(indent) + parts.join(' '))
}
open(indent: number, ...parts: string[]): void {
this.lines.push(this.prefix(indent) + parts.join(' ') + ' {')
}
close(indent: number): void {
this.lines.push(this.prefix(indent) + '}')
}
blank(): void {
this.lines.push('')
}
emitTypeChild(indent: number, shape: TypeShape): void {
switch (shape.kind) {
case 'primitive':
this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive)))
return
case 'ref':
this.leaf(indent, 'ref', kdlString(shape.name))
return
case 'list':
this.open(indent, 'list')
this.emitTypeChild(indent + 1, shape.inner)
this.close(indent)
return
case 'optional':
this.open(indent, 'optional')
this.emitTypeChild(indent + 1, shape.inner)
this.close(indent)
return
case 'enum':
this.leaf(indent, 'enum', ...shape.variants.map(kdlString))
return
case 'union':
this.open(indent, 'union')
for (const b of shape.branches) this.emitTypeChild(indent + 1, b)
this.close(indent)
return
case 'upload':
this.emitUpload(indent, shape)
return
}
}
private emitUpload(indent: number, shape: Extract<TypeShape, { kind: 'upload' }>): void {
const props: string[] = []
if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`)
if (shape.contentTypes && shape.contentTypes.length > 0) {
this.open(indent, 'upload', ...props)
for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct))
this.close(indent)
} else {
this.leaf(indent, 'upload', ...props)
}
}
emitNamedType(indent: number, name: string, body: NamedType): void {
this.open(indent, 'type', kdlString(name))
if (body.kind === 'struct') {
this.open(indent + 1, 'struct')
for (const field of body.fields) this.emitStructField(indent + 2, field)
this.close(indent + 1)
} else if (body.kind === 'alias') {
this.open(indent + 1, 'alias')
this.emitTypeChild(indent + 2, body.inner)
this.close(indent + 1)
} else {
this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString))
}
this.close(indent)
}
emitStructField(indent: number, field: StructField): void {
const header: string[] = ['field', kdlString(field.name)]
if (!field.required) {
header.push(`required=${kdlBool(false)}`)
if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`)
}
this.open(indent, ...header)
this.emitTypeChild(indent + 1, field.shape)
this.close(indent)
}
intoString(): string {
const lines = [...this.lines]
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
return lines.join('\n') + '\n'
}
}
// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ─────────────
/** Strip Optional[T] → [inner, isOptional]. */
function stripOptional(shape: TypeShape): [TypeShape, boolean] {
if (shape.kind === 'optional') return [shape.inner, true]
return [shape, false]
}
/** list element type, or null. */
function listElement(shape: TypeShape): TypeShape | null {
if (shape.kind === 'list') return shape.inner
return null
}
/** All ref names reachable inside a shape. */
function refsIn(shape: TypeShape): string[] {
switch (shape.kind) {
case 'ref':
return [shape.name]
case 'list':
case 'optional':
return refsIn(shape.inner)
case 'union':
return shape.branches.flatMap(refsIn)
default:
return []
}
}
/** All ref names a NamedType body references. */
function refsInBody(body: NamedType): string[] {
if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape))
if (body.kind === 'alias') return refsIn(body.inner)
return []
}
interface FnTypeInfo {
schema: import('./types').IrSchema
camel: string
}
/**
* First pass: collect every named type the IR's `function` section references,
* keyed by emitted name. Two kinds, exactly as `_collect_named_types`:
* - structs visited anywhere in input/output traversal (under their ref name,
* and under the canonical `<camel>Input` / `<camel>Output` rename)
* - output wrapper aliases (`<camel>Output = list[T]` / primitive / renamed
* model) so the consumer has one named type to reference.
*/
function collectNamedTypes(fns: Map<string, FnTypeInfo>): Record<string, NamedType> {
const seen: Record<string, NamedType> = {}
function visitModel(name: string, types: Record<string, NamedType>): void {
if (name in seen) return
const body = types[name]
if (body === undefined) {
throw new Error(
`IR schema references type "${name}" but no definition was provided in the function's \`types\`.`,
)
}
seen[name] = body
for (const ref of refsInBody(body)) visitModel(ref, types)
}
function visitShape(shape: TypeShape, types: Record<string, NamedType>): void {
for (const ref of refsIn(shape)) visitModel(ref, types)
}
for (const { schema, camel } of fns.values()) {
const types = schema.types ?? {}
// Input — named `<camel>Input`, emitted as a struct.
if (schema.input && schema.input.length > 0) {
const inputName = `${camel}Input`
if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input }
// Visit nested refs in the input fields.
for (const field of schema.input) visitShape(field.shape, types)
}
// Output.
if (schema.output === undefined) continue
const outputName = `${camel}Output`
const [inner] = stripOptional(schema.output)
const elem = listElement(inner)
if (elem !== null) {
// list[T] (possibly Optional) — list alias. Visit element type.
visitShape(schema.output, types)
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
} else if (inner.kind === 'ref') {
// <Model> or Optional[<Model>] — emit the model under the canonical
// output name (rename). Python renames the Pydantic model to
// `<camel>Output`; we emit the referenced struct under that name.
const refName = inner.name
const body = types[refName]
if (body === undefined) {
throw new Error(
`IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`,
)
}
if (body.kind === 'struct') {
// Emit the struct under the canonical output name (the rename),
// and visit its nested refs.
if (!(outputName in seen)) {
seen[outputName] = body
for (const ref of refsInBody(body)) visitModel(ref, types)
}
} else {
// Non-struct named type referenced as output — emit under its
// own name plus a canonical alias.
visitModel(refName, types)
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
}
} else {
// Primitive-wrapped output (`result: int`) — alias.
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
}
}
return seen
}
// ─── Function / context emission ──────────────────────────────────────────
function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } {
const camel = snakeToCamel(entry.name)
const canonical = `${camel}Output`
const schema = entry.ir
if (!schema || schema.output === undefined) return { name: canonical, nullable: false }
const [, nullable] = stripOptional(schema.output)
return { name: canonical, nullable }
}
function emitFunction(em: Emitter, entry: RegistryEntry): void {
const camel = snakeToCamel(entry.name)
const schema = entry.ir ?? {}
const hasInput = !!(schema.input && schema.input.length > 0)
const { name: outputName, nullable } = resolveOutput(entry)
em.open(0, 'function', kdlString(entry.name))
em.leaf(1, 'camel', kdlString(camel))
em.leaf(1, 'has-input', kdlBool(hasInput))
if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`))
em.leaf(1, 'output', kdlString(outputName))
if (nullable) em.leaf(1, 'output-nullable', kdlBool(true))
em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http'))
if (entry.context) em.leaf(1, 'context', kdlString(entry.context))
// Only context-typed affects make it into the KDL (matches ir.py).
for (const a of entry.affects ?? []) {
if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name))
}
for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m))
if (entry.form) {
em.leaf(1, 'is-form', kdlBool(true))
if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName))
if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole))
}
em.close(0)
}
function annotationToPrimitive(shape: TypeShape | undefined): Primitive {
if (shape === undefined) return 'string'
const [inner] = stripOptional(shape)
if (inner.kind === 'primitive') return inner.primitive
return 'string'
}
function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void {
// Collect param info across every function in the context.
interface Slot {
type: Primitive
sharedBy: string[]
}
const paramInfo = new Map<string, Slot>()
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
const input = entry.ir?.input
if (!input || input.length === 0) continue
for (const field of input) {
let slot = paramInfo.get(field.name)
if (!slot) {
slot = { type: 'string', sharedBy: [] }
paramInfo.set(field.name, slot)
}
slot.type = annotationToPrimitive(field.shape)
slot.sharedBy.push(fnName)
}
}
em.open(0, 'context', kdlString(ctxName))
// Members alphabetical — canonical order.
for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName))
for (const paramName of [...paramInfo.keys()].sort()) {
const slot = paramInfo.get(paramName)!
const required = slot.sharedBy.length === fnNames.length
em.open(1, 'param', kdlString(paramName))
em.leaf(2, 'type', kdlString(slot.type))
em.leaf(2, 'required', kdlBool(required))
for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer))
em.close(1)
}
em.close(0)
}
// ─── Top-level builder ──────────────────────────────────────────────────────
/**
* Build the Mizan IR (KDL) for every registered function. Byte-equivalent to
* the Python `build_ir()` against the same registry.
*
* `private` and view-path functions are excluded from the function section,
* matching ir.py.
*/
export function buildIr(): string {
const functions = getAllFunctions()
const contextGroups = getContextGroups()
// Functions contributing to the type/function sections (skip private + view).
const typeFns = new Map<string, FnTypeInfo>()
const emitFns: RegistryEntry[] = []
for (const [name, entry] of functions) {
if (entry.private || entry.viewPath) continue
typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) })
emitFns.push(entry)
}
const namedTypes = collectNamedTypes(typeFns)
const em = new Emitter()
// Types — alphabetical by name (canonical IR ordering).
const typeNames = Object.keys(namedTypes).sort()
for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName])
if (typeNames.length > 0) em.blank()
// Functions — alphabetical by wire name.
emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
for (const entry of emitFns) emitFunction(em, entry)
if (emitFns.length > 0) em.blank()
// Contexts — alphabetical by name.
const ctxNames = Object.keys(contextGroups).sort()
for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName])
if (ctxNames.length > 0) em.blank()
return em.intoString()
}

View File

@@ -0,0 +1,17 @@
/**
* Mizan IR (KDL) — the codegen contract.
*
* `buildIr()` emits KDL byte-identical to the Python `build_ir()` against the
* same registry. This is what lets a TypeScript backend feed
* `protocol/mizan-codegen`.
*/
export { buildIr, snakeToCamel } from './build'
export type {
Primitive,
TypeShape,
DefaultValue,
StructField,
NamedType,
IrSchema,
} from './types'

View File

@@ -0,0 +1,70 @@
/**
* IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and
* `cores/mizan-rust/src/ir.rs` 1:1.
*
* The IR is the contract. Backends emit it; the codegen consumes it. The
* TypeScript side produces byte-equivalent KDL to the Python emitter against
* the same function registry.
*
* TypeScript has no Pydantic to introspect, so the `@client` decorator carries
* an explicit IR type schema (input fields + output shape). That schema is the
* binding: a TS backend declares its IR types, and `buildIr()` emits the KDL
* the codegen reads — exactly as the Rust adapter declares typed `StructField`
* / `TypeShape` registrations.
*/
export type Primitive = 'integer' | 'number' | 'boolean' | 'string'
/**
* An in-place type shape — referenced from struct fields, function
* inputs/outputs, and alias bodies.
*/
export type TypeShape =
| { kind: 'primitive'; primitive: Primitive }
| { kind: 'ref'; name: string }
| { kind: 'list'; inner: TypeShape }
| { kind: 'optional'; inner: TypeShape }
| { kind: 'enum'; variants: string[] }
| { kind: 'union'; branches: TypeShape[] }
| { kind: 'upload'; maxSize?: number; contentTypes?: string[] }
export type DefaultValue =
| { kind: 'integer'; value: number }
| { kind: 'number'; value: number }
| { kind: 'boolean'; value: boolean }
| { kind: 'string'; value: string }
| { kind: 'null' }
export interface StructField {
name: string
required: boolean
default?: DefaultValue
shape: TypeShape
}
/** A named type that appears in the IR's `type "<Name>" { ... }` section. */
export type NamedType =
| { kind: 'struct'; fields: StructField[] }
| { kind: 'alias'; inner: TypeShape }
| { kind: 'enum'; variants: string[] }
/**
* The IR type schema a `@client` function carries.
*
* `input` is the ordered list of input fields (already excluding the implicit
* request/identity arg). When absent or empty, the function `has-input #false`.
*
* `output` is the function's return shape: a `ref` to a named struct, a `list`,
* an `optional`, or a `primitive`. The emitter derives the canonical
* `<camel>Input` / `<camel>Output` names and the struct-vs-alias split exactly
* as `_collect_named_types` does.
*
* `types` resolves every `ref` used in `input`/`output` (and transitively) to
* its `NamedType` definition — Python gets this from Pydantic model
* introspection; TS declares it explicitly.
*/
export interface IrSchema {
input?: StructField[]
output?: TypeShape
types?: Record<string, NamedType>
}

View File

@@ -0,0 +1,46 @@
/**
* Session / CSRF init endpoint — the AFI-common `GET /api/mizan/session/`.
*
* Wired at parity with mizan-django / mizan-fastapi / mizan-rust-axum. The CSRF
* *token* is a Django session mechanism with no TypeScript-runtime equivalent,
* so this returns a null token by default; the endpoint itself is the owed AFI
* surface, and a host that mints CSRF tokens can pass one in. A SPA client uses
* the response as its session-readiness signal.
*/
import type { MizanResponse } from './dispatch'
/**
* Canonical mount path for the session-init endpoint, relative to the Mizan
* mount (`/api/mizan`). A router adapter binds `handleSessionInit` here — the
* same `/session/` route Django (`path("session/")`), FastAPI
* (`@router.get("/session/")`), and Axum register.
*/
export const SESSION_INIT_PATH = '/session/'
/** HTTP method for the session-init route. */
export const SESSION_INIT_METHOD = 'GET'
/**
* Build the session-init response. Returns `{ csrfToken }` with `no-store`.
* `csrfToken` defaults to null (no Django-style session); a host with its own
* CSRF mechanism passes the token to embed.
*/
export function handleSessionInit(csrfToken: string | null = null): MizanResponse {
return {
status: 200,
body: { csrfToken },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/**
* Route descriptor for the session-init endpoint — what a router adapter
* registers: `GET /session/` → `handleSessionInit`. Mirrors the
* `path("session/", session_init_view)` URL entry the Python adapters declare.
*/
export const sessionInitRoute = {
path: SESSION_INIT_PATH,
method: SESSION_INIT_METHOD,
handler: () => handleSessionInit(),
} as const

View File

@@ -0,0 +1,78 @@
/**
* Shapes — typed query projection.
*
* AFI-common capability; the binding is per-ORM. Django's binding is
* django-readers (select named fields + nested relations from a QuerySet in
* one query). The TypeScript binding is the same shape over the data source a
* TS backend already has: a `QueryProjection` declares the fields and nested
* relations to keep, and `project()` produces records carrying *only* those —
* the over-fetch-elimination the Shapes capability exists for, expressed
* against plain records rather than a Django QuerySet.
*
* A projection composes: a relation is itself a `QueryProjection`, so nested
* shapes prune recursively (mirrors `Shape._spec` / `_build_pair`).
*/
/** A declarative projection: scalar fields plus nested relation projections. */
export interface QueryProjection {
/** Scalar field names to keep on each record. */
fields: string[]
/** Nested relations to keep, each projected by its own `QueryProjection`. */
relations?: Record<string, QueryProjection>
}
type Record_ = Record<string, any>
function projectOne(record: Record_, projection: QueryProjection): Record_ {
const out: Record_ = {}
for (const f of projection.fields) {
if (f in record) out[f] = record[f]
}
for (const [name, child] of Object.entries(projection.relations ?? {})) {
const value = record[name]
if (value === undefined || value === null) {
out[name] = value
} else if (Array.isArray(value)) {
out[name] = value.map((v) => projectOne(v, child))
} else {
out[name] = projectOne(value, child)
}
}
return out
}
/**
* Project a list of records through a `QueryProjection`, keeping only the
* declared fields + nested relations. Each output record carries nothing the
* projection didn't name — the typed-projection guarantee.
*/
export function project(records: Record_[], projection: QueryProjection): Record_[] {
return records.map((r) => projectOne(r, projection))
}
/** Project a single record. */
export function projectRecord(record: Record_, projection: QueryProjection): Record_ {
return projectOne(record, projection)
}
/**
* A reusable Shape: binds a `QueryProjection` to a name so a `@client` context
* function can `Shape.query(source)` and return uniformly-projected records.
* The per-ORM source differs; the projection contract does not.
*/
export class Shape {
constructor(
public readonly name: string,
public readonly projection: QueryProjection,
) {}
/** Project a record source through this shape's projection. */
query(source: Record_[]): Record_[] {
return project(source, this.projection)
}
/** Project a single record. */
one(record: Record_): Record_ {
return projectRecord(record, this.projection)
}
}

View File

@@ -0,0 +1,216 @@
/**
* SSR Bridge — manages a persistent Bun subprocess for React server-rendering.
*
* TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire
* protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with
* message-id correlation so concurrent renders don't cross.
*
* → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } }
* ← { "id": 1, "html": "<div>...</div>" }
* ← { "id": 1, "error": "..." } (on failure)
*
* The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file
* and calls `renderToString` — no registry. It announces readiness with
* `{ "id": 0, "ready": true }`; the bridge waits for that before accepting
* renders, and restarts the worker if it exits.
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
export interface SSRBridgeOptions {
/** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */
worker: string
/** Per-render + startup timeout, seconds. Default 5. */
timeout?: number
/** Runtime to launch the worker. Default 'bun'. */
runtime?: string
/**
* Args passed to the runtime before the worker path. Default `['run']`
* (the Bun/`bun run <worker>` convention). Set `[]` for a runtime like
* `node` that takes the script path directly.
*/
runtimeArgs?: string[]
}
export interface RenderResult {
html: string
}
interface Pending {
resolve: (msg: any) => void
reject: (err: Error) => void
timer: ReturnType<typeof setTimeout>
}
export class SSRBridge {
private readonly worker: string
private readonly timeoutMs: number
private readonly runtime: string
private readonly runtimeArgs: string[]
private proc: ChildProcessWithoutNullStreams | null = null
private counter = 0
private buffer = ''
private readonly pending = new Map<number, Pending>()
private readyPromise: Promise<void> | null = null
private readyResolve: (() => void) | null = null
private readyReject: ((err: Error) => void) | null = null
constructor(options: SSRBridgeOptions) {
this.worker = options.worker
this.timeoutMs = (options.timeout ?? 5) * 1000
this.runtime = options.runtime ?? 'bun'
this.runtimeArgs = options.runtimeArgs ?? ['run']
}
private ensureRunning(): Promise<void> {
if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) {
return this.readyPromise
}
let settled = false
this.readyPromise = new Promise<void>((resolve, reject) => {
this.readyResolve = () => {
if (!settled) {
settled = true
resolve()
}
}
this.readyReject = (err) => {
if (!settled) {
settled = true
reject(err)
}
}
})
const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], {
stdio: ['pipe', 'pipe', 'pipe'],
})
this.proc = proc
proc.stdout.setEncoding('utf-8')
proc.stdout.on('data', (chunk: string) => this.onStdout(chunk))
// Only react to THIS proc's exit — a stale exit event (from a worker we
// already replaced) must not null out the freshly-spawned one.
proc.on('exit', () => this.onExit(proc))
proc.on('error', (err) => {
this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`))
})
const startTimer = setTimeout(() => {
this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`))
this.shutdown()
}, this.timeoutMs)
// Clear the start timer once ready settles (either way).
this.readyPromise.then(
() => clearTimeout(startTimer),
() => clearTimeout(startTimer),
)
return this.readyPromise
}
private onStdout(chunk: string): void {
this.buffer += chunk
let nl: number
while ((nl = this.buffer.indexOf('\n')) !== -1) {
const line = this.buffer.slice(0, nl).trim()
this.buffer = this.buffer.slice(nl + 1)
if (!line) continue
let msg: any
try {
msg = JSON.parse(line)
} catch {
continue // malformed line — ignore, matches the Python reader
}
this.onMessage(msg)
}
}
private onMessage(msg: any): void {
// Ready signal (id=0).
if (msg.id === 0 && msg.ready) {
this.readyResolve?.()
return
}
const id = msg.id
if (typeof id === 'number' && this.pending.has(id)) {
const p = this.pending.get(id)!
this.pending.delete(id)
clearTimeout(p.timer)
p.resolve(msg)
}
}
private onExit(proc: ChildProcessWithoutNullStreams): void {
// Ignore exit events from a worker we've already replaced.
if (this.proc !== null && this.proc !== proc) return
// Fail any in-flight requests; the next call re-spawns a fresh worker.
const err = new Error('SSR worker exited')
for (const [, p] of this.pending) {
clearTimeout(p.timer)
p.reject(err)
}
this.pending.clear()
this.readyReject?.(err)
this.proc = null
this.readyPromise = null
}
private request(method: string, params: Record<string, any>): Promise<any> {
const id = ++this.counter
const frame = JSON.stringify({ id, method, params }) + '\n'
return new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`))
}, this.timeoutMs)
this.pending.set(id, { resolve, reject, timer })
try {
this.proc!.stdin.write(frame)
} catch (e: any) {
this.pending.delete(id)
clearTimeout(timer)
reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`))
}
})
}
/** Render a React component file to HTML. Spawns the worker on first use. */
async render(file: string, props: Record<string, any> = {}): Promise<RenderResult> {
await this.ensureRunning()
const msg = await this.request('render', { file, props })
if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`)
return { html: msg.html }
}
/** Health check — resolves true when the worker answers a ping. */
async ping(): Promise<boolean> {
await this.ensureRunning()
const msg = await this.request('ping', {})
return msg.pong === true
}
/** Stop the Bun subprocess. */
shutdown(): void {
if (this.proc !== null) {
try {
this.proc.stdin.end()
} catch {
/* already closed */
}
try {
this.proc.kill()
} catch {
/* already gone */
}
this.proc = null
this.readyPromise = null
}
}
}

View File

@@ -1,14 +1,56 @@
/**
* MWT / JWT decode — HS256 verification, cross-language parity with
* cores/mizan-python/src/mizan_core/mwt.py.
* MWT / JWT mint + decode — HS256, cross-language parity with
* `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`.
*
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
* aud, malformed). Never throws.
* Decode returns null on ANY failure (bad signature, expired, future nbf,
* wrong aud, malformed) and never throws. Mint is byte-identical to PyJWT's
* `jwt.encode(...)`: the JOSE header is serialized with sorted keys, the
* payload preserves insertion order, both with `(",", ":")` separators and
* base64url-without-padding — so a TS-minted token equals a Python-minted one
* for the same claims. `tests/token.test.ts` pins this against the live Python
* mint via subprocess.
*/
import { createHmac, timingSafeEqual } from 'crypto'
import { createHash, createHmac, timingSafeEqual } from 'crypto'
import type { Identity } from './identity'
// ─── HS256 JWS serialization (PyJWT byte-parity) ──────────────────────────────
function base64urlEncode(buf: Buffer | string): string {
return Buffer.from(buf).toString('base64url')
}
/**
* Serialize a JSON object the way PyJWT does — compact `(",", ":")` separators.
* `sortKeys` matches PyJWT: the JOSE header is emitted with sorted keys; the
* payload preserves the object's own (insertion) order. Mirrors Python's
* `json.dumps(obj, separators=(",", ":"), sort_keys=...)`.
*/
function compactJson(obj: Record<string, unknown>, sortKeys: boolean): string {
if (!sortKeys) return JSON.stringify(obj)
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]
return JSON.stringify(sorted)
}
/**
* Sign an HS256 JWS. `header` extras (e.g. `{kid}`) merge over the base
* `{alg, typ}`; the JOSE header is serialized with sorted keys, exactly as
* PyJWT's `api_jws.encode`. Returns `header.payload.signature` (base64url).
*/
export function signHs256(
payload: Record<string, unknown>,
secret: string,
headerExtras: Record<string, unknown> = {},
): string {
const header = { alg: 'HS256', typ: 'JWT', ...headerExtras }
const headerB64 = base64urlEncode(compactJson(header, true))
const payloadB64 = base64urlEncode(compactJson(payload, false))
const signing = `${headerB64}.${payloadB64}`
const sig = createHmac('sha256', secret).update(signing).digest('base64url')
return `${signing}.${sig}`
}
export interface MwtPayload {
sub: string
staff: boolean
@@ -108,3 +150,115 @@ export function identityFromMwt(payload: MwtPayload): Identity {
id: Number(payload.sub),
}
}
// ─── MWT mint (byte-parity with mwt.create_mwt) ───────────────────────────────
/** A user-shaped source for minting. Mirrors the fields create_mwt reads. */
export interface MintUser {
pk: number | string
isStaff?: boolean
isSuperuser?: boolean
/** All permission strings, in any order (sorted here, as Python does). */
permissions?: string[]
}
/**
* Deterministic hash of permission state — byte-identical to
* `mwt.compute_permission_key`: SHA-256 over `"{staff}:{super}:{sorted_perms}"`.
*/
export function computePermissionKey(user: MintUser): string {
const perms = [...(user.permissions ?? [])].sort()
const staff = user.isStaff ? '1' : '0'
const superuser = user.isSuperuser ? '1' : '0'
const blob = `${staff}:${superuser}:${perms.join(',')}`
return createHash('sha256').update(blob, 'utf-8').digest('hex')
}
/**
* Sign an MWT for `user`. Byte-identical to `mwt.create_mwt`: claims in order
* `sub, staff, super, pkey, aud, iat, nbf, exp`; `kid` in the JOSE header.
*/
export function signMwt(
user: MintUser,
secret: string,
options: { ttl?: number; audience?: string; kid?: string; now?: number } = {},
): string {
const { ttl = 300, audience = 'mizan', kid = 'v1', now = Math.floor(Date.now() / 1000) } = options
const payload = {
sub: String(user.pk),
staff: Boolean(user.isStaff),
super: Boolean(user.isSuperuser),
pkey: computePermissionKey(user),
aud: audience,
iat: now,
nbf: now,
exp: now + ttl,
}
return signHs256(payload, secret, { kid })
}
/** Alias matching the `mintXxx` naming the protocol-parity surface expects. */
export const mintMwt = signMwt
// ─── JWT access/refresh mint (byte-parity with auth.jwt._mint) ────────────────
export interface JwtConfig {
privateKey: string
algorithm?: 'HS256'
accessTokenExpiresIn?: number
refreshTokenExpiresIn?: number
}
export interface JwtMintClaims {
userId: number | string
sessionKey: string
isStaff?: boolean
isSuperuser?: boolean
}
/**
* Mint one HS256 JWT. Byte-identical to `auth.jwt._mint`: claims in order
* `sub, sid, staff, super, type, iat, exp`. No custom JOSE header (PyJWT emits
* the bare `{alg, typ}` header for `jwt.encode` without `headers=`).
*/
export function signJwt(
claims: JwtMintClaims,
tokenType: 'access' | 'refresh',
ttl: number,
config: JwtConfig,
now: number = Math.floor(Date.now() / 1000),
): string {
const payload = {
sub: String(claims.userId),
sid: claims.sessionKey,
staff: Boolean(claims.isStaff),
super: Boolean(claims.isSuperuser),
type: tokenType,
iat: now,
exp: now + ttl,
}
return signHs256(payload, config.privateKey)
}
export function createAccessToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
return signJwt(claims, 'access', config.accessTokenExpiresIn ?? 300, config, now)
}
export function createRefreshToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
return signJwt(claims, 'refresh', config.refreshTokenExpiresIn ?? 604800, config, now)
}
export interface JwtTokenPair {
accessToken: string
refreshToken: string
expiresIn: number
}
/** Mint an access+refresh pair. Mirrors `auth.jwt.create_token_pair`. */
export function mintJwt(claims: JwtMintClaims, config: JwtConfig, now?: number): JwtTokenPair {
return {
accessToken: createAccessToken(claims, config, now),
refreshToken: createRefreshToken(claims, config, now),
expiresIn: config.accessTokenExpiresIn ?? 300,
}
}

View File

@@ -3,6 +3,7 @@
*/
import type { AuthPredicate } from './identity'
import type { IrSchema } from './ir/types'
export class ReactContext {
constructor(public readonly name: string) {
@@ -18,15 +19,31 @@ export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
/** Normalized auth requirement as stored on the registry entry. */
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
/** Form role for a forms-binding function (schema / validate / submit). */
export type FormRole = 'schema' | 'validate' | 'submit'
export interface ClientOptions {
context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[]
/** Contexts the mutation's return value merges into (vs. refetch). */
merge?: AffectsTarget | AffectsTarget[]
private?: boolean
route?: string
methods?: string[]
auth?: AuthOption
websocket?: boolean
rev?: number
cache?: number | false
/**
* IR type schema (input fields + output shape). TypeScript has no Pydantic
* to introspect, so the codegen IR is declared here. Without it the
* function still dispatches, but `buildIr()` cannot emit its types.
*/
ir?: IrSchema
/** Forms binding: marks this as a form function and names its role. */
form?: boolean
formName?: string
formRole?: FormRole
}
export interface ParamDef {
@@ -40,14 +57,20 @@ export interface RegistryEntry {
fn: (...args: any[]) => Promise<any>
context?: string
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
merge?: string[]
params: ParamDef[]
private: boolean
viewPath: boolean
route?: string
methods?: string[]
auth?: AuthRequirement
websocket?: boolean
rev?: number
cache?: number | false
ir?: IrSchema
form?: boolean
formName?: string
formRole?: FormRole
}
export interface ManifestContext {

View File

@@ -0,0 +1,143 @@
/**
* Mizan Upload — first-class binary input for `@client` functions.
*
* Mirrors `cores/mizan-python/src/mizan_core/upload.py`. Declaring an
* Upload-typed field in a function's `ir.input` makes a call multipart-aware:
* the generated client switches to `multipart/form-data`, and dispatch binds
* each file part into a uniform `UploadedFile` on the function's args.
* Constraints declared via `File` (max size, content types) are enforced at
* dispatch, exactly as the Python `validate_upload` enforces them.
*
* TypeScript has no Pydantic to introspect, so the Upload fields are read from
* the function's declared `ir.input` shapes (`{ kind: 'upload', ... }`) rather
* than from model metadata.
*/
import type { RegistryEntry } from './types'
import type { TypeShape } from './ir/types'
const SIZE_UNITS: Array<[string, number]> = [
['GB', 1024 ** 3],
['MB', 1024 ** 2],
['KB', 1024],
['B', 1],
]
/** Parse a byte count. Accepts a number (bytes) or a string like `"5MB"`. */
export function parseSize(value: number | string): number {
if (typeof value === 'number') return value
const s = value.trim().toUpperCase()
for (const [unit, mult] of SIZE_UNITS) {
if (s.endsWith(unit)) return Math.trunc(parseFloat(s.slice(0, -unit.length).trim()) * mult)
}
return Math.trunc(Number(s))
}
/** Declarative constraints for an Upload field. */
export interface File {
maxSize?: number
contentTypes?: string[]
}
/**
* Uniform file handle handed to `@client` functions — adapter-agnostic.
* Constructed by dispatch from a multipart `Blob`/`File` part.
*/
export class UploadedFile {
constructor(
public readonly filename: string | null,
public readonly contentType: string | null,
private readonly data: Uint8Array,
) {}
get size(): number {
return this.data.byteLength
}
read(): Uint8Array {
return this.data
}
text(): string {
return new TextDecoder().decode(this.data)
}
}
function contentTypeAllowed(contentType: string | null, allowed: string[]): boolean {
if (!contentType) return false
for (const ct of allowed) {
if (ct === contentType) return true
if (ct.endsWith('/*') && contentType.startsWith(ct.slice(0, -1))) return true
}
return false
}
/** Enforce declared constraints. Returns an error message, or null if ok. */
export function validateUpload(file: UploadedFile, spec: File | undefined): string | null {
if (!spec) return null
if (spec.maxSize !== undefined && file.size > spec.maxSize) {
return `file exceeds max size ${spec.maxSize} bytes (got ${file.size})`
}
if (spec.contentTypes && spec.contentTypes.length > 0 && !contentTypeAllowed(file.contentType, spec.contentTypes)) {
return `content-type ${JSON.stringify(file.contentType)} not allowed (expected one of ${JSON.stringify(spec.contentTypes)})`
}
return null
}
/** An Upload field on a function input: name → (isList, spec). */
interface UploadField {
isList: boolean
spec: File | undefined
}
/** Unwrap Optional/list around an `upload` shape → [isUpload, isList, spec]. */
function classifyUpload(shape: TypeShape): { isUpload: boolean; isList: boolean; spec: File | undefined } {
let s = shape
if (s.kind === 'optional') s = s.inner
let isList = false
if (s.kind === 'list') {
isList = true
s = s.inner
}
if (s.kind === 'upload') {
const spec: File = {}
if (s.maxSize !== undefined) spec.maxSize = s.maxSize
if (s.contentTypes !== undefined) spec.contentTypes = s.contentTypes
const hasSpec = s.maxSize !== undefined || s.contentTypes !== undefined
return { isUpload: true, isList, spec: hasSpec ? spec : undefined }
}
return { isUpload: false, isList: false, spec: undefined }
}
/** Map each Upload-typed field of a function's input → (isList, spec). */
export function uploadFields(entry: RegistryEntry): Map<string, UploadField> {
const out = new Map<string, UploadField>()
for (const field of entry.ir?.input ?? []) {
const { isUpload, isList, spec } = classifyUpload(field.shape)
if (isUpload) out.set(field.name, { isList, spec })
}
return out
}
/**
* Place uploaded files into `args` by field name, enforcing constraints.
* Mutates `args` in place. `files` maps a field name to the parts received for
* it (a list field receives several). Returns an error message on the first
* constraint violation, else null. Mirrors `upload.bind_uploads`.
*/
export function bindUploads(
entry: RegistryEntry,
args: Record<string, any>,
files: Map<string, UploadedFile[]>,
): string | null {
for (const [name, { isList, spec }] of uploadFields(entry)) {
const bucket = files.get(name) ?? []
if (bucket.length === 0) continue
for (const f of bucket) {
const err = validateUpload(f, spec)
if (err !== null) return `${name}: ${err}`
}
args[name] = isList ? [...bucket] : bucket[0]
}
return null
}

View File

@@ -0,0 +1,116 @@
/**
* WebSocket transport — RPC over a WebSocket connection for
* `@client({ websocket: true })` functions.
*
* Parity with the Django Channels consumer and the Axum WebSocket handler: the
* client sends JSON-RPC frames and receives correlated replies. Both the
* mutation (`call`) and the bundled context (`fetch`) verbs route through the
* *same* dispatch core the HTTP path uses, so invalidation, auth, and caching
* behave identically on either transport — only the framing differs.
*
* Frame protocol (newline-free JSON, one object per WS message):
*
* → { "id": 1, "type": "call", "fn": "update_profile", "args": { ... } }
* ← { "id": 1, "result": { ... }, "invalidate": [ ... ] }
*
* → { "id": 2, "type": "fetch", "context": "user", "params": { ... } }
* ← { "id": 2, "result": { user_profile: { ... }, ... } }
*
* ← { "id": N, "error": { "code": "...", "message": "..." } } (on failure)
*
* The `id` echoes back so a client can correlate concurrent in-flight calls
* over one socket.
*/
import { handleContextFetch, handleMutationCall } from './dispatch'
import { ANONYMOUS, type Identity } from './identity'
interface CallFrame {
id?: number | string
type: 'call'
fn: string
args?: Record<string, any>
}
interface FetchFrame {
id?: number | string
type: 'fetch'
context: string
params?: Record<string, string>
}
export type MizanWsFrame = CallFrame | FetchFrame
export interface MizanWsReply {
id?: number | string
result?: any
invalidate?: any
error?: { code: string; message: string }
}
/**
* Handle one inbound WebSocket frame and produce the reply object.
*
* `raw` is the message payload (string or already-parsed object). Routing is by
* the frame `type`; the body of the work is the same dispatch the HTTP handlers
* call, so a function exposed over both transports behaves identically.
*/
export async function handleWebSocketMessage(
raw: string | MizanWsFrame,
identity: Identity = ANONYMOUS,
): Promise<MizanWsReply> {
let frame: MizanWsFrame
try {
frame = typeof raw === 'string' ? JSON.parse(raw) : raw
} catch {
return { error: { code: 'BAD_REQUEST', message: 'Invalid JSON frame' } }
}
const id = (frame as { id?: number | string }).id
if (frame.type === 'call') {
if (!frame.fn) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'fn'" } }
const res = await handleMutationCall(frame.fn, frame.args ?? {}, identity)
if (res.status !== 200) {
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
}
const reply: MizanWsReply = { id, result: res.body.result }
if (res.body.invalidate !== undefined) reply.invalidate = res.body.invalidate
return reply
}
if (frame.type === 'fetch') {
if (!frame.context) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'context'" } }
const res = await handleContextFetch(frame.context, frame.params ?? {}, identity)
if (res.status !== 200) {
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
}
return { id, result: res.body }
}
return { id, error: { code: 'BAD_REQUEST', message: `Unknown frame type` } }
}
/** Minimal structural type for a WebSocket-like connection. */
export interface WebSocketLike {
send(data: string): void
addEventListener(type: 'message', listener: (event: { data: any }) => void): void
}
/**
* Attach the Mizan RPC protocol to a `WebSocket`-like connection. Each inbound
* message is dispatched via `handleWebSocketMessage` and the reply is sent back
* as JSON. `identity` resolves the caller (host wires MWT/JWT decode here).
*/
export function serveWebSocket(
ws: WebSocketLike,
identity: Identity = ANONYMOUS,
): void {
ws.addEventListener('message', async (event) => {
const reply = await handleWebSocketMessage(
typeof event.data === 'string' ? event.data : String(event.data),
identity,
)
ws.send(JSON.stringify(reply))
})
}