Initial Commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
channelFromTopic,
|
||||
dispatchInboundFrame,
|
||||
LocalSubscriberRegistry,
|
||||
topicFromChannel,
|
||||
} from "./subscribe.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user