Initial Commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user