Files
pubsub/test/event-target-redis.test.ts
glm-5.1 a12c52b407 fix: add close() lifecycle methods to all adapters, fix WS client handler preservation, add Worker thread context guard
- Add close() to Redis, WS Client, WS Server, Worker Host, Worker Thread adapters
  for graceful teardown (cleanup subscriptions, restore handlers, clear maps)
- WS Client now saves/restores original onmessage (consistent with WS Server)
- WS Client dispatchEvent/addEventListener/removeEventListener are no-ops after close()
- WS Server close() removes all connections and clears local listeners
- Redis close() unsubscribes all channels and removes message listener
- Worker Host/Thread close() restore original onmessage and clear callbacks
- Worker Thread throws clear error if globalThis.postMessage is unavailable
- Add double-call guard to WS Server removeConnection
- Export new adapter interface types (RedisEventTarget, WebSocketClientEventTarget, etc.)
- Add sideEffects: false to package.json for tree-shaking
- Update architecture docs: lifecycle section, close() contract, adapter status updates
- 22 new tests covering close(), handler restoration, idempotency, and context guard
2026-05-08 16:19:16 +00:00

701 lines
25 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { createRedisEventTarget } from "../src/event-target-redis.js";
import type { EventEnvelope, TypedEvent } from "../src/types.js";
type TestEvent = TypedEvent<string, EventEnvelope>;
function createMockRedis() {
const publications: { channel: string; message: string }[] = [];
const subscriptions: { channel: string }[] = [];
const unsubscriptions: { channel: string }[] = [];
let messageListener: ((channel: string, message: string) => void) | null = null;
return {
publish: vi.fn((channel: string, message: string) => {
publications.push({ channel, message });
}),
subscribe: vi.fn((channel: string) => {
subscriptions.push({ channel });
}),
unsubscribe: vi.fn((channel: string) => {
unsubscriptions.push({ channel });
}),
on: vi.fn((event: string, callback: (channel: string, message: string) => void) => {
if (event === "message") {
messageListener = callback;
}
return {} as any;
}),
off: vi.fn((event: string, callback: (channel: string, message: string) => void) => {
if (event === "message" && messageListener === callback) {
messageListener = null;
}
}),
publications,
subscriptions,
unsubscriptions,
simulateMessage(channel: string, message: string) {
if (messageListener) {
messageListener(channel, message);
}
},
};
}
describe("createRedisEventTarget", () => {
describe("dispatchEvent (publish path)", () => {
it("publishes to Redis with correct channel name matching event type", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const event = new CustomEvent("call.responded:uuid-123", {
detail: { type: "call.responded", id: "uuid-123", payload: { status: "ok" } },
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(publishClient.publish).toHaveBeenCalledTimes(1);
expect(publishClient.publish).toHaveBeenCalledWith(
"call.responded:uuid-123",
JSON.stringify({ type: "call.responded", id: "uuid-123", payload: { status: "ok" } }),
);
});
it("serializes the envelope detail using the default JSON serializer", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const envelope: EventEnvelope<"user.joined", { name: string }> = {
type: "user.joined",
id: "user-42",
payload: { name: "Alice" },
};
const event = new CustomEvent("user.joined:user-42", {
detail: envelope,
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(publishClient.publish).toHaveBeenCalledTimes(1);
const publishedMessage = publishClient.publications[0].message;
expect(JSON.parse(publishedMessage)).toEqual(envelope);
});
it("returns true from dispatchEvent", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const event = new CustomEvent("test.event:id1", {
detail: { type: "test.event", id: "id1", payload: null },
}) as TestEvent;
const result = eventTarget.dispatchEvent(event);
expect(result).toBe(true);
});
});
describe("addEventListener (subscribe path)", () => {
it("subscribes to Redis on the topic when first listener is added", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("message.sent:msg1", listener);
expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1);
expect(subscribeClient.subscribe).toHaveBeenCalledWith("message.sent:msg1");
});
it("dispatches deserialized messages to local listeners", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("message.sent:msg1", listener);
const envelope: EventEnvelope = {
type: "message.sent",
id: "msg1",
payload: "hello world",
};
subscribeClient.simulateMessage("message.sent:msg1", JSON.stringify(envelope));
expect(listener).toHaveBeenCalledTimes(1);
const receivedEvent = listener.mock.calls[0][0] as TestEvent;
expect(receivedEvent.type).toBe("message.sent:msg1");
expect(receivedEvent.detail).toEqual(envelope);
});
it("ignores messages on channels with no registered listeners", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
subscribeClient.simulateMessage("topic:b", JSON.stringify({ type: "topic", id: "b", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
});
describe("removeEventListener (unsubscribe path)", () => {
it("unsubscribes from Redis when the last listener for a topic is removed", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1);
eventTarget.removeEventListener("topic:a", listener);
expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1);
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:a");
});
it("does not unsubscribe from Redis while other listeners remain on the same topic", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener1 = vi.fn();
const listener2 = vi.fn();
eventTarget.addEventListener("event:type1", listener1);
eventTarget.addEventListener("event:type1", listener2);
expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1);
eventTarget.removeEventListener("event:type1", listener1);
expect(subscribeClient.unsubscribe).not.toHaveBeenCalled();
eventTarget.removeEventListener("event:type1", listener2);
expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1);
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("event:type1");
});
});
describe("topic scoping", () => {
it("uses type:id strings as Redis channel names", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("call.responded:uuid-456", listener);
expect(subscribeClient.subscribe).toHaveBeenCalledWith("call.responded:uuid-456");
const event = new CustomEvent("call.responded:uuid-456", {
detail: { type: "call.responded", id: "uuid-456", payload: { ok: true } },
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(publishClient.publications[0].channel).toBe("call.responded:uuid-456");
});
});
describe("EventEnvelope serialization round-trip", () => {
it("round-trips full { type, id, payload } envelope through JSON", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("user.joined:user-99", listener);
const originalEnvelope: EventEnvelope<"user.joined", { name: string; role: string }> = {
type: "user.joined",
id: "user-99",
payload: { name: "Bob", role: "admin" },
};
const event = new CustomEvent("user.joined:user-99", {
detail: originalEnvelope,
}) as TestEvent;
eventTarget.dispatchEvent(event);
const publishedMessage = publishClient.publications[0].message;
subscribeClient.simulateMessage("user.joined:user-99", publishedMessage);
expect(listener).toHaveBeenCalledTimes(1);
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
expect(receivedDetail).toEqual(originalEnvelope);
expect(receivedDetail.type).toBe("user.joined");
expect(receivedDetail.id).toBe("user-99");
expect(receivedDetail.payload).toEqual({ name: "Bob", role: "admin" });
});
it("round-trips envelope with null payload", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("ping:1", listener);
const envelope: EventEnvelope = { type: "ping", id: "1", payload: null };
subscribeClient.simulateMessage("ping:1", JSON.stringify(envelope));
expect(listener).toHaveBeenCalledTimes(1);
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
expect(receivedDetail).toEqual(envelope);
});
});
describe("multiple listeners on the same topic", () => {
it("subscribes to Redis only once when multiple listeners are added to the same topic", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener1 = vi.fn();
const listener2 = vi.fn();
const listener3 = vi.fn();
eventTarget.addEventListener("topic:x", listener1);
eventTarget.addEventListener("topic:x", listener2);
eventTarget.addEventListener("topic:x", listener3);
expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1);
expect(subscribeClient.subscribe).toHaveBeenCalledWith("topic:x");
});
it("delivers messages to all listeners on the same topic", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener1 = vi.fn();
const listener2 = vi.fn();
eventTarget.addEventListener("topic:x", listener1);
eventTarget.addEventListener("topic:x", listener2);
const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" };
subscribeClient.simulateMessage("topic:x", JSON.stringify(envelope));
expect(listener1).toHaveBeenCalledTimes(1);
expect(listener2).toHaveBeenCalledTimes(1);
expect((listener1.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
});
});
describe("custom serializer", () => {
it("uses custom serializer for stringify and parse", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const customSerializer = {
stringify: vi.fn((value: unknown) => JSON.stringify(value)),
parse: vi.fn((text: string) => JSON.parse(text)),
};
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
serializer: customSerializer,
});
const listener = vi.fn();
eventTarget.addEventListener("custom:event1", listener);
const envelope: EventEnvelope = { type: "custom", id: "event1", payload: { key: "value" } };
const event = new CustomEvent("custom:event1", { detail: envelope }) as TestEvent;
eventTarget.dispatchEvent(event);
expect(customSerializer.stringify).toHaveBeenCalledTimes(1);
expect(customSerializer.stringify).toHaveBeenCalledWith(envelope);
const publishedMessage = publishClient.publications[0].message;
subscribeClient.simulateMessage("custom:event1", publishedMessage);
expect(customSerializer.parse).toHaveBeenCalledTimes(1);
expect(customSerializer.parse).toHaveBeenCalledWith(publishedMessage);
expect(listener).toHaveBeenCalledTimes(1);
});
it("allows non-JSON serializers to round-trip envelopes", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const prefixSerializer = {
stringify: (value: unknown) => `PREFIX:${JSON.stringify(value)}`,
parse: (text: string) => JSON.parse(text.slice(7)),
};
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
serializer: prefixSerializer,
});
const listener = vi.fn();
eventTarget.addEventListener("test:str", listener);
const envelope: EventEnvelope = { type: "test", id: "str", payload: 42 };
const event = new CustomEvent("test:str", { detail: envelope }) as TestEvent;
eventTarget.dispatchEvent(event);
const publishedMessage = publishClient.publications[0].message;
expect(publishedMessage).toBe(`PREFIX:${JSON.stringify(envelope)}`);
subscribeClient.simulateMessage("test:str", publishedMessage);
expect(listener).toHaveBeenCalledTimes(1);
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
expect(receivedDetail).toEqual(envelope);
});
});
describe("channel prefix", () => {
it("publishes to prefixed channel when prefix is set", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const event = new CustomEvent("call.responded:uuid-123", {
detail: { type: "call.responded", id: "uuid-123", payload: { status: "ok" } },
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(publishClient.publish).toHaveBeenCalledWith(
"alk:events:call.responded:uuid-123",
expect.any(String),
);
});
it("subscribes to prefixed channel when prefix is set", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const listener = vi.fn();
eventTarget.addEventListener("call.responded:uuid-123", listener);
expect(subscribeClient.subscribe).toHaveBeenCalledWith("alk:events:call.responded:uuid-123");
});
it("unsubscribes from prefixed channel when prefix is set", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const listener = vi.fn();
eventTarget.addEventListener("call.responded:uuid-123", listener);
eventTarget.removeEventListener("call.responded:uuid-123", listener);
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("alk:events:call.responded:uuid-123");
});
it("delivers messages on prefixed channels to listeners", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const listener = vi.fn();
eventTarget.addEventListener("call.responded:uuid-123", listener);
const envelope: EventEnvelope = {
type: "call.responded",
id: "uuid-123",
payload: { status: "ok" },
};
subscribeClient.simulateMessage("alk:events:call.responded:uuid-123", JSON.stringify(envelope));
expect(listener).toHaveBeenCalledTimes(1);
expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
});
it("ignores messages on non-prefixed channels when prefix is set", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const listener = vi.fn();
eventTarget.addEventListener("call.responded:uuid-123", listener);
subscribeClient.simulateMessage("call.responded:uuid-123", JSON.stringify({ type: "call.responded", id: "uuid-123", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
it("defaults prefix to empty string", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const event = new CustomEvent("test:1", {
detail: { type: "test", id: "1", payload: null },
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(publishClient.publish).toHaveBeenCalledWith("test:1", expect.any(String));
});
});
describe("error handling", () => {
it("skips messages that fail to parse and logs a warning", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
subscribeClient.simulateMessage("topic:a", "not valid json{{");
expect(listener).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'Failed to parse message on channel "topic:a": not valid json{{',
);
warnSpy.mockRestore();
});
it("continues delivering valid messages after a parse error", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
subscribeClient.simulateMessage("topic:a", "broken{{{");
expect(listener).not.toHaveBeenCalled();
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "good" };
subscribeClient.simulateMessage("topic:a", JSON.stringify(envelope));
expect(listener).toHaveBeenCalledTimes(1);
expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
warnSpy.mockRestore();
});
});
describe("EventListenerObject support", () => {
it("addEventListener accepts EventListenerObject with handleEvent", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const handleEvent = vi.fn();
const listenerObject = { handleEvent };
eventTarget.addEventListener("obj:test", listenerObject);
const envelope: EventEnvelope = { type: "obj", id: "test", payload: true };
subscribeClient.simulateMessage("obj:test", JSON.stringify(envelope));
expect(handleEvent).toHaveBeenCalledTimes(1);
});
it("removeEventListener accepts EventListenerObject with handleEvent", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const handleEvent = vi.fn();
const listenerObject = { handleEvent };
eventTarget.addEventListener("obj:test2", listenerObject);
expect(subscribeClient.subscribe).toHaveBeenCalledWith("obj:test2");
eventTarget.removeEventListener("obj:test2", listenerObject);
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("obj:test2");
});
});
describe("close()", () => {
it("unsubscribes from all active channels", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener1 = vi.fn();
const listener2 = vi.fn();
eventTarget.addEventListener("topic:a", listener1);
eventTarget.addEventListener("topic:b", listener2);
eventTarget.close();
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:a");
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:b");
});
it("removes the message listener from subscribeClient", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
eventTarget.close();
expect(subscribeClient.off).toHaveBeenCalledWith("message", expect.any(Function));
});
it("does not receive messages after close", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "hello" };
subscribeClient.simulateMessage("topic:a", JSON.stringify(envelope));
expect(listener).not.toHaveBeenCalled();
});
it("is idempotent", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
eventTarget.close();
expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1);
});
it("handles close with no subscriptions", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
});
expect(() => eventTarget.close()).not.toThrow();
});
it("unsubscribes from prefixed channels correctly", () => {
const publishClient = createMockRedis();
const subscribeClient = createMockRedis();
const eventTarget = createRedisEventTarget<TestEvent>({
publishClient: publishClient as any,
subscribeClient: subscribeClient as any,
prefix: "alk:events:",
});
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("alk:events:topic:a");
});
});
});