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:
@@ -5,15 +5,31 @@
|
||||
"": {
|
||||
"name": "@mizan/ts",
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"bun-types": "latest",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"bun-types": "latest",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
},
|
||||
"license": "Elastic-2.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
170
backends/mizan-ts/src/forms.ts
Normal file
170
backends/mizan-ts/src/forms.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
409
backends/mizan-ts/src/ir/build.ts
Normal file
409
backends/mizan-ts/src/ir/build.ts
Normal 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()
|
||||
}
|
||||
17
backends/mizan-ts/src/ir/index.ts
Normal file
17
backends/mizan-ts/src/ir/index.ts
Normal 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'
|
||||
70
backends/mizan-ts/src/ir/types.ts
Normal file
70
backends/mizan-ts/src/ir/types.ts
Normal 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>
|
||||
}
|
||||
46
backends/mizan-ts/src/session.ts
Normal file
46
backends/mizan-ts/src/session.ts
Normal 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
|
||||
78
backends/mizan-ts/src/shapes.ts
Normal file
78
backends/mizan-ts/src/shapes.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
216
backends/mizan-ts/src/ssr.ts
Normal file
216
backends/mizan-ts/src/ssr.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
143
backends/mizan-ts/src/upload.ts
Normal file
143
backends/mizan-ts/src/upload.ts
Normal 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
|
||||
}
|
||||
116
backends/mizan-ts/src/websocket.ts
Normal file
116
backends/mizan-ts/src/websocket.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
/** SSR fixture component — rendered by the Bun worker in the bridge test. */
|
||||
export default function Hello({ name }: { name: string }) {
|
||||
return createElement('div', { className: 'greeting' }, `Hello, ${name}!`)
|
||||
}
|
||||
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited
|
||||
* JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React
|
||||
* dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess
|
||||
* machinery (ready handshake, id correlation, render reply, ping, error frame)
|
||||
* under plain Node, independent of the real worker's install state.
|
||||
*
|
||||
* `render` echoes the props into a deterministic HTML string so the bridge's
|
||||
* request/response correlation is observable; a file named "*boom*" yields an
|
||||
* error frame to prove the failure path.
|
||||
*/
|
||||
|
||||
function respond(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n')
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
if (msg.method === 'ping') {
|
||||
respond({ id: msg.id, pong: true })
|
||||
return
|
||||
}
|
||||
if (msg.method === 'render') {
|
||||
const { file, props } = msg.params ?? {}
|
||||
if (typeof file === 'string' && file.includes('boom')) {
|
||||
respond({ id: msg.id, error: `cannot render ${file}` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, html: `<div data-file="${file}">${JSON.stringify(props ?? {})}</div>` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
process.stdin.setEncoding('utf-8')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
let nl
|
||||
while ((nl = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (line) {
|
||||
try {
|
||||
handle(JSON.parse(line))
|
||||
} catch (e) {
|
||||
respond({ id: -1, error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ready handshake — identical to the real worker.
|
||||
respond({ id: 0, ready: true })
|
||||
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1.
|
||||
*
|
||||
* Each function declares the same IR type schema the Python fixture's Pydantic
|
||||
* Input/Output models imply, so `buildIr()` here emits the same KDL the Python
|
||||
* `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`)
|
||||
* subprocesses the live Python emitter and asserts equality.
|
||||
*
|
||||
* Output structs are declared under their model name (`ProfileOutput`,
|
||||
* `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames
|
||||
* them to the canonical `<camel>Output`, exactly as `_collect_named_types`
|
||||
* renames the Pydantic models.
|
||||
*/
|
||||
|
||||
import { client, ReactContext } from '../src'
|
||||
import type { NamedType, StructField } from '../src'
|
||||
|
||||
const intField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'integer' },
|
||||
})
|
||||
const strField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'string' },
|
||||
})
|
||||
const boolField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'boolean' },
|
||||
})
|
||||
|
||||
const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] }
|
||||
const OrderOutput: NamedType = {
|
||||
kind: 'struct',
|
||||
fields: [intField('id'), intField('user_id'), intField('total')],
|
||||
}
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
/** Register the AFI fixture functions with the mizan-ts registry. */
|
||||
export function registerFixture(): void {
|
||||
// echo — plain function, typed input + struct output.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [strField('text')],
|
||||
output: { kind: 'ref', name: 'EchoOutput' },
|
||||
types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } },
|
||||
},
|
||||
},
|
||||
async function echo(text: string) {
|
||||
return { message: `echo: ${text}` }
|
||||
},
|
||||
)
|
||||
|
||||
// whoami — no input.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
output: { kind: 'ref', name: 'WhoamiOutput' },
|
||||
types: {
|
||||
WhoamiOutput: {
|
||||
kind: 'struct',
|
||||
fields: [strField('email'), boolField('authenticated')],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async function whoami() {
|
||||
return { email: 'anon@example.com', authenticated: false }
|
||||
},
|
||||
)
|
||||
|
||||
// user_profile — context member.
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function user_profile(user_id: number) {
|
||||
return { user_id, name: 'placeholder' }
|
||||
},
|
||||
)
|
||||
|
||||
// user_orders — context member, list output, same param (param elevation).
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } },
|
||||
types: { OrderOutput },
|
||||
},
|
||||
},
|
||||
async function user_orders(_user_id: number) {
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
// update_profile — mutation affecting the user context.
|
||||
client(
|
||||
{
|
||||
affects: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'StatusOutput' },
|
||||
types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } },
|
||||
},
|
||||
},
|
||||
async function update_profile(_user_id: number, _name: string) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// find_user — optional return.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function find_user(_user_id: number) {
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
// rename_user — merge target.
|
||||
client(
|
||||
{
|
||||
merge: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function rename_user(user_id: number, name: string) {
|
||||
return { user_id, name }
|
||||
},
|
||||
)
|
||||
}
|
||||
159
backends/mizan-ts/tests/ir.test.ts
Normal file
159
backends/mizan-ts/tests/ir.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python
|
||||
* `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`).
|
||||
*
|
||||
* The IR is the codegen contract. A TypeScript backend can only feed
|
||||
* `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends
|
||||
* emit for the same registry. This test reconstructs the AFI fixture in both
|
||||
* languages, subprocesses the live Python emitter, and asserts byte-equality —
|
||||
* the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildIr, clearRegistry } from '../src'
|
||||
import { registerFixture } from './ir-fixture'
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||
|
||||
/**
|
||||
* Reconstruct the AFI fixture in Python via `mizan_core` only (no backend
|
||||
* adapter dependency) and emit `build_ir()`. This is the cross-language oracle:
|
||||
* the same registrations the TS fixture makes, run through the reference
|
||||
* emitter.
|
||||
*/
|
||||
const PY_FIXTURE = String.raw`
|
||||
import sys
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core import registry as reg
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
reg.clear_registry()
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
class WhoamiOutput(BaseModel):
|
||||
email: str
|
||||
authenticated: bool
|
||||
|
||||
class ProfileOutput(BaseModel):
|
||||
user_id: int
|
||||
name: str
|
||||
|
||||
class OrderOutput(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
total: int
|
||||
|
||||
class StatusOutput(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput: ...
|
||||
|
||||
@client
|
||||
def whoami(request) -> WhoamiOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_orders(request, user_id: int) -> list[OrderOutput]: ...
|
||||
|
||||
@client(affects="user")
|
||||
def update_profile(request, user_id: int, name: str) -> StatusOutput: ...
|
||||
|
||||
@client
|
||||
def find_user(request, user_id: int) -> Optional[ProfileOutput]: ...
|
||||
|
||||
@client(merge="user")
|
||||
def rename_user(request, user_id: int, name: str) -> ProfileOutput: ...
|
||||
|
||||
for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]:
|
||||
reg.register(f, f.__name__)
|
||||
|
||||
sys.stdout.write(build_ir())
|
||||
`
|
||||
|
||||
function pythonBuildIr(): string {
|
||||
return execFileSync(
|
||||
'uv',
|
||||
['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
}
|
||||
|
||||
const UV_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
describe('KDL IR — buildIr()', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('emits the canonical type / function / context sections', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
|
||||
// Types are alphabetical; output structs renamed to <camel>Output.
|
||||
expect(kdl).toContain('type "OrderOutput" {')
|
||||
expect(kdl).toContain('type "echoInput" {')
|
||||
expect(kdl).toContain('type "findUserOutput" {')
|
||||
expect(kdl).toContain('type "userOrdersOutput" {')
|
||||
|
||||
// Functions alphabetical, with transport + context/affects/merge leaves.
|
||||
expect(kdl).toContain('function "echo" {')
|
||||
expect(kdl).toContain(' camel "echo"')
|
||||
expect(kdl).toContain(' has-input #true')
|
||||
expect(kdl).toContain(' output-nullable #true') // find_user
|
||||
expect(kdl).toContain(' affects "user"') // update_profile
|
||||
expect(kdl).toContain(' merge "user"') // rename_user
|
||||
|
||||
// Context section with shared param elevation.
|
||||
expect(kdl).toContain('context "user" {')
|
||||
expect(kdl).toContain(' shared-by "user_orders"')
|
||||
expect(kdl).toContain(' shared-by "user_profile"')
|
||||
})
|
||||
|
||||
test('has-input #false for a no-arg function', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
const whoami = kdl.slice(kdl.indexOf('function "whoami" {'))
|
||||
expect(whoami).toContain('has-input #false')
|
||||
expect(whoami).not.toContain('input "whoamiInput"')
|
||||
})
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)(
|
||||
'byte-identical to the Python build_ir() (cores/mizan-python)',
|
||||
() => {
|
||||
registerFixture()
|
||||
const tsKdl = buildIr()
|
||||
const pyKdl = pythonBuildIr()
|
||||
|
||||
// Line-by-line first so a divergence names the offending line.
|
||||
const tsLines = tsKdl.split('\n')
|
||||
const pyLines = pyKdl.split('\n')
|
||||
const n = Math.max(tsLines.length, pyLines.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (tsLines[i] !== pyLines[i]) {
|
||||
throw new Error(
|
||||
`KDL diverges at line ${i + 1}:\n` +
|
||||
` python: ${JSON.stringify(pyLines[i])}\n` +
|
||||
` ts: ${JSON.stringify(tsLines[i])}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(tsKdl).toBe(pyKdl)
|
||||
},
|
||||
)
|
||||
})
|
||||
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Shapes (typed query projection) + Forms (schema / validate / submit) tests.
|
||||
*
|
||||
* Shapes prove over-fetch elimination: the projected record carries only the
|
||||
* declared fields + nested relations, nothing else. Forms prove the three
|
||||
* roles register as dispatchable `@client` functions carrying the IR's
|
||||
* `form`/`form-name`/`form-role` meta, and that validate/submit enforce the
|
||||
* declared field rules.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
clearRegistry,
|
||||
getFunction,
|
||||
handleMutationCall,
|
||||
Shape,
|
||||
project,
|
||||
registerForm,
|
||||
formSchema,
|
||||
validateForm,
|
||||
type QueryProjection,
|
||||
} from '../src'
|
||||
|
||||
describe('Shapes — typed query projection', () => {
|
||||
test('keeps only declared scalar fields', () => {
|
||||
const projection: QueryProjection = { fields: ['id', 'name'] }
|
||||
const out = project([{ id: 1, name: 'A', secret: 'x', internal: 42 }], projection)
|
||||
expect(out).toEqual([{ id: 1, name: 'A' }])
|
||||
expect(out[0]).not.toHaveProperty('secret')
|
||||
expect(out[0]).not.toHaveProperty('internal')
|
||||
})
|
||||
|
||||
test('prunes nested relations recursively', () => {
|
||||
const projection: QueryProjection = {
|
||||
fields: ['id'],
|
||||
relations: { orders: { fields: ['total'] } },
|
||||
}
|
||||
const out = project(
|
||||
[{ id: 1, name: 'drop', orders: [{ id: 9, total: 100, hidden: true }] }],
|
||||
projection,
|
||||
)
|
||||
expect(out).toEqual([{ id: 1, orders: [{ total: 100 }] }])
|
||||
expect(out[0].orders[0]).not.toHaveProperty('hidden')
|
||||
})
|
||||
|
||||
test('handles single-object relation + null', () => {
|
||||
const projection: QueryProjection = {
|
||||
fields: ['id'],
|
||||
relations: { profile: { fields: ['bio'] } },
|
||||
}
|
||||
const out = project(
|
||||
[
|
||||
{ id: 1, profile: { bio: 'hi', age: 30 } },
|
||||
{ id: 2, profile: null },
|
||||
],
|
||||
projection,
|
||||
)
|
||||
expect(out[0]).toEqual({ id: 1, profile: { bio: 'hi' } })
|
||||
expect(out[1]).toEqual({ id: 2, profile: null })
|
||||
})
|
||||
|
||||
test('Shape.query binds a projection to a source', () => {
|
||||
const UserShape = new Shape('user', { fields: ['id', 'email'] })
|
||||
const out = UserShape.query([{ id: 1, email: 'a@b.c', password: 'nope' }])
|
||||
expect(out).toEqual([{ id: 1, email: 'a@b.c' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forms — schema / validate / submit', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
const contactForm = {
|
||||
fields: [
|
||||
{ name: 'email', type: 'email', required: true, label: 'Email' },
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
required: false,
|
||||
validate: (v: unknown) => (Number(v) < 0 ? 'must be non-negative' : null),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('formSchema produces field definitions', () => {
|
||||
const schema = formSchema(contactForm)
|
||||
expect(schema.fields).toHaveLength(2)
|
||||
expect(schema.fields[0]).toEqual({
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
label: 'Email',
|
||||
helpText: '',
|
||||
choices: null,
|
||||
initial: null,
|
||||
})
|
||||
// Default label derived from name when omitted.
|
||||
expect(schema.fields[1].label).toBe('Age')
|
||||
})
|
||||
|
||||
test('validateForm: required + custom validator', () => {
|
||||
expect(validateForm(contactForm, { email: 'a@b.c' }).valid).toBe(true)
|
||||
expect(validateForm(contactForm, {}).errors.email).toEqual(['This field is required.'])
|
||||
expect(validateForm(contactForm, { email: 'a@b.c', age: -1 }).errors.age).toEqual([
|
||||
'must be non-negative',
|
||||
])
|
||||
})
|
||||
|
||||
test('registerForm registers schema + validate + submit with form meta', () => {
|
||||
const reg = registerForm(contactForm, 'contact', {
|
||||
submit: async (data) => ({ saved: data.email }),
|
||||
})
|
||||
expect(reg).toEqual({ schema: 'contact-schema', validate: 'contact-validate', submit: 'contact-submit' })
|
||||
|
||||
for (const [wire, role] of [
|
||||
['contact-schema', 'schema'],
|
||||
['contact-validate', 'validate'],
|
||||
['contact-submit', 'submit'],
|
||||
] as const) {
|
||||
const entry = getFunction(wire)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.form).toBe(true)
|
||||
expect(entry!.formName).toBe('contact')
|
||||
expect(entry!.formRole).toBe(role)
|
||||
}
|
||||
})
|
||||
|
||||
test('schema function dispatches to the field defs', async () => {
|
||||
registerForm(contactForm, 'contact')
|
||||
const r = await handleMutationCall('contact-schema', {})
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result.fields).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('validate function dispatches and rejects bad data', async () => {
|
||||
registerForm(contactForm, 'contact')
|
||||
const ok = await handleMutationCall('contact-validate', { data: { email: 'a@b.c' } })
|
||||
expect(ok.body.result.valid).toBe(true)
|
||||
|
||||
const bad = await handleMutationCall('contact-validate', { data: {} })
|
||||
expect(bad.body.result.valid).toBe(false)
|
||||
expect(bad.body.result.errors.email).toBeDefined()
|
||||
})
|
||||
|
||||
test('submit validates then runs the handler', async () => {
|
||||
let handled: any = null
|
||||
registerForm(contactForm, 'contact', {
|
||||
submit: async (data) => {
|
||||
handled = data
|
||||
return { id: 7 }
|
||||
},
|
||||
})
|
||||
|
||||
const ok = await handleMutationCall('contact-submit', { data: { email: 'a@b.c' } })
|
||||
expect(ok.body.result).toEqual({ ok: true, result: { id: 7 } })
|
||||
expect(handled).toEqual({ email: 'a@b.c' })
|
||||
|
||||
const bad = await handleMutationCall('contact-submit', { data: {} })
|
||||
expect(bad.body.result.ok).toBe(false)
|
||||
expect(bad.body.result.errors.email).toBeDefined()
|
||||
})
|
||||
|
||||
test('submit not registered without a handler', () => {
|
||||
const reg = registerForm(contactForm, 'noSubmit')
|
||||
expect(reg.submit).toBeUndefined()
|
||||
expect(getFunction('noSubmit-submit')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SSR bridge tests — spawn + drive a JSON-RPC worker subprocess.
|
||||
*
|
||||
* The bridge's contract is the newline-delimited JSON-RPC protocol over a
|
||||
* spawned worker (ready handshake, id-correlated render/ping, error frames,
|
||||
* timeout, restart). Two peers exercise it:
|
||||
*
|
||||
* - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always
|
||||
* runs, proving the full subprocess machinery independent of any install;
|
||||
* - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an
|
||||
* actual React component — runs when `bun` + the worker's deps are present.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { SSRBridge } from '../src'
|
||||
|
||||
const HERE = import.meta.dir
|
||||
const REPO_ROOT = resolve(HERE, '../../..')
|
||||
const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs')
|
||||
const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx')
|
||||
const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx')
|
||||
|
||||
// The real worker renders an actual React component. bun resolves `react`
|
||||
// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's
|
||||
// own react devDependency (installed alongside this package).
|
||||
const BUN_OK = (() => {
|
||||
try {
|
||||
execFileSync('bun', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(HERE, '../node_modules/react/package.json'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
let bridge: SSRBridge | null = null
|
||||
afterEach(() => {
|
||||
bridge?.shutdown()
|
||||
bridge = null
|
||||
})
|
||||
|
||||
describe('SSRBridge — stub worker (Node, no React)', () => {
|
||||
test('waits for ready, then renders with id correlation', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 })
|
||||
expect(r.html).toBe('<div data-file="/abs/Card.tsx">{"title":"Hi","n":3}</div>')
|
||||
})
|
||||
|
||||
test('ping health check', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
|
||||
test('concurrent renders stay correlated', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const [a, b, c] = await Promise.all([
|
||||
bridge.render('/a.tsx', { k: 'a' }),
|
||||
bridge.render('/b.tsx', { k: 'b' }),
|
||||
bridge.render('/c.tsx', { k: 'c' }),
|
||||
])
|
||||
expect(a.html).toContain('"k":"a"')
|
||||
expect(b.html).toContain('"k":"b"')
|
||||
expect(c.html).toContain('"k":"c"')
|
||||
})
|
||||
|
||||
test('worker error frame surfaces as a thrown error', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed')
|
||||
})
|
||||
|
||||
test('restarts after the worker exits', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const first = await bridge.render('/one.tsx', { k: 1 })
|
||||
expect(first.html).toContain('"k":1')
|
||||
bridge.shutdown() // simulate a crashed/stopped worker
|
||||
const second = await bridge.render('/two.tsx', { k: 2 })
|
||||
expect(second.html).toContain('"k":2')
|
||||
})
|
||||
|
||||
test('startup timeout when the worker never signals ready', async () => {
|
||||
// `true` exits immediately without a ready frame → start times out.
|
||||
bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 })
|
||||
await expect(bridge.render('/x.tsx', {})).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRBridge — real Bun worker (renderToString)', () => {
|
||||
test.skipIf(!BUN_OK)('renders a React component to HTML', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
const r = await bridge.render(HELLO_TSX, { name: 'Ryth' })
|
||||
expect(r.html).toContain('Hello, Ryth!')
|
||||
expect(r.html).toContain('class="greeting"')
|
||||
})
|
||||
|
||||
test.skipIf(!BUN_OK)('ping on the real worker', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,23 @@
|
||||
/**
|
||||
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
|
||||
* MWT / JWT token tests — decode round-trip + cross-language byte-parity pins
|
||||
* against the live Python mint (`cores/mizan-python`).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createHmac } from 'crypto'
|
||||
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
|
||||
import { createHmac, createHash } from 'crypto'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import {
|
||||
decodeMwt,
|
||||
decodeJwtBearer,
|
||||
identityFromMwt,
|
||||
signMwt,
|
||||
computePermissionKey,
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
type MintUser,
|
||||
} from '../src'
|
||||
|
||||
function b64url(buf: Buffer | string): string {
|
||||
return Buffer.from(buf).toString('base64url')
|
||||
@@ -124,3 +137,117 @@ describe('MWT cross-language pin (Python create_mwt)', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Mint: round-trip + cross-language byte-parity ────────────────────────────
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||
|
||||
const UV_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* Run a Python snippet against cores/mizan-python and return stdout (trimmed).
|
||||
* `time.time` is pinned so the production mint functions are deterministic.
|
||||
*/
|
||||
function py(snippet: string): string {
|
||||
return execFileSync('uv', ['run', '--project', MIZAN_PYTHON, 'python', '-c', snippet], {
|
||||
encoding: 'utf-8',
|
||||
}).trim()
|
||||
}
|
||||
|
||||
describe('MWT mint — round-trip', () => {
|
||||
const SECRET = 'mint-roundtrip-secret'
|
||||
|
||||
test('signMwt produces a token decodeMwt accepts', () => {
|
||||
const user: MintUser = { pk: 7, isStaff: true, isSuperuser: false, permissions: ['a.view', 'a.edit'] }
|
||||
const token = signMwt(user, SECRET, { now: Math.floor(Date.now() / 1000) })
|
||||
const p = decodeMwt(token, SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('7')
|
||||
expect(p!.staff).toBe(true)
|
||||
expect(p!.super).toBe(false)
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
// pkey is the permission hash, surviving the round-trip.
|
||||
expect(p!.pkey).toBe(computePermissionKey(user))
|
||||
})
|
||||
|
||||
test('computePermissionKey matches the documented blob hash', () => {
|
||||
const user: MintUser = { pk: 1, isStaff: true, isSuperuser: false, permissions: ['z', 'a'] }
|
||||
// "1:0:a,z" — staff:super:sorted-perms.
|
||||
const expected = createHash('sha256').update('1:0:a,z', 'utf-8').digest('hex')
|
||||
expect(computePermissionKey(user)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MWT mint — cross-language pin (Python create_mwt)', () => {
|
||||
const SECRET = 'pin-mint-secret-mwt'
|
||||
const NOW = 1700000000
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS signMwt byte-identical to Python create_mwt', () => {
|
||||
const user: MintUser = {
|
||||
pk: 42,
|
||||
isStaff: true,
|
||||
isSuperuser: false,
|
||||
permissions: ['app.view_thing', 'app.change_thing'],
|
||||
}
|
||||
const tsToken = signMwt(user, SECRET, { ttl: 300, now: NOW })
|
||||
|
||||
// Drive the REAL create_mwt with time.time pinned to NOW and a
|
||||
// user stub whose get_all_permissions returns the same perms.
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.mwt import create_mwt
|
||||
|
||||
class U:
|
||||
pk = 42
|
||||
is_staff = True
|
||||
is_superuser = False
|
||||
def get_all_permissions(self):
|
||||
return {"app.view_thing", "app.change_thing"}
|
||||
|
||||
sys.stdout.write(create_mwt(U(), ${JSON.stringify(SECRET)}, ttl=300))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JWT mint — cross-language pin (Python create_access/refresh_token)', () => {
|
||||
const SECRET = 'pin-mint-secret-jwt'
|
||||
const NOW = 1700000000
|
||||
|
||||
const config = { privateKey: SECRET, accessTokenExpiresIn: 300, refreshTokenExpiresIn: 604800 }
|
||||
const claims = { userId: 42, sessionKey: 'sess-abc', isStaff: true, isSuperuser: false }
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS createAccessToken byte-identical to Python', () => {
|
||||
const tsToken = createAccessToken(claims, config, NOW)
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.auth.jwt import JWTConfig, create_access_token
|
||||
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||
sys.stdout.write(create_access_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS createRefreshToken byte-identical to Python', () => {
|
||||
const tsToken = createRefreshToken(claims, config, NOW)
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.auth.jwt import JWTConfig, create_refresh_token
|
||||
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||
sys.stdout.write(create_refresh_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
})
|
||||
|
||||
131
backends/mizan-ts/tests/transport.test.ts
Normal file
131
backends/mizan-ts/tests/transport.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Session-init + WebSocket transport tests.
|
||||
*
|
||||
* session-init returns the `{ csrfToken }` no-store shape at parity with the
|
||||
* Django/FastAPI/Axum session endpoint. The WebSocket transport drives the
|
||||
* SAME dispatch core the HTTP path uses, so a function exposed over WS behaves
|
||||
* identically — invalidation, auth, and not-found all carry through.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
ReactContext,
|
||||
client,
|
||||
clearRegistry,
|
||||
handleSessionInit,
|
||||
sessionInitRoute,
|
||||
SESSION_INIT_PATH,
|
||||
handleWebSocketMessage,
|
||||
serveWebSocket,
|
||||
type Identity,
|
||||
type WebSocketLike,
|
||||
} from '../src'
|
||||
|
||||
describe('session-init', () => {
|
||||
test('returns { csrfToken: null } with no-store', () => {
|
||||
const r = handleSessionInit()
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body).toEqual({ csrfToken: null })
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
expect(r.headers['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('embeds a host-provided CSRF token', () => {
|
||||
const r = handleSessionInit('tok-123')
|
||||
expect(r.body).toEqual({ csrfToken: 'tok-123' })
|
||||
})
|
||||
|
||||
test('route descriptor mounts GET /session/ (parity with Django/FastAPI/Axum)', () => {
|
||||
expect(SESSION_INIT_PATH).toBe('/session/')
|
||||
expect(sessionInitRoute.path).toBe('/session/')
|
||||
expect(sessionInitRoute.method).toBe('GET')
|
||||
// The wired handler returns the session shape.
|
||||
expect(sessionInitRoute.handler().body).toEqual({ csrfToken: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket transport', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
function setup() {
|
||||
client({ context: UserCtx, websocket: true }, async function user_profile(user_id: number) {
|
||||
return { user_id, name: `user_${user_id}` }
|
||||
})
|
||||
client({ affects: UserCtx, websocket: true }, async function update_profile(user_id: number, name: string) {
|
||||
return { ok: true, user_id, name }
|
||||
})
|
||||
}
|
||||
|
||||
test('call frame routes through mutation dispatch + carries invalidation', async () => {
|
||||
setup()
|
||||
const reply = await handleWebSocketMessage({
|
||||
id: 1,
|
||||
type: 'call',
|
||||
fn: 'update_profile',
|
||||
args: { user_id: 5, name: 'X' },
|
||||
})
|
||||
expect(reply.id).toBe(1)
|
||||
expect(reply.result).toEqual({ ok: true, user_id: 5, name: 'X' })
|
||||
expect(reply.invalidate).toBeDefined()
|
||||
expect(reply.invalidate[0].context).toBe('user')
|
||||
expect(reply.invalidate[0].params.user_id).toBe(5)
|
||||
})
|
||||
|
||||
test('fetch frame routes through context bundle', async () => {
|
||||
setup()
|
||||
const reply = await handleWebSocketMessage({
|
||||
id: 2,
|
||||
type: 'fetch',
|
||||
context: 'user',
|
||||
params: { user_id: '7' },
|
||||
})
|
||||
expect(reply.id).toBe(2)
|
||||
expect(reply.result.user_profile).toEqual({ user_id: '7', name: 'user_7' })
|
||||
})
|
||||
|
||||
test('unknown function returns an error frame, not a throw', async () => {
|
||||
const reply = await handleWebSocketMessage({ id: 3, type: 'call', fn: 'nope' })
|
||||
expect(reply.error).toBeDefined()
|
||||
expect(reply.error!.code).toBe('NOT_FOUND')
|
||||
expect(reply.id).toBe(3)
|
||||
})
|
||||
|
||||
test('auth enforcement carries over the WS transport', async () => {
|
||||
client({ auth: true, websocket: true }, async function secret() {
|
||||
return { ok: true }
|
||||
})
|
||||
const anon: Identity = { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
|
||||
const reply = await handleWebSocketMessage({ id: 4, type: 'call', fn: 'secret' }, anon)
|
||||
expect(reply.error!.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
test('malformed JSON frame → error', async () => {
|
||||
const reply = await handleWebSocketMessage('{not json')
|
||||
expect(reply.error!.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
test('serveWebSocket wires a connection and replies as JSON', async () => {
|
||||
setup()
|
||||
const sent: string[] = []
|
||||
let listener: ((e: { data: any }) => void) | null = null
|
||||
const ws: WebSocketLike = {
|
||||
send: (d) => sent.push(d),
|
||||
addEventListener: (_t, l) => {
|
||||
listener = l
|
||||
},
|
||||
}
|
||||
serveWebSocket(ws)
|
||||
expect(listener).not.toBeNull()
|
||||
|
||||
// Drive a message through the wired listener.
|
||||
await listener!({ data: JSON.stringify({ id: 9, type: 'fetch', context: 'user', params: { user_id: '3' } }) })
|
||||
// Give the async handler a tick to resolve + send.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(sent.length).toBe(1)
|
||||
const reply = JSON.parse(sent[0])
|
||||
expect(reply.id).toBe(9)
|
||||
expect(reply.result.user_profile.name).toBe('user_3')
|
||||
})
|
||||
})
|
||||
163
backends/mizan-ts/tests/upload.test.ts
Normal file
163
backends/mizan-ts/tests/upload.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Upload tests — multipart File-part binding + constraint enforcement.
|
||||
*
|
||||
* Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts
|
||||
* into the function's Upload-typed inputs, and `File(...)` constraints
|
||||
* (max-size, content-type) reject at dispatch with a 400.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
client,
|
||||
clearRegistry,
|
||||
handleMultipartCall,
|
||||
parseSize,
|
||||
validateUpload,
|
||||
UploadedFile,
|
||||
type StructField,
|
||||
} from '../src'
|
||||
|
||||
const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => {
|
||||
let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes }
|
||||
if (opts.list) shape = { kind: 'list', inner: shape }
|
||||
if (opts.optional) shape = { kind: 'optional', inner: shape }
|
||||
return { name, required: !opts.optional, shape }
|
||||
}
|
||||
const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } })
|
||||
|
||||
function multipart(fn: string, args: Record<string, any>, files: Record<string, Blob | Blob[]>): FormData {
|
||||
const form = new FormData()
|
||||
form.set('fn', fn)
|
||||
form.set('args', JSON.stringify(args))
|
||||
for (const [key, val] of Object.entries(files)) {
|
||||
for (const f of Array.isArray(val) ? val : [val]) form.append(key, f)
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
describe('parseSize', () => {
|
||||
test('parses human sizes', () => {
|
||||
expect(parseSize('5MB')).toBe(5 * 1024 * 1024)
|
||||
expect(parseSize('1KB')).toBe(1024)
|
||||
expect(parseSize('2GB')).toBe(2 * 1024 ** 3)
|
||||
expect(parseSize(123)).toBe(123)
|
||||
expect(parseSize('500')).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpload', () => {
|
||||
test('max-size rejection', () => {
|
||||
const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100))
|
||||
expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size')
|
||||
expect(validateUpload(f, { maxSize: 200 })).toBeNull()
|
||||
})
|
||||
|
||||
test('content-type allowlist + wildcard', () => {
|
||||
const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1))
|
||||
expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull()
|
||||
expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull()
|
||||
expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multipart dispatch', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('binds a file part into the Upload input', async () => {
|
||||
let received: UploadedFile | null = null
|
||||
client(
|
||||
{
|
||||
affects: 'avatars',
|
||||
ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] },
|
||||
},
|
||||
async function set_avatar(user_id: number, avatar: UploadedFile) {
|
||||
received = avatar
|
||||
return { ok: true, name: avatar.filename, bytes: avatar.size }
|
||||
},
|
||||
)
|
||||
|
||||
const form = multipart('set_avatar', { user_id: 5 }, {
|
||||
avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 })
|
||||
expect(received).not.toBeNull()
|
||||
expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
test('max-size violation rejects with 400', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } },
|
||||
async function set_avatar(_avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', {}, {
|
||||
avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain('avatar:')
|
||||
expect(r.body.message).toContain('exceeds max size')
|
||||
})
|
||||
|
||||
test('content-type violation rejects with 400', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } },
|
||||
async function set_avatar(_avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', {}, {
|
||||
avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain('not allowed')
|
||||
})
|
||||
|
||||
test('list upload binds multiple parts', async () => {
|
||||
let count = 0
|
||||
client(
|
||||
{ affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } },
|
||||
async function add_photos(photos: UploadedFile[]) {
|
||||
count = photos.length
|
||||
return { ok: true, count: photos.length }
|
||||
},
|
||||
)
|
||||
const form = multipart('add_photos', {}, {
|
||||
photos: [
|
||||
new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }),
|
||||
new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }),
|
||||
],
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result.count).toBe(2)
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
test('missing fn → 400', async () => {
|
||||
const form = new FormData()
|
||||
form.set('args', '{}')
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(400)
|
||||
expect(r.body.message).toContain("'fn'")
|
||||
})
|
||||
|
||||
test('invalidation still emitted on multipart mutation', async () => {
|
||||
client(
|
||||
{ affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } },
|
||||
async function set_avatar(_user_id: number, _avatar: UploadedFile) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
const form = multipart('set_avatar', { user_id: 9 }, {
|
||||
avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }),
|
||||
})
|
||||
const r = await handleMultipartCall(form)
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.headers['X-Mizan-Invalidate']).toContain('avatars')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user