/** * @mizan/runtime — The client state kernel. * * Zero framework dependencies. React, Vue, Svelte — all import from here. * * Four concerns: * 1. Configuration — baseUrl, auth headers, CSRF * 2. Context registry — mounted providers register for invalidation * 3. Invalidation — microtask-batched, scoped or broad * 4. Fetch — mizanFetch (GET context bundles) + mizanCall (POST mutations) */ // === Error === export class MizanError extends Error { constructor(public status: number, public body: string) { super(`Mizan call failed (${status})`) } } // === Configuration === interface MizanConfig { baseUrl: string getHeaders: () => Record | Promise> csrfCookieName: string csrfHeaderName: string } const config: MizanConfig = { baseUrl: '/api/mizan', getHeaders: () => ({}), csrfCookieName: 'csrftoken', csrfHeaderName: 'X-CSRFToken', } 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 /** * Initialize a session (fetches CSRF cookie from GET /session/). * Called automatically on first fetch if not called explicitly. * No-op if a CSRF cookie already exists. */ export function initSession(): Promise { if (_sessionReady) return _sessionReady _sessionReady = (async () => { // If we already have a CSRF token, skip if (getCSRFToken()) return try { await fetch(`${config.baseUrl}/session/`, { credentials: 'include' }) } catch (e) { console.error('[mizan] Session init failed:', e) } })() return _sessionReady } // === Context Registry === export type RefetchFn = () => void type ParamKey = string interface ContextEntry { params: Record refetch: RefetchFn } 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 === const pending: Set = new Set() const pendingScoped: Map> = new Map() let scheduled = false export function invalidate(context: string, params?: Record): void { if (params) { pendingScoped.set(context, params) } else { pending.add(context) } if (!scheduled) { scheduled = true queueMicrotask(flush) } } function flush(): void { for (const name of pending) { const entries = contexts.get(name) if (entries) entries.forEach(entry => entry.refetch()) } for (const [name, params] of pendingScoped) { if (pending.has(name)) continue 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 === async function resolveHeaders(): Promise> { await initSession() const custom = await config.getHeaders() const csrf = getCSRFToken() return { ...custom, ...(csrf ? { [config.csrfHeaderName]: csrf } : {}), 'Accept': 'application/json', } } export async function mizanFetch( contextName: string, params?: Record, ): Promise { 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 fetch(url.toString(), { headers, 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 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()) 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 }