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
This commit is contained in:
2026-05-08 16:19:16 +00:00
parent 96ec2456e1
commit a12c52b407
13 changed files with 483 additions and 24 deletions

View File

@@ -26,6 +26,11 @@ function createMockRedis() {
}
return {} as any;
}),
off: vi.fn((event: string, callback: (channel: string, message: string) => void) => {
if (event === "message" && messageListener === callback) {
messageListener = null;
}
}),
publications,
subscriptions,
unsubscriptions,
@@ -595,4 +600,102 @@ describe("createRedisEventTarget", () => {
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");
});
});
});

View File

@@ -626,6 +626,100 @@ describe("createWebSocketClientEventTarget", () => {
});
});
describe("close()", () => {
it("sends __unsubscribe for all active subscriptions", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener1 = vi.fn();
const listener2 = vi.fn();
eventTarget.addEventListener("topic:a", listener1);
eventTarget.addEventListener("topic:b", listener2);
(ws.send as ReturnType<typeof vi.fn>).mockClear();
eventTarget.close();
const sent = ws.sent.map((s: string) => JSON.parse(s));
const unsubscribes = sent.filter((e: any) => e.type === "__unsubscribe");
expect(unsubscribes).toHaveLength(2);
const topics = unsubscribes.map((e: any) => e.payload.topic);
expect(topics).toContain("topic:a");
expect(topics).toContain("topic:b");
});
it("restores original onmessage handler", () => {
const ws = createMockWebSocket();
const originalOnmessage = vi.fn();
ws.onmessage = originalOnmessage;
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
expect(ws.onmessage).not.toBe(originalOnmessage);
eventTarget.close();
expect(ws.onmessage).toBe(originalOnmessage);
});
it("does not deliver messages after close", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "hello" };
ws.simulateMessage(JSON.stringify(envelope));
expect(listener).not.toHaveBeenCalled();
});
it("does not send __subscribe after close", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
eventTarget.close();
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
expect(ws.send).not.toHaveBeenCalledWith(
expect.stringContaining("__subscribe"),
);
});
it("dispatchEvent returns true but does not send after close", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
eventTarget.close();
(ws.send as ReturnType<typeof vi.fn>).mockClear();
const event = new CustomEvent("test:event", {
detail: { type: "test", id: "event", payload: null },
}) as TestEvent;
const result = eventTarget.dispatchEvent(event);
expect(result).toBe(true);
expect(ws.send).not.toHaveBeenCalled();
});
it("is idempotent", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
eventTarget.close();
const sentCalls = ws.sent.filter((s: string) => JSON.parse(s).type === "__unsubscribe");
expect(sentCalls).toHaveLength(1);
});
});
describe("dispatchEvent (send path) edge cases", () => {
it("sends envelope with null payload", () => {
const ws = createMockWebSocket();

View File

@@ -819,4 +819,90 @@ describe("createWebSocketServerEventTarget", () => {
expect(rawWs).toBe(ws);
});
});
describe("close()", () => {
it("removes all connections and clears local listeners", () => {
const onDisconnection = vi.fn();
const server = createWebSocketServerEventTarget<TestEvent>({ onDisconnection });
const ws1 = createMockWebSocket();
const ws2 = createMockWebSocket();
server.addConnection(ws1 as any);
server.addConnection(ws2 as any);
server.close();
expect(onDisconnection).toHaveBeenCalledTimes(2);
});
it("no longer delivers events to removed connections after close", () => {
const server = createWebSocketServerEventTarget<TestEvent>();
const ws = createMockWebSocket();
server.addConnection(ws as any);
ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } }));
server.close();
const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" };
const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent;
server.dispatchEvent(event);
expect(ws.send).not.toHaveBeenCalledWith(JSON.stringify(envelope));
});
it("no longer delivers events to local listeners after close", () => {
const server = createWebSocketServerEventTarget<TestEvent>();
const listener = vi.fn();
server.addEventListener("chat:room1", listener);
server.close();
const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" };
const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent;
server.dispatchEvent(event);
expect(listener).not.toHaveBeenCalled();
});
it("restores original onmessage and onclose for all connections", () => {
const server = createWebSocketServerEventTarget<TestEvent>();
const ws = createMockWebSocket();
const originalOnmessage = vi.fn();
const originalOnclose = vi.fn();
ws.onmessage = originalOnmessage;
ws.onclose = originalOnclose;
server.addConnection(ws as any);
expect(ws.onmessage).not.toBe(originalOnmessage);
server.close();
expect(ws.onmessage).toBe(originalOnmessage);
expect(ws.onclose).toBe(originalOnclose);
});
it("does not close the WebSocket connections", () => {
const server = createWebSocketServerEventTarget<TestEvent>();
const ws = createMockWebSocket();
server.addConnection(ws as any);
server.close();
expect(ws.close).not.toHaveBeenCalled();
});
it("is idempotent", () => {
const onDisconnection = vi.fn();
const server = createWebSocketServerEventTarget<TestEvent>({ onDisconnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
server.close();
server.close();
expect(onDisconnection).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -473,6 +473,47 @@ describe("createWorkerHostEventTarget", () => {
expect(listener).not.toHaveBeenCalled();
});
});
describe("close()", () => {
it("restores original worker.onmessage handler", () => {
const worker = createMockWorker();
const originalOnmessage = vi.fn();
worker.onmessage = originalOnmessage as any;
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
expect(worker.onmessage).not.toBe(originalOnmessage);
eventTarget.close();
expect(worker.onmessage).toBe(originalOnmessage);
});
it("clears all listeners so events are no longer delivered", () => {
const worker = createMockWorker();
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
eventTarget.close();
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "data" };
worker.simulateMessage(envelope);
expect(listener).not.toHaveBeenCalled();
});
it("is idempotent", () => {
const worker = createMockWorker();
const originalOnmessage = vi.fn();
worker.onmessage = originalOnmessage as any;
const eventTarget = createWorkerHostEventTarget<TestEvent>(worker as any);
eventTarget.close();
eventTarget.close();
expect(worker.onmessage).toBe(originalOnmessage);
});
});
});
describe("createWorkerThreadEventTarget", () => {
@@ -769,6 +810,20 @@ describe("createWorkerThreadEventTarget", () => {
});
});
describe("createWorkerThreadEventTarget context guard", () => {
it("throws if globalThis.postMessage is not available", () => {
const originalPostMessage = (globalThis as any).postMessage;
delete (globalThis as any).postMessage;
try {
expect(() => createWorkerThreadEventTarget<TestEvent>()).toThrow(
"createWorkerThreadEventTarget must be called inside a Worker context where globalThis.postMessage is available",
);
} finally {
(globalThis as any).postMessage = originalPostMessage;
}
});
});
describe("bidirectional communication (host + thread)", () => {
it("host sends envelope that thread receives", () => {
const worker = createMockWorker();