Restructure tree by role; rename mizan-runtime → mizan-base

packages/ flattens into:
  backends/   server protocol adapters (mizan-django, mizan-ts)
  frontends/  client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
  workers/    runtime workers (mizan-ssr)
  cores/      shared language-level primitives (empty for now; mizan-python forthcoming)

The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.

Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).

Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.

Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:55:37 -04:00
parent 6eca514777
commit fe39fcb229
126 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"name": "@mizan/runtime",
"version": "0.1.0",
"description": "Mizan client runtime — context registry, invalidation, fetch. Zero framework dependencies.",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"license": "MIT"
}

View File

@@ -0,0 +1,317 @@
/**
* @mizan/runtime — The client state kernel.
*
* Zero framework dependencies. React, Vue, Svelte — all import from here.
*
* The kernel owns the data. Adapters subscribe and render.
*/
// === 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
export function initSession(): Promise<void> {
if (_sessionReady) return _sessionReady
_sessionReady = (async () => {
if (getCSRFToken()) return
for (let attempt = 0; attempt < 3; attempt++) {
try {
await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
if (getCSRFToken()) return
} catch (e) {
console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e)
}
if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
}
_sessionReady = null
})()
return _sessionReady
}
// === Context State ===
export type ContextStatus = 'idle' | 'loading' | 'success' | 'error'
export interface ContextState<T = any> {
data: T | null
status: ContextStatus
error: Error | null
}
type Listener = () => void
type ParamKey = string
interface ContextEntry {
params: Record<string, any>
state: ContextState
listeners: Set<Listener>
fetchFn: () => Promise<any>
}
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
function stableKey(params: Record<string, any>): string {
return JSON.stringify(params, Object.keys(params).sort())
}
/**
* Register a context instance. The kernel owns the fetch lifecycle.
*
* Returns { getState, subscribe, refetch, unregister }.
* Adapters call subscribe() to get notified on state changes.
*/
export function registerContext(
name: string,
params: Record<string, any>,
fetchFn: () => Promise<any>,
initialData?: any,
): {
getState: () => ContextState
subscribe: (listener: Listener) => () => void
refetch: () => Promise<void>
unregister: () => void
} {
if (!contexts.has(name)) contexts.set(name, new Map())
const key = stableKey(params)
const map = contexts.get(name)!
// Reuse existing entry if same key is re-registered (React Strict Mode)
let entry = map.get(key)
if (!entry) {
entry = {
params,
state: {
data: initialData ?? null,
status: initialData ? 'success' : 'idle',
error: null,
},
listeners: new Set(),
fetchFn,
}
map.set(key, entry)
} else {
// Update fetchFn in case closure changed
entry.fetchFn = fetchFn
}
const self = entry
function notify() {
self.listeners.forEach(l => l())
}
async function refetch() {
self.state = { ...self.state, status: 'loading', error: null }
notify()
try {
const data = await self.fetchFn()
self.state = { data, status: 'success', error: null }
} catch (e) {
self.state = { ...self.state, status: 'error', error: e as Error }
}
notify()
}
return {
getState: () => self.state,
subscribe: (listener: Listener) => {
self.listeners.add(listener)
return () => self.listeners.delete(listener)
},
refetch,
unregister: () => {
self.listeners.clear()
map.delete(key)
},
}
}
// === Invalidation ===
const pending: Set<string> = new Set()
const pendingScoped: Array<{ context: string; params: Record<string, any> }> = []
let scheduled = false
export function invalidate(context: string, params?: Record<string, any>): void {
if (params) {
pendingScoped.push({ context, params })
} else {
pending.add(context)
}
if (!scheduled) {
scheduled = true
queueMicrotask(flush)
}
}
function flush(): void {
// Broad invalidations — refetch all instances
for (const name of pending) {
const entries = contexts.get(name)
if (entries) {
entries.forEach(entry => {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
})
}
}
// Scoped invalidations — refetch matching params
for (const { context: name, params } of pendingScoped) {
if (pending.has(name)) continue
const entries = contexts.get(name)
if (!entries) continue
const key = stableKey(params)
const entry = entries.get(key)
if (entry) {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
}
}
pending.clear()
pendingScoped.length = 0
scheduled = false
}
// === Fetch ===
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
retries = 2,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(input, init)
if (res.ok || (res.status >= 400 && res.status < 500)) return res
if (attempt >= retries) return res
} catch (e) {
if (attempt >= retries) throw e
}
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
}
}
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 fetchWithRetry(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
}