Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# @suaveplan/api-worker-host - Worker-side runtime that hosts exactly one Suaveplan API plugin inside an isolated Worker thread. Provides the bootstrap entry point that handshakes with the main host, wires capability proxies via @suaveplan/api-worker-rpc, dispatches lifecycle commands (install/start/stop/uninstall/reload) to plugin hooks, and surfaces uncaught exceptions as WORKER_HOST.CRASHED envelopes so the main host's LifecycleDriver can transition the plugin to the crashed state.
Worker-side runtime that hosts exactly one Suaveplan API plugin inside
an isolated `Worker` thread. The main-process counterpart is
`@suaveplan/api-host`; this package owns everything that runs *inside*
the worker: bootstrap handshake, capability proxy wiring, lifecycle
command dispatch, and uncaught-exception → `WORKER_HOST.CRASHED`
propagation.
This is API plugin system (design doc §2.2, §5.2a).
The transport layer (`@suaveplan/api-worker-rpc`) is consumed here for
every cross-boundary frame.
<!-- begin:auto-docs -->
## Documentation tree
- [bootstrap](./src/bootstrap/bootstrap.md) — `bootstrapWorkerPlugin` is the single entry point a worker thread invokes to host exactly one Suaveplan API plugin. It…
- [bootstrap](./src/bootstrap/bootstrap.md) — `bootstrapWorkerPlugin` is the single entry point a worker thread invokes to host exactly one Suaveplan API plugin. It…
- [capability-proxies](./src/capability-proxies/capability-proxies.md) — This module turns the capability-handle descriptor table delivered by the main host into a live, frozen map of RPC-back…
- [capability-proxies](./src/capability-proxies/capability-proxies.md) — This module turns the capability-handle descriptor table delivered by the main host into a live, frozen map of RPC-back…
- [crash-detection](./src/crash-detection/crash-detection.md) — This module installs the worker-side safety net that turns silent process death into observable crash telemetry. Withou…
- [crash-detection](./src/crash-detection/crash-detection.md) — This module installs the worker-side safety net that turns silent process death into observable crash telemetry. Withou…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the namespaced `WORKER_HOST.*` error registry consumed by the worker-side runtime that hosts exactly o…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the namespaced `WORKER_HOST.*` error registry consumed by the worker-side runtime that hosts exactly o…
- [lifecycle](./src/lifecycle/lifecycle.md) — This module hosts the worker-side counterpart to the main host's `LifecycleDriver`. It owns the worker's nine-state lif…
- [lifecycle](./src/lifecycle/lifecycle.md) — This module hosts the worker-side counterpart to the main host's `LifecycleDriver`. It owns the worker's nine-state lif…
- [protocol-extension](./src/protocol-extension/protocol-extension.md) — This module defines the Zod schemas that the worker host layers on top of the transport-level frame protocol owned by `…
- [protocol-extension](./src/protocol-extension/protocol-extension.md) — This module defines the Zod schemas that the worker host layers on top of the transport-level frame protocol owned by `…
- [testing](./src/testing/testing.md) — `runWorkerHostConformance` spins up an in-process worker-shaped peer using paired `MessageChannel` ports. It exists so…
- [testing](./src/testing/testing.md) — `runWorkerHostConformance` spins up an in-process worker-shaped peer using paired `MessageChannel` ports. It exists so…
<!-- end:auto-docs -->
## Layout
- [bootstrap](./src/bootstrap/bootstrap.md) — `bootstrapWorkerPlugin`
validates the manifest + capability handles, builds the proxy map,
mounts the lifecycle RPC server, posts the `worker-ready` frame.
- [capability-proxies](./src/capability-proxies/capability-proxies.md) —
`installCapabilityProxies` builds frozen RPC-backed capability slots;
`getCapability` enforces declared capabilities.
- [lifecycle](./src/lifecycle/lifecycle.md) — `createLifecycleReceiver`
owns the nine-state worker-side FSM and the `LifecycleAck` envelope.
- [crash-detection](./src/crash-detection/crash-detection.md) —
`installCrashDetector` wires `uncaughtException` /
`unhandledRejection` to a `WORKER_HOST.CRASHED` envelope before
exiting.
- [protocol-extension](./src/protocol-extension/protocol-extension.md) —
Zod schemas for every worker-host envelope.
- [error-codes](./src/error-codes/error-codes.md) — nine stable
`WORKER_HOST.*` codes.
- [testing](./src/testing/testing.md) —
`runWorkerHostConformance` in-process simulator.
## Stability
- **Version**: `0.1.0` (Original).
- **Tier**: server (requires Node `process` + `MessageChannel`; Node
18+).
- **Dependencies**: `@suaveplan/api-types`,
`@suaveplan/api-worker-rpc`, `@suaveplan/api-lifecycle`,
`@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`,
`tslib`, `zod`.
## Verification
```bash
bunx turbo lint typecheck test build --filter=@suaveplan/api-worker-host
bunx turbo test:coverage --filter=@suaveplan/api-worker-host
```
All four coverage dimensions land at 100/100/100/100.

View File

@@ -0,0 +1,116 @@
{
"name": "@suaveplan/api-worker-host",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Worker-side runtime that hosts exactly one Suaveplan API plugin inside an isolated Worker thread. Provides the bootstrap entry point that handshakes with the main host, wires capability proxies via @suaveplan/api-worker-rpc, dispatches lifecycle commands (install/start/stop/uninstall/reload) to plugin hooks, and surfaces uncaught exceptions as WORKER_HOST.CRASHED envelopes so the main host's LifecycleDriver can transition the plugin to the crashed state.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=20"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bootstrap": {
"import": {
"types": "./dist/bootstrap/bootstrap.d.ts",
"default": "./dist/bootstrap/bootstrap.js"
}
},
"./capability-proxies": {
"import": {
"types": "./dist/capability-proxies/capability-proxies.d.ts",
"default": "./dist/capability-proxies/capability-proxies.js"
}
},
"./lifecycle": {
"import": {
"types": "./dist/lifecycle/lifecycle.d.ts",
"default": "./dist/lifecycle/lifecycle.js"
}
},
"./crash-detection": {
"import": {
"types": "./dist/crash-detection/crash-detection.d.ts",
"default": "./dist/crash-detection/crash-detection.js"
}
},
"./protocol-extension": {
"import": {
"types": "./dist/protocol-extension/protocol-extension.d.ts",
"default": "./dist/protocol-extension/protocol-extension.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-lifecycle": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
],
"suaveplan": {
"conformanceSkip": [
"api-lifecycle"
],
"conformanceSkipReason": "api-worker-host runs the WORKER-side lifecycle FSM which is intentionally different from the main-host FSM (it lacks `installing`/`uninstalling` transitions since those happen main-side). Reusing main's lifecycle conformance harness would falsely fail; worker-side lifecycle is verified by api-worker-host's own bootstrap/lifecycle tests."
}
}

View File

@@ -0,0 +1,100 @@
# Worker bootstrap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bootstrapWorkerPlugin` is the single entry point a worker thread
invokes to host exactly one Suaveplan API plugin. It validates the
manifest envelope and capability descriptor table at the wire
boundary, builds the capability proxy map from a freshly-created
`WorkerRpcClient`, instantiates the lifecycle receiver wrapping the
plugin's hooks, mounts a `serveWorkerRpc` handler set on the control
port that exposes `lifecycle.dispatch`, `host.manifest` and
`host.state`, then posts the `worker-ready` frame back so the main
host knows the worker is live. Any wiring failure surfaces as a
typed `WorkerHostError` carrying `WORKER_HOST.MANIFEST_INVALID` or
`WORKER_HOST.BOOTSTRAP_FAILED` so the main host's
`LifecycleDriver` can transition the plugin to `install_failed`.
## Features
- Pure function — no module-load side effects, no global mutation,
no implicit import of `worker_threads`.
- Two ports — `controlPort` for lifecycle, `capabilityPort` for
capability RPC; transports are abstracted through `PortLike` so
the same code paths run under real `MessageChannel` and Node's
`worker_threads` `MessagePort`.
- Returns a `WorkerHost` handle exposing the validated manifest,
the proxy map, the lifecycle receiver and a `close()` disposer.
- Optional `onReady` callback fires immediately after the ready
frame is posted — useful for in-process testing harnesses.
## Usage: Basic
```ts
import {
bootstrapWorkerPlugin,
} from "@suaveplan/api-worker-host/bootstrap";
const host = bootstrapWorkerPlugin({
manifest: {
name: "demo",
version: "0.1.0",
capabilities: ["db"],
tier: "isolated",
},
capabilityHandles: { db: { methods: ["query"] } },
hooks: { install: async () => {} },
controlPort: portFromMainHost,
capabilityPort: capabilityPortFromMainHost,
});
```
## Usage: Advanced
Compose the bootstrap with a real `MessageChannel`-based simulator
for unit tests:
```ts
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import {
bootstrapWorkerPlugin,
} from "@suaveplan/api-worker-host/bootstrap";
const control = createInProcessPortPair();
const caps = createInProcessPortPair();
const host = bootstrapWorkerPlugin({
manifest: { name: "x", version: "0.1.0", capabilities: [], tier: "trusted" },
capabilityHandles: {},
hooks: {},
controlPort: control.serverPort,
capabilityPort: caps.serverPort,
onReady: (frame) => console.log("ready:", frame.manifest.name),
});
host.close();
```
## API Reference
- `bootstrapWorkerPlugin(spec: BootstrapSpec): WorkerHost`
- `BootstrapSpec`:
- `manifest: ManifestEnvelope`
- `capabilityHandles: CapabilityHandles`
- `hooks: PluginLifecycleHooks`
- `controlPort: PortLike` / `capabilityPort: PortLike`
- `onReady?: (frame: WorkerReadyFrame) => void`
- `WorkerHost`: `manifest`, `capabilities`, `receiver`, `close()`
## Implementation Notes
The two ports are intentionally separated so capability RPC cannot
starve lifecycle control (or vice versa) — different queues,
different correlation-id namespaces, different backpressure budgets.
On failure the bootstrap closes any RPC client it already opened
before throwing, so the worker never leaks a half-attached
`MessagePort` listener. The ready frame is posted *after* every
piece of wiring succeeds; an early failure means no ready frame and
the main host can detect a stillborn worker via timeout.

View File

