@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.
@@ -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