@wooksjs/ws-client 0.7.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/index.cjs +409 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.mjs +406 -0
- package/package.json +59 -0
- package/scripts/setup-skills.js +78 -0
- package/skills/wooksjs-ws-client/SKILL.md +35 -0
- package/skills/wooksjs-ws-client/core.md +203 -0
- package/skills/wooksjs-ws-client/push.md +111 -0
- package/skills/wooksjs-ws-client/reconnect.md +145 -0
- package/skills/wooksjs-ws-client/rpc.md +134 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Core concepts & setup — @wooksjs/ws-client
|
|
2
|
+
|
|
3
|
+
> WebSocket client with RPC, subscriptions, reconnection, and push listeners — designed to pair with `@wooksjs/event-ws`.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
`@wooksjs/ws-client` is a standalone WebSocket client (no dependency on `@wooksjs/event-core`) that speaks the same JSON wire protocol as `@wooksjs/event-ws`. It provides:
|
|
8
|
+
|
|
9
|
+
- **Fire-and-forget messaging** via `send()`
|
|
10
|
+
- **RPC (request-response)** via `call()` with correlation IDs and timeouts
|
|
11
|
+
- **Subscriptions** via `subscribe()` with auto-resubscribe on reconnect
|
|
12
|
+
- **Push listeners** via `on()` with exact and wildcard path matching
|
|
13
|
+
- **Auto-reconnection** with configurable backoff
|
|
14
|
+
- **Message queuing** when disconnected (with reconnect enabled)
|
|
15
|
+
|
|
16
|
+
### Wire protocol
|
|
17
|
+
|
|
18
|
+
The client sends `WsClientMessage` and receives either `WsReplyMessage` (for RPC) or `WsPushMessage` (for push/broadcast):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// Client → Server
|
|
22
|
+
interface WsClientMessage {
|
|
23
|
+
event: string // e.g. "message", "rpc", "subscribe"
|
|
24
|
+
path: string // e.g. "/chat/rooms/lobby"
|
|
25
|
+
data?: unknown
|
|
26
|
+
id?: number // auto-generated for call(), triggers a reply
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Server → Client (reply)
|
|
30
|
+
interface WsReplyMessage {
|
|
31
|
+
id: string | number
|
|
32
|
+
data?: unknown
|
|
33
|
+
error?: { code: number; message: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Server → Client (push)
|
|
37
|
+
interface WsPushMessage {
|
|
38
|
+
event: string
|
|
39
|
+
path: string
|
|
40
|
+
params?: Record<string, string>
|
|
41
|
+
data?: unknown
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Installation / Setup
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm add @wooksjs/ws-client
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The client uses the native `WebSocket` API (available in browsers and Node.js >= 22). For Node.js < 22, install the `ws` package — it's an optional peer dependency.
|
|
52
|
+
|
|
53
|
+
## API Reference
|
|
54
|
+
|
|
55
|
+
### `createWsClient(url, options?)`
|
|
56
|
+
|
|
57
|
+
Factory function. Creates and immediately connects a `WsClient`.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const client = createWsClient('wss://api.example.com/ws', {
|
|
61
|
+
reconnect: true,
|
|
62
|
+
rpcTimeout: 5000,
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `WsClientOptions`
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
interface WsClientOptions {
|
|
70
|
+
protocols?: string | string[] // WebSocket sub-protocols
|
|
71
|
+
reconnect?: boolean | WsClientReconnectOptions // default: disabled
|
|
72
|
+
rpcTimeout?: number // ms (default: 10000)
|
|
73
|
+
messageParser?: (raw: string) => WsReplyMessage | WsPushMessage
|
|
74
|
+
messageSerializer?: (msg: WsClientMessage) => string
|
|
75
|
+
_WebSocket?: WebSocketConstructor // @internal — for testing
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `WsClient` class
|
|
80
|
+
|
|
81
|
+
#### `client.send(event, path, data?)`
|
|
82
|
+
|
|
83
|
+
Fire-and-forget message. Queued when disconnected if reconnect is enabled.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
client.send('message', '/chat/rooms/lobby', { text: 'hello' })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### `client.call<T>(event, path, data?): Promise<T>`
|
|
90
|
+
|
|
91
|
+
RPC call. Auto-generates a correlation ID. Rejects with `WsClientError` on timeout, server error, or disconnect.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const user = await client.call<User>('rpc', '/users/me')
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### `client.subscribe(path, data?): Promise<() => void>`
|
|
98
|
+
|
|
99
|
+
Subscribe to a server path. Sends a `subscribe` event via RPC, stores the subscription for auto-resubscribe on reconnect. Returns an unsubscribe function.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const unsub = await client.subscribe('/chat/rooms/lobby')
|
|
103
|
+
// later:
|
|
104
|
+
unsub() // sends "unsubscribe" event
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### `client.on<T>(event, pathPattern, handler): () => void`
|
|
108
|
+
|
|
109
|
+
Register a client-side push listener. Supports exact paths and wildcard (`*`) suffix. Returns an unregister function.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const off = client.on<{ text: string }>('message', '/chat/rooms/*', ({ data, path }) => {
|
|
113
|
+
console.log(`${path}: ${data.text}`)
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### `client.close()`
|
|
118
|
+
|
|
119
|
+
Close the connection. Disables reconnect. Rejects all pending RPCs. Clears the message queue.
|
|
120
|
+
|
|
121
|
+
#### Lifecycle events
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
client.onOpen(() => console.log('Connected'))
|
|
125
|
+
client.onClose((code, reason) => console.log(`Closed: ${code}`))
|
|
126
|
+
client.onError((event) => console.error('Error:', event))
|
|
127
|
+
client.onReconnect((attempt) => console.log(`Reconnecting #${attempt}`))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
All lifecycle methods return an unregister function.
|
|
131
|
+
|
|
132
|
+
### `WsClientError`
|
|
133
|
+
|
|
134
|
+
Error class with a numeric `code`. Thrown/rejected by `call()` and `subscribe()`.
|
|
135
|
+
|
|
136
|
+
Common codes:
|
|
137
|
+
|
|
138
|
+
- `408` — RPC timeout
|
|
139
|
+
- `503` — Not connected / connection lost / connection closed
|
|
140
|
+
- Server error codes (e.g., `404`, `403`, `500`) from `WsReplyMessage.error`
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
try {
|
|
144
|
+
await client.call('rpc', '/protected')
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof WsClientError && err.code === 403) {
|
|
147
|
+
console.log('Forbidden')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Common Patterns
|
|
153
|
+
|
|
154
|
+
### Pattern: Basic chat client
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const client = createWsClient('wss://api.example.com/ws', { reconnect: true })
|
|
158
|
+
|
|
159
|
+
// Listen for messages
|
|
160
|
+
client.on<{ text: string }>('message', '/chat/rooms/*', ({ data, path }) => {
|
|
161
|
+
console.log(`[${path}] ${data.text}`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Subscribe to a room
|
|
165
|
+
await client.subscribe('/chat/rooms/lobby')
|
|
166
|
+
|
|
167
|
+
// Send a message
|
|
168
|
+
client.send('message', '/chat/rooms/lobby', { text: 'Hello everyone!' })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Pattern: RPC calls
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const client = createWsClient('wss://api.example.com/ws')
|
|
175
|
+
|
|
176
|
+
const user = await client.call<{ name: string }>('rpc', '/users/me')
|
|
177
|
+
console.log(user.name)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Pattern: Custom serializer
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const client = createWsClient('wss://api.example.com/ws', {
|
|
184
|
+
messageSerializer: (msg) => JSON.stringify(msg),
|
|
185
|
+
messageParser: (raw) => JSON.parse(raw),
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Best Practices
|
|
190
|
+
|
|
191
|
+
- Always enable `reconnect` for production clients — network interruptions are inevitable
|
|
192
|
+
- Set `rpcTimeout` appropriate for your use case (default: 10s)
|
|
193
|
+
- Use typed generics with `call<T>()` and `on<T>()` for type-safe payloads
|
|
194
|
+
- Store the unsubscribe/unregister functions returned by `subscribe()`, `on()`, `onOpen()`, etc. to prevent leaks
|
|
195
|
+
- Call `client.close()` when the client is no longer needed
|
|
196
|
+
|
|
197
|
+
## Gotchas
|
|
198
|
+
|
|
199
|
+
- `call()` rejects immediately with code 503 if the socket is not open — it does NOT queue like `send()`
|
|
200
|
+
- `send()` only queues when disconnected if `reconnect` is enabled; otherwise the message is silently dropped
|
|
201
|
+
- The client connects immediately on construction — there is no `connect()` method
|
|
202
|
+
- For Node.js < 22 without the `ws` package, the constructor throws `TypeError`
|
|
203
|
+
- Subscriptions are auto-resubscribed on reconnect, but failures are silently swallowed — the next reconnect will retry
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Push listeners — @wooksjs/ws-client
|
|
2
|
+
|
|
3
|
+
> Client-side listeners for server push messages with exact and wildcard path matching.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
Server push messages (`WsPushMessage`) arrive without a correlation ID — they represent broadcasts, room messages, or subscription notifications. The client dispatches these to registered handlers based on `event` + `path` matching.
|
|
8
|
+
|
|
9
|
+
Two matching modes:
|
|
10
|
+
|
|
11
|
+
1. **Exact match** — O(1) Map lookup by `"event:path"` key
|
|
12
|
+
2. **Wildcard match** — path pattern ends with `*`, uses `startsWith` prefix check
|
|
13
|
+
|
|
14
|
+
Handlers receive a `WsClientPushEvent<T>` with typed `data`.
|
|
15
|
+
|
|
16
|
+
## API Reference
|
|
17
|
+
|
|
18
|
+
### `client.on<T>(event, pathPattern, handler): () => void`
|
|
19
|
+
|
|
20
|
+
Register a push listener. Returns an unregister function.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
|
|
24
|
+
- `event: string` — the event type to match (e.g. `"message"`, `"notification"`)
|
|
25
|
+
- `pathPattern: string` — exact path or wildcard (`"/chat/rooms/*"`)
|
|
26
|
+
- `handler: WsPushHandler<T>` — receives `WsClientPushEvent<T>`
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
interface WsClientPushEvent<T = unknown> {
|
|
30
|
+
event: string // event type from server
|
|
31
|
+
path: string // concrete path from server
|
|
32
|
+
params: Record<string, string> // route params from server
|
|
33
|
+
data: T // typed payload
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type WsPushHandler<T = unknown> = (ev: WsClientPushEvent<T>) => void
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Matching rules
|
|
40
|
+
|
|
41
|
+
**Exact:**
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
client.on('message', '/chat/rooms/lobby', handler)
|
|
45
|
+
// Matches: { event: 'message', path: '/chat/rooms/lobby' }
|
|
46
|
+
// No match: { event: 'message', path: '/chat/rooms/general' }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Wildcard:**
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
client.on('message', '/chat/rooms/*', handler)
|
|
53
|
+
// Matches: { event: 'message', path: '/chat/rooms/lobby' }
|
|
54
|
+
// Matches: { event: 'message', path: '/chat/rooms/general' }
|
|
55
|
+
// No match: { event: 'notification', path: '/chat/rooms/lobby' }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The wildcard `*` only works as a suffix — `"/*/rooms"` is NOT supported.
|
|
59
|
+
|
|
60
|
+
## Common Patterns
|
|
61
|
+
|
|
62
|
+
### Pattern: Listen for chat messages in all rooms
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
client.on<{ text: string; from: string }>('message', '/chat/rooms/*', ({ data, path, params }) => {
|
|
66
|
+
console.log(`[${params.roomId ?? path}] ${data.from}: ${data.text}`)
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Pattern: Notifications
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
client.on<{ title: string; body: string }>('notification', '/notifications', ({ data }) => {
|
|
74
|
+
showToast(data.title, data.body)
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Pattern: Multiple listeners for the same path
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// Both handlers fire for the same message
|
|
82
|
+
client.on('message', '/chat/rooms/lobby', ({ data }) => updateUI(data))
|
|
83
|
+
client.on('message', '/chat/rooms/lobby', ({ data }) => logMessage(data))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Pattern: Cleanup
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const handlers: Array<() => void> = []
|
|
90
|
+
|
|
91
|
+
handlers.push(client.on('message', '/chat/*', handleChat))
|
|
92
|
+
handlers.push(client.on('notification', '/alerts', handleAlert))
|
|
93
|
+
|
|
94
|
+
// Cleanup all:
|
|
95
|
+
for (const off of handlers) off()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Best Practices
|
|
99
|
+
|
|
100
|
+
- Use wildcard patterns sparingly — each incoming push message is checked against all wildcards (linear scan)
|
|
101
|
+
- Prefer exact paths when the set of paths is known at compile time
|
|
102
|
+
- Always store the unregister function to prevent memory leaks when handlers are no longer needed
|
|
103
|
+
- Type the generic `<T>` on `on<T>()` to get typed `data` in the handler
|
|
104
|
+
|
|
105
|
+
## Gotchas
|
|
106
|
+
|
|
107
|
+
- Wildcard only works as a suffix (`/path/*`); it is NOT a glob or regex
|
|
108
|
+
- The `params` field comes from the server (extracted by the Wooks router) — the client does NOT parse path params itself
|
|
109
|
+
- If no handler matches a push message, it is silently dropped
|
|
110
|
+
- Handlers are called synchronously in iteration order — a slow handler delays dispatch to subsequent handlers
|
|
111
|
+
- The event type must match exactly — there is no wildcard for event types
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Reconnection — @wooksjs/ws-client
|
|
2
|
+
|
|
3
|
+
> Auto-reconnection with configurable backoff, message queuing during disconnect, and subscription rehydration.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
When `reconnect` is enabled, the client automatically attempts to re-establish the connection after an unexpected close. The reconnection system has three components:
|
|
8
|
+
|
|
9
|
+
1. **ReconnectController** — manages backoff timing and attempt counting
|
|
10
|
+
2. **MessageQueue** — buffers `send()` messages while disconnected
|
|
11
|
+
3. **Subscription store** — remembers active subscriptions for auto-resubscribe
|
|
12
|
+
|
|
13
|
+
On reconnection:
|
|
14
|
+
|
|
15
|
+
1. Backoff delay elapses → new WebSocket connection attempt
|
|
16
|
+
2. On open → reset attempt counter, flush queued messages, re-subscribe all stored subscriptions
|
|
17
|
+
3. On failure → increment attempt, schedule next attempt (up to `maxRetries`)
|
|
18
|
+
|
|
19
|
+
## API Reference
|
|
20
|
+
|
|
21
|
+
### `WsClientReconnectOptions`
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
interface WsClientReconnectOptions {
|
|
25
|
+
enabled: boolean // must be true to enable
|
|
26
|
+
maxRetries?: number // default: Infinity
|
|
27
|
+
baseDelay?: number // ms (default: 1000)
|
|
28
|
+
maxDelay?: number // ms (default: 30000)
|
|
29
|
+
backoff?: 'linear' | 'exponential' // default: 'exponential'
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Shorthand: pass `reconnect: true` for defaults, `reconnect: false` to disable.
|
|
34
|
+
|
|
35
|
+
### Backoff calculation
|
|
36
|
+
|
|
37
|
+
**Exponential** (default): `delay = baseDelay * 2^attempt` (capped at `maxDelay`)
|
|
38
|
+
|
|
39
|
+
- Attempt 0: 1000ms
|
|
40
|
+
- Attempt 1: 2000ms
|
|
41
|
+
- Attempt 2: 4000ms
|
|
42
|
+
- Attempt 3: 8000ms
|
|
43
|
+
- ... capped at 30000ms
|
|
44
|
+
|
|
45
|
+
**Linear**: `delay = baseDelay * (attempt + 1)` (capped at `maxDelay`)
|
|
46
|
+
|
|
47
|
+
- Attempt 0: 1000ms
|
|
48
|
+
- Attempt 1: 2000ms
|
|
49
|
+
- Attempt 2: 3000ms
|
|
50
|
+
- ... capped at 30000ms
|
|
51
|
+
|
|
52
|
+
### `client.onReconnect(handler): () => void`
|
|
53
|
+
|
|
54
|
+
Register a handler called before each reconnection attempt. Receives the attempt number.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
client.onReconnect((attempt) => {
|
|
58
|
+
console.log(`Reconnecting... attempt #${attempt}`)
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Message queuing
|
|
63
|
+
|
|
64
|
+
When disconnected and reconnect is enabled:
|
|
65
|
+
|
|
66
|
+
- `send()` queues messages (serialized strings)
|
|
67
|
+
- `call()` rejects immediately with code 503 (NOT queued)
|
|
68
|
+
- On reconnect success, all queued messages are flushed in order
|
|
69
|
+
|
|
70
|
+
### Auto-resubscribe
|
|
71
|
+
|
|
72
|
+
Subscriptions created via `subscribe()` are stored as `Map<path, data>`. On successful reconnect, the client calls `call('subscribe', path, data)` for each stored subscription. Failures are silently caught and will retry on the next reconnect.
|
|
73
|
+
|
|
74
|
+
## Common Patterns
|
|
75
|
+
|
|
76
|
+
### Pattern: Basic reconnect
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const client = createWsClient('wss://api.example.com/ws', {
|
|
80
|
+
reconnect: true, // exponential backoff, unlimited retries
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Pattern: Custom backoff
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const client = createWsClient('wss://api.example.com/ws', {
|
|
88
|
+
reconnect: {
|
|
89
|
+
enabled: true,
|
|
90
|
+
maxRetries: 10,
|
|
91
|
+
baseDelay: 500,
|
|
92
|
+
maxDelay: 15000,
|
|
93
|
+
backoff: 'linear',
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Pattern: Reconnect with UI feedback
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const client = createWsClient('wss://api.example.com/ws', { reconnect: true })
|
|
102
|
+
|
|
103
|
+
client.onClose((code, reason) => {
|
|
104
|
+
showBanner('Connection lost, reconnecting...')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
client.onReconnect((attempt) => {
|
|
108
|
+
updateBanner(`Reconnecting (attempt ${attempt})...`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
client.onOpen(() => {
|
|
112
|
+
hideBanner()
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Pattern: Send during disconnection
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// With reconnect enabled, messages are queued:
|
|
120
|
+
client.send('message', '/chat/rooms/lobby', { text: 'hello' })
|
|
121
|
+
// If disconnected, this will be sent when the connection is restored
|
|
122
|
+
|
|
123
|
+
// RPCs are NOT queued — they reject immediately:
|
|
124
|
+
try {
|
|
125
|
+
await client.call('rpc', '/users/me')
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// WsClientError(503, 'Not connected')
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Best Practices
|
|
132
|
+
|
|
133
|
+
- Use exponential backoff (default) for production — it reduces server load during outages
|
|
134
|
+
- Set `maxRetries` for scenarios where giving up is better than infinite retry (e.g., auth failures)
|
|
135
|
+
- Monitor `onReconnect` to update UI state
|
|
136
|
+
- Use `send()` for messages that can tolerate delayed delivery; use `call()` only when you need an immediate response
|
|
137
|
+
- Call `client.close()` to permanently stop reconnection — useful on logout or page unload
|
|
138
|
+
|
|
139
|
+
## Gotchas
|
|
140
|
+
|
|
141
|
+
- `client.close()` permanently disables reconnection — there is no way to re-enable it; create a new `WsClient` instead
|
|
142
|
+
- Queued messages are lost if `client.close()` is called — the queue is cleared
|
|
143
|
+
- Auto-resubscribe happens after `onOpen` handlers fire — your `onOpen` handler runs before subscriptions are re-established
|
|
144
|
+
- The attempt counter resets to 0 on successful connection — if the connection drops again, backoff restarts from `baseDelay`
|
|
145
|
+
- `call()` during disconnection always rejects, even with reconnect enabled — there is no "queue and resolve later" mode for RPCs
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# RPC & subscriptions — @wooksjs/ws-client
|
|
2
|
+
|
|
3
|
+
> Request-response calls with correlation IDs, timeouts, and managed subscriptions with auto-resubscribe.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
RPC in `@wooksjs/ws-client` uses correlation IDs to match requests with responses. When you call `client.call()`, the client:
|
|
8
|
+
|
|
9
|
+
1. Generates an auto-incrementing numeric `id`
|
|
10
|
+
2. Sends `{ event, path, data, id }` to the server
|
|
11
|
+
3. Starts a timeout timer
|
|
12
|
+
4. Returns a `Promise` that resolves/rejects when the server replies with the same `id`
|
|
13
|
+
|
|
14
|
+
The server must include the `id` in its `WsReplyMessage` for the response to be matched.
|
|
15
|
+
|
|
16
|
+
Subscriptions build on RPC — `subscribe()` calls `call('subscribe', path, data)` and remembers the subscription for auto-resubscribe on reconnect.
|
|
17
|
+
|
|
18
|
+
## API Reference
|
|
19
|
+
|
|
20
|
+
### `client.call<T>(event, path, data?): Promise<T>`
|
|
21
|
+
|
|
22
|
+
Send a message with an auto-generated correlation ID and wait for the server's reply.
|
|
23
|
+
|
|
24
|
+
- Rejects with `WsClientError(503)` if not connected
|
|
25
|
+
- Rejects with `WsClientError(408)` on timeout (default: 10s)
|
|
26
|
+
- Rejects with `WsClientError(code, message)` if the server replies with an `error`
|
|
27
|
+
- Resolves with `reply.data` typed as `T`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
interface User {
|
|
31
|
+
name: string
|
|
32
|
+
email: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const user = await client.call<User>('rpc', '/users/me')
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `client.subscribe(path, data?): Promise<() => void>`
|
|
39
|
+
|
|
40
|
+
Subscribe to a server path:
|
|
41
|
+
|
|
42
|
+
1. Sends `{ event: 'subscribe', path, data, id }` via RPC
|
|
43
|
+
2. Waits for server confirmation (reply)
|
|
44
|
+
3. Stores `{ path, data }` for auto-resubscribe on reconnect
|
|
45
|
+
4. Returns an unsubscribe function that:
|
|
46
|
+
- Removes the stored subscription
|
|
47
|
+
- Sends `{ event: 'unsubscribe', path }` (fire-and-forget) if still connected
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const unsub = await client.subscribe('/chat/rooms/lobby')
|
|
51
|
+
|
|
52
|
+
// Later:
|
|
53
|
+
unsub()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `RpcTracker` (internal)
|
|
57
|
+
|
|
58
|
+
Tracks pending RPC calls. Not part of the public API but useful for understanding internals:
|
|
59
|
+
|
|
60
|
+
- `generateId()` — auto-incrementing numeric IDs
|
|
61
|
+
- `track(id, timeout)` — returns a Promise, starts timeout timer
|
|
62
|
+
- `resolve(reply)` — matches `reply.id`, resolves or rejects based on `reply.error`
|
|
63
|
+
- `rejectAll(code, message)` — called on disconnect/close, rejects all pending promises
|
|
64
|
+
|
|
65
|
+
## Common Patterns
|
|
66
|
+
|
|
67
|
+
### Pattern: Error handling
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
try {
|
|
71
|
+
const result = await client.call('rpc', '/protected/resource')
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof WsClientError) {
|
|
74
|
+
switch (err.code) {
|
|
75
|
+
case 403:
|
|
76
|
+
console.log('Forbidden')
|
|
77
|
+
break
|
|
78
|
+
case 404:
|
|
79
|
+
console.log('Not found')
|
|
80
|
+
break
|
|
81
|
+
case 408:
|
|
82
|
+
console.log('Timeout')
|
|
83
|
+
break
|
|
84
|
+
case 503:
|
|
85
|
+
console.log('Not connected')
|
|
86
|
+
break
|
|
87
|
+
default:
|
|
88
|
+
console.log(`Error ${err.code}: ${err.message}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Pattern: Multiple subscriptions
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const unsubs: Array<() => void> = []
|
|
98
|
+
|
|
99
|
+
unsubs.push(await client.subscribe('/chat/rooms/lobby'))
|
|
100
|
+
unsubs.push(await client.subscribe('/chat/rooms/general'))
|
|
101
|
+
unsubs.push(await client.subscribe('/notifications'))
|
|
102
|
+
|
|
103
|
+
// Cleanup:
|
|
104
|
+
for (const unsub of unsubs) unsub()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Pattern: Typed RPC calls
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
interface CreateRoomResponse {
|
|
111
|
+
roomId: string
|
|
112
|
+
created: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const response = await client.call<CreateRoomResponse>('rpc', '/rooms/create', {
|
|
116
|
+
name: 'My Room',
|
|
117
|
+
})
|
|
118
|
+
console.log(response.roomId)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Best Practices
|
|
122
|
+
|
|
123
|
+
- Set `rpcTimeout` to match your server's expected response time — 10s default is generous for most APIs
|
|
124
|
+
- Always handle `WsClientError` in `.catch()` — unhandled rejections from timeout or disconnect are common
|
|
125
|
+
- Use `subscribe()` for durable subscriptions that should survive reconnections; use `send()` for one-off events
|
|
126
|
+
- Store unsubscribe functions and call them during cleanup to avoid server-side resource leaks
|
|
127
|
+
|
|
128
|
+
## Gotchas
|
|
129
|
+
|
|
130
|
+
- `call()` rejects immediately if not connected — it does NOT queue. Use `send()` for queued fire-and-forget
|
|
131
|
+
- RPC IDs are auto-incrementing integers, not UUIDs — they reset to 1 on new `WsClient` instances
|
|
132
|
+
- On disconnect, ALL pending RPCs are rejected with code 503 — there is no retry mechanism for in-flight calls
|
|
133
|
+
- `subscribe()` rejects if the server's subscribe handler throws/rejects — the subscription is NOT stored in that case
|
|
134
|
+
- Auto-resubscribe on reconnect silently swallows errors — check server logs if subscriptions seem lost
|