@@ -0,0 +1,285 @@
import {
createInProcessPortPair,
createRawWorkerRpcClient,
serveWorkerRpc,
} from "@suaveplan/api-worker-rpc";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import type { BootstrapSpec } from "./bootstrap.js";
import { bootstrapWorkerPlugin } from "./bootstrap.js";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) cleanups.pop()?.();
});
interface Rig {
spec: BootstrapSpec;
/** Port on the main-host side of the control channel. */
mainControlPort: ReturnType<typeof createInProcessPortPair>["clientPort"];
/** Port on the main-host side of the capability channel. */
mainCapabilityPort: ReturnType<
typeof createInProcessPortPair
>["clientPort"];
readyFrames: unknown[];
}
function rig(
overrides: Partial<BootstrapSpec> = {},
capabilityServer?: Parameters<typeof serveWorkerRpc>[1]
): Rig {
const control = createInProcessPortPair();
const caps = createInProcessPortPair();
cleanups.push(() => {
control.close();
caps.close();
});
if (capabilityServer !== undefined) {
const srv = serveWorkerRpc(caps.clientPort, capabilityServer);
cleanups.push(() => srv.close("rig.cleanup"));
}
const readyFrames: unknown[] = [];
control.clientPort.addEventListener("message", (e) => {
readyFrames.push(e.data);
});
const spec: BootstrapSpec = {
manifest: {
name: "demo",
version: "0.1.0",
capabilities: ["db"],
tier: "isolated",
},
capabilityHandles: { db: { methods: ["query"] } },
hooks: {},
controlPort: control.serverPort,
capabilityPort: caps.serverPort,
...overrides,
};
return {
spec,
mainControlPort: control.clientPort,
mainCapabilityPort: caps.clientPort,
readyFrames,
};
}
const waitTick = (): Promise<void> => new Promise((r) => setTimeout(r, 10));
describe("bootstrapWorkerPlugin", () => {
it("validates the manifest and returns the host handle", {
timeout: 5000,
}, () => {
const r = rig();
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
expect(host.manifest.name).toBe("demo");
expect(host.receiver.state()).toBe("loaded");
});
it("posts the worker-ready frame after wiring", {
timeout: 5000,
}, async () => {
const r = rig();
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
await waitTick();
expect(r.readyFrames).toContainEqual({
kind: "worker-ready",
manifest: r.spec.manifest,
});
});
it("invokes onReady when supplied", { timeout: 5000 }, () => {
const observed: unknown[] = [];
const r = rig({ onReady: (f) => observed.push(f) });
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
expect(observed).toEqual([
{ kind: "worker-ready", manifest: r.spec.manifest },
]);
});
it("rejects manifest with MANIFEST_INVALID", { timeout: 5000 }, () => {
const r = rig({
// @ts-expect-error — invalid tier surfaces error path.
manifest: {
name: "x",
version: "0.1.0",
capabilities: [],
tier: "weird",
},
});
try {
bootstrapWorkerPlugin(r.spec);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(WorkerHostError);
expect((err as WorkerHostError).code).toBe(
WORKER_HOST_CODES.MANIFEST_INVALID
);
}
});
it("rejects malformed capability handles with BOOTSTRAP_FAILED", {
timeout: 5000,
}, () => {
const r = rig({
// @ts-expect-error — invalid handles shape.
capabilityHandles: { db: "not-a-descriptor" },
});
try {
bootstrapWorkerPlugin(r.spec);
throw new Error("should have thrown");
} catch (err) {
expect((err as WorkerHostError).code).toBe(
WORKER_HOST_CODES.BOOTSTRAP_FAILED
);
}
});
it("dispatches lifecycle.dispatch over the control port", {
timeout: 5000,
}, async () => {
const called: string[] = [];
const r = rig({
hooks: {
install: () => {
called.push("install");
},
},
});
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
const mainClient = createRawWorkerRpcClient(r.mainControlPort);
cleanups.push(() => mainClient.close());
const ack = (await mainClient.call("lifecycle.dispatch", [
{ command: "install" },
])) as { ok: boolean };
expect(ack.ok).toBe(true);
expect(called).toEqual(["install"]);
expect(host.receiver.state()).toBe("installed");
});
it("dispatches host.manifest and host.state over the control port", {
timeout: 5000,
}, async () => {
const r = rig();
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
const mainClient = createRawWorkerRpcClient(r.mainControlPort);
cleanups.push(() => mainClient.close());
const manifest = await mainClient.call("host.manifest", []);
expect(manifest).toEqual(r.spec.manifest);
const state = await mainClient.call("host.state", []);
expect(state).toBe("loaded");
});
it("BOOTSTRAP_FAILED when capability port wiring throws", {
timeout: 5000,
}, () => {
const r = rig();
const failingPort = {
postMessage: () => {},
addEventListener: () => {
throw new Error("port broken");
},
removeEventListener: () => {},
};
try {
bootstrapWorkerPlugin({
...r.spec,
capabilityPort: failingPort,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as WorkerHostError).code).toBe(
WORKER_HOST_CODES.BOOTSTRAP_FAILED
);
expect((err as WorkerHostError).message).toContain(
"capability RPC client"
);
}
});
it("BOOTSTRAP_FAILED when control port serve throws (and closes the capability client)", {
timeout: 5000,
}, () => {
const r = rig();
let attached = false;
const failingControl = {
postMessage: () => {},
addEventListener: () => {
if (attached) {
throw new Error("second attach denied");
}
attached = true;
},
removeEventListener: () => {},
};
// First addEventListener on the capability port succeeds (no-op); the
// server then attempts addEventListener on controlPort and throws —
// wait, both ports are the same here. We need a distinct control port
// that throws while the capability port succeeds.
const okCapsPair = createInProcessPortPair();
cleanups.push(() => okCapsPair.close());
const failingControlPort = {
postMessage: () => {},
addEventListener: () => {
throw new Error("control broken");
},
removeEventListener: () => {},
};
try {
bootstrapWorkerPlugin({
...r.spec,
controlPort: failingControlPort,
capabilityPort: okCapsPair.serverPort,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as WorkerHostError).code).toBe(
WORKER_HOST_CODES.BOOTSTRAP_FAILED
);
expect((err as WorkerHostError).message).toContain(
"lifecycle RPC server"
);
}
// Silence unused warning.
void attached;
void failingControl;
});
it("capability proxy round-trips against capabilityPort", {
timeout: 5000,
}, async () => {
const observed: Array<{
method: string;
params: ReadonlyArray<unknown>;
}> = [];
const r = rig(
{},
{
"db.query": (params) => {
observed.push({ method: "db.query", params });
return { rows: [params[0]] };
},
}
);
const host = bootstrapWorkerPlugin(r.spec);
cleanups.push(() => host.close());
const dbQuery = host.capabilities.db?.query;
if (dbQuery === undefined) throw new Error("missing");
const result = await dbQuery("SELECT 1");
expect(result).toEqual({ rows: ["SELECT 1"] });
expect(observed).toEqual([
{ method: "db.query", params: ["SELECT 1"] },
]);
});
});

View File

@@ -0,0 +1,135 @@
import type { PortLike, WorkerRpcServer } from "@suaveplan/api-worker-rpc";
import {
createRawWorkerRpcClient,
serveWorkerRpc,
type WorkerRpcClient,
} from "@suaveplan/api-worker-rpc";
import {
type CapabilityProxyMap,
installCapabilityProxies,
} from "../capability-proxies/capability-proxies.js";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import {
createLifecycleReceiver,
type LifecycleReceiver,
type PluginLifecycleHooks,
} from "../lifecycle/lifecycle.js";
import {
type CapabilityHandles,
capabilityHandlesSchema,
type ManifestEnvelope,
manifestEnvelopeSchema,
type WorkerReadyFrame,
} from "../protocol-extension/protocol-extension.js";
/**
* Worker-side bootstrap input. The main host hands the worker a fully
* pre-built object containing the manifest envelope, capability
* descriptor table, and the loaded plugin module — the contract is
* "everything I need to host one plugin, validated".
*/
export interface BootstrapSpec {
/** Plugin manifest, validated at the boundary. */
readonly manifest: ManifestEnvelope;
/** Capability descriptor table, validated at the boundary. */
readonly capabilityHandles: CapabilityHandles;
/** Plugin module hooks (the `default export` of `definePlugin`). */
readonly hooks: PluginLifecycleHooks;
/** Control port — lifecycle commands flow over this. */
readonly controlPort: PortLike;
/** Capability RPC port — capability calls flow over this. */
readonly capabilityPort: PortLike;
/** Optional hook invoked when `worker-ready` is published. */
readonly onReady?: (frame: WorkerReadyFrame) => void;
}
/** Live handles owned by the bootstrap call. */
export interface WorkerHost {
readonly manifest: ManifestEnvelope;
readonly capabilities: CapabilityProxyMap;
readonly receiver: LifecycleReceiver;
readonly close: () => void;
}
/**
* Boot the worker-side plugin runtime. Validates manifest + handles
* with Zod, builds the capability proxy table over the supplied
* capability port, registers a lifecycle RPC handler on the control
* port, and publishes the `worker-ready` frame so the main host knows
* the worker is alive.
*
* `bootstrapWorkerPlugin` throws `WORKER_HOST.BOOTSTRAP_FAILED` on
* unexpected wiring errors and `WORKER_HOST.MANIFEST_INVALID` on
* schema rejection so the main host can transition the plugin to
* `install_failed`.
*/
export function bootstrapWorkerPlugin(spec: BootstrapSpec): WorkerHost {
let manifest: ManifestEnvelope;
try {
manifest = manifestEnvelopeSchema.parse(spec.manifest);
} catch (cause) {
throw new WorkerHostError(
WORKER_HOST_CODES.MANIFEST_INVALID,
"manifest envelope failed schema validation",
{ cause }
);
}
let handles: CapabilityHandles;
try {
handles = capabilityHandlesSchema.parse(spec.capabilityHandles);
} catch (cause) {
throw new WorkerHostError(
WORKER_HOST_CODES.BOOTSTRAP_FAILED,
"capability handles failed schema validation",
{ cause }
);
}
let capabilityClient: WorkerRpcClient;
try {
capabilityClient = createRawWorkerRpcClient(spec.capabilityPort);
} catch (cause) {
throw new WorkerHostError(
WORKER_HOST_CODES.BOOTSTRAP_FAILED,
"failed to wire capability RPC client",
{ cause }
);
}
const capabilities = installCapabilityProxies(handles, capabilityClient);
const receiver = createLifecycleReceiver({ hooks: spec.hooks });
let server: WorkerRpcServer;
try {
server = serveWorkerRpc(spec.controlPort, {
"lifecycle.dispatch": async (params) =>
receiver.dispatch(params[0]),
"host.manifest": () => manifest,
"host.state": () => receiver.state(),
});
} catch (cause) {
capabilityClient.close();
throw new WorkerHostError(
WORKER_HOST_CODES.BOOTSTRAP_FAILED,
"failed to mount lifecycle RPC server on control port",
{ cause }
);
}
const readyFrame: WorkerReadyFrame = { kind: "worker-ready", manifest };
spec.controlPort.postMessage(readyFrame);
spec.onReady?.(readyFrame);
return {
manifest,
capabilities,
receiver,
close: () => {
server.close("bootstrap.close");
capabilityClient.close();
},
};
}

