release: WRNexusJS 0.8.0
This commit is contained in:
@@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process).
|
||||
interface PubSub {
|
||||
publish<T = unknown>(topic: string, message: T): Promise<void>;
|
||||
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
|
||||
```
|
||||
|
||||
- `publish(topic, message)` — resolves once the driver has dispatched the message.
|
||||
- `publish(topic, message)` — resolves once the driver and in-memory async handlers finish.
|
||||
- `subscribe(pattern, handler)` — returns an unsubscribe function.
|
||||
- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver.
|
||||
|
||||
### Pattern matching
|
||||
|
||||
@@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index
|
||||
(e.g. `redis://:secret@host:6379/2`).
|
||||
|
||||
```ts
|
||||
function redisDriver(url?: string): PubSubDriver & { close(): void };
|
||||
function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void };
|
||||
```
|
||||
|
||||
- Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use
|
||||
@@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void };
|
||||
- Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload
|
||||
that isn't valid JSON is delivered as the raw string.
|
||||
- `close()` tears down both the subscriber and publisher connections.
|
||||
- Lost sockets reconnect with bounded exponential backoff and active subscriptions
|
||||
are replayed. `maxPending` bounds unavailable-connection writes (default 1000);
|
||||
`reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms).
|
||||
|
||||
### RESP codec (internal)
|
||||
|
||||
@@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => {
|
||||
|
||||
await bus.publish("order:created", { id: 7 });
|
||||
|
||||
// on shutdown
|
||||
driver.close();
|
||||
// on shutdown (also closes the driver)
|
||||
await bus.close();
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./brokers": "./src/brokers.ts",
|
||||
"./redis": "./src/redis.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Handler, PubSubDriver } from "./index.ts";
|
||||
|
||||
export interface NatsClient {
|
||||
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
||||
subscribe(subject: string, handler: (data: Uint8Array, subject: string) => void): () => void;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export function natsDriver(client: NatsClient): PubSubDriver {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
return {
|
||||
publish(topic, message) {
|
||||
return client.publish(topic, encoder.encode(JSON.stringify(message)));
|
||||
},
|
||||
subscribe(pattern, handler) {
|
||||
const subject =
|
||||
pattern === "*" ? ">" : pattern.endsWith(":*") ? `${pattern.slice(0, -2)}:>` : pattern;
|
||||
return client.subscribe(subject, (data, topic) => {
|
||||
const raw = decoder.decode(data);
|
||||
let value: unknown = raw;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
/* raw broker payload */
|
||||
}
|
||||
void handler(value, topic);
|
||||
});
|
||||
},
|
||||
close: () => client.close?.(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface KafkaClient {
|
||||
publish(topic: string, value: string): void | Promise<void>;
|
||||
subscribe(pattern: string, handler: (value: string, topic: string) => void): () => void;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Kafka adapter contract; consumer-group/rebalance policy remains owned by the selected client. */
|
||||
export function kafkaDriver(client: KafkaClient): PubSubDriver {
|
||||
return {
|
||||
publish(topic, message) {
|
||||
return client.publish(topic, JSON.stringify(message));
|
||||
},
|
||||
subscribe(pattern, handler: Handler) {
|
||||
return client.subscribe(pattern, (raw, topic) => {
|
||||
let value: unknown = raw;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
/* raw broker payload */
|
||||
}
|
||||
void handler(value, topic);
|
||||
});
|
||||
},
|
||||
close: () => client.close?.(),
|
||||
};
|
||||
}
|
||||
@@ -15,11 +15,14 @@ export type Handler<T = unknown> = (message: T, topic: string) => void | Promise
|
||||
export interface PubSubDriver {
|
||||
publish(topic: string, message: unknown): void | Promise<void>;
|
||||
subscribe(pattern: string, handler: Handler): () => void;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PubSub {
|
||||
publish<T = unknown>(topic: string, message: T): Promise<void>;
|
||||
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
|
||||
/** Stop new work, remove subscriptions, and close the backing driver. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function patternMatches(pattern: string, topic: string): boolean {
|
||||
@@ -31,14 +34,19 @@ function patternMatches(pattern: string, topic: string): boolean {
|
||||
/** In-process pub/sub driver (default). */
|
||||
export function memoryDriver(): PubSubDriver {
|
||||
const subs = new Map<string, Set<Handler>>();
|
||||
let closed = false;
|
||||
return {
|
||||
publish(topic, message) {
|
||||
async publish(topic, message) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub driver is closed");
|
||||
const pending: Promise<void>[] = [];
|
||||
for (const [pattern, handlers] of subs) {
|
||||
if (!patternMatches(pattern, topic)) continue;
|
||||
for (const handler of handlers) void handler(message, topic);
|
||||
for (const handler of handlers) pending.push(Promise.resolve(handler(message, topic)));
|
||||
}
|
||||
await Promise.all(pending);
|
||||
},
|
||||
subscribe(pattern, handler) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub driver is closed");
|
||||
let set = subs.get(pattern);
|
||||
if (!set) subs.set(pattern, (set = new Set()));
|
||||
set.add(handler);
|
||||
@@ -47,19 +55,35 @@ export function memoryDriver(): PubSubDriver {
|
||||
if (!set!.size) subs.delete(pattern);
|
||||
};
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
subs.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a pub/sub bus over a driver (in-memory by default). */
|
||||
export function createPubSub(driver: PubSubDriver = memoryDriver()): PubSub {
|
||||
let closed = false;
|
||||
return {
|
||||
async publish(topic, message) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed");
|
||||
if (!topic.trim()) throw new TypeError("pubsub topic cannot be empty");
|
||||
await driver.publish(topic, message);
|
||||
},
|
||||
subscribe(pattern, handler) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed");
|
||||
if (!pattern.trim()) throw new TypeError("pubsub pattern cannot be empty");
|
||||
return driver.subscribe(pattern, handler as Handler);
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await driver.close?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createResilientPubSub, PresenceChannel } from "./resilient.ts";
|
||||
export type { MessageEnvelope, ResilientPubSubOptions, PresenceMember } from "./resilient.ts";
|
||||
export { natsDriver, kafkaDriver } from "./brokers.ts";
|
||||
export type { NatsClient, KafkaClient } from "./brokers.ts";
|
||||
|
||||
+121
-39
@@ -32,6 +32,15 @@ interface ParsedUrl {
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export interface RedisDriverOptions {
|
||||
/** Maximum writes buffered while Redis is unavailable. Default 1000. */
|
||||
maxPending?: number;
|
||||
/** Initial reconnect delay. Doubles up to reconnectMaxDelayMs. Default 100. */
|
||||
reconnectDelayMs?: number;
|
||||
/** Maximum reconnect delay. Default 5000. */
|
||||
reconnectMaxDelayMs?: number;
|
||||
}
|
||||
|
||||
function parseUrl(url: string): ParsedUrl {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
|
||||
@@ -59,58 +68,101 @@ const isPattern = (p: string): boolean => p.includes("*");
|
||||
* Open a Redis TCP connection. `onReply` receives every parsed top-level reply
|
||||
* (used by the subscriber connection to dispatch message/pmessage pushes).
|
||||
*/
|
||||
function connect(cfg: ParsedUrl, onReply?: (value: RespValue) => void): RedisConn {
|
||||
function connect(
|
||||
cfg: ParsedUrl,
|
||||
options: Required<RedisDriverOptions>,
|
||||
onReply?: (value: RespValue) => void,
|
||||
onReconnect?: () => void,
|
||||
): RedisConn {
|
||||
let socket: { write(data: Uint8Array): void; end(): void } | null = null;
|
||||
const pending: Uint8Array[] = [];
|
||||
const maxPending = 1000;
|
||||
let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
||||
let closed = false;
|
||||
let connecting = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let openedOnce = false;
|
||||
|
||||
// Bun.connect is available in the Bun runtime.
|
||||
const Bun = (globalThis as { Bun?: { connect: (opts: unknown) => Promise<unknown> } }).Bun;
|
||||
if (!Bun?.connect) throw new Error("redisDriver requires the Bun runtime (Bun.connect).");
|
||||
|
||||
void Bun.connect({
|
||||
hostname: cfg.host,
|
||||
port: cfg.port,
|
||||
tls: cfg.tls,
|
||||
socket: {
|
||||
open(s: { write(data: Uint8Array): void; end(): void }) {
|
||||
socket = s;
|
||||
if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password]));
|
||||
if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)]));
|
||||
for (const p of pending) s.write(p);
|
||||
pending.length = 0;
|
||||
const scheduleReconnect = () => {
|
||||
if (closed || reconnectTimer) return;
|
||||
const delay = Math.min(
|
||||
options.reconnectMaxDelayMs,
|
||||
options.reconnectDelayMs * 2 ** reconnectAttempt++,
|
||||
);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
start();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (closed || connecting) return;
|
||||
connecting = true;
|
||||
void Bun.connect({
|
||||
hostname: cfg.host,
|
||||
port: cfg.port,
|
||||
tls: cfg.tls,
|
||||
socket: {
|
||||
open(s: { write(data: Uint8Array): void; end(): void }) {
|
||||
connecting = false;
|
||||
socket = s;
|
||||
reconnectAttempt = 0;
|
||||
if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password]));
|
||||
if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)]));
|
||||
if (openedOnce) onReconnect?.();
|
||||
openedOnce = true;
|
||||
for (const p of pending) s.write(p);
|
||||
pending.length = 0;
|
||||
},
|
||||
data(_s: unknown, chunk: Uint8Array) {
|
||||
buf = buf.length ? concat([buf, chunk]) : chunk;
|
||||
for (;;) {
|
||||
const r = parseReply(buf);
|
||||
if (!r) break;
|
||||
buf = buf.slice(r.next);
|
||||
onReply?.(r.value);
|
||||
}
|
||||
},
|
||||
error() {
|
||||
socket = null;
|
||||
connecting = false;
|
||||
scheduleReconnect();
|
||||
},
|
||||
close() {
|
||||
socket = null;
|
||||
connecting = false;
|
||||
scheduleReconnect();
|
||||
},
|
||||
},
|
||||
data(_s: unknown, chunk: Uint8Array) {
|
||||
buf = buf.length ? concat([buf, chunk]) : chunk;
|
||||
for (;;) {
|
||||
const r = parseReply(buf);
|
||||
if (!r) break;
|
||||
buf = buf.slice(r.next);
|
||||
onReply?.(r.value);
|
||||
}
|
||||
},
|
||||
error() {
|
||||
/* connection error — writes silently no-op until reconnect */
|
||||
},
|
||||
close() {
|
||||
socket = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}).catch(() => {
|
||||
connecting = false;
|
||||
scheduleReconnect();
|
||||
});
|
||||
};
|
||||
start();
|
||||
|
||||
return {
|
||||
send(bytes) {
|
||||
if (socket) socket.write(bytes);
|
||||
else {
|
||||
if (pending.length >= maxPending) {
|
||||
if (closed) throw new Error("Redis pubsub connection is closed");
|
||||
if (pending.length >= options.maxPending) {
|
||||
throw new Error("Redis connection is unavailable and its pending write queue is full");
|
||||
}
|
||||
pending.push(bytes);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
pending.length = 0;
|
||||
socket?.end();
|
||||
socket = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -119,8 +171,22 @@ function connect(cfg: ParsedUrl, onReply?: (value: RespValue) => void): RedisCon
|
||||
* A cross-process pub/sub driver backed by Redis. `url` defaults to
|
||||
* `$REDIS_URL` or `redis://localhost:6379`.
|
||||
*/
|
||||
export function redisDriver(url?: string): PubSubDriver & { close(): void } {
|
||||
export function redisDriver(
|
||||
url?: string,
|
||||
options: RedisDriverOptions = {},
|
||||
): PubSubDriver & { close(): void } {
|
||||
const cfg = parseUrl(url ?? getEnv("REDIS_URL") ?? "redis://localhost:6379");
|
||||
const resolved = {
|
||||
maxPending: positiveInteger(options.maxPending ?? 1000, "Redis maxPending"),
|
||||
reconnectDelayMs: positiveInteger(options.reconnectDelayMs ?? 100, "Redis reconnectDelayMs"),
|
||||
reconnectMaxDelayMs: positiveInteger(
|
||||
options.reconnectMaxDelayMs ?? 5000,
|
||||
"Redis reconnectMaxDelayMs",
|
||||
),
|
||||
};
|
||||
if (resolved.reconnectMaxDelayMs < resolved.reconnectDelayMs) {
|
||||
throw new RangeError("Redis reconnectMaxDelayMs must be >= reconnectDelayMs");
|
||||
}
|
||||
const subs = new Map<string, Set<Handler>>();
|
||||
const redisSubscribed = new Set<string>();
|
||||
|
||||
@@ -136,13 +202,22 @@ export function redisDriver(url?: string): PubSubDriver & { close(): void } {
|
||||
for (const handler of handlers) void handler(message, topic);
|
||||
};
|
||||
|
||||
const subConn = connect(cfg, (value) => {
|
||||
if (!Array.isArray(value)) return;
|
||||
const kind = value[0];
|
||||
if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2]));
|
||||
else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3]));
|
||||
});
|
||||
const pubConn = connect(cfg);
|
||||
const subConn = connect(
|
||||
cfg,
|
||||
resolved,
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) return;
|
||||
const kind = value[0];
|
||||
if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2]));
|
||||
else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3]));
|
||||
},
|
||||
() => {
|
||||
for (const pattern of redisSubscribed) {
|
||||
subConn.send(encodeCommand([isPattern(pattern) ? "PSUBSCRIBE" : "SUBSCRIBE", pattern]));
|
||||
}
|
||||
},
|
||||
);
|
||||
const pubConn = connect(cfg, resolved);
|
||||
|
||||
return {
|
||||
publish(topic, message) {
|
||||
@@ -175,6 +250,13 @@ export function redisDriver(url?: string): PubSubDriver & { close(): void } {
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: number, label: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${label} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getEnv(key: string): string | undefined {
|
||||
return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
|
||||
key
|
||||
|
||||
@@ -26,9 +26,11 @@ export function createResilientPubSub(
|
||||
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
||||
throw new RangeError("pubsub retryDelayMs must be a non-negative number");
|
||||
}
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
async publish(topic, message) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed");
|
||||
if (!topic.trim()) throw new TypeError("pubsub topic cannot be empty");
|
||||
const envelope: MessageEnvelope = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -55,6 +57,7 @@ export function createResilientPubSub(
|
||||
},
|
||||
|
||||
subscribe<T>(pattern: string, handler: Handler<T>) {
|
||||
if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed");
|
||||
if (!pattern.trim()) throw new TypeError("pubsub pattern cannot be empty");
|
||||
return driver.subscribe(pattern, async (value, topic) => {
|
||||
const envelope = value as MessageEnvelope<T>;
|
||||
@@ -65,6 +68,11 @@ export function createResilientPubSub(
|
||||
}
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await driver.close?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createPubSub, kafkaDriver, natsDriver } from "../src/index.ts";
|
||||
|
||||
test("NATS adapter serializes messages and maps namespace wildcards", async () => {
|
||||
let subscription = "";
|
||||
let receive: ((data: Uint8Array, subject: string) => void) | undefined;
|
||||
const bus = createPubSub(
|
||||
natsDriver({
|
||||
async publish(subject, data) {
|
||||
receive?.(data, subject);
|
||||
},
|
||||
subscribe(subject, handler) {
|
||||
subscription = subject;
|
||||
receive = handler;
|
||||
return () => {
|
||||
receive = undefined;
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const values: unknown[] = [];
|
||||
bus.subscribe("room:*", (value) => {
|
||||
values.push(value);
|
||||
});
|
||||
await bus.publish("room:one", { online: 2 });
|
||||
expect(subscription).toBe("room:>");
|
||||
expect(values).toEqual([{ online: 2 }]);
|
||||
});
|
||||
|
||||
test("Kafka adapter preserves topic and JSON payload contracts", async () => {
|
||||
let receive: ((value: string, topic: string) => void) | undefined;
|
||||
const bus = createPubSub(
|
||||
kafkaDriver({
|
||||
async publish(topic, value) {
|
||||
receive?.(value, topic);
|
||||
},
|
||||
subscribe(_pattern, handler) {
|
||||
receive = handler;
|
||||
return () => {
|
||||
receive = undefined;
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
let received = "";
|
||||
bus.subscribe("events", (value, topic) => {
|
||||
received = `${topic}:${(value as { id: number }).id}`;
|
||||
});
|
||||
await bus.publish("events", { id: 7 });
|
||||
expect(received).toBe("events:7");
|
||||
});
|
||||
@@ -36,6 +36,21 @@ test("unsubscribe stops delivery", async () => {
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
|
||||
test("publish awaits async handlers and close rejects new work", async () => {
|
||||
const bus = createPubSub();
|
||||
let completed = false;
|
||||
bus.subscribe("task", async () => {
|
||||
await Promise.resolve();
|
||||
completed = true;
|
||||
});
|
||||
await bus.publish("task", {});
|
||||
expect(completed).toBe(true);
|
||||
await bus.close();
|
||||
await bus.close();
|
||||
await expect(bus.publish("task", {})).rejects.toThrow("WRN-PUBSUB-CLOSED");
|
||||
expect(() => bus.subscribe("task", () => {})).toThrow("WRN-PUBSUB-CLOSED");
|
||||
});
|
||||
|
||||
test("resilient pubsub validates retry and presence settings", () => {
|
||||
const driver = {
|
||||
publish: async () => {},
|
||||
|
||||
@@ -31,3 +31,43 @@ test("bounds writes queued while Redis is unavailable", () => {
|
||||
expect(() => driver.publish("topic", "overflow")).toThrow("queue is full");
|
||||
driver.close();
|
||||
});
|
||||
|
||||
test("validates reconnect and backpressure options", () => {
|
||||
bun.connect = (() => new Promise(() => {})) as typeof bun.connect;
|
||||
expect(() => redisDriver(undefined, { maxPending: 0 })).toThrow("maxPending");
|
||||
expect(() => redisDriver(undefined, { reconnectDelayMs: 20, reconnectMaxDelayMs: 10 })).toThrow(
|
||||
"reconnectMaxDelayMs",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnects and replays subscriptions after a socket closes", async () => {
|
||||
const connections: Array<Record<string, any>> = [];
|
||||
bun.connect = ((options: Record<string, any>) => {
|
||||
connections.push(options);
|
||||
return Promise.resolve({});
|
||||
}) as typeof bun.connect;
|
||||
const driver = redisDriver("redis://localhost:6379", {
|
||||
reconnectDelayMs: 1,
|
||||
reconnectMaxDelayMs: 1,
|
||||
});
|
||||
expect(connections).toHaveLength(2);
|
||||
|
||||
const firstWrites: string[] = [];
|
||||
connections[0].socket.open({
|
||||
write: (bytes: Uint8Array) => firstWrites.push(new TextDecoder().decode(bytes)),
|
||||
end() {},
|
||||
});
|
||||
driver.subscribe("order:*", () => {});
|
||||
expect(firstWrites.some((write) => write.includes("PSUBSCRIBE"))).toBe(true);
|
||||
|
||||
connections[0].socket.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(connections.length).toBeGreaterThanOrEqual(3);
|
||||
const replayed: string[] = [];
|
||||
connections[2].socket.open({
|
||||
write: (bytes: Uint8Array) => replayed.push(new TextDecoder().decode(bytes)),
|
||||
end() {},
|
||||
});
|
||||
expect(replayed.some((write) => write.includes("PSUBSCRIBE"))).toBe(true);
|
||||
driver.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user