@tonytomo/gosocket-client 0.0.0-stage → 1.0.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 CHANGED
@@ -1,3 +1,631 @@
1
- # Temporary Holding Version
1
+ # @ws-server/ws-client (`ws-client-js`)
2
+
3
+ Browser-first TypeScript / JavaScript client SDK for the Universal WebSocket Server (Complete SDK: Phases J1, J2, J3 & J4).
4
+
5
+ This SDK runs natively in modern web browsers (HTML5 `WebSocket`) with **zero runtime dependencies**, providing robust connection lifecycle management, browser query-based authentication, exponential backoff reconnection with full jitter, automatic serialized session restoration, heartbeat liveness checks, a two-lane outbound pipeline with rate limiting, coalescing, and backpressure, binary channel multiplexing, presence cache with reconnect invalidation, sequence-gap detection, comprehensive protocol error classification, local pre-send validation, capability pre-flight checks, and ping/RTT tracking.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Browser constraints](#browser-constraints)
12
+ - [Browser auth — why a heuristic](#browser-auth--why-a-heuristic)
13
+ - [Reconnect policy](#reconnect-policy)
14
+ - [Session restore](#session-restore)
15
+ - [Outbound pipeline](#outbound-pipeline)
16
+ - [Coalescing](#coalescing)
17
+ - [Rate limiting — cooperative, not authoritative](#rate-limiting--cooperative-not-authoritative)
18
+ - [Backpressure](#backpressure)
19
+ - [Reconnect discards queues](#reconnect-discards-queues)
20
+ - [Capabilities](#capabilities)
21
+ - [Binary channels](#binary-channels)
22
+ - [Presence — a hint, not a source of truth](#presence--a-hint-not-a-source-of-truth)
23
+ - [Sequence gaps — what reliable means](#sequence-gaps--what-reliable-means)
24
+ - [Reconnect invalidates presence and re-baselines seq](#reconnect-invalidates-presence-and-re-baselines-seq)
25
+ - [Browser binary behavior](#browser-binary-behavior)
26
+ - [Error classification & recovery policy](#error-classification--recovery-policy)
27
+ - [Round-trip time (RTT) & ping API](#round-trip-time-rtt--ping-api)
28
+ - [Local validation vs. server validation](#local-validation-vs-server-validation)
29
+ - [Capability pre-flight checks](#capability-pre-flight-checks)
30
+ - [What the SDK does *not* do](#what-the-sdk-does-not-do)
31
+ - [Usage Guide](#usage-guide)
32
+ - [Live Conformance](#live-conformance)
33
+
34
+ ---
35
+
36
+ ## Browser constraints
37
+
38
+ The browser runtime imposes three fundamental constraints that distinguish this SDK from server-side clients (such as the Go SDK):
39
+
40
+ 1. **No `Authorization` HTTP header on native `WebSocket`**:
41
+ The W3C / WHATWG `WebSocket` browser API does not allow setting custom HTTP request headers during the WebSocket upgrade handshake. Server-to-server clients can send `Authorization: Bearer <key>`, but browser clients **must** pass their credentials using the URL query parameter (`?key=<token>`). There is intentionally no `Authorization` header path in this package.
42
+
43
+ 2. **No HTTP status code visibility on failed upgrades**:
44
+ If the WebSocket upgrade request fails (for instance, when the server rejects an invalid API key with `HTTP 401 Unauthorized`), the browser security sandbox masks the HTTP response code and response body. The native JavaScript `WebSocket` instance simply triggers an `error` event followed immediately by a `close` event with code `1006` (`StatusAbnormalClosure`) and an empty reason string. Network drops, DNS errors, TLS handshake failures, and HTTP 401 rejections all appear identically to JavaScript.
45
+
46
+ 3. **No native fake timer or transport injection**:
47
+ Browser environments lack built-in mechanisms to pause, step, or mock time and network sockets without polluting global prototypes. To support fast, deterministic, reproducible testing without flaky wall-clock sleeps, this SDK abstracts the system clock (`Clock`) and network layer (`Transport`). All internal timers schedule through `Clock`, and production instances default to `RealClock` and `BrowserWebSocketTransport`.
48
+
49
+ > [!WARNING]
50
+ > **Event Handler Isolation**:
51
+ > Reader and writer event dispatches are synchronous and run on the main JavaScript event loop. Do not perform long-running, blocking, or CPU-heavy work inside SDK event listeners; the SDK provides no worker thread isolation, and slow listeners will delay protocol handling and writer execution.
52
+
53
+ ---
54
+
55
+ ## Browser auth — why a heuristic
56
+
57
+ Because the browser hides the HTTP 401 status code behind an ambiguous abnormal closure (`close` code `1006`), the SDK must determine whether a connection drop is an authentication failure (which should **never** auto-reconnect to avoid spamming the server and getting rate-limited or locked out) or a transient network glitch (which **should** auto-reconnect).
58
+
59
+ The SDK implements an authentication heuristic mirrored from the reference Go SDK:
60
+
61
+ ### Default Heuristic (`'heuristic'`)
62
+ - If the socket closes with code `1006` **before** the initial `welcome` frame has been received on the very first connect attempt, the SDK classifies the failure as `AUTH_FAILED`.
63
+ - The client emits a `fatal` event with `{ code: 'AUTH_FAILED', message: '...' }`, transitions to `closed`, and does **not** attempt automatic reconnection.
64
+ - Once the first successful `welcome` frame has arrived in a session, subsequent `1006` closures are recognized as mid-stream disconnects and will trigger automatic reconnection.
65
+ - The heuristic state is reset whenever `client.close()` is called followed by a fresh `client.connect()`.
66
+
67
+ ### Alternative Auth Failure Modes
68
+ You can configure `authFailureMode` in `ClientConfig`:
69
+ - `'heuristic'` (default): Treat pre-welcome `1006` on the first attempt as `AUTH_FAILED`.
70
+ - `'never-fatal'`: Never classify a pre-welcome `1006` as an authentication failure. Reconnect backoff will always engage. Useful when connecting over notoriously unstable networks where initial drops are common and the caller accepts the cost of retry loops.
71
+ - `'always-fatal'`: Treat **any** pre-welcome close (regardless of close code) on the first attempt as an authentication failure. Useful in controlled production environments with reliable networking where handshake failure indicates invalid credentials.
72
+
73
+ ### Ambiguous Auth Escape Hatch (`onAmbiguousAuth`)
74
+ If your application requires custom classification logic, provide an `onAmbiguousAuth` callback:
75
+
76
+ ```ts
77
+ const client = new Client({
78
+ url: 'wss://example.com/ws',
79
+ apiKey: 'k_alice_123',
80
+ onAmbiguousAuth: (closeCode, reason) => {
81
+ // Return 'fatal' to abort immediately or 'retry' to engage reconnection
82
+ return closeCode === 1006 ? 'fatal' : 'retry';
83
+ },
84
+ });
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Reconnect policy
90
+
91
+ The SDK automatically recovers dropped connections using **full-jitter exponential backoff** identical to the wire protocol reference:
92
+
93
+ $$\text{backoff} = \min(\text{maxDelayMs}, \text{baseDelayMs} \times 2^{\text{attempt}})$$
94
+ $$\text{delay} = \text{random}(0, \text{backoff})$$
95
+
96
+ ### Default Configuration
97
+ - `baseDelayMs`: `500` ms
98
+ - `maxDelayMs`: `30000` ms (30 s)
99
+ - `maxAttempts`: `Infinity`
100
+ - `jitter`: `'full'` (also supports `'equal'` and `'none'`)
101
+
102
+ ### Reconnect Triggers
103
+ Automatic reconnection engages on:
104
+ - `1006` (Abnormal Closure, post-welcome or in `'never-fatal'` mode)
105
+ - `1001` (Going Away, indicating server graceful shutdown recovery per PROTOCOL.md §9.2)
106
+ - `1000` (Unexpected normal closure initiated by the server without prior client `close()`)
107
+ - Transport errors mid-stream
108
+ - Heartbeat ping/pong deadline timeouts
109
+
110
+ ### Non-Reconnect (Fatal) Triggers
111
+ Reconnection is **halted** and state transitions to `closed` on:
112
+ - Explicit caller termination via `client.close()`
113
+ - `1008` (Policy Violation, such as rate abuse, reliable queue overflow, or server read timeouts) — surfaces `fatal` with `{ code: 'POLICY_VIOLATION' }`
114
+ - Initial handshake classification of `AUTH_FAILED`
115
+ - Exhaustion of `maxAttempts` (emits `fatal` with `{ code: 'CLOSED' }`)
116
+ - Custom rejection via `shouldReconnect({ closeCode, firstConnect })` returning `false`
117
+
118
+ The attempt counter resets to `0` upon each successful `welcome` frame.
119
+
120
+ ---
121
+
122
+ ## Session restore
123
+
124
+ The SDK automatically restores room memberships following connection recovery without re-transmitting business traffic.
125
+
126
+ ### In-Memory Only
127
+ > [!IMPORTANT]
128
+ > **No Local Storage Persistence**:
129
+ > Room intents and authentication credentials exist **in memory only**. The SDK intentionally never writes API keys, passcodes, or room states to `localStorage`, `sessionStorage`, or `IndexedDB`. If the user navigates away or refreshes the page, all state is lost and must be re-initialized.
130
+
131
+ ### Serialized Execution
132
+ The wire protocol contains no multiplexed request correlation identifiers. Because of this:
133
+ - **The session restore loop is strictly serialized**: The client issues `join_room` for intent $N$, waits for the server's `room_joined` confirmation (or error), and only then sends `join_room` for intent $N+1$.
134
+ - Parallel restore joins are forbidden to prevent protocol race conditions.
135
+ - On recovery, room intents are sorted alphabetically by room name to ensure deterministic order across connections.
136
+
137
+ ### Per-Intent Recovery Outcomes
138
+ When rejoining each room on reconnect:
139
+ - `room_joined` $\rightarrow$ Membership restored.
140
+ - `ROOM_NOT_FOUND` $\rightarrow$ Intent deleted from memory, emits `intentFailed{ room, code: 'ROOM_NOT_FOUND' }`.
141
+ - `FORBIDDEN_SCOPE` $\rightarrow$ Intent deleted from memory, emits `intentFailed{ room, code: 'FORBIDDEN_SCOPE' }`.
142
+ - `INVALID_PASSCODE` / `PASSCODE_RATE_LIMITED` $\rightarrow$ Intent kept in memory, emits `intentFailed`, skipped for the remainder of this reconnect cycle.
143
+ - `ROOM_FULL` $\rightarrow$ Controlled by `restore.roomFullPolicy`:
144
+ - `'drop'` (default): Intent dropped from memory, emits `intentFailed{ room, code: 'ROOM_FULL' }`.
145
+ - `'retry'`: Retries up to 3 times with exponential backoff and jitter. If still full after 3 attempts, intent is kept for future reconnect cycles.
146
+ - Per-room timeout (`restorePerRoomTimeoutMs: 5000`) $\rightarrow$ Emits `intentFailed`, intent is kept.
147
+
148
+ When the restore loop finishes, the client emits `sessionRestored` with `{ restored: string[], failed: Array<{ room, code }> }`.
149
+
150
+ ---
151
+
152
+ ## Outbound pipeline
153
+
154
+ Phase J2 replaces direct socket writing with a prioritised two-lane async outbound pipeline mirroring the server delivery semantics in PROTOCOL.md §5.4:
155
+
156
+ ```
157
+ publish(req)
158
+ │
159
+ ┌─────────┴─────────┐
160
+ │ Channel Policy │
161
+ │ Rate Limit Check │
162
+ └─────────┬─────────┘
163
+ │
164
+ ┌───────────────┴───────────────┐
165
+ ▼ ▼
166
+ [Reliable Lane] [Unreliable Lane]
167
+ (FIFO, cap: 256) (FIFO, cap: 16)
168
+ │ │
169
+ │ ├── Coalescing (optional)
170
+ │ ▼
171
+ └───────────────┬───────────────┘
172
+ │
173
+ [Writer Pump Loop]
174
+ (Priority: Reliable 32,
175
+ Unreliable 8; Backpressure check)
176
+ │
177
+ ▼
178
+ [WebSocket.send()]
179
+ ```
180
+
181
+ ### Lane Characteristics
182
+ 1. **Reliable Lane (`reliable: true`)**:
183
+ - Default buffer capacity: `256` frames.
184
+ - Enforces strict FIFO ordering.
185
+ - **Overflow Behavior**: Controlled by `outbound.reliableOverflow`:
186
+ - `'drop'` (default): Drops the **newest** frame admitted, keeping older frames intact to preserve chronological delivery. Emits `reliableOverflow{ action: 'dropped', depth }`.
187
+ - `'close'`: Emits `reliableOverflow{ action: 'closed' }` and immediately terminates the WebSocket connection with status code `1008` (`reliable queue overflow`), triggering a `fatal` policy violation event.
188
+ 2. **Unreliable Lane (`reliable: false`)**:
189
+ - Default buffer capacity: `16` frames.
190
+ - Designed for high-frequency state updates where fresher data replaces stale data.
191
+ - **Overflow Behavior**: Drops the **oldest** frame in the queue to admit the newest frame. Emits `unreliableDropped{ depth }`.
192
+
193
+ ### Writer Loop Scheduling
194
+ Because browsers lack background writer goroutines, the writer is driven by an async pump loop running on the event loop:
195
+ - Drains up to `reliableBurst` (default: 32) frames from the reliable lane.
196
+ - Drains up to `unreliableBurst` (default: 8) frames from the unreliable lane or coalesce slots.
197
+ - Performs a microtask yield (`await Promise.resolve()`) per frame to avoid blocking UI rendering or starve incoming WebSocket frames.
198
+ - If the browser transport is blocked by backpressure or the connection is not in `ready` state, the writer pauses cooperatively until unblocked.
199
+
200
+ ---
201
+
202
+ ## Coalescing
203
+
204
+ On unreliable channels opted in via `policy.coalesce = true`, rapid updates sent before the transport flushes are coalesced into a single slot instead of consuming FIFO slots.
205
+
206
+ ### Drivers: `'timer'` vs. `'raf'`
207
+ - `'timer'` (default): Drains on the next microtask turn. Suitable for general high-frequency state streams and maintains behavioral parity with the Go SDK.
208
+ - `'raf'` (`requestAnimationFrame`): Browser-native driver that aligns outbound state flushes with the monitor refresh rate (typically 60 Hz or 120 Hz). All state updates generated within an animation frame are coalesced into one wire transmission. In Node or test environments where `requestAnimationFrame` is undefined, the SDK falls back to a 16 ms timer scheduled via `Clock`.
209
+
210
+ ### Merge Semantics (`merge`)
211
+ By default, the newest update replaces the previous pending update (last-writer-wins). Applications can supply a custom `merge` function to combine or filter updates:
212
+
213
+ ```ts
214
+ const policy: ChannelPolicy = {
215
+ reliable: false,
216
+ maxRate: 0,
217
+ coalesce: true,
218
+ coalesceDriver: 'raf',
219
+ merge: (prev: unknown, next: unknown) => {
220
+ const p = prev as { x: number; y: number; seq: number };
221
+ const n = next as { x: number; y: number; seq: number };
222
+ if (n.seq < p.seq) {
223
+ // Out-of-order frame: skip new update
224
+ return { value: prev, skip: true };
225
+ }
226
+ // Return accumulated or latest value
227
+ return { value: next, skip: false };
228
+ },
229
+ };
230
+ ```
231
+ - If `skip: true`, the new message is discarded and the previous message is retained in the slot, emitting `coalesced{ merged: false, skipped: true }`.
232
+ - If `skip: false`, `value` replaces the slot and `coalesced{ merged: true, skipped: false }` is emitted.
233
+ - Flipping a channel's policy to `coalesce: false` via `setChannelPolicy` immediately flushes any pending slot as an individual write without emitting a `coalesced` event.
234
+
235
+ ---
236
+
237
+ ## Rate limiting — cooperative, not authoritative
238
+
239
+ Rate limiting is enforced locally per `(room, channel)` using an exact client-side token bucket matching the server algorithm in PROTOCOL.md §5.5:
240
+ - **Rate**: `maxRate` tokens per second.
241
+ - **Burst Capacity**: $\max(2, \text{maxRate} \times 2)$.
242
+ - `maxRate = 0` bypasses rate limiting (unlimited).
243
+
244
+ ### Role of the Client Limiter
245
+ The client-side limiter is **cooperative, not authoritative**. Its primary role is to intercept excess outbound traffic before it hits the wire, preventing the client from accumulating rate violations that would cause the server to forcibly terminate the connection with code `1008` (`rate limit abuse`).
246
+
247
+ ### Local vs. Server Events
248
+ - `rateLimited{ source: 'local', room, channel, retryAfterMs }`: Emitted when the local token bucket is exhausted or during an active server-instructed pause. The `publish()` call rejects locally with a `RateLimitedError`.
249
+ - `rateLimited{ source: 'server', room, channel, retryAfterMs }`: Emitted when the server returns an `error` frame with code `RATE_LIMITED`. The SDK pauses local publishing on that channel for the instructed duration (`retry_after_ms`, typically 250 ms).
250
+
251
+ ---
252
+
253
+ ## Backpressure
254
+
255
+ Unlike server-side runtimes, web browsers expose `WebSocket.bufferedAmount`—the byte count of queued data in the browser's internal network buffer. If the client publishes faster than the socket can transmit over the network, `bufferedAmount` will grow unboundedly.
256
+
257
+ The SDK monitors backpressure cooperatively:
258
+ - **High Water Mark (`highWaterMarkBytes`, default $1 \text{ MiB}$)**: When `bufferedAmount >= highWaterMarkBytes`, the writer pauses unreliable lane draining and emits `backpressure{ state: 'high', bufferedAmount }`.
259
+ - **Low Water Mark (`lowWaterMarkBytes`, default $512 \text{ KiB}$)**: When `bufferedAmount <= lowWaterMarkBytes`, backpressure clears, `backpressure{ state: 'low', bufferedAmount }` is emitted, and the writer resumes.
260
+ - **Hysteresis**: Fluctuations between the low and high thresholds do not trigger flapping events.
261
+ - **Debugging**: If you encounter unexpected message drops or pauses, monitor `client.queueDepth()` and `client.bufferedAmount()`. Operators with high-throughput state streams over fast networks can adjust `highWaterMarkBytes` and `lowWaterMarkBytes` in `ClientConfig.backpressure`.
262
+
263
+ ---
264
+
265
+ ## Reconnect discards queues
266
+
267
+ Outbound queues and coalesce slots belong strictly to the active **connection generation**:
268
+
269
+ > [!IMPORTANT]
270
+ > **Rationale from Go SDK §4.4**:
271
+ > Post-reconnect room state may contradict messages buffered before disconnection (the app's view of the room is stale after a reconnect; buffering stale outbound traffic risks delivering messages that contradict restored state).
272
+ >
273
+ > On reconnect, all pending reliable, unreliable, and coalesced frames are discarded, and the SDK emits `queuesDiscarded{ reliable, unreliable, coalesced }` **before** the session restore cycle begins.
274
+ >
275
+ > Applications requiring durable or offline message queueing across page reloads or network drops should maintain an application-level buffer and flush after observing the `sessionRestored` event.
276
+
277
+ ---
278
+
279
+ ## Capabilities
280
+
281
+ Upon each successful connection, the server synchronously sends a `welcome` frame containing a `capabilities` descriptor advertising its runtime limits and supported features.
282
+
283
+ ### S2 `null`-means-disabled Semantics
284
+ The SDK preserves the authoritative wire semantics from PROTOCOL.md §2.4 and S2:
285
+ - An integer value indicates an enforced maximum (e.g. `max_rooms: 1000`, `max_channels_per_room: 32`).
286
+ - A `null` value indicates that the limit is **explicitly disabled** (unlimited) by server configuration (e.g. `max_rooms_per_key: null`).
287
+ - `undefined` indicates that the connected server is a legacy version that does not advertise capabilities.
288
+
289
+ Capabilities are cached on `client.capabilities`. When a reconnect yields a different `server_version` or modified capabilities, the SDK emits `capabilitiesChanged`.
290
+
291
+ ---
292
+
293
+ ## Binary channels
294
+
295
+ Binary frames allow high-throughput, low-overhead transmission of packed binary data (such as physics snapshots, audio streams, or state deltas) without JSON serialization overhead (PROTOCOL.md §7).
296
+
297
+ ### Wire Format & Mapping
298
+ - Wire format: `[channel_id: 1 byte][payload: N bytes]`.
299
+ - Channels map names to a 1-byte identifier in $[1, 254]$ (`0` is reserved for JSON control frames, and `255` is reserved for presence).
300
+ - Channel `"presence"` cannot be mapped to binary (`RESERVED_CHANNEL_NAME`).
301
+
302
+ ### Builder Construction
303
+ Use `BinaryMapBuilder` to construct and validate mappings:
304
+
305
+ ```ts
306
+ import { BinaryMapBuilder } from 'ws-client-js';
307
+
308
+ const binMap = new BinaryMapBuilder()
309
+ .set('state', 1)
310
+ .set('actions', 2)
311
+ .autoSet('telemetry') // assigns smallest available ID (e.g. 3)
312
+ .build();
313
+ ```
314
+
315
+ Construction-time validation enforces:
316
+ - Channel name non-empty and `!= 'presence'`.
317
+ - ID integer in $[1, 254]$.
318
+ - No duplicate IDs and no duplicate channel names in the builder.
319
+ - Returns an immutable, frozen mapping object.
320
+
321
+ ### Global Binary-ID Uniqueness Rule
322
+ Because the wire binary frame does not carry a room identifier, the client connection enforces global ID uniqueness across all rooms joined on that connection.
323
+
324
+ If room B attempts to map an ID already assigned to room A:
325
+ - `joinRoomWithBinary` rejects locally before touching the wire with `BinaryIdCollisionError`.
326
+ - Emits a `binaryIdCollision` event with `{ id, existing, rejected }`.
327
+
328
+ ### Update via Re-Join
329
+ Calling `joinRoomWithBinary` on an already-joined room updates that room's binary mappings in place (`PROTOCOL.md` §3.2). IDs removed by the update are freed from `binaryOwner`, and newly added IDs are reserved. If the server rejects the join (e.g. `ROOM_NOT_FOUND`), tentative reservations are rolled back to the previous state.
330
+
331
+ ### Binary Publishing & Coalescing
332
+ Use `client.publishBinary({ room, channel, payload })`:
333
+ - Payloads can be passed as `ArrayBuffer` or `Uint8Array`.
334
+ - Routes through the channel's resolved policy (`reliable` vs `unreliable`).
335
+ - Shares the same token-bucket rate limiter and server `RATE_LIMITED` backoff with text messages on that channel.
336
+ - On coalesced unreliable channels (`coalesce: true`), binary publishes update slot `${room}:${channel}:binary`. Text and binary messages on the same channel maintain distinct slots and never overwrite each other. Binary coalescing operates in last-wins mode.
337
+ - Configurable maximum payload size via `maxBinaryPayloadBytes` (default 65,535 bytes). Oversize payloads reject immediately with `BinaryPayloadTooLargeError`.
338
+
339
+ ---
340
+
341
+ ## Presence — a hint, not a source of truth
342
+
343
+ Room presence is managed automatically on the reserved `"presence"` channel (PROTOCOL.md §6).
344
+
345
+ > [!IMPORTANT]
346
+ > **Presence Caveats**:
347
+ > - **Presence is a best-effort hint, not an authoritative source of truth.**
348
+ > - Peers who joined before the current client joined are not visible in `knownMembers(room)` (the server sends `room_joined.members` count, but does not replay prior peer joins).
349
+ > - Peers who left while this client was disconnected or reconnecting may still appear in `knownMembers(room)` until `resetPresence(room)` or room departure.
350
+ > - The protocol does not provide an application-level roster sync. Applications requiring authoritative user rosters must perform their own sync over an application channel.
351
+
352
+ ### Presence Inspection & APIs
353
+ - `client.selfLabel`: The connection's unique client label assigned in `welcome.label`.
354
+ - `client.knownMembers(room)`: Sorted array of peer labels observed joining since the last `resetPresence`, with `selfLabel` excluded.
355
+ - `client.lastServerMemberCount(room)`: Last member count from `room_joined.members`, adjusted by subsequent `presenceJoin` (+1) and `presenceLeave` (-1) events. Returns `{ count: number, ok: boolean }`.
356
+ - `client.presenceInvalidated(room)`: `true` if connection drop marked the cache stale.
357
+ - `client.resetPresence(room)`: Clears the `known` roster and invalidation flag while keeping `lastServerMemberCount`.
358
+
359
+ ### Typed Events
360
+ - `presenceJoin`: `{ room, who }`
361
+ - `presenceLeave`: `{ room, who }`
362
+ - `presenceInvalidated`: `{ room, reason }`
363
+ - `roomDeleted`: `{ room, who }`
364
+
365
+ ---
366
+
367
+ ## Sequence gaps — what reliable means
368
+
369
+ The server sequences messages monotonically via a per-`(room, channel)` `seq` counter (PROTOCOL.md §5.6).
370
+
371
+ ### Anomalous vs Expected
372
+ - **Reliable Channels (`reliable: true`)**: Every sequence number must arrive contiguously without gaps ($1, 2, 3\dots$). A forward jump ($s > \text{last} + 1$) indicates dropped frames due to network error or server buffer eviction, emitting `sequenceGap` with `reliable: true`.
373
+ - **Unreliable Channels (`reliable: false`)**: Frame loss is expected under network saturation or client rate limiting. Forward jumps emit `sequenceGap` with `reliable: false` for application telemetry.
374
+ - **Re-baselining**: Reconnections, backward sequence numbers (e.g. room re-creation), duplicate sequence numbers, and unsequenced frames (`seq = 0`) silently establish or maintain baseline without emitting false gap alarms.
375
+ - **Auto-invalidation**: Configure `presence: { autoInvalidateOnReliableGap: true }` to automatically mark a room's presence cache invalidated when a sequence gap occurs on the reliable `"presence"` channel.
376
+
377
+ ---
378
+
379
+ ## Reconnect invalidates presence and re-baselines seq
380
+
381
+ When a connection disconnects and reconnects:
382
+ 1. **Queues Discarded**: Outbound queues are purged to avoid sending stale commands.
383
+ 2. **Presence Invalidated**: Before session restore begins, every live room intent is marked `presenceInvalidated(room) == true` with reason `"reconnected"`, and emits a `presenceInvalidated` event. The known roster is kept for inspection until `client.resetPresence(room)` is called.
384
+ 3. **Seq Re-baselined**: All sequence baselines are cleared (`hasBase = false`). The first message received on each channel after reconnect silently sets a fresh baseline.
385
+ 4. **Session Restored**: Surviving rooms rejoin; if a room is dropped (e.g. `ROOM_NOT_FOUND`), all room state (`binaryOwner`, `presence`, `seq`, `rateLimiters`) is completely evicted via `clearRoomState`.
386
+
387
+ ---
388
+
389
+ ## Browser binary behavior
390
+
391
+ In web browsers, the standard HTML5 `WebSocket` defaults to `binaryType = 'blob'`. Converting `Blob` payloads requires asynchronous `blob.arrayBuffer()` calls that cause microtask queue delays and GC overhead.
392
+
393
+ This SDK automatically sets:
394
+ ```ts
395
+ this.ws.binaryType = 'arraybuffer';
396
+ ```
397
+ Inbound binary messages arrive synchronously as `ArrayBuffer` instances and are decoded without microtask lag into `Uint8Array` payloads on `binaryMessage` events.
398
+
399
+ ---
400
+
401
+ ## Usage Guide
402
+
403
+ ### Installation
404
+ ```bash
405
+ npm install ws-client-js
406
+ ```
407
+
408
+ ### Complete Example (Lifecycle, Binary, Presence, Policies)
409
+ ```ts
410
+ import { Client, BinaryMapBuilder, presetsFor } from 'ws-client-js';
411
+
412
+ const client = new Client({
413
+ url: 'wss://your-server.example.com/ws',
414
+ apiKey: 'k_alice_123',
415
+ heartbeat: { intervalMs: 25000, timeoutMs: 10000 },
416
+ reconnect: { baseDelayMs: 500, maxDelayMs: 30000 },
417
+ presence: { autoInvalidateOnReliableGap: true },
418
+ outbound: {
419
+ reliableQueueSize: 256,
420
+ unreliableQueueSize: 16,
421
+ policyProvider: {
422
+ channelPolicy: (room, channel) => presetsFor('game')[channel],
423
+ },
424
+ },
425
+ });
426
+
427
+ // Binary channel mapping
428
+ const binMap = new BinaryMapBuilder()
429
+ .set('state', 1)
430
+ .set('actions', 2)
431
+ .build();
432
+
433
+ // Inbound binary frames
434
+ client.on('binaryMessage', ({ room, channel, payload }) => {
435
+ // payload is a Uint8Array
436
+ console.log(`Binary frame on ${room}/${channel} (${payload.byteLength} bytes)`);
437
+ });
438
+
439
+ // Presence events
440
+ client.on('presenceJoin', ({ room, who }) => {
441
+ console.log(`Peer ${who} joined ${room}`);
442
+ console.log('Known roster:', client.knownMembers(room));
443
+ });
444
+
445
+ client.on('presenceLeave', ({ room, who }) => {
446
+ console.log(`Peer ${who} left ${room}`);
447
+ });
448
+
449
+ // Sequence gaps
450
+ client.on('sequenceGap', (ev) => {
451
+ console.warn(`Sequence gap on ${ev.room}/${ev.channel}: missed ${ev.missed} frames (reliable: ${ev.reliable})`);
452
+ });
453
+
454
+ // Connect and join with binary
455
+ await client.connect();
456
+ await client.joinRoomWithBinary({
457
+ room: 'arena-1',
458
+ binaryChannels: binMap,
459
+ });
460
+
461
+ // Publish binary state at 60 Hz (unreliable lane)
462
+ await client.publishBinary({
463
+ room: 'arena-1',
464
+ channel: 'state',
465
+ payload: new Uint8Array([0x01, 0x02, 0x03]),
466
+ });
467
+
468
+ // Clean room deletion
469
+ await client.deleteRoom('arena-1');
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Error classification & recovery policy
475
+
476
+ The SDK maps all 18 protocol error codes from `PROTOCOL.md` §4.7 into standardized recovery actions (`fatal`, `drop_intent`, `app_prompt`, `retry_backoff`, `ignore`):
477
+
478
+ ### Recovery Decision Table
479
+
480
+ | Error Code | Action | Default Retry Delay | Application Recovery Guidance |
481
+ | :--- | :--- | :--- | :--- |
482
+ | `UNAUTHORIZED` | `fatal` | — | Halt connection; prompt user for valid API key |
483
+ | `FORBIDDEN_SCOPE` | `fatal` | — | Halt connection; API key lacks required scope |
484
+ | `NOT_ROOM_OWNER` | `drop_intent` | — | Discard intent; caller cannot manage room |
485
+ | `BAD_REQUEST` | `drop_intent` | — | Discard intent; fix malformed request syntax |
486
+ | `ROOM_NOT_FOUND` | `drop_intent` | — | Discard intent; room does not exist |
487
+ | `ROOM_ALREADY_EXISTS` | `app_prompt` | — | Prompt user or generate alternate room name |
488
+ | `INVALID_PASSCODE` | `app_prompt` | — | Prompt user to enter correct passcode |
489
+ | `ROOM_FULL` | `retry_backoff` | 1000 ms | Wait for member departure; retry join |
490
+ | `PASSCODE_RATE_LIMITED` | `retry_backoff` | `retry_after_ms` (fallback 60 s) | Pause passcode entry until lockout window expires |
491
+ | `MAX_ROOMS_REACHED` | `retry_backoff` | 5000 ms | Wait or delete existing rooms |
492
+ | `BINARY_NOT_ALLOWED` | `ignore` | — | Inform application; fallback to text frames |
493
+ | `CHANNEL_NOT_RELIABLE` | `ignore` | — | Unreliable channel cannot guarantee delivery |
494
+ | `CHANNEL_NOT_FOUND` | `ignore` | — | Channel not configured in room |
495
+ | `RESERVED_CHANNEL_NAME` | `ignore` | — | Reserved name (`presence`) cannot be used |
496
+ | `CHANNEL_READ_ONLY` | `ignore` | — | Channel is read-only |
497
+ | `RATE_LIMITED` | `retry_backoff` | `retry_after_ms` (fallback 250 ms) | Back off outbound publishing |
498
+ | `INTERNAL_ERROR` | `retry_backoff` | 2000 ms | Transient server issue; retry request |
499
+
500
+ ### `AUTH_FAILED` vs `UNAUTHORIZED`
501
+
502
+ Because native browser `WebSocket` obscures HTTP status codes on rejected handshakes (closing with code `1006` rather than providing `HTTP 401`), the SDK employs the auth heuristic:
503
+ 1. When a pre-welcome `1006` occurs on first connect, the SDK emits `errorClassified { code: 'UNAUTHORIZED', action: 'fatal', rawFrame: undefined }`.
504
+ 2. Immediately following `errorClassified`, it emits `fatal { code: 'AUTH_FAILED' }` and transitions to `closed`.
505
+
506
+ This preserves full parity with server-side SDKs that observe HTTP 401 directly, while providing browser applications with typed `errorClassified` signals.
507
+
508
+ ### Policy Overrides
509
+
510
+ Applications can customize error classifications via `ClientConfig.errorPolicy.overrides`:
511
+
512
+ ```ts
513
+ const client = new Client({
514
+ url: 'wss://example.com/ws',
515
+ apiKey: 'k_123',
516
+ errorPolicy: {
517
+ overrides: {
518
+ INTERNAL_ERROR: { action: 'fatal' },
519
+ ROOM_NOT_FOUND: { action: 'ignore' },
520
+ },
521
+ },
522
+ });
523
+ ```
524
+
525
+ ---
526
+
527
+ ## Round-trip time (RTT) & ping API
528
+
529
+ ### On-Demand Ping
530
+ Measure current connection round-trip latency at any time using `client.ping()`:
531
+
532
+ ```ts
533
+ const rttMs = await client.ping(5000); // 5s timeout
534
+ console.log(`Ping round-trip: ${rttMs}ms`);
535
+ ```
536
+
537
+ - Sends an application-level `{"type":"ping"}` frame and waits for `{"type":"pong"}`.
538
+ - Serialized to prevent concurrent ping interleaving.
539
+ - Rejects with `NotReadyError` if called while not in `ready` state.
540
+ - Rejects with `RequestTimeoutError` if pong does not arrive within deadline.
541
+
542
+ ### Latency Statistics (`rttStats()`)
543
+ Get a snapshot of latency statistics over the active connection generation:
544
+
545
+ ```ts
546
+ const stats = client.rttStats();
547
+ if (stats) {
548
+ console.log(`Last: ${stats.last}ms, Min: ${stats.min}ms, Max: ${stats.max}ms, Mean: ${stats.mean.toFixed(1)}ms (${stats.samples} samples)`);
549
+ }
550
+ ```
551
+
552
+ The `mean` latency is calculated as an exponential moving average (EMA) with smoothing factor $\alpha = 0.2$:
553
+ $$\text{mean}_{\text{new}} = 0.8 \times \text{mean}_{\text{old}} + 0.2 \times \text{sample}$$
554
+
555
+ Statistics reset to `undefined` on every reconnect cycle and client close.
556
+
557
+ ### Passive Heartbeat RTT Sampling
558
+ Every background heartbeat ping/pong cycle automatically computes an RTT sample and updates latency stats. Opt out via `heartbeat: { trackRtt: false }`.
559
+
560
+ Both heartbeat and on-demand samples emit the `rtt` event:
561
+
562
+ ```ts
563
+ client.on('rtt', ({ sample, source, stats }) => {
564
+ console.log(`RTT [${source}]: ${sample}ms (avg ${stats.mean.toFixed(1)}ms)`);
565
+ });
566
+ ```
567
+
568
+ ---
569
+
570
+ ## Local validation vs. server validation
571
+
572
+ The SDK provides fast, client-side pre-send validation to save network round-trips and provide immediate developer feedback:
573
+
574
+ | Constraint | Validation Rule | Checked On | Error Thrown |
575
+ | :--- | :--- | :--- | :--- |
576
+ | Room name syntax | `^[a-zA-Z0-9_-]{1,64}$` | `createRoom`, `joinRoom`, `deleteRoom`, `forgetRoom`, `publish`, `publishBinary` | `ValidationError` (`field: 'room'`, `rule: 'name_syntax'`) |
577
+ | Channel name syntax | `^[a-zA-Z0-9_-]{1,32}$` | `publish`, `publishBinary` | `ValidationError` (`field: 'channel'`, `rule: 'name_syntax'`) |
578
+ | Reserved channel | Cannot publish to `'presence'` | `publish`, `publishBinary` | `ValidationError` (`field: 'channel'`, `rule: 'reserved_channel'`) |
579
+ | Text payload size | Marshaled bytes $\le 65024$ (leaving 512B envelope budget) | `publish` | `ValidationError` (`field: 'payload'`, `rule: 'too_large'`) |
580
+ | Binary payload size | Raw bytes $\le 65535$ | `publishBinary` | `ValidationError` / `BinaryPayloadTooLargeError` |
581
+
582
+ ### Bypassing Local Validation
583
+ - Set `validation: { enabled: false }` in `ClientConfig` to disable local validation and allow invalid frames to reach the wire for negative testing.
584
+ - Direct calls to `client.send(frame)` bypass local validation entirely.
585
+
586
+ ---
587
+
588
+ ## Capability pre-flight checks
589
+
590
+ Upon connecting, the server advertises its resource limits in `welcome.capabilities.limits`. The SDK performs local pre-flight validation on `createRoom()` calls before transmitting frames:
591
+
592
+ - **`max_metadata_bytes`**: Rejects metadata exceeding the server byte limit with `CapabilityLimitError`.
593
+ - **`max_channels_per_room`**: Computes effective room channels (preset channels $\cup$ custom channels $\cup$ `'presence'`) and rejects if greater than server limit.
594
+ - **`max_rooms_per_key`**: Tracks active rooms created by the client on the current connection and rejects additional room creation attempts before wire traffic.
595
+
596
+ Pre-flight checks fail synchronously before wire frames are dispatched and do **not** emit `errorClassified`. If a server does not advertise limits (or advertises `null`), checks pass through unconditionally.
597
+
598
+ ---
599
+
600
+ ## What the SDK does *not* do
601
+
602
+ To preserve architectural clarity and adhere to browser constraints, the SDK explicitly does **not**:
603
+
604
+ 1. **Send HTTP Headers on WebSocket**: The browser W3C API forbids setting custom HTTP headers on WebSocket handshakes; authentication is strictly query-parameter based (`?key=...`).
605
+ 2. **Expose Failed Upgrade Status Codes**: Browsers do not reveal HTTP 401 response bodies to JavaScript; the SDK relies on its heuristic.
606
+ 3. **Run Dedicated Writer Threads**: JavaScript is single-threaded; the SDK coordinates outbound writes via an async event loop pump yielding microtasks (`await Promise.resolve()`).
607
+ 4. **Isolate Slow Event Handlers**: Callbacks registered on `client.on(...)` execute synchronously on the pump; slow handlers block the event loop.
608
+ 5. **Act as an Authoritative Arbiter**: Client rate limiting and presence rosters are local cooperative hints; the server remains the authoritative source of truth.
609
+ 6. **Reorder or Resend Packets on Sequence Gaps**: Sequence gap detection identifies dropped or out-of-order packets and emits `sequenceGap`, leaving resync policy to the application.
610
+
611
+ ---
612
+
613
+ ## Live Conformance
614
+
615
+ The SDK includes a comprehensive live conformance test suite running against real Go server instances using the `ws` library polyfill in Node.js.
616
+
617
+ ### Running Live Conformance
618
+ ```bash
619
+ # Run the complete live conformance suite
620
+ bash scripts/live-conformance-js.sh
621
+ ```
622
+
623
+ The runner:
624
+ 1. Compiles the Go server binary from `./cmd/server`.
625
+ 2. Starts three isolated test server instances:
626
+ - Primary server on port `18080` (full capabilities, rate limits, timeouts).
627
+ - Binary-disabled server on port `18081` (`ALLOW_BINARY=false`).
628
+ - Policy-violation server on port `18082` (`MAX_RATE_VIOLATIONS_PER_MINUTE=3`).
629
+ 3. Executes the Vitest live suite in `test/live/`.
630
+ 4. Performs graceful SIGTERM shutdown and verifies clean process exit.
2
631
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.