import { describe, it, expect, vi, beforeEach } from "vitest"; import { createWebSocketServerEventTarget } from "../src/event-target-websocket-server.js"; import type { WebSocketLike, SpokeEventTarget } from "../src/event-target-websocket-server.js"; import type { EventEnvelope, TypedEvent } from "../src/types.js"; type TestEvent = TypedEvent; function createMockWebSocket(): WebSocketLike & { sent: string[]; simulateMessage: (data: string) => void; simulateClose: (code?: number, reason?: string) => void; } { let onmessageHandler: ((ev: { data: string }) => void) | null = null; let oncloseHandler: ((ev: { code: number; reason?: string }) => void) | null = null; const ws = { bufferedAmount: 0, sent: [] as string[], send: vi.fn((data: string) => { ws.sent.push(data); }) as any, close: vi.fn() as any, get onmessage() { return onmessageHandler; }, set onmessage(handler: ((ev: { data: string }) => void) | null) { onmessageHandler = handler; }, get onclose() { return oncloseHandler; }, set onclose(handler: ((ev: { code: number; reason?: string }) => void) | null) { oncloseHandler = handler; }, simulateMessage(data: string) { if (onmessageHandler) { onmessageHandler({ data }); } }, simulateClose(code: number = 1000, reason?: string) { if (oncloseHandler) { oncloseHandler({ code, reason }); } }, }; Object.defineProperty(ws, "onmessage", { get() { return onmessageHandler; }, set(handler: ((ev: { data: string }) => void) | null) { onmessageHandler = handler; }, }); Object.defineProperty(ws, "onclose", { get() { return oncloseHandler; }, set(handler: ((ev: { code: number; reason?: string }) => void) | null) { oncloseHandler = handler; }, }); return ws; } describe("createWebSocketServerEventTarget", () => { describe("addConnection", () => { it("sets up onmessage and onclose handlers on the WebSocket", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); expect(ws.onmessage).toBeNull(); expect(ws.onclose).toBeNull(); server.addConnection(ws as any); expect(ws.onmessage).not.toBeNull(); expect(ws.onclose).not.toBeNull(); }); it("calls onConnection callback when a new connection is added", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); expect(onConnection).toHaveBeenCalledTimes(1); expect(onConnection).toHaveBeenCalledWith(expect.any(Object), ws); const [spoke] = onConnection.mock.calls[0]; expect(spoke).toHaveProperty("addEventListener"); expect(spoke).toHaveProperty("removeEventListener"); expect(spoke).toHaveProperty("dispatchEvent"); expect(spoke).toHaveProperty("ws"); }); it("does not add the same connection twice", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); server.addConnection(ws as any); expect(onConnection).toHaveBeenCalledTimes(1); }); it("preserves original onclose handler", () => { const originalOnclose = vi.fn(); const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); ws.onclose = originalOnclose; server.addConnection(ws as any); ws.simulateClose(1000); expect(originalOnclose).toHaveBeenCalledTimes(1); }); }); describe("removeConnection", () => { it("cleans up subscription maps when a connection is removed", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); server.removeConnection(ws as any); 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("calls onDisconnection callback when a connection is removed", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ onDisconnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); server.removeConnection(ws as any); expect(onDisconnection).toHaveBeenCalledTimes(1); expect(onDisconnection).toHaveBeenCalledWith(expect.any(Object), ws); }); it("does not close the WebSocket", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); server.removeConnection(ws as any); expect(ws.close).not.toHaveBeenCalled(); }); it("restores original onmessage and onclose handlers", () => { const originalOnmessage = vi.fn(); const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); ws.onmessage = originalOnmessage; server.addConnection(ws as any); expect(ws.onmessage).not.toBe(originalOnmessage); server.removeConnection(ws as any); expect(ws.onmessage).toBe(originalOnmessage); }); }); describe("automatic cleanup on close", () => { it("calls removeConnection automatically when WebSocket closes", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ onDisconnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateClose(1000); expect(onDisconnection).toHaveBeenCalledTimes(1); }); }); describe("subscription tracking (__subscribe / __unsubscribe)", () => { it("adds connection to topic subscriber set on __subscribe", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); 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).toHaveBeenCalledWith(JSON.stringify(envelope)); }); it("is idempotent for duplicate __subscribe", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); 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).toHaveBeenCalledTimes(1); }); it("removes connection from topic subscriber set on __unsubscribe", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.simulateMessage(JSON.stringify({ type: "__unsubscribe", id: "", payload: { topic: "chat:room1" } })); 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.toHaveBeenCalled(); }); it("silently ignores invalid topic in __subscribe", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "" } })); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: {} })); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: 123 } })); 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.toHaveBeenCalled(); warnSpy.mockRestore(); }); it("does not dispatch __subscribe to local listeners", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("__subscribe" as any, listener); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); expect(listener).not.toHaveBeenCalled(); }); it("does not dispatch __unsubscribe to local listeners", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("__unsubscribe" as any, listener); ws.simulateMessage(JSON.stringify({ type: "__unsubscribe", id: "", payload: { topic: "chat:room1" } })); expect(listener).not.toHaveBeenCalled(); }); it("cleans up all subscriptions when a connection is removed", () => { const server = createWebSocketServerEventTarget(); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); ws1.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws2.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); server.removeConnection(ws1 as any); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(ws1.send).not.toHaveBeenCalledWith(JSON.stringify(envelope)); expect(ws2.send).toHaveBeenCalledWith(JSON.stringify(envelope)); }); }); describe("topic-based fan-out", () => { it("sends events only to connections subscribed to that topic", () => { const server = createWebSocketServerEventTarget(); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); ws1.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(ws1.send).toHaveBeenCalledWith(JSON.stringify(envelope)); expect(ws2.send).not.toHaveBeenCalledWith(JSON.stringify(envelope)); }); it("sends to multiple subscribed connections", () => { const server = createWebSocketServerEventTarget(); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); ws1.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws2.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(ws1.send).toHaveBeenCalledWith(JSON.stringify(envelope)); expect(ws2.send).toHaveBeenCalledWith(JSON.stringify(envelope)); }); it("routes events to different topics independently", () => { const server = createWebSocketServerEventTarget(); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); ws1.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws2.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room2" } })); const envelope1: EventEnvelope = { type: "chat", id: "room1", payload: "hello1" }; const envelope2: EventEnvelope = { type: "chat", id: "room2", payload: "hello2" }; const event1 = new CustomEvent("chat:room1", { detail: envelope1 }) as TestEvent; const event2 = new CustomEvent("chat:room2", { detail: envelope2 }) as TestEvent; server.dispatchEvent(event1); expect(ws1.send).toHaveBeenCalledWith(JSON.stringify(envelope1)); expect(ws2.send).not.toHaveBeenCalledWith(JSON.stringify(envelope1)); server.dispatchEvent(event2); expect(ws2.send).toHaveBeenCalledWith(JSON.stringify(envelope2)); expect(ws1.send).not.toHaveBeenCalledWith(JSON.stringify(envelope2)); }); it("does not send to unsubscribed connections", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); 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.toHaveBeenCalled(); }); }); describe("dispatchEvent on server target", () => { it("always returns true", () => { const server = createWebSocketServerEventTarget(); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; expect(server.dispatchEvent(event)).toBe(true); }); it("returns true even when there are no subscribers", () => { const server = createWebSocketServerEventTarget(); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; expect(server.dispatchEvent(event)).toBe(true); }); it("delivers to local listeners", () => { const server = createWebSocketServerEventTarget(); const listener = vi.fn(); server.addEventListener("chat:room1", listener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(listener).toHaveBeenCalledTimes(1); expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); }); it("delivers to local listeners even without WebSocket subscribers", () => { const server = createWebSocketServerEventTarget(); const listener = vi.fn(); server.addEventListener("chat:room1", listener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(listener).toHaveBeenCalledTimes(1); }); }); describe("incoming messages from spokes", () => { it("dispatches regular events to local listeners", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("chat:room1", listener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; ws.simulateMessage(JSON.stringify(envelope)); expect(listener).toHaveBeenCalledTimes(1); expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); }); it("delivers events from a spoke to its per-connection spoke target", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget; const spokeListener = vi.fn(); spoke.addEventListener("chat:room1", spokeListener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; ws.simulateMessage(JSON.stringify(envelope)); expect(spokeListener).toHaveBeenCalledTimes(1); expect((spokeListener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); }); it("does not deliver spoke events to other spokes' listeners", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); const spoke1 = onConnection.mock.calls[0][0] as SpokeEventTarget; const spoke2 = onConnection.mock.calls[1][0] as SpokeEventTarget; const spoke1Listener = vi.fn(); const spoke2Listener = vi.fn(); spoke1.addEventListener("chat:room1", spoke1Listener); spoke2.addEventListener("chat:room1", spoke2Listener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "from-ws1" }; ws1.simulateMessage(JSON.stringify(envelope)); expect(spoke1Listener).toHaveBeenCalledTimes(1); expect(spoke2Listener).not.toHaveBeenCalled(); }); }); describe("spoke target dispatchEvent", () => { it("sends events to the specific spoke connection", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget; const envelope: EventEnvelope = { type: "direct", id: "spoke1", payload: "hello" }; const event = new CustomEvent("direct:spoke1", { detail: envelope }) as TestEvent; spoke.dispatchEvent(event); expect(ws.send).toHaveBeenCalledWith(JSON.stringify(envelope)); expect(spoke.dispatchEvent(event)).toBe(true); }); }); describe("malformed JSON handling", () => { it("silently ignores malformed JSON and logs a warning", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("topic:a", listener); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); ws.simulateMessage("not valid json{{"); expect(listener).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse WebSocket message")); warnSpy.mockRestore(); }); it("continues delivering valid messages after a parse error", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("topic:a", listener); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); ws.simulateMessage("broken{{{"); const envelope: EventEnvelope = { type: "topic", id: "a", payload: "good" }; ws.simulateMessage(JSON.stringify(envelope)); expect(listener).toHaveBeenCalledTimes(1); warnSpy.mockRestore(); }); }); describe("backpressure handling", () => { it("closes connection when bufferedAmount exceeds threshold", () => { const server = createWebSocketServerEventTarget({ maxBufferedAmount: 1024 }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 2048; const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(ws.close).toHaveBeenCalledWith(1013, "Try Again Later"); }); it("calls onBackpressure callback before disconnecting", () => { const onBackpressure = vi.fn(); const server = createWebSocketServerEventTarget({ maxBufferedAmount: 1024, onBackpressure, }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 2048; const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(onBackpressure).toHaveBeenCalledTimes(1); expect(onBackpressure).toHaveBeenCalledWith(ws, 2048); }); it("removes connection after backpressure disconnect", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ maxBufferedAmount: 1024, onDisconnection, }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 2048; const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(onDisconnection).toHaveBeenCalledTimes(1); }); it("does not send the current event when backpressure threshold is exceeded", () => { const server = createWebSocketServerEventTarget({ maxBufferedAmount: 1024 }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 2048; const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; (ws.send as ReturnType).mockClear(); server.dispatchEvent(event); expect(ws.send).not.toHaveBeenCalled(); }); it("default maxBufferedAmount is 1MB (1_048_576)", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 1_048_577; const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; (ws.send as ReturnType).mockClear(); server.dispatchEvent(event); expect(ws.send).not.toHaveBeenCalled(); expect(ws.close).toHaveBeenCalledWith(1013, "Try Again Later"); }); it("allows sending when bufferedAmount is below threshold", () => { const server = createWebSocketServerEventTarget({ maxBufferedAmount: 1024 }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); ws.bufferedAmount = 512; 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).toHaveBeenCalledWith(JSON.stringify(envelope)); }); }); describe("send failure handling", () => { it("removes connection and fires onDisconnection when ws.send throws", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ onDisconnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); (ws.send as ReturnType).mockImplementation(() => { throw new Error("Connection closed"); }); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(onDisconnection).toHaveBeenCalledTimes(1); }); it("dispatchEvent still returns true when ws.send throws", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); server.addConnection(ws as any); ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } })); (ws.send as ReturnType).mockImplementation(() => { throw new Error("Connection closed"); }); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; expect(server.dispatchEvent(event)).toBe(true); }); }); describe("addEventListener / removeEventListener on server target", () => { it("adds and removes local listeners", () => { const server = createWebSocketServerEventTarget(); const listener = vi.fn(); server.addEventListener("chat:room1", listener); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(listener).toHaveBeenCalledTimes(1); server.removeEventListener("chat:room1", listener); server.dispatchEvent(event); expect(listener).toHaveBeenCalledTimes(1); }); it("supports EventListenerObject with handleEvent", () => { const server = createWebSocketServerEventTarget(); const handleEvent = vi.fn(); const listenerObject = { handleEvent }; server.addEventListener("chat:room1", listenerObject); const envelope: EventEnvelope = { type: "chat", id: "room1", payload: "hello" }; const event = new CustomEvent("chat:room1", { detail: envelope }) as TestEvent; server.dispatchEvent(event); expect(handleEvent).toHaveBeenCalledTimes(1); server.removeEventListener("chat:room1", listenerObject); server.dispatchEvent(event); expect(handleEvent).toHaveBeenCalledTimes(1); }); }); describe("per-connection spoke target", () => { it("exposes ws property on spoke target", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget; expect(spoke.ws).toBe(ws); }); it("spoke target dispatchEvent sends to specific connection", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws1 = createMockWebSocket(); const ws2 = createMockWebSocket(); server.addConnection(ws1 as any); server.addConnection(ws2 as any); const spoke1 = onConnection.mock.calls[0][0] as SpokeEventTarget; const envelope: EventEnvelope = { type: "direct", id: "spoke1", payload: "secret" }; const event = new CustomEvent("direct:spoke1", { detail: envelope }) as TestEvent; spoke1.dispatchEvent(event); expect(ws1.send).toHaveBeenCalledWith(JSON.stringify(envelope)); expect(ws2.send).not.toHaveBeenCalled(); }); it("spoke target dispatchEvent returns true", () => { const onConnection = vi.fn(); const server = createWebSocketServerEventTarget({ onConnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget; const envelope: EventEnvelope = { type: "direct", id: "spoke1", payload: "hello" }; const event = new CustomEvent("direct:spoke1", { detail: envelope }) as TestEvent; expect(spoke.dispatchEvent(event)).toBe(true); }); }); describe("invalid message handling", () => { it("ignores messages without a string type", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("topic:a", listener); ws.simulateMessage(JSON.stringify({ id: "1", payload: null })); expect(listener).not.toHaveBeenCalled(); }); it("handles non-string type gracefully", () => { const server = createWebSocketServerEventTarget(); const ws = createMockWebSocket(); const listener = vi.fn(); server.addConnection(ws as any); server.addEventListener("topic:a", listener); ws.simulateMessage(JSON.stringify({ type: 123, id: "1", payload: null })); expect(listener).not.toHaveBeenCalled(); }); }); describe("onDisconnection callback", () => { it("receives the spoke event target and raw WebSocket", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ onDisconnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); server.removeConnection(ws as any); expect(onDisconnection).toHaveBeenCalledTimes(1); const [spoke, rawWs] = onDisconnection.mock.calls[0]; expect(spoke).toHaveProperty("addEventListener"); expect(spoke).toHaveProperty("removeEventListener"); expect(spoke).toHaveProperty("dispatchEvent"); expect(spoke).toHaveProperty("ws"); expect(rawWs).toBe(ws); }); }); describe("close()", () => { it("removes all connections and clears local listeners", () => { const onDisconnection = vi.fn(); const server = createWebSocketServerEventTarget({ 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(); 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(); 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(); 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(); 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({ onDisconnection }); const ws = createMockWebSocket(); server.addConnection(ws as any); server.close(); server.close(); expect(onDisconnection).toHaveBeenCalledTimes(1); }); }); });