View File

@@ -0,0 +1,2 @@
export type { BootstrapSpec, WorkerHost } from "./bootstrap.js";
export { bootstrapWorkerPlugin } from "./bootstrap.js";

View File

@@ -0,0 +1,82 @@
# Capability proxies (worker side)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module turns the capability-handle descriptor table delivered by
the main host into a live, frozen map of RPC-backed capability slots
that the worker host hands to the plugin via its `PluginContext`. Every
slot method, regardless of how it looked on the trusted-tier surface,
becomes an async function whose body is a single `client.call(...)`
into `@suaveplan/api-worker-rpc`. The plugin author cannot — by design
— tell whether the capability they hold is in-process or proxied across
the worker boundary; the structural shape is identical.
## Features
- `installCapabilityProxies(handles, client)` — validates the handle
table with Zod, then builds a frozen
`Record<capability, Record<method, fn>>` table; each method dispatches
via `client.call("<capability>.<method>", args)`.
- `getCapability(table, name)` — explicit accessor that raises
`WORKER_HOST.CAPABILITY_HANDLE_MISSING` when the plugin asks for a
capability the main host never declared, so missing wiring fails
loudly rather than producing `undefined` at runtime.
- Slot objects are `Object.create(null)` + `Object.freeze` to deny
accidental mutation and prototype-pollution surface.
## Usage: Basic
```ts
import { createWorkerRpcClient } from "@suaveplan/api-worker-rpc";
import {
installCapabilityProxies,
} from "@suaveplan/api-worker-host/capability-proxies";
const client = createWorkerRpcClient(controlPort);
const caps = installCapabilityProxies(
{ db: { methods: ["query"] } },
client.client
);
await caps.db?.query?.("SELECT 1");
```
## Usage: Advanced
Use `getCapability` inside the plugin context builder so unwired
capabilities never reach handler code:
```ts
import {
getCapability,
installCapabilityProxies,
} from "@suaveplan/api-worker-host/capability-proxies";
const proxies = installCapabilityProxies(handles, client);
const ctx = {
capabilities: {
db: getCapability(proxies, "db"),
logger: getCapability(proxies, "logger"),
},
};
```
## API Reference
- `installCapabilityProxies(handles: CapabilityHandles, client: WorkerRpcClient): CapabilityProxyMap`
- `getCapability(proxies: CapabilityProxyMap, name: string): CapabilityProxy`
- `CapabilityProxy` — `Record<string, (...args) => Promise<unknown>>`
- `CapabilityProxyMap` — `Readonly<Record<string, CapabilityProxy>>`
## Implementation Notes
The wire method name is `"<capability>.<method>"` rather than just
`method` so the main host's RPC server can route each call to the
correct capability adapter without keeping per-channel state. Returning
frozen objects also means consumers of the proxy table cannot
accidentally graft synchronous methods onto a slot — which would break
the trusted-vs-isolated structural-equivalence requirement in spec
delta §"Plugin code cannot distinguish trusted from isolated tier".

View File

@@ -0,0 +1,139 @@
import {
createInProcessPortPair,
createRawWorkerRpcClient,
serveWorkerRpc,
} from "@suaveplan/api-worker-rpc";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import {
getCapability,
installCapabilityProxies,
} from "./capability-proxies.js";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
interface Setup {
callsObserved: Array<{ method: string; params: ReadonlyArray<unknown> }>;
proxies: ReturnType<typeof installCapabilityProxies>;
}
function setup(handles: Parameters<typeof installCapabilityProxies>[0]): Setup {
const pair = createInProcessPortPair();
const callsObserved: Array<{
method: string;
params: ReadonlyArray<unknown>;
}> = [];
const server = serveWorkerRpc(pair.serverPort, {
"db.query": (params) => {
callsObserved.push({ method: "db.query", params });
return { rows: params[0] };
},
"db.exec": (params) => {
callsObserved.push({ method: "db.exec", params });
return { ok: true };
},
"logger.info": (params) => {
callsObserved.push({ method: "logger.info", params });
return undefined;
},
});
const client = createRawWorkerRpcClient(pair.clientPort);
const proxies = installCapabilityProxies(handles, client);
cleanups.push(() => {
client.close();
server.close();
pair.close();
});
return { callsObserved, proxies };
}
describe("installCapabilityProxies", () => {
it("dispatches a capability method through the rpc client", {
timeout: 5000,
}, async () => {
const { proxies, callsObserved } = setup({
db: { methods: ["query", "exec"] },
});
const dbSlot = proxies.db;
expect(dbSlot).toBeDefined();
if (dbSlot === undefined) throw new Error("dbSlot missing");
const queryFn = dbSlot.query;
if (queryFn === undefined) throw new Error("query fn missing");
const result = await queryFn("SELECT 1");
expect(result).toEqual({ rows: "SELECT 1" });
expect(callsObserved).toEqual([
{ method: "db.query", params: ["SELECT 1"] },
]);
});
it("freezes both the table and each slot", { timeout: 5000 }, () => {
const { proxies } = setup({ db: { methods: ["query"] } });
expect(Object.isFrozen(proxies)).toBe(true);
expect(Object.isFrozen(proxies.db)).toBe(true);
});
it("supports multi-capability tables", { timeout: 5000 }, async () => {
const { proxies, callsObserved } = setup({
db: { methods: ["exec"] },
logger: { methods: ["info"] },
});
const execFn = proxies.db?.exec;
const infoFn = proxies.logger?.info;
if (execFn === undefined || infoFn === undefined) {
throw new Error("methods missing");
}
await execFn("CREATE TABLE x");
await infoFn("hello");
expect(callsObserved.map((c) => c.method)).toEqual([
"db.exec",
"logger.info",
]);
});
it("rejects an invalid handles table at the boundary", {
timeout: 5000,
}, () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const client = createRawWorkerRpcClient(pair.clientPort);
cleanups.push(() => client.close());
expect(() =>
// @ts-expect-error — intentionally invalid for boundary check.
installCapabilityProxies(
{ db: { methods: "not-an-array" } },
client
)
).toThrow();
});
});
describe("getCapability", () => {
it("returns the wired slot", { timeout: 5000 }, () => {
const { proxies } = setup({ db: { methods: ["query"] } });
const slot = getCapability(proxies, "db");
expect(typeof slot.query).toBe("function");
});
it("raises CAPABILITY_HANDLE_MISSING for unknown name", {
timeout: 5000,
}, () => {
const { proxies } = setup({ db: { methods: ["query"] } });
try {
getCapability(proxies, "missing");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(WorkerHostError);
const we = err as WorkerHostError;
expect(we.code).toBe(WORKER_HOST_CODES.CAPABILITY_HANDLE_MISSING);
expect(we.context).toEqual({ capability: "missing" });
}
});
});

View File

@@ -0,0 +1,75 @@
import type { WorkerRpcClient } from "@suaveplan/api-worker-rpc";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import {
type CapabilityHandles,
capabilityHandlesSchema,
} from "../protocol-extension/protocol-extension.js";
/**
* A runtime capability slot — the worker-side stand-in for a capability
* the plugin author imports. Every method is async (the wire boundary
* means even synchronous-looking calls return a `Promise`), and every
* method routes through a single shared {@link WorkerRpcClient}.
*
* `Record<string, (...args: ReadonlyArray<unknown>) => Promise<unknown>>`
* is the structural shape every proxy conforms to. Generic
* `PluginContext<Caps>` typing happens at the plugin author's boundary;
* inside the worker host we operate on the dynamic shape.
*/
export type CapabilityProxy = Record<
string,
(...args: ReadonlyArray<unknown>) => Promise<unknown>
>;
/** Mapping from capability name to the wired proxy object. */
export type CapabilityProxyMap = Readonly<Record<string, CapabilityProxy>>;
/**
* Build a capability proxy table from a validated handle descriptor
* and a shared RPC client. The RPC method name dispatched on the wire
* is `"<capability>.<method>"` so the main host can route to the right
* capability adapter regardless of which capability owns it.
*/
export function installCapabilityProxies(
handles: CapabilityHandles,
client: WorkerRpcClient
): CapabilityProxyMap {
const validated = capabilityHandlesSchema.parse(handles);
const out: Record<string, CapabilityProxy> = Object.create(null);
for (const [capability, descriptor] of Object.entries(validated)) {
const slot: Record<
string,
(...args: ReadonlyArray<unknown>) => Promise<unknown>
> = Object.create(null);
for (const method of descriptor.methods) {
slot[method] = (
...args: ReadonlyArray<unknown>
): Promise<unknown> => client.call(`${capability}.${method}`, args);
}
out[capability] = Object.freeze(slot) as CapabilityProxy;
}
return Object.freeze(out) as CapabilityProxyMap;
}
/**
* Resolve a capability slot from the proxy table. Raises
* `WORKER_HOST.CAPABILITY_HANDLE_MISSING` when the plugin asked for a
* capability the main host never advertised.
*/
export function getCapability(
proxies: CapabilityProxyMap,
name: string
): CapabilityProxy {
const slot = proxies[name];
if (slot === undefined) {
throw new WorkerHostError(
WORKER_HOST_CODES.CAPABILITY_HANDLE_MISSING,
`no capability handle wired for "${name}"`,
{ context: { capability: name } }
);
}
return slot;
}

View File

@@ -0,0 +1,8 @@
export type {
CapabilityProxy,
CapabilityProxyMap,
} from "./capability-proxies.js";
export {
getCapability,
installCapabilityProxies,
} from "./capability-proxies.js";

View File

