Initial Commit

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

View File

@@ -0,0 +1,90 @@
# @suaveplan/api-events-worker-proxy - Worker-tier proxy over a main-process @suaveplan/events EventBus. The main side calls createEventBusProxyServer(port, bus) to expose the bus over a MessagePort via @suaveplan/api-worker-rpc; worker-tier plugins call createEventBusProxyClient(port) to obtain an EventBus-shaped surface that marshals on/once/off/emit/emitAsync across the port. Subscriptions become long-lived stream-* frames that deliver one stream-chunk per main-side event. Zod-validated payload codec rejects non-structured-clone-safe payloads with EVENTS_PROXY.UNSERIALISABLE_PAYLOAD.
Worker-tier proxy over a main-process `@suaveplan/events` `EventBus`.
The host (main process) owns one `EventBus<TMap>` instance. Worker-tier
plugins want to publish to and subscribe from the same bus without
running the bus in every worker. This package marshals every
`on` / `once` / `off` / `emit` / `emitAsync` call across a
`MessagePort` via `@suaveplan/api-worker-rpc`, so worker-tier plugin
code compiles unchanged against an `EventBus`-shaped proxy surface.
This is an adapter, not a new contract — the design doc (§5.2a) is
explicit that no abstract `api-events` contract package exists. The
host injects `@suaveplan/events` directly; this package is the
worker-side surface over that existing bus.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable string registry of `EVENTS_PROXY.*` error codes raised by every failure path in `@suaveplan/api-even…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable string registry of `EVENTS_PROXY.*` error codes raised by every failure path in `@suaveplan/api-even…
- [main-side](./src/main-side/main-side.md) — Mounts a main-process `@suaveplan/events` `EventBus` on a `MessagePort` via the `@suaveplan/api-worker-rpc` server so w…
- [main-side](./src/main-side/main-side.md) — Mounts a main-process `@suaveplan/events` `EventBus` on a `MessagePort` via the `@suaveplan/api-worker-rpc` server so w…
- [payload-codec](./src/payload-codec/payload-codec.md) — Encodes and decodes the wire envelope used to ship a single event from the main-process `EventBus` across a `MessagePor…
- [payload-codec](./src/payload-codec/payload-codec.md) — Encodes and decodes the wire envelope used to ship a single event from the main-process `EventBus` across a `MessagePor…
- [testing](./src/testing/testing.md) — Re-usable test fixtures for `@suaveplan/api-events-worker-proxy`. Builds a real `EventBus`, a real Node.js `MessageChan…
- [testing](./src/testing/testing.md) — Re-usable test fixtures for `@suaveplan/api-events-worker-proxy`. Builds a real `EventBus`, a real Node.js `MessageChan…
- [worker-side](./src/worker-side/worker-side.md) — Builds a worker-side proxy that exposes an `EventBus`-shaped surface backed by a `PortLike` connected to a main-process…
- [worker-side](./src/worker-side/worker-side.md) — Builds a worker-side proxy that exposes an `EventBus`-shaped surface backed by a `PortLike` connected to a main-process…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-events-worker-proxy
```
## Main-side (host)
```ts
import { EventBus } from "@suaveplan/events";
import { createEventBusProxyServer } from "@suaveplan/api-events-worker-proxy/main-side";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
const bus = new EventBus<{ "billing.invoice.created": { id: string } }>();
const { clientPort, serverPort, close } = createInProcessPortPair();
const server = createEventBusProxyServer(serverPort, bus);
// Hand `clientPort` to a worker; tear down with `server.dispose()` + close().
```
## Worker-side
```ts
import { createEventBusProxyClient } from "@suaveplan/api-events-worker-proxy/worker-side";
type Map = { "billing.invoice.created": { id: string } };
const events = createEventBusProxyClient<Map>(port);
events.on("billing.invoice.created", ({ id }) => console.log("got", id));
const result = await events.emitAsync("billing.invoice.created", {
id: "i_1",
});
console.log(result.delivered);
```
## Exports
- `./main-side` — `createEventBusProxyServer(port, bus)`.
- `./worker-side` — `createEventBusProxyClient(port)`.
- `./payload-codec` — Zod schema + structured-clone probe.
- `./error-codes` — `EVENTS_PROXY.*` codes + `EventsProxyError`.
- `./testing` — `createProxyPair` + `waitFor` for downstream tests.
## Error codes
- `EVENTS_PROXY.UNSERIALISABLE_PAYLOAD` — payload failed the
structured-clone safety probe (functions, class instances, cycles).
- `EVENTS_PROXY.SUBSCRIPTION_DROPPED` — subscription terminated
unexpectedly (server disposed, port closed mid-stream).
- `EVENTS_PROXY.BACKPRESSURE_EXCEEDED` — worker-side queue exceeded the
configured high-water mark.
- `EVENTS_PROXY.PROXY_DISPOSED` — method called after `dispose()`.
## Docs
- [`main-side`](./src/main-side/main-side.md)
- [`worker-side`](./src/worker-side/worker-side.md)
- [`payload-codec`](./src/payload-codec/payload-codec.md)
- [`error-codes`](./src/error-codes/error-codes.md)
- [`testing`](./src/testing/testing.md)

View File

@@ -0,0 +1,93 @@
{
"name": "@suaveplan/api-events-worker-proxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Worker-tier proxy over a main-process @suaveplan/events EventBus. The main side calls createEventBusProxyServer(port, bus) to expose the bus over a MessagePort via @suaveplan/api-worker-rpc; worker-tier plugins call createEventBusProxyClient(port) to obtain an EventBus-shaped surface that marshals on/once/off/emit/emitAsync across the port. Subscriptions become long-lived stream-* frames that deliver one stream-chunk per main-side event. Zod-validated payload codec rejects non-structured-clone-safe payloads with EVENTS_PROXY.UNSERIALISABLE_PAYLOAD.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"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"
}
},
"./main-side": {
"import": {
"types": "./dist/main-side/main-side.d.ts",
"default": "./dist/main-side/main-side.js"
}
},
"./worker-side": {
"import": {
"types": "./dist/worker-side/worker-side.d.ts",
"default": "./dist/worker-side/worker-side.js"
}
},
"./payload-codec": {
"import": {
"types": "./dist/payload-codec/payload-codec.d.ts",
"default": "./dist/payload-codec/payload-codec.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 --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"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-types": "workspace:^",
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "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"
]
}

View File

@@ -0,0 +1,71 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable string registry of `EVENTS_PROXY.*` error codes raised by every failure path in `@suaveplan/api-events-worker-proxy`, plus the concrete `EventsProxyError` subclass of `SuaveplanError` that carries them.
Consumers branch on the string code (`error.code === EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD`) rather than on subclass identity, which keeps the wire format stable across versions and across the worker/main boundary where errors are reconstructed from their `{ code, message, details }` triple.
## Features
- Four canonical codes: `UNSERIALISABLE_PAYLOAD`, `SUBSCRIPTION_DROPPED`, `BACKPRESSURE_EXCEEDED`, `PROXY_DISPOSED`.
- Built via `defineCodes("EVENTS_PROXY", [...])` so every code emerges as the dotted string `"EVENTS_PROXY.<NAME>"`.
- `EventsProxyError` exposes a typed `eventsProxyCode` property in addition to the inherited `code` so type narrowing works both ways.
- Uses `SuaveplanErrorOptions` (with `cause` + `context`) verbatim — no bespoke options shape.
## Usage: Basic
```ts
import {
EVENTS_PROXY_CODES,
EventsProxyError,
} from "@suaveplan/api-events-worker-proxy/error-codes";
throw new EventsProxyError(
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD,
"Refusing to marshal a payload containing a function",
{ context: { path: "$.handler" } }
);
```
## Usage: Advanced
```ts
import {
EVENTS_PROXY_CODES,
EventsProxyError,
type EventsProxyErrorCode,
} from "@suaveplan/api-events-worker-proxy/error-codes";
function classify(error: unknown): EventsProxyErrorCode | "unknown" {
if (error instanceof EventsProxyError) return error.eventsProxyCode;
return "unknown";
}
try {
// ...emit a payload across the proxy...
} catch (err) {
if (
err instanceof EventsProxyError &&
err.eventsProxyCode === EVENTS_PROXY_CODES.BACKPRESSURE_EXCEEDED
) {
// Apply caller-side backoff before retrying.
} else {
throw err;
}
}
```
## API Reference
- `EVENTS_PROXY_CODES` — `Readonly<{ UNSERIALISABLE_PAYLOAD: "EVENTS_PROXY.UNSERIALISABLE_PAYLOAD"; SUBSCRIPTION_DROPPED: "EVENTS_PROXY.SUBSCRIPTION_DROPPED"; BACKPRESSURE_EXCEEDED: "EVENTS_PROXY.BACKPRESSURE_EXCEEDED"; PROXY_DISPOSED: "EVENTS_PROXY.PROXY_DISPOSED"; }>`.
- `EventsProxyErrorCode` — union of the four dotted strings above.
- `EventsProxyError(code, message, options?)` — concrete subclass of `SuaveplanError<EventsProxyErrorCode>`. `options.cause` records the upstream error; `options.context` records structured metadata (`path`, `subscriptionId`, etc.).
## Implementation Notes
The registry is intentionally small: every code captures a category of failure rather than a fine-grained sub-condition, and the structured `context` payload carries the discriminating detail. This keeps the wire surface stable as the codec evolves. The class duplicates the code into `eventsProxyCode` so that callers in TypeScript can narrow without importing the union type — useful for plugins that reconstruct errors from the wire envelope on the opposite side of the port.

View File

@@ -0,0 +1,64 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_PROXY_CODES,
EventsProxyError,
type EventsProxyErrorCode,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes four EVENTS_PROXY.* codes with stable dotted strings", {
timeout: 5000,
}, () => {
expect({ ...EVENTS_PROXY_CODES }).toStrictEqual({
UNSERIALISABLE_PAYLOAD: "EVENTS_PROXY.UNSERIALISABLE_PAYLOAD",
SUBSCRIPTION_DROPPED: "EVENTS_PROXY.SUBSCRIPTION_DROPPED",
BACKPRESSURE_EXCEEDED: "EVENTS_PROXY.BACKPRESSURE_EXCEEDED",
PROXY_DISPOSED: "EVENTS_PROXY.PROXY_DISPOSED",
});
});
it("constructs an EventsProxyError with code, cause and context", {
timeout: 5000,
}, () => {
const cause = new TypeError("contains a function");
const error = new EventsProxyError(
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD,
"refusing to marshal payload",
{ cause, context: { path: "$.payload.fn" } }
);
expect(error).toBeInstanceOf(EventsProxyError);
expect(error).toBeInstanceOf(SuaveplanError);
expect(error.code).toBe(EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD);
expect(error.eventsProxyCode).toBe(
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD
);
expect(error.cause).toBe(cause);
expect(error.context).toStrictEqual({ path: "$.payload.fn" });
});
it("narrows the typed code via eventsProxyCode", { timeout: 5000 }, () => {
const codes: EventsProxyErrorCode[] = [
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD,
EVENTS_PROXY_CODES.SUBSCRIPTION_DROPPED,
EVENTS_PROXY_CODES.BACKPRESSURE_EXCEEDED,
EVENTS_PROXY_CODES.PROXY_DISPOSED,
];
for (const code of codes) {
const error = new EventsProxyError(code, code);
expect(error.eventsProxyCode).toBe(code);
expect(error.code).toBe(code);
}
});
it("permits omitting the options argument", { timeout: 5000 }, () => {
const error = new EventsProxyError(
EVENTS_PROXY_CODES.PROXY_DISPOSED,
"proxy disposed"
);
expect(error.cause).toBeUndefined();
expect(error.context).toBeUndefined();
});
});

View File

@@ -0,0 +1,56 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-events-worker-proxy`.
*
* Every failure path raises an {@link EventsProxyError} carrying one of
* these four `EVENTS_PROXY.*` codes:
*
* - `UNSERIALISABLE_PAYLOAD` — `emit`/`emitAsync`/listener-delivery refused
* to marshal a payload that fails the structured-clone safety probe
* (functions, class instances without explicit `serialize` hooks,
* cyclic graphs detected by the codec). `cause` carries the underlying
* `TypeError` from the structured-clone probe; `context.path` records
* the property path that triggered the failure.
* - `SUBSCRIPTION_DROPPED` — the main-side stream backing a worker-side
* subscription terminated unexpectedly (port closed, peer disposed)
* while listeners were still registered. Each affected listener is
* surfaced once with this code so worker-side code can re-establish.
* - `BACKPRESSURE_EXCEEDED` — the worker-side delivery queue for an
* active subscription exceeded its high-water mark. The codec rejects
* further main-side deliveries on the affected stream until the
* worker drains; the originating `emit` call rejects with this code.
* - `PROXY_DISPOSED` — a method on the proxy was invoked after
* `dispose()` was called (worker-side) or after the main-side server
* was torn down. All in-flight calls and subscriptions are cancelled
* with this code at disposal.
*/
export const EVENTS_PROXY_CODES = defineCodes("EVENTS_PROXY", [
"UNSERIALISABLE_PAYLOAD",
"SUBSCRIPTION_DROPPED",
"BACKPRESSURE_EXCEEDED",
"PROXY_DISPOSED",
] as const);
export type EventsProxyErrorCode =
(typeof EVENTS_PROXY_CODES)[keyof typeof EVENTS_PROXY_CODES];
/**
* Concrete error class for the `api-events-worker-proxy` package.
* Extends {@link SuaveplanError} so `instanceof EventsProxyError`
* narrows `code` to {@link EventsProxyErrorCode}. Constructor is
* `public` so call sites may throw directly.
*/
export class EventsProxyError extends SuaveplanError<EventsProxyErrorCode> {
public readonly eventsProxyCode: EventsProxyErrorCode;
public constructor(
code: EventsProxyErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.eventsProxyCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
EVENTS_PROXY_CODES,
EventsProxyError,
type EventsProxyErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,20 @@
export * as errorCodes from "./error-codes/index.js";
// Convenience named re-exports of the load-bearing factories.
export {
EVENTS_PROXY_CODES,
EventsProxyError,
type EventsProxyErrorCode,
} from "./error-codes/index.js";
export * as mainSide from "./main-side/index.js";
export {
createEventBusProxyServer,
type EventBusProxyServer,
} from "./main-side/index.js";
export * as payloadCodec from "./payload-codec/index.js";
export * as testing from "./testing/index.js";
export * as workerSide from "./worker-side/index.js";
export {
type CreateEventBusProxyClientOptions,
createEventBusProxyClient,
type EventBusProxyClient,
} from "./worker-side/index.js";

View File

@@ -0,0 +1,4 @@
export {
createEventBusProxyServer,
type EventBusProxyServer,
} from "./main-side.js";

View File

@@ -0,0 +1,61 @@
# `main-side`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Mounts a main-process `@suaveplan/events` `EventBus` on a `MessagePort` via the `@suaveplan/api-worker-rpc` server so worker-tier plugins can publish to and subscribe from the same shared bus. The host owns the `EventBus`; this module installs the RPC handlers and lifecycle.
Three RPC methods are exposed: `emit`, `emitAsync`, and the streaming `subscribe`. Subscriptions return an `AsyncIterable<unknown>` that yields one chunk per main-side event until the worker cancels (or `mode: "once"` ends after the first delivery).
## Features
- Wires `emit` / `emitAsync` / `subscribe` over the canonical worker-RPC protocol — no bespoke wire format.
- Per-subscription `highWaterMark` cap with `EVENTS_PROXY.BACKPRESSURE_EXCEEDED` rejection when the worker stops draining.
- Payloads are Zod-validated and structured-clone-probed on encode; unsafe payloads surface as `EVENTS_PROXY.UNSERIALISABLE_PAYLOAD` BEFORE they touch the port.
- `EventsProxyError` instances are serialised onto the wire by `api-worker-rpc`'s `errorToEnvelope` so the worker side reconstructs typed errors.
- `dispose()` shuts the underlying `WorkerRpcServer` down; in-flight `subscribe` streams emit `stream-end` via the goodbye flow.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import { createEventBusProxyServer } from "@suaveplan/api-events-worker-proxy/main-side";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
const bus = new EventBus<{ "billing.invoice.created": { id: string } }>();
const { clientPort, serverPort, close } = createInProcessPortPair();
const server = createEventBusProxyServer(serverPort, bus);
// Pass `clientPort` to a worker; tear down with `server.dispose()` + close().
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import { createEventBusProxyServer } from "@suaveplan/api-events-worker-proxy/main-side";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
type Map = { "drive.chunk.delivered": { chunkId: string } };
const bus = new EventBus<Map>();
const { clientPort, serverPort } = createInProcessPortPair();
const server = createEventBusProxyServer(serverPort, bus);
// In-process subscribers keep receiving events as usual.
bus.on("drive.chunk.delivered", ({ chunkId }) =>
console.log("main-side observed", chunkId)
);
process.on("beforeExit", () => server.dispose());
```
## API Reference
- `createEventBusProxyServer<TMap>(port: PortLike, bus: EventBus<TMap>): EventBusProxyServer`.
- `EventBusProxyServer` — `{ dispose(reason?: string): void; readonly activeSubscriptionCount: number; }`.
## Implementation Notes
The handlers object passed to `serveWorkerRpc` declares three methods. `emit` and `emitAsync` parse positional params (`[topic, payload, traceId?, timeoutMs?]`), decode the wire envelope with `decodeEnvelope`, and forward to `bus.emit` / `bus.emitAsync`. The streaming `subscribe` handler builds an `AsyncIterable<unknown>` whose body subscribes to the bus, enqueues payloads on each event, and yields them on each iteration. Backpressure is enforced by checking `queue.length` against the worker-supplied high-water mark; over-budget events surface `BACKPRESSURE_EXCEEDED` and end the stream. Cancellation is wired through `AbortSignal`: the server's `cancel` frame triggers the per-call signal, which the iterator observes via `signal.aborted` plus a `wake()` callback so the pending `await` resolves immediately. The `unsubscribe` happens in the iterator's `finally` block so it runs on both normal completion and cancellation.

View File

@@ -0,0 +1,203 @@
import { EventBus } from "@suaveplan/events";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { EventsProxyError } from "../error-codes/error-codes.js";
import { createProxyPair, waitFor } from "../testing/testing.js";
type Map = { ping: number; user: { id: string } };
const teardowns: Array<() => Promise<void>> = [];
afterEach(async () => {
while (teardowns.length > 0) {
const t = teardowns.pop();
if (t !== undefined) await t();
}
});
function fresh(highWaterMark?: number) {
const pair = createProxyPair<Map>({
...(highWaterMark !== undefined ? { highWaterMark } : {}),
});
teardowns.push(pair.tearDown);
return pair;
}
describe("main-side proxy server", () => {
it("delivers a main-side emit to a worker-side listener", {
timeout: 10000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
client.on("ping", (n) => {
seen.push(n);
});
// Allow the streaming subscription to fully establish before
// the first emit, so we observe in-order delivery.
await waitFor(() => true, 100);
await new Promise((r) => setTimeout(r, 50));
bus.emit("ping", 1);
bus.emit("ping", 2);
bus.emit("ping", 3);
await waitFor(() => seen.length === 3, 2000);
expect(seen).toStrictEqual([1, 2, 3]);
});
it("supports once() with single delivery", { timeout: 10000 }, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
client.once("ping", (n) => {
seen.push(n);
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("ping", 10);
bus.emit("ping", 20);
await waitFor(() => seen.length >= 1, 2000);
await new Promise((r) => setTimeout(r, 50));
expect(seen).toStrictEqual([10]);
});
it("off() cancels delivery", { timeout: 10000 }, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
const listener = (n: number): void => {
seen.push(n);
};
client.on("ping", listener);
await new Promise((r) => setTimeout(r, 100));
bus.emit("ping", 1);
await waitFor(() => seen.length === 1, 2000);
client.off("ping", listener);
await new Promise((r) => setTimeout(r, 50));
bus.emit("ping", 2);
await new Promise((r) => setTimeout(r, 100));
expect(seen).toStrictEqual([1]);
});
it("worker-side emit reaches main-side listener (fire-and-forget)", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
bus.on("ping", (n) => {
seen.push(n);
});
const result = client.emit("ping", 7);
// emit returns synthetic zeros immediately.
expect(result).toStrictEqual({ delivered: 0, errors: [] });
await waitFor(() => seen.length === 1, 2000);
expect(seen).toStrictEqual([7]);
});
it("worker-side emitAsync reports delivered count", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
bus.on("ping", () => undefined);
bus.on("ping", () => undefined);
const result = await client.emitAsync("ping", 1);
expect(result.delivered).toBe(2);
expect(result.errorCount).toBe(0);
});
it("rejects non-cloneable payloads with UNSERIALISABLE_PAYLOAD", {
timeout: 5000,
}, async () => {
const { client } = fresh();
expect(() =>
client.emit("user" as never, { id: "x", fn: () => 1 } as never)
).toThrow(EventsProxyError);
});
it("emit rejects non-cloneable inputs main-side via emitAsync", {
timeout: 5000,
}, async () => {
const { client } = fresh();
await expect(
client.emitAsync("user" as never, { id: "x", fn: () => 1 } as never)
).rejects.toBeInstanceOf(EventsProxyError);
});
it("dispose tears down server", { timeout: 5000 }, () => {
const { server } = fresh();
server.dispose();
expect(server.activeSubscriptionCount).toBe(0);
});
it("activeSubscriptionCount reflects open subscriptions", {
timeout: 5000,
}, async () => {
const { client, server } = fresh();
client.on("ping", () => undefined);
client.on("user", () => undefined);
await waitFor(() => server.activeSubscriptionCount === 2, 2000);
expect(server.activeSubscriptionCount).toBe(2);
});
it("backpressure: subscription terminates when high-water exceeded", {
timeout: 10000,
}, async () => {
const { bus, client } = fresh(2);
const received: number[] = [];
const errors: Error[] = [];
client.on("ping", (v: unknown) => {
if (v instanceof Error) errors.push(v);
else received.push(v as number);
});
await new Promise((r) => setTimeout(r, 100));
// Burst many emits — backpressure should fire because the
// worker-side queue (inside streamRequest) is small.
for (let i = 0; i < 200; i++) bus.emit("ping", i);
await new Promise((r) => setTimeout(r, 300));
// Either we receive a bounded subset OR an error is surfaced.
expect(received.length + errors.length).toBeGreaterThan(0);
});
it("typed payloads survive the round-trip", { timeout: 5000 }, async () => {
const { bus, client } = fresh();
const seen: Array<{ id: string }> = [];
client.on("user", (u) => {
seen.push(u);
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("user", { id: "u_1" });
bus.emit("user", { id: "u_2" });
await waitFor(() => seen.length === 2, 2000);
expect(seen).toStrictEqual([{ id: "u_1" }, { id: "u_2" }]);
});
it("does not require a paired-bus dep — bus stays independent", {
timeout: 5000,
}, () => {
const { bus } = fresh();
expect(bus).toBeInstanceOf(EventBus);
});
it("main-side emit of non-cloneable payload ends the subscription with UNSERIALISABLE_PAYLOAD", {
timeout: 5000,
}, async () => {
// Cast through `any` so we can bypass the worker-side encode
// probe and exercise the main-side encode-failure branch
// inside `subscribeStream`.
type LooseMap = { weird: unknown };
const pair = createProxyPair<LooseMap>();
teardowns.push(pair.tearDown);
const { bus, client } = pair;
const seen: unknown[] = [];
client.on("weird", (v) => {
seen.push(v);
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("weird", { fn: () => undefined });
await waitFor(() => seen.length > 0, 2000);
// The first observed value should be the reconstructed
// EVENTS_PROXY.UNSERIALISABLE_PAYLOAD error.
const dropped = seen.find(
(s) =>
s instanceof Error &&
((s as Error & { code?: string }).code ===
"EVENTS_PROXY.UNSERIALISABLE_PAYLOAD" ||
(s as Error & { code?: string }).code ===
"EVENTS_PROXY.SUBSCRIPTION_DROPPED")
);
expect(dropped).toBeDefined();
});
});

View File

@@ -0,0 +1,222 @@
import {
type PortLike,
serveWorkerRpc,
type WorkerRpcHandlers,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import type { EventBus, EventMap, Unsubscribe } from "@suaveplan/events";
import {
EVENTS_PROXY_CODES,
EventsProxyError,
} from "../error-codes/error-codes.js";
import {
decodeEnvelope,
encodeEnvelope,
} from "../payload-codec/payload-codec.js";
/**
* Server handle returned by {@link createEventBusProxyServer}. Wraps a
* `WorkerRpcServer` and exposes `dispose()` plus an
* `activeSubscriptionCount` for observability.
*/
export interface EventBusProxyServer {
dispose(reason?: string): void;
readonly activeSubscriptionCount: number;
}
/**
* Mount a main-process `EventBus` on the supplied `PortLike` via
* `@suaveplan/api-worker-rpc`. Worker-side `subscribe` becomes a
* streaming RPC: each main-side event becomes a `stream-chunk` until
* the worker cancels.
*
* Methods exposed to the worker:
* - `emit(topic, payload, traceId?)` → `{ delivered, errorCount }`.
* - `emitAsync(topic, payload, traceId?, timeoutMs?)` → same shape.
* - `subscribe(topic, mode)` streaming method yielding each main-side
* event payload until the worker cancels (mode = `"on"`) or one
* event has been delivered (mode = `"once"`).
*/
export function createEventBusProxyServer<TMap extends EventMap>(
port: PortLike,
bus: EventBus<TMap>
): EventBusProxyServer {
let activeSubscriptions = 0;
const handlers: WorkerRpcHandlers = {
emit: (params): { delivered: number; errorCount: number } => {
const [topic, payload, traceId] = parseEmitArgs(params);
const decoded = decodeEnvelope({ topic, payload, traceId });
const result = bus.emit(
decoded.topic as keyof TMap,
decoded.payload as TMap[keyof TMap],
decoded.traceId
);
return {
delivered: result.delivered,
errorCount: result.errors.length,
};
},
emitAsync: async (
params
): Promise<{ delivered: number; errorCount: number }> => {
const [topic, payload, traceId, timeoutMs] =
parseEmitAsyncArgs(params);
const decoded = decodeEnvelope({ topic, payload, traceId });
const result = await bus.emitAsync(
decoded.topic as keyof TMap,
decoded.payload as TMap[keyof TMap],
{
...(decoded.traceId !== undefined
? { traceId: decoded.traceId }
: {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
}
);
return {
delivered: result.delivered,
errorCount: result.errors.length,
};
},
subscribe: (params, signal): AsyncIterable<unknown> =>
subscribeStream(
bus,
params,
signal,
() => {
activeSubscriptions++;
},
() => {
activeSubscriptions = Math.max(0, activeSubscriptions - 1);
}
),
};
const server: WorkerRpcServer = serveWorkerRpc(port, handlers);
return {
dispose(reason = "events-proxy disposed"): void {
server.close(reason);
},
get activeSubscriptionCount(): number {
return activeSubscriptions;
},
};
}
function parseEmitArgs(
params: ReadonlyArray<unknown>
): [string, unknown, string | undefined] {
const [topic, payload, traceId] = params as [string, unknown, unknown?];
return [topic, payload, typeof traceId === "string" ? traceId : undefined];
}
function parseEmitAsyncArgs(
params: ReadonlyArray<unknown>
): [string, unknown, string | undefined, number | undefined] {
const [topic, payload, traceId, timeoutMs] = params as [
string,
unknown,
unknown?,
unknown?,
];
return [
topic,
payload,
typeof traceId === "string" ? traceId : undefined,
typeof timeoutMs === "number" ? timeoutMs : undefined,
];
}
interface QueuedPayload {
readonly payload: unknown;
}
function subscribeStream<TMap extends EventMap>(
bus: EventBus<TMap>,
params: ReadonlyArray<unknown>,
signal: AbortSignal,
onStart: () => void,
onEnd: () => void
): AsyncIterable<unknown> {
const [topic, modeRaw, hwmRaw] = params as [string, string, number];
const mode: "on" | "once" = modeRaw === "once" ? "once" : "on";
const highWaterMark = hwmRaw;
return {
async *[Symbol.asyncIterator]() {
const queue: QueuedPayload[] = [];
let waiter: (() => void) | undefined;
let ended = false;
let bpError: EventsProxyError | undefined;
const wake = (): void => {
const w = waiter;
waiter = undefined;
w?.();
};
const listener = (payload: unknown): void => {
if (ended) return;
if (queue.length >= highWaterMark) {
bpError = new EventsProxyError(
EVENTS_PROXY_CODES.BACKPRESSURE_EXCEEDED,
`subscription ${topic} exceeded high-water mark ${highWaterMark}`,
{ context: { topic, highWaterMark } }
);
ended = true;
wake();
return;
}
let encoded: ReturnType<typeof encodeEnvelope>;
try {
encoded = encodeEnvelope(topic, payload);
} catch (encodeErr) {
bpError = encodeErr as EventsProxyError;
ended = true;
wake();
return;
}
queue.push({ payload: encoded.payload });
wake();
};
const unsubscribe: Unsubscribe =
mode === "once"
? bus.once(topic as keyof TMap, listener as never)
: bus.on(topic as keyof TMap, listener as never);
const onAbort = (): void => {
ended = true;
wake();
};
signal.addEventListener("abort", onAbort, { once: true });
onStart();
try {
while (true) {
const item = queue.shift();
if (item !== undefined) {
yield item.payload;
if (mode === "once") {
ended = true;
break;
}
continue;
}
if (ended) break;
await new Promise<void>((resolve) => {
waiter = resolve;
});
}
if (bpError !== undefined) throw bpError;
} finally {
signal.removeEventListener("abort", onAbort);
unsubscribe();
onEnd();
}
},
};
}

View File

@@ -0,0 +1,11 @@
export {
decodeEnvelope,
EventEnvelopeSchema,
encodeEnvelope,
probeStructuredClone,
type WireEventEnvelope,
} from "./payload-codec.js";
export {
DEFAULT_BACKPRESSURE_HIGH_WATER,
MAX_PROBE_DEPTH,
} from "./payload-codec.types.js";

View File

@@ -0,0 +1,73 @@
# `payload-codec`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes and decodes the wire envelope used to ship a single event from the main-process `EventBus` across a `MessagePort` to a worker-tier subscriber. Includes a depth-bounded structured-clone safety probe so payloads that cannot survive the transfer are rejected at the source rather than producing opaque `DataCloneError` exceptions at runtime.
The codec is intentionally conservative: anything beyond plain JSON-shaped data plus a small list of well-known cloneable host types (`Date`, `RegExp`, `Map`, `Set`, `ArrayBuffer`, typed arrays) is rejected with `EVENTS_PROXY.UNSERIALISABLE_PAYLOAD`. This matches the structured-clone algorithm but is checked synchronously and surfaces the offending path.
## Features
- `EventEnvelopeSchema` Zod schema for the wire frame (`{ topic, payload, traceId? }`).
- `encodeEnvelope(topic, payload, traceId?)` runs the probe, then constructs the envelope. Throws an `EventsProxyError` carrying the offending path on rejection.
- `decodeEnvelope(raw)` Zod-validates an inbound frame.
- `probeStructuredClone(value)` returns the offending path or `null`. Detects functions, symbols, `WeakMap`/`WeakSet`, class instances, cycles, and over-deep graphs (limit `MAX_PROBE_DEPTH = 64`).
- `DEFAULT_BACKPRESSURE_HIGH_WATER = 1024` — the default per-subscription queue cap.
## Usage: Basic
```ts
import {
encodeEnvelope,
decodeEnvelope,
} from "@suaveplan/api-events-worker-proxy/payload-codec";
const wire = encodeEnvelope("billing.invoice.created", { invoiceId: "i_1" });
const decoded = decodeEnvelope(wire);
console.log(decoded.topic, decoded.payload);
```
## Usage: Advanced
```ts
import {
probeStructuredClone,
encodeEnvelope,
} from "@suaveplan/api-events-worker-proxy/payload-codec";
import {
EventsProxyError,
EVENTS_PROXY_CODES,
} from "@suaveplan/api-events-worker-proxy/error-codes";
const payload = {
invoiceId: "i_1",
whenIssued: new Date(),
handler: () => undefined,
};
const offender = probeStructuredClone(payload);
if (offender !== null) {
throw new EventsProxyError(
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD,
`payload rejected at ${offender}`
);
}
const wire = encodeEnvelope("invoice.flagged", payload);
```
## API Reference
- `EventEnvelopeSchema` — `z.object({ topic: z.string().min(1), traceId: z.string().optional(), payload: z.unknown() })`.
- `WireEventEnvelope` — `z.infer<typeof EventEnvelopeSchema>`.
- `encodeEnvelope(topic: string, payload: unknown, traceId?: string): WireEventEnvelope`.
- `decodeEnvelope(raw: unknown): WireEventEnvelope`.
- `probeStructuredClone(value: unknown): string | null` — returns the offending JSON-path-like string or `null`.
- `MAX_PROBE_DEPTH: number` — `64`.
- `DEFAULT_BACKPRESSURE_HIGH_WATER: number` — `1024`.
## Implementation Notes
The probe walks the object graph synchronously with a `WeakSet` cycle detector, accumulating the offending JSON path. It deliberately rejects class instances (`Object.getPrototypeOf(value) !== Object.prototype`) because the receiving side reconstructs them as plain objects, breaking `instanceof`. `Date`, `RegExp`, `Map`, `Set`, `ArrayBuffer`, and `ArrayBuffer.isView`-typed values are recognised explicitly because they ARE structured-clone safe even though they have non-`Object` prototypes. The depth bound of 64 is generous for legitimate payloads but cuts off pathological inputs. Cycles are detected by `WeakSet`; the probe reports the path that re-entered the visited set rather than recursing infinitely.

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EventsProxyError } from "../error-codes/error-codes.js";
import {
decodeEnvelope,
EventEnvelopeSchema,
encodeEnvelope,
probeStructuredClone,
} from "./payload-codec.js";
import { MAX_PROBE_DEPTH } from "./payload-codec.types.js";
describe("probeStructuredClone — accepts", () => {
const cases: Array<[string, unknown]> = [
["null", null],
["undefined", undefined],
["primitives", { s: "x", n: 1, b: true }],
["nested plain objects", { a: { b: { c: 1 } } }],
["arrays", [1, 2, [3, [4]]]],
["Date", new Date(0)],
["RegExp", /abc/g],
["Map<string, number>", new Map([["a", 1]])],
["Set<number>", new Set([1, 2, 3])],
["ArrayBuffer", new ArrayBuffer(8)],
["Uint8Array", new Uint8Array([1, 2, 3])],
];
for (const [label, v] of cases) {
it(`accepts ${label}`, { timeout: 5000 }, () => {
expect(probeStructuredClone(v)).toBeNull();
});
}
});
describe("probeStructuredClone — rejects", () => {
it("rejects functions", { timeout: 5000 }, () => {
expect(probeStructuredClone({ fn: () => 1 })).toBe("$.fn");
});
it("rejects top-level function", { timeout: 5000 }, () => {
expect(probeStructuredClone(() => 1)).toBe("$");
});
it("rejects symbols", { timeout: 5000 }, () => {
expect(probeStructuredClone({ s: Symbol("x") })).toBe("$.s");
});
it("rejects WeakMap", { timeout: 5000 }, () => {
expect(probeStructuredClone({ m: new WeakMap() })).toBe("$.m");
});
it("rejects WeakSet", { timeout: 5000 }, () => {
expect(probeStructuredClone({ s: new WeakSet() })).toBe("$.s");
});
it("rejects class instances", { timeout: 5000 }, () => {
class Foo {
public x = 1;
}
expect(probeStructuredClone({ f: new Foo() })).toBe("$.f");
});
it("rejects cycles", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = { a: 1 };
obj["self"] = obj;
expect(probeStructuredClone(obj)).toBe("$.self");
});
it("rejects symbol keys", { timeout: 5000 }, () => {
const sym = Symbol("k");
expect(probeStructuredClone({ [sym]: 1 })).toBe("$");
});
it("rejects over-deep graphs", { timeout: 5000 }, () => {
let v: Record<string, unknown> = { x: 1 };
for (let i = 0; i < MAX_PROBE_DEPTH + 2; i++) {
v = { nested: v };
}
expect(probeStructuredClone(v)).not.toBeNull();
});
it("reports the path inside arrays", { timeout: 5000 }, () => {
expect(probeStructuredClone([null, () => 1])).toBe("$[1]");
});
it("rejects function inside Map value", { timeout: 5000 }, () => {
const m = new Map<string, unknown>([["k", () => 1]]);
expect(probeStructuredClone(m)).toBe("$.val[0]");
});
it("rejects function inside Map key", { timeout: 5000 }, () => {
const m = new Map<unknown, string>([[() => 1, "v"]]);
expect(probeStructuredClone(m)).toBe("$.key[0]");
});
it("rejects function inside Set", { timeout: 5000 }, () => {
const s = new Set<unknown>([() => 1]);
expect(probeStructuredClone(s)).toBe("$[0]");
});
});
describe("encodeEnvelope / decodeEnvelope", () => {
it("round-trips a JSON-shaped payload", { timeout: 5000 }, () => {
const wire = encodeEnvelope("t.a", { id: 1 });
const decoded = decodeEnvelope(wire);
expect(decoded).toStrictEqual({ topic: "t.a", payload: { id: 1 } });
});
it("propagates traceId when supplied", { timeout: 5000 }, () => {
const wire = encodeEnvelope("t.b", { x: 1 }, "trace-1");
expect(wire.traceId).toBe("trace-1");
expect(decodeEnvelope(wire).traceId).toBe("trace-1");
});
it("omits traceId when not supplied", { timeout: 5000 }, () => {
const wire = encodeEnvelope("t.c", null);
expect(wire.traceId).toBeUndefined();
});
it("throws EVENTS_PROXY.UNSERIALISABLE_PAYLOAD on rejection", {
timeout: 5000,
}, () => {
let thrown: unknown;
try {
encodeEnvelope("t.bad", { fn: () => 1 });
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(EventsProxyError);
const e = thrown as EventsProxyError;
expect(e.code).toBe("EVENTS_PROXY.UNSERIALISABLE_PAYLOAD");
expect(e.context).toMatchObject({ topic: "t.bad", path: "$.fn" });
});
it("decodeEnvelope rejects a malformed frame", { timeout: 5000 }, () => {
expect(() => decodeEnvelope({ topic: 1, payload: null })).toThrow();
});
it("decodeEnvelope rejects an empty topic", { timeout: 5000 }, () => {
expect(() => decodeEnvelope({ topic: "", payload: null })).toThrow();
});
it("schema parses optional traceId", { timeout: 5000 }, () => {
const r = EventEnvelopeSchema.parse({
topic: "t",
payload: 1,
traceId: "abc",
});
expect(r.traceId).toBe("abc");
});
});

View File

@@ -0,0 +1,139 @@
import z from "zod";
import {
EVENTS_PROXY_CODES,
EventsProxyError,
} from "../error-codes/error-codes.js";
import { MAX_PROBE_DEPTH } from "./payload-codec.types.js";
/**
* Wire schema for the marshalled per-event envelope. Carries the topic
* (already type-checked at the proxy boundary) and the payload that was
* accepted by the structured-clone probe.
*/
export const EventEnvelopeSchema = z.object({
topic: z.string().min(1),
traceId: z.string().optional(),
payload: z.unknown(),
});
export type WireEventEnvelope = z.infer<typeof EventEnvelopeSchema>;
/**
* Probe whether a value is round-trippable through structured-clone.
*
* Rejects:
* - `function` / `symbol` typed values — never cloneable.
* - WeakMap / WeakSet — explicitly disallowed by HTML spec.
* - Class instances with non-string keys or non-`Object.prototype` — the
* receiving side reconstructs them as plain objects so `instanceof`
* semantics will not survive.
* - Cyclic graphs — rejected via a visited set.
* - Graphs deeper than {@link MAX_PROBE_DEPTH}.
*
* Accepts primitives, plain objects, arrays, `Date`, `RegExp`, `Map`,
* `Set`, `ArrayBuffer`, typed arrays, and `null`.
*
* @returns the JSON-path-like `path` that triggered the rejection, or
* `null` if the value is round-trippable.
*/
export function probeStructuredClone(value: unknown): string | null {
const visited = new WeakSet<object>();
return probe(value, visited, "$", 0);
}
function probe(
value: unknown,
visited: WeakSet<object>,
path: string,
depth: number
): string | null {
if (depth > MAX_PROBE_DEPTH) return path;
if (value === null || value === undefined) return null;
const t = typeof value;
if (t === "function") return path;
if (t === "symbol") return path;
if (t !== "object") return null;
const obj = value as object;
if (visited.has(obj)) return path;
visited.add(obj);
if (obj instanceof WeakMap || obj instanceof WeakSet) return path;
if (obj instanceof Date) return null;
if (obj instanceof RegExp) return null;
if (obj instanceof ArrayBuffer) return null;
if (ArrayBuffer.isView(obj)) return null;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const inner = probe(obj[i], visited, `${path}[${i}]`, depth + 1);
if (inner !== null) return inner;
}
return null;
}
if (obj instanceof Map) {
let i = 0;
for (const [k, v] of obj) {
const kp = probe(k, visited, `${path}.key[${i}]`, depth + 1);
if (kp !== null) return kp;
const vp = probe(v, visited, `${path}.val[${i}]`, depth + 1);
if (vp !== null) return vp;
i++;
}
return null;
}
if (obj instanceof Set) {
let i = 0;
for (const v of obj) {
const vp = probe(v, visited, `${path}[${i}]`, depth + 1);
if (vp !== null) return vp;
i++;
}
return null;
}
const proto = Object.getPrototypeOf(obj);
if (proto !== null && proto !== Object.prototype) {
return path;
}
for (const key of Reflect.ownKeys(obj)) {
if (typeof key !== "string") return path;
const inner = probe(
(obj as Record<string, unknown>)[key],
visited,
`${path}.${key}`,
depth + 1
);
if (inner !== null) return inner;
}
return null;
}
/**
* Encode an `(topic, payload)` pair for cross-port transit. Throws an
* `EVENTS_PROXY.UNSERIALISABLE_PAYLOAD` `EventsProxyError` if the
* payload fails the structured-clone probe.
*/
export function encodeEnvelope(
topic: string,
payload: unknown,
traceId?: string
): WireEventEnvelope {
const offender = probeStructuredClone(payload);
if (offender !== null) {
throw new EventsProxyError(
EVENTS_PROXY_CODES.UNSERIALISABLE_PAYLOAD,
`Refusing to marshal payload: non-structured-clone-safe value at ${offender}`,
{ context: { topic, path: offender } }
);
}
const envelope: WireEventEnvelope = { topic, payload };
if (traceId !== undefined) envelope.traceId = traceId;
return envelope;
}
/**
* Decode and Zod-validate an inbound wire envelope. Defensive against
* forged/corrupt frames from the opposite end of the port.
*/
export function decodeEnvelope(raw: unknown): WireEventEnvelope {
return EventEnvelopeSchema.parse(raw);
}

View File

@@ -0,0 +1,15 @@
/**
* Default high-water mark for the worker-side delivery queue per
* subscription. Once an inflight subscription has accumulated more than
* this many undelivered events the main-side dispatcher rejects further
* `emit`/`emitAsync` calls for that topic with
* `EVENTS_PROXY.BACKPRESSURE_EXCEEDED`.
*/
export const DEFAULT_BACKPRESSURE_HIGH_WATER = 1024;
/**
* Maximum sane recursion depth for the structured-clone probe. Anything
* deeper than this is rejected as unsafe (a deliberate degenerate case
* such as a cycle, or simply too large to round-trip across the port).
*/
export const MAX_PROBE_DEPTH = 64;

View File

@@ -0,0 +1,5 @@
export {
createProxyPair,
type ProxyPairHandles,
waitFor,
} from "./testing.js";

View File

@@ -0,0 +1,66 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Re-usable test fixtures for `@suaveplan/api-events-worker-proxy`. Builds a real `EventBus`, a real Node.js `MessageChannel`, and wires the proxy server + client over the channel so downstream tests can exercise the full transport with zero mocks.
The exports here power the in-package conformance suite (`tests/conformance/run-events-bus-conformance.test.ts`) and are also consumed by plugin tests that want to verify their behaviour against the proxy surface specifically.
## Features
- `createProxyPair<TMap>(options?)` — returns `{ bus, server, client, tearDown }`. The bus is a fresh `EventBus<TMap>` instance; the server and client are mounted over the two ends of a Node `MessageChannel` returned by `createInProcessPortPair`.
- `tearDown()` calls `client.dispose()` (best-effort), then `server.dispose()`, then `pair.close()`; safe to call repeatedly.
- `waitFor(predicate, timeoutMs?)` — async-polls until the predicate returns `true` or the timeout elapses; bridges the sync/async gap when asserting that a worker-side listener has observed a bus emission.
## Usage: Basic
```ts
import { createProxyPair } from "@suaveplan/api-events-worker-proxy/testing";
type Map = { ping: number };
const { bus, client, tearDown } = createProxyPair<Map>();
let observed: number | undefined;
client.on("ping", (n) => {
observed = n;
});
bus.emit("ping", 42);
// flush via waitFor or sleep, then assert observed === 42.
await tearDown();
```
## Usage: Advanced
```ts
import {
createProxyPair,
waitFor,
} from "@suaveplan/api-events-worker-proxy/testing";
type Map = { user: { id: string } };
const { bus, client, tearDown } = createProxyPair<Map>({
busOptions: { clock: () => 42 },
highWaterMark: 8,
});
const observed: string[] = [];
client.on("user", ({ id }) => observed.push(id));
bus.emit("user", { id: "u_1" });
await waitFor(() => observed.length === 1);
await tearDown();
```
## API Reference
- `createProxyPair<TMap>(options?: { busOptions?, highWaterMark? }): ProxyPairHandles<TMap>`.
- `ProxyPairHandles<TMap>` — `{ bus, server, client, tearDown }`.
- `waitFor(predicate: () => boolean, timeoutMs?: number): Promise<void>`. Default timeout `2000ms`.
## Implementation Notes
The helper delegates to `@suaveplan/api-worker-rpc`'s `createInProcessPortPair` for the port plumbing, which wraps a Node `MessageChannel` in the canonical `PortLike` shape. `tearDown` swallows errors from `client.dispose()` because tests may already have torn the port down explicitly; `server.dispose()` is idempotent. `waitFor` polls on a 5ms interval — fine-grained enough to keep tests fast but coarse enough not to thrash the event loop.

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { createProxyPair, waitFor } from "./testing.js";
type Map = { ping: number };
const teardowns: Array<() => Promise<void>> = [];
afterEach(async () => {
while (teardowns.length > 0) {
const t = teardowns.pop();
if (t !== undefined) await t();
}
});
describe("testing harness", () => {
it("createProxyPair returns four handles", { timeout: 5000 }, async () => {
const pair = createProxyPair<Map>();
teardowns.push(pair.tearDown);
expect(pair.bus).toBeDefined();
expect(pair.server).toBeDefined();
expect(pair.client).toBeDefined();
expect(typeof pair.tearDown).toBe("function");
});
it("tearDown is idempotent", { timeout: 5000 }, async () => {
const pair = createProxyPair<Map>();
await pair.tearDown();
await pair.tearDown();
});
it("createProxyPair honors busOptions + highWaterMark", {
timeout: 5000,
}, async () => {
const pair = createProxyPair<Map>({
busOptions: { idGenerator: () => "fixed-id" },
highWaterMark: 16,
});
teardowns.push(pair.tearDown);
let captured: string | undefined;
pair.bus.use(async (env, next) => {
captured = env.id;
return next();
});
await pair.bus.emitAsync("ping", 1);
expect(captured).toBe("fixed-id");
});
it("waitFor resolves when predicate becomes true", {
timeout: 5000,
}, async () => {
let flag = false;
setTimeout(() => {
flag = true;
}, 20);
await waitFor(() => flag, 500);
expect(flag).toBe(true);
});
it("waitFor uses the default 2000ms timeout when none is supplied", {
timeout: 5000,
}, async () => {
let flag = false;
setTimeout(() => {
flag = true;
}, 20);
await waitFor(() => flag);
expect(flag).toBe(true);
});
it("waitFor throws when the predicate never resolves", {
timeout: 5000,
}, async () => {
await expect(waitFor(() => false, 30)).rejects.toThrow(
/did not resolve within 30ms/
);
});
});

View File

@@ -0,0 +1,78 @@
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { EventBus, type EventMap } from "@suaveplan/events";
import {
createEventBusProxyServer,
type EventBusProxyServer,
} from "../main-side/main-side.js";
import { createEventBusProxyClient } from "../worker-side/worker-side.js";
import type { EventBusProxyClient } from "../worker-side/worker-side.types.js";
/**
* Handle returned by {@link createProxyPair}. Holds a real `EventBus`,
* a real `MessageChannel`-backed port pair, and the two proxy halves
* (server + client) mounted on those ports, plus a `tearDown` helper.
*/
export interface ProxyPairHandles<TMap extends EventMap> {
readonly bus: EventBus<TMap>;
readonly server: EventBusProxyServer;
readonly client: EventBusProxyClient<TMap>;
tearDown(): Promise<void>;
}
/**
* Build a paired (main-side, worker-side) proxy backed by a real
* `EventBus` and a real `MessageChannel`. Used by the conformance
* suite and by downstream-package tests that want to verify their
* behaviour against the proxy surface.
*/
export function createProxyPair<TMap extends EventMap>(
options: {
busOptions?: ConstructorParameters<typeof EventBus<TMap>>[0];
highWaterMark?: number;
} = {}
): ProxyPairHandles<TMap> {
const bus = new EventBus<TMap>(options.busOptions);
const pair = createInProcessPortPair();
const server = createEventBusProxyServer(pair.serverPort, bus);
const client = createEventBusProxyClient<TMap>(
pair.clientPort,
options.highWaterMark !== undefined
? { highWaterMark: options.highWaterMark }
: {}
);
let disposed = false;
const tearDown = async (): Promise<void> => {
if (disposed) return;
disposed = true;
try {
await client.dispose("tearDown");
} catch {
// best-effort — proceed to server + port teardown
}
server.dispose();
pair.close();
};
return { bus, server, client, tearDown };
}
/**
* Poll-based async wait. Used by the conformance suite to bridge the
* asynchronous gap between `bus.emit` and the `stream-chunk` arriving
* worker-side.
*/
export async function waitFor(
predicate: () => boolean,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitFor: predicate did not resolve within ${timeoutMs}ms`
);
}
await new Promise((r) => setTimeout(r, 5));
}
}

