Initial Commit

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

View File

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

View File

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

View File

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

View File

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