Files
pubsub/test/event-target-websocket-server.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

908 lines
34 KiB
TypeScript

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<string, EventEnvelope>;
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<TestEvent>();
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<TestEvent>({ 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<TestEvent>({ 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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ 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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ 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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ onConnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget<TestEvent>;
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<TestEvent>({ 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<TestEvent>;
const spoke2 = onConnection.mock.calls[1][0] as SpokeEventTarget<TestEvent>;
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<TestEvent>({ onConnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget<TestEvent>;
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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ 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<TestEvent>({
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<TestEvent>({
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<TestEvent>({ 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<typeof vi.fn>).mockClear();
server.dispatchEvent(event);
expect(ws.send).not.toHaveBeenCalled();
});
it("default maxBufferedAmount is 1MB (1_048_576)", () => {
const server = createWebSocketServerEventTarget<TestEvent>();
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<typeof vi.fn>).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<TestEvent>({ 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<TestEvent>({ onDisconnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } }));
(ws.send as ReturnType<typeof vi.fn>).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<TestEvent>();
const ws = createMockWebSocket();
server.addConnection(ws as any);
ws.simulateMessage(JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "chat:room1" } }));
(ws.send as ReturnType<typeof vi.fn>).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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ onConnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget<TestEvent>;
expect(spoke.ws).toBe(ws);
});
it("spoke target dispatchEvent sends to specific connection", () => {
const onConnection = vi.fn();
const server = createWebSocketServerEventTarget<TestEvent>({ 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<TestEvent>;
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<TestEvent>({ onConnection });
const ws = createMockWebSocket();
server.addConnection(ws as any);
const spoke = onConnection.mock.calls[0][0] as SpokeEventTarget<TestEvent>;
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<TestEvent>();
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<TestEvent>();
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<TestEvent>({ 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<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);
});
});
});