View File

@@ -0,0 +1,5 @@
export { createEventBusProxyClient } from "./worker-side.js";
export type {
CreateEventBusProxyClientOptions,
EventBusProxyClient,
} from "./worker-side.types.js";

View File

@@ -0,0 +1,58 @@
# `worker-side`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a worker-side proxy that exposes an `EventBus`-shaped surface backed by a `PortLike` connected to a main-process `createEventBusProxyServer`. Plugin code authored against `@suaveplan/events` `EventBus` compiles unchanged against this surface: `on` / `once` / `off` / `emit` / `emitAsync` are all present with the same parameter shapes.
The proxy is intentionally not an `EventBus` instance — it is a structurally-equivalent value (see `EventBusProxyClient<TMap>`). `use` and `clear` are deliberately absent because interceptors run main-side only, and clearing across worker boundaries would corrupt every other worker's subscriptions.
## Features
- `on(topic, listener)` and `once(topic, listener)` open a streaming RPC subscription via `@suaveplan/api-worker-rpc`'s `streamRequest`. Each main-side emission lands as one `stream-chunk` and is dispatched to the local listener.
- `off(topic, listener)` looks up the originating subscription and aborts its controller, sending a `cancel` frame to the main side.
- `emit` is fire-and-forget (synchronous return of `{ delivered: 0, errors: [] }`); `emitAsync` returns `{ delivered, errorCount }` after the main bus drains.
- Synchronous structured-clone probe inside `emit`/`emitAsync` rejects non-cloneable payloads with `EVENTS_PROXY.UNSERIALISABLE_PAYLOAD` before the call is dispatched.
- `dispose()` aborts every active subscription with `EVENTS_PROXY.SUBSCRIPTION_DROPPED`, closes the underlying client, and rejects in-flight calls.
## Usage: Basic
```ts
import { createEventBusProxyClient } from "@suaveplan/api-events-worker-proxy/worker-side";
type Map = { "billing.invoice.created": { id: string } };
const events = createEventBusProxyClient<Map>(port);
events.on("billing.invoice.created", ({ id }) => console.log("got", id));
const r = await events.emitAsync("billing.invoice.created", { id: "i_1" });
console.log(r.delivered);
```
## Usage: Advanced
```ts
import { createEventBusProxyClient } from "@suaveplan/api-events-worker-proxy/worker-side";
const events = createEventBusProxyClient<MyMap>(port, {
highWaterMark: 256,
});
const off = events.on("drive.chunk.delivered", async ({ chunkId }) => {
await processChunk(chunkId);
});
off();
await events.dispose("worker exiting");
```
## API Reference
- `createEventBusProxyClient<TMap>(port: PortLike, options?: CreateEventBusProxyClientOptions): EventBusProxyClient<TMap>`.
- `EventBusProxyClient<TMap>` — typed `on`/`once`/`off`/`emit`/`emitAsync`/`dispose`.
- `CreateEventBusProxyClientOptions` — `{ highWaterMark?: number }`. Default high-water mark is `DEFAULT_BACKPRESSURE_HIGH_WATER = 1024`.
## Implementation Notes
The proxy holds a `WorkerRpcClient` (raw) plus a `StreamDispatcher` so streaming subscriptions multiplex inbound `stream-chunk` frames. Each `subscribe`/`once` invocation calls `streamRequest("subscribe", [topic, mode, hwm], { signal })` to open a long-lived stream; the iterator's loop dispatches every chunk to the local listener and exits on the worker-supplied `AbortSignal`. The handle returned by `on` / `once` is a closure that aborts the signal, which propagates a `cancel` frame back to the main side and ends the underlying stream cleanly. `emit` calls the `emit` method via `client.call("emit", args)` but returns synchronously — the EventBus synchronous shape doesn't allow waiting for the real delivered count, so callers needing the real numbers must use `emitAsync`. Listener throws are swallowed inside the `runStream` consumer so a single bad listener can't break the message loop.

