Initial Commit
This commit is contained in:
90
packages/api-stack/api/api-events-worker-proxy/README.md
Normal file
90
packages/api-stack/api/api-events-worker-proxy/README.md
Normal 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)
|
||||
|
||||
93
packages/api-stack/api/api-events-worker-proxy/package.json
Normal file
93
packages/api-stack/api/api-events-worker-proxy/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
EVENTS_PROXY_CODES,
|
||||
EventsProxyError,
|
||||
type EventsProxyErrorCode,
|
||||
} from "./error-codes.js";
|
||||
20
packages/api-stack/api/api-events-worker-proxy/src/index.ts
Normal file
20
packages/api-stack/api/api-events-worker-proxy/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createEventBusProxyServer,
|
||||
type EventBusProxyServer,
|
||||
} from "./main-side.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createProxyPair,
|
||||
type ProxyPairHandles,
|
||||
waitFor,
|
||||
} from "./testing.js";
|
||||
@@ -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.
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createEventBusProxyClient } from "./worker-side.js";
|
||||
export type {
|
||||
CreateEventBusProxyClientOptions,
|
||||
EventBusProxyClient,
|
||||
} from "./worker-side.types.js";
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-events-worker-proxy/tsconfig.json
Normal file
12
packages/api-stack/api/api-events-worker-proxy/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user