@studyportals/ws-client 0.1.1-beta.0

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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # @studyportals/ws-client
2
+
3
+ Typed WebSocket client with automatic reconnect, exponential backoff, heartbeat, and a mitt-powered event bus.
4
+
5
+ Designed to replace and generalise the inline WebSocket logic previously embedded in `CampaignManagementAPIClient`.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @studyportals/ws-client
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Usage
18
+
19
+ ### 1. Initialise with `RealWebSocketTransport`
20
+
21
+ ```ts
22
+ import {
23
+ eventBus,
24
+ WebSocketManager,
25
+ RealWebSocketTransport,
26
+ } from '@studyportals/ws-client';
27
+
28
+ const transport = new RealWebSocketTransport();
29
+
30
+ const manager = WebSocketManager.getInstance(eventBus, {
31
+ url: 'wss://api.example.com/ws',
32
+ transport,
33
+ // Optional overrides (shown with defaults):
34
+ maxReconnectAttempts: 10,
35
+ baseReconnectDelayMs: 500,
36
+ maxReconnectDelayMs: 30_000,
37
+ heartbeatIntervalMs: 30_000,
38
+ heartbeatMessage: { type: 'ping' },
39
+ });
40
+
41
+ eventBus.on('ws:connected', () => {
42
+ console.log('Connected. connectionId:', manager.getConnectionId());
43
+ });
44
+
45
+ eventBus.on('ws:max-retries-exceeded', () => {
46
+ console.error('WebSocket gave up reconnecting.');
47
+ });
48
+ ```
49
+
50
+ ### 2. Subscribe to a domain event
51
+
52
+ The server must push JSON frames shaped as `{ "type": "<event-key>", "payload": { ... } }`.
53
+
54
+ ```ts
55
+ import { eventBus } from '@studyportals/ws-client';
56
+ import type { CampaignSavingPayload } from '@studyportals/ws-client';
57
+
58
+ eventBus.on('campaign:saving', (payload: CampaignSavingPayload) => {
59
+ if (payload.status === 'success') {
60
+ console.log('Campaign saved successfully.');
61
+ } else if (payload.status === 'failed') {
62
+ console.error('Save failed:', payload.error);
63
+ }
64
+ });
65
+ ```
66
+
67
+ #### Adding custom domain events
68
+
69
+ Extend `WsEventMap` via TypeScript module augmentation:
70
+
71
+ ```ts
72
+ // my-app/ws-events.d.ts
73
+ import '@studyportals/ws-client';
74
+
75
+ declare module '@studyportals/ws-client' {
76
+ interface WsEventMap {
77
+ 'invoice:generated': { invoiceId: string; amount: number };
78
+ }
79
+ }
80
+ ```
81
+
82
+ Then subscribe with full type safety:
83
+
84
+ ```ts
85
+ eventBus.on('invoice:generated', ({ invoiceId, amount }) => {
86
+ // invoiceId: string, amount: number ✓
87
+ });
88
+ ```
89
+
90
+ ### 3. Mock transport in unit tests and Cypress
91
+
92
+ ```ts
93
+ import {
94
+ eventBus,
95
+ WebSocketManager,
96
+ MockWebSocketTransport,
97
+ } from '@studyportals/ws-client';
98
+ import type { CampaignSavingPayload } from '@studyportals/ws-client';
99
+
100
+ // --- Setup (beforeEach) ---
101
+ const transport = new MockWebSocketTransport();
102
+
103
+ WebSocketManager.reset(); // clear singleton between tests
104
+ const manager = WebSocketManager.getInstance(eventBus, {
105
+ url: 'wss://irrelevant-in-tests',
106
+ transport,
107
+ });
108
+
109
+ // MockWebSocketTransport.connect() fires onopen on the next tick.
110
+ // Await a tick before asserting connected state:
111
+ await new Promise((resolve) => setTimeout(resolve, 0));
112
+
113
+ // --- Simulate a server message ---
114
+ const payload: CampaignSavingPayload = { status: 'success' };
115
+ transport.simulateMessage(JSON.stringify({ type: 'campaign:saving', payload }));
116
+
117
+ // --- Simulate a server-initiated disconnect ---
118
+ transport.simulateClose(1001);
119
+
120
+ // --- Simulate the connection identity handshake ---
121
+ transport.simulateMessage(
122
+ JSON.stringify({ type: 'ws:identified', payload: { connectionId: 'abc-123' } })
123
+ );
124
+ console.log(manager.getConnectionId()); // 'abc-123'
125
+
126
+ // --- Teardown (afterEach) ---
127
+ manager.disconnect();
128
+ WebSocketManager.reset();
129
+ eventBus.all.clear();
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Server message format
135
+
136
+ All server-pushed frames must be valid JSON with this shape:
137
+
138
+ ```json
139
+ { "type": "campaign:saving", "payload": { "status": "success" } }
140
+ ```
141
+
142
+ Frames that are not valid JSON or lack a string `type` field are emitted as `ws:unparseable` with the raw string as the payload.
143
+
144
+ The `ws:identified` event is a reserved internal type. When the server sends it, the manager caches the `connectionId` internally (accessible via `manager.getConnectionId()`) and also emits it on the bus.
145
+
146
+ ---
147
+
148
+ ## API
149
+
150
+ ### `WebSocketManager`
151
+
152
+ | Member | Description |
153
+ |---|---|
154
+ | `getInstance(bus, config)` | Returns (or creates) the singleton. |
155
+ | `reset()` | Destroys the singleton. Use between tests. |
156
+ | `getConnectionId()` | Returns the last received `connectionId`, or `undefined`. |
157
+ | `disconnect()` | Graceful close; suppresses auto-reconnect. |
158
+
159
+ ### `WebSocketManagerConfig`
160
+
161
+ | Field | Type | Default |
162
+ |---|---|---|
163
+ | `url` | `string` | — |
164
+ | `transport` | `IWebSocketTransport` | — |
165
+ | `maxReconnectAttempts` | `number` | `10` |
166
+ | `baseReconnectDelayMs` | `number` | `500` |
167
+ | `maxReconnectDelayMs` | `number` | `30000` |
168
+ | `heartbeatIntervalMs` | `number` | `30000` |
169
+ | `heartbeatMessage` | `Record<string, unknown>` | `{ type: "ping" }` |
170
+
171
+ ### Internal bus events
172
+
173
+ | Event | Payload |
174
+ |---|---|
175
+ | `ws:connected` | `undefined` |
176
+ | `ws:disconnected` | `CloseEvent` |
177
+ | `ws:error` | `Event` |
178
+ | `ws:max-retries-exceeded` | `undefined` |
179
+ | `ws:unparseable` | `string` (raw frame) |
180
+ | `ws:identified` | `{ connectionId: string }` |
@@ -0,0 +1,6 @@
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 {};
@@ -0,0 +1,2 @@
1
+ import mitt from 'mitt';
2
+ export const eventBus = mitt();
@@ -0,0 +1,8 @@
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, } from './types';
5
+ export { wsIdentifiedPayloadSchema, campaignSavingPayloadSchema, incomingMessageSchema, } from './types';
6
+ export type { IWebSocketTransport } from './transport.interface';
7
+ export { RealWebSocketTransport } from './transports/real.transport';
8
+ export { MockWebSocketTransport } from './transports/mock.transport';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
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, } from './types';
7
+ // Transport implementations
8
+ export { RealWebSocketTransport } from './transports/real.transport';
9
+ export { MockWebSocketTransport } from './transports/mock.transport';
@@ -0,0 +1,18 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
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
+ }
@@ -0,0 +1,47 @@
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
+ }
@@ -0,0 +1,11 @@
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
+ }
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,10 @@
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>;
@@ -0,0 +1,16 @@
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
+ ]);
@@ -0,0 +1,8 @@
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';
@@ -0,0 +1,3 @@
1
+ export { wsIdentifiedPayloadSchema } from './ws-identified';
2
+ export { campaignSavingPayloadSchema } from './campaign-saving';
3
+ export { incomingMessageSchema } from './ws-message';
@@ -0,0 +1,12 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
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>;
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod';
2
+ export const wsIdentifiedPayloadSchema = z.object({
3
+ connectionId: z.string(),
4
+ });
@@ -0,0 +1,18 @@
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
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
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>;
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod';
2
+ export const incomingMessageSchema = z.object({
3
+ type: z.string(),
4
+ payload: z.unknown(),
5
+ });
@@ -0,0 +1,46 @@
1
+ import type { IWebSocketTransport } from './transport.interface';
2
+ export type WsIdentifiedPayload = {
3
+ connectionId: string;
4
+ };
5
+ export type CampaignSavingPayload = {
6
+ status: 'success' | 'start' | 'failed';
7
+ error?: Record<string, unknown>;
8
+ };
9
+ export type UserUpdatedPayload = {
10
+ userId: string;
11
+ [key: string]: unknown;
12
+ };
13
+ export type NotificationReceivedPayload = {
14
+ id: string;
15
+ message: string;
16
+ [key: string]: unknown;
17
+ };
18
+ export interface WsEventMap {
19
+ 'ws:connected': undefined;
20
+ 'ws:disconnected': CloseEvent;
21
+ 'ws:error': Event;
22
+ 'ws:max-retries-exceeded': undefined;
23
+ 'ws:unparseable': string;
24
+ /** Emitted when the server sends a message with type "ws:identified". */
25
+ 'ws:identified': WsIdentifiedPayload;
26
+ 'campaign:saving': CampaignSavingPayload;
27
+ 'user:updated': UserUpdatedPayload;
28
+ 'notification:received': NotificationReceivedPayload;
29
+ }
30
+ export interface WebSocketManagerConfig {
31
+ /** WebSocket endpoint URL (e.g. "wss://api.example.com/ws"). */
32
+ url: string;
33
+ /** Transport implementation. Use RealWebSocketTransport in production,
34
+ * MockWebSocketTransport in tests. */
35
+ transport: IWebSocketTransport;
36
+ /** Maximum number of reconnect attempts before giving up. Default: 10. */
37
+ maxReconnectAttempts?: number;
38
+ /** Base delay (ms) for exponential backoff. Default: 500. */
39
+ baseReconnectDelayMs?: number;
40
+ /** Upper cap (ms) for reconnect delay. Default: 30 000. */
41
+ maxReconnectDelayMs?: number;
42
+ /** Interval (ms) between heartbeat messages while connected. Default: 30 000. */
43
+ heartbeatIntervalMs?: number;
44
+ /** Payload sent as the heartbeat message. Default: `{ "type": "ping" }`. */
45
+ heartbeatMessage?: Record<string, unknown>;
46
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,131 @@
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
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@studyportals/ws-client",
3
+ "version": "0.1.1-beta.0",
4
+ "description": "WebSocket client with reconnect, heartbeat, and typed event bus",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "type-check": "tsc --noEmit",
15
+ "release": "npm publish --access public",
16
+ "release:major": "npm run build && npm version major && npm run release",
17
+ "release:minor": "npm run build && npm version minor && npm run release",
18
+ "release:patch": "npm run build && npm version patch && npm run release",
19
+ "release:beta": "npm run build && npm version prerelease --preid beta && npm run release -- --tag beta"
20
+ },
21
+ "dependencies": {
22
+ "mitt": "^3.0.1",
23
+ "zod": "^4.3.6"
24
+ },
25
+ "peerDependencies": {
26
+ "typescript": ">=5.0"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^6.0.2"
30
+ }
31
+ }