@tonytomo/gosocket-client 1.0.0 → 1.0.1

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.
Files changed (3) hide show
  1. package/MIGRATION.md +366 -0
  2. package/README.md +121 -64
  3. package/package.json +2 -1
package/MIGRATION.md ADDED
@@ -0,0 +1,366 @@
1
+ # Migrating from raw WebSocket to the SDK
2
+
3
+ **Who this is for:** an app that already talks to the Universal WebSocket
4
+ Server using the browser's native `WebSocket` and hand-rolled JSON, and wants
5
+ to move to `@githubusername/ws-client` incrementally.
6
+
7
+ **What this is not:** a tutorial for new SDK users (see `README.md`) or a wire
8
+ protocol reference (see `PROTOCOL.md`). It assumes you know both your existing
9
+ client and the protocol.
10
+
11
+ The SDK is additive. Every feature it provides is optional at the call site.
12
+ You can migrate in five independent steps and stop at any of them.
13
+
14
+ ---
15
+
16
+ ## TL;DR
17
+
18
+ **Before** — your app drives the WebSocket directly:
19
+
20
+ ```js
21
+ const ws = new WebSocket(`wss://host/ws?key=${key}`);
22
+ let retries = 0;
23
+ const rooms = new Set();
24
+
25
+ ws.onopen = () => rooms.forEach((r) => send({ type: "join_room", room: r }));
26
+ ws.onmessage = (e) => {
27
+ const f = JSON.parse(e.data);
28
+ if (f.type === "message") render(f);
29
+ if (f.channel === "presence") updateRoster(f.payload);
30
+ if (f.type === "error" && f.code === "ROOM_FULL") setTimeout(rejoin, 1000);
31
+ };
32
+ ws.onclose = () => {
33
+ if (++retries < 10) setTimeout(() => reconnect(), 500 * 2 ** retries);
34
+ };
35
+
36
+ function send(frame) {
37
+ ws.send(JSON.stringify(frame));
38
+ }
39
+ function join(room) {
40
+ rooms.add(room);
41
+ send({ type: "join_room", room });
42
+ }
43
+ function leave(room) {
44
+ rooms.delete(room);
45
+ send({ type: "leave_room", room });
46
+ }
47
+ ```
48
+
49
+ **After** — the SDK owns all of that:
50
+
51
+ ```js
52
+ import { Client, presetsFor } from "@githubusername/ws-client";
53
+
54
+ const client = new Client({
55
+ url: "wss://host/ws",
56
+ apiKey: key,
57
+ outbound: {
58
+ policyProvider: { channelPolicy: (_r, c) => presetsFor("game")[c] },
59
+ },
60
+ });
61
+
62
+ client.on("message", (f) => render(f));
63
+ client.on("presenceJoin", (f) => addPlayer(f.who));
64
+ client.on("presenceLeave", (f) => removePlayer(f.who));
65
+ client.on("errorClassified", (f) => {
66
+ if (f.action === "retry_backoff")
67
+ setTimeout(() => rejoin(f.room), f.retryAfterMs);
68
+ });
69
+
70
+ await client.connect();
71
+ await client.joinRoom({ room: "arena-1" });
72
+ ```
73
+
74
+ Same wire traffic. Same server. The reconnect loop, room bookkeeping, presence
75
+ roster, and error classification are now SDK-managed.
76
+
77
+ ---
78
+
79
+ ## Frame ↔ API mapping
80
+
81
+ ### Outbound (your app → server)
82
+
83
+ | Raw frame you send today | SDK equivalent | Notes |
84
+ | ---------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
85
+ | `{"type":"create_room",...}` | `await client.createRoom(req)` | Resolves on `room_created` |
86
+ | `{"type":"join_room",...}` | `await client.joinRoom(req)` or `client.joinRoomWithBinary(req)` | Resolves on `room_joined`; second form replaces `binary_channels` |
87
+ | `{"type":"leave_room",...}` | `await client.leaveRoom(room)` | Resolves on `room_left` |
88
+ | `{"type":"delete_room",...}` | `await client.deleteRoom(room)` | Resolves on `room_deleted` **presence event** (no separate ack) |
89
+ | `{"type":"list_rooms"}` | `await client.listRooms()` | First page / full response; use `limit`/`cursor` in the request if you paginate |
90
+ | `{"type":"publish",...}` (JSON) | `await client.publish(req)` | Routes through reliability lane and applies policy + client rate limiter + coalescing |
91
+ | `{"type":"publish",...}` (binary) | `await client.publishBinary(req)` | Prefixes `[id][payload]`; requires `joinRoomWithBinary` first |
92
+ | `{"type":"ping"}` | `await client.ping()` or rely on heartbeat | `ping()` returns measured RTT |
93
+ | `{"type":"send",...}` (deprecated) | `await client.publish(req)` | Do not use the old form; the SDK doesn't expose `send` as a message type |
94
+ | Raw control frame | `client.send(frame)` | Escape hatch; bypasses rate limit, coalescing, and validation |
95
+
96
+ **`publish()` resolves on enqueue, not on-wire delivery.** The protocol has
97
+ no per-message acks. If your app relies on "message has been delivered,"
98
+ nothing changes — you never had that guarantee. If you need ack semantics,
99
+ build it into your payload (server relays verbatim; peer acks; your app
100
+ correlates).
101
+
102
+ ### Inbound (server → your app)
103
+
104
+ | Raw frame you receive today | SDK event | Notes |
105
+ | ---------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
106
+ | `{"type":"welcome",...}` | `connected` | Also returned by `connect()` |
107
+ | `{"type":"room_created",...}` | resolved by `createRoom()` | No event unless you listen to the promise result |
108
+ | `{"type":"room_joined",...}` | resolved by `joinRoom()` | |
109
+ | `{"type":"room_left",...}` | resolved by `leaveRoom()` | |
110
+ | `{"type":"room_list",...}` | resolved by `listRooms()` | |
111
+ | `{"type":"message","channel":"chat"}` | `message` | Payload forwarded verbatim |
112
+ | `{"type":"message","channel":"presence","payload":{"event":"join"}}` | `presenceJoin` | Also updates `knownMembers()` |
113
+ | `{"type":"message","channel":"presence","payload":{"event":"leave"}}` | `presenceLeave` | Also updates `knownMembers()` |
114
+ | `{"type":"message","channel":"presence","payload":{"event":"room_deleted"}}` | `roomDeleted` | Also removes the intent; `deleteRoom()` promise resolves |
115
+ | Binary frame `[id][payload]` | `binaryMessage` | Payload is a `Uint8Array`; unknown id → `binaryUnknownChannel` |
116
+ | `{"type":"error",...}` | `error` **and** `errorClassified` | Classification runs first, then the raw frame |
117
+ | `{"type":"pong"}` | `rtt` (if `Heartbeat.trackRtt`) | `ping()` also resolves |
118
+
119
+ ---
120
+
121
+ ## Five behavior changes
122
+
123
+ ### 1. Reconnect is the SDK's job now
124
+
125
+ **Was:** your `onclose` handler, your backoff math, your retry cap.
126
+
127
+ **Now:** full-jitter exponential backoff, per-close-code policy, cancelled by
128
+ `client.close()`. Configurable via `ClientConfig.reconnect`.
129
+
130
+ **Do not add a second reconnect loop around `client.on('disconnected', ...)`.**
131
+ You'll get two competing retry chains and double-connect. If you want to
132
+ customize behavior, use `reconnect.shouldReconnect(ctx)`, not an outer loop.
133
+
134
+ **Does not reconnect on:** `client.close()`, `1008` (policy violation),
135
+ `AUTH_FAILED` (browser auth heuristic). Reconnects on `1006`, `1001`,
136
+ unexpected `1000`, transport error, heartbeat timeout.
137
+
138
+ ### 2. Session restore is automatic
139
+
140
+ **Was:** your code re-joined rooms in `onopen`.
141
+
142
+ **Now:** the SDK keeps a per-`Client` in-memory list of joined rooms
143
+ (passcodes, binary maps) and re-issues `join_room` for each one after a
144
+ successful reconnect — **before** `connected` fires again. You do not need
145
+ rejoin logic.
146
+
147
+ **Emitted signal:** `sessionRestored { restored: string[], failed: [...] }`.
148
+ Subscribe to it if you need to resync app state (pull fresh game state, clear
149
+ stale UI).
150
+
151
+ **Cleared on `client.close()`.** Session restore is for automatic reconnect,
152
+ not for page reloads. If you want cross-reload persistence, write your own
153
+ `sessionStorage` of `{room, passcode}` and call `joinRoom` on `connect()`.
154
+
155
+ ### 3. Presence is cached
156
+
157
+ **Was:** your `Map<room, Set<label>>` populated from raw presence frames.
158
+
159
+ **Now:** `client.knownMembers(room)` returns a sorted snapshot.
160
+ `client.lastServerMemberCount(room)` returns the last `members` count from
161
+ `room_joined`, adjusted by presence deltas. Both are **hints, not
162
+ authoritative** — after a reconnect, `presenceInvalidated(room)` returns
163
+ `true` because the protocol offers no roster snapshot and peers who left
164
+ during your downtime never emitted a `leave` for you.
165
+
166
+ **Call `resetPresence(room)` after your own out-of-band roster sync** to
167
+ clear the invalidated flag.
168
+
169
+ ### 4. Rate limiting is cooperative on the client
170
+
171
+ **Was:** you find out by getting disconnected with `1008`.
172
+
173
+ **Now:** the SDK applies a per-`(room, channel)` token bucket mirroring the
174
+ server's `max_rate` and burst. Publishes beyond the bucket reject with
175
+ `RateLimitedError{ source: 'local' }` before touching the wire. The server's
176
+ `RATE_LIMITED` response still arrives (as `rateLimited{ source: 'server' }`)
177
+ if you disable the client limiter — which you might do in tests.
178
+
179
+ **Implication:** `await client.publish()` can now **reject** for a reason
180
+ that used to be a silent drop. Wrap your publish call if you were relying on
181
+ fire-and-forget.
182
+
183
+ ### 5. Errors become actions
184
+
185
+ **Was:** `if (frame.code === 'ROOM_FULL') setTimeout(rejoin, 1000);` —
186
+ hand-coded per code.
187
+
188
+ **Now:** `errorClassified { code, action, retryAfterMs }` on every server
189
+ error. `action` is one of `'ignore' | 'drop_intent' | 'retry_backoff' |
190
+ 'reconnect' | 'fatal' | 'app_prompt'`.
191
+
192
+ **Recommended pattern:**
193
+
194
+ ```js
195
+ client.on("errorClassified", (e) => {
196
+ switch (e.action) {
197
+ case "retry_backoff":
198
+ setTimeout(retry, e.retryAfterMs ?? 1000);
199
+ break;
200
+ case "drop_intent":
201
+ client.forgetRoom(e.room);
202
+ break;
203
+ case "app_prompt":
204
+ showDialog(e.code, e.message);
205
+ break;
206
+ case "fatal":
207
+ showFatalScreen(e.code);
208
+ break;
209
+ case "ignore":
210
+ console.warn(e.code);
211
+ break;
212
+ }
213
+ });
214
+ ```
215
+
216
+ The classification table is stable and testable via the exported
217
+ `classifyError(code, frame?, overrides?)` function. Use overrides in
218
+ `ClientConfig.errorPolicy` to change individual codes without forking the
219
+ SDK.
220
+
221
+ ---
222
+
223
+ ## Three things that stay the same
224
+
225
+ 1. **Your lifecycle flow.** `connect` → `createRoom` → `joinRoom` → `publish`
226
+ → `leaveRoom` → `close`. Same order, same server-side preconditions.
227
+
228
+ 2. **Your room names, channel names, payload schemas.** The SDK marshals your
229
+ `payload` verbatim; the server relays it verbatim. No schema registry, no
230
+ translation, no validation beyond the size limits you can configure in
231
+ `validation`.
232
+
233
+ 3. **The server.** No env-var changes, no `PROTOCOL.md` changes, no
234
+ re-deployment. The SDK speaks the same protocol your current client speaks.
235
+ You can run raw and SDK clients against the same server from day one.
236
+
237
+ ---
238
+
239
+ ## Migration path
240
+
241
+ Five independently shippable steps. Each is reversible. Stop at any step.
242
+
243
+ ### Step 1 — swap the transport, keep everything else
244
+
245
+ Replace `new WebSocket(...)` with `new Client(...)` and wire the SDK's `send`
246
+ and `message` event to your existing handlers. Disable reconnect, heartbeat,
247
+ lanes, presence cache, and classification by turning them off in `ClientConfig`
248
+ during step 1. Your app should behave **byte-for-byte the same** as before.
249
+
250
+ **Verify:** open the app, exercise every feature, compare DevTools network
251
+ traffic before/after — same frames, same cadence.
252
+
253
+ ### Step 2 — turn on reconnect and session restore
254
+
255
+ Enable `reconnect` and remove your `onclose` retry loop. Enable
256
+ `restore` (default on) and remove your manual `onopen` rejoin code.
257
+
258
+ **Verify:** kill the WebSocket from DevTools network panel (or `docker
259
+ restart` the server). Watch `reconnecting` → `connected` → `sessionRestored`
260
+ fire in order. Rooms come back automatically.
261
+
262
+ ### Step 3 — turn on outbound lanes and rate limiting
263
+
264
+ Install `outbound.policyProvider` (usually `presetsFor(kind)`). Publish now
265
+ goes through reliability lanes with the channel's configured behavior.
266
+
267
+ **Verify:** subscribe to `reliableOverflow`, `unreliableDropped`,
268
+ `rateLimited`. A fast-publish test should emit `rateLimited{ source: 'local' }`
269
+ **before** the server's `RATE_LIMITED` fires — the local limiter preempts.
270
+
271
+ ### Step 4 — turn on presence cache and seq detection
272
+
273
+ Replace your roster map with `knownMembers()` and subscribe to
274
+ `presenceJoin` / `presenceLeave` / `presenceInvalidated` / `roomDeleted`.
275
+ Subscribe to `sequenceGap` for observability.
276
+
277
+ **Verify:** two clients in one room, one joins/leaves rapidly — roster in your
278
+ UI matches `knownMembers()` after the dust settles. After a reconnect, check
279
+ `presenceInvalidated(room)` is `true` and call `resetPresence(room)` after
280
+ your roster resync.
281
+
282
+ ### Step 5 — turn on validation, pre-flight, and error classification
283
+
284
+ Enable `validation` (default on) and `errorPolicy` (default table). Wire your
285
+ error handlers to `errorClassified.action`. Enable capability pre-flight by
286
+ leaving it on (it only fires when the server's `capabilities.limits` are set).
287
+
288
+ **Verify:** submit a bad room name → `ValidationError` throws synchronously
289
+ before wire traffic. A room-full scenario → `errorClassified{action:
290
+ 'retry_backoff'}` and your UI retries. `createRoom` with oversize metadata
291
+ when the server exposes `max_metadata_bytes` → `CapabilityLimitError` fires
292
+ locally.
293
+
294
+ ---
295
+
296
+ ## Caveats and gotchas
297
+
298
+ **ESM-only.** `client-js` ships as `"type": "module"`. You need a bundler
299
+ (Vite, Webpack 5, esbuild, Rollup, Parcel) or `<script type="module">`. If
300
+ you're currently loading your client with a plain `<script src>`, add a
301
+ bundler before step 1.
302
+
303
+ **`publish()` resolves on enqueue, not delivery.** The protocol has no per-
304
+ message ack. See the mapping table note above.
305
+
306
+ **`deleteRoom()` resolves on the `roomDeleted` presence event.** The SDK also
307
+ emits the event, so if you subscribe to `roomDeleted` **and** await
308
+ `deleteRoom()`, both fire. Don't double-handle.
309
+
310
+ **`send()` routes through the reliable lane (J2+).** In earlier raw usage
311
+ your `ws.send()` was synchronous; the SDK's `send()` is fire-and-forget but
312
+ buffered through a bounded (256) lane. Overflow drops newest by default. If
313
+ you were sending bursts >256 control frames without yielding, throttle.
314
+
315
+ **`binaryMessage.payload` is a `Uint8Array`, not an `ArrayBuffer`.** It's a
316
+ defensive copy; you may mutate it without affecting the transport.
317
+
318
+ **Binary channel IDs are globally unique per `Client` instance.** The wire
319
+ format has no room field, so two rooms on the same connection can't share an
320
+ ID. If you need per-room-independent IDs, use separate `Client` instances.
321
+
322
+ **`capabilities` may be `undefined`.** Older servers (pre-S2) don't emit the
323
+ field. Capability pre-flight silently skips when it's absent. Your app should
324
+ tolerate `client.capabilities === undefined`.
325
+
326
+ **`AUTH_FAILED` and `UNAUTHORIZED` both fire on auth failure.** The browser
327
+ can't see the HTTP 401 status, so the SDK heuristically classifies the first
328
+ pre-`welcome` `1006` close. Both events fire, `errorClassified` first:
329
+
330
+ ```js
331
+ client.on("errorClassified", (e) => {
332
+ if (e.code === "UNAUTHORIZED") showLoginScreen(); // protocol name
333
+ });
334
+ client.on("fatal", (e) => {
335
+ if (e.code === "AUTH_FAILED") hideSpinner(); // browser name
336
+ });
337
+ ```
338
+
339
+ **Concurrent same-type requests on the same room are serialized.** Because
340
+ the protocol has no request IDs, `joinRoom('arena')` called twice in quick
341
+ succession rejects the second with `ConcurrentRequestError`. Different rooms
342
+ in parallel are fine.
343
+
344
+ **Session restore is in-memory only.** Page reload wipes intents. Persist
345
+ `{room, passcode}` in `sessionStorage` yourself if you need that.
346
+
347
+ **Heartbeat is on by default.** Every 25s the SDK sends `{"type":"ping"}` and
348
+ expects a `pong` within 10s. If you're on a metered connection and want to
349
+ disable it, set `heartbeat: { enabled: false }` — but then dead connections
350
+ won't be detected until the transport's TCP timeout.
351
+
352
+ ---
353
+
354
+ ## Where to read more
355
+
356
+ - **`client-js/README.md`** — full API reference. Every method, event, and
357
+ config option with the SDK's actual behavior.
358
+ - **`PROTOCOL.md`** — wire contract. §4.7 for error codes, §5 for channels
359
+ and rate limits, §6 for presence, §7 for binary frames.
360
+ - **`PROJECT.md`** — phase history (J1–J4 for the JS SDK). "What's not here"
361
+ lists SDK non-goals.
362
+ - **`client-js/CHANGELOG.md`** — which feature landed in which version.
363
+
364
+ If you're unsure whether a raw pattern you use has an SDK equivalent, the
365
+ answer is usually in the mapping tables above. If it isn't, the SDK
366
+ deliberately doesn't cover it — use `client.send()` as the escape hatch.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @ws-server/ws-client (`ws-client-js`)
1
+ # @tonytomo/gosocket-client (`gosocket-client`)
2
2
 
3
3
  Browser-first TypeScript / JavaScript client SDK for the Universal WebSocket Server (Complete SDK: Phases J1, J2, J3 & J4).
4
4
 
@@ -27,7 +27,7 @@ This SDK runs natively in modern web browsers (HTML5 `WebSocket`) with **zero ru
27
27
  - [Round-trip time (RTT) & ping API](#round-trip-time-rtt--ping-api)
28
28
  - [Local validation vs. server validation](#local-validation-vs-server-validation)
29
29
  - [Capability pre-flight checks](#capability-pre-flight-checks)
30
- - [What the SDK does *not* do](#what-the-sdk-does-not-do)
30
+ - [What the SDK does _not_ do](#what-the-sdk-does-not-do)
31
31
  - [Usage Guide](#usage-guide)
32
32
  - [Live Conformance](#live-conformance)
33
33
 
@@ -59,27 +59,31 @@ Because the browser hides the HTTP 401 status code behind an ambiguous abnormal
59
59
  The SDK implements an authentication heuristic mirrored from the reference Go SDK:
60
60
 
61
61
  ### Default Heuristic (`'heuristic'`)
62
+
62
63
  - 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
64
  - The client emits a `fatal` event with `{ code: 'AUTH_FAILED', message: '...' }`, transitions to `closed`, and does **not** attempt automatic reconnection.
64
65
  - 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
66
  - The heuristic state is reset whenever `client.close()` is called followed by a fresh `client.connect()`.
66
67
 
67
68
  ### Alternative Auth Failure Modes
69
+
68
70
  You can configure `authFailureMode` in `ClientConfig`:
71
+
69
72
  - `'heuristic'` (default): Treat pre-welcome `1006` on the first attempt as `AUTH_FAILED`.
70
73
  - `'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
