@studyportals/ws-client 0.1.1-beta.1 → 0.1.1-beta.5

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.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,168 @@
1
- export { eventBus } from './event-bus';
2
- export type { EventBus } from './event-bus';
3
- export { WebSocketManager } from './websocket-manager';
4
- export type { WsEventMap, WsIdentifiedPayload, CampaignSavingPayload, WebSocketManagerConfig, IncomingMessage, ServerEventKey, ServerMessage, ServerMessageParsed, } from './types';
5
- export { wsIdentifiedPayloadSchema, campaignSavingPayloadSchema, incomingMessageSchema, serverMessageSchema, } from './types';
6
- export type { IWebSocketTransport } from './transport.interface';
7
- export { RealWebSocketTransport } from './transports/real.transport';
8
- export { MockWebSocketTransport } from './transports/mock.transport';
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
+ /** Gracefully close the connection without triggering automatic reconnect. */
120
+ disconnect(): void;
121
+ private connect;
122
+ private handleMessage;
123
+ private scheduleReconnect;
124
+ private clearReconnectTimer;
125
+ private startHeartbeat;
126
+ private clearHeartbeat;
127
+ }
128
+
129
+ declare class RealWebSocketTransport implements IWebSocketTransport {
130
+ private socket;
131
+ onopen: (() => void) | null;
132
+ onmessage: ((data: string) => void) | null;
133
+ onclose: ((event: CloseEvent) => void) | null;
134
+ onerror: ((event: Event) => void) | null;
135
+ connect(url: string): void;
136
+ send(data: string): void;
137
+ close(code?: number, reason?: string): void;
138
+ }
139
+
140
+ /**
141
+ * In-memory WebSocket transport for use in unit tests and Cypress specs.
142
+ *
143
+ * Wire it up via WebSocketManagerConfig.transport, then drive the connection
144
+ * state with simulateMessage() and simulateClose().
145
+ */
146
+ declare class MockWebSocketTransport implements IWebSocketTransport {
147
+ onopen: (() => void) | null;
148
+ onmessage: ((data: string) => void) | null;
149
+ onclose: ((event: CloseEvent) => void) | null;
150
+ onerror: ((event: Event) => void) | null;
151
+ private connected;
152
+ connect(_url: string): void;
153
+ /** No-op by design. Spy on this method to assert outgoing frames in tests. */
154
+ send(_data: string): void;
155
+ close(code?: number, _reason?: string): void;
156
+ /**
157
+ * Deliver a raw message string directly to the manager's onmessage handler.
158
+ * Call this from tests/Cypress to simulate server-pushed frames.
159
+ */
160
+ simulateMessage(data: string): void;
161
+ /**
162
+ * Fire a CloseEvent on the manager's onclose handler.
163
+ * Call this from tests/Cypress to simulate a server-initiated disconnect.
164
+ */
165
+ simulateClose(code?: number): void;
166
+ }
167
+
168
+ 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,9 +1,257 @@
1
- // Event bus
2
- export { eventBus } from './event-bus';
3
- // Manager
4
- export { WebSocketManager } from './websocket-manager';
5
- // Zod validators (useful for consumers and tests)
6
- export { wsIdentifiedPayloadSchema, campaignSavingPayloadSchema, incomingMessageSchema, serverMessageSchema, } from './types';
7
- // Transport implementations
8
- export { RealWebSocketTransport } from './transports/real.transport';
9
- export { MockWebSocketTransport } from './transports/mock.transport';
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 = { type: "ping" };
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
+ /** Gracefully close the connection without triggering automatic reconnect. */
86
+ disconnect() {
87
+ this.intentionalClose = true;
88
+ this.clearHeartbeat();
89
+ this.clearReconnectTimer();
90
+ this.config.transport.close(1e3, "client disconnect");
91
+ }
92
+ connect() {
93
+ const { transport, url } = this.config;
94
+ transport.onopen = () => {
95
+ this.reconnectAttempts = 0;
96
+ this.intentionalClose = false;
97
+ this.startHeartbeat();
98
+ this.bus.emit("ws:connected", void 0);
99
+ };
100
+ transport.onmessage = (data) => {
101
+ this.handleMessage(data);
102
+ };
103
+ transport.onclose = (event) => {
104
+ this.clearHeartbeat();
105
+ this.connectionId = void 0;
106
+ this.bus.emit("ws:disconnected", event);
107
+ if (!this.intentionalClose) {
108
+ this.scheduleReconnect();
109
+ }
110
+ };
111
+ transport.onerror = (event) => {
112
+ this.bus.emit("ws:error", event);
113
+ };
114
+ transport.connect(url);
115
+ }
116
+ handleMessage(raw) {
117
+ let parsedJson;
118
+ try {
119
+ parsedJson = JSON.parse(raw);
120
+ } catch {
121
+ this.bus.emit("ws:unparseable", raw);
122
+ return;
123
+ }
124
+ const result = serverMessageSchema.safeParse(parsedJson);
125
+ if (!result.success) {
126
+ this.bus.emit("ws:unparseable", raw);
127
+ return;
128
+ }
129
+ const msg = result.data;
130
+ switch (msg.type) {
131
+ case "ws:identified":
132
+ this.connectionId = msg.payload.connectionId;
133
+ this.bus.emit("ws:identified", msg.payload);
134
+ break;
135
+ case "campaign:saving":
136
+ this.bus.emit("campaign:saving", msg.payload);
137
+ break;
138
+ default: {
139
+ const _exhaustive = msg;
140
+ }
141
+ }
142
+ }
143
+ scheduleReconnect() {
144
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
145
+ this.bus.emit("ws:max-retries-exceeded", void 0);
146
+ return;
147
+ }
148
+ const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;
149
+ const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);
150
+ const capped = Math.min(exponential, maxReconnectDelayMs);
151
+ const delay = capped + Math.random() * baseReconnectDelayMs;
152
+ this.reconnectAttempts++;
153
+ this.reconnectTimer = setTimeout(() => {
154
+ this.connect();
155
+ }, delay);
156
+ }
157
+ clearReconnectTimer() {
158
+ if (this.reconnectTimer !== void 0) {
159
+ clearTimeout(this.reconnectTimer);
160
+ this.reconnectTimer = void 0;
161
+ }
162
+ }
163
+ startHeartbeat() {
164
+ this.clearHeartbeat();
165
+ this.heartbeatTimer = setInterval(() => {
166
+ this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));
167
+ }, this.config.heartbeatIntervalMs);
168
+ }
169
+ clearHeartbeat() {
170
+ if (this.heartbeatTimer !== void 0) {
171
+ clearInterval(this.heartbeatTimer);
172
+ this.heartbeatTimer = void 0;
173
+ }
174
+ }
175
+ };
176
+
177
+ // src/transports/real.transport.ts
178
+ var RealWebSocketTransport = class {
179
+ socket = null;
180
+ onopen = null;
181
+ onmessage = null;
182
+ onclose = null;
183
+ onerror = null;
184
+ connect(url) {
185
+ this.socket = new WebSocket(url);
186
+ this.socket.addEventListener("open", () => {
187
+ this.onopen?.();
188
+ });
189
+ this.socket.addEventListener("message", (event) => {
190
+ if (typeof event.data === "string") {
191
+ this.onmessage?.(event.data);
192
+ }
193
+ });
194
+ this.socket.addEventListener("close", (event) => {
195
+ this.onclose?.(event);
196
+ });
197
+ this.socket.addEventListener("error", (event) => {
198
+ this.onerror?.(event);
199
+ });
200
+ }
201
+ send(data) {
202
+ if (this.socket?.readyState === WebSocket.OPEN) {
203
+ this.socket.send(data);
204
+ }
205
+ }
206
+ close(code, reason) {
207
+ this.socket?.close(code, reason);
208
+ }
209
+ };
210
+
211
+ // src/transports/mock.transport.ts
212
+ var MockWebSocketTransport = class {
213
+ onopen = null;
214
+ onmessage = null;
215
+ onclose = null;
216
+ onerror = null;
217
+ connected = false;
218
+ connect(_url) {
219
+ this.connected = true;
220
+ setTimeout(() => {
221
+ this.onopen?.();
222
+ }, 0);
223
+ }
224
+ /** No-op by design. Spy on this method to assert outgoing frames in tests. */
225
+ send(_data) {
226
+ }
227
+ close(code = 1e3, _reason) {
228
+ if (!this.connected) return;
229
+ this.simulateClose(code);
230
+ }
231
+ /**
232
+ * Deliver a raw message string directly to the manager's onmessage handler.
233
+ * Call this from tests/Cypress to simulate server-pushed frames.
234
+ */
235
+ simulateMessage(data) {
236
+ this.onmessage?.(data);
237
+ }
238
+ /**
239
+ * Fire a CloseEvent on the manager's onclose handler.
240
+ * Call this from tests/Cypress to simulate a server-initiated disconnect.
241
+ */
242
+ simulateClose(code = 1e3) {
243
+ this.connected = false;
244
+ const event = new CloseEvent("close", { code, wasClean: code === 1e3 });
245
+ this.onclose?.(event);
246
+ }
247
+ };
248
+ export {
249
+ MockWebSocketTransport,
250
+ RealWebSocketTransport,
251
+ WebSocketManager,
252
+ campaignSavingPayloadSchema,
253
+ eventBus,
254
+ serverMessageSchema,
255
+ wsIdentifiedPayloadSchema
256
+ };
257
+ //# sourceMappingURL=index.js.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 } 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> = { type: 'ping' };\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 /** 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 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,MAAM,OAAO;AAInE,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,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;AACxB,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;;;AClLO,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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studyportals/ws-client",
3
- "version": "0.1.1-beta.1",
3
+ "version": "0.1.1-beta.5",
4
4
  "description": "WebSocket client with reconnect, heartbeat, and typed event bus",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "scripts": {
13
- "build": "tsc",
13
+ "build": "tsup",
14
14
  "type-check": "tsc --noEmit",
15
15
  "release": "npm publish --access public",
16
16
  "release:major": "npm run build && npm version major && npm run release",
@@ -26,6 +26,7 @@
26
26
  "typescript": ">=5.0"
27
27
  },
