@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.
- package/MIGRATION.md +366 -0
- package/README.md +121 -64
- 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
|
-
# @
|
|
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
|
|
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:
|
|
79
|
-
apiKey:
|
|
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 ?
|
|
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:
|
|
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
|
|
334
|
+
import { BinaryMapBuilder } from "gosocket-client";
|
|
307
335
|
|
|
308
336
|
const binMap = new BinaryMapBuilder()
|
|
309
|
-
.set(
|
|
310
|
-
.set(
|
|
311
|
-
.autoSet(
|
|
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 =
|
|
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
|
|
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
|
|
453
|
+
import { Client, BinaryMapBuilder, presetsFor } from "gosocket-client";
|
|
411
454
|
|
|
412
455
|
const client = new Client({
|
|
413
|
-
url:
|
|
414
|
-
apiKey:
|
|
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(
|
|
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(
|
|
474
|
+
client.on("binaryMessage", ({ room, channel, payload }) => {
|
|
435
475
|
// payload is a Uint8Array
|
|
436
|
-
console.log(
|
|
476
|
+
console.log(
|
|
477
|
+
`Binary frame on ${room}/${channel} (${payload.byteLength} bytes)`,
|
|
478
|
+
);
|
|
437
479
|
});
|
|
438
480
|
|
|
439
481
|
// Presence events
|
|
440
|
-
client.on(
|
|
482
|
+
client.on("presenceJoin", ({ room, who }) => {
|
|
441
483
|
console.log(`Peer ${who} joined ${room}`);
|
|
442
|
-
console.log(
|
|
484
|
+
console.log("Known roster:", client.knownMembers(room));
|
|
443
485
|
});
|
|
444
486
|
|
|
445
|
-
client.on(
|
|
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(
|
|
451
|
-
console.warn(
|
|
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:
|
|
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:
|
|
464
|
-
channel:
|
|
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(
|
|
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
|
|
481
|
-
|
|
|
482
|
-
| `UNAUTHORIZED`
|
|
483
|
-
| `FORBIDDEN_SCOPE`
|
|
484
|
-
| `NOT_ROOM_OWNER`
|
|
485
|
-
| `BAD_REQUEST`
|
|
486
|
-
| `ROOM_NOT_FOUND`
|
|
487
|
-
| `ROOM_ALREADY_EXISTS`
|
|
488
|
-
| `INVALID_PASSCODE`
|
|
489
|
-
| `ROOM_FULL`
|
|
490
|
-
| `PASSCODE_RATE_LIMITED` | `retry_backoff` | `retry_after_ms` (fallback 60 s)
|
|
491
|
-
| `MAX_ROOMS_REACHED`
|
|
492
|
-
| `BINARY_NOT_ALLOWED`
|
|
493
|
-
| `CHANNEL_NOT_RELIABLE`
|
|
494
|
-
| `CHANNEL_NOT_FOUND`
|
|
495
|
-
| `RESERVED_CHANNEL_NAME` | `ignore`
|
|
496
|
-
| `CHANNEL_READ_ONLY`
|
|
497
|
-
| `RATE_LIMITED`
|
|
498
|
-
| `INTERNAL_ERROR`
|
|
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:
|
|
515
|
-
apiKey:
|
|
559
|
+
url: "wss://example.com/ws",
|
|
560
|
+
apiKey: "k_123",
|
|
516
561
|
errorPolicy: {
|
|
517
562
|
overrides: {
|
|
518
|
-
INTERNAL_ERROR: { action:
|
|
519
|
-
ROOM_NOT_FOUND: { action:
|
|
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(
|
|
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(
|
|
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
|
|
575
|
-
|
|
|
576
|
-
| Room name syntax
|
|
577
|
-
| Channel name syntax | `^[a-zA-Z0-9_-]{1,32}$`
|
|
578
|
-
| Reserved channel
|
|
579
|
-
| Text payload size
|
|
580
|
-
| Binary payload size | Raw bytes $\le 65535$
|
|
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
|
|
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.
|
|
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": {
|