@studyportals/ws-client 0.1.1-beta.7 → 0.1.1-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import { Emitter } from 'mitt';
2
+ import { z } from 'zod';
3
+
4
+ declare const wsIdentifiedPayloadSchema: z.ZodObject<{
5
+ connectionId: z.ZodString;
6
+ }, z.core.$strip>;
7
+ type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;
8
+
9
+ declare const campaignSavingPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
10
+ status: z.ZodLiteral<"start">;
11
+ }, z.core.$strip>, z.ZodObject<{
12
+ status: z.ZodLiteral<"success">;
13
+ }, z.core.$strip>, z.ZodObject<{
14
+ status: z.ZodLiteral<"failed">;
15
+ error: z.ZodRecord<z.ZodString, z.ZodUnknown>;
16
+ }, z.core.$strip>], "status">;
17
+ type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;
18
+
19
+ interface WsEventMap {
20
+ 'ws:connected': undefined;
21
+ 'ws:disconnected': CloseEvent;
22
+ 'ws:error': Event;
23
+ 'ws:max-retries-exceeded': undefined;
24
+ 'ws:unparseable': string;
25
+ /** Emitted when the server sends a message with type "ws:identified". */
26
+ 'ws:identified': WsIdentifiedPayload;
27
+ 'campaign:saving': CampaignSavingPayload;
28
+ }
29
+
30
+ /**
31
+ * Abstraction over a WebSocket connection.
32
+ *
33
+ * The manager wires up the callbacks and calls connect() / send() / close().
34
+ * Implementations handle the actual I/O (or simulation in tests).
35
+ */
36
+ interface IWebSocketTransport {
37
+ onopen: (() => void) | null;
38
+ onmessage: ((data: string) => void) | null;
39
+ onclose: ((event: CloseEvent) => void) | null;
40
+ onerror: ((event: Event) => void) | null;
41
+ /** Open the connection to the given URL. */
42
+ connect(url: string): void;
43
+ /** Send a raw string frame. */
44
+ send(data: string): void;
45
+ /** Close the connection with an optional status code and reason. */
46
+ close(code?: number, reason?: string): void;
47
+ }
48
+
49
+ type WebSocketManagerConfig = {
50
+ /** WebSocket endpoint URL (e.g. "wss://api.example.com/ws"). */
51
+ url: string;
52
+ /** Transport implementation. Use RealWebSocketTransport in production,
53
+ * MockWebSocketTransport in tests. */
54
+ transport: IWebSocketTransport;
55
+ /** Maximum number of reconnect attempts before giving up. Default: 10. */
56
+ maxReconnectAttempts?: number;
57
+ /** Base delay (ms) for exponential backoff. Default: 500. */
58
+ baseReconnectDelayMs?: number;
59
+ /** Upper cap (ms) for reconnect delay. Default: 30 000. */
60
+ maxReconnectDelayMs?: number;
61
+ /** Interval (ms) between heartbeat messages while connected. Default: 30 000. */
62
+ heartbeatIntervalMs?: number;
63
+ /** Payload sent as the heartbeat message. Default: `{ "type": "ping" }`. */
64
+ heartbeatMessage?: Record<string, unknown>;
65
+ };
66
+
67
+ type ClientOnlyEvent = 'ws:connected' | 'ws:disconnected' | 'ws:error' | 'ws:max-retries-exceeded' | 'ws:unparseable';
68
+ type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;
69
+ type ServerMessage = {
70
+ [K in ServerEventKey]: {
71
+ type: K;
72
+ payload: WsEventMap[K];
73
+ };
74
+ }[ServerEventKey];
75
+ declare const serverMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
76
+ type: z.ZodLiteral<"ws:identified">;
77
+ payload: z.ZodObject<{
78
+ connectionId: z.ZodString;
79
+ }, z.core.$strip>;
80
+ }, z.core.$strip>, z.ZodObject<{
81
+ type: z.ZodLiteral<"campaign:saving">;
82
+ payload: z.ZodDiscriminatedUnion<[z.ZodObject<{
83
+ status: z.ZodLiteral<"start">;
84
+ }, z.core.$strip>, z.ZodObject<{
85
+ status: z.ZodLiteral<"success">;
86
+ }, z.core.$strip>, z.ZodObject<{
87
+ status: z.ZodLiteral<"failed">;
88
+ error: z.ZodRecord<z.ZodString, z.ZodUnknown>;
89
+ }, z.core.$strip>], "status">;
90
+ }, z.core.$strip>], "type">;
91
+ type ServerMessageParsed = z.infer<typeof serverMessageSchema>;
92
+
93
+ type MittMap = WsEventMap & Record<string | symbol, unknown>;
94
+ type EventBus = Emitter<MittMap>;
95
+ declare const eventBus: EventBus;
96
+
97
+ declare class WebSocketManager {
98
+ private static instance;
99
+ private readonly bus;
100
+ private readonly config;
101
+ private reconnectAttempts;
102
+ private intentionalClose;
103
+ private heartbeatTimer;
104
+ private reconnectTimer;
105
+ private connectionId;
106
+ private constructor();
107
+ /**
108
+ * Returns the singleton instance, creating it on first call.
109
+ * Subsequent calls ignore `bus` and `config` — pass them only on first call.
110
+ */
111
+ static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager;
112
+ /**
113
+ * Destroy the singleton.
114
+ * Call before re-initialising in tests or when switching environments.
115
+ */
116
+ static reset(): void;
117
+ /** Returns the connectionId received from the last "ws:identified" message. */
118
+ getConnectionId(): string | undefined;
119
+ /**
120
+ * Resolves with the connectionId once the server sends "ws:identified".
121
+ * Resolves immediately if identification has already happened.
122
+ * Rejects if the socket disconnects before identification, or if
123
+ * `timeoutMs` elapses (default 10 000 ms).
124
+ */
125
+ waitForConnectionId(timeoutMs?: number): Promise<string>;
126
+ /** Gracefully close the connection without triggering automatic reconnect. */
127
+ disconnect(): void;
128
+ private connect;
129
+ private handleMessage;
130
+ private scheduleReconnect;
131
+ private clearReconnectTimer;
132
+ private startHeartbeat;
133
+ private clearHeartbeat;
134
+ }
135
+
136
+ declare class RealWebSocketTransport implements IWebSocketTransport {
137
+ private socket;
138
+ onopen: (() => void) | null;
139
+ onmessage: ((data: string) => void) | null;
140
+ onclose: ((event: CloseEvent) => void) | null;
141
+ onerror: ((event: Event) => void) | null;
142
+ connect(url: string): void;
143
+ send(data: string): void;
144
+ close(code?: number, reason?: string): void;
145
+ }
146
+
147
+ /**
148
+ * In-memory WebSocket transport for use in unit tests and Cypress specs.
149
+ *
150
+ * Wire it up via WebSocketManagerConfig.transport, then drive the connection
151
+ * state with simulateMessage() and simulateClose().
152
+ */
153
+ declare class MockWebSocketTransport implements IWebSocketTransport {
154
+ onopen: (() => void) | null;
155
+ onmessage: ((data: string) => void) | null;
156
+ onclose: ((event: CloseEvent) => void) | null;
157
+ onerror: ((event: Event) => void) | null;
158
+ private connected;
159
+ connect(_url: string): void;
160
+ /** No-op by design. Spy on this method to assert outgoing frames in tests. */
161
+ send(_data: string): void;
162
+ close(code?: number, _reason?: string): void;
163
+ /**
164
+ * Deliver a raw message string directly to the manager's onmessage handler.
165
+ * Call this from tests/Cypress to simulate server-pushed frames.
166
+ */
167
+ simulateMessage(data: string): void;
168
+ /**
169
+ * Fire a CloseEvent on the manager's onclose handler.
170
+ * Call this from tests/Cypress to simulate a server-initiated disconnect.
171
+ */
172
+ simulateClose(code?: number): void;
173
+ }
174
+
175
+ export { type CampaignSavingPayload, type EventBus, type IWebSocketTransport, MockWebSocketTransport, RealWebSocketTransport, type ServerEventKey, type ServerMessage, type ServerMessageParsed, WebSocketManager, type WebSocketManagerConfig, type WsEventMap, type WsIdentifiedPayload, campaignSavingPayloadSchema, eventBus, serverMessageSchema, wsIdentifiedPayloadSchema };
package/dist/index.js CHANGED
@@ -1,36 +1,78 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ MockWebSocketTransport: () => MockWebSocketTransport,
34
+ RealWebSocketTransport: () => RealWebSocketTransport,
35
+ WebSocketManager: () => WebSocketManager,
36
+ campaignSavingPayloadSchema: () => campaignSavingPayloadSchema,
37
+ eventBus: () => eventBus,
38
+ serverMessageSchema: () => serverMessageSchema,
39
+ wsIdentifiedPayloadSchema: () => wsIdentifiedPayloadSchema
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
1
43
  // src/event-bus.ts
