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:
2026-04-07 12:09:35 -04:00
parent 6108845d99
commit c20de182e1
35 changed files with 6009 additions and 817 deletions

View 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
}