@@ -0,0 +1,92 @@
# Worker crash detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module installs the worker-side safety net that turns silent
process death into observable crash telemetry. Without it, a plugin's
uncaught synchronous throw or unhandled promise rejection would tear
the Node `Worker` down with no signal to the main host's
`LifecycleDriver` — leaving the plugin stuck in `started` until the
control port's `close` event eventually fires (which is too late to
attribute the failure to a specific plugin handler). The detector
captures both `uncaughtException` and `unhandledRejection`, posts a
`WORKER_HOST.CRASHED` envelope over the control port, then invokes the
configured termination hook (`process.exit(1)` by default).
## Features
- `installCrashDetector({ controlPort, process?, onTerminate? })` —
returns a `CrashDetector` with `close()` + `trigger(cause)`.
- Structural `ProcessLike` surface — unit tests substitute a fake
process so the real test runner does not exit.
- `onTerminate` override — tests record the intended exit code
instead of actually exiting.
- `trigger(cause)` lets the lifecycle layer escalate a fatal
bootstrap error to the same code path as an uncaught throw.
- Reports are wrapped in a typed `WorkerHostError(CRASHED, ..., {
cause })` so the original throwable is preserved via the wire
`cause` field after reconstruction on the main host.
## Usage: Basic
```ts
import {
installCrashDetector,
} from "@suaveplan/api-worker-host/crash-detection";
const detector = installCrashDetector({ controlPort });
// later
detector.close();
```
## Usage: Advanced
Inject a fake `process` for deterministic testing — handlers fire
synchronously, exits are recorded:
```ts
import {
installCrashDetector,
} from "@suaveplan/api-worker-host/crash-detection";
let handler: ((err: Error) => void) | undefined;
const fakeProcess = {
on: (_: string, l: (err: Error) => void) => { handler = l; },
off: () => {},
exit: () => {},
};
const exits: number[] = [];
installCrashDetector({
controlPort: port,
process: fakeProcess as never,
onTerminate: (code) => exits.push(code),
});
handler?.(new Error("boom"));
```
## API Reference
- `installCrashDetector(opts: CrashDetectorOptions): CrashDetector`
- `CrashDetectorOptions`:
- `controlPort: PortLike` — required
- `process?: ProcessLike`
- `onTerminate?: (exitCode: number) => void`
- `CrashDetector`:
- `close(): void`
- `trigger(cause: unknown): void`
- `ProcessLike` — `{ on, off, exit }` subset of Node `process`.
## Implementation Notes
`postMessage` is wrapped in a try/catch because the port may already
be closing by the time a crash fires — best effort there, no nested
crash on top of the original. The `close()` flag also guards against
re-entrant invocations triggered by the very crash handlers being
detached. The default `process` lookup is a `globalThis`-based read
so the module never imports `node:process` at module load — keeping
it tree-shake-clean for any future browser-tier consumer that
substitutes its own `ProcessLike`.

View File

@@ -0,0 +1,210 @@
import {
createInProcessPortPair,
type PortLike,
} from "@suaveplan/api-worker-rpc";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import type { ProcessLike } from "./crash-detection.js";
import { installCrashDetector } from "./crash-detection.js";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) cleanups.pop()?.();
});
interface FakeProc {
proc: ProcessLike;
fireException: (err: Error) => void;
fireRejection: (reason: unknown) => void;
exits: number[];
handlersAttached: number;
}
function fakeProcess(): FakeProc {
let onException: ((err: Error) => void) | undefined;
let onRejection: ((reason: unknown) => void) | undefined;
const exits: number[] = [];
let attached = 0;
const proc: ProcessLike = {
on: ((event: string, l: (arg: unknown) => void) => {
attached++;
if (event === "uncaughtException") {
onException = l as (err: Error) => void;
} else if (event === "unhandledRejection") {
onRejection = l as (reason: unknown) => void;
}
}) as ProcessLike["on"],
off: ((event: string, l: (arg: unknown) => void) => {
attached--;
if (event === "uncaughtException" && onException === l) {
onException = undefined;
} else if (event === "unhandledRejection" && onRejection === l) {
onRejection = undefined;
}
}) as ProcessLike["off"],
exit: (code: number) => exits.push(code),
};
return {
proc,
fireException: (err) => onException?.(err),
fireRejection: (reason) => onRejection?.(reason),
exits,
get handlersAttached(): number {
return attached;
},
};
}
function captureFromPort(port: PortLike): unknown[] {
const captured: unknown[] = [];
port.addEventListener("message", (e) => captured.push(e.data));
return captured;
}
describe("installCrashDetector", () => {
it("emits WORKER_HOST.CRASHED envelope on uncaughtException", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const captured = captureFromPort(pair.clientPort);
const fp = fakeProcess();
const exits: number[] = [];
installCrashDetector({
controlPort: pair.serverPort,
process: fp.proc,
onTerminate: (code) => exits.push(code),
});
fp.fireException(new Error("boom"));
await new Promise((r) => setTimeout(r, 10));
expect(exits).toEqual([1]);
const report = captured[0] as {
kind: string;
error: { code: string; cause?: { message: string } };
};
expect(report.kind).toBe("crashed");
expect(report.error.code).toBe(WORKER_HOST_CODES.CRASHED);
expect(report.error.cause?.message).toBe("boom");
});
it("emits envelope on unhandledRejection", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const captured = captureFromPort(pair.clientPort);
const fp = fakeProcess();
const exits: number[] = [];
installCrashDetector({
controlPort: pair.serverPort,
process: fp.proc,
onTerminate: (code) => exits.push(code),
});
fp.fireRejection(new Error("rej"));
await new Promise((r) => setTimeout(r, 10));
expect(exits).toEqual([1]);
expect(captured.length).toBe(1);
});
it("trigger() escalates a fatal cause synchronously", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const captured = captureFromPort(pair.clientPort);
const fp = fakeProcess();
const exits: number[] = [];
const detector = installCrashDetector({
controlPort: pair.serverPort,
process: fp.proc,
onTerminate: (code) => exits.push(code),
});
detector.trigger(new Error("manual"));
await new Promise((r) => setTimeout(r, 10));
expect(exits).toEqual([1]);
expect(captured.length).toBe(1);
});
it("close() detaches the listeners and ignores subsequent triggers", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const captured = captureFromPort(pair.clientPort);
const fp = fakeProcess();
const exits: number[] = [];
const detector = installCrashDetector({
controlPort: pair.serverPort,
process: fp.proc,
onTerminate: (code) => exits.push(code),
});
expect(fp.handlersAttached).toBe(2);
detector.close();
detector.close(); // idempotent
expect(fp.handlersAttached).toBe(0);
detector.trigger(new Error("after-close"));
await new Promise((r) => setTimeout(r, 10));
expect(exits).toEqual([]);
expect(captured).toEqual([]);
});
it("falls back to process.exit when onTerminate is omitted", {
timeout: 5000,
}, () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
const fp = fakeProcess();
installCrashDetector({
controlPort: pair.serverPort,
process: fp.proc,
});
fp.fireException(new Error("boom"));
expect(fp.exits).toEqual([1]);
});
it("survives postMessage throws (port already closing)", {
timeout: 5000,
}, () => {
const fp = fakeProcess();
const exits: number[] = [];
const failingPort: PortLike = {
postMessage: () => {
throw new Error("port closed");
},
addEventListener: () => {},
removeEventListener: () => {},
};
installCrashDetector({
controlPort: failingPort,
process: fp.proc,
onTerminate: (c) => exits.push(c),
});
fp.fireException(new Error("boom"));
expect(exits).toEqual([1]);
});
it("throws BOOTSTRAP_FAILED when no process surface is available", {
timeout: 5000,
}, () => {
const pair = createInProcessPortPair();
cleanups.push(() => pair.close());
// Hide the global process so the fallback path triggers.
const g = globalThis as { process?: ProcessLike };
const original = g.process;
Object.defineProperty(g, "process", {
value: undefined,
configurable: true,
});
try {
expect(() =>
installCrashDetector({ controlPort: pair.serverPort })
).toThrow(WorkerHostError);
} finally {
Object.defineProperty(g, "process", {
value: original,
configurable: true,
});
}
});
});

View File

@@ -0,0 +1,99 @@
import type { PortLike } from "@suaveplan/api-worker-rpc";
import { errorToEnvelope } from "@suaveplan/api-worker-rpc";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import type { CrashReport } from "../protocol-extension/protocol-extension.js";
/**
* Subset of the Node `process` surface this module touches. Declared
* structurally so the module is unit-testable without mocking and so a
* future browser-tier consumer could substitute an equivalent.
*/
export interface ProcessLike {
on(event: "uncaughtException", listener: (err: Error) => void): void;
on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
off(event: "uncaughtException", listener: (err: Error) => void): void;
off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
exit(code: number): void;
}
export interface CrashDetectorOptions {
/** Control port — crash reports are posted here before exit. */
readonly controlPort: PortLike;
/**
* Process-like surface. Defaults to the global `process` when the
* detector is started in a real worker; tests inject a fake.
*/
readonly process?: ProcessLike;
/**
* Hook invoked instead of `process.exit` after a crash report is
* dispatched. Defaults to `process.exit(1)`. Tests inject a
* recorder so the test runner doesn't tear itself down.
*/
readonly onTerminate?: (exitCode: number) => void;
}
export interface CrashDetector {
/** Detach the registered listeners. Safe to call more than once. */
readonly close: () => void;
/** Trigger a crash report manually (used in tests + reload errors). */
readonly trigger: (cause: unknown) => void;
}
/**
* Install `uncaughtException` and `unhandledRejection` handlers that
* emit a `WORKER_HOST.CRASHED` envelope over the control port and
* then terminate the worker. Returns a disposer that detaches the
* handlers so subsequent crashes do not double-fire.
*/
export function installCrashDetector(
opts: CrashDetectorOptions
): CrashDetector {
const proc =
opts.process ?? (globalThis as { process?: ProcessLike }).process;
if (proc === undefined) {
throw new WorkerHostError(
WORKER_HOST_CODES.BOOTSTRAP_FAILED,
"no process-like surface available to install crash handlers"
);
}
let closed = false;
const emit = (cause: unknown): void => {
if (closed) return;
const wrapped = new WorkerHostError(
WORKER_HOST_CODES.CRASHED,
"worker crashed",
{ cause }
);
const report: CrashReport = {
kind: "crashed",
error: errorToEnvelope(wrapped),
};
try {
opts.controlPort.postMessage(report);
} catch {
// Port may already be closing — best effort only.
}
const exit = opts.onTerminate ?? ((code: number) => proc.exit(code));
exit(1);
};
const onException = (err: Error): void => emit(err);
const onRejection = (reason: unknown): void => emit(reason);
proc.on("uncaughtException", onException);
proc.on("unhandledRejection", onRejection);
return {
close: () => {
if (closed) return;
closed = true;
proc.off("uncaughtException", onException);
proc.off("unhandledRejection", onRejection);
},
trigger: (cause) => emit(cause),
};
}

