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,6 @@
export {
channelFromTopic,
dispatchInboundFrame,
LocalSubscriberRegistry,
topicFromChannel,
} from "./subscribe.js";

View File

@@ -0,0 +1,96 @@
# `subscribe` — local routing + topic / channel mapping
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the broker's local routing table — the in-process map from
topic name to handler set that the inbound pub/sub stream targets. Frames
land on the subscriber socket as `pmessage` arrays with the full channel
name; the helpers here strip the `channelPrefix`, look up the local handler
set, and dispatch the decoded envelope to every subscriber.
Per-handler error isolation is the load-bearing invariant: Redis does not
stop pushing frames just because one subscriber rejected its handler call,
so any rejection (including non-`Error` thrown values) is wrapped and routed
through an `onError` callback rather than allowed to abort the loop.
## Features
- `LocalSubscriberRegistry<Payload>` — `Map<topic, Set<handler>>` with O(1)
add/remove and a `snapshot(topic)` helper that returns a defensive copy
for safe iteration during fan-out.
- `channelFromTopic(topic, prefix)` / `topicFromChannel(channel, prefix)` —
the inverse functions stripping or building the `<prefix><topic>` channel
name. Returns `null` for non-matching channels so the broker can drop
cross-traffic without crashing.
- `dispatchInboundFrame(registry, topic, bytes, onError)` — decode +
fan-out wrapped in per-subscriber error isolation. Any decode failure or
handler rejection routes through `onError` and never propagates back to
the subscribe loop.
- Frame-decode coverage of every documented `WS_BROKER_REDIS.DESERIALISE_*`
path — empty frame, unknown magic byte, malformed JSON, schema mismatch,
gzip framing inconsistency.
## Usage: Basic
```ts
import {
LocalSubscriberRegistry,
channelFromTopic,
dispatchInboundFrame,
} from "@suaveplan/api-ws-broker-redis/subscribe";
const registry = new LocalSubscriberRegistry<{ id: number }>();
registry.add("orders", (env) => console.log("delivered", env.payload.id));
const channel = channelFromTopic("orders", "ws.broadcast.");
```
## Usage: Advanced
```ts
import {
dispatchInboundFrame,
LocalSubscriberRegistry,
} from "@suaveplan/api-ws-broker-redis/subscribe";
const registry = new LocalSubscriberRegistry();
const subscribeErrors: Error[] = [];
await dispatchInboundFrame(
registry,
"orders",
inboundFrame,
(err) => {
// Surface the decode/handler failure on a sidecar channel — the
// pub/sub loop must keep running so other topics stay healthy.
subscribeErrors.push(err);
}
);
```
## API Reference
- `class LocalSubscriberRegistry<Payload>` — `add`, `remove`, `size`,
`snapshot`, `clear`.
- `channelFromTopic(topic, prefix): string` — topic → channel name.
- `topicFromChannel(channel, prefix): string | null` — inverse; `null` for
non-matching channels.
- `dispatchInboundFrame(registry, topic, bytes, onError): Promise<void>` —
decode + isolated fan-out.
## Implementation Notes
`snapshot()` materialises an `Array<handler>` rather than returning the
underlying `Set` directly so a handler that calls `registry.remove(...)`
mid-fan-out does not silently skip its peers later in the loop. The cost is
one allocation per topic per dispatch, which is negligible against the
inbound frame's parse and decode work.
`dispatchInboundFrame` wraps non-`Error` rejections (e.g. `Promise.reject("boom")`)
into a real `Error` before invoking `onError`. This keeps the callback's
signature stable regardless of what the handler threw — operators consuming
the error never have to defend against `string`, `null`, or arbitrary
objects landing on the wire.

View File