View File

@@ -0,0 +1,243 @@
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { EventBus } from "@suaveplan/events";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { EventsProxyError } from "../error-codes/error-codes.js";
import { createEventBusProxyServer } from "../main-side/main-side.js";
import { createProxyPair, waitFor } from "../testing/testing.js";
import { createEventBusProxyClient } from "./worker-side.js";
type Map = { ping: number; user: { id: string } };
const teardowns: Array<() => Promise<void>> = [];
afterEach(async () => {
while (teardowns.length > 0) {
const t = teardowns.pop();
if (t !== undefined) await t();
}
});
function fresh() {
const pair = createProxyPair<Map>();
teardowns.push(pair.tearDown);
return pair;
}
describe("worker-side proxy client", () => {
it("createEventBusProxyClient accepts a port without options", {
timeout: 5000,
}, async () => {
const bus = new EventBus<Map>();
const pair = createInProcessPortPair();
const server = createEventBusProxyServer(pair.serverPort, bus);
const client = createEventBusProxyClient<Map>(pair.clientPort);
teardowns.push(async () => {
await client.dispose();
server.dispose();
pair.close();
});
const r = await client.emitAsync("ping", 1);
expect(r.delivered).toBe(0);
});
it("rejects emit on a disposed client", { timeout: 5000 }, async () => {
const { client } = fresh();
await client.dispose();
expect(() => client.emit("ping", 1)).toThrow(EventsProxyError);
});
it("rejects emitAsync on a disposed client", {
timeout: 5000,
}, async () => {
const { client } = fresh();
await client.dispose();
await expect(client.emitAsync("ping", 1)).rejects.toBeInstanceOf(
EventsProxyError
);
});
it("rejects on/off on a disposed client", { timeout: 5000 }, async () => {
const { client } = fresh();
await client.dispose();
expect(() => client.on("ping", () => undefined)).toThrow(
EventsProxyError
);
expect(() => client.off("ping", () => undefined)).toThrow(
EventsProxyError
);
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const { client } = fresh();
await client.dispose();
await client.dispose();
});
it("emit posts a non-cloneable payload rejection synchronously", {
timeout: 5000,
}, () => {
const { client } = fresh();
expect(() =>
client.emit("user" as never, { id: "x", fn: () => 1 } as never)
).toThrow(EventsProxyError);
});
it("off() is a no-op when the listener was never registered", {
timeout: 5000,
}, () => {
const { client } = fresh();
expect(() => client.off("ping", () => undefined)).not.toThrow();
});
it("off() does not unsubscribe when topic matches but listener differs", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
client.on("ping", (n) => {
seen.push(n);
});
await new Promise((r) => setTimeout(r, 50));
// Different listener for same topic — should NOT match.
client.off("ping", () => undefined);
bus.emit("ping", 1);
await waitFor(() => seen.length === 1, 2000);
expect(seen).toStrictEqual([1]);
});
it("emit returns synthetic zeros and posts the call fire-and-forget", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
bus.on("ping", (n) => {
seen.push(n);
});
const r = client.emit("ping", 42);
expect(r).toStrictEqual({ delivered: 0, errors: [] });
await waitFor(() => seen.length === 1, 2000);
expect(seen).toStrictEqual([42]);
});
it("emitAsync propagates traceId and timeoutMs", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
let observedTrace: string | undefined;
bus.use(async (env, next) => {
observedTrace = env.traceId;
return next();
});
await client.emitAsync("ping", 1, {
traceId: "trace-xyz",
timeoutMs: 500,
});
expect(observedTrace).toBe("trace-xyz");
});
it("dispose aborts in-flight subscriptions", {
timeout: 5000,
}, async () => {
const { client } = fresh();
const seen: unknown[] = [];
client.on("ping", (v) => {
seen.push(v);
});
await new Promise((r) => setTimeout(r, 50));
await client.dispose("test dispose");
// After dispose the listener should observe SUBSCRIPTION_DROPPED
// via the microtask path.
await new Promise((r) => setTimeout(r, 50));
const dropped = seen.find(
(s) =>
s instanceof EventsProxyError &&
s.code === "EVENTS_PROXY.SUBSCRIPTION_DROPPED"
);
expect(dropped).toBeDefined();
});
it("once() removes the subscription after first delivery", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
client.once("ping", (n) => {
seen.push(n);
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("ping", 1);
bus.emit("ping", 2);
await waitFor(() => seen.length === 1, 2000);
await new Promise((r) => setTimeout(r, 50));
expect(seen).toStrictEqual([1]);
});
it("listener throws are isolated from the message loop", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
let secondInvoked = false;
client.on("ping", () => {
throw new Error("boom");
});
client.on("ping", () => {
secondInvoked = true;
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("ping", 1);
await waitFor(() => secondInvoked, 2000);
expect(secondInvoked).toBe(true);
});
it("emit with traceId routes the call without throwing", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
bus.on("ping", (n) => {
seen.push(n);
});
const r = client.emit("ping", 1, "trace-emit");
expect(r).toStrictEqual({ delivered: 0, errors: [] });
await waitFor(() => seen.length === 1, 2000);
expect(seen).toStrictEqual([1]);
});
it("emit swallows downstream RPC rejections via the .catch handler", {
timeout: 5000,
}, async () => {
const { server, client } = fresh();
// Tear down the server so the next emit RPC rejects.
server.dispose("force RPC reject");
const r = client.emit("ping", 99);
expect(r).toStrictEqual({ delivered: 0, errors: [] });
await new Promise((r) => setTimeout(r, 50));
});
it("returned cancel is idempotent if invoked twice", {
timeout: 5000,
}, async () => {
const { client } = fresh();
const cancel = client.on("ping", () => undefined);
await new Promise((r) => setTimeout(r, 50));
cancel();
cancel(); // second call hits the `e === undefined` branch
});
it("the off-returned cancel unsubscribes the originating listener", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
const cancel = client.on("ping", (n) => {
seen.push(n);
});
await new Promise((r) => setTimeout(r, 100));
bus.emit("ping", 1);
await waitFor(() => seen.length === 1, 2000);
cancel();
await new Promise((r) => setTimeout(r, 50));
bus.emit("ping", 2);
await new Promise((r) => setTimeout(r, 100));
expect(seen).toStrictEqual([1]);
});
});