View File

@@ -0,0 +1,6 @@
export type {
CrashDetector,
CrashDetectorOptions,
ProcessLike,
} from "./crash-detection.js";
export { installCrashDetector } from "./crash-detection.js";

View File

@@ -0,0 +1,88 @@
# Worker host error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the namespaced `WORKER_HOST.*` error registry consumed by
the worker-side runtime that hosts exactly one Suaveplan API plugin. Every
failure path that originates inside the worker host itself — boot failure,
malformed manifest envelope, missing capability handle, lifecycle hook
throw, handshake timeout, uncaught exception — raises a
`WorkerHostError` carrying one of these codes so the main host's
`LifecycleDriver` can branch deterministically on the literal code value
rather than parsing free-form messages.
The registry is deliberately narrow: capability transport failures
surface through `WORKER_RPC.*` (owned by `@suaveplan/api-worker-rpc`),
plugin-author runtime failures surface through whatever code the plugin's
own error registry defines, and lifecycle FSM violations on the main side
surface through `LIFECYCLE.*`. Codes here describe failures that the
worker host alone can detect and report.
## Features
- `defineCodes("WORKER_HOST", [...])` produces a frozen string-literal
registry consumable as `WORKER_HOST_CODES.CRASHED` and friends.
- `WorkerHostError extends SuaveplanError<WorkerHostCode>` — fully
type-narrowed `code` field; carries the same serialise/redact/i18n
surface as every other `@suaveplan/error` subclass.
- Nine stable codes covering bootstrap, manifest validation, capability
wiring, lifecycle dispatch, handshake, crash detection, and FSM
rejection.
## Usage: Basic
```ts
import { WORKER_HOST_CODES, WorkerHostError } from "@suaveplan/api-worker-host/error-codes";
throw new WorkerHostError(
WORKER_HOST_CODES.MANIFEST_INVALID,
"manifest envelope failed schema check"
);
```
## Usage: Advanced
Branch on code values in the main host's lifecycle driver to drive FSM
transitions and crash-restart accounting:
```ts
import { WORKER_HOST_CODES } from "@suaveplan/api-worker-host/error-codes";
function onWorkerEnvelopeError(err: { code: string }): void {
switch (err.code) {
case WORKER_HOST_CODES.CRASHED:
crashBudget.recordCrash();
driver.transition("crashed");
break;
case WORKER_HOST_CODES.LIFECYCLE_HOOK_THREW:
driver.transition("start_failed");
break;
case WORKER_HOST_CODES.MAIN_HANDSHAKE_TIMEOUT:
driver.transition("install_failed");
break;
default:
driver.recordUnknown(err);
}
}
```
## API Reference
- `WORKER_HOST_CODES` — frozen registry keyed by base name, valued by
fully-qualified `"WORKER_HOST.<NAME>"` string.
- `WorkerHostCode` — discriminated union over every code value.
- `WorkerHostError` — `SuaveplanError<WorkerHostCode>` subclass.
## Implementation Notes
The registry list is closed: adding a code requires an OpenSpec change.
Order matches the spec delta authored in
the specification so the
verification trail (`**Verified by:**` lines) lines up with the test
files that exercise each code path. The error class overrides `name` so
`err.name === "WorkerHostError"` survives across the structured-clone
boundary back to the main host.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WORKER_HOST_CODES, WorkerHostError } from "./error-codes.js";
describe("WORKER_HOST_CODES", () => {
it("registers every documented code with the WORKER_HOST namespace", {
timeout: 5000,
}, () => {
expect(WORKER_HOST_CODES.BOOTSTRAP_FAILED).toBe(
"WORKER_HOST.BOOTSTRAP_FAILED"
);
expect(WORKER_HOST_CODES.MANIFEST_INVALID).toBe(
"WORKER_HOST.MANIFEST_INVALID"
);
expect(WORKER_HOST_CODES.CAPABILITY_HANDLE_MISSING).toBe(
"WORKER_HOST.CAPABILITY_HANDLE_MISSING"
);
expect(WORKER_HOST_CODES.LIFECYCLE_HOOK_THREW).toBe(
"WORKER_HOST.LIFECYCLE_HOOK_THREW"
);
expect(WORKER_HOST_CODES.MAIN_HANDSHAKE_TIMEOUT).toBe(
"WORKER_HOST.MAIN_HANDSHAKE_TIMEOUT"
);
expect(WORKER_HOST_CODES.CRASHED).toBe("WORKER_HOST.CRASHED");
expect(WORKER_HOST_CODES.DUPLICATE_INSTALL).toBe(
"WORKER_HOST.DUPLICATE_INSTALL"
);
expect(WORKER_HOST_CODES.INVALID_LIFECYCLE_TRANSITION).toBe(
"WORKER_HOST.INVALID_LIFECYCLE_TRANSITION"
);
expect(WORKER_HOST_CODES.UNKNOWN_LIFECYCLE_COMMAND).toBe(
"WORKER_HOST.UNKNOWN_LIFECYCLE_COMMAND"
);
});
});
describe("WorkerHostError", () => {
it("constructs with code + message and identifies via instanceof", {
timeout: 5000,
}, () => {
const err = new WorkerHostError(
WORKER_HOST_CODES.BOOTSTRAP_FAILED,
"boot blew up"
);
expect(err).toBeInstanceOf(WorkerHostError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("WORKER_HOST.BOOTSTRAP_FAILED");
expect(err.message).toBe("boot blew up");
expect(err.name).toBe("WorkerHostError");
});
it("propagates SuaveplanErrorOptions like cause + context", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new WorkerHostError(WORKER_HOST_CODES.CRASHED, "crashed", {
cause,
context: { reason: "uncaught" },
});
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ reason: "uncaught" });
});
});

View File

