/** * Mizan Runtime — The client state engine. * * Framework-agnostic. React, Vue, Svelte, Solid — all wrap this. * * Three concerns: * 1. Context registry — mounted providers register here for invalidation * 2. Invalidation — batched via microtask, supports scoped params * 3. Fetch — mizanFetch (GET context bundles) + mizanCall (POST mutations) */ // === Types === export class MizanError extends Error { constructor(public status: number, public body: string) { super(`Mizan call failed (${status})`) } } export type RefetchFn = () => void type ParamKey = string // JSON.stringify of params interface ContextEntry { params: Record refetch: RefetchFn } // === Configuration === let config = { baseUrl: '/api/mizan', getHeaders: (): Record => ({}), } export function configure(opts: Partial) { Object.assign(config, opts) } // === Context Registry === // Mounted context providers register here. Unmounted ones deregister. const contexts: Map> = new Map() export function registerContext( name: string, params: Record, refetch: RefetchFn, ): () => void { if (!contexts.has(name)) contexts.set(name, new Map()) const key = JSON.stringify(params) contexts.get(name)!.set(key, { params, refetch }) return () => contexts.get(name)!.delete(key) } // === Invalidation === // Batched via microtask. Multiple invalidations in the same tick coalesce. const pending: Set = new Set() const pendingScoped: Map> = new Map() let scheduled = false export function invalidate(context: string, params?: Record) { if (params) { pendingScoped.set(context, params) } else { pending.add(context) } if (!scheduled) { scheduled = true queueMicrotask(flush) } } function flush() { // Broad invalidations — refetch all instances of context for (const name of pending) { const entries = contexts.get(name) if (entries) entries.forEach(entry => entry.refetch()) } // Scoped invalidations — refetch only matching params for (const [name, params] of pendingScoped) { if (pending.has(name)) continue // already refetched all const entries = contexts.get(name) if (!entries) continue const key = JSON.stringify(params) const entry = entries.get(key) if (entry) entry.refetch() } pending.clear() pendingScoped.clear() scheduled = false } // === Fetch === export async function mizanFetch( contextName: string, params?: Record, ): Promise { const url = new URL(`${config.baseUrl}/ctx/${contextName}/`, globalThis.location?.origin ?? 'http://localhost') if (params) { for (const [k, v] of Object.entries(params)) { url.searchParams.set(k, String(v)) } } const res = await fetch(url.toString(), { headers: { ...config.getHeaders(), 'Accept': 'application/json' }, credentials: 'same-origin', }) if (!res.ok) throw new MizanError(res.status, await res.text()) return res.json() } export async function mizanCall( functionName: string, args: Record, ): Promise { const res = await fetch(`${config.baseUrl}/call/`, { method: 'POST', headers: { ...config.getHeaders(), 'Content-Type': 'application/json', }, credentials: 'same-origin', body: JSON.stringify({ function: functionName, args }), }) if (!res.ok) throw new MizanError(res.status, await res.text()) const data = await res.json() // Server-driven invalidation if (data.invalidate) { for (const entry of data.invalidate) { if (typeof entry === 'string') { invalidate(entry) } else { invalidate(entry.context, entry.params) } } } return data.result }