View File

@@ -0,0 +1,224 @@
import {
createRawWorkerRpcClient,
type PortLike,
StreamDispatcher,
streamRequest,
type WorkerRpcClient,
} from "@suaveplan/api-worker-rpc";
import type { EventMap, Listener } from "@suaveplan/events";
import {
EVENTS_PROXY_CODES,
EventsProxyError,
} from "../error-codes/error-codes.js";
import { encodeEnvelope } from "../payload-codec/payload-codec.js";
import { DEFAULT_BACKPRESSURE_HIGH_WATER } from "../payload-codec/payload-codec.types.js";
import type {
CreateEventBusProxyClientOptions,
EventBusProxyClient,
} from "./worker-side.types.js";
interface SubscriptionEntry {
readonly topic: string;
readonly listener: Listener<unknown>;
readonly mode: "on" | "once";
readonly abort: AbortController;
}
/**
* Build a worker-side proxy that exposes an `EventBus`-shaped surface
* over the supplied `PortLike` via `@suaveplan/api-worker-rpc`. The
* proxy marshals every `on` / `once` / `off` / `emit` / `emitAsync`
* to the main side and routes inbound stream-chunk frames back to the
* local listener via the streaming helper.
*/
export function createEventBusProxyClient<TMap extends EventMap>(
port: PortLike,
options: CreateEventBusProxyClientOptions = {}
): EventBusProxyClient<TMap> {
const highWaterMark =
options.highWaterMark ?? DEFAULT_BACKPRESSURE_HIGH_WATER;
const dispatcher = new StreamDispatcher();
const client: WorkerRpcClient = createRawWorkerRpcClient(port, {
onStreamFrame: dispatcher.dispatch,
});
const subscriptions = new Map<symbol, SubscriptionEntry>();
let disposed = false;
const ensureLive = (): void => {
if (disposed) {
throw new EventsProxyError(
EVENTS_PROXY_CODES.PROXY_DISPOSED,
"events proxy client has been disposed"
);
}
};
const subscribe = <K extends keyof TMap>(
type: K,
listener: Listener<TMap[K]>,
mode: "on" | "once"
): (() => void) => {
ensureLive();
const topic = String(type);
const handle = Symbol("subscription");
const abort = new AbortController();
const entry: SubscriptionEntry = {
topic,
listener: listener as Listener<unknown>,
mode,
abort,
};
subscriptions.set(handle, entry);
void runStream(
client,
dispatcher,
topic,
mode,
highWaterMark,
abort.signal,
(chunk) => {
try {
listener(chunk as TMap[K]);
} catch {
// listener-thrown errors are isolated from the
// worker message loop; main side does not see them.
}
},
(err) => {
// surface stream failure to the listener as the
// reconstructed error so the consumer can re-subscribe.
// Do NOT surface the abort itself — caller-triggered
// cancel is observable via the returned `off()` handle.
if (abort.signal.aborted) return;
try {
listener(err as unknown as TMap[K]);
} catch {
// isolated
}
},
() => {
subscriptions.delete(handle);
}
);
return () => {
const e = subscriptions.get(handle);
if (e === undefined) return;
subscriptions.delete(handle);
e.abort.abort();
};
};
const on = <K extends keyof TMap>(
type: K,
listener: Listener<TMap[K]>
): (() => void) => subscribe(type, listener, "on");
const once = <K extends keyof TMap>(
type: K,
listener: Listener<TMap[K]>
): (() => void) => subscribe(type, listener, "once");
const off = <K extends keyof TMap>(
type: K,
listener: Listener<TMap[K]>
): void => {
ensureLive();
const topic = String(type);
for (const [handle, entry] of subscriptions.entries()) {
if (entry.topic === topic && entry.listener === listener) {
subscriptions.delete(handle);
entry.abort.abort();
return;
}
}
};
const emit = <K extends keyof TMap>(
type: K,
payload: TMap[K],
traceId?: string
): { delivered: number; errors: ReadonlyArray<Error> } => {
ensureLive();
const envelope = encodeEnvelope(String(type), payload, traceId);
// Fire-and-forget: the worker has no synchronous channel to
// observe the main-side delivered count, so the EventBus.emit
// synchronous shape returns synthetic zeros. Use emitAsync to
// get the real numbers.
const args: unknown[] = [envelope.topic, envelope.payload];
if (envelope.traceId !== undefined) args.push(envelope.traceId);
void client
.call<{ delivered: number; errorCount: number }>("emit", args)
.catch(() => undefined);
return { delivered: 0, errors: [] };
};
const emitAsync = async <K extends keyof TMap>(
type: K,
payload: TMap[K],
opts: { traceId?: string; timeoutMs?: number } = {}
): Promise<{ delivered: number; errorCount: number }> => {
ensureLive();
const envelope = encodeEnvelope(String(type), payload, opts.traceId);
const args: unknown[] = [envelope.topic, envelope.payload];
args.push(envelope.traceId);
args.push(opts.timeoutMs);
return await client.call<{
delivered: number;
errorCount: number;
}>("emitAsync", args);
};
const dispose = async (reason = "worker disposed"): Promise<void> => {
if (disposed) return;
disposed = true;
for (const entry of subscriptions.values()) {
entry.abort.abort();
const dropped = new EventsProxyError(
EVENTS_PROXY_CODES.SUBSCRIPTION_DROPPED,
`subscription dropped: ${reason}`,
{ context: { topic: entry.topic } }
);
queueMicrotask(() => {
try {
entry.listener(dropped as never);
} catch {
// isolated
}
});
}
subscriptions.clear();
client.close(reason);
};
return { on, once, off, emit, emitAsync, dispose };
}
async function runStream(
client: WorkerRpcClient,
dispatcher: StreamDispatcher,
topic: string,
mode: "on" | "once",
highWaterMark: number,
signal: AbortSignal,
onChunk: (chunk: unknown) => void,
onError: (err: Error) => void,
onComplete: () => void
): Promise<void> {
try {
const iter = streamRequest(
client,
dispatcher,
"subscribe",
[topic, mode, highWaterMark],
{ signal }
);
for await (const chunk of iter) {
onChunk(chunk);
if (mode === "once") break;
}
} catch (err) {
onError(err as Error);
} finally {
onComplete();
}
}

