Files
mizan/frontends/mizan-base/src/index.ts
Ryth Azhur 22dcf0e3c1 mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate
Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.

New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
  command that routes through mizan-core's FUNCTIONS / CONTEXTS
  registries. No per-function tauri::command; the linkme slice IS the
  dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
  tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
  and re-shapes errors into MizanError. Pairs with mizan-tauri.

@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
  side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).

mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
  KDL from stdout. The bin uses mizan_core::build_ir() after
  force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
  registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
  embedded Python bridge (scripts/run_decoru.py) to python and writes
  decoru-emitted Rust types into the consumer crate. The bridge
  auto-discovers BaseModel subclasses AND Enum subclasses
  (last-variant-is-default convention so decoru's impl Default keeps
  compiling against enum-typed fields without explicit Pydantic
  defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
  types by hand.

mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
  dispatch wrapper `?`-unwraps the user fn so server-side errors
  surface as the protocol's standard {code, message, details?}
  envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
  field-level #[serde(rename = "...")] when emitting IR field names.
  Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
  fields (e.g. `r#type` → `type`).

react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
  helper based on has_global || !named_contexts.is_empty(). Consumers
  without contexts (mutation/RPC-only apps like claude-manage) no
  longer get dead imports that trip noUnusedLocals.

Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:01:45 -04:00

477 lines
15 KiB
TypeScript

/**
* @mizan/base — The client state kernel.
*
* Zero framework dependencies. React, Vue, Svelte — all import from here.
*
* The kernel owns the data. Adapters subscribe and render.
*/
// === Error ===
export class MizanError extends Error {
public code: string
public details?: unknown
constructor(public status: number, public body: string) {
super(`Mizan call failed (${status})`)
// Two envelope shapes are tolerated:
// FastAPI: {"error": {"code", "message", "details"}}
// Django: {"error": true, "code", "message", "details"}
try {
const parsed = JSON.parse(body)
const err = parsed?.error
const source = typeof err === 'object' && err !== null ? err : parsed
this.code = source?.code ?? `HTTP_${status}`
this.details = source?.details
if (source?.message) this.message = source.message
} catch {
this.code = `HTTP_${status}`
}
}
}
// === Transport ===
/**
* Wire surface the kernel uses to reach a Mizan backend. The default
* implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
* apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
* future transport — workers, edge runtimes, channels — implements this
* interface and replaces the default via `configure({ transport })`.
*/
export interface MizanTransport {
/** RPC dispatch — invokes a Mizan-registered function. */
call(
fnName: string,
args: Record<string, any>,
): Promise<MizanCallResponse>
/** Context-bundle fetch — invokes a Mizan-registered context. */
fetch(
contextName: string,
params?: Record<string, any>,
): Promise<any>
}
/**
* Raw envelope a transport returns from `call()`. The kernel uses the
* `merge` and `invalidate` arrays to drive client-side cache updates;
* `result` is the function's typed return value.
*/
export interface MizanCallResponse {
result: any
invalidate?: Array<string | { context: string; params?: Record<string, any> } | { function: string }>
merge?: Array<{ context: string; slot: string; value: unknown; params?: Record<string, any> }>
}
// === Configuration ===
interface MizanConfig {
baseUrl: string
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
csrfCookieName: string
csrfHeaderName: string
/**
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
* `true` for Django (the default — preserves existing setups); set
* `false` for FastAPI or any backend that doesn't ship a session
* endpoint to avoid a 404 storm on startup. A future revision moves
* this onto the schema-advertised capability surface.
*/
session: boolean
/**
* Wire transport. Defaults to `httpTransport()` (fetch-based,
* compatible with FastAPI / Django backends). Swap with a custom
* transport (e.g. `tauriTransport()`) at app entry to route
* Mizan calls through a different channel.
*/
transport: MizanTransport
}
const config: MizanConfig = {
baseUrl: '/api/mizan',
getHeaders: () => ({}),
csrfCookieName: 'csrftoken',
csrfHeaderName: 'X-CSRFToken',
session: true,
// Initialized below once httpTransport is defined.
transport: null as unknown as MizanTransport,
}
export function configure(opts: Partial<MizanConfig>): void {
Object.assign(config, opts)
}
export function getConfig(): Readonly<MizanConfig> {
return config
}
// === CSRF ===
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(new RegExp(`${config.csrfCookieName}=([^;]+)`))
return match?.[1] ?? null
}
// === Session Init ===
let _sessionReady: Promise<void> | null = null
export function initSession(): Promise<void> {
if (!config.session) return Promise.resolve()
if (_sessionReady) return _sessionReady
_sessionReady = (async () => {
if (getCSRFToken()) return
for (let attempt = 0; attempt < 3; attempt++) {
try {
await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
if (getCSRFToken()) return
} catch (e) {
console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e)
}
if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
}
_sessionReady = null
})()
return _sessionReady
}
// === Context State ===
export type ContextStatus = 'idle' | 'loading' | 'success' | 'error'
export interface ContextState<T = any> {
data: T | null
status: ContextStatus
error: Error | null
}
type Listener = () => void
type ParamKey = string
interface ContextEntry {
params: Record<string, any>
state: ContextState
listeners: Set<Listener>
fetchFn: () => Promise<any>
}
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
function stableKey(params: Record<string, any>): string {
return JSON.stringify(params, Object.keys(params).sort())
}
/**
* Register a context instance. The kernel owns the fetch lifecycle.
*
* Returns { getState, subscribe, refetch, unregister }.
* Adapters call subscribe() to get notified on state changes.
*/
export function registerContext(
name: string,
params: Record<string, any>,
fetchFn: () => Promise<any>,
initialData?: any,
): {
getState: () => ContextState
subscribe: (listener: Listener) => () => void
refetch: () => Promise<void>
unregister: () => void
} {
if (!contexts.has(name)) contexts.set(name, new Map())
const key = stableKey(params)
const map = contexts.get(name)!
// Reuse existing entry if same key is re-registered (React Strict Mode)
let entry = map.get(key)
if (!entry) {
entry = {
params,
state: {
data: initialData ?? null,
status: initialData ? 'success' : 'idle',
error: null,
},
listeners: new Set(),
fetchFn,
}
map.set(key, entry)
} else {
// Update fetchFn in case closure changed
entry.fetchFn = fetchFn
}
const self = entry
function notify() {
self.listeners.forEach(l => l())
}
async function refetch() {
self.state = { ...self.state, status: 'loading', error: null }
notify()
try {
const data = await self.fetchFn()
self.state = { data, status: 'success', error: null }
} catch (e) {
self.state = { ...self.state, status: 'error', error: e as Error }
}
notify()
}
return {
getState: () => self.state,
subscribe: (listener: Listener) => {
self.listeners.add(listener)
return () => self.listeners.delete(listener)
},
refetch,
unregister: () => {
self.listeners.clear()
map.delete(key)
},
}
}
// === Merge ===
//
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
// already resolved which bundle slot the value lands in (by matching the
// mutation's return type against each context function's return type), so
// the kernel does no inference — it writes directly to `bundle[slot]`,
// upserting by id when the slot is a list. The type information lives in
// the schema-aware backend layer; the kernel is type-erased on purpose.
function spliceSlot(slot: unknown, value: unknown): unknown {
if (Array.isArray(slot)) {
if (Array.isArray(value)) return value
if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id: unknown }).id
const idx = slot.findIndex(item =>
item && typeof item === 'object' && 'id' in item
&& (item as { id: unknown }).id === id
)
const next = slot.slice()
if (idx >= 0) next[idx] = value
else next.push(value)
return next
}
}
return value
}
export function merge(
context: string,
params: Record<string, any> | undefined,
slot: string,
value: unknown,
): void {
const entries = contexts.get(context)
if (!entries) return
const entry = entries.get(stableKey(params ?? {}))
if (!entry || entry.state.data == null) return
const data = entry.state.data
if (!data || typeof data !== 'object' || Array.isArray(data)) return
const bundle = data as Record<string, unknown>
if (!(slot in bundle)) return
entry.state = {
data: { ...bundle, [slot]: spliceSlot(bundle[slot], value) },
status: 'success',
error: null,
}
entry.listeners.forEach(l => l())
}
// === Invalidation ===
const pending: Set<string> = new Set()
const pendingScoped: Array<{ context: string; params: Record<string, any> }> = []
let scheduled = false
export function invalidate(context: string, params?: Record<string, any>): void {
if (params) {
pendingScoped.push({ context, params })
} else {
pending.add(context)
}
if (!scheduled) {
scheduled = true
queueMicrotask(flush)
}
}
function flush(): void {
// Broad invalidations — refetch all instances
for (const name of pending) {
const entries = contexts.get(name)
if (entries) {
entries.forEach(entry => {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
})
}
}
// Scoped invalidations — refetch matching params
for (const { context: name, params } of pendingScoped) {
if (pending.has(name)) continue
const entries = contexts.get(name)
if (!entries) continue
const key = stableKey(params)
const entry = entries.get(key)
if (entry) {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
}
}
pending.clear()
pendingScoped.length = 0
scheduled = false
}
// === Fetch ===
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
retries = 2,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(input, init)
if (res.ok || (res.status >= 400 && res.status < 500)) return res
if (attempt >= retries) return res
} catch (e) {
if (attempt >= retries) throw e
}
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
}
}
async function resolveHeaders(): Promise<Record<string, string>> {
await initSession()
const custom = await config.getHeaders()
const csrf = getCSRFToken()
return {
...custom,
...(csrf ? { [config.csrfHeaderName]: csrf } : {}),
'Accept': 'application/json',
}
}
/**
* Default Mizan transport — POST `${baseUrl}/call/` and GET
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
* transport via `configure({ transport })` when running in a
* non-HTTP host (e.g. Tauri).
*/
export function httpTransport(): MizanTransport {
return {
async call(functionName, args) {
const headers = await resolveHeaders()
headers['Content-Type'] = 'application/json'
const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ fn: functionName, args }),
})
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
},
async fetch(contextName, params) {
const url = new URL(
`${config.baseUrl}/ctx/${contextName}/`,
typeof globalThis.location !== 'undefined'
? globalThis.location.origin
: 'http://localhost',
)
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v))
}
}
const headers = await resolveHeaders()
const res = await fetchWithRetry(url.toString(), {
headers,
credentials: 'same-origin',
})
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
},
}
}
// Install the default transport now that httpTransport is in scope. The
// config object was constructed earlier with a placeholder so the type
// stayed honest; this line is the actual binding.
config.transport = httpTransport()
export async function mizanFetch(
contextName: string,
params?: Record<string, any>,
): Promise<any> {
return config.transport.fetch(contextName, params)
}
export async function mizanCall(
functionName: string,
args: Record<string, any>,
): Promise<any> {
const data = await config.transport.call(functionName, args)
// Server-driven merges run before invalidations so a context that is
// both merged-into and invalidated ends in the invalidation state — the
// server told us to refetch, that wins.
if (data.merge) {
for (const entry of data.merge) {
merge(entry.context, entry.params, entry.slot, entry.value)
}
}
// Server-driven invalidation
if (data.invalidate) {
for (const entry of data.invalidate) {
if (typeof entry === 'string') {
invalidate(entry)
} else if ('context' in entry) {
invalidate(entry.context, entry.params)
}
// {function: name} entries route through the kernel's
// function-output cache layer, which lives in the framework
// adapter; mizan-base treats them as a no-op here.
}
}
return data.result
}