@@ -0,0 +1,48 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-worker-host`.
*
* Every public failure path in the worker-side runtime raises a
* {@link WorkerHostError} carrying one of these namespaced
* `WORKER_HOST.*` codes. The main host's `LifecycleDriver` and crash
* reporter branch on the literal code values (e.g.
* `error.code === WORKER_HOST_CODES.CRASHED`).
*
* The registry is intentionally narrow — capability/transport failures
* surface through `WORKER_RPC.*` (see `@suaveplan/api-worker-rpc`);
* plugin-author errors surface through whatever code the plugin throws.
* Codes here describe failures owned by the worker host *itself*.
*/
export const WORKER_HOST_CODES = defineCodes("WORKER_HOST", [
"BOOTSTRAP_FAILED",
"MANIFEST_INVALID",
"CAPABILITY_HANDLE_MISSING",
"LIFECYCLE_HOOK_THREW",
"MAIN_HANDSHAKE_TIMEOUT",
"CRASHED",
"DUPLICATE_INSTALL",
"INVALID_LIFECYCLE_TRANSITION",
"UNKNOWN_LIFECYCLE_COMMAND",
] as const);
/** Union of every fully-qualified code in {@link WORKER_HOST_CODES}. */
export type WorkerHostCode =
(typeof WORKER_HOST_CODES)[keyof typeof WORKER_HOST_CODES];
/**
* Typed error class for `@suaveplan/api-worker-host`. Extends
* {@link SuaveplanError} so callers may discriminate via either
* `instanceof WorkerHostError` or by code string.
*/
export class WorkerHostError extends SuaveplanError<WorkerHostCode> {
public constructor(
code: WorkerHostCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "WorkerHostError";
}
}

View File

@@ -0,0 +1,2 @@
export type { WorkerHostCode } from "./error-codes.js";
export { WORKER_HOST_CODES, WorkerHostError } from "./error-codes.js";

View File

@@ -0,0 +1,62 @@
// Bootstrap
export type { BootstrapSpec, WorkerHost } from "./bootstrap/bootstrap.js";
export { bootstrapWorkerPlugin } from "./bootstrap/bootstrap.js";
// Capability proxies
export type {
CapabilityProxy,
CapabilityProxyMap,
} from "./capability-proxies/capability-proxies.js";
export {
getCapability,
installCapabilityProxies,
} from "./capability-proxies/capability-proxies.js";
// Crash detection
export type {
CrashDetector,
CrashDetectorOptions,
ProcessLike,
} from "./crash-detection/crash-detection.js";
export { installCrashDetector } from "./crash-detection/crash-detection.js";
// Error codes
export type { WorkerHostCode } from "./error-codes/error-codes.js";
export {
WORKER_HOST_CODES,
WorkerHostError,
} from "./error-codes/error-codes.js";
// Lifecycle
export type {
LifecycleReceiver,
LifecycleReceiverOptions,
PluginLifecycleHooks,
WorkerLifecycleState,
} from "./lifecycle/lifecycle.js";
export { createLifecycleReceiver } from "./lifecycle/lifecycle.js";
export type {
CapabilityHandles,
CrashReport,
ErrorEnvelope,
LifecycleAck,
LifecycleCommand,
LifecycleCommandKind,
ManifestEnvelope,
WorkerReadyFrame,
} from "./protocol-extension/protocol-extension.js";
// Protocol extension
export {
capabilityHandlesSchema,
crashReportSchema,
errorEnvelopeSchema,
lifecycleAckErrSchema,
lifecycleAckOkSchema,
lifecycleAckSchema,
lifecycleCommandKindSchema,
lifecycleCommandSchema,
manifestEnvelopeSchema,
workerReadyFrameSchema,
} from "./protocol-extension/protocol-extension.js";
// Testing
export type {
WorkerHostConformanceHarness,
WorkerHostConformanceOptions,
} from "./testing/testing.js";
export { runWorkerHostConformance } from "./testing/testing.js";

View File

@@ -0,0 +1,7 @@
export type {
LifecycleReceiver,
LifecycleReceiverOptions,
PluginLifecycleHooks,
WorkerLifecycleState,
} from "./lifecycle.js";
export { createLifecycleReceiver } from "./lifecycle.js";

View File

@@ -0,0 +1,95 @@
# Worker-side lifecycle receiver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module hosts the worker-side counterpart to the main host's
`LifecycleDriver`. It owns the worker's nine-state lifecycle FSM
(`loaded` → `installing` → `installed` → `starting` → `started` →
`stopping` → `stopped` → `uninstalling` → `uninstalled`), validates
every inbound lifecycle command at the wire boundary with the Zod
schema from `protocol-extension`, dispatches the matched command to
the plugin author's hook (`install` / `start` / `stop` /
`uninstall` / `reload`), and returns a serialisable `LifecycleAck`
envelope the main host's RPC server forwards back to its driver. The
receiver is intentionally synchronous in its state mutation but
asynchronous in its hook invocation — the hook's promise drives the
ack timing.
## Features
- `createLifecycleReceiver({ hooks, initialState? })` — pure factory;
no global state, no module-load side effects.
- Tracks current state with a closure variable; `state()` exposes it
for observability and tests.
- `dispatch(raw)` validates the command, refuses illegal transitions
with `WORKER_HOST.INVALID_LIFECYCLE_TRANSITION`, runs the matched
hook (if defined), and on throw reconstructs the error via
`api-worker-rpc`'s `errorToEnvelope` while rewinding to the
pre-command state for retry.
- Hook throws are wrapped in
`WORKER_HOST.LIFECYCLE_HOOK_THREW` carrying `cause` + `context`
describing which hook triggered.
- Unknown command kinds raise
`WORKER_HOST.UNKNOWN_LIFECYCLE_COMMAND`.
## Usage: Basic
```ts
import {
createLifecycleReceiver,
} from "@suaveplan/api-worker-host/lifecycle";
const receiver = createLifecycleReceiver({
hooks: {
install: async () => { await db.migrate(); },
start: async () => { httpServer.listen(); },
},
});
const ack = await receiver.dispatch({ command: "install" });
if (ack.ok) console.log("installed");
```
## Usage: Advanced
Pair the receiver with `serveWorkerRpc` so the main host can drive
lifecycle over a dedicated `MessagePort`:
```ts
import { serveWorkerRpc } from "@suaveplan/api-worker-rpc";
import {
createLifecycleReceiver,
} from "@suaveplan/api-worker-host/lifecycle";
const receiver = createLifecycleReceiver({ hooks });
serveWorkerRpc(controlPort, {
"lifecycle.dispatch": async (params) =>
receiver.dispatch(params[0]),
});
```
## API Reference
- `createLifecycleReceiver(opts: LifecycleReceiverOptions): LifecycleReceiver`
- `LifecycleReceiverOptions`:
- `hooks: PluginLifecycleHooks`
- `initialState?: WorkerLifecycleState`
- `LifecycleReceiver`:
- `state(): WorkerLifecycleState`
- `dispatch(raw: unknown): Promise<LifecycleAck>`
- `PluginLifecycleHooks` — optional async hooks per command kind.
- `WorkerLifecycleState` — nine-state union.
## Implementation Notes
Legal-from sets are declared at module scope so the transition table
is auditable in one read. The `*ing` state is exposed mid-hook so the
main host can observe an in-flight transition rather than only the
terminal state. On hook throw, the receiver reverts to the *previous*
state (not the *ing* state) so retry attempts are evaluated against
the correct legal-from set. Schema validation runs before any state
mutation — a malformed envelope cannot push the FSM out of
`loaded` accidentally.

View File

@@ -0,0 +1,180 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WORKER_HOST_CODES } from "../error-codes/error-codes.js";
import { createLifecycleReceiver } from "./lifecycle.js";
const PLUGIN_CODES = defineCodes("TEST_PLUGIN", ["BOOM"] as const);
class TestPluginError extends SuaveplanError<
(typeof PLUGIN_CODES)[keyof typeof PLUGIN_CODES]
> {
public constructor(
code: (typeof PLUGIN_CODES)[keyof typeof PLUGIN_CODES],
message: string
) {
super(code, message);
this.name = "TestPluginError";
}
}
describe("createLifecycleReceiver", () => {
it("starts in 'loaded' by default", { timeout: 5000 }, () => {
const r = createLifecycleReceiver({ hooks: {} });
expect(r.state()).toBe("loaded");
});
it("honours initialState override", { timeout: 5000 }, () => {
const r = createLifecycleReceiver({
hooks: {},
initialState: "installed",
});
expect(r.state()).toBe("installed");
});
it("install hook runs and acks", { timeout: 5000 }, async () => {
const calls: string[] = [];
const r = createLifecycleReceiver({
hooks: {
install: () => {
calls.push("install");
},
},
});
const ack = await r.dispatch({ command: "install" });
expect(ack.ok).toBe(true);
expect(r.state()).toBe("installed");
expect(calls).toEqual(["install"]);
});
it("no-op when hook is undefined", { timeout: 5000 }, async () => {
const r = createLifecycleReceiver({ hooks: {} });
const ack = await r.dispatch({ command: "install" });
expect(ack.ok).toBe(true);
expect(r.state()).toBe("installed");
});
it("stop hook throw is reconstructed via errorToEnvelope", {
timeout: 5000,
}, async () => {
const r = createLifecycleReceiver({
hooks: {
stop: () => {
throw new TestPluginError(PLUGIN_CODES.BOOM, "kaboom");
},
},
initialState: "started",
});
const ack = await r.dispatch({ command: "stop" });
expect(ack.ok).toBe(false);
if (ack.ok === false) {
expect(ack.error.code).toBe(WORKER_HOST_CODES.LIFECYCLE_HOOK_THREW);
expect(ack.error.cause?.code).toBe(PLUGIN_CODES.BOOM);
}
// Rewound to previous (started) for retry.
expect(r.state()).toBe("started");
});
it("rejects malformed envelope with UNKNOWN_LIFECYCLE_COMMAND", {
timeout: 5000,
}, async () => {
const r = createLifecycleReceiver({ hooks: {} });
const ack = await r.dispatch({ command: "explode" });
expect(ack.ok).toBe(false);
if (ack.ok === false) {
expect(ack.error.code).toBe(
WORKER_HOST_CODES.UNKNOWN_LIFECYCLE_COMMAND
);
}
});
it("refuses illegal transitions", { timeout: 5000 }, async () => {
const r = createLifecycleReceiver({ hooks: {} });
const ack = await r.dispatch({ command: "stop" });
expect(ack.ok).toBe(false);
if (ack.ok === false) {
expect(ack.error.code).toBe(
WORKER_HOST_CODES.INVALID_LIFECYCLE_TRANSITION
);
}
expect(r.state()).toBe("loaded");
});
it("walks the install → start → stop → uninstall path", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = createLifecycleReceiver({
hooks: {
install: () => {
calls.push("install");
},
start: () => {
calls.push("start");
},
stop: () => {
calls.push("stop");
},
uninstall: () => {
calls.push("uninstall");
},
},
});
expect((await r.dispatch({ command: "install" })).ok).toBe(true);
expect((await r.dispatch({ command: "start" })).ok).toBe(true);
expect((await r.dispatch({ command: "stop" })).ok).toBe(true);
expect((await r.dispatch({ command: "uninstall" })).ok).toBe(true);
expect(r.state()).toBe("uninstalled");
expect(calls).toEqual(["install", "start", "stop", "uninstall"]);
});
it("reload runs the reload hook from started without changing terminal state", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = createLifecycleReceiver({
hooks: {
reload: () => {
calls.push("reload");
},
},
initialState: "started",
});
const ack = await r.dispatch({ command: "reload" });
expect(ack.ok).toBe(true);
expect(r.state()).toBe("started");
expect(calls).toEqual(["reload"]);
});
it("supports starting from stopped (restart path)", {
timeout: 5000,
}, async () => {
const r = createLifecycleReceiver({
hooks: {},
initialState: "stopped",
});
const ack = await r.dispatch({ command: "start" });
expect(ack.ok).toBe(true);
expect(r.state()).toBe("started");
});
it("supports uninstalling from stopped", { timeout: 5000 }, async () => {
const r = createLifecycleReceiver({
hooks: {},
initialState: "stopped",
});
const ack = await r.dispatch({ command: "uninstall" });
expect(ack.ok).toBe(true);
expect(r.state()).toBe("uninstalled");
});
it("supports async hooks that reject", { timeout: 5000 }, async () => {
const r = createLifecycleReceiver({
hooks: {
start: () => Promise.reject(new Error("async boom")),
},
initialState: "installed",
});
const ack = await r.dispatch({ command: "start" });
expect(ack.ok).toBe(false);
expect(r.state()).toBe("installed");
});
});

View File

@@ -0,0 +1,151 @@
import { errorToEnvelope } from "@suaveplan/api-worker-rpc";
import {
WORKER_HOST_CODES,
WorkerHostError,
} from "../error-codes/error-codes.js";
import type {
LifecycleAck,
LifecycleCommand,
LifecycleCommandKind,
} from "../protocol-extension/protocol-extension.js";
import { lifecycleCommandSchema } from "../protocol-extension/protocol-extension.js";
/**
* Hook table the plugin module exposes — `definePlugin` produces an
* object with these optional async hooks. Each receives the plugin
* context already built in `bootstrap` and may throw a
* `SuaveplanError`; throws are reconstructed across the boundary by
* `@suaveplan/api-worker-rpc`'s `errorToEnvelope`.
*/
export interface PluginLifecycleHooks {
install?: () => Promise<void> | void;
start?: () => Promise<void> | void;
stop?: () => Promise<void> | void;
uninstall?: () => Promise<void> | void;
reload?: () => Promise<void> | void;
}
/** Sequence of states the worker-side lifecycle tracks. */
export type WorkerLifecycleState =
| "loaded"
| "installing"
| "installed"
| "starting"
| "started"
| "stopping"
| "stopped"
| "uninstalling"
| "uninstalled";
const LEGAL_FROM: Record<
LifecycleCommandKind,
ReadonlySet<WorkerLifecycleState>
> = {
install: new Set<WorkerLifecycleState>(["loaded"]),
start: new Set<WorkerLifecycleState>(["installed", "stopped"]),
stop: new Set<WorkerLifecycleState>(["started"]),
uninstall: new Set<WorkerLifecycleState>(["installed", "stopped"]),
reload: new Set<WorkerLifecycleState>(["started", "stopped", "installed"]),
};
const NEXT: Record<LifecycleCommandKind, WorkerLifecycleState> = {
install: "installed",
start: "started",
stop: "stopped",
uninstall: "uninstalled",
reload: "started",
};
/**
* Constructs the lifecycle receiver. The receiver owns the worker-side
* state machine, validates incoming commands at the wire boundary,
* dispatches to plugin hooks and produces a serialisable
* {@link LifecycleAck} envelope per command.
*/
export interface LifecycleReceiver {
readonly state: () => WorkerLifecycleState;
readonly dispatch: (raw: unknown) => Promise<LifecycleAck>;
}
export interface LifecycleReceiverOptions {
readonly hooks: PluginLifecycleHooks;
readonly initialState?: WorkerLifecycleState;
}
export function createLifecycleReceiver(
opts: LifecycleReceiverOptions
): LifecycleReceiver {
let current: WorkerLifecycleState = opts.initialState ?? "loaded";
const runHook = async (kind: LifecycleCommandKind): Promise<void> => {
const hook = opts.hooks[kind];
if (hook === undefined) return;
try {
await hook();
} catch (err) {
throw new WorkerHostError(
WORKER_HOST_CODES.LIFECYCLE_HOOK_THREW,
`plugin ${kind} hook threw`,
{ cause: err, context: { hook: kind } }
);
}
};
const dispatch = async (raw: unknown): Promise<LifecycleAck> => {
let cmd: LifecycleCommand;
try {
cmd = lifecycleCommandSchema.parse(raw);
} catch (err) {
return {
ok: false,
error: errorToEnvelope(
new WorkerHostError(
WORKER_HOST_CODES.UNKNOWN_LIFECYCLE_COMMAND,
"lifecycle command envelope failed schema check",
{ cause: err }
)
),
};
}
const legalSources = LEGAL_FROM[cmd.command];
if (!legalSources.has(current)) {
return {
ok: false,
error: errorToEnvelope(
new WorkerHostError(
WORKER_HOST_CODES.INVALID_LIFECYCLE_TRANSITION,
`cannot ${cmd.command} from state ${current}`,
{ context: { from: current, command: cmd.command } }
)
),
};
}
// Transition through *ing state for observability.
const previous = current;
const tickingState: WorkerLifecycleState =
cmd.command === "install"
? "installing"
: cmd.command === "start"
? "starting"
: cmd.command === "stop"
? "stopping"
: cmd.command === "uninstall"
? "uninstalling"
: current;
current = tickingState;
try {
await runHook(cmd.command);
current = NEXT[cmd.command];
return { ok: true };
} catch (err) {
// Hook threw — rewind to pre-command state so caller can retry.
current = previous;
return { ok: false, error: errorToEnvelope(err) };
}
};
return {
state: () => current,
dispatch,
};
}

View File

@@ -0,0 +1,22 @@
export type {
CapabilityHandles,
CrashReport,
ErrorEnvelope,
LifecycleAck,
LifecycleCommand,
LifecycleCommandKind,
ManifestEnvelope,
WorkerReadyFrame,
} from "./protocol-extension.js";
export {
capabilityHandlesSchema,
crashReportSchema,
errorEnvelopeSchema,
lifecycleAckErrSchema,
lifecycleAckOkSchema,
lifecycleAckSchema,
lifecycleCommandKindSchema,
lifecycleCommandSchema,
manifestEnvelopeSchema,
workerReadyFrameSchema,
} from "./protocol-extension.js";

View File

@@ -0,0 +1,99 @@
# Worker host protocol extensions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the Zod schemas that the worker host layers on top
of the transport-level frame protocol owned by
`@suaveplan/api-worker-rpc`. Where the transport package speaks
`request` / `response` / `cancel` / `stream-*` / `hello` / `goodbye`
frames, the worker host carries higher-level *control envelopes* —
manifest advertisement, lifecycle commands, lifecycle acks, crash
reports and capability-handle descriptors — inside those frames'
payloads. Keeping the envelope schemas in one place lets the bootstrap,
lifecycle and crash-detection modules import a single validator and
gives the conformance harness one round-trip surface to test against.
## Features
- `manifestEnvelopeSchema` — the minimum manifest shape the main host
needs for FSM bookkeeping (name, version, capabilities, tier).
- `workerReadyFrameSchema` — initial readiness frame the worker pushes
after the plugin module has been imported and validated.
- `lifecycleCommandSchema` — discriminated by `command:
"install"|"start"|"stop"|"uninstall"|"reload"`.
- `lifecycleAckSchema` — discriminated union over success / failure
acks carrying a reconstructed `ErrorEnvelope` on failure.
- `crashReportSchema` — emitted by the crash detector before the worker
exits non-zero.
- `capabilityHandlesSchema` — descriptor table delivered by the main
host so the worker knows which capability methods to expose as RPC
proxies.
## Usage: Basic
```ts
import {
workerReadyFrameSchema,
} from "@suaveplan/api-worker-host/protocol-extension";
const ready = workerReadyFrameSchema.parse({
kind: "worker-ready",
manifest: {
name: "demo",
version: "0.1.0",
capabilities: ["db", "logger"],
tier: "isolated",
},
});
```
## Usage: Advanced
Validate every payload at the wire boundary in the lifecycle receiver:
```ts
import {
lifecycleAckSchema,
lifecycleCommandSchema,
} from "@suaveplan/api-worker-host/protocol-extension";
function dispatch(raw: unknown): void {
const cmd = lifecycleCommandSchema.parse(raw);
runHook(cmd.command).then(
() => send(lifecycleAckSchema.parse({ ok: true })),
(err) => send(
lifecycleAckSchema.parse({
ok: false,
error: errorToEnvelope(err),
})
)
);
}
```
## API Reference
- `manifestEnvelopeSchema` / `ManifestEnvelope`
- `workerReadyFrameSchema` / `WorkerReadyFrame`
- `lifecycleCommandKindSchema` / `LifecycleCommandKind`
- `lifecycleCommandSchema` / `LifecycleCommand`
- `lifecycleAckOkSchema` / `lifecycleAckErrSchema` / `lifecycleAckSchema`
/ `LifecycleAck`
- `crashReportSchema` / `CrashReport`
- `capabilityHandlesSchema` / `CapabilityHandles`
- `errorEnvelopeSchema` / `ErrorEnvelope` — re-exported from the
transport layer for boundary use.
## Implementation Notes
Schemas are declared at module scope and reused on every parse so Zod
does not rebuild the type graph per call. The tier enum is fixed to the
three values from design doc §2.2 (`trusted` / `isolated` / `sandboxed`)
— extending it requires an OpenSpec change. Capability handles only
carry the *method names* exposed by each capability proxy; the actual
`MessagePort` transfer happens out-of-band as part of the underlying
`worker_threads` channel setup.

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
capabilityHandlesSchema,
crashReportSchema,
lifecycleAckSchema,
lifecycleCommandSchema,
manifestEnvelopeSchema,
workerReadyFrameSchema,
} from "./protocol-extension.js";
describe("manifestEnvelopeSchema", () => {
it("accepts a minimal valid envelope", { timeout: 5000 }, () => {
const parsed = manifestEnvelopeSchema.parse({
name: "demo",
version: "0.1.0",
capabilities: ["db"],
tier: "isolated",
});
expect(parsed.name).toBe("demo");
expect(parsed.tier).toBe("isolated");
});
it("rejects unknown tiers", { timeout: 5000 }, () => {
expect(() =>
manifestEnvelopeSchema.parse({
name: "x",
version: "0.1.0",
capabilities: [],
tier: "weird",
})
).toThrow();
});
});
describe("workerReadyFrameSchema", () => {
it("round-trips a ready frame", { timeout: 5000 }, () => {
const f = workerReadyFrameSchema.parse({
kind: "worker-ready",
manifest: {
name: "a",
version: "0.1.0",
capabilities: [],
tier: "trusted",
},
});
expect(f.kind).toBe("worker-ready");
});
});
describe("lifecycleCommandSchema", () => {
it.each([
"install",
"start",
"stop",
"uninstall",
"reload",
])("accepts command %s", (command) => {
const cmd = lifecycleCommandSchema.parse({ command });
expect(cmd.command).toBe(command);
});
it("rejects unknown commands", { timeout: 5000 }, () => {
expect(() =>
lifecycleCommandSchema.parse({ command: "explode" })
).toThrow();
});
});
describe("lifecycleAckSchema", () => {
it("accepts ok ack", { timeout: 5000 }, () => {
const ack = lifecycleAckSchema.parse({ ok: true });
expect(ack.ok).toBe(true);
});
it("accepts err ack carrying error envelope", { timeout: 5000 }, () => {
const ack = lifecycleAckSchema.parse({
ok: false,
error: { code: "X.Y", name: "E", message: "m" },
});
expect(ack.ok).toBe(false);
if (ack.ok === false) {
expect(ack.error.code).toBe("X.Y");
}
});
});
describe("crashReportSchema", () => {
it("round-trips a crash report", { timeout: 5000 }, () => {
const r = crashReportSchema.parse({
kind: "crashed",
error: { code: "X", name: "E", message: "m" },
});
expect(r.kind).toBe("crashed");
});
});
describe("capabilityHandlesSchema", () => {
it("accepts a method table", { timeout: 5000 }, () => {
const h = capabilityHandlesSchema.parse({
db: { methods: ["query", "exec"] },
});
expect(h.db?.methods).toEqual(["query", "exec"]);
});
});

View File

@@ -0,0 +1,94 @@
import {
type ErrorEnvelope,
errorEnvelopeSchema,
} from "@suaveplan/api-worker-rpc";
import z from "zod";
/**
* Wire-protocol extensions specific to the worker-host control channel.
*
* `@suaveplan/api-worker-rpc` owns the transport-level frame protocol
* (`request` / `response` / `cancel` / `stream-*` / `hello` /
* `goodbye`). The worker host adds three higher-level envelopes on top
* that ride inside `request` `params` or `response` `result` payloads
* for the dedicated control-port handlers.
*
* Keeping these schemas in their own module means the bootstrap,
* lifecycle and crash-detection modules import one shared validator
* and the test suite can round-trip-parse every envelope shape.
*/
/**
* Plugin manifest envelope advertised by the worker on bootstrap. Only
* the fields the main host actually consumes for FSM bookkeeping are
* required here — full manifest validation is the main host's job.
*/
export const manifestEnvelopeSchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
capabilities: z.array(z.string()),
tier: z.enum(["trusted", "isolated", "sandboxed"]),
});
export type ManifestEnvelope = z.infer<typeof manifestEnvelopeSchema>;
/** Initial readiness frame the worker pushes once the plugin module has loaded. */
export const workerReadyFrameSchema = z.object({
kind: z.literal("worker-ready"),
manifest: manifestEnvelopeSchema,
});
export type WorkerReadyFrame = z.infer<typeof workerReadyFrameSchema>;
/** Lifecycle command kinds dispatched from main → worker. */
export const lifecycleCommandKindSchema = z.enum([
"install",
"start",
"stop",
"uninstall",
"reload",
]);
export type LifecycleCommandKind = z.infer<typeof lifecycleCommandKindSchema>;
/** Body of a lifecycle command issued by the main host. */
export const lifecycleCommandSchema = z.object({
command: lifecycleCommandKindSchema,
});
export type LifecycleCommand = z.infer<typeof lifecycleCommandSchema>;
/** Successful lifecycle ack. */
export const lifecycleAckOkSchema = z.object({
ok: z.literal(true),
});
/** Failure lifecycle ack carrying a reconstructed error envelope. */
export const lifecycleAckErrSchema = z.object({
ok: z.literal(false),
error: errorEnvelopeSchema,
});
export const lifecycleAckSchema = z.discriminatedUnion("ok", [
lifecycleAckOkSchema,
lifecycleAckErrSchema,
]);
export type LifecycleAck = z.infer<typeof lifecycleAckSchema>;
/** Crash report — emitted on uncaughtException / unhandledRejection. */
export const crashReportSchema = z.object({
kind: z.literal("crashed"),
error: errorEnvelopeSchema,
});
export type CrashReport = z.infer<typeof crashReportSchema>;
/**
* Capability-handle descriptor table — what the main host hands the
* worker so it can attach RPC proxies to the right `MessagePort` per
* declared capability.
*/
export const capabilityHandlesSchema = z.record(
z.string().min(1),
z.object({
methods: z.array(z.string().min(1)),
})
);
export type CapabilityHandles = z.infer<typeof capabilityHandlesSchema>;
/** Re-export the underlying error envelope shape for downstream modules. */
export type { ErrorEnvelope };
export { errorEnvelopeSchema };

View File

@@ -0,0 +1,5 @@
export type {
WorkerHostConformanceHarness,
WorkerHostConformanceOptions,
} from "./testing.js";
export { runWorkerHostConformance } from "./testing.js";

View File

@@ -0,0 +1,92 @@
# Worker host conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runWorkerHostConformance` spins up an in-process worker-shaped peer
using paired `MessageChannel` ports. It exists so this package's own
test suite — and the lifecycle-driver tests inside `api-host` — can
exercise the full bootstrap → lifecycle → capability-proxy chain
without spawning a real `node:worker_threads` `Worker`. Two paired
ports are wired up (one for control, one for capability), the
bootstrap is run on the worker-side ports, a stub capability server
is mounted on the main-side of the capability port, and a main-side
RPC client is wired to the control port — giving callers everything
they need to drive lifecycle commands and observe capability call
dispatch deterministically.
## Features
- Optional overrides for `manifest`, `capabilityHandles`, plugin
`hooks` and the main-side `capabilityHandlers`.
- Reasonable defaults (`{ name: "test-plugin", version: "0.1.0",
tier: "isolated", capabilities: [] }`) so trivial tests need zero
setup.
- Returns the live `WorkerHost`, the main-side `WorkerRpcClient`,
the stub capability server, both port pairs and a single
`dispose()` that tears everything down in reverse order.
- No real `Worker` thread spawned — pure structured-clone routing
via `MessageChannel`.
## Usage: Basic
```ts
import {
runWorkerHostConformance,
} from "@suaveplan/api-worker-host/testing";
const h = runWorkerHostConformance({
hooks: { install: () => {} },
});
const ack = await h.mainClient.call("lifecycle.dispatch", [
{ command: "install" },
]);
h.dispose();
```
## Usage: Advanced
Provide capability handles + main-side handlers to round-trip a
capability call:
```ts
import {
runWorkerHostConformance,
} from "@suaveplan/api-worker-host/testing";
const h = runWorkerHostConformance({
capabilityHandles: { db: { methods: ["query"] } },
capabilityHandlers: {
"db.query": (params) => ({ rows: [params[0]] }),
},
});
const query = h.host.capabilities.db?.query;
const result = await query?.("SELECT 1");
h.dispose();
```
## API Reference
- `runWorkerHostConformance(opts?): WorkerHostConformanceHarness`
- `WorkerHostConformanceOptions`:
- `manifest?: ManifestEnvelope`
- `capabilityHandles?: CapabilityHandles`
- `hooks?: PluginLifecycleHooks`
- `capabilityHandlers?: WorkerRpcHandlers`
- `WorkerHostConformanceHarness`:
- `host: WorkerHost`
- `mainClient: WorkerRpcClient`
- `capabilityServer: WorkerRpcServer`
- `controlPorts / capabilityPorts: InProcessPortPair`
- `dispose(): void`
## Implementation Notes
`dispose()` closes the main-side client first so it stops sending,
then the worker host (which closes its own server + capability
client), then the main-side capability server, then both port pairs.
Closing in reverse-construction order avoids `port already closed`
log noise in vitest while still flushing the inflight RPC budget.

