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:
317
frontends/mizan-base/src/index.ts
Normal file
317
frontends/mizan-base/src/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user