/** * @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, ): Promise /** Context-bundle fetch — invokes a Mizan-registered context. */ fetch( contextName: string, params?: Record, ): Promise } /** * 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 } | { function: string }> merge?: Array<{ context: string; slot: string; value: unknown; params?: Record }> } // === Configuration === interface MizanConfig { baseUrl: string getHeaders: () => Record | Promise> 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): void { Object.assign(config, opts) } export function getConfig(): Readonly { 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 | null = null export function initSession(): Promise { 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 { data: T | null status: ContextStatus error: Error | null } type Listener = () => void type ParamKey = string interface ContextEntry { params: Record state: ContextState listeners: Set fetchFn: () => Promise } const contexts: Map> = new Map() function stableKey(params: Record): 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, fetchFn: () => Promise, initialData?: any, ): { getState: () => ContextState subscribe: (listener: Listener) => () => void refetch: () => Promise 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 | 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 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 = new Set() const pendingScoped: Array<{ context: string; params: Record }> = [] let scheduled = false export function invalidate(context: string, params?: Record): 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 { 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> { 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, ): Promise { return config.transport.fetch(contextName, params) } export async function mizanCall( functionName: string, args: Record, ): Promise { 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 }