Two-stage codegen: React + Vue + Svelte from one schema
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>
This commit is contained in:
204
packages/mizan-runtime/src/index.ts
Normal file
204
packages/mizan-runtime/src/index.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
Reference in New Issue
Block a user