first commit
This commit is contained in:
95
docs/api-stack/api/api-sandbox-vm-host-bridge/README.md
Normal file
95
docs/api-stack/api/api-sandbox-vm-host-bridge/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# @suaveplan/api-sandbox-vm-host-bridge - Host-integration bridge that wraps a @suaveplan/api-sandbox-vm VmSandboxHandle as a PluginModule + PipelineRoute[] bundle for @suaveplan/api-host.installPlugin. Marshals host Request → SerializedRequest into the vm.Context, unmarshals SerializedResponse → host Response, delegates start/dispose lifecycle to the sandbox handle, and surfaces sandbox faults via the SAND_VM_BRIDGE.* code registry.
|
||||
|
||||
Host-integration bridge that lets a `VmSandboxHandle` from
|
||||
`@suaveplan/api-sandbox-vm` install into `@suaveplan/api-host` as a
|
||||
first-class plugin.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bridge](./src/bridge/bridge.md) — This module is the package's flagship factory: `createSandboxedVmPluginModule({ sandbox, manifest, registry })` → `Brid…
|
||||
- [bridge](./src/bridge/bridge.md) — This module is the package's flagship factory: `createSandboxedVmPluginModule({ sandbox, manifest, registry })` → `Brid…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used by every public failure path inside the host br…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used by every public failure path inside the host br…
|
||||
- [instruments](./src/instruments/instruments.md) — This module owns the OTel instrument bundle the bridge emits. Splitting the bundle off the bridge module makes the metr…
|
||||
- [instruments](./src/instruments/instruments.md) — This module owns the OTel instrument bundle the bridge emits. Splitting the bundle off the bridge module makes the metr…
|
||||
- [lifecycle](./src/lifecycle/lifecycle.md) — This module owns the idempotency wrapper around a `VmSandboxHandle`'s `start` / `dispose` lifecycle. The host's lifecyc…
|
||||
- [lifecycle](./src/lifecycle/lifecycle.md) — This module owns the idempotency wrapper around a `VmSandboxHandle`'s `start` / `dispose` lifecycle. The host's lifecyc…
|
||||
- [marshal-request](./src/marshal/marshal-request.md) — This module turns the host's per-request `RequestContext` into a plain-data `SerializedRequest` value that the sandbox…
|
||||
- [marshal-response](./src/marshal/marshal-response.md) — This module turns the value returned by a sandboxed handler into a host `Response`. The sandbox-side handler returns a…
|
||||
- [route-discovery](./src/route-discovery/route-discovery.md) — This module owns the route-discovery protocol that lets a sandboxed plugin tell the host which HTTP routes it wants to…
|
||||
- [route-discovery](./src/route-discovery/route-discovery.md) — This module owns the route-discovery protocol that lets a sandboxed plugin tell the host which HTTP routes it wants to…
|
||||
- [vm-host-bridge-conformance](./src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.md) — This module ships `runVmHostBridgeConformance({ buildBridge })` — a five-invariant conformance suite that every concret…
|
||||
- [vm-host-bridge-conformance](./src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.md) — This module ships `runVmHostBridgeConformance({ buildBridge })` — a five-invariant conformance suite that every concret…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What it does
|
||||
|
||||
A `VmSandboxHandle` is a frozen `vm.Context` running a `SourceTextModule`
|
||||
with permission-gated `__suaveplan` capabilities — but it is NOT a
|
||||
`PluginModule`. The host's `installPlugin({ module, manifest, routes })`
|
||||
takes a plain TS object with `start/stop/dispose/health` hooks plus a
|
||||
`PipelineRoute[]` registration list. This package builds that
|
||||
`PluginModule + routes` bundle by:
|
||||
|
||||
1. Asking the sandbox (via a host-installed `bridge.registerRoute`
|
||||
capability) for the plugin's route registrations.
|
||||
2. Wrapping every registered handler in a marshal closure that turns the
|
||||
host `Request` into a `SerializedRequest`, calls the sandbox-side
|
||||
handler, and turns the returned `SerializedResponse` into a host
|
||||
`Response`.
|
||||
3. Delegating `start()` / `dispose()` to the underlying sandbox handle,
|
||||
idempotently.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSandboxedVmPluginModule,
|
||||
createBridgeRouteRegistry,
|
||||
} from "@suaveplan/api-sandbox-vm-host-bridge";
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
|
||||
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const sandbox = createVmSandbox({
|
||||
pluginId: "demo",
|
||||
pluginModule: { kind: "source", identifier: "demo.mjs", source },
|
||||
checker, // PermissionChecker
|
||||
proxies: {
|
||||
fs,
|
||||
fetch,
|
||||
capabilities: { bridge: registry.capability },
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest,
|
||||
registry,
|
||||
});
|
||||
|
||||
await host.installPlugin({
|
||||
module: bridged.module,
|
||||
manifest,
|
||||
manifestHash,
|
||||
specifier,
|
||||
routes: bridged.routes,
|
||||
});
|
||||
```
|
||||
|
||||
## Error codes
|
||||
|
||||
`SAND_VM_BRIDGE.*` — five stable codes covering plugin-surface
|
||||
discovery (`INVALID_PLUGIN_SURFACE`), wire marshalling (`MARSHAL_FAILED`
|
||||
/ `UNMARSHAL_FAILED`), handler throws (`HANDLER_REJECTED`), and
|
||||
lifecycle delegation (`LIFECYCLE_FAILED`).
|
||||
|
||||
## Conformance
|
||||
|
||||
`@suaveplan/api-sandbox-vm-host-bridge/testing` ships
|
||||
`runVmHostBridgeConformance({ buildBridge })` with the five invariants
|
||||
the spec mandates: route registration, request round-trip, idempotent
|
||||
lifecycle, error propagation with `cause` chain, and capability
|
||||
passthrough including denied capability surfacing `SAND_PERM.DENIED`.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the package's flagship factory:
|
||||
`createSandboxedVmPluginModule({ sandbox, manifest, registry })` →
|
||||
`BridgedPlugin` — a frozen object whose `module` is a
|
||||
`PluginModule`-shaped lifecycle bundle and whose `routes` is a
|
||||
materialised `PipelineRoute[]` ready to hand to
|
||||
`host.installPlugin({ module, manifest, routes, ... })`.
|
||||
|
||||
The factory drives the underlying sandbox's `start()` through the
|
||||
idempotent lifecycle forwarder, asserts the sandbox registered at
|
||||
least one route via the `bridge.registerRoute` capability, and wraps
|
||||
every registered handler in a marshal-invoke-unmarshal closure so the
|
||||
host's request pipeline can dispatch into the sandboxed plugin as if
|
||||
it were a regular trusted plugin.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle delegation — `module.start()`, `module.stop()`,
|
||||
`module.uninstall()` thread through the lifecycle forwarder so the
|
||||
host's lifecycle driver sees consistent idempotent semantics.
|
||||
- Route materialisation — every sandbox registration becomes a frozen
|
||||
`PipelineRoute` with the plugin id, method (already upper-cased),
|
||||
path, options (defaulted, overridable), wrapped handler, empty
|
||||
params, and forwarded status map.
|
||||
- Marshal closure per route — host `Request` → `SerializedRequest` →
|
||||
sandbox handler → `SerializedResponse` → host `Response`.
|
||||
- Empty-registry guard — sandboxes that registered no routes raise
|
||||
`INVALID_PLUGIN_SURFACE` so the host never mounts an empty plugin.
|
||||
- OTel attribution — each step (marshal, handler invocation,
|
||||
unmarshal) increments the relevant counter on the bridge's OTel
|
||||
bundle.
|
||||
- Cause-chain preservation — sandbox throws surface as
|
||||
`HANDLER_REJECTED` with the original error preserved as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSandboxedVmPluginModule, createBridgeRouteRegistry }
|
||||
from "@suaveplan/api-sandbox-vm-host-bridge";
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
|
||||
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const sandbox = createVmSandbox({
|
||||
pluginId: "demo",
|
||||
pluginModule: { kind: "source", identifier: "demo.mjs", source },
|
||||
checker,
|
||||
proxies: { fs, fetch, capabilities: { bridge: registry.capability } },
|
||||
logger,
|
||||
});
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest,
|
||||
registry,
|
||||
});
|
||||
await host.installPlugin({
|
||||
module: bridged.module,
|
||||
manifest,
|
||||
manifestHash,
|
||||
specifier,
|
||||
routes: bridged.routes,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Supply per-route option overrides keyed by `"METHOD /path"`:
|
||||
|
||||
```ts
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest,
|
||||
registry,
|
||||
routeOptions: {
|
||||
"POST /upload": { streaming: true, auth: "tenant.write" },
|
||||
"GET /healthz": { auth: null, csrf: false },
|
||||
},
|
||||
statusMap: { 200: 201 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BridgedPluginModule<Caps>` — frozen lifecycle bundle.
|
||||
- `interface BridgedPlugin<Caps>` — `module`, `routes`, `dispose`,
|
||||
`lifecycle`.
|
||||
- `interface CreateSandboxedVmPluginModuleOptions` — `sandbox`,
|
||||
`manifest`, `registry`, `routeOptions?`, `statusMap?`.
|
||||
- `createSandboxedVmPluginModule<Caps>(options):
|
||||
Promise<BridgedPlugin<Caps>>` — async factory; runs
|
||||
`lifecycle.start()` synchronously before returning.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory deliberately freezes both the returned `BridgedPlugin` and
|
||||
its nested `module` / `routes` so the host's lifecycle driver cannot
|
||||
patch a route's handler after install. The wrapped handler captures
|
||||
the OTel duration histogram across the whole marshal-invoke-unmarshal
|
||||
cycle — this surfaces back-pressure under load as a single metric the
|
||||
operator can alert on without instrumenting each sub-step
|
||||
individually.
|
||||
|
||||
The `requires` generic on `BridgedPluginModule` is allowed to be empty
|
||||
because the bridge owns the only capability the sandbox needs (`bridge`);
|
||||
plugins that declare other capability requirements (db, secrets) thread
|
||||
those through the sandbox via the host's existing capability map — the
|
||||
bridge itself is unaware of them.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used
|
||||
by every public failure path inside the host bridge. The registry is
|
||||
built once via `defineCodes("SAND_VM_BRIDGE", […])` so the namespace
|
||||
prefix and per-code suffix are co-derived — that guarantee lets the
|
||||
host's error boundary, audit sink, and the conformance harness branch
|
||||
on literal code strings without ever decoding free-form messages.
|
||||
|
||||
Side-effect-importing this module from `src/index.ts` ensures every
|
||||
consumer that imports the package barrel (or any subpath export)
|
||||
participates in the same registration that the
|
||||
`scripts/conformance.ts` audit walks.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes — `INVALID_PLUGIN_SURFACE`, `MARSHAL_FAILED`,
|
||||
`UNMARSHAL_FAILED`, `HANDLER_REJECTED`, `LIFECYCLE_FAILED` — each
|
||||
mapped to exactly one failure surface inside the bridge.
|
||||
- A typed `SandboxVmBridgeError` subclass of `SuaveplanError` so
|
||||
consumers may discriminate via `instanceof` or by `code` string.
|
||||
- A public constructor (`new SandboxVmBridgeError(code, message,
|
||||
options?)`) so consumers can synthesise the error at the host edge
|
||||
when re-wrapping a non-`SuaveplanError` cause.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "@suaveplan/api-sandbox-vm-host-bridge/error-codes/error-codes";
|
||||
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"request body type is not supported by the bridge marshal layer"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Preserve the sandbox-side cause so the host's error boundary can walk
|
||||
the chain back to the original `SandboxVmError`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "@suaveplan/api-sandbox-vm-host-bridge/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
await invokeHandler();
|
||||
} catch (err) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.HANDLER_REJECTED,
|
||||
"sandboxed handler threw",
|
||||
{ cause: err, context: { route: "/foo", method: "GET" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SAND_VM_BRIDGE_CODES` — frozen record mapping enum key (e.g.
|
||||
`HANDLER_REJECTED`) to the namespaced string code
|
||||
(`"SAND_VM_BRIDGE.HANDLER_REJECTED"`).
|
||||
- `type SandboxVmBridgeCode` — literal union of every value in
|
||||
`SAND_VM_BRIDGE_CODES`.
|
||||
- `class SandboxVmBridgeError extends SuaveplanError<SandboxVmBridgeCode>`
|
||||
— public constructor `(code, message, options?)`. `options.cause`
|
||||
preserves the original sandbox throw for the host's cause-chain
|
||||
walker.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is declared `as const` so the compiler retains literal
|
||||
types — essential for exhaustive `switch` narrowing in the bridge's
|
||||
internal dispatch. The class sets `this.name = "SandboxVmBridgeError"`
|
||||
so heap snapshots, sourcemaps and uncaught-exception handlers
|
||||
attribute failures to this package rather than the generic
|
||||
`SuaveplanError` superclass. Registry ordering reflects the request
|
||||
lifecycle: discovery first, then per-request marshal failures, then
|
||||
the in-handler reject, then lifecycle failures last.
|
||||
@@ -0,0 +1,84 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OTel instrument bundle the bridge emits. Splitting
|
||||
the bundle off the bridge module makes the metric names a single
|
||||
source of truth (no fan-out of `meter.createCounter(...)` calls across
|
||||
the package) and gives the conformance harness a `reset…` entry point
|
||||
to clear cached instruments between scenarios.
|
||||
|
||||
The bundle is built lazily and cached so the first bridge invocation
|
||||
takes the meter-binding cost; every subsequent call reuses the
|
||||
instrument handles. Tests that need a clean slate per scenario call
|
||||
the reset helper in their `beforeEach`.
|
||||
|
||||
## Features
|
||||
|
||||
- Four instruments — `requestsMarshalledTotal`,
|
||||
`marshalFailuresTotal`, `handlerRejectionsTotal`,
|
||||
`bridgeInvocationDurationMs`.
|
||||
- Stable metric names — all four namespaced under
|
||||
`sandbox_vm_bridge.*` so dashboards can filter the bridge's metrics
|
||||
separately from the underlying sandbox host's metrics.
|
||||
- Lazy binding — meter creation is deferred until first use, so a
|
||||
consumer that imports the module without ever invoking the bridge
|
||||
pays no OTel cost.
|
||||
- Test-friendly reset — `resetSandboxVmBridgeInstruments()` drops the
|
||||
cached bundle so the next `getSandboxVmBridgeInstruments()` call
|
||||
re-binds against a freshly-installed `MeterProvider`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSandboxVmBridgeInstruments } from "@suaveplan/api-sandbox-vm-host-bridge/instruments/instruments";
|
||||
|
||||
getSandboxVmBridgeInstruments().requestsMarshalledTotal.add(1, {
|
||||
plugin: "demo",
|
||||
route: "/foo",
|
||||
method: "GET",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getSandboxVmBridgeInstruments,
|
||||
resetSandboxVmBridgeInstruments,
|
||||
} from "@suaveplan/api-sandbox-vm-host-bridge/instruments/instruments";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSandboxVmBridgeInstruments();
|
||||
// ...install a fresh OTel MeterProvider here so the next
|
||||
// `getSandboxVmBridgeInstruments()` re-binds against it.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const instruments = getSandboxVmBridgeInstruments();
|
||||
// assert against your provider's exported reader
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SandboxVmBridgeInstruments` — four instrument fields
|
||||
documented above.
|
||||
- `getSandboxVmBridgeInstruments(): SandboxVmBridgeInstruments` —
|
||||
lazy, cached.
|
||||
- `resetSandboxVmBridgeInstruments(): void` — drops the cached
|
||||
bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Counter` and `Histogram` types come from `@opentelemetry/api`
|
||||
directly rather than from any of the `@opentelemetry/sdk-*` packages
|
||||
so this module stays SDK-agnostic — the consumer's choice of metric
|
||||
exporter is honoured because the API package looks up the bound
|
||||
`MeterProvider` on each `metrics.getMeter()` call. The cache is
|
||||
module-local so an HMR reload in development never strands a stale
|
||||
bundle (the module reloads, the cache resets implicitly).
|
||||
@@ -0,0 +1,81 @@
|
||||
# lifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the idempotency wrapper around a `VmSandboxHandle`'s
|
||||
`start` / `dispose` lifecycle. The host's lifecycle driver may invoke
|
||||
these hooks multiple times (e.g. a hot-reload's stop+start pair, or a
|
||||
test harness's deterministic shutdown sequence); the wrapper makes the
|
||||
double-invocation case a silent no-op rather than a throw, and
|
||||
reflects under-the-hood sandbox throws via the bridge's
|
||||
`LIFECYCLE_FAILED` code so the host's error boundary sees a uniform
|
||||
shape.
|
||||
|
||||
Splitting the wrapper into its own module keeps the bridge factory
|
||||
small (no inline state machine) and gives the conformance harness a
|
||||
direct seam for asserting idempotency without standing up the full
|
||||
bridge.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `start` — second call returns without re-invoking
|
||||
`sandbox.start()`.
|
||||
- Idempotent `dispose` — second call returns without re-invoking
|
||||
`sandbox.dispose()`.
|
||||
- Forbidden start-after-dispose — calling `start()` after `dispose()`
|
||||
raises `LIFECYCLE_FAILED` so the host can fail the hot-reload
|
||||
cleanly rather than silently swallowing the request.
|
||||
- Error wrapping — underlying sandbox throws surface as
|
||||
`SandboxVmBridgeError` with `code: LIFECYCLE_FAILED` and the
|
||||
original error preserved as `cause`.
|
||||
- Inspection helpers — `hasStarted()` and `isDisposed()` for the
|
||||
bridge's internal request-pipeline guard.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
|
||||
|
||||
const forwarder = createSandboxLifecycleForwarder(sandbox);
|
||||
await forwarder.start();
|
||||
forwarder.dispose();
|
||||
forwarder.dispose(); // no-op
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
|
||||
|
||||
const forwarder = createSandboxLifecycleForwarder(sandbox);
|
||||
try {
|
||||
await forwarder.start();
|
||||
} catch (err) {
|
||||
// err.code === "SAND_VM_BRIDGE.LIFECYCLE_FAILED"
|
||||
// err.cause is the original SandboxVmError
|
||||
rollbackJournal.markFailed(err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SandboxLifecycleForwarder` — `start()`, `dispose()`,
|
||||
`hasStarted()`, `isDisposed()`.
|
||||
- `createSandboxLifecycleForwarder(sandbox: VmSandboxHandle):
|
||||
SandboxLifecycleForwarder` — factory; returns a frozen object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `started` / `disposed` flags are kept in a closure rather
|
||||
than on the returned object so the sandbox cannot mutate them via a
|
||||
property-set on the forwarder. The forwarder itself is `Object.freeze`d
|
||||
so consumers cannot patch `dispose` to skip the cleanup. Errors raised
|
||||
inside `dispose()` are caught and rewrapped — the alternative (letting
|
||||
them propagate raw) would mean the host's error boundary sees a mixed
|
||||
shape (sometimes `SandboxVmError`, sometimes
|
||||
`SandboxVmBridgeError`); the wrap keeps the boundary's branch arm
|
||||
tight.
|
||||
@@ -0,0 +1,84 @@
|
||||
# marshal-request
|
||||
|
||||
## Purpose
|
||||
|
||||
This module turns the host's per-request `RequestContext` into a
|
||||
plain-data `SerializedRequest` value that the sandbox can consume. The
|
||||
marshal step is the only place where the host's class-flavoured types
|
||||
(`Headers`, `Request`, `Principal`, `TenantContext`) are projected onto
|
||||
the JSON-safe, transport-agnostic shape published by
|
||||
`@suaveplan/api-types/sandbox-bridge`. Every downstream consumer of
|
||||
`SerializedRequest` — whether it crossed a `vm.Context` boundary or
|
||||
travelled through `WebAssembly.Memory` as raw bytes — sees the same
|
||||
record.
|
||||
|
||||
A Zod schema validates the result before it leaves this module so any
|
||||
host-side wiring bug surfaces at this seam (with a clear
|
||||
`MARSHAL_FAILED` code) rather than producing a cryptic crash inside the
|
||||
sandbox handler.
|
||||
|
||||
## Features
|
||||
|
||||
- Header projection — `Headers` → ordered `[name, value]` tuple array
|
||||
via `Headers#forEach`. Names are lowercased; duplicates are preserved
|
||||
in iteration order so the sandbox can rebuild a `Headers` instance
|
||||
with identical semantics.
|
||||
- Body drain + base64-url encoding — `Request.arrayBuffer()` materialises
|
||||
the request body into a `Uint8Array`, then `Buffer.from(bytes).toString("base64url")`
|
||||
produces the JSON-safe `bodyBase64` string. `GET`/`HEAD` requests skip
|
||||
the drain and emit an empty string.
|
||||
- Principal + tenant projection — drops to bare branded id values
|
||||
(`principalId`, `tenantId`). Plugins call back into the host via the
|
||||
capability proxy if they need scopes / claims / slug.
|
||||
- Capability snapshot — flat `string[]` of capability ids the sandbox is
|
||||
permitted to invoke from this request, in resolver order.
|
||||
- Timestamp — host wall-clock (`Date.now()`) at marshal time, useful for
|
||||
cross-boundary trace correlation and sandbox-side clock-skew checks.
|
||||
- Output validation — every marshalled value is `safeParse`-validated
|
||||
against `SerializedRequestSchema`; failures raise
|
||||
`SAND_VM_BRIDGE.MARSHAL_FAILED` with the Zod issue list attached.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { marshalRequest } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-request";
|
||||
|
||||
const serialized = await marshalRequest(ctx);
|
||||
console.log(serialized.url, serialized.method, serialized.requestId);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The exported Zod schema is reusable on the consumer side — for example a
|
||||
host-tier replay layer that wants to validate stored requests before
|
||||
re-injection:
|
||||
|
||||
```ts
|
||||
import { SerializedRequestSchema } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-request";
|
||||
|
||||
const result = SerializedRequestSchema.safeParse(replayCandidate);
|
||||
if (!result.success) {
|
||||
throw new BridgeError("replay payload failed marshalling schema");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `marshalRequest(ctx: RequestContext): Promise<SerializedRequest>` —
|
||||
async because body draining is async.
|
||||
- `SerializedRequestSchema: z.ZodType<SerializedRequest>` — the Zod
|
||||
validator used internally; mirrors the contract published by
|
||||
`@suaveplan/api-types/sandbox-bridge`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The marshal layer rejects non-WHATWG `Request` values up front because
|
||||
the host pipeline always synthesises a `Request` before calling the
|
||||
route handler — anything else indicates a contract violation worth
|
||||
surfacing immediately. The body-drain path catches `arrayBuffer()`
|
||||
rejection (locked streams, network errors mid-stream) and re-throws as
|
||||
`MARSHAL_FAILED` so the handler never sees a partially-drained body.
|
||||
Base64-url-no-pad is the canonical encoding shared with the wasm-tier
|
||||
sibling bridge (`@suaveplan/api-sandbox-wasm-host-bridge`); both bridges
|
||||
emit byte-for-byte identical `SerializedRequest` records given the same
|
||||
host inputs.
|
||||
@@ -0,0 +1,78 @@
|
||||
# marshal-response
|
||||
|
||||
## Purpose
|
||||
|
||||
This module turns the value returned by a sandboxed handler into a host
|
||||
`Response`. The sandbox-side handler returns a `SerializedResponse`
|
||||
(plain object with `status`, ordered header pairs, and base64-url
|
||||
`bodyBase64`); this module validates that shape with a Zod schema and
|
||||
constructs the corresponding WHATWG `Response` for the host's request
|
||||
pipeline to forward to the caller.
|
||||
|
||||
The validation step is what stops a misbehaving sandboxed handler
|
||||
(returning `null`, returning a primitive, returning a partial object
|
||||
missing `status`) from crashing the host pipeline downstream — and
|
||||
re-rehydrates the structurally pure JSON shape (`Array<[k, v]>` headers,
|
||||
`bodyBase64` string) back into the host's class-flavoured `Response`
|
||||
type.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated unmarshal — `SerializedResponseSchema` enforces
|
||||
`status ∈ [100,599]`, ordered `[name, value]` header pairs, and
|
||||
`bodyBase64: string` plus the optional `errorCode`/`errorMessage`
|
||||
pair the wire format uses for sandbox-tier exceptions.
|
||||
- Failure surfacing — non-conformant shapes raise
|
||||
`SAND_VM_BRIDGE.UNMARSHAL_FAILED` with the Zod issue list attached
|
||||
as `context.issues`.
|
||||
- Base64-url body decode — `Buffer.from(str, "base64url")` rehydrates
|
||||
the bytes; the result feeds straight into `Response`'s
|
||||
`BodyInit`-compatible `Uint8Array` slot.
|
||||
- Headers rehydration — `Array<[name, value]>` is appended onto a fresh
|
||||
`Headers` instance so duplicate-name semantics from the sandbox are
|
||||
preserved as the response leaves the bridge.
|
||||
- Bodyless-status enforcement — 204/304 responses drop the body even if
|
||||
the sandbox produced one, matching the WHATWG fetch rule the host
|
||||
pipeline expects.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { unmarshalResponse } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-response";
|
||||
|
||||
const response = unmarshalResponse(await sandboxHandler(serialized));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the exported schema to pre-validate cached or replayed responses
|
||||
before they re-enter the pipeline:
|
||||
|
||||
```ts
|
||||
import { SerializedResponseSchema } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-response";
|
||||
|
||||
const cached = await responseCache.get(key);
|
||||
const result = SerializedResponseSchema.safeParse(cached);
|
||||
if (result.success) {
|
||||
return cached;
|
||||
}
|
||||
return rebuildFromSandbox();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `unmarshalResponse(value: unknown): Response` — synchronous; throws
|
||||
`SandboxVmBridgeError(UNMARSHAL_FAILED)` on shape failure.
|
||||
- `SerializedResponseSchema: z.ZodType<SerializedResponse>` — the Zod
|
||||
validator used internally; mirrors the contract published by
|
||||
`@suaveplan/api-types/sandbox-bridge`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Headers cross as a `Headers` instance built via `append()` so duplicates
|
||||
(multi-`Set-Cookie`, etc.) preserve their iteration order. The body
|
||||
decode skips the `Buffer.from` call for empty strings so the common
|
||||
"no body" path costs nothing. Status 204/304 force a `null` body in the
|
||||
constructed `Response` because WHATWG forbids a body on those codes —
|
||||
the bridge enforces this so a sandbox that mistakenly attaches one
|
||||
cannot break the host pipeline.
|
||||
@@ -0,0 +1,98 @@
|
||||
# route-discovery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the route-discovery protocol that lets a sandboxed
|
||||
plugin tell the host which HTTP routes it wants to serve. Because the
|
||||
sandboxed plugin runs inside a `vm.Context` with no direct access to
|
||||
the host's router, the bridge installs a `bridge.registerRoute`
|
||||
capability that the plugin invokes during its module-evaluation phase;
|
||||
the host captures every registration and replays them as
|
||||
`PipelineRoute` entries on the bridge's `PluginModule`.
|
||||
|
||||
The pattern intentionally mirrors how `__suaveplan.capabilities.*`
|
||||
already works in `@suaveplan/api-sandbox-vm` — a sandboxed plugin sees
|
||||
`bridge` as just another capability, so the contract for declaring a
|
||||
route is the same as the contract for any other host-resident API.
|
||||
|
||||
## Features
|
||||
|
||||
- `createBridgeRouteRegistry()` factory — produces a fresh capture
|
||||
registry + the `bridge` capability bag in one call.
|
||||
- Zod-validated registrations — `registerRoute` rejects non-string
|
||||
methods, paths missing the leading `/`, or non-function handlers
|
||||
with `SAND_VM_BRIDGE.INVALID_PLUGIN_SURFACE`.
|
||||
- Method normalisation — methods are upper-cased before storage so
|
||||
`"get"` and `"GET"` produce equivalent registrations.
|
||||
- `requireNonEmptyRegistry()` — guard the bridge calls after
|
||||
`sandbox.start()` resolves to ensure the plugin registered at least
|
||||
one route. Empty registries raise `INVALID_PLUGIN_SURFACE`.
|
||||
- Frozen capability bag — the sandbox cannot patch `registerRoute` to
|
||||
bypass the validator.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBridgeRouteRegistry } from "@suaveplan/api-sandbox-vm-host-bridge/route-discovery/route-discovery";
|
||||
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const sandbox = createVmSandbox({
|
||||
proxies: {
|
||||
fs,
|
||||
fetch,
|
||||
capabilities: { bridge: registry.capability },
|
||||
},
|
||||
// ...
|
||||
});
|
||||
await sandbox.start();
|
||||
const routes = registry.registrations();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBridgeRouteRegistry,
|
||||
requireNonEmptyRegistry,
|
||||
} from "@suaveplan/api-sandbox-vm-host-bridge/route-discovery/route-discovery";
|
||||
|
||||
const registry = createBridgeRouteRegistry();
|
||||
// pass registry.capability to the sandbox under
|
||||
// `proxies.capabilities.bridge`, then start the sandbox.
|
||||
await sandbox.start();
|
||||
const routes = requireNonEmptyRegistry(registry, "demo-plugin");
|
||||
// routes is `readonly BridgeRouteRegistration[]`
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SandboxRouteHandler = (req: SerializedRequest) =>
|
||||
Promise<SerializedResponse> | SerializedResponse`.
|
||||
- `interface BridgeRouteRegistration` — `method`, `path`, `handler`.
|
||||
- `interface BridgeCapability` — `registerRoute(registration:
|
||||
unknown): Promise<void>` plus the open string-indexed mirror so the
|
||||
capability bag conforms to `@suaveplan/api-sandbox-vm`'s
|
||||
`CapabilityBag` structural type.
|
||||
- `interface BridgeRouteRegistry` — `registrations()` and
|
||||
`capability`.
|
||||
- `createBridgeRouteRegistry(): BridgeRouteRegistry`.
|
||||
- `requireNonEmptyRegistry(registry, pluginId):
|
||||
readonly BridgeRouteRegistration[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`registerRoute` is implemented as an `async` function on the capability
|
||||
bag because `@suaveplan/api-sandbox-vm`'s `installCapabilityProxies`
|
||||
contract requires every capability method to be async. The validator
|
||||
runs synchronously inside the async body so failures surface on the
|
||||
sandbox side via the proxy's permission-gate wrapper.
|
||||
|
||||
The Zod schema uses `z.custom<SandboxRouteHandler>` for the handler
|
||||
field because the runtime check (`typeof v === "function"`) is the
|
||||
only invariant we can verify before invocation — the signature shape
|
||||
is type-only and cannot be runtime-checked without invoking the
|
||||
handler with a probe value, which would defeat the purpose.
|
||||
@@ -0,0 +1,82 @@
|
||||
# vm-host-bridge-conformance
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module ships `runVmHostBridgeConformance({ buildBridge })` — a
|
||||
five-invariant conformance suite that every concrete bridge
|
||||
configuration MUST pass. The harness encodes the public contract from
|
||||
the OpenSpec change `api-sandbox-vm-host-bridge`: route registration,
|
||||
request round-trip, idempotent lifecycle, error propagation with
|
||||
preserved `cause`, and capability-gate fidelity (denied capabilities
|
||||
do NOT silently succeed).
|
||||
|
||||
A bridge integrator (e.g. the foundation-stack-full closure smoke,
|
||||
`api-plugin-example`'s vm-sandboxed deployment fixture) wires their
|
||||
own factory into the harness; the harness drives the bridge with the
|
||||
five scenarios and asserts each invariant.
|
||||
|
||||
## Features
|
||||
|
||||
- Five scenarios — one per requirement in the spec delta.
|
||||
- Reusable `RequestContext` builder — handles GET/HEAD bodies, binary
|
||||
POST bodies, and the minimum required field set so integrators do
|
||||
not have to re-author it.
|
||||
- Capability-denial scenario — accepts an integrator-supplied
|
||||
`checkCapability` shim so the same harness exercises both the
|
||||
default (allow-all) checker and a tightened policy.
|
||||
- `SandboxVmBridgeError` assertions — the harness checks `instanceof`
|
||||
AND the `code` string, so a faulty bridge that swallows errors
|
||||
silently is caught.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runVmHostBridgeConformance } from "@suaveplan/api-sandbox-vm-host-bridge/testing";
|
||||
import { createMyBridge } from "./my-bridge.js";
|
||||
|
||||
runVmHostBridgeConformance("my-bridge", async ({ source, checkCapability }) => {
|
||||
const bridged = await createMyBridge({ source, checkCapability });
|
||||
return { bridged, dispose: () => bridged.dispose() };
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The harness is `describe`-flavoured — embed it inside an existing
|
||||
`describe` block to add label-scoped setup/teardown:
|
||||
|
||||
```ts
|
||||
import { runVmHostBridgeConformance } from "@suaveplan/api-sandbox-vm-host-bridge/testing";
|
||||
|
||||
describe("api-plugin-example vm-sandboxed deployment", () => {
|
||||
runVmHostBridgeConformance("api-plugin-example",
|
||||
buildBridgeForExamplePlugin);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BuildBridgeOptions` — `source`, `checkCapability?`.
|
||||
- `interface BuildBridgeResult` — `bridged` (`BridgedPlugin`),
|
||||
`dispose()`.
|
||||
- `type BuildBridge = (options) => Promise<BuildBridgeResult>`.
|
||||
- `runVmHostBridgeConformance(label: string, buildBridge:
|
||||
BuildBridge): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately tolerates either `SAND_VM.PERMISSION_DENIED`
|
||||
(the underlying sandbox's own code) or `SAND_PERM.DENIED` (the
|
||||
shared permission-package's code) as the cause-chain leaf for the
|
||||
capability-gate scenario. Both indicate a permission-denial path; the
|
||||
test fails only when a denied capability silently completes (i.e. the
|
||||
handler returns 200 without raising at all).
|
||||
|
||||
`makeCtx` synthesises only the fields a marshal-stage actually
|
||||
references — full `RequestContext` construction would pull in
|
||||
production-only fixtures (OTel span factory, secrets adapter) that
|
||||
the harness has no business owning.
|
||||
Reference in New Issue
Block a user