7.0 KiB
Dual-shape api-plugin-example (trusted tier + WASI-stdio wasm tier)
Package: @suaveplan/api-plugin-example (refactor + additive subpath)
Status: PROPOSED
Type: in-package refactor + new ./wasm subpath export
Blocks: wasm-compiler-javy (keystone test needs this)
Why
The wasm-compiler-javy build agent (2026-05-27 attempt 2) stopped with an architectural blocker: @suaveplan/api-plugin-example is a trusted-tier api-host plugin (Hono routes, OTel SDK Node, streaming responses, closure-injected ledger / publisher, Request-typed handlers) and cannot be compiled to WASM via Javy. Three independent walls:
- Plugin shape: exports
examplePluginModule: PluginModuleandbuildRoutes({ ledger, publisher })— not{ routes, handleRequest(SerializedRequest) }. - Bundling: transitive deps drag
@hono/node-server, OTel SDK Node bits,node:*specifiers;Bun.build({ target: "browser" })trips its own gate. - Runtime: Javy v8.1.1's QuickJS lacks usable
ReadableStream/WritableStream, fetch, OTel SDK Node-isms.
Three options were considered (full transcript in the agent's stop report); the orchestrator chose dual-shape: keep the existing trusted-tier reference plugin AND add a WASI-stdio surface on the SAME package via a new ./wasm subpath. This preserves the single reference-plugin name (api-plugin-example continues to be THE reference) and gives both tiers concrete deploy-targets that share a domain core. The wasm subpath has zero runtime overlap with the trusted exports — its dependency tree is disjoint from Hono/OTel-SDK/streaming.
Concrete consumer
@suaveplan/wasm-compiler-javy(next-up build) — keystone test compiles@suaveplan/api-plugin-example/wasm(NOT the root entry), produces a WASI-stdio.wasm, deploys it throughapi-sandbox-wasm-host-bridgewithabiVariant: "wasi-stdio".e2e/foundation-stack-fullStage-8 closure smoke (gate 2.8) — exercisesapi-plugin-exampleunder trusted + vm tiers ANDapi-plugin-example/wasmunder wasm tier. All three isolation tiers covered by ONE reference name.
Concrete host bridge
@suaveplan/api-sandbox-wasm-host-bridge (already shipped) — receives the produced .wasm, no changes needed.
End-to-end test
packages/api-stack/api/api-plugin-example/tests/wasm/shape-conformance.test.ts— boots the wasm subpath under a host-side WASI runner, exercises every route via{kind:"request"}envelopes, asserts each response matches the trusted-tier output for the equivalent endpoint (so the dual-shape isn't divergent semantics — it's two surfaces over one domain core).
What changes
Source layout (refactor)
packages/api-stack/api/api-plugin-example/src/
index.ts ← unchanged trusted-tier barrel (preserves existing exports)
domain/ ← NEW: pure, dependency-free invoice domain
invoice.types.ts
invoice.types.md
invoice-rules.ts ← business rules (state transitions, validation)
invoice-rules.md
invoice-rules.test.ts
in-memory-store.ts ← simple Map-backed store; no IO; deterministic
in-memory-store.md
in-memory-store.test.ts
trusted/ ← existing routes/, plugin/, install/, events/ moved here
index.ts ← internal barrel
routes.ts ← references domain/ for business rules; Hono-tier handlers
plugin.ts ← api-host PluginModule with hooks
install.ts
events.ts
wasm/ ← NEW: WASI-stdio surface
index.ts ← export const plugin = { routes, handleRequest, start, dispose }
routes.ts ← export const routes: readonly SerializedRoute[]
handle-request.ts ← (req: SerializedRequest) => SerializedResponse
handle-request.md
handle-request.test.ts
start.ts ← () => void; primes in-memory store
dispose.ts ← () => void; clears store
wasm-state.ts ← module-singleton InMemoryStore instance
The existing error-codes/, instruments/, schemas/, events/ stay at the package root because they're shared between trusted and wasm tiers (only the trusted-tier instruments register against the OTel SDK; the wasm tier defines them as no-op shims to keep the API surface uniform).
Public surface (additive — existing entry unchanged)
// package.json exports (only the additions shown):
{
"./wasm": {
"import": {
"types": "./dist/wasm/index.d.ts",
"default": "./dist/wasm/index.js"
}
},
"./domain": {
"import": {
"types": "./dist/domain/index.d.ts",
"default": "./dist/domain/index.js"
}
}
}
import { plugin } from "@suaveplan/api-plugin-example/wasm";
plugin.routes; // readonly SerializedRoute[]
await plugin.handleRequest(req); // SerializedRequest -> SerializedResponse
plugin.start?.(); // optional
plugin.dispose?.(); // optional
Dependency constraint on wasm/
The wasm/ directory MUST NOT directly OR transitively import:
@suaveplan/api-host,hono,@hono/*@opentelemetry/sdk-metrics,@opentelemetry/sdk-trace-base,@opentelemetry/sdk-node- Any
node:*specifier - Any package whose dependency tree drags the above (transitive ban)
A new lint script bun scripts/lint/check-wasm-subpath-deps.ts (added as part of this change) enforces this by walking the imports from wasm/index.ts and rejecting any forbidden specifier. The script is wired into bunx turbo lint.
The wasm/ directory MAY use @opentelemetry/api (types-only, browser-safe) for OTel context propagation if needed. It MAY use zod for envelope validation. It MAY use @suaveplan/api-types/sandbox-bridge for the wire-protocol types.
Domain parity
The tests/wasm/shape-conformance.test.ts integration test asserts that for every route shared between trusted and wasm surfaces, calling the trusted handler with Request("/foo", { method: "POST", body: JSON.stringify(x) }) and calling the wasm handler with { method: "POST", url: "/foo", headers: [...], bodyBase64: base64(JSON.stringify(x)), ... } produces the SAME response status and the SAME response body. The shared domain/ module is what guarantees this; the test verifies it stays true.
Out of scope
- Streaming routes. The trusted-tier example has a
streamLargeReportendpoint that returns a chunkedResponse. The wasm tier OMITS this endpoint — it's documented inwasm/index.mdas "streaming endpoints don't apply under the wasm tier; use a polling endpoint or pagination instead". The shape-conformance test excludes it. - Plugin lifecycle hooks beyond
start/dispose. The trusted tier'shealth/uninstallhooks have no wasm-tier analogue. - Changes to the existing trusted-tier exports. Anything imported from
@suaveplan/api-plugin-example(root) keeps its current shape and dependency graph.