first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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.