@@ -0,0 +1,164 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeEnvelope } from "../publish/publish.js";
import {
channelFromTopic,
dispatchInboundFrame,
LocalSubscriberRegistry,
topicFromChannel,
} from "./subscribe.js";
const tenant = "tenant-a" as unknown as TenantId;
describe("LocalSubscriberRegistry", () => {
it("add + snapshot + remove behave as a Set", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
const h = (): void => undefined;
r.add("orders", h);
expect(r.size("orders")).toBe(1);
expect(r.snapshot("orders").length).toBe(1);
r.remove("orders", h);
expect(r.size("orders")).toBe(0);
expect(r.snapshot("orders").length).toBe(0);
});
it("second add to same topic reuses existing Set (no rebuild)", {
timeout: 5000,
}, () => {
const r = new LocalSubscriberRegistry<number>();
const h1 = (): void => undefined;
const h2 = (): void => undefined;
r.add("orders", h1);
r.add("orders", h2);
expect(r.size("orders")).toBe(2);
});
it("remove with multiple handlers keeps Set alive until empty", {
timeout: 5000,
}, () => {
const r = new LocalSubscriberRegistry<number>();
const h1 = (): void => undefined;
const h2 = (): void => undefined;
r.add("orders", h1);
r.add("orders", h2);
r.remove("orders", h1);
expect(r.size("orders")).toBe(1);
r.remove("orders", h2);
expect(r.size("orders")).toBe(0);
});
it("remove on empty topic is a no-op", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
r.remove("missing", () => undefined);
expect(r.size("missing")).toBe(0);
});
it("clear empties every topic", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
r.add("a", () => undefined);
r.add("b", () => undefined);
r.clear();
expect(r.size("a")).toBe(0);
expect(r.size("b")).toBe(0);
});
});
describe("channelFromTopic / topicFromChannel", () => {
it("round-trips", { timeout: 5000 }, () => {
expect(channelFromTopic("orders", "ws.broadcast.")).toBe(
"ws.broadcast.orders"
);
expect(topicFromChannel("ws.broadcast.orders", "ws.broadcast.")).toBe(
"orders"
);
});
it("returns null for non-matching channel", { timeout: 5000 }, () => {
expect(topicFromChannel("foo.bar", "ws.broadcast.")).toBeNull();
});
});
describe("dispatchInboundFrame", () => {
it("decodes the frame and delivers to every handler", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
const seen: Array<BrokerEnvelope<{ id: number }>> = [];
r.add("orders", (env) => {
seen.push(env);
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 7 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(seen.length).toBe(1);
expect(seen[0]!.payload.id).toBe(7);
expect(errs.length).toBe(0);
});
it("routes decode failures through onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<unknown>();
r.add("orders", () => undefined);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", new Uint8Array([0xff]), (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
});
it("isolates handler rejections via onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
r.add("orders", () => {
throw new Error("handler boom");
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 1 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
expect(errs[0]!.message).toBe("handler boom");
});
it("handler rejection of non-Error value still routes via onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
r.add("orders", () => {
// Non-Error rejection — exercises the wrap branch in dispatchInboundFrame.
const rejection: unknown = "boom";
return Promise.reject(rejection as Error);
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 2 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
expect(errs[0]!.message).toBe("boom");
});
it("no handlers => no-op", { timeout: 5000 }, async () => {
const r = new LocalSubscriberRegistry<unknown>();
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", new Uint8Array([0xff]), (e) =>
errs.push(e)
);
expect(errs.length).toBe(0);
});
});

View File

@@ -0,0 +1,100 @@
import type { BrokerEnvelope, BrokerHandler } from "@suaveplan/api-ws/broker";
import { receivesCounter } from "../instruments/instruments.js";
import { decodeEnvelope } from "../publish/publish.js";
/**
* In-memory routing table the broker uses to deliver inbound pub/sub
* frames back to local subscribers. Frames arrive on a single
* `PSUBSCRIBE <channelPrefix>*` pattern and are dispatched by the topic
* derived from the channel suffix.
*/
export class LocalSubscriberRegistry<Payload> {
private readonly topics = new Map<string, Set<BrokerHandler<Payload>>>();
public add(topic: string, handler: BrokerHandler<Payload>): void {
let set = this.topics.get(topic);
if (set === undefined) {
set = new Set<BrokerHandler<Payload>>();
this.topics.set(topic, set);
}
set.add(handler);
}
public remove(topic: string, handler: BrokerHandler<Payload>): void {
const set = this.topics.get(topic);
if (set === undefined) return;
set.delete(handler);
if (set.size === 0) this.topics.delete(topic);
}
public size(topic: string): number {
return this.topics.get(topic)?.size ?? 0;
}
public clear(): void {
this.topics.clear();
}
public snapshot(topic: string): BrokerHandler<Payload>[] {
const set = this.topics.get(topic);
if (set === undefined) return [];
return Array.from(set);
}
}
/**
* Convert a Redis channel string back into the topic name a subscriber
* registered against. The leading `channelPrefix` is stripped; anything
* not matching the prefix is returned `null` so the caller can drop the
* frame.
*/
export function topicFromChannel(
channel: string,
channelPrefix: string
): string | null {
if (!channel.startsWith(channelPrefix)) return null;
return channel.slice(channelPrefix.length);
}
/**
* Convert a topic name to its full Redis channel name.
*/
export function channelFromTopic(topic: string, channelPrefix: string): string {
return `${channelPrefix}${topic}`;
}
/**
* Deliver a wire frame to every local subscriber for `topic`. Decode
* failures and per-subscriber handler errors are reported via the
* `onError` callback rather than propagating, because the subscriber loop
* runs across many independent topics — one bad subscriber must never
* abort sibling deliveries.
*/
export async function dispatchInboundFrame<Payload>(
registry: LocalSubscriberRegistry<Payload>,
topic: string,
bytes: Uint8Array,
onError: (err: Error) => void
): Promise<void> {
receivesCounter().add(1, { topic });
const handlers = registry.snapshot(topic);
if (handlers.length === 0) return;
let envelope: BrokerEnvelope<Payload>;
try {
envelope = decodeEnvelope<Payload>(bytes);
} catch (err) {
onError(err as Error);
return;
}
await Promise.all(
handlers.map((h) =>
Promise.resolve()
.then(() => h(envelope))
.catch((err: unknown) => {
onError(
err instanceof Error ? err : new Error(String(err))
);
})
)
);
}