Initial Commit
This commit is contained in:
114
openspec/changes/api-plugin-example-dual-shape/proposal.md
Normal file
114
openspec/changes/api-plugin-example-dual-shape/proposal.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user