Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.
New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
command that routes through mizan-core's FUNCTIONS / CONTEXTS
registries. No per-function tauri::command; the linkme slice IS the
dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
and re-shapes errors into MizanError. Pairs with mizan-tauri.
@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).
mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
KDL from stdout. The bin uses mizan_core::build_ir() after
force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
embedded Python bridge (scripts/run_decoru.py) to python and writes
decoru-emitted Rust types into the consumer crate. The bridge
auto-discovers BaseModel subclasses AND Enum subclasses
(last-variant-is-default convention so decoru's impl Default keeps
compiling against enum-typed fields without explicit Pydantic
defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
types by hand.
mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
dispatch wrapper `?`-unwraps the user fn so server-side errors
surface as the protocol's standard {code, message, details?}
envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
field-level #[serde(rename = "...")] when emitting IR field names.
Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
fields (e.g. `r#type` → `type`).
react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
helper based on has_global || !named_contexts.is_empty(). Consumers
without contexts (mutation/RPC-only apps like claude-manage) no
longer get dead imports that trip noUnusedLocals.
Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
/**
|
|
* @mizan/base — 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 {
|
|
public code: string
|
|
public details?: unknown
|
|
|
|
constructor(public status: number, public body: string) {
|
|
super(`Mizan call failed (${status})`)
|
|
// Two envelope shapes are tolerated:
|
|
// FastAPI: {"error": {"code", "message", "details"}}
|
|
// Django: {"error": true, "code", "message", "details"}
|
|
try {
|
|
const parsed = JSON.parse(body)
|
|
const err = parsed?.error
|
|
const source = typeof err === 'object' && err !== null ? err : parsed
|
|
this.code = source?.code ?? `HTTP_${status}`
|
|
this.details = source?.details
|
|
if (source?.message) this.message = source.message
|
|
} catch {
|
|
this.code = `HTTP_${status}`
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Transport ===
|
|
|
|
/**
|
|
* Wire surface the kernel uses to reach a Mizan backend. The default
|
|
* implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
|
|
* apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
|
|
* future transport — workers, edge runtimes, channels — implements this
|
|
* interface and replaces the default via `configure({ transport })`.
|
|
*/
|
|
export interface MizanTransport {
|
|
/** RPC dispatch — invokes a Mizan-registered function. */
|
|
call(
|
|
fnName: string,
|
|
args: Record<string, any>,
|
|
): Promise<MizanCallResponse>
|
|
/** Context-bundle fetch — invokes a Mizan-registered context. */
|
|
fetch(
|
|
contextName: string,
|
|
params?: Record<string, any>,
|
|
): Promise<any>
|
|
}
|
|
|
|
/**
|
|
* Raw envelope a transport returns from `call()`. The kernel uses the
|
|
* `merge` and `invalidate` arrays to drive client-side cache updates;
|
|
* `result` is the function's typed return value.
|
|
*/
|
|
export interface MizanCallResponse {
|
|
result: any
|
|
invalidate?: Array<string | { context: string; params?: Record<string, any> } | { function: string }>
|
|
merge?: Array<{ context: string; slot: string; value: unknown; params?: Record<string, any> }>
|
|
}
|
|
|
|
// === Configuration ===
|
|
|
|
interface MizanConfig {
|
|
baseUrl: string
|
|
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
|
|
csrfCookieName: string
|
|
csrfHeaderName: string
|
|
/**
|
|
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
|
|
* `true` for Django (the default — preserves existing setups); set
|
|
* `false` for FastAPI or any backend that doesn't ship a session
|
|
* endpoint to avoid a 404 storm on startup. A future revision moves
|
|
* this onto the schema-advertised capability surface.
|
|
*/
|
|
session: boolean
|
|
/**
|
|
* Wire transport. Defaults to `httpTransport()` (fetch-based,
|
|
* compatible with FastAPI / Django backends). Swap with a custom
|
|
* transport (e.g. `tauriTransport()`) at app entry to route
|
|
* Mizan calls through a different channel.
|
|
*/
|
|
transport: MizanTransport
|
|
}
|
|
|
|
const config: MizanConfig = {
|
|
baseUrl: '/api/mizan',
|
|
getHeaders: () => ({}),
|
|
csrfCookieName: 'csrftoken',
|
|
csrfHeaderName: 'X-CSRFToken',
|
|
session: true,
|
|
// Initialized below once httpTransport is defined.
|
|
transport: null as unknown as MizanTransport,
|
|
}
|
|
|
|
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 (!config.session) return Promise.resolve()
|
|
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)
|
|
},
|
|
}
|
|
}
|
|
|
|
// === Merge ===
|
|
//
|
|
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
|
|
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
|
|
// already resolved which bundle slot the value lands in (by matching the
|
|
// mutation's return type against each context function's return type), so
|
|
// the kernel does no inference — it writes directly to `bundle[slot]`,
|
|
// upserting by id when the slot is a list. The type information lives in
|
|
// the schema-aware backend layer; the kernel is type-erased on purpose.
|
|
|
|
function spliceSlot(slot: unknown, value: unknown): unknown {
|
|
if (Array.isArray(slot)) {
|
|
if (Array.isArray(value)) return value
|
|
if (value && typeof value === 'object' && 'id' in value) {
|
|
const id = (value as { id: unknown }).id
|
|
const idx = slot.findIndex(item =>
|
|
item && typeof item === 'object' && 'id' in item
|
|
&& (item as { id: unknown }).id === id
|
|
)
|
|
const next = slot.slice()
|
|
if (idx >= 0) next[idx] = value
|
|
else next.push(value)
|
|
return next
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
export function merge(
|
|
context: string,
|
|
params: Record<string, any> | undefined,
|
|
slot: string,
|
|
value: unknown,
|
|
): void {
|
|
const entries = contexts.get(context)
|
|
if (!entries) return
|
|
const entry = entries.get(stableKey(params ?? {}))
|
|
if (!entry || entry.state.data == null) return
|
|
|
|
const data = entry.state.data
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return
|
|
|
|
const bundle = data as Record<string, unknown>
|
|
if (!(slot in bundle)) return
|
|
|
|
entry.state = {
|
|
data: { ...bundle, [slot]: spliceSlot(bundle[slot], value) },
|
|
status: 'success',
|
|
error: null,
|
|
}
|
|
entry.listeners.forEach(l => l())
|
|
}
|
|
|
|
// === 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',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
|
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
|
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
|
|
* transport via `configure({ transport })` when running in a
|
|
* non-HTTP host (e.g. Tauri).
|
|
*/
|
|
export function httpTransport(): MizanTransport {
|
|
return {
|
|
async call(functionName, args) {
|
|
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())
|
|
return res.json()
|
|
},
|
|
async fetch(contextName, params) {
|
|
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()
|
|
},
|
|
}
|
|
}
|
|
|
|
// Install the default transport now that httpTransport is in scope. The
|
|
// config object was constructed earlier with a placeholder so the type
|
|
// stayed honest; this line is the actual binding.
|
|
config.transport = httpTransport()
|
|
|
|
export async function mizanFetch(
|
|
contextName: string,
|
|
params?: Record<string, any>,
|
|
): Promise<any> {
|
|
return config.transport.fetch(contextName, params)
|
|
}
|
|
|
|
export async function mizanCall(
|
|
functionName: string,
|
|
args: Record<string, any>,
|
|
): Promise<any> {
|
|
const data = await config.transport.call(functionName, args)
|
|
|
|
// Server-driven merges run before invalidations so a context that is
|
|
// both merged-into and invalidated ends in the invalidation state — the
|
|
// server told us to refetch, that wins.
|
|
if (data.merge) {
|
|
for (const entry of data.merge) {
|
|
merge(entry.context, entry.params, entry.slot, entry.value)
|
|
}
|
|
}
|
|
|
|
// Server-driven invalidation
|
|
if (data.invalidate) {
|
|
for (const entry of data.invalidate) {
|
|
if (typeof entry === 'string') {
|
|
invalidate(entry)
|
|
} else if ('context' in entry) {
|
|
invalidate(entry.context, entry.params)
|
|
}
|
|
// {function: name} entries route through the kernel's
|
|
// function-output cache layer, which lives in the framework
|
|
// adapter; mizan-base treats them as a no-op here.
|
|
}
|
|
}
|
|
|
|
return data.result
|
|
}
|