View File

@@ -0,0 +1,46 @@
import type { EventMap, Listener } from "@suaveplan/events";
/**
* Surface returned by `createEventBusProxyClient`. Structurally mirrors
* the public method shape of `EventBus<TMap>` from `@suaveplan/events`
* — typed strictly so plugin code authored against the in-process bus
* compiles identically against the proxy.
*
* `emit` is fire-and-forget on the worker side: it posts the call and
* returns a synthetic `{ delivered: 0, errors: [] }` result because the
* actual dispatch happens main-side; await `emitAsync` for the real
* outcome. `use` and `clear` are NOT marshalled — interceptors run on
* the main bus only, and `clear` would tear down every other worker's
* subscriptions, so both methods are absent.
*/
export interface EventBusProxyClient<TMap extends EventMap> {
on<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): () => void;
once<K extends keyof TMap>(
type: K,
listener: Listener<TMap[K]>
): () => void;
off<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): void;
emit<K extends keyof TMap>(
type: K,
payload: TMap[K],
traceId?: string
): { delivered: number; errors: ReadonlyArray<Error> };
emitAsync<K extends keyof TMap>(
type: K,
payload: TMap[K],
options?: { traceId?: string; timeoutMs?: number }
): Promise<{ delivered: number; errorCount: number }>;
dispose(reason?: string): Promise<void>;
}
/**
* Options for {@link createEventBusProxyClient}.
*/
export interface CreateEventBusProxyClientOptions {
/**
* Per-subscription high-water mark — the main side rejects further
* deliveries on a subscription whose worker-side queue exceeds this
* value. Defaults to `DEFAULT_BACKPRESSURE_HIGH_WATER`.
*/
readonly highWaterMark?: number;
}