2
- import mitt from "mitt";
3
- var eventBus = mitt();
44
+ var import_mitt = __toESM(require("mitt"));
45
+ var eventBus = (0, import_mitt.default)();
4
46
 
5
47
  // src/types/ws-identified.ts
6
- import { z } from "zod";
7
- var wsIdentifiedPayloadSchema = z.object({
8
- connectionId: z.string()
48
+ var import_zod = require("zod");
49
+ var wsIdentifiedPayloadSchema = import_zod.z.object({
50
+ connectionId: import_zod.z.string()
9
51
  });
10
52
 
11
53
  // src/types/campaign-saving.ts
12
- import { z as z2 } from "zod";
13
- var campaignSavingStartSchema = z2.object({
14
- status: z2.literal("start")
54
+ var import_zod2 = require("zod");
55
+ var campaignSavingStartSchema = import_zod2.z.object({
56
+ status: import_zod2.z.literal("start")
15
57
  });
16
- var campaignSavingSuccessSchema = z2.object({
17
- status: z2.literal("success")
58
+ var campaignSavingSuccessSchema = import_zod2.z.object({
59
+ status: import_zod2.z.literal("success")
18
60
  });
19
- var campaignSavingFailedSchema = z2.object({
20
- status: z2.literal("failed"),
21
- error: z2.record(z2.string(), z2.unknown())
61
+ var campaignSavingFailedSchema = import_zod2.z.object({
62
+ status: import_zod2.z.literal("failed"),
63
+ error: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown())
22
64
  });
23
- var campaignSavingPayloadSchema = z2.discriminatedUnion("status", [
65
+ var campaignSavingPayloadSchema = import_zod2.z.discriminatedUnion("status", [
24
66
  campaignSavingStartSchema,
25
67
  campaignSavingSuccessSchema,
26
68
  campaignSavingFailedSchema
27
69
  ]);
28
70
 
29
71
  // src/types/server-message.ts
30
- import { z as z3 } from "zod";
31
- var serverMessageSchema = z3.discriminatedUnion("type", [
32
- z3.object({ type: z3.literal("ws:identified"), payload: wsIdentifiedPayloadSchema }),
33
- z3.object({ type: z3.literal("campaign:saving"), payload: campaignSavingPayloadSchema })
72
+ var import_zod3 = require("zod");
73
+ var serverMessageSchema = import_zod3.z.discriminatedUnion("type", [
74
+ import_zod3.z.object({ type: import_zod3.z.literal("ws:identified"), payload: wsIdentifiedPayloadSchema }),
75
+ import_zod3.z.object({ type: import_zod3.z.literal("campaign:saving"), payload: campaignSavingPayloadSchema })
34
76
  ]);
35
77
 
36
78
  // src/websocket-manager.ts
@@ -283,7 +325,8 @@ var MockWebSocketTransport = class {
283
325
  this.onclose?.(event);
284
326
  }
285
327
  };
286
- export {
328
+ // Annotate the CommonJS export names for ESM import in node:
329
+ 0 && (module.exports = {
287
330
  MockWebSocketTransport,
288
331
  RealWebSocketTransport,
289
332
  WebSocketManager,
@@ -291,5 +334,5 @@ export {
291
334
  eventBus,
292
335
  serverMessageSchema,
293
336
  wsIdentifiedPayloadSchema
294
- };
337
+ });
295
338
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/event-bus.ts","../src/types/ws-identified.ts","../src/types/campaign-saving.ts","../src/types/server-message.ts","../src/websocket-manager.ts","../src/transports/real.transport.ts","../src/transports/mock.transport.ts"],"sourcesContent":["import mitt from 'mitt';\nimport type { Emitter } from 'mitt';\nimport type { WsEventMap } from './types';\n\n// Intersect with Record<string | symbol, unknown> to satisfy mitt's generic\n// constraint while keeping WsEventMap clean of index signatures.\ntype MittMap = WsEventMap & Record<string | symbol, unknown>;\n\nexport type EventBus = Emitter<MittMap>;\n\nexport const eventBus: EventBus = mitt<MittMap>();\n","import { z } from 'zod';\n\nexport const wsIdentifiedPayloadSchema = z.object({\n connectionId: z.string(),\n});\n\nexport type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;\n","import { z } from 'zod';\n\nconst campaignSavingStartSchema = z.object({\n status: z.literal('start'),\n});\n\nconst campaignSavingSuccessSchema = z.object({\n status: z.literal('success'),\n});\n\nconst campaignSavingFailedSchema = z.object({\n status: z.literal('failed'),\n error: z.record(z.string(), z.unknown()),\n});\n\nexport const campaignSavingPayloadSchema = z.discriminatedUnion('status', [\n campaignSavingStartSchema,\n campaignSavingSuccessSchema,\n campaignSavingFailedSchema,\n]);\n\nexport type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;\n","import { z } from 'zod';\nimport type { WsEventMap } from './ws-event-map';\nimport { wsIdentifiedPayloadSchema } from './ws-identified';\nimport { campaignSavingPayloadSchema } from './campaign-saving';\n\n// ---------------------------------------------------------------------------\n// Client-only events — fired by the transport layer, never sent by the server\n// ---------------------------------------------------------------------------\n\ntype ClientOnlyEvent =\n | 'ws:connected'\n | 'ws:disconnected'\n | 'ws:error'\n | 'ws:max-retries-exceeded'\n | 'ws:unparseable';\n\n// ---------------------------------------------------------------------------\n// ServerEventKey — every WsEventMap key the server is allowed to send\n// Automatically excludes client-only events; stays in sync with WsEventMap.\n// ---------------------------------------------------------------------------\n\nexport type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;\n\n// ---------------------------------------------------------------------------\n// ServerMessage — strict discriminated union of all valid server wire frames.\n// Use this in backend code and anywhere a server frame must be type-narrowed.\n//\n// Shape: { type: ServerEventKey; payload: WsEventMap[K] }\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = {\n [K in ServerEventKey]: { type: K; payload: WsEventMap[K] };\n}[ServerEventKey];\n\n// ---------------------------------------------------------------------------\n// serverMessageSchema — Zod discriminated union over every server event.\n// Validates that an incoming frame is both structurally and semantically valid.\n//\n// NOTE: if you extend WsEventMap via module augmentation you must also add\n// the corresponding member to this schema.\n// ---------------------------------------------------------------------------\n\nexport const serverMessageSchema = z.discriminatedUnion('type', [\n z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),\n z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),\n]);\n\nexport type ServerMessageParsed = z.infer<typeof serverMessageSchema>;\n","import type { EventBus } from './event-bus';\nimport type { WebSocketManagerConfig, WsIdentifiedPayload } from './types';\nimport { serverMessageSchema } from './types';\n\nexport type { WebSocketManagerConfig } from './types';\n\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;\nconst DEFAULT_BASE_RECONNECT_DELAY_MS = 500;\nconst DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;\nconst DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;\nconst DEFAULT_HEARTBEAT_MESSAGE: Record<string, unknown> = { action: \"who-am-I\" };\n\ntype ResolvedConfig = Required<WebSocketManagerConfig>;\n\nexport class WebSocketManager {\n private static instance: WebSocketManager | undefined;\n\n private readonly bus: EventBus;\n private readonly config: ResolvedConfig;\n\n private reconnectAttempts = 0;\n private intentionalClose = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | undefined;\n private reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n private connectionId: string | undefined;\n\n private constructor(bus: EventBus, config: WebSocketManagerConfig) {\n this.bus = bus;\n this.config = {\n url: config.url,\n transport: config.transport,\n maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,\n baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,\n maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,\n heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,\n heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,\n };\n\n this.connect();\n }\n\n /**\n * Returns the singleton instance, creating it on first call.\n * Subsequent calls ignore `bus` and `config` — pass them only on first call.\n */\n public static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager {\n if (!WebSocketManager.instance) {\n WebSocketManager.instance = new WebSocketManager(bus, config);\n }\n return WebSocketManager.instance;\n }\n\n /**\n * Destroy the singleton.\n * Call before re-initialising in tests or when switching environments.\n */\n public static reset(): void {\n WebSocketManager.instance = undefined;\n }\n\n /** Returns the connectionId received from the last \"ws:identified\" message. */\n public getConnectionId(): string | undefined {\n return this.connectionId;\n }\n\n /**\n * Resolves with the connectionId once the server sends \"ws:identified\".\n * Resolves immediately if identification has already happened.\n * Rejects if the socket disconnects before identification, or if\n * `timeoutMs` elapses (default 10 000 ms).\n */\n public waitForConnectionId(timeoutMs = 10_000): Promise<string> {\n if (this.connectionId !== undefined) {\n return Promise.resolve(this.connectionId);\n }\n\n return new Promise((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n settled = true;\n clearTimeout(timer);\n this.bus.off('ws:identified', onIdentified);\n this.bus.off('ws:disconnected', onDisconnected);\n };\n\n const onIdentified = ({ connectionId }: WsIdentifiedPayload): void => {\n if (settled) return;\n cleanup();\n resolve(connectionId);\n };\n\n const onDisconnected = (): void => {\n if (settled) return;\n cleanup();\n reject(new Error('WebSocket disconnected before identification'));\n };\n\n const timer = setTimeout(() => {\n if (settled) return;\n cleanup();\n reject(new Error(`waitForConnectionId timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n this.bus.on('ws:identified', onIdentified);\n this.bus.on('ws:disconnected', onDisconnected);\n });\n }\n\n /** Gracefully close the connection without triggering automatic reconnect. */\n public disconnect(): void {\n this.intentionalClose = true;\n this.clearHeartbeat();\n this.clearReconnectTimer();\n this.config.transport.close(1000, 'client disconnect');\n }\n\n private connect(): void {\n const { transport, url } = this.config;\n\n transport.onopen = (): void => {\n this.reconnectAttempts = 0;\n this.intentionalClose = false;\n // Send immediately so the server can respond with ws:identified,\n // then keep the connection alive on the regular interval.\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n this.startHeartbeat();\n this.bus.emit('ws:connected', undefined);\n };\n\n transport.onmessage = (data: string): void => {\n this.handleMessage(data);\n };\n\n transport.onclose = (event: CloseEvent): void => {\n this.clearHeartbeat();\n this.connectionId = undefined;\n this.bus.emit('ws:disconnected', event);\n\n if (!this.intentionalClose) {\n this.scheduleReconnect();\n }\n };\n\n transport.onerror = (event: Event): void => {\n this.bus.emit('ws:error', event);\n };\n\n transport.connect(url);\n }\n\n private handleMessage(raw: string): void {\n let parsedJson: unknown;\n\n try {\n parsedJson = JSON.parse(raw);\n } catch {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n const result = serverMessageSchema.safeParse(parsedJson);\n if (!result.success) {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n // result.data is ServerMessageParsed — a strict discriminated union.\n // The switch is exhaustive: adding a new event to serverMessageSchema\n // without handling it here is a compile error.\n const msg = result.data;\n\n switch (msg.type) {\n case 'ws:identified':\n this.connectionId = msg.payload.connectionId;\n this.bus.emit('ws:identified', msg.payload);\n break;\n case 'campaign:saving':\n this.bus.emit('campaign:saving', msg.payload);\n break;\n default: {\n // Exhaustive guard — msg is `never` if all cases are handled.\n // If this line errors, a new ServerMessage member needs a case above.\n const _exhaustive: never = msg;\n }\n }\n }\n\n private scheduleReconnect(): void {\n if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {\n this.bus.emit('ws:max-retries-exceeded', undefined);\n return;\n }\n\n const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;\n const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);\n const capped = Math.min(exponential, maxReconnectDelayMs);\n // Add random jitter up to one base interval to avoid thundering herd\n const delay = capped + Math.random() * baseReconnectDelayMs;\n\n this.reconnectAttempts++;\n\n this.reconnectTimer = setTimeout(() => {\n this.connect();\n }, delay);\n }\n\n private clearReconnectTimer(): void {\n if (this.reconnectTimer !== undefined) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n }\n }\n\n private startHeartbeat(): void {\n this.clearHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n }, this.config.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer !== undefined) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = undefined;\n }\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\nexport class RealWebSocketTransport implements IWebSocketTransport {\n private socket: WebSocket | null = null;\n\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n public connect(url: string): void {\n this.socket = new WebSocket(url);\n\n this.socket.addEventListener('open', () => {\n this.onopen?.();\n });\n\n this.socket.addEventListener('message', (event: MessageEvent) => {\n if (typeof event.data === 'string') {\n this.onmessage?.(event.data);\n }\n });\n\n this.socket.addEventListener('close', (event: CloseEvent) => {\n this.onclose?.(event);\n });\n\n this.socket.addEventListener('error', (event: Event) => {\n this.onerror?.(event);\n });\n }\n\n public send(data: string): void {\n if (this.socket?.readyState === WebSocket.OPEN) {\n this.socket.send(data);\n }\n }\n\n public close(code?: number, reason?: string): void {\n this.socket?.close(code, reason);\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\n/**\n * In-memory WebSocket transport for use in unit tests and Cypress specs.\n *\n * Wire it up via WebSocketManagerConfig.transport, then drive the connection\n * state with simulateMessage() and simulateClose().\n */\nexport class MockWebSocketTransport implements IWebSocketTransport {\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n private connected = false;\n\n public connect(_url: string): void {\n this.connected = true;\n // Simulate async open so callers can register handlers before the event fires\n setTimeout(() => {\n this.onopen?.();\n }, 0);\n }\n\n /** No-op by design. Spy on this method to assert outgoing frames in tests. */\n public send(_data: string): void {\n // intentionally empty\n }\n\n public close(code = 1000, _reason?: string): void {\n if (!this.connected) return;\n this.simulateClose(code);\n }\n\n /**\n * Deliver a raw message string directly to the manager's onmessage handler.\n * Call this from tests/Cypress to simulate server-pushed frames.\n */\n public simulateMessage(data: string): void {\n this.onmessage?.(data);\n }\n\n /**\n * Fire a CloseEvent on the manager's onclose handler.\n * Call this from tests/Cypress to simulate a server-initiated disconnect.\n */\n public simulateClose(code = 1000): void {\n this.connected = false;\n const event = new CloseEvent('close', { code, wasClean: code === 1000 });\n this.onclose?.(event);\n }\n}\n"],"mappings":";AAAA,OAAO,UAAU;AAUV,IAAM,WAAqB,KAAc;;;ACVhD,SAAS,SAAS;AAEX,IAAM,4BAA4B,EAAE,OAAO;AAAA,EAChD,cAAc,EAAE,OAAO;AACzB,CAAC;;;ACJD,SAAS,KAAAA,UAAS;AAElB,IAAM,4BAA4BA,GAAE,OAAO;AAAA,EACzC,QAAQA,GAAE,QAAQ,OAAO;AAC3B,CAAC;AAED,IAAM,8BAA8BA,GAAE,OAAO;AAAA,EAC3C,QAAQA,GAAE,QAAQ,SAAS;AAC7B,CAAC;AAED,IAAM,6BAA6BA,GAAE,OAAO;AAAA,EAC1C,QAAQA,GAAE,QAAQ,QAAQ;AAAA,EAC1B,OAAOA,GAAE,OAAOA,GAAE,OAAO,GAAGA,GAAE,QAAQ,CAAC;AACzC,CAAC;AAEM,IAAM,8BAA8BA,GAAE,mBAAmB,UAAU;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AACF,CAAC;;;ACnBD,SAAS,KAAAC,UAAS;AA0CX,IAAM,sBAAsBC,GAAE,mBAAmB,QAAQ;AAAA,EAC9DA,GAAE,OAAO,EAAE,MAAMA,GAAE,QAAQ,eAAe,GAAG,SAAS,0BAA0B,CAAC;AAAA,EACjFA,GAAE,OAAO,EAAE,MAAMA,GAAE,QAAQ,iBAAiB,GAAG,SAAS,4BAA4B,CAAC;AACvF,CAAC;;;ACvCD,IAAM,iCAAiC;AACvC,IAAM,kCAAkC;AACxC,IAAM,iCAAiC;AACvC,IAAM,gCAAgC;AACtC,IAAM,4BAAqD,EAAE,QAAQ,WAAW;AAIzE,IAAM,mBAAN,MAAM,kBAAiB;AAAA,EAC5B,OAAe;AAAA,EAEE;AAAA,EACA;AAAA,EAET,oBAAoB;AAAA,EACpB,mBAAmB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EAEA,YAAY,KAAe,QAAgC;AACjE,SAAK,MAAM;AACX,SAAK,SAAS;AAAA,MACZ,KAAK,OAAO;AAAA,MACZ,WAAW,OAAO;AAAA,MAClB,sBAAsB,OAAO,wBAAwB;AAAA,MACrD,sBAAsB,OAAO,wBAAwB;AAAA,MACrD,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,kBAAkB,OAAO,oBAAoB;AAAA,IAC/C;AAEA,SAAK,QAAQ;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAc,YAAY,KAAe,QAAkD;AACzF,QAAI,CAAC,kBAAiB,UAAU;AAC9B,wBAAiB,WAAW,IAAI,kBAAiB,KAAK,MAAM;AAAA,IAC9D;AACA,WAAO,kBAAiB;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAc,QAAc;AAC1B,sBAAiB,WAAW;AAAA,EAC9B;AAAA;AAAA,EAGO,kBAAsC;AAC3C,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQO,oBAAoB,YAAY,KAAyB;AAC9D,QAAI,KAAK,iBAAiB,QAAW;AACnC,aAAO,QAAQ,QAAQ,KAAK,YAAY;AAAA,IAC1C;AAEA,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAI,UAAU;AAEd,YAAM,UAAU,MAAY;AAC1B,kBAAU;AACV,qBAAa,KAAK;AAClB,aAAK,IAAI,IAAI,iBAAiB,YAAY;AAC1C,aAAK,IAAI,IAAI,mBAAmB,cAAc;AAAA,MAChD;AAEA,YAAM,eAAe,CAAC,EAAE,aAAa,MAAiC;AACpE,YAAI,QAAS;AACb,gBAAQ;AACR,gBAAQ,YAAY;AAAA,MACtB;AAEA,YAAM,iBAAiB,MAAY;AACjC,YAAI,QAAS;AACb,gBAAQ;AACR,eAAO,IAAI,MAAM,8CAA8C,CAAC;AAAA,MAClE;AAEA,YAAM,QAAQ,WAAW,MAAM;AAC7B,YAAI,QAAS;AACb,gBAAQ;AACR,eAAO,IAAI,MAAM,uCAAuC,SAAS,IAAI,CAAC;AAAA,MACxE,GAAG,SAAS;AAEZ,WAAK,IAAI,GAAG,iBAAiB,YAAY;AACzC,WAAK,IAAI,GAAG,mBAAmB,cAAc;AAAA,IAC/C,CAAC;AAAA,EACH;AAAA;AAAA,EAGO,aAAmB;AACxB,SAAK,mBAAmB;AACxB,SAAK,eAAe;AACpB,SAAK,oBAAoB;AACzB,SAAK,OAAO,UAAU,MAAM,KAAM,mBAAmB;AAAA,EACvD;AAAA,EAEQ,UAAgB;AACtB,UAAM,EAAE,WAAW,IAAI,IAAI,KAAK;AAEhC,cAAU,SAAS,MAAY;AAC7B,WAAK,oBAAoB;AACzB,WAAK,mBAAmB;AAGxB,WAAK,OAAO,UAAU,KAAK,KAAK,UAAU,KAAK,OAAO,gBAAgB,CAAC;AACvE,WAAK,eAAe;AACpB,WAAK,IAAI,KAAK,gBAAgB,MAAS;AAAA,IACzC;AAEA,cAAU,YAAY,CAAC,SAAuB;AAC5C,WAAK,cAAc,IAAI;AAAA,IACzB;AAEA,cAAU,UAAU,CAAC,UAA4B;AAC/C,WAAK,eAAe;AACpB,WAAK,eAAe;AACpB,WAAK,IAAI,KAAK,mBAAmB,KAAK;AAEtC,UAAI,CAAC,KAAK,kBAAkB;AAC1B,aAAK,kBAAkB;AAAA,MACzB;AAAA,IACF;AAEA,cAAU,UAAU,CAAC,UAAuB;AAC1C,WAAK,IAAI,KAAK,YAAY,KAAK;AAAA,IACjC;AAEA,cAAU,QAAQ,GAAG;AAAA,EACvB;AAAA,EAEQ,cAAc,KAAmB;AACvC,QAAI;AAEJ,QAAI;AACF,mBAAa,KAAK,MAAM,GAAG;AAAA,IAC7B,QAAQ;AACN,WAAK,IAAI,KAAK,kBAAkB,GAAG;AACnC;AAAA,IACF;AAEA,UAAM,SAAS,oBAAoB,UAAU,UAAU;AACvD,QAAI,CAAC,OAAO,SAAS;AACnB,WAAK,IAAI,KAAK,kBAAkB,GAAG;AACnC;AAAA,IACF;AAKA,UAAM,MAAM,OAAO;AAEnB,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,aAAK,eAAe,IAAI,QAAQ;AAChC,aAAK,IAAI,KAAK,iBAAiB,IAAI,OAAO;AAC1C;AAAA,MACF,KAAK;AACH,aAAK,IAAI,KAAK,mBAAmB,IAAI,OAAO;AAC5C;AAAA,MACF,SAAS;AAGP,cAAM,cAAqB;AAAA,MAC7B;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,oBAA0B;AAChC,QAAI,KAAK,qBAAqB,KAAK,OAAO,sBAAsB;AAC9D,WAAK,IAAI,KAAK,2BAA2B,MAAS;AAClD;AAAA,IACF;AAEA,UAAM,EAAE,sBAAsB,oBAAoB,IAAI,KAAK;AAC3D,UAAM,cAAc,uBAAuB,KAAK,IAAI,GAAG,KAAK,iBAAiB;AAC7E,UAAM,SAAS,KAAK,IAAI,aAAa,mBAAmB;AAExD,UAAM,QAAQ,SAAS,KAAK,OAAO,IAAI;AAEvC,SAAK;AAEL,SAAK,iBAAiB,WAAW,MAAM;AACrC,WAAK,QAAQ;AAAA,IACf,GAAG,KAAK;AAAA,EACV;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,mBAAmB,QAAW;AACrC,mBAAa,KAAK,cAAc;AAChC,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEQ,iBAAuB;AAC7B,SAAK,eAAe;AACpB,SAAK,iBAAiB,YAAY,MAAM;AACtC,WAAK,OAAO,UAAU,KAAK,KAAK,UAAU,KAAK,OAAO,gBAAgB,CAAC;AAAA,IACzE,GAAG,KAAK,OAAO,mBAAmB;AAAA,EACpC;AAAA,EAEQ,iBAAuB;AAC7B,QAAI,KAAK,mBAAmB,QAAW;AACrC,oBAAc,KAAK,cAAc;AACjC,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AACF;;;ACjOO,IAAM,yBAAN,MAA4D;AAAA,EACzD,SAA2B;AAAA,EAE5B,SAA8B;AAAA,EAC9B,YAA6C;AAAA,EAC7C,UAAgD;AAAA,EAChD,UAA2C;AAAA,EAE3C,QAAQ,KAAmB;AAChC,SAAK,SAAS,IAAI,UAAU,GAAG;AAE/B,SAAK,OAAO,iBAAiB,QAAQ,MAAM;AACzC,WAAK,SAAS;AAAA,IAChB,CAAC;AAED,SAAK,OAAO,iBAAiB,WAAW,CAAC,UAAwB;AAC/D,UAAI,OAAO,MAAM,SAAS,UAAU;AAClC,aAAK,YAAY,MAAM,IAAI;AAAA,MAC7B;AAAA,IACF,CAAC;AAED,SAAK,OAAO,iBAAiB,SAAS,CAAC,UAAsB;AAC3D,WAAK,UAAU,KAAK;AAAA,IACtB,CAAC;AAED,SAAK,OAAO,iBAAiB,SAAS,CAAC,UAAiB;AACtD,WAAK,UAAU,KAAK;AAAA,IACtB,CAAC;AAAA,EACH;AAAA,EAEO,KAAK,MAAoB;AAC9B,QAAI,KAAK,QAAQ,eAAe,UAAU,MAAM;AAC9C,WAAK,OAAO,KAAK,IAAI;AAAA,IACvB;AAAA,EACF;AAAA,EAEO,MAAM,MAAe,QAAuB;AACjD,SAAK,QAAQ,MAAM,MAAM,MAAM;AAAA,EACjC;AACF;;;ACjCO,IAAM,yBAAN,MAA4D;AAAA,EAC1D,SAA8B;AAAA,EAC9B,YAA6C;AAAA,EAC7C,UAAgD;AAAA,EAChD,UAA2C;AAAA,EAE1C,YAAY;AAAA,EAEb,QAAQ,MAAoB;AACjC,SAAK,YAAY;AAEjB,eAAW,MAAM;AACf,WAAK,SAAS;AAAA,IAChB,GAAG,CAAC;AAAA,EACN;AAAA;AAAA,EAGO,KAAK,OAAqB;AAAA,EAEjC;AAAA,EAEO,MAAM,OAAO,KAAM,SAAwB;AAChD,QAAI,CAAC,KAAK,UAAW;AACrB,SAAK,cAAc,IAAI;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMO,gBAAgB,MAAoB;AACzC,SAAK,YAAY,IAAI;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMO,cAAc,OAAO,KAAY;AACtC,SAAK,YAAY;AACjB,UAAM,QAAQ,IAAI,WAAW,SAAS,EAAE,MAAM,UAAU,SAAS,IAAK,CAAC;AACvE,SAAK,UAAU,KAAK;AAAA,EACtB;AACF;","names":["z","z","z"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/event-bus.ts","../src/types/ws-identified.ts","../src/types/campaign-saving.ts","../src/types/server-message.ts","../src/websocket-manager.ts","../src/transports/real.transport.ts","../src/transports/mock.transport.ts"],"sourcesContent":["// Event bus\nexport { eventBus } from './event-bus';\nexport type { EventBus } from './event-bus';\n\n// Manager\nexport { WebSocketManager } from './websocket-manager';\n\n// All types\nexport type {\n WsEventMap,\n WsIdentifiedPayload,\n CampaignSavingPayload,\n WebSocketManagerConfig,\n ServerEventKey,\n ServerMessage,\n ServerMessageParsed,\n} from './types';\n\n// Zod validators (useful for consumers and tests)\nexport {\n wsIdentifiedPayloadSchema,\n campaignSavingPayloadSchema,\n serverMessageSchema,\n} from './types';\n\n// Transport interface\nexport type { IWebSocketTransport } from './transport.interface';\n\n// Transport implementations\nexport { RealWebSocketTransport } from './transports/real.transport';\nexport { MockWebSocketTransport } from './transports/mock.transport';\n","import mitt from 'mitt';\nimport type { Emitter } from 'mitt';\nimport type { WsEventMap } from './types';\n\n// Intersect with Record<string | symbol, unknown> to satisfy mitt's generic\n// constraint while keeping WsEventMap clean of index signatures.\ntype MittMap = WsEventMap & Record<string | symbol, unknown>;\n\nexport type EventBus = Emitter<MittMap>;\n\nexport const eventBus: EventBus = mitt<MittMap>();\n","import { z } from 'zod';\n\nexport const wsIdentifiedPayloadSchema = z.object({\n connectionId: z.string(),\n});\n\nexport type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;\n","import { z } from 'zod';\n\nconst campaignSavingStartSchema = z.object({\n status: z.literal('start'),\n});\n\nconst campaignSavingSuccessSchema = z.object({\n status: z.literal('success'),\n});\n\nconst campaignSavingFailedSchema = z.object({\n status: z.literal('failed'),\n error: z.record(z.string(), z.unknown()),\n});\n\nexport const campaignSavingPayloadSchema = z.discriminatedUnion('status', [\n campaignSavingStartSchema,\n campaignSavingSuccessSchema,\n campaignSavingFailedSchema,\n]);\n\nexport type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;\n","import { z } from 'zod';\nimport type { WsEventMap } from './ws-event-map';\nimport { wsIdentifiedPayloadSchema } from './ws-identified';\nimport { campaignSavingPayloadSchema } from './campaign-saving';\n\n// ---------------------------------------------------------------------------\n// Client-only events — fired by the transport layer, never sent by the server\n// ---------------------------------------------------------------------------\n\ntype ClientOnlyEvent =\n | 'ws:connected'\n | 'ws:disconnected'\n | 'ws:error'\n | 'ws:max-retries-exceeded'\n | 'ws:unparseable';\n\n// ---------------------------------------------------------------------------\n// ServerEventKey — every WsEventMap key the server is allowed to send\n// Automatically excludes client-only events; stays in sync with WsEventMap.\n// ---------------------------------------------------------------------------\n\nexport type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;\n\n// ---------------------------------------------------------------------------\n// ServerMessage — strict discriminated union of all valid server wire frames.\n// Use this in backend code and anywhere a server frame must be type-narrowed.\n//\n// Shape: { type: ServerEventKey; payload: WsEventMap[K] }\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = {\n [K in ServerEventKey]: { type: K; payload: WsEventMap[K] };\n}[ServerEventKey];\n\n// ---------------------------------------------------------------------------\n// serverMessageSchema — Zod discriminated union over every server event.\n// Validates that an incoming frame is both structurally and semantically valid.\n//\n// NOTE: if you extend WsEventMap via module augmentation you must also add\n// the corresponding member to this schema.\n// ---------------------------------------------------------------------------\n\nexport const serverMessageSchema = z.discriminatedUnion('type', [\n z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),\n z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),\n]);\n\nexport type ServerMessageParsed = z.infer<typeof serverMessageSchema>;\n","import type { EventBus } from './event-bus';\nimport type { WebSocketManagerConfig, WsIdentifiedPayload } from './types';\nimport { serverMessageSchema } from './types';\n\nexport type { WebSocketManagerConfig } from './types';\n\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;\nconst DEFAULT_BASE_RECONNECT_DELAY_MS = 500;\nconst DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;\nconst DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;\nconst DEFAULT_HEARTBEAT_MESSAGE: Record<string, unknown> = { action: \"who-am-I\" };\n\ntype ResolvedConfig = Required<WebSocketManagerConfig>;\n\nexport class WebSocketManager {\n private static instance: WebSocketManager | undefined;\n\n private readonly bus: EventBus;\n private readonly config: ResolvedConfig;\n\n private reconnectAttempts = 0;\n private intentionalClose = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | undefined;\n private reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n private connectionId: string | undefined;\n\n private constructor(bus: EventBus, config: WebSocketManagerConfig) {\n this.bus = bus;\n this.config = {\n url: config.url,\n transport: config.transport,\n maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,\n baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,\n maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,\n heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,\n heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,\n };\n\n this.connect();\n }\n\n /**\n * Returns the singleton instance, creating it on first call.\n * Subsequent calls ignore `bus` and `config` — pass them only on first call.\n */\n public static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager {\n if (!WebSocketManager.instance) {\n WebSocketManager.instance = new WebSocketManager(bus, config);\n }\n return WebSocketManager.instance;\n }\n\n /**\n * Destroy the singleton.\n * Call before re-initialising in tests or when switching environments.\n */\n public static reset(): void {\n WebSocketManager.instance = undefined;\n }\n\n /** Returns the connectionId received from the last \"ws:identified\" message. */\n public getConnectionId(): string | undefined {\n return this.connectionId;\n }\n\n /**\n * Resolves with the connectionId once the server sends \"ws:identified\".\n * Resolves immediately if identification has already happened.\n * Rejects if the socket disconnects before identification, or if\n * `timeoutMs` elapses (default 10 000 ms).\n */\n public waitForConnectionId(timeoutMs = 10_000): Promise<string> {\n if (this.connectionId !== undefined) {\n return Promise.resolve(this.connectionId);\n }\n\n return new Promise((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n settled = true;\n clearTimeout(timer);\n this.bus.off('ws:identified', onIdentified);\n this.bus.off('ws:disconnected', onDisconnected);\n };\n\n const onIdentified = ({ connectionId }: WsIdentifiedPayload): void => {\n if (settled) return;\n cleanup();\n resolve(connectionId);\n };\n\n const onDisconnected = (): void => {\n if (settled) return;\n cleanup();\n reject(new Error('WebSocket disconnected before identification'));\n };\n\n const timer = setTimeout(() => {\n if (settled) return;\n cleanup();\n reject(new Error(`waitForConnectionId timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n this.bus.on('ws:identified', onIdentified);\n this.bus.on('ws:disconnected', onDisconnected);\n });\n }\n\n /** Gracefully close the connection without triggering automatic reconnect. */\n public disconnect(): void {\n this.intentionalClose = true;\n this.clearHeartbeat();\n this.clearReconnectTimer();\n this.config.transport.close(1000, 'client disconnect');\n }\n\n private connect(): void {\n const { transport, url } = this.config;\n\n transport.onopen = (): void => {\n this.reconnectAttempts = 0;\n this.intentionalClose = false;\n // Send immediately so the server can respond with ws:identified,\n // then keep the connection alive on the regular interval.\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n this.startHeartbeat();\n this.bus.emit('ws:connected', undefined);\n };\n\n transport.onmessage = (data: string): void => {\n this.handleMessage(data);\n };\n\n transport.onclose = (event: CloseEvent): void => {\n this.clearHeartbeat();\n this.connectionId = undefined;\n this.bus.emit('ws:disconnected', event);\n\n if (!this.intentionalClose) {\n this.scheduleReconnect();\n }\n };\n\n transport.onerror = (event: Event): void => {\n this.bus.emit('ws:error', event);\n };\n\n transport.connect(url);\n }\n\n private handleMessage(raw: string): void {\n let parsedJson: unknown;\n\n try {\n parsedJson = JSON.parse(raw);\n } catch {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n const result = serverMessageSchema.safeParse(parsedJson);\n if (!result.success) {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n // result.data is ServerMessageParsed — a strict discriminated union.\n // The switch is exhaustive: adding a new event to serverMessageSchema\n // without handling it here is a compile error.\n const msg = result.data;\n\n switch (msg.type) {\n case 'ws:identified':\n this.connectionId = msg.payload.connectionId;\n this.bus.emit('ws:identified', msg.payload);\n break;\n case 'campaign:saving':\n this.bus.emit('campaign:saving', msg.payload);\n break;\n default: {\n // Exhaustive guard — msg is `never` if all cases are handled.\n // If this line errors, a new ServerMessage member needs a case above.\n const _exhaustive: never = msg;\n }\n }\n }\n\n private scheduleReconnect(): void {\n if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {\n this.bus.emit('ws:max-retries-exceeded', undefined);\n return;\n }\n\n const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;\n const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);\n const capped = Math.min(exponential, maxReconnectDelayMs);\n // Add random jitter up to one base interval to avoid thundering herd\n const delay = capped + Math.random() * baseReconnectDelayMs;\n\n this.reconnectAttempts++;\n\n this.reconnectTimer = setTimeout(() => {\n this.connect();\n }, delay);\n }\n\n private clearReconnectTimer(): void {\n if (this.reconnectTimer !== undefined) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n }\n }\n\n private startHeartbeat(): void {\n this.clearHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n }, this.config.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer !== undefined) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = undefined;\n }\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\nexport class RealWebSocketTransport implements IWebSocketTransport {\n private socket: WebSocket | null = null;\n\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n public connect(url: string): void {\n this.socket = new WebSocket(url);\n\n this.socket.addEventListener('open', () => {\n this.onopen?.();\n });\n\n this.socket.addEventListener('message', (event: MessageEvent) => {\n if (typeof event.data === 'string') {\n this.onmessage?.(event.data);\n }\n });\n\n this.socket.addEventListener('close', (event: CloseEvent) => {\n this.onclose?.(event);\n });\n\n this.socket.addEventListener('error', (event: Event) => {\n this.onerror?.(event);\n });\n }\n\n public send(data: string): void {\n if (this.socket?.readyState === WebSocket.OPEN) {\n this.socket.send(data);\n }\n }\n\n public close(code?: number, reason?: string): void {\n this.socket?.close(code, reason);\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\n/**\n * In-memory WebSocket transport for use in unit tests and Cypress specs.\n *\n * Wire it up via WebSocketManagerConfig.transport, then drive the connection\n * state with simulateMessage() and simulateClose().\n */\nexport class MockWebSocketTransport implements IWebSocketTransport {\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n private connected = false;\n\n public connect(_url: string): void {\n this.connected = true;\n // Simulate async open so callers can register handlers before the event fires\n setTimeout(() => {\n this.onopen?.();\n }, 0);\n }\n\n /** No-op by design. Spy on this method to assert outgoing frames in tests. */\n public send(_data: string): void {\n // intentionally empty\n }\n\n public close(code = 1000, _reason?: string): void {\n if (!this.connected) return;\n this.simulateClose(code);\n }\n\n /**\n * Deliver a raw message string directly to the manager's onmessage handler.\n * Call this from tests/Cypress to simulate server-pushed frames.\n */\n public simulateMessage(data: string): void {\n this.onmessage?.(data);\n }\n\n /**\n * Fire a CloseEvent on the manager's onclose handler.\n * Call this from tests/Cypress to simulate a server-initiated disconnect.\n */\n public simulateClose(code = 1000): void {\n this.connected = false;\n const event = new CloseEvent('close', { code, wasClean: code === 1000 });\n this.onclose?.(event);\n }\n}\n"],"mappings":"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","names":["mitt","import_zod","import_zod"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,295 @@
1
+ // src/event-bus.ts
2
+ import mitt from "mitt";
3
+ var eventBus = mitt();
4
+
5
+ // src/types/ws-identified.ts
6
+ import { z } from "zod";
7
+ var wsIdentifiedPayloadSchema = z.object({
8
+ connectionId: z.string()
9
+ });
10
+
11
+ // src/types/campaign-saving.ts
12
+ import { z as z2 } from "zod";
13
+ var campaignSavingStartSchema = z2.object({
14
+ status: z2.literal("start")
15
+ });
16
+ var campaignSavingSuccessSchema = z2.object({
17
+ status: z2.literal("success")
18
+ });
19
+ var campaignSavingFailedSchema = z2.object({
20
+ status: z2.literal("failed"),
21
+ error: z2.record(z2.string(), z2.unknown())
22
+ });
23
+ var campaignSavingPayloadSchema = z2.discriminatedUnion("status", [
24
+ campaignSavingStartSchema,
25
+ campaignSavingSuccessSchema,
26
+ campaignSavingFailedSchema
27
+ ]);
28
+
29
+ // src/types/server-message.ts
30
+ import { z as z3 } from "zod";
31
+ var serverMessageSchema = z3.discriminatedUnion("type", [
32
+ z3.object({ type: z3.literal("ws:identified"), payload: wsIdentifiedPayloadSchema }),
33
+ z3.object({ type: z3.literal("campaign:saving"), payload: campaignSavingPayloadSchema })
34
+ ]);
35
+
36
+ // src/websocket-manager.ts
37
+ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
38
+ var DEFAULT_BASE_RECONNECT_DELAY_MS = 500;
39
+ var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
40
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
41
+ var DEFAULT_HEARTBEAT_MESSAGE = { action: "who-am-I" };
42
+ var WebSocketManager = class _WebSocketManager {
43
+ static instance;
44
+ bus;
45
+ config;
46
+ reconnectAttempts = 0;
47
+ intentionalClose = false;
48
+ heartbeatTimer;
49
+ reconnectTimer;
50
+ connectionId;
51
+ constructor(bus, config) {
52
+ this.bus = bus;
53
+ this.config = {
54
+ url: config.url,
55
+ transport: config.transport,
56
+ maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,
57
+ baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,
58
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,
59
+ heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
60
+ heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE
61
+ };
62
+ this.connect();
63
+ }
64
+ /**
65
+ * Returns the singleton instance, creating it on first call.
66
+ * Subsequent calls ignore `bus` and `config` — pass them only on first call.
67
+ */
68
+ static getInstance(bus, config) {
69
+ if (!_WebSocketManager.instance) {
70
+ _WebSocketManager.instance = new _WebSocketManager(bus, config);
71
+ }
72
+ return _WebSocketManager.instance;
73
+ }
74
+ /**
75
+ * Destroy the singleton.
76
+ * Call before re-initialising in tests or when switching environments.
77
+ */
78
+ static reset() {
79
+ _WebSocketManager.instance = void 0;
80
+ }
81
+ /** Returns the connectionId received from the last "ws:identified" message. */
82
+ getConnectionId() {
83
+ return this.connectionId;
84
+ }
85
+ /**
86
+ * Resolves with the connectionId once the server sends "ws:identified".
87
+ * Resolves immediately if identification has already happened.
88
+ * Rejects if the socket disconnects before identification, or if
89
+ * `timeoutMs` elapses (default 10 000 ms).
90
+ */
91
+ waitForConnectionId(timeoutMs = 1e4) {
92
+ if (this.connectionId !== void 0) {
93
+ return Promise.resolve(this.connectionId);
94
+ }
95
+ return new Promise((resolve, reject) => {
96
+ let settled = false;
97
+ const cleanup = () => {
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ this.bus.off("ws:identified", onIdentified);
101
+ this.bus.off("ws:disconnected", onDisconnected);
102
+ };
103
+ const onIdentified = ({ connectionId }) => {
104
+ if (settled) return;
105
+ cleanup();
106
+ resolve(connectionId);
107
+ };
108
+ const onDisconnected = () => {
109
+ if (settled) return;
110
+ cleanup();
111
+ reject(new Error("WebSocket disconnected before identification"));
112
+ };
113
+ const timer = setTimeout(() => {
114
+ if (settled) return;
115
+ cleanup();
116
+ reject(new Error(`waitForConnectionId timed out after ${timeoutMs}ms`));
117
+ }, timeoutMs);
118
+ this.bus.on("ws:identified", onIdentified);
119
+ this.bus.on("ws:disconnected", onDisconnected);
120
+ });
121
+ }
122
+ /** Gracefully close the connection without triggering automatic reconnect. */
123
+ disconnect() {
124
+ this.intentionalClose = true;
125
+ this.clearHeartbeat();
126
+ this.clearReconnectTimer();
127
+ this.config.transport.close(1e3, "client disconnect");
128
+ }
129
+ connect() {
130
+ const { transport, url } = this.config;
131
+ transport.onopen = () => {
132
+ this.reconnectAttempts = 0;
133
+ this.intentionalClose = false;
134
+ this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));
135
+ this.startHeartbeat();
136
+ this.bus.emit("ws:connected", void 0);
137
+ };
138
+ transport.onmessage = (data) => {
139
+ this.handleMessage(data);
140
+ };
141
+ transport.onclose = (event) => {
142
+ this.clearHeartbeat();
143
+ this.connectionId = void 0;
144
+ this.bus.emit("ws:disconnected", event);
145
+ if (!this.intentionalClose) {
146
+ this.scheduleReconnect();
147
+ }
148
+ };
149
+ transport.onerror = (event) => {
150
+ this.bus.emit("ws:error", event);
151
+ };
152
+ transport.connect(url);
153
+ }
154
+ handleMessage(raw) {
155
+ let parsedJson;
156
+ try {
157
+ parsedJson = JSON.parse(raw);
158
+ } catch {
159
+ this.bus.emit("ws:unparseable", raw);
160
+ return;
161
+ }
162
+ const result = serverMessageSchema.safeParse(parsedJson);
163
+ if (!result.success) {
164
+ this.bus.emit("ws:unparseable", raw);
165
+ return;
166
+ }
167
+ const msg = result.data;
168
+ switch (msg.type) {
169
+ case "ws:identified":
170
+ this.connectionId = msg.payload.connectionId;
171
+ this.bus.emit("ws:identified", msg.payload);
172
+ break;
173
+ case "campaign:saving":
174
+ this.bus.emit("campaign:saving", msg.payload);
175
+ break;
176
+ default: {
177
+ const _exhaustive = msg;
178
+ }
179
+ }
180
+ }
181
+ scheduleReconnect() {
182
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
183
+ this.bus.emit("ws:max-retries-exceeded", void 0);
184
+ return;
185
+ }
186
+ const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;
187
+ const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);
188
+ const capped = Math.min(exponential, maxReconnectDelayMs);
189
+ const delay = capped + Math.random() * baseReconnectDelayMs;
190
+ this.reconnectAttempts++;
191
+ this.reconnectTimer = setTimeout(() => {
192
+ this.connect();
193
+ }, delay);
194
+ }
195
+ clearReconnectTimer() {
196
+ if (this.reconnectTimer !== void 0) {
197
+ clearTimeout(this.reconnectTimer);
198
+ this.reconnectTimer = void 0;
199
+ }
200
+ }
201
+ startHeartbeat() {
202
+ this.clearHeartbeat();
203
+ this.heartbeatTimer = setInterval(() => {
204
+ this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));
205
+ }, this.config.heartbeatIntervalMs);
206
+ }
207
+ clearHeartbeat() {
208
+ if (this.heartbeatTimer !== void 0) {
209
+ clearInterval(this.heartbeatTimer);
210
+ this.heartbeatTimer = void 0;
211
+ }
212
+ }
213
+ };
214
+
215
+ // src/transports/real.transport.ts
216
+ var RealWebSocketTransport = class {
217
+ socket = null;
218
+ onopen = null;
219
+ onmessage = null;
220
+ onclose = null;
221
+ onerror = null;
222
+ connect(url) {
223
+ this.socket = new WebSocket(url);
224
+ this.socket.addEventListener("open", () => {
225
+ this.onopen?.();
226
+ });
227
+ this.socket.addEventListener("message", (event) => {
228
+ if (typeof event.data === "string") {
229
+ this.onmessage?.(event.data);
230
+ }
231
+ });
232
+ this.socket.addEventListener("close", (event) => {
233
+ this.onclose?.(event);
234
+ });
235
+ this.socket.addEventListener("error", (event) => {
236
+ this.onerror?.(event);
237
+ });
238
+ }
239
+ send(data) {
240
+ if (this.socket?.readyState === WebSocket.OPEN) {
241
+ this.socket.send(data);
242
+ }
243
+ }
244
+ close(code, reason) {
245
+ this.socket?.close(code, reason);
246
+ }
247
+ };
248
+
249
+ // src/transports/mock.transport.ts
250
+ var MockWebSocketTransport = class {
251
+ onopen = null;
252
+ onmessage = null;
253
+ onclose = null;
254
+ onerror = null;
255
+ connected = false;
256
+ connect(_url) {
257
+ this.connected = true;
258
+ setTimeout(() => {
259
+ this.onopen?.();
260
+ }, 0);
261
+ }
262
+ /** No-op by design. Spy on this method to assert outgoing frames in tests. */
263
+ send(_data) {
264
+ }
265
+ close(code = 1e3, _reason) {
266
+ if (!this.connected) return;
267
+ this.simulateClose(code);
268
+ }
269
+ /**
270
+ * Deliver a raw message string directly to the manager's onmessage handler.
271
+ * Call this from tests/Cypress to simulate server-pushed frames.
272
+ */
273
+ simulateMessage(data) {
274
+ this.onmessage?.(data);
275
+ }
276
+ /**
277
+ * Fire a CloseEvent on the manager's onclose handler.
278
+ * Call this from tests/Cypress to simulate a server-initiated disconnect.
279
+ */
280
+ simulateClose(code = 1e3) {
281
+ this.connected = false;
282
+ const event = new CloseEvent("close", { code, wasClean: code === 1e3 });
283
+ this.onclose?.(event);
284
+ }
285
+ };
286
+ export {
287
+ MockWebSocketTransport,
288
+ RealWebSocketTransport,
289
+ WebSocketManager,
290
+ campaignSavingPayloadSchema,
291
+ eventBus,
292
+ serverMessageSchema,
293
+ wsIdentifiedPayloadSchema
294
+ };
295
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/event-bus.ts","../src/types/ws-identified.ts","../src/types/campaign-saving.ts","../src/types/server-message.ts","../src/websocket-manager.ts","../src/transports/real.transport.ts","../src/transports/mock.transport.ts"],"sourcesContent":["import mitt from 'mitt';\nimport type { Emitter } from 'mitt';\nimport type { WsEventMap } from './types';\n\n// Intersect with Record<string | symbol, unknown> to satisfy mitt's generic\n// constraint while keeping WsEventMap clean of index signatures.\ntype MittMap = WsEventMap & Record<string | symbol, unknown>;\n\nexport type EventBus = Emitter<MittMap>;\n\nexport const eventBus: EventBus = mitt<MittMap>();\n","import { z } from 'zod';\n\nexport const wsIdentifiedPayloadSchema = z.object({\n connectionId: z.string(),\n});\n\nexport type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;\n","import { z } from 'zod';\n\nconst campaignSavingStartSchema = z.object({\n status: z.literal('start'),\n});\n\nconst campaignSavingSuccessSchema = z.object({\n status: z.literal('success'),\n});\n\nconst campaignSavingFailedSchema = z.object({\n status: z.literal('failed'),\n error: z.record(z.string(), z.unknown()),\n});\n\nexport const campaignSavingPayloadSchema = z.discriminatedUnion('status', [\n campaignSavingStartSchema,\n campaignSavingSuccessSchema,\n campaignSavingFailedSchema,\n]);\n\nexport type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;\n","import { z } from 'zod';\nimport type { WsEventMap } from './ws-event-map';\nimport { wsIdentifiedPayloadSchema } from './ws-identified';\nimport { campaignSavingPayloadSchema } from './campaign-saving';\n\n// ---------------------------------------------------------------------------\n// Client-only events — fired by the transport layer, never sent by the server\n// ---------------------------------------------------------------------------\n\ntype ClientOnlyEvent =\n | 'ws:connected'\n | 'ws:disconnected'\n | 'ws:error'\n | 'ws:max-retries-exceeded'\n | 'ws:unparseable';\n\n// ---------------------------------------------------------------------------\n// ServerEventKey — every WsEventMap key the server is allowed to send\n// Automatically excludes client-only events; stays in sync with WsEventMap.\n// ---------------------------------------------------------------------------\n\nexport type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;\n\n// ---------------------------------------------------------------------------\n// ServerMessage — strict discriminated union of all valid server wire frames.\n// Use this in backend code and anywhere a server frame must be type-narrowed.\n//\n// Shape: { type: ServerEventKey; payload: WsEventMap[K] }\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = {\n [K in ServerEventKey]: { type: K; payload: WsEventMap[K] };\n}[ServerEventKey];\n\n// ---------------------------------------------------------------------------\n// serverMessageSchema — Zod discriminated union over every server event.\n// Validates that an incoming frame is both structurally and semantically valid.\n//\n// NOTE: if you extend WsEventMap via module augmentation you must also add\n// the corresponding member to this schema.\n// ---------------------------------------------------------------------------\n\nexport const serverMessageSchema = z.discriminatedUnion('type', [\n z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),\n z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),\n]);\n\nexport type ServerMessageParsed = z.infer<typeof serverMessageSchema>;\n","import type { EventBus } from './event-bus';\nimport type { WebSocketManagerConfig, WsIdentifiedPayload } from './types';\nimport { serverMessageSchema } from './types';\n\nexport type { WebSocketManagerConfig } from './types';\n\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;\nconst DEFAULT_BASE_RECONNECT_DELAY_MS = 500;\nconst DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;\nconst DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;\nconst DEFAULT_HEARTBEAT_MESSAGE: Record<string, unknown> = { action: \"who-am-I\" };\n\ntype ResolvedConfig = Required<WebSocketManagerConfig>;\n\nexport class WebSocketManager {\n private static instance: WebSocketManager | undefined;\n\n private readonly bus: EventBus;\n private readonly config: ResolvedConfig;\n\n private reconnectAttempts = 0;\n private intentionalClose = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | undefined;\n private reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n private connectionId: string | undefined;\n\n private constructor(bus: EventBus, config: WebSocketManagerConfig) {\n this.bus = bus;\n this.config = {\n url: config.url,\n transport: config.transport,\n maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,\n baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,\n maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,\n heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,\n heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,\n };\n\n this.connect();\n }\n\n /**\n * Returns the singleton instance, creating it on first call.\n * Subsequent calls ignore `bus` and `config` — pass them only on first call.\n */\n public static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager {\n if (!WebSocketManager.instance) {\n WebSocketManager.instance = new WebSocketManager(bus, config);\n }\n return WebSocketManager.instance;\n }\n\n /**\n * Destroy the singleton.\n * Call before re-initialising in tests or when switching environments.\n */\n public static reset(): void {\n WebSocketManager.instance = undefined;\n }\n\n /** Returns the connectionId received from the last \"ws:identified\" message. */\n public getConnectionId(): string | undefined {\n return this.connectionId;\n }\n\n /**\n * Resolves with the connectionId once the server sends \"ws:identified\".\n * Resolves immediately if identification has already happened.\n * Rejects if the socket disconnects before identification, or if\n * `timeoutMs` elapses (default 10 000 ms).\n */\n public waitForConnectionId(timeoutMs = 10_000): Promise<string> {\n if (this.connectionId !== undefined) {\n return Promise.resolve(this.connectionId);\n }\n\n return new Promise((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n settled = true;\n clearTimeout(timer);\n this.bus.off('ws:identified', onIdentified);\n this.bus.off('ws:disconnected', onDisconnected);\n };\n\n const onIdentified = ({ connectionId }: WsIdentifiedPayload): void => {\n if (settled) return;\n cleanup();\n resolve(connectionId);\n };\n\n const onDisconnected = (): void => {\n if (settled) return;\n cleanup();\n reject(new Error('WebSocket disconnected before identification'));\n };\n\n const timer = setTimeout(() => {\n if (settled) return;\n cleanup();\n reject(new Error(`waitForConnectionId timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n this.bus.on('ws:identified', onIdentified);\n this.bus.on('ws:disconnected', onDisconnected);\n });\n }\n\n /** Gracefully close the connection without triggering automatic reconnect. */\n public disconnect(): void {\n this.intentionalClose = true;\n this.clearHeartbeat();\n this.clearReconnectTimer();\n this.config.transport.close(1000, 'client disconnect');\n }\n\n private connect(): void {\n const { transport, url } = this.config;\n\n transport.onopen = (): void => {\n this.reconnectAttempts = 0;\n this.intentionalClose = false;\n // Send immediately so the server can respond with ws:identified,\n // then keep the connection alive on the regular interval.\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n this.startHeartbeat();\n this.bus.emit('ws:connected', undefined);\n };\n\n transport.onmessage = (data: string): void => {\n this.handleMessage(data);\n };\n\n transport.onclose = (event: CloseEvent): void => {\n this.clearHeartbeat();\n this.connectionId = undefined;\n this.bus.emit('ws:disconnected', event);\n\n if (!this.intentionalClose) {\n this.scheduleReconnect();\n }\n };\n\n transport.onerror = (event: Event): void => {\n this.bus.emit('ws:error', event);\n };\n\n transport.connect(url);\n }\n\n private handleMessage(raw: string): void {\n let parsedJson: unknown;\n\n try {\n parsedJson = JSON.parse(raw);\n } catch {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n const result = serverMessageSchema.safeParse(parsedJson);\n if (!result.success) {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n // result.data is ServerMessageParsed — a strict discriminated union.\n // The switch is exhaustive: adding a new event to serverMessageSchema\n // without handling it here is a compile error.\n const msg = result.data;\n\n switch (msg.type) {\n case 'ws:identified':\n this.connectionId = msg.payload.connectionId;\n this.bus.emit('ws:identified', msg.payload);\n break;\n case 'campaign:saving':\n this.bus.emit('campaign:saving', msg.payload);\n break;\n default: {\n // Exhaustive guard — msg is `never` if all cases are handled.\n // If this line errors, a new ServerMessage member needs a case above.\n const _exhaustive: never = msg;\n }\n }\n }\n\n private scheduleReconnect(): void {\n if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {\n this.bus.emit('ws:max-retries-exceeded', undefined);\n return;\n }\n\n const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;\n const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);\n const capped = Math.min(exponential, maxReconnectDelayMs);\n // Add random jitter up to one base interval to avoid thundering herd\n const delay = capped + Math.random() * baseReconnectDelayMs;\n\n this.reconnectAttempts++;\n\n this.reconnectTimer = setTimeout(() => {\n this.connect();\n }, delay);\n }\n\n private clearReconnectTimer(): void {\n if (this.reconnectTimer !== undefined) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n }\n }\n\n private startHeartbeat(): void {\n this.clearHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n }, this.config.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer !== undefined) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = undefined;\n }\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\nexport class RealWebSocketTransport implements IWebSocketTransport {\n private socket: WebSocket | null = null;\n\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n public connect(url: string): void {\n this.socket = new WebSocket(url);\n\n this.socket.addEventListener('open', () => {\n this.onopen?.();\n });\n\n this.socket.addEventListener('message', (event: MessageEvent) => {\n if (typeof event.data === 'string') {\n this.onmessage?.(event.data);\n }\n });\n\n this.socket.addEventListener('close', (event: CloseEvent) => {\n this.onclose?.(event);\n });\n\n this.socket.addEventListener('error', (event: Event) => {\n this.onerror?.(event);\n });\n }\n\n public send(data: string): void {\n if (this.socket?.readyState === WebSocket.OPEN) {\n this.socket.send(data);\n }\n }\n\n public close(code?: number, reason?: string): void {\n this.socket?.close(code, reason);\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\n/**\n * In-memory WebSocket transport for use in unit tests and Cypress specs.\n *\n * Wire it up via WebSocketManagerConfig.transport, then drive the connection\n * state with simulateMessage() and simulateClose().\n */\nexport class MockWebSocketTransport implements IWebSocketTransport {\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n private connected = false;\n\n public connect(_url: string): void {\n this.connected = true;\n // Simulate async open so callers can register handlers before the event fires\n setTimeout(() => {\n this.onopen?.();\n }, 0);\n }\n\n /** No-op by design. Spy on this method to assert outgoing frames in tests. */\n public send(_data: string): void {\n // intentionally empty\n }\n\n public close(code = 1000, _reason?: string): void {\n if (!this.connected) return;\n this.simulateClose(code);\n }\n\n /**\n * Deliver a raw message string directly to the manager's onmessage handler.\n * Call this from tests/Cypress to simulate server-pushed frames.\n */\n public simulateMessage(data: string): void {\n this.onmessage?.(data);\n }\n\n /**\n * Fire a CloseEvent on the manager's onclose handler.\n * Call this from tests/Cypress to simulate a server-initiated disconnect.\n */\n public simulateClose(code = 1000): void {\n this.connected = false;\n const event = new CloseEvent('close', { code, wasClean: code === 1000 });\n this.onclose?.(event);\n }\n}\n"],"mappings":"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","names":["z","z","z"]}
package/package.json CHANGED
@@ -1,11 +1,22 @@
1
1
  {
2
2
  "name": "@studyportals/ws-client",
3
- "version": "0.1.1-beta.7",
3
+ "version": "0.1.1-beta.8",
4
4
  "description": "WebSocket client with reconnect, heartbeat, and typed event bus",
5
- "type": "module",
6
5
  "main": "dist/index.js",
7
- "module": "dist/index.js",
6
+ "module": "dist/index.mjs",
8
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ }
19
+ },
9
20
  "files": [
10
21
  "dist"
11
22
  ],