diff --git a/frontends/mizan-webview-channels/package.json b/frontends/mizan-webview-channels/package.json new file mode 100644 index 0000000..b8ac50d --- /dev/null +++ b/frontends/mizan-webview-channels/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mizan/webview-channels", + "version": "0.1.0", + "description": "Webview-side channel connection for Mizan — RPC + pub/sub over a VSCode webview's postMessage channel. Mirror of the WebSocket-based ChannelConnection in mizan-react/channels.", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "license": "MIT" +} diff --git a/frontends/mizan-webview-channels/src/index.ts b/frontends/mizan-webview-channels/src/index.ts new file mode 100644 index 0000000..07a525a --- /dev/null +++ b/frontends/mizan-webview-channels/src/index.ts @@ -0,0 +1,252 @@ +/** + * @mizan/webview-channels — RPC + pub/sub over a VSCode webview's + * postMessage channel. Mirror of mizan-react/src/channels (WebSocket-based) + * adapted for the in-process bidirectional channel a webview shares with + * its extension host. + * + * The webview process can only acquire the VSCode API once, so this module + * and `@mizan/webview-transport` coexist by sharing the singleton: + * whichever loads first acquires the API. Both packages post their own + * envelope kinds and filter incoming messages by the `kind` field. + * + * Envelope shapes (this side ↔ extension-host): + * + * webview → host: + * { kind: 'rpc', id, fn, args } + * { kind: 'subscribe', id, channel, params? } + * { kind: 'unsubscribe', id } + * + * host → webview: + * { kind: 'rpc-response', id, ok: true, data } + * { kind: 'rpc-response', id, ok: false, error: { code, message, details? } } + * { kind: 'event', subscription, payload } + * { kind: 'close', subscription, reason? } + * + * Long-running ops (e.g. kata's claude-p shell-out) live as channel + * subscriptions — the host streams events as work progresses, then closes + * the subscription on completion. RPC is reserved for short request/reply. + */ + +declare global { + function acquireVsCodeApi(): VsCodeApi +} + +interface VsCodeApi { + postMessage(msg: unknown): void + setState(state: unknown): void + getState(): unknown +} + +let _api: VsCodeApi | null = null + +function api(): VsCodeApi { + if (_api === null) { + if (typeof acquireVsCodeApi !== 'function') { + throw new Error( + '@mizan/webview-channels: acquireVsCodeApi is not defined. ' + + 'This module runs inside a VSCode webview only.', + ) + } + _api = acquireVsCodeApi() + } + return _api +} + +// ─── Errors ──────────────────────────────────────────────────────────────── + +export class WebviewRPCError extends Error { + code: string + details?: Record + + constructor(code: string, message: string, details?: Record) { + super(message) + this.name = 'WebviewRPCError' + this.code = code + this.details = details + } +} + +// ─── Envelope types ──────────────────────────────────────────────────────── + +type RPCEnvelope = { kind: 'rpc'; id: string; fn: string; args: unknown } +type SubscribeEnvelope = { kind: 'subscribe'; id: string; channel: string; params?: Record } +type UnsubscribeEnvelope = { kind: 'unsubscribe'; id: string } + +type RPCResponseOk = { kind: 'rpc-response'; id: string; ok: true; data: unknown } +type RPCResponseErr = { + kind: 'rpc-response' + id: string + ok: false + error: { code: string; message: string; details?: Record } +} +type EventEnvelope = { kind: 'event'; subscription: string; payload: unknown } +type CloseEnvelope = { kind: 'close'; subscription: string; reason?: string } + +type Incoming = RPCResponseOk | RPCResponseErr | EventEnvelope | CloseEnvelope + +// ─── Subscription handle ─────────────────────────────────────────────────── + +export interface Subscription { + readonly id: string + readonly channel: string + readonly params: Record | undefined + /** Called for every event the host pushes on this subscription. */ + onEvent(handler: (payload: unknown) => void): () => void + /** Called once when the host closes the subscription. */ + onClose(handler: (reason?: string) => void): () => void + /** Send an unsubscribe envelope to the host; local handlers stop firing. */ + unsubscribe(): void + /** True until the host has closed the subscription or the consumer has unsubscribed. */ + readonly active: boolean +} + +// ─── Connection ──────────────────────────────────────────────────────────── + +export class WebviewChannelConnection { + private pendingRPCs = new Map void + reject: (err: WebviewRPCError) => void + }>() + private subscriptions = new Map() + private installed = false + private counter = 0 + + private install(): void { + if (this.installed) return + this.installed = true + window.addEventListener('message', (e: MessageEvent) => this.handle(e.data)) + } + + private nextId(prefix: string): string { + return `${prefix}_${++this.counter}_${Date.now()}` + } + + private handle(msg: unknown): void { + if (!msg || typeof msg !== 'object') return + const m = msg as Incoming + switch (m.kind) { + case 'rpc-response': { + const pen = this.pendingRPCs.get(m.id) + if (!pen) return + this.pendingRPCs.delete(m.id) + if (m.ok) { + pen.resolve(m.data) + } else { + pen.reject(new WebviewRPCError( + m.error.code, + m.error.message, + m.error.details, + )) + } + return + } + case 'event': { + const sub = this.subscriptions.get(m.subscription) + if (sub) sub.dispatchEvent(m.payload) + return + } + case 'close': { + const sub = this.subscriptions.get(m.subscription) + if (sub) { + sub.dispatchClose(m.reason) + this.subscriptions.delete(m.subscription) + } + return + } + } + } + + /** + * Call a host-registered RPC function. Resolves with the function's + * return value or throws `WebviewRPCError` on failure. + */ + rpc(fn: string, args: TInput): Promise { + this.install() + return new Promise((resolve, reject) => { + const id = this.nextId('rpc') + this.pendingRPCs.set(id, { + resolve: resolve as (data: unknown) => void, + reject, + }) + const env: RPCEnvelope = { kind: 'rpc', id, fn, args } + api().postMessage(env) + }) + } + + /** + * Open a long-running subscription on a host-registered channel. + * The returned Subscription handle receives events until the host + * closes it or the consumer calls `unsubscribe()`. + */ + subscribe(channel: string, params?: Record): Subscription { + this.install() + const id = this.nextId('sub') + const sub = new SubscriptionImpl(id, channel, params, () => { + this.subscriptions.delete(id) + const env: UnsubscribeEnvelope = { kind: 'unsubscribe', id } + api().postMessage(env) + }) + this.subscriptions.set(id, sub) + const env: SubscribeEnvelope = { kind: 'subscribe', id, channel, params } + api().postMessage(env) + return sub + } +} + +class SubscriptionImpl implements Subscription { + private eventHandlers = new Set<(payload: unknown) => void>() + private closeHandlers = new Set<(reason?: string) => void>() + private _active = true + + constructor( + public readonly id: string, + public readonly channel: string, + public readonly params: Record | undefined, + private readonly onLocalUnsubscribe: () => void, + ) {} + + get active(): boolean { + return this._active + } + + onEvent(handler: (payload: unknown) => void): () => void { + this.eventHandlers.add(handler) + return () => this.eventHandlers.delete(handler) + } + + onClose(handler: (reason?: string) => void): () => void { + this.closeHandlers.add(handler) + return () => this.closeHandlers.delete(handler) + } + + unsubscribe(): void { + if (!this._active) return + this._active = false + this.onLocalUnsubscribe() + this.closeHandlers.forEach(h => h('unsubscribed')) + this.eventHandlers.clear() + this.closeHandlers.clear() + } + + dispatchEvent(payload: unknown): void { + if (!this._active) return + this.eventHandlers.forEach(h => h(payload)) + } + + dispatchClose(reason?: string): void { + if (!this._active) return + this._active = false + this.closeHandlers.forEach(h => h(reason)) + this.eventHandlers.clear() + this.closeHandlers.clear() + } +} + +// ─── Singleton ───────────────────────────────────────────────────────────── + +let _default: WebviewChannelConnection | null = null + +export function getDefaultChannelConnection(): WebviewChannelConnection { + if (_default === null) _default = new WebviewChannelConnection() + return _default +} diff --git a/frontends/mizan-webview-transport/package.json b/frontends/mizan-webview-transport/package.json new file mode 100644 index 0000000..1de5fe8 --- /dev/null +++ b/frontends/mizan-webview-transport/package.json @@ -0,0 +1,14 @@ +{ + "name": "@mizan/webview-transport", + "version": "0.1.0", + "description": "Mizan transport adapter routing calls through a VSCode webview's postMessage channel. Paired with the extension-host-side Mizan dispatcher.", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "peerDependencies": { + "@mizan/base": "*" + }, + "license": "MIT" +} diff --git a/frontends/mizan-webview-transport/src/index.ts b/frontends/mizan-webview-transport/src/index.ts new file mode 100644 index 0000000..6f7c763 --- /dev/null +++ b/frontends/mizan-webview-transport/src/index.ts @@ -0,0 +1,141 @@ +/** + * @mizan/webview-transport — routes Mizan calls through a VSCode webview's + * postMessage channel instead of HTTP fetch or Tauri IPC. Paired with the + * extension-host-side Mizan dispatcher (e.g. `MizanHost` in the holomorphic + * extension), which receives envelopes via `webview.onDidReceiveMessage` + * and posts responses back via `webview.postMessage`. + * + * Usage (webview side, inside the bundled React/TS app): + * + * import { configure } from '@mizan/base' + * import { webviewTransport } from '@mizan/webview-transport' + * + * configure({ transport: webviewTransport() }) + * + * The transport keeps the same protocol surface as the HTTP and Tauri + * transports (call/fetch envelopes, {result, invalidate, merge} response + * shape), so the codegen output and React adapter are unchanged — only + * the wire channel differs. + * + * Envelope shapes (this side ↔ extension-host): + * + * webview → host: + * { kind: 'call', id, fn, args } + * { kind: 'fetch', id, context, params? } + * + * host → webview: + * { kind: 'response', id, ok: true, body } + * { kind: 'response', id, ok: false, error: { status, body } } + * + * Correlation by `id` lets multiple in-flight calls share the one + * postMessage channel. + */ + +import { MizanError, type MizanCallResponse, type MizanTransport } from '@mizan/base' + +// VSCode's webview API — present at runtime, declared globally so the +// transport can be authored without pulling vscode types into a generic +// frontend package. Returned by acquireVsCodeApi() exactly once per +// webview load; subsequent calls throw. +declare global { + function acquireVsCodeApi(): VsCodeApi +} + +interface VsCodeApi { + postMessage(msg: unknown): void + setState(state: unknown): void + getState(): unknown +} + +type Pending = { + resolve: (data: unknown) => void + reject: (err: unknown) => void +} + +type CallEnvelope = { kind: 'call'; id: string; fn: string; args: Record } +type FetchEnvelope = { kind: 'fetch'; id: string; context: string; params?: Record } +type Envelope = CallEnvelope | FetchEnvelope + +type ResponseOk = { kind: 'response'; id: string; ok: true; body: any } +type ResponseErr = { kind: 'response'; id: string; ok: false; error: { status: number; body: string } } +type ResponseEnvelope = ResponseOk | ResponseErr + +let _api: VsCodeApi | null = null + +function api(): VsCodeApi { + if (_api === null) { + if (typeof acquireVsCodeApi !== 'function') { + throw new Error( + '@mizan/webview-transport: acquireVsCodeApi is not defined. ' + + 'This transport runs inside a VSCode webview only.', + ) + } + _api = acquireVsCodeApi() + } + return _api +} + +const pending = new Map() +let installed = false +let counter = 0 + +function install(): void { + if (installed) return + installed = true + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data as ResponseEnvelope | undefined + if (!msg || msg.kind !== 'response') return + const pen = pending.get(msg.id) + if (!pen) return + pending.delete(msg.id) + if (msg.ok) { + pen.resolve(msg.body) + } else { + pen.reject(new MizanError(msg.error.status, msg.error.body)) + } + }) +} + +function nextId(): string { + return `mz_${++counter}_${Date.now()}` +} + +function send(env: Envelope): Promise { + install() + return new Promise((resolve, reject) => { + pending.set(env.id, { + resolve: resolve as (data: unknown) => void, + reject, + }) + api().postMessage(env) + }) +} + +/** + * Build a Mizan transport that routes through a VSCode webview's + * postMessage channel. Install via: + * + * import { configure } from '@mizan/base' + * import { webviewTransport } from '@mizan/webview-transport' + * configure({ transport: webviewTransport() }) + */ +export function webviewTransport(): MizanTransport { + return { + async call(fn, args): Promise { + return send({ + kind: 'call', + id: nextId(), + fn, + args, + }) + }, + async fetch(context, params) { + return send({ + kind: 'fetch', + id: nextId(), + context, + params, + }) + }, + } +} diff --git a/protocol/mizan-codegen/src/config.rs b/protocol/mizan-codegen/src/config.rs index 2c2f3bd..835821a 100644 --- a/protocol/mizan-codegen/src/config.rs +++ b/protocol/mizan-codegen/src/config.rs @@ -69,6 +69,16 @@ pub struct SourceConfig { /// usage (no Pydantic) just omits the sub-block. #[serde(default)] pub rust: Option, + + /// `[source.script]` — generic source. Spawn an arbitrary command and + /// read its stdout as KDL IR. Use when none of the language-specific + /// sources fit — e.g. a Python module that walks `mizan_core.registry` + /// for a non-Django/non-FastAPI consumer, or a custom IR emitter. + /// Keeps mizan-codegen out of the business of knowing every possible + /// backend language while preserving the "subprocess emits KDL" + /// contract every other source already follows. + #[serde(default)] + pub script: Option, } @@ -206,6 +216,35 @@ fn default_pydantic_derives() -> Vec { } +/// `[source.script]` — generic stdout-of-arbitrary-command source. +/// +/// Spawns `command` with `args`, reads its stdout, and parses it as KDL +/// Mizan IR. The same contract every other source follows; this one just +/// doesn't bake in any language-specific assumptions. +/// +/// Example: +/// +/// ```toml +/// [source.script] +/// command = ["uv", "run", "python", "-m", "holomorphic.emit_ir"] +/// ``` +#[derive(Debug, Deserialize)] +pub struct ScriptSource { + /// Full command vector. First entry is the program; rest are argv. + /// Must be non-empty. + pub command: Vec, + + /// Working directory for the subprocess, relative to the codegen + /// config directory. Defaults to the config directory itself. + #[serde(default)] + pub cwd: Option, + + /// Environment overrides. + #[serde(default)] + pub env: BTreeMap, +} + + #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] pub enum RustKernelSpec { diff --git a/protocol/mizan-codegen/src/fetch.rs b/protocol/mizan-codegen/src/fetch.rs index 52bb738..a0d395c 100644 --- a/protocol/mizan-codegen/src/fetch.rs +++ b/protocol/mizan-codegen/src/fetch.rs @@ -21,7 +21,7 @@ use std::process::{Command, Stdio}; use anyhow::{anyhow, Context, Result}; use serde_json::json; -use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource}; +use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource, ScriptSource}; use crate::ir::{parse_ir, MizanIR}; @@ -43,9 +43,11 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result { run_fastapi(fa, config_dir)? } else if let Some(dj) = &config.source.django { run_django(dj, config_dir)? + } else if let Some(sc) = &config.source.script { + run_script(sc, config_dir)? } else { return Err(anyhow!( - "config.source must declare one of [source.rust], [source.fastapi], or [source.django]" + "config.source must declare one of [source.rust], [source.fastapi], [source.django], or [source.script]" )); }; @@ -70,6 +72,18 @@ fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result { } +fn run_script(src: &ScriptSource, config_dir: &Path) -> Result { + let cwd = match &src.cwd { + Some(rel) => config_dir.join(rel), + None => config_dir.to_path_buf(), + }; + let (program, args) = src.command.split_first().ok_or_else(|| { + anyhow!("[source.script]: command must be non-empty") + })?; + run_subprocess(program, args, &cwd, &src.env, "script IR export") +} + + fn run_django(src: &DjangoSource, config_dir: &Path) -> Result { let manage_path = config_dir.join(&src.manage_path); let manage_dir = manage_path