@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 +180 -0
- package/dist/event-bus.d.ts +6 -0
- package/dist/event-bus.js +2 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/transport.interface.d.ts +18 -0
- package/dist/transport.interface.js +1 -0
- package/dist/transports/mock.transport.d.ts +28 -0
- package/dist/transports/mock.transport.js +47 -0
- package/dist/transports/real.transport.d.ts +11 -0
- package/dist/transports/real.transport.js +34 -0
- package/dist/types/campaign-saving.d.ts +10 -0
- package/dist/types/campaign-saving.js +16 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.js +3 -0
- package/dist/types/ws-event-map.d.ts +12 -0
- package/dist/types/ws-event-map.js +1 -0
- package/dist/types/ws-identified.d.ts +5 -0
- package/dist/types/ws-identified.js +4 -0
- package/dist/types/ws-manager-config.d.ts +18 -0
- package/dist/types/ws-manager-config.js +1 -0
- package/dist/types/ws-message.d.ts +6 -0
- package/dist/types/ws-message.js +5 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.js +1 -0
- package/dist/websocket-manager.d.ts +34 -0
- package/dist/websocket-manager.js +131 -0
- package/package.json +31 -0
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 }` |
|
package/dist/index.d.ts
ADDED
|
@@ -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,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,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 {};
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|