View File

@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerHostConformance } from "./testing.js";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) cleanups.pop()?.();
});
describe("runWorkerHostConformance", () => {
it("boots with defaults and exposes the manifest", {
timeout: 5000,
}, () => {
const h = runWorkerHostConformance();
cleanups.push(() => h.dispose());
expect(h.host.manifest.name).toBe("test-plugin");
});
it("drives a lifecycle install command through mainClient", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const h = runWorkerHostConformance({
hooks: {
install: () => {
calls.push("install");
},
},
});
cleanups.push(() => h.dispose());
const ack = (await h.mainClient.call("lifecycle.dispatch", [
{ command: "install" },
])) as { ok: boolean };
expect(ack.ok).toBe(true);
expect(calls).toEqual(["install"]);
});
it("round-trips a capability call against the stubbed handler", {
timeout: 5000,
}, async () => {
const h = runWorkerHostConformance({
capabilityHandles: { db: { methods: ["query"] } },
capabilityHandlers: {
"db.query": (params) => ({ echo: params[0] }),
},
});
cleanups.push(() => h.dispose());
const query = h.host.capabilities.db?.query;
if (query === undefined) throw new Error("missing");
expect(await query("ping")).toEqual({ echo: "ping" });
});
it("respects a manifest override", { timeout: 5000 }, () => {
const h = runWorkerHostConformance({
manifest: {
name: "override",
version: "9.9.9",
capabilities: ["x"],
tier: "sandboxed",
},
});
cleanups.push(() => h.dispose());
expect(h.host.manifest.name).toBe("override");
expect(h.host.manifest.tier).toBe("sandboxed");
});
});

