Stage 1 (framework-agnostic):
types.ts — OpenAPI interfaces
contexts/<name>.ts — fetchXxxContext(params) using mizanFetch
mutations/<name>.ts — callXxx(args) using mizanCall
functions/<name>.ts — callXxx(args) using mizanCall
index.ts — re-exports
Stage 2 (per framework):
react.tsx — hooks + context providers + SSR hydration
vue.ts — composables with provide/inject + ref/computed
svelte.ts — writable/derived store factories
New packages:
mizan-runtime — the kernel (~200 lines, zero framework deps)
configure(), initSession(), registerContext(), invalidate(),
mizanFetch(), mizanCall(), MizanError
mizan-vue — Vue adapter (package.json, codegen template)
mizan-svelte — Svelte adapter (package.json, codegen template)
CLI: mizan-generate --target react,vue,svelte
Config: target: 'react' (default) in django.config.mjs
Verified: codegen produces 33 functions across 2 contexts,
14 plain functions, 0 mutations, generating all three Stage 2
outputs from one schema fetch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
/**
|
|
* @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<string, string> | Promise<Record<string, string>>
|
|
csrfCookieName: string
|
|
csrfHeaderName: string
|
|
}
|
|
|
|
const config: MizanConfig = {
|
|
baseUrl: '/api/mizan',
|
|
getHeaders: () => ({}),
|
|
csrfCookieName: 'csrftoken',
|
|
csrfHeaderName: 'X-CSRFToken',
|
|
}
|
|
|
|
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
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<string, any>
|
|
refetch: RefetchFn
|
|
}
|
|
|
|
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
|
|
|
|
export function registerContext(
|
|
name: string,
|
|
params: Record<string, any>,
|
|
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<string> = new Set()
|
|
const pendingScoped: Map<string, Record<string, any>> = new Map()
|
|
let scheduled = false
|
|
|
|
export function invalidate(context: string, params?: Record<string, any>): 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<Record<string, string>> {
|
|
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<string, any>,
|
|
): Promise<any> {
|
|
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<string, any>,
|
|
): Promise<any> {
|
|
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
|
|
}
|