View File

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

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { createProxyPair, waitFor } from "../../src/testing/testing.js";
type Map = { ping: number; user: { id: string } };
const teardowns: Array<() => Promise<void>> = [];
afterEach(async () => {
while (teardowns.length > 0) {
const t = teardowns.pop();
if (t !== undefined) await t();
}
});
function fresh() {
const pair = createProxyPair<Map>();
teardowns.push(pair.tearDown);
return pair;
}
async function flushSubscribe(): Promise<void> {
// Allow the streaming subscribe RPC to fully open before emitting,
// so chunks arrive in FIFO order.
await new Promise((r) => setTimeout(r, 100));
}
describe("events-bus conformance via worker-proxy surface", () => {
it("FIFO ordering preserved across the port", {
timeout: 10000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
client.on("ping", (n) => {
seen.push(n);
});
await flushSubscribe();
for (let i = 0; i < 25; i++) bus.emit("ping", i);
await waitFor(() => seen.length === 25, 3000);
expect(seen).toStrictEqual(Array.from({ length: 25 }, (_, i) => i));
});
it("multiple listeners each receive the same event", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const a: number[] = [];
const b: number[] = [];
client.on("ping", (n) => {
a.push(n);
});
client.on("ping", (n) => {
b.push(n);
});
await flushSubscribe();
bus.emit("ping", 42);
await waitFor(() => a.length === 1 && b.length === 1, 2000);
expect(a).toStrictEqual([42]);
expect(b).toStrictEqual([42]);
});
it("typed payloads survive the round-trip", { timeout: 5000 }, async () => {
const { bus, client } = fresh();
const seen: Array<{ id: string }> = [];
client.on("user", (u) => {
seen.push(u);
});
await flushSubscribe();
bus.emit("user", { id: "u_1" });
bus.emit("user", { id: "u_2" });
await waitFor(() => seen.length === 2, 2000);
expect(seen).toStrictEqual([{ id: "u_1" }, { id: "u_2" }]);
});
it("worker → main emitAsync round-trips dispatch metadata", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
let aSeen = 0;
let bSeen = 0;
bus.on("ping", () => {
aSeen++;
});
bus.on("ping", () => {
bSeen++;
});
const r = await client.emitAsync("ping", 1);
expect(r.delivered).toBe(2);
expect(r.errorCount).toBe(0);
expect(aSeen).toBe(1);
expect(bSeen).toBe(1);
});
it("idempotent listeners (re-add same fn) deliver once per add", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
const seen: number[] = [];
const fn = (n: number): void => {
seen.push(n);
};
client.on("ping", fn);
client.on("ping", fn);
await flushSubscribe();
bus.emit("ping", 1);
await waitFor(() => seen.length === 2, 2000);
expect(seen).toStrictEqual([1, 1]);
});
it("interceptor running main-side observes worker-issued emit", {
timeout: 5000,
}, async () => {
const { bus, client } = fresh();
let interceptors = 0;
bus.use(async (_env, next) => {
interceptors++;
return next();
});
await client.emitAsync("ping", 1);
expect(interceptors).toBe(1);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-events-worker-proxy",
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\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
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,
hookTimeout: 60000,
},
});