28
28
  "devDependencies": {
29
+ "tsup": "^8.5.1",
29
30
  "typescript": "^6.0.2"
30
31
  }
31
32
  }
@@ -1,6 +0,0 @@
1
- import mitt from 'mitt';
2
- import type { WsEventMap } from './types';
3
- type MittMap = WsEventMap & Record<string | symbol, unknown>;
4
- export type EventBus = ReturnType<typeof mitt<MittMap>>;
5
- export declare const eventBus: EventBus;
6
- export {};
package/dist/event-bus.js DELETED
@@ -1,2 +0,0 @@
1
- import mitt from 'mitt';
2
- export const eventBus = mitt();
@@ -1,18 +0,0 @@
1
- /**
2
- * Abstraction over a WebSocket connection.
3
- *
4
- * The manager wires up the callbacks and calls connect() / send() / close().
5
- * Implementations handle the actual I/O (or simulation in tests).
6
- */
7
- export interface IWebSocketTransport {
8
- onopen: (() => void) | null;
9
- onmessage: ((data: string) => void) | null;
10
- onclose: ((event: CloseEvent) => void) | null;
11
- onerror: ((event: Event) => void) | null;
12
- /** Open the connection to the given URL. */
13
- connect(url: string): void;
14
- /** Send a raw string frame. */
15
- send(data: string): void;
16
- /** Close the connection with an optional status code and reason. */
17
- close(code?: number, reason?: string): void;
18
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,28 +0,0 @@
1
- import type { IWebSocketTransport } from '../transport.interface';
2
- /**
3
- * In-memory WebSocket transport for use in unit tests and Cypress specs.
4
- *
5
- * Wire it up via WebSocketManagerConfig.transport, then drive the connection
6
- * state with simulateMessage() and simulateClose().
7
- */
8
- export declare class MockWebSocketTransport implements IWebSocketTransport {
9
- onopen: (() => void) | null;
10
- onmessage: ((data: string) => void) | null;
11
- onclose: ((event: CloseEvent) => void) | null;
12
- onerror: ((event: Event) => void) | null;
13
- private connected;
14
- connect(_url: string): void;
15
- /** No-op by design. Spy on this method to assert outgoing frames in tests. */
16
- send(_data: string): void;
17
- close(code?: number, _reason?: string): void;
18
- /**
19
- * Deliver a raw message string directly to the manager's onmessage handler.
20
- * Call this from tests/Cypress to simulate server-pushed frames.
21
- */
22
- simulateMessage(data: string): void;
23
- /**
24
- * Fire a CloseEvent on the manager's onclose handler.
25
- * Call this from tests/Cypress to simulate a server-initiated disconnect.
26
- */
27
- simulateClose(code?: number): void;
28
- }
@@ -1,47 +0,0 @@
1
- /**
2
- * In-memory WebSocket transport for use in unit tests and Cypress specs.
3
- *
4
- * Wire it up via WebSocketManagerConfig.transport, then drive the connection
5
- * state with simulateMessage() and simulateClose().
6
- */
7
- export class MockWebSocketTransport {
8
- constructor() {
9
- this.onopen = null;
10
- this.onmessage = null;
11
- this.onclose = null;
12
- this.onerror = null;
13
- this.connected = false;
14
- }
15
- connect(_url) {
16
- this.connected = true;
17
- // Simulate async open so callers can register handlers before the event fires
18
- setTimeout(() => {
19
- this.onopen?.();
20
- }, 0);
21
- }
22
- /** No-op by design. Spy on this method to assert outgoing frames in tests. */
23
- send(_data) {
24
- // intentionally empty
25
- }
26
- close(code = 1000, _reason) {
27
- if (!this.connected)
28
- return;
29
- this.simulateClose(code);
30
- }
31
- /**
32
- * Deliver a raw message string directly to the manager's onmessage handler.
33
- * Call this from tests/Cypress to simulate server-pushed frames.
34
- */
35
- simulateMessage(data) {
36
- this.onmessage?.(data);
37
- }
38
- /**
39
- * Fire a CloseEvent on the manager's onclose handler.
40
- * Call this from tests/Cypress to simulate a server-initiated disconnect.
41
- */
42
- simulateClose(code = 1000) {
43
- this.connected = false;
44
- const event = new CloseEvent('close', { code, wasClean: code === 1000 });
45
- this.onclose?.(event);
46
- }
47
- }
@@ -1,11 +0,0 @@
1
- import type { IWebSocketTransport } from '../transport.interface';
2
- export declare class RealWebSocketTransport implements IWebSocketTransport {
3
- private socket;
4
- onopen: (() => void) | null;
5
- onmessage: ((data: string) => void) | null;
6
- onclose: ((event: CloseEvent) => void) | null;
7
- onerror: ((event: Event) => void) | null;
8
- connect(url: string): void;
9
- send(data: string): void;
10
- close(code?: number, reason?: string): void;
11
- }
@@ -1,34 +0,0 @@
1
- export class RealWebSocketTransport {
2
- constructor() {
3
- this.socket = null;
4
- this.onopen = null;
5
- this.onmessage = null;
6
- this.onclose = null;
7
- this.onerror = null;
8
- }
9
- connect(url) {
10
- this.socket = new WebSocket(url);
11
- this.socket.addEventListener('open', () => {
12
- this.onopen?.();
13
- });
14
- this.socket.addEventListener('message', (event) => {
15
- if (typeof event.data === 'string') {
16
- this.onmessage?.(event.data);
17
- }
18
- });
19
- this.socket.addEventListener('close', (event) => {
20
- this.onclose?.(event);
21
- });
22
- this.socket.addEventListener('error', (event) => {
23
- this.onerror?.(event);
24
- });
25
- }
26
- send(data) {
27
- if (this.socket?.readyState === WebSocket.OPEN) {
28
- this.socket.send(data);
29
- }
30
- }
31
- close(code, reason) {
32
- this.socket?.close(code, reason);
33
- }
34
- }
@@ -1,10 +0,0 @@
1
- import { z } from 'zod';
2
- export declare const campaignSavingPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
- status: z.ZodLiteral<"start">;
4
- }, z.core.$strip>, z.ZodObject<{
5
- status: z.ZodLiteral<"success">;
6
- }, z.core.$strip>, z.ZodObject<{
7
- status: z.ZodLiteral<"failed">;
8
- error: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9
- }, z.core.$strip>], "status">;
10
- export type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;
@@ -1,16 +0,0 @@
1
- import { z } from 'zod';
2
- const campaignSavingStartSchema = z.object({
3
- status: z.literal('start'),
4
- });
5
- const campaignSavingSuccessSchema = z.object({
6
- status: z.literal('success'),
7
- });
8
- const campaignSavingFailedSchema = z.object({
9
- status: z.literal('failed'),
10
- error: z.record(z.string(), z.unknown()),
11
- });
12
- export const campaignSavingPayloadSchema = z.discriminatedUnion('status', [
13
- campaignSavingStartSchema,
14
- campaignSavingSuccessSchema,
15
- campaignSavingFailedSchema,
16
- ]);
@@ -1,10 +0,0 @@
1
- export type { WsEventMap } from './ws-event-map';
2
- export type { WsIdentifiedPayload } from './ws-identified';
3
- export { wsIdentifiedPayloadSchema } from './ws-identified';
4
- export type { CampaignSavingPayload } from './campaign-saving';
5
- export { campaignSavingPayloadSchema } from './campaign-saving';
6
- export type { WebSocketManagerConfig } from './ws-manager-config';
7
- export type { IncomingMessage } from './ws-message';
8
- export { incomingMessageSchema } from './ws-message';
9
- export type { ServerEventKey, ServerMessage, ServerMessageParsed } from './server-message';
10
- export { serverMessageSchema } from './server-message';
@@ -1,4 +0,0 @@
1
- export { wsIdentifiedPayloadSchema } from './ws-identified';
2
- export { campaignSavingPayloadSchema } from './campaign-saving';
3
- export { incomingMessageSchema } from './ws-message';
4
- export { serverMessageSchema } from './server-message';
@@ -1,28 +0,0 @@
1
- import { z } from 'zod';
2
- import type { WsEventMap } from './ws-event-map';
3
- type ClientOnlyEvent = 'ws:connected' | 'ws:disconnected' | 'ws:error' | 'ws:max-retries-exceeded' | 'ws:unparseable';
4
- export type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;
5
- export type ServerMessage = {
6
- [K in ServerEventKey]: {
7
- type: K;
8
- payload: WsEventMap[K];
9
- };
10
- }[ServerEventKey];
11
- export declare const serverMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
12
- type: z.ZodLiteral<"ws:identified">;
13
- payload: z.ZodObject<{
14
- connectionId: z.ZodString;
15
- }, z.core.$strip>;
16
- }, z.core.$strip>, z.ZodObject<{
17
- type: z.ZodLiteral<"campaign:saving">;
18
- payload: z.ZodDiscriminatedUnion<[z.ZodObject<{
19
- status: z.ZodLiteral<"start">;
20
- }, z.core.$strip>, z.ZodObject<{
21
- status: z.ZodLiteral<"success">;
22
- }, z.core.$strip>, z.ZodObject<{
23
- status: z.ZodLiteral<"failed">;
24
- error: z.ZodRecord<z.ZodString, z.ZodUnknown>;
25
- }, z.core.$strip>], "status">;
26
- }, z.core.$strip>], "type">;
27
- export type ServerMessageParsed = z.infer<typeof serverMessageSchema>;
28
- export {};
@@ -1,14 +0,0 @@
1
- import { z } from 'zod';
2
- import { wsIdentifiedPayloadSchema } from './ws-identified';
3
- import { campaignSavingPayloadSchema } from './campaign-saving';
4
- // ---------------------------------------------------------------------------
5
- // serverMessageSchema — Zod discriminated union over every server event.
6
- // Validates that an incoming frame is both structurally and semantically valid.
7
- //
8
- // NOTE: if you extend WsEventMap via module augmentation you must also add
9
- // the corresponding member to this schema.
10
- // ---------------------------------------------------------------------------
11
- export const serverMessageSchema = z.discriminatedUnion('type', [
12
- z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),
13
- z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),
14
- ]);
@@ -1,12 +0,0 @@
1
- import type { WsIdentifiedPayload } from './ws-identified';
2
- import type { CampaignSavingPayload } from './campaign-saving';
3
- export interface WsEventMap {
4
- 'ws:connected': undefined;
5
- 'ws:disconnected': CloseEvent;
6
- 'ws:error': Event;
7
- 'ws:max-retries-exceeded': undefined;
8
- 'ws:unparseable': string;
9
- /** Emitted when the server sends a message with type "ws:identified". */
10
- 'ws:identified': WsIdentifiedPayload;
11
- 'campaign:saving': CampaignSavingPayload;
12
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,5 +0,0 @@
1
- import { z } from 'zod';
2
- export declare const wsIdentifiedPayloadSchema: z.ZodObject<{
3
- connectionId: z.ZodString;
4
- }, z.core.$strip>;
5
- export type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;
@@ -1,4 +0,0 @@
1
- import { z } from 'zod';
2
- export const wsIdentifiedPayloadSchema = z.object({
3
- connectionId: z.string(),
4
- });
@@ -1,18 +0,0 @@
1
- import type { IWebSocketTransport } from '../transport.interface';
2
- export type WebSocketManagerConfig = {
3
- /** WebSocket endpoint URL (e.g. "wss://api.example.com/ws"). */
4
- url: string;
5
- /** Transport implementation. Use RealWebSocketTransport in production,
6
- * MockWebSocketTransport in tests. */
7
- transport: IWebSocketTransport;
8
- /** Maximum number of reconnect attempts before giving up. Default: 10. */
9
- maxReconnectAttempts?: number;
10
- /** Base delay (ms) for exponential backoff. Default: 500. */
11
- baseReconnectDelayMs?: number;
12
- /** Upper cap (ms) for reconnect delay. Default: 30 000. */
13
- maxReconnectDelayMs?: number;
14
- /** Interval (ms) between heartbeat messages while connected. Default: 30 000. */
15
- heartbeatIntervalMs?: number;
16
- /** Payload sent as the heartbeat message. Default: `{ "type": "ping" }`. */
17
- heartbeatMessage?: Record<string, unknown>;
18
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,6 +0,0 @@
1
- import { z } from 'zod';
2
- export declare const incomingMessageSchema: z.ZodObject<{
3
- type: z.ZodString;
4
- payload: z.ZodUnknown;
5
- }, z.core.$strip>;
6
- export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
@@ -1,5 +0,0 @@
1
- import { z } from 'zod';
2
- export const incomingMessageSchema = z.object({
3
- type: z.string(),
4
- payload: z.unknown(),
5
- });
@@ -1,34 +0,0 @@
1
- import type { EventBus } from './event-bus';
2
- import type { WebSocketManagerConfig } from './types';
3
- export type { WebSocketManagerConfig } from './types';
4
- export declare class WebSocketManager {
5
- private static instance;
6
- private readonly bus;
7
- private readonly config;
8
- private reconnectAttempts;
9
- private intentionalClose;
10
- private heartbeatTimer;
11
- private reconnectTimer;
12
- private connectionId;
13
- private constructor();
14
- /**
15
- * Returns the singleton instance, creating it on first call.
16
- * Subsequent calls ignore `bus` and `config` — pass them only on first call.
17
- */
18
- static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager;
19
- /**
20
- * Destroy the singleton.
21
- * Call before re-initialising in tests or when switching environments.
22
- */
23
- static reset(): void;
24
- /** Returns the connectionId received from the last "ws:identified" message. */
25
- getConnectionId(): string | undefined;
26
- /** Gracefully close the connection without triggering automatic reconnect. */
27
- disconnect(): void;
28
- private connect;
29
- private handleMessage;
30
- private scheduleReconnect;
31
- private clearReconnectTimer;
32
- private startHeartbeat;
33
- private clearHeartbeat;
34
- }
@@ -1,131 +0,0 @@
1
- import { incomingMessageSchema, wsIdentifiedPayloadSchema } from './types';
2
- const DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
3
- const DEFAULT_BASE_RECONNECT_DELAY_MS = 500;
4
- const DEFAULT_MAX_RECONNECT_DELAY_MS = 30000;
5
- const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
6
- const DEFAULT_HEARTBEAT_MESSAGE = { type: 'ping' };
7
- export class WebSocketManager {
8
- constructor(bus, config) {
9
- this.reconnectAttempts = 0;
10
- this.intentionalClose = false;
11
- this.bus = bus;
12
- this.config = {
13
- url: config.url,
14
- transport: config.transport,
15
- maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,
16
- baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,
17
- maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,
18
- heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
19
- heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,
20
- };
21
- this.connect();
22
- }
23
- /**
24
- * Returns the singleton instance, creating it on first call.
25
- * Subsequent calls ignore `bus` and `config` — pass them only on first call.
26
- */
27
- static getInstance(bus, config) {
28
- if (!WebSocketManager.instance) {
29
- WebSocketManager.instance = new WebSocketManager(bus, config);
30
- }
31
- return WebSocketManager.instance;
32
- }
33
- /**
34
- * Destroy the singleton.
35
- * Call before re-initialising in tests or when switching environments.
36
- */
37
- static reset() {
38
- WebSocketManager.instance = undefined;
39
- }
40
- /** Returns the connectionId received from the last "ws:identified" message. */
41
- getConnectionId() {
42
- return this.connectionId;
43
- }
44
- /** Gracefully close the connection without triggering automatic reconnect. */
45
- disconnect() {
46
- this.intentionalClose = true;
47
- this.clearHeartbeat();
48
- this.clearReconnectTimer();
49
- this.config.transport.close(1000, 'client disconnect');
50
- }
51
- connect() {
52
- const { transport, url } = this.config;
53
- transport.onopen = () => {
54
- this.reconnectAttempts = 0;
55
- this.intentionalClose = false;
56
- this.startHeartbeat();
57
- this.bus.emit('ws:connected', undefined);
58
- };
59
- transport.onmessage = (data) => {
60
- this.handleMessage(data);
61
- };
62
- transport.onclose = (event) => {
63
- this.clearHeartbeat();
64
- this.connectionId = undefined;
65
- this.bus.emit('ws:disconnected', event);
66
- if (!this.intentionalClose) {
67
- this.scheduleReconnect();
68
- }
69
- };
70
- transport.onerror = (event) => {
71
- this.bus.emit('ws:error', event);
72
- };
73
- transport.connect(url);
74
- }
75
- handleMessage(raw) {
76
- let parsedJson;
77
- try {
78
- parsedJson = JSON.parse(raw);
79
- }
80
- catch {
81
- this.bus.emit('ws:unparseable', raw);
82
- return;
83
- }
84
- const msgResult = incomingMessageSchema.safeParse(parsedJson);
85
- if (!msgResult.success) {
86
- this.bus.emit('ws:unparseable', raw);
87
- return;
88
- }
89
- const msg = msgResult.data;
90
- if (msg.type === 'ws:identified') {
91
- const idResult = wsIdentifiedPayloadSchema.safeParse(msg.payload);
92
- if (idResult.success) {
93
- this.connectionId = idResult.data.connectionId;
94
- }
95
- }
96
- this.bus.emit(msg.type, msg.payload);
97
- }
98
- scheduleReconnect() {
99
- if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
100
- this.bus.emit('ws:max-retries-exceeded', undefined);
101
- return;
102
- }
103
- const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;
104
- const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);
105
- const capped = Math.min(exponential, maxReconnectDelayMs);
106
- // Add random jitter up to one base interval to avoid thundering herd
107
- const delay = capped + Math.random() * baseReconnectDelayMs;
108
- this.reconnectAttempts++;
109
- this.reconnectTimer = setTimeout(() => {
110
- this.connect();
111
- }, delay);
112
- }
113
- clearReconnectTimer() {
114
- if (this.reconnectTimer !== undefined) {
115
- clearTimeout(this.reconnectTimer);
116
- this.reconnectTimer = undefined;
117
- }
118
- }
119
- startHeartbeat() {
120
- this.clearHeartbeat();
121
- this.heartbeatTimer = setInterval(() => {
122
- this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));
123
- }, this.config.heartbeatIntervalMs);
124
- }
125
- clearHeartbeat() {
126
- if (this.heartbeatTimer !== undefined) {
127
- clearInterval(this.heartbeatTimer);
128
- this.heartbeatTimer = undefined;
129
- }
130
- }
131
- }