@tonytomo/gosocket-client 0.0.0-stage → 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.
- package/CHANGELOG.md +98 -0
- package/LICENSE.md +21 -0
- package/MIGRATION.md +366 -0
- package/README.md +687 -2
- package/dist/index.d.ts +880 -0
- package/dist/index.js +3060 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Browser WebSocket Client SDK (`@ws-server/ws-client`) will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.4.0] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added — JS SDK Phase J4 (Final Phase)
|
|
11
|
+
|
|
12
|
+
- **Error Classification & Recovery Policy (PROTOCOL.md §4.7)**:
|
|
13
|
+
- 18-row classification table mapping every protocol `ErrorCode` to SDK recovery actions (`fatal`, `drop_intent`, `app_prompt`, `retry_backoff`, `ignore`).
|
|
14
|
+
- Pure `classifyError(code, frame?, overrides?)` function supporting caller overrides via `ClientConfig.errorPolicy`.
|
|
15
|
+
- `errorClassified` event emitted immediately before raw `error` frames with code, action, retry delay, and optional raw frame.
|
|
16
|
+
- `AUTH_FAILED` ↔ `UNAUTHORIZED` unification: browser auth heuristic emits `errorClassified { code: 'UNAUTHORIZED', action: 'fatal' }` before emitting `fatal { code: 'AUTH_FAILED' }`.
|
|
17
|
+
- **Round-Trip Latency (RTT) Tracking & Ping API**:
|
|
18
|
+
- `client.ping(timeoutMs?)`: Explicit on-demand ping returning measured round-trip time in milliseconds, serialized to prevent interleaving.
|
|
19
|
+
- `client.rttStats()`: Latency summary (`last`, `min`, `max`, `mean` via exponential moving average $\alpha = 0.2$, `samples`), reset on every reconnect cycle and client close.
|
|
20
|
+
- Passive heartbeat RTT sampling enabled by default (opt-out via `heartbeat: { trackRtt: false }`).
|
|
21
|
+
- `rtt` event emitted with `{ sample, source: 'heartbeat' | 'on_demand', stats }`.
|
|
22
|
+
- **Local Pre-Send Validation (PROTOCOL.md §§3.1, 3.6)**:
|
|
23
|
+
- Strict validation of room names (`^[a-zA-Z0-9_-]{1,64}$`) and channel names (`^[a-zA-Z0-9_-]{1,32}$`).
|
|
24
|
+
- Rejection of reserved `'presence'` channel name for outbound message publishing.
|
|
25
|
+
- Maximum text payload limit (`maxTextPayloadBytes: 65024`, reserving 512 bytes for message envelope).
|
|
26
|
+
- Maximum binary payload limit (`maxBinaryPayloadBytes: 65535`).
|
|
27
|
+
- Client-side validation throwing `ValidationError` with field, rule, limit, and truncated value.
|
|
28
|
+
- Escape hatches: `validation: { enabled: false }` or direct `client.send(frame)` bypassing local validation to test raw wire frames.
|
|
29
|
+
- **Capability-Driven Pre-Flight Checks (PROTOCOL.md §8)**:
|
|
30
|
+
- Synchronous pre-flight checks in `createRoom()` against advertised `welcome.capabilities.limits`:
|
|
31
|
+
- `max_metadata_bytes`: JSON-encoded byte size check.
|
|
32
|
+
- `max_channels_per_room`: effective channel count check including `'presence'`, room preset channels, and custom channels.
|
|
33
|
+
- `max_rooms_per_key`: tracks owned rooms created on current connection, rejecting creation exceeding server limits before sending frames.
|
|
34
|
+
- Rejections throw `CapabilityLimitError` locally without sending wire frames or emitting `errorClassified`.
|
|
35
|
+
- **Live Conformance Suite & CI Runner**:
|
|
36
|
+
- Live conformance suite running in Vitest against real server binaries in `test/live/` with `ws` Node polyfill.
|
|
37
|
+
- Conformance test runner `scripts/live-conformance-js.sh` orchestrating primary (18080), binary-disabled (18081), and policy-violation (18082) server instances.
|
|
38
|
+
- GitHub Actions CI workflow running JS unit tests and live conformance suite.
|
|
39
|
+
|
|
40
|
+
## [0.3.0] - 2026-10-29
|
|
41
|
+
|
|
42
|
+
### Added — JS SDK Phase J3
|
|
43
|
+
|
|
44
|
+
- **Binary Channels (PROTOCOL.md §7)**:
|
|
45
|
+
- `BinaryMapBuilder` and `validateBinaryMap` ensuring channel IDs are integers in $[1, 254]$, rejecting reserved names (`""`, `"presence"`) and duplicate IDs/channels with construction-time validation.
|
|
46
|
+
- Global binary-ID uniqueness across rooms on a single connection (`binaryOwner` registry) with proactive pre-wire collision checks and `binaryIdCollision` event dispatch.
|
|
47
|
+
- Transactional reservation rollback when server join requests fail or reject.
|
|
48
|
+
- Inbound binary frame parsing using `ws.binaryType = 'arraybuffer'`, decoding 1-byte channel prefix and dispatching `binaryMessage` or `binaryUnknownChannel`.
|
|
49
|
+
- Binary publishing via `publishBinary` routing through reliable/unreliable lanes, shared token-bucket rate limiting, server backoff, and frame-type-distinct coalescing slots (`${room}:${channel}:binary`).
|
|
50
|
+
- **Presence Cache (PROTOCOL.md §6)**:
|
|
51
|
+
- Local cached roster tracking with typed events: `presenceJoin`, `presenceLeave`, `presenceInvalidated`, `roomDeleted`.
|
|
52
|
+
- Self-exclusion filtering in `knownMembers(room)` and `selfLabel` connection tracking from welcome frames.
|
|
53
|
+
- Server count tracking in `lastServerMemberCount(room)`.
|
|
54
|
+
- Protocol violation detection (`self presence event`, `unknown presence event`, `presence event missing who`, `malformed presence payload`, `empty binary frame`, `binary frame on presence channel`).
|
|
55
|
+
- Automatic reconnection invalidation marking presence caches stale (`invalidReason: 'reconnected'`) and requiring explicit `resetPresence(room)` to clear.
|
|
56
|
+
- **Sequence-Gap Detection (PROTOCOL.md §5.6)**:
|
|
57
|
+
- Per-`(room, channel)` monotonic sequence number tracking across text message frames.
|
|
58
|
+
- Forward sequence gap detection emitting `sequenceGap` with `prev`, `current`, `missed`, and `reliable` lane flag.
|
|
59
|
+
- Silent re-baselining on reconnection, backward sequences (e.g. room re-creation), duplicate sequences, and sequence 0.
|
|
60
|
+
- Optional `autoInvalidateOnReliableGap` configuration invalidating room presence caches when a sequence gap occurs on the reliable `presence` channel.
|
|
61
|
+
- **Delete Room Ackless Semantics**:
|
|
62
|
+
- `deleteRoom` resolves cleanly on incoming `room_deleted` presence frames with caller identification, removing room intents and executing complete state cleanup via `clearRoomState(room)`.
|
|
63
|
+
- **Comprehensive Cleanup Hooks (`clearRoomState`)**:
|
|
64
|
+
- Centralized eviction of binary owners, presence states, sequence states, rate limiters, coalescing slots, and applied policies upon room leave, delete, or session restore drop.
|
|
65
|
+
- **New Events & Errors**:
|
|
66
|
+
- Events: `binaryMessage`, `binaryUnknownChannel`, `binaryIdCollision`, `binaryMappingChanged`, `presenceJoin`, `presenceLeave`, `presenceInvalidated`, `roomDeleted`, `sequenceGap`, `protocolViolation`.
|
|
67
|
+
- Errors: `BinaryMapValidationError`, `BinaryMapExhaustedError`, `BinaryIdCollisionError`, `BinaryChannelUnmappedError`, `BinaryPayloadTooLargeError`.
|
|
68
|
+
|
|
69
|
+
## [0.2.0] - 2026-10-29
|
|
70
|
+
|
|
71
|
+
### Added — JS SDK Phase J2
|
|
72
|
+
|
|
73
|
+
- **Two-Lane Outbound Pipeline**: Reliable lane (bounded FIFO, drop-newest or close on overflow) and Unreliable lane (bounded FIFO, drop-oldest) matching server delivery semantics in PROTOCOL.md §5.4.
|
|
74
|
+
- **Channel Policy System**: `ChannelPolicy`, `PolicyProvider`, and `presetsFor(kind, overrides)` reproducing PROTOCOL.md §5.2 room presets for `chat`, `game`, and `custom` room templates.
|
|
75
|
+
- **Client-Side Token Bucket Rate Limiting**: Per `(room, channel)` limiters enforcing `rate = maxRate` and `burst = max(2, maxRate * 2)` to cooperatively preempt server rate violations.
|
|
76
|
+
- **Inbound RATE_LIMITED Backoff Correlation**: Reader pauses local publishing on channels receiving server `RATE_LIMITED` frames, inferring target rooms from recent publish tracking.
|
|
77
|
+
- **Message Coalescing**: Outbound state coalescing on unreliable channels with `'timer'` and browser-native `'raf'` (`requestAnimationFrame`) drivers and customizable `merge` functions.
|
|
78
|
+
- **Writer Loop Scheduler**: Single async event loop writer pump with burst draining (reliable 32, unreliable 8) and microtask yields (`await Promise.resolve()`) per frame to avoid blocking UI rendering.
|
|
79
|
+
- **Transport Backpressure Management**: Cooperative monitoring of `WebSocket.bufferedAmount` with high ($1 \text{ MiB}$) and low ($512 \text{ KiB}$) watermarks and hysteresis, pausing unreliable flushes under saturation.
|
|
80
|
+
- **Generation-Scoped Queues**: Outbound queues discard pending frames on reconnect to prevent stale message delivery contradicting restored room states, emitting `queuesDiscarded` before session restore begins.
|
|
81
|
+
- **Public API Additions**: Added `publish(req)`, `setChannelPolicy(room, channel, policy)`, `queueDepth()`, `bufferedAmount()`, and test seam `__flushNow()`.
|
|
82
|
+
- **New Events & Errors**: Added events (`rateLimited`, `reliableOverflow`, `unreliableDropped`, `coalesced`, `queuesDiscarded`, `backpressure`, `policyApplied`) and errors (`NotReadyError`, `ChannelNotReliableError`, `RateLimitedError`).
|
|
83
|
+
|
|
84
|
+
## [0.1.0] - 2026-10-29
|
|
85
|
+
|
|
86
|
+
### Added — JS SDK Phase J1
|
|
87
|
+
|
|
88
|
+
- **Connection Lifecycle State Machine**: Full lifecycle management covering `idle`, `connecting`, `awaiting_welcome`, `ready`, `reconnecting`, and `closed` states with typed event notifications.
|
|
89
|
+
- **Browser-Native Authentication**: Query parameter authentication (`?key=...`) with mandatory key redaction in logs and error frames.
|
|
90
|
+
- **401 Detection Heuristic**: Intelligent classification of browser-masked abnormal close (`1006`) errors into `AUTH_FAILED` fatal halts versus retryable network failures, supporting `'heuristic'`, `'never-fatal'`, and `'always-fatal'` modes alongside an `onAmbiguousAuth` callback.
|
|
91
|
+
- **Exponential Reconnection**: Full-jitter, equal-jitter, and non-jitter exponential backoff with configurable base delays, maximum ceilings, retry ceilings, and `shouldReconnect` overrides.
|
|
92
|
+
- **Serialized Session Restore**: Automatic rejoining of active room memberships upon reconnection in alphabetical room order, handling `ROOM_NOT_FOUND`, `FORBIDDEN_SCOPE`, `INVALID_PASSCODE`, `ROOM_FULL` (with `'drop'` and `'retry'` policies), and per-room deadlines.
|
|
93
|
+
- **Heartbeat Management**: Application-level ping/pong liveness verification with configurable intervals and timeouts, forcing connection resets on missed deadlines.
|
|
94
|
+
- **Capabilities Discovery**: Caching of server `welcome.capabilities` preserving S2 `null`-means-disabled semantics and emitting `capabilitiesChanged` on server version or capacity upgrades.
|
|
95
|
+
- **Core Protocol API**: Async convenience wrappers for `createRoom`, `joinRoom`, `leaveRoom`, `deleteRoom`, `listRooms`, and low-level `send`.
|
|
96
|
+
- **Error Hierarchy**: ES2022 error taxonomy rooted at `SdkError`, including `AuthError`, `ConnectError`, `PolicyViolationError`, `RequestTimeoutError`, `ConcurrentRequestError`, and `ServerError`.
|
|
97
|
+
- **Transport & Clock Abstractions**: Dependency-injected `Transport` and `Clock` interfaces powering deterministic zero-wall-clock test suites with 100% fake-timer execution.
|
|
98
|
+
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tony Tomo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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.
|