View File

@@ -0,0 +1,91 @@
import type {
InProcessPortPair,
WorkerRpcClient,
WorkerRpcHandlers,
WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import {
createInProcessPortPair,
createRawWorkerRpcClient,
serveWorkerRpc,
} from "@suaveplan/api-worker-rpc";
import {
bootstrapWorkerPlugin,
type WorkerHost,
} from "../bootstrap/bootstrap.js";
import type { PluginLifecycleHooks } from "../lifecycle/lifecycle.js";
import type {
CapabilityHandles,
ManifestEnvelope,
} from "../protocol-extension/protocol-extension.js";
/**
* In-process simulator factory. Spins up two paired `MessageChannel`
* ports (one for control, one for capability), boots a worker-host
* over them, and pairs the main-side ports with a stub capability
* server. Returns the live host + main-side RPC client + a disposer.
*
* Used by every test in this package — and by downstream packages
* (e.g. `api-host`'s loader tests) that want a worker-shaped peer
* without spawning a real OS thread.
*/
export interface WorkerHostConformanceHarness {
readonly host: WorkerHost;
readonly mainClient: WorkerRpcClient;
readonly capabilityServer: WorkerRpcServer;
readonly controlPorts: InProcessPortPair;
readonly capabilityPorts: InProcessPortPair;
readonly dispose: () => void;
}
export interface WorkerHostConformanceOptions {
readonly manifest?: ManifestEnvelope;
readonly capabilityHandles?: CapabilityHandles;
readonly hooks?: PluginLifecycleHooks;
/** Handler set for the main-side capability server stub. */
readonly capabilityHandlers?: WorkerRpcHandlers;
}
const DEFAULT_MANIFEST: ManifestEnvelope = {
name: "test-plugin",
version: "0.1.0",
capabilities: [],
tier: "isolated",
};
export function runWorkerHostConformance(
opts: WorkerHostConformanceOptions = {}
): WorkerHostConformanceHarness {
const controlPorts = createInProcessPortPair();
const capabilityPorts = createInProcessPortPair();
const capabilityServer = serveWorkerRpc(
capabilityPorts.clientPort,
opts.capabilityHandlers ?? {}
);
const host = bootstrapWorkerPlugin({
manifest: opts.manifest ?? DEFAULT_MANIFEST,
capabilityHandles: opts.capabilityHandles ?? {},
hooks: opts.hooks ?? {},
controlPort: controlPorts.serverPort,
capabilityPort: capabilityPorts.serverPort,
});
const mainClient = createRawWorkerRpcClient(controlPorts.clientPort);
return {
host,
mainClient,
capabilityServer,
controlPorts,
capabilityPorts,
dispose: () => {
mainClient.close();
host.close();
capabilityServer.close();
controlPorts.close();
capabilityPorts.close();
},
};
}

View File

@@ -0,0 +1,37 @@
/**
* §16 conformance proof — the worker-proxy package consumes the
* `@suaveplan/api-worker-rpc` contract directly (client + server +
* MessagePort transport). This test exercises the canonical
* `runWorkerRpcConformance` harness with a minimal handler set to
* prove the consumer is wire-compatible end-to-end (request frame,
* response frame, abort propagation) using the same port transport
* the package builds on top of in `src/main-side` / `src/worker-side`.
*/
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
const handles: Array<{ dispose(): void }> = [];
afterEach(() => {
while (handles.length > 0) handles.pop()?.dispose();
});
describe("api-worker-rpc contract conformance (consumer-side)", () => {
it("round-trips a request through the canonical harness", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
handles.push(h);
const result = await h.client.call<number>("echo", [42]);
expect(result).toBe(42);
});
it("surfaces UNKNOWN_METHOD from the server", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({}));
handles.push(h);
await expect(h.client.call("missing", [])).rejects.toThrow();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**/*.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-worker-host",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["@suaveplan/testing/polyfills"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});