Initial Commit
This commit is contained in:
72
packages/api-stack/api/api-worker-host/README.md
Normal file
72
packages/api-stack/api/api-worker-host/README.md
Normal 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.
|
||||
|
||||
116
packages/api-stack/api/api-worker-host/package.json
Normal file
116
packages/api-stack/api/api-worker-host/package.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BootstrapSpec, WorkerHost } from "./bootstrap.js";
|
||||
export { bootstrapWorkerPlugin } from "./bootstrap.js";
|
||||
@@ -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".
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
CapabilityProxy,
|
||||
CapabilityProxyMap,
|
||||
} from "./capability-proxies.js";
|
||||
export {
|
||||
getCapability,
|
||||
installCapabilityProxies,
|
||||
} from "./capability-proxies.js";
|
||||
@@ -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`.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CrashDetector,
|
||||
CrashDetectorOptions,
|
||||
ProcessLike,
|
||||
} from "./crash-detection.js";
|
||||
export { installCrashDetector } from "./crash-detection.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { WorkerHostCode } from "./error-codes.js";
|
||||
export { WORKER_HOST_CODES, WorkerHostError } from "./error-codes.js";
|
||||
62
packages/api-stack/api/api-worker-host/src/index.ts
Normal file
62
packages/api-stack/api/api-worker-host/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
LifecycleReceiver,
|
||||
LifecycleReceiverOptions,
|
||||
PluginLifecycleHooks,
|
||||
WorkerLifecycleState,
|
||||
} from "./lifecycle.js";
|
||||
export { createLifecycleReceiver } from "./lifecycle.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
WorkerHostConformanceHarness,
|
||||
WorkerHostConformanceOptions,
|
||||
} from "./testing.js";
|
||||
export { runWorkerHostConformance } from "./testing.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-worker-host/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-worker-host/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-worker-host/vite.config.ts
Normal file
30
packages/api-stack/api/api-worker-host/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-worker-host/vitest.config.ts
Normal file
31
packages/api-stack/api/api-worker-host/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user