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

115 lines
7.0 KiB
Markdown

# 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)
```text
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)
```jsonc
// 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"
}
}
}
```
```ts
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.