Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted
The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:
1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
how named contexts collapse onto bundled fetches, how the registry-to-
Provider mapping is shaped) ships compiled instead of as source bytes
in every consumer's node_modules.
2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
The emit substrate is askama templates in templates/<target>/*.j2 —
actual target-language files with {{ ... }} substitution markers,
syntax-highlighted natively, type-checked against the render context
structs at compile time. The Rust emit modules build typed render
contexts and call .render(); no string-builder surface exists.
3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
/ Rust — the server always populates them, so consumer code reads them
without nullable checks. Surfaced by Blazr's typecheck on regeneration.
Layout:
frontends/mizan-rust/ — Rust port of @mizan/base; #[cfg(feature="pyo3")]
exposes PyMizanClient for the Python target.
protocol/mizan-codegen/ — codegen binary source + askama templates.
protocol/mizan-generate/ — npm-package shim. bin/launcher.mjs dispatches
to the platform-appropriate prebuilt binary.
Old generator/ JS tree deleted.
tests/rust/ — wire-parity drivers. drive_kernel exercises
raw client.call() / fetch_context(); drive_emitted
exercises the typed crate the codegen emits.
tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
codegen can wrap T | None responses in Option<T>.
Verification:
- 20 mizan-codegen tests green (IR deserialization, byte-equivalent
parity vs JS baseline for stage1/rust/python/react/vue/svelte,
structural test for channels).
- tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
driving the FastAPI fixture end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
195
frontends/mizan-rust/src/client.rs
Normal file
195
frontends/mizan-rust/src/client.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! `MizanClient` — the kernel entry point.
|
||||
//!
|
||||
//! Mirrors the `configure(opts)` + module-level state in
|
||||
//! `frontends/mizan-base/src/index.ts`, but as an owned struct because
|
||||
//! Rust lacks module-level mutable state. Consumers hold an
|
||||
//! `Arc<MizanClient>` and pass it everywhere the TS code would have
|
||||
//! used the module-level `config`.
|
||||
//!
|
||||
//! Public surface:
|
||||
//! - `MizanClient::new(config)` — build with reqwest cookie jar.
|
||||
//! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle.
|
||||
//! - `client.call(fn_name, args)` — async, applies merge + invalidation
|
||||
//! from the response then returns `result`.
|
||||
//! - `client.register_context(name, params, fetch_fn)` — register an
|
||||
//! instance; returns a `ContextHandle`.
|
||||
//! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)`
|
||||
//! — schedule invalidation via the kernel queue.
|
||||
//! - `client.merge(context, params, slot, value)` — splice a value into
|
||||
//! a context bundle slot.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::cookie::CookieStore;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT};
|
||||
use reqwest::Url;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::context::{ContextHandle, ContextRegistry, FetchFn};
|
||||
use crate::error::MizanError;
|
||||
use crate::invalidation::InvalidationQueue;
|
||||
use crate::transport;
|
||||
|
||||
|
||||
pub struct MizanConfig {
|
||||
pub base_url: String,
|
||||
pub session: bool,
|
||||
pub csrf_cookie_name: String,
|
||||
pub csrf_header_name: String,
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
|
||||
impl Default for MizanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "/api/mizan".to_string(),
|
||||
session: true,
|
||||
csrf_cookie_name: "csrftoken".to_string(),
|
||||
csrf_header_name: "X-CSRFToken".to_string(),
|
||||
extra_headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub struct MizanClient {
|
||||
config: Arc<MizanConfig>,
|
||||
http: reqwest::Client,
|
||||
cookie_jar: Arc<reqwest::cookie::Jar>,
|
||||
registry: Arc<ContextRegistry>,
|
||||
queue: Arc<InvalidationQueue>,
|
||||
session_ready: OnceCell<()>,
|
||||
}
|
||||
|
||||
|
||||
impl MizanClient {
|
||||
pub fn new(config: MizanConfig) -> Arc<Self> {
|
||||
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
|
||||
let http = reqwest::Client::builder()
|
||||
.cookie_provider(Arc::clone(&cookie_jar))
|
||||
.build()
|
||||
.expect("reqwest client construction");
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let queue = InvalidationQueue::new(Arc::clone(®istry));
|
||||
Arc::new(Self {
|
||||
config: Arc::new(config),
|
||||
http,
|
||||
cookie_jar,
|
||||
registry,
|
||||
queue,
|
||||
session_ready: OnceCell::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &MizanConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
|
||||
pub fn context_registry(&self) -> &Arc<ContextRegistry> {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn invalidation_queue(&self) -> &Arc<InvalidationQueue> {
|
||||
&self.queue
|
||||
}
|
||||
|
||||
/// Hit `/session/` once on first call to bootstrap the CSRF cookie.
|
||||
/// No-op when `config.session == false`. Three attempts with 100ms
|
||||
/// × attempt backoff.
|
||||
pub async fn ensure_session_ready(&self) -> Result<(), MizanError> {
|
||||
if !self.config.session {
|
||||
return Ok(());
|
||||
}
|
||||
self.session_ready
|
||||
.get_or_try_init(|| async {
|
||||
if self.read_csrf_cookie().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/')))
|
||||
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
|
||||
for attempt in 0..3 {
|
||||
let res = self.http.get(url.clone()).send().await;
|
||||
if res.is_ok() && self.read_csrf_cookie().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if attempt < 2 {
|
||||
tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await;
|
||||
}
|
||||
}
|
||||
// Mirror TS: failing to bootstrap is non-fatal — subsequent
|
||||
// calls proceed without CSRF and may still succeed (e.g.,
|
||||
// FastAPI configs that don't require it).
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_headers(&self) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in &self.config.extra_headers {
|
||||
if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) {
|
||||
headers.insert(n, v);
|
||||
}
|
||||
}
|
||||
if let Some(token) = self.read_csrf_cookie() {
|
||||
if let (Ok(n), Ok(v)) = (
|
||||
HeaderName::try_from(self.config.csrf_header_name.as_str()),
|
||||
HeaderValue::try_from(token.as_str()),
|
||||
) {
|
||||
headers.insert(n, v);
|
||||
}
|
||||
}
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn read_csrf_cookie(&self) -> Option<String> {
|
||||
let url = Url::parse(&self.config.base_url).ok()?;
|
||||
let header = self.cookie_jar.cookies(&url)?;
|
||||
let raw = header.to_str().ok()?;
|
||||
let needle = format!("{}=", self.config.csrf_cookie_name);
|
||||
raw.split(';')
|
||||
.map(|p| p.trim())
|
||||
.find_map(|p| p.strip_prefix(&needle))
|
||||
.map(|v| v.trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
// ── High-level API ─────────────────────────────────────────────────
|
||||
|
||||
pub async fn fetch_context(&self, context: &str, params: &Value) -> Result<Value, MizanError> {
|
||||
transport::mizan_fetch(self, context, params).await
|
||||
}
|
||||
|
||||
pub async fn call(&self, fn_name: &str, args: Value) -> Result<Value, MizanError> {
|
||||
transport::mizan_call(self, fn_name, args).await
|
||||
}
|
||||
|
||||
pub async fn register_context(
|
||||
self: &Arc<Self>,
|
||||
name: impl Into<String>,
|
||||
params: Value,
|
||||
fetch_fn: FetchFn,
|
||||
) -> ContextHandle {
|
||||
self.registry.register(name, params, fetch_fn, None).await
|
||||
}
|
||||
|
||||
pub async fn invalidate(self: &Arc<Self>, name: impl Into<String>) {
|
||||
self.queue.invalidate(name).await;
|
||||
}
|
||||
|
||||
pub async fn invalidate_scoped(self: &Arc<Self>, name: impl Into<String>, params: Value) {
|
||||
self.queue.invalidate_scoped(name, params).await;
|
||||
}
|
||||
|
||||
pub async fn merge(&self, context: &str, params: Option<&Value>, slot: &str, value: &Value) {
|
||||
self.registry.merge(context, params, slot, value).await;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user