mizan-webview-transport + webview-channels: VSCode webview as Mizan frontend

Two new frontend packages let a VSCode webview consume a Mizan backend
through its postMessage channel — peer transports to `mizan-tauri-transport`
and the default `httpTransport()`.

- `@mizan/webview-transport` implements `MizanTransport` (call/fetch)
  over postMessage with correlation-id pairing. Drop-in for `configure({
  transport: webviewTransport() })`; codegen output and React adapter
  are unchanged.

- `@mizan/webview-channels` mirrors mizan-react's WebSocket-based
  ChannelConnection — RPC + subscribe over the same postMessage channel
  for long-running ops where short request/reply isn't enough.

Both expect an extension-host-side dispatcher that reads envelopes via
`webview.onDidReceiveMessage` and routes them through mizan-ts's
`handleMutationCall` / `handleContextFetch`. First consumer is the
holomorphic VSCode extension.

mizan-codegen: new `[source.script]` generic source. Spawns an arbitrary
command and reads stdout as KDL IR. 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.
Holomorphic uses it to invoke `python -m holomorphic.emit_ir` against
the mizan_core registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:51:12 -04:00
parent 22dcf0e3c1
commit a5ef93b879
6 changed files with 473 additions and 2 deletions

View File

@@ -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"
}

View File

@@ -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<string, unknown>
constructor(code: string, message: string, details?: Record<string, unknown>) {
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<string, unknown> }
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<string, unknown> }
}
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<string, unknown> | 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<string, {
resolve: (data: unknown) => void
reject: (err: WebviewRPCError) => void
}>()
private subscriptions = new Map<string, SubscriptionImpl>()
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<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> {
this.install()
return new Promise<TOutput>((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<string, unknown>): 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<string, unknown> | 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
}

View File

@@ -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"
}

View File

@@ -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<string, any> }
type FetchEnvelope = { kind: 'fetch'; id: string; context: string; params?: Record<string, any> }
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<string, Pending>()
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<T>(env: Envelope): Promise<T> {
install()
return new Promise<T>((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<MizanCallResponse> {
return send<MizanCallResponse>({
kind: 'call',
id: nextId(),
fn,
args,
})
},
async fetch(context, params) {
return send<any>({
kind: 'fetch',
id: nextId(),
context,
params,
})
},
}
}