74
  - `'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
75
 
73
76
  ### Ambiguous Auth Escape Hatch (`onAmbiguousAuth`)
77
+
74
78
  If your application requires custom classification logic, provide an `onAmbiguousAuth` callback:
75
79
 
76
80
  ```ts
77
81
  const client = new Client({
78
- url: 'wss://example.com/ws',
79
- apiKey: 'k_alice_123',
82
+ url: "wss://example.com/ws",
83
+ apiKey: "k_alice_123",
80
84
  onAmbiguousAuth: (closeCode, reason) => {
81
85
  // Return 'fatal' to abort immediately or 'retry' to engage reconnection
82
- return closeCode === 1006 ? 'fatal' : 'retry';
86
+ return closeCode === 1006 ? "fatal" : "retry";
83
87
  },
84
88
  });
85
89
  ```
@@ -94,13 +98,16 @@ $$\text{backoff} = \min(\text{maxDelayMs}, \text{baseDelayMs} \times 2^{\text{at
94
98
  $$\text{delay} = \text{random}(0, \text{backoff})$$
95
99
 
96
100
  ### Default Configuration
101
+
97
102
  - `baseDelayMs`: `500` ms
98
103
  - `maxDelayMs`: `30000` ms (30 s)
99
104
  - `maxAttempts`: `Infinity`
100
105
  - `jitter`: `'full'` (also supports `'equal'` and `'none'`)
101
106
 
102
107
  ### Reconnect Triggers
108
+
103
109
  Automatic reconnection engages on:
110
+
104
111
  - `1006` (Abnormal Closure, post-welcome or in `'never-fatal'` mode)
105
112
  - `1001` (Going Away, indicating server graceful shutdown recovery per PROTOCOL.md §9.2)
106
113
  - `1000` (Unexpected normal closure initiated by the server without prior client `close()`)
@@ -108,7 +115,9 @@ Automatic reconnection engages on:
108
115
  - Heartbeat ping/pong deadline timeouts
109
116
 
110
117
  ### Non-Reconnect (Fatal) Triggers
118
+
111
119
  Reconnection is **halted** and state transitions to `closed` on:
120
+
112
121
  - Explicit caller termination via `client.close()`
113
122
  - `1008` (Policy Violation, such as rate abuse, reliable queue overflow, or server read timeouts) — surfaces `fatal` with `{ code: 'POLICY_VIOLATION' }`
114
123
  - Initial handshake classification of `AUTH_FAILED`
@@ -124,18 +133,23 @@ The attempt counter resets to `0` upon each successful `welcome` frame.
124
133
  The SDK automatically restores room memberships following connection recovery without re-transmitting business traffic.
125
134
 
126
135
  ### In-Memory Only
136
+
127
137
  > [!IMPORTANT]
128
138
  > **No Local Storage Persistence**:
129
139
  > 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
140
 
131
141
  ### Serialized Execution
142
+
132
143
  The wire protocol contains no multiplexed request correlation identifiers. Because of this:
144
+
133
145
  - **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
146
  - Parallel restore joins are forbidden to prevent protocol race conditions.
135
147
  - On recovery, room intents are sorted alphabetically by room name to ensure deterministic order across connections.
136
148
 
137
149
  ### Per-Intent Recovery Outcomes
150
+
138
151
  When rejoining each room on reconnect:
152
+
139
153
  - `room_joined` $\rightarrow$ Membership restored.
140
154
  - `ROOM_NOT_FOUND` $\rightarrow$ Intent deleted from memory, emits `intentFailed{ room, code: 'ROOM_NOT_FOUND' }`.
141
155
  - `FORBIDDEN_SCOPE` $\rightarrow$ Intent deleted from memory, emits `intentFailed{ room, code: 'FORBIDDEN_SCOPE' }`.
@@ -179,6 +193,7 @@ Phase J2 replaces direct socket writing with a prioritised two-lane async outbou
179
193
  ```
180
194
 
181
195
  ### Lane Characteristics
196
+
182
197
  1. **Reliable Lane (`reliable: true`)**:
183
198
  - Default buffer capacity: `256` frames.
184
199
  - Enforces strict FIFO ordering.
@@ -191,7 +206,9 @@ Phase J2 replaces direct socket writing with a prioritised two-lane async outbou
191
206
  - **Overflow Behavior**: Drops the **oldest** frame in the queue to admit the newest frame. Emits `unreliableDropped{ depth }`.
192
207
 
193
208
  ### Writer Loop Scheduling
209
+
194
210
  Because browsers lack background writer goroutines, the writer is driven by an async pump loop running on the event loop:
211
+
195
212
  - Drains up to `reliableBurst` (default: 32) frames from the reliable lane.
196
213
  - Drains up to `unreliableBurst` (default: 8) frames from the unreliable lane or coalesce slots.
197
214
  - Performs a microtask yield (`await Promise.resolve()`) per frame to avoid blocking UI rendering or starve incoming WebSocket frames.
@@ -204,10 +221,12 @@ Because browsers lack background writer goroutines, the writer is driven by an a
204
221
  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
222
 
206
223
  ### Drivers: `'timer'` vs. `'raf'`
224
+
207
225
  - `'timer'` (default): Drains on the next microtask turn. Suitable for general high-frequency state streams and maintains behavioral parity with the Go SDK.
208
226
  - `'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
227
 
210
228
  ### Merge Semantics (`merge`)
229
+
211
230
  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
231
 
213
232
  ```ts
@@ -215,7 +234,7 @@ const policy: ChannelPolicy = {
215
234
  reliable: false,
216
235
  maxRate: 0,
217
236
  coalesce: true,
218
- coalesceDriver: 'raf',
237
+ coalesceDriver: "raf",
219
238
  merge: (prev: unknown, next: unknown) => {
220
239
  const p = prev as { x: number; y: number; seq: number };
221
240
  const n = next as { x: number; y: number; seq: number };
@@ -228,6 +247,7 @@ const policy: ChannelPolicy = {
228
247
  },
229
248
  };
230
249
  ```
250
+
231
251
  - If `skip: true`, the new message is discarded and the previous message is retained in the slot, emitting `coalesced{ merged: false, skipped: true }`.
232
252
  - If `skip: false`, `value` replaces the slot and `coalesced{ merged: true, skipped: false }` is emitted.
233
253
  - Flipping a channel's policy to `coalesce: false` via `setChannelPolicy` immediately flushes any pending slot as an individual write without emitting a `coalesced` event.
@@ -237,14 +257,17 @@ const policy: ChannelPolicy = {
237
257
  ## Rate limiting — cooperative, not authoritative
238
258
 
239
259
  Rate limiting is enforced locally per `(room, channel)` using an exact client-side token bucket matching the server algorithm in PROTOCOL.md §5.5:
260
+
240
261
  - **Rate**: `maxRate` tokens per second.
241
262
  - **Burst Capacity**: $\max(2, \text{maxRate} \times 2)$.
242
263
  - `maxRate = 0` bypasses rate limiting (unlimited).
243
264
 
244
265
  ### Role of the Client Limiter
266
+
245
267
  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
268
 
247
269
  ### Local vs. Server Events
270
+
248
271
  - `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
272
  - `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
273
 
@@ -255,6 +278,7 @@ The client-side limiter is **cooperative, not authoritative**. Its primary role
255
278
  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
279
 
257
280
  The SDK monitors backpressure cooperatively:
281
+
258
282
  - **High Water Mark (`highWaterMarkBytes`, default $1 \text{ MiB}$)**: When `bufferedAmount >= highWaterMarkBytes`, the writer pauses unreliable lane draining and emits `backpressure{ state: 'high', bufferedAmount }`.
259
283
  - **Low Water Mark (`lowWaterMarkBytes`, default $512 \text{ KiB}$)**: When `bufferedAmount <= lowWaterMarkBytes`, backpressure clears, `backpressure{ state: 'low', bufferedAmount }` is emitted, and the writer resumes.
260
284
  - **Hysteresis**: Fluctuations between the low and high thresholds do not trigger flapping events.
@@ -281,7 +305,9 @@ Outbound queues and coalesce slots belong strictly to the active **connection ge
281
305
  Upon each successful connection, the server synchronously sends a `welcome` frame containing a `capabilities` descriptor advertising its runtime limits and supported features.
282
306
 
283
307
  ### S2 `null`-means-disabled Semantics
308
+
284
309
  The SDK preserves the authoritative wire semantics from PROTOCOL.md §2.4 and S2:
310
+
285
311
  - An integer value indicates an enforced maximum (e.g. `max_rooms: 1000`, `max_channels_per_room: 32`).
286
312
  - A `null` value indicates that the limit is **explicitly disabled** (unlimited) by server configuration (e.g. `max_rooms_per_key: null`).
287
313
  - `undefined` indicates that the connected server is a legacy version that does not advertise capabilities.
@@ -295,41 +321,49 @@ Capabilities are cached on `client.capabilities`. When a reconnect yields a diff
295
321
  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
322
 
297
323
  ### Wire Format & Mapping
324
+
298
325
  - Wire format: `[channel_id: 1 byte][payload: N bytes]`.
299
326
  - 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
327
  - Channel `"presence"` cannot be mapped to binary (`RESERVED_CHANNEL_NAME`).
301
328
 
302
329
  ### Builder Construction
330
+
303
331
  Use `BinaryMapBuilder` to construct and validate mappings:
304
332
 
305
333
  ```ts
306
- import { BinaryMapBuilder } from 'ws-client-js';
334
+ import { BinaryMapBuilder } from "gosocket-client";
307
335
 
308
336
  const binMap = new BinaryMapBuilder()
309
- .set('state', 1)
310
- .set('actions', 2)
311
- .autoSet('telemetry') // assigns smallest available ID (e.g. 3)
337
+ .set("state", 1)
338
+ .set("actions", 2)
339
+ .autoSet("telemetry") // assigns smallest available ID (e.g. 3)
312
340
  .build();
313
341
  ```
314
342
 
315
343
  Construction-time validation enforces:
344
+
316
345
  - Channel name non-empty and `!= 'presence'`.
317
346
  - ID integer in $[1, 254]$.
318
347
  - No duplicate IDs and no duplicate channel names in the builder.
319
348
  - Returns an immutable, frozen mapping object.
320
349
 
321
350
  ### Global Binary-ID Uniqueness Rule
351
+
322
352
  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
353
 
324
354
  If room B attempts to map an ID already assigned to room A:
355
+
325
356
  - `joinRoomWithBinary` rejects locally before touching the wire with `BinaryIdCollisionError`.
326
357
  - Emits a `binaryIdCollision` event with `{ id, existing, rejected }`.
327
358
 
328
359
  ### Update via Re-Join
360
+
329
361
  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
362
 
331
363
  ### Binary Publishing & Coalescing
364
+
332
365
  Use `client.publishBinary({ room, channel, payload })`:
366
+
333
367
  - Payloads can be passed as `ArrayBuffer` or `Uint8Array`.
334
368
  - Routes through the channel's resolved policy (`reliable` vs `unreliable`).
335
369
  - Shares the same token-bucket rate limiter and server `RATE_LIMITED` backoff with text messages on that channel.
@@ -344,12 +378,14 @@ Room presence is managed automatically on the reserved `"presence"` channel (PRO
344
378
 
345
379
  > [!IMPORTANT]
346
380
  > **Presence Caveats**:
381
+ >
347
382
  > - **Presence is a best-effort hint, not an authoritative source of truth.**
348
383
  > - 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
384
  > - Peers who left while this client was disconnected or reconnecting may still appear in `knownMembers(room)` until `resetPresence(room)` or room departure.
350
385
  > - 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
386
 
352
387
  ### Presence Inspection & APIs
388
+
353
389
  - `client.selfLabel`: The connection's unique client label assigned in `welcome.label`.
354
390
  - `client.knownMembers(room)`: Sorted array of peer labels observed joining since the last `resetPresence`, with `selfLabel` excluded.
355
391
  - `client.lastServerMemberCount(room)`: Last member count from `room_joined.members`, adjusted by subsequent `presenceJoin` (+1) and `presenceLeave` (-1) events. Returns `{ count: number, ok: boolean }`.
@@ -357,6 +393,7 @@ Room presence is managed automatically on the reserved `"presence"` channel (PRO
357
393
  - `client.resetPresence(room)`: Clears the `known` roster and invalidation flag while keeping `lastServerMemberCount`.
358
394
 
359
395
  ### Typed Events
396
+
360
397
  - `presenceJoin`: `{ room, who }`
361
398
  - `presenceLeave`: `{ room, who }`
362
399
  - `presenceInvalidated`: `{ room, reason }`
@@ -369,6 +406,7 @@ Room presence is managed automatically on the reserved `"presence"` channel (PRO
369
406
  The server sequences messages monotonically via a per-`(room, channel)` `seq` counter (PROTOCOL.md §5.6).
370
407
 
371
408
  ### Anomalous vs Expected
409
+
372
410
  - **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
411
  - **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
412
  - **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.
@@ -379,6 +417,7 @@ The server sequences messages monotonically via a per-`(room, channel)` `seq` co
379
417
  ## Reconnect invalidates presence and re-baselines seq
380
418
 
381
419
  When a connection disconnects and reconnects:
420
+
382
421
  1. **Queues Discarded**: Outbound queues are purged to avoid sending stale commands.
383
422
  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
423
  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.
@@ -391,9 +430,11 @@ When a connection disconnects and reconnects:
391
430
  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
431
 
393
432
  This SDK automatically sets:
433
+
394
434
  ```ts
395
- this.ws.binaryType = 'arraybuffer';
435
+ this.ws.binaryType = "arraybuffer";
396
436
  ```
437
+
397
438
  Inbound binary messages arrive synchronously as `ArrayBuffer` instances and are decoded without microtask lag into `Uint8Array` payloads on `binaryMessage` events.
398
439
 
399
440
  ---
@@ -401,17 +442,19 @@ Inbound binary messages arrive synchronously as `ArrayBuffer` instances and are
401
442
  ## Usage Guide
402
443
 
403
444
  ### Installation
445
+
404
446
  ```bash
405
- npm install ws-client-js
447
+ npm install gosocket-client
406
448
  ```
407
449
 
408
450
  ### Complete Example (Lifecycle, Binary, Presence, Policies)
451
+
409
452
  ```ts
410
- import { Client, BinaryMapBuilder, presetsFor } from 'ws-client-js';
453
+ import { Client, BinaryMapBuilder, presetsFor } from "gosocket-client";
411
454
 
412
455
  const client = new Client({
413
- url: 'wss://your-server.example.com/ws',
414
- apiKey: 'k_alice_123',
456
+ url: "wss://your-server.example.com/ws",
457
+ apiKey: "k_alice_123",
415
458
  heartbeat: { intervalMs: 25000, timeoutMs: 10000 },
416
459
  reconnect: { baseDelayMs: 500, maxDelayMs: 30000 },
417
460
  presence: { autoInvalidateOnReliableGap: true },
@@ -419,54 +462,55 @@ const client = new Client({
419
462
  reliableQueueSize: 256,
420
463
  unreliableQueueSize: 16,
421
464
  policyProvider: {
422
- channelPolicy: (room, channel) => presetsFor('game')[channel],
465
+ channelPolicy: (room, channel) => presetsFor("game")[channel],
423
466
  },
424
467
  },
425
468
  });
426
469
 
427
470
  // Binary channel mapping
428
- const binMap = new BinaryMapBuilder()
429
- .set('state', 1)
430
- .set('actions', 2)
431
- .build();
471
+ const binMap = new BinaryMapBuilder().set("state", 1).set("actions", 2).build();
432
472
 
433
473
  // Inbound binary frames
434
- client.on('binaryMessage', ({ room, channel, payload }) => {
474
+ client.on("binaryMessage", ({ room, channel, payload }) => {
435
475
  // payload is a Uint8Array
436
- console.log(`Binary frame on ${room}/${channel} (${payload.byteLength} bytes)`);
476
+ console.log(
477
+ `Binary frame on ${room}/${channel} (${payload.byteLength} bytes)`,
478
+ );
437
479
  });
438
480
 
439
481
  // Presence events
440
- client.on('presenceJoin', ({ room, who }) => {
482
+ client.on("presenceJoin", ({ room, who }) => {
441
483
  console.log(`Peer ${who} joined ${room}`);
442
- console.log('Known roster:', client.knownMembers(room));
484
+ console.log("Known roster:", client.knownMembers(room));
443
485
  });
444
486
 
445
- client.on('presenceLeave', ({ room, who }) => {
487
+ client.on("presenceLeave", ({ room, who }) => {
446
488
  console.log(`Peer ${who} left ${room}`);
447
489
  });
448
490
 
449
491
  // Sequence gaps
450
- client.on('sequenceGap', (ev) => {
451
- console.warn(`Sequence gap on ${ev.room}/${ev.channel}: missed ${ev.missed} frames (reliable: ${ev.reliable})`);
492
+ client.on("sequenceGap", (ev) => {
493
+ console.warn(
494
+ `Sequence gap on ${ev.room}/${ev.channel}: missed ${ev.missed} frames (reliable: ${ev.reliable})`,
495
+ );
452
496
  });
453
497
 
454
498
  // Connect and join with binary
455
499
  await client.connect();
456
500
  await client.joinRoomWithBinary({
457
- room: 'arena-1',
501
+ room: "arena-1",
458
502
  binaryChannels: binMap,
459
503
  });
460
504
 
461
505
  // Publish binary state at 60 Hz (unreliable lane)
462
506
  await client.publishBinary({
463
- room: 'arena-1',
464
- channel: 'state',
507
+ room: "arena-1",
508
+ channel: "state",
465
509
  payload: new Uint8Array([0x01, 0x02, 0x03]),
466
510
  });
467
511
 
468
512
  // Clean room deletion
469
- await client.deleteRoom('arena-1');
513
+ await client.deleteRoom("arena-1");
470
514
  ```
471
515
 
472
516
  ---
@@ -477,29 +521,30 @@ The SDK maps all 18 protocol error codes from `PROTOCOL.md` §4.7 into standardi
477
521
 
478
522
  ### Recovery Decision Table
479
523
 
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 |
524
+ | Error Code | Action | Default Retry Delay | Application Recovery Guidance |
525
+ | :---------------------- | :-------------- | :--------------------------------- | :------------------------------------------------ |
526
+ | `UNAUTHORIZED` | `fatal` | — | Halt connection; prompt user for valid API key |
527
+ | `FORBIDDEN_SCOPE` | `fatal` | — | Halt connection; API key lacks required scope |
528
+ | `NOT_ROOM_OWNER` | `drop_intent` | — | Discard intent; caller cannot manage room |
529
+ | `BAD_REQUEST` | `drop_intent` | — | Discard intent; fix malformed request syntax |
530
+ | `ROOM_NOT_FOUND` | `drop_intent` | — | Discard intent; room does not exist |
531
+ | `ROOM_ALREADY_EXISTS` | `app_prompt` | — | Prompt user or generate alternate room name |
532
+ | `INVALID_PASSCODE` | `app_prompt` | — | Prompt user to enter correct passcode |
533
+ | `ROOM_FULL` | `retry_backoff` | 1000 ms | Wait for member departure; retry join |
534
+ | `PASSCODE_RATE_LIMITED` | `retry_backoff` | `retry_after_ms` (fallback 60 s) | Pause passcode entry until lockout window expires |
535
+ | `MAX_ROOMS_REACHED` | `retry_backoff` | 5000 ms | Wait or delete existing rooms |
536
+ | `BINARY_NOT_ALLOWED` | `ignore` | — | Inform application; fallback to text frames |
537
+ | `CHANNEL_NOT_RELIABLE` | `ignore` | — | Unreliable channel cannot guarantee delivery |
538
+ | `CHANNEL_NOT_FOUND` | `ignore` | — | Channel not configured in room |
539
+ | `RESERVED_CHANNEL_NAME` | `ignore` | — | Reserved name (`presence`) cannot be used |
540
+ | `CHANNEL_READ_ONLY` | `ignore` | — | Channel is read-only |
541
+ | `RATE_LIMITED` | `retry_backoff` | `retry_after_ms` (fallback 250 ms) | Back off outbound publishing |
542
+ | `INTERNAL_ERROR` | `retry_backoff` | 2000 ms | Transient server issue; retry request |
499
543
 
500
544
  ### `AUTH_FAILED` vs `UNAUTHORIZED`
501
545
 
502
546
  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:
547
+
503
548
  1. When a pre-welcome `1006` occurs on first connect, the SDK emits `errorClassified { code: 'UNAUTHORIZED', action: 'fatal', rawFrame: undefined }`.
504
549
  2. Immediately following `errorClassified`, it emits `fatal { code: 'AUTH_FAILED' }` and transitions to `closed`.
505
550
 
@@ -511,12 +556,12 @@ Applications can customize error classifications via `ClientConfig.errorPolicy.o
511
556
 
512
557
  ```ts
513
558
  const client = new Client({
514
- url: 'wss://example.com/ws',
515
- apiKey: 'k_123',
559
+ url: "wss://example.com/ws",
560
+ apiKey: "k_123",
516
561
  errorPolicy: {
517
562
  overrides: {
518
- INTERNAL_ERROR: { action: 'fatal' },
519
- ROOM_NOT_FOUND: { action: 'ignore' },
563
+ INTERNAL_ERROR: { action: "fatal" },
564
+ ROOM_NOT_FOUND: { action: "ignore" },
520
565
  },
521
566
  },
522
567
  });
@@ -527,6 +572,7 @@ const client = new Client({
527
572
  ## Round-trip time (RTT) & ping API
528
573
 
529
574
  ### On-Demand Ping
575
+
530
576
  Measure current connection round-trip latency at any time using `client.ping()`:
531
577
 
532
578
  ```ts
@@ -540,12 +586,15 @@ console.log(`Ping round-trip: ${rttMs}ms`);
540
586
  - Rejects with `RequestTimeoutError` if pong does not arrive within deadline.
541
587
 
542
588
  ### Latency Statistics (`rttStats()`)
589
+
543
590
  Get a snapshot of latency statistics over the active connection generation:
544
591
 
545
592
  ```ts
546
593
  const stats = client.rttStats();
547
594
  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)`);
595
+ console.log(
596
+ `Last: ${stats.last}ms, Min: ${stats.min}ms, Max: ${stats.max}ms, Mean: ${stats.mean.toFixed(1)}ms (${stats.samples} samples)`,
597
+ );
549
598
  }
550
599
  ```
551
600
 
@@ -555,12 +604,13 @@ $$\text{mean}_{\text{new}} = 0.8 \times \text{mean}_{\text{old}} + 0.2 \times \t
555
604
  Statistics reset to `undefined` on every reconnect cycle and client close.
556
605
 
557
606
  ### Passive Heartbeat RTT Sampling
607
+
558
608
  Every background heartbeat ping/pong cycle automatically computes an RTT sample and updates latency stats. Opt out via `heartbeat: { trackRtt: false }`.
559
609
 
560
610
  Both heartbeat and on-demand samples emit the `rtt` event:
561
611
 
562
612
  ```ts
563
- client.on('rtt', ({ sample, source, stats }) => {
613
+ client.on("rtt", ({ sample, source, stats }) => {
564
614
  console.log(`RTT [${source}]: ${sample}ms (avg ${stats.mean.toFixed(1)}ms)`);
565
615
  });
566
616
  ```
@@ -571,15 +621,16 @@ client.on('rtt', ({ sample, source, stats }) => {
571
621
 
572
622
  The SDK provides fast, client-side pre-send validation to save network round-trips and provide immediate developer feedback:
573
623
 
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` |
624
+ | Constraint | Validation Rule | Checked On | Error Thrown |
625
+ | :------------------ | :--------------------------------------------------------- | :------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
626
+ | Room name syntax | `^[a-zA-Z0-9_-]{1,64}$` | `createRoom`, `joinRoom`, `deleteRoom`, `forgetRoom`, `publish`, `publishBinary` | `ValidationError` (`field: 'room'`, `rule: 'name_syntax'`) |
627
+ | Channel name syntax | `^[a-zA-Z0-9_-]{1,32}$` | `publish`, `publishBinary` | `ValidationError` (`field: 'channel'`, `rule: 'name_syntax'`) |
628
+ | Reserved channel | Cannot publish to `'presence'` | `publish`, `publishBinary` | `ValidationError` (`field: 'channel'`, `rule: 'reserved_channel'`) |
629
+ | Text payload size | Marshaled bytes $\le 65024$ (leaving 512B envelope budget) | `publish` | `ValidationError` (`field: 'payload'`, `rule: 'too_large'`) |
630
+ | Binary payload size | Raw bytes $\le 65535$ | `publishBinary` | `ValidationError` / `BinaryPayloadTooLargeError` |
581
631
 
582
632
  ### Bypassing Local Validation
633
+
583
634
  - Set `validation: { enabled: false }` in `ClientConfig` to disable local validation and allow invalid frames to reach the wire for negative testing.
584
635
  - Direct calls to `client.send(frame)` bypass local validation entirely.
585
636
 
@@ -597,7 +648,7 @@ Pre-flight checks fail synchronously before wire frames are dispatched and do **
597
648
 
598
649
  ---
599
650
 
600
- ## What the SDK does *not* do
651
+ ## What the SDK does _not_ do
601
652
 
602
653
  To preserve architectural clarity and adhere to browser constraints, the SDK explicitly does **not**:
603
654
 
@@ -615,12 +666,14 @@ To preserve architectural clarity and adhere to browser constraints, the SDK exp
615
666
  The SDK includes a comprehensive live conformance test suite running against real Go server instances using the `ws` library polyfill in Node.js.
616
667
 
617
668
  ### Running Live Conformance
669
+
618
670
  ```bash
619
671
  # Run the complete live conformance suite
620
672
  bash scripts/live-conformance-js.sh
621
673
  ```
622
674
 
623
675
  The runner:
676
+
624
677
  1. Compiles the Go server binary from `./cmd/server`.
625
678
  2. Starts three isolated test server instances:
626
679
  - Primary server on port `18080` (full capabilities, rate limits, timeouts).
@@ -629,3 +682,7 @@ The runner:
629
682
  3. Executes the Vitest live suite in `test/live/`.
630
683
  4. Performs graceful SIGTERM shutdown and verifies clean process exit.
631
684
 
685
+ ## Documentation
686
+
687
+ - [Migrating from raw WebSocket](MIGRATION.md) — a phased swap for existing
688
+ clients.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonytomo/gosocket-client",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Browser WebSocket client SDK for Universal WebSocket Server: reconnect, session restore, rate limiting, binary channels, presence, and capability pre-flight.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "files": [
44
44
  "dist",
45
45
  "README.md",
46
+ "MIGRATION.md",
46
47
  "CHANGELOG.md"
47
48
  ],
48
49
  "scripts": {