Files
suaveplan/openspec/changes/api-plugin-example-dual-shape/proposal.md
2026-05-27 23:29:53 +01:00

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:

  1. Plugin shape: exports examplePluginModule: PluginModule and buildRoutes({ ledger, publisher }) — not { routes, handleRequest(SerializedRequest) }.
  2. Bundling: transitive deps drag @hono/node-server, OTel SDK Node bits, node:* specifiers; Bun.build({ target: "browser" }) trips its own gate.
  3. 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 through api-sandbox-wasm-host-bridge with abiVariant: "wasi-stdio".
  • e2e/foundation-stack-full Stage-8 closure smoke (gate 2.8) — exercises api-plugin-example under trusted + vm tiers AND api-plugin-example/wasm under 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 streamLargeReport endpoint that returns a chunked Response. The wasm tier OMITS this endpoint — it's documented in wasm/index.md as "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's health / uninstall hooks 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.