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

@@ -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);
});
});
});