@marianmeres/ws 0.3.0 → 0.4.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/AGENTS.md CHANGED
@@ -43,11 +43,13 @@ example/ # standalone reference app — `deno task example`
43
43
  ├── server.ts # demino apps: /ws (createWSApp), /api, / (static)
44
44
  ├── history.ts # WSPubSubAdapter decorator -> in-memory backlog
45
45
  ├── shared.ts # the APPLICATION protocol (what goes in `payload`)
46
+ ├── build-styles.ts # generates public/theme.css, copies vui-base.css
46
47
  ├── client/ # @marianmeres/vanilla views + store, bundled by
47
48
  │ # @marianmeres/deno-build to public/dist/bundle.js
48
- └── public/ # index.html + design-tokens CSS (theme.css is
49
- # generated by build-theme.ts and IS committed;
50
- # dist/ is not)
49
+ └── public/ # index.html + CSS. theme.css (design tokens, prefix
50
+ # "vui-") and vui-base.css (@marianmeres/vanilla-ui's
51
+ # base style layer, copied verbatim) are generated by
52
+ # build-styles.ts and ARE committed; dist/ is not
51
53
  ```
52
54
 
53
55
  `src/server.ts` and `src/protocol.ts` exist because the npm build maps subpath
@@ -73,9 +75,24 @@ not registered yet. There is a test that fails if you break it
73
75
  **`sub`/`unsub` bypass the outbox.** They are replayed wholesale by the
74
76
  re-subscribe step, so buffering them too would apply them twice.
75
77
 
78
+ **A control-frame promise the client does not await must carry a `.catch()`.**
79
+ `#sendControl` tracks the frame in the outbox, so its promise rejects on dispose,
80
+ on a terminal close and on the send timeout. An unawaited one — the unsubscriber's
81
+ `unsub`, the `#onHello` re-subscribe — is an uncaught rejection that exits a Deno
82
+ or Node process.
83
+
84
+ **The server validates every frame field before it uses it, and wraps the whole
85
+ dispatch.** A malformed but parseable frame is a `nack`/`error` `bad_request`
86
+ and the socket stays open; an unexpected throw is `error` `internal` and a 1011
87
+ close. Both paths, including the async one, end in a `.catch()` — a handler that
88
+ throws must never be able to take the process down.
89
+
76
90
  **Every send carries one deadline** spanning queue + flight + ack — not an
77
91
  ack-only timeout. Combining acks with infinite retry otherwise produces promises
78
- that pend forever.
92
+ that pend forever. And a socket close settles in-flight frames at once, rather
93
+ than waiting out a deadline for an answer that can no longer arrive: `sub` and
94
+ `unsub` resolve, everything else rejects with `WSConnectionLostError`. Queued
95
+ frames are untouched — they are still waiting for a connection, not an ack.
79
96
 
80
97
  **The pong deadline is not restarted by later pings.** It measures time since
81
98
  the _oldest_ unanswered ping. Restarting it means any `pingInterval <=
@@ -87,7 +104,13 @@ checks its captured generation. Without it a slow `onclose` from a dead socket
87
104
  cancels the reconnect that replaced it.
88
105
 
89
106
  **Safe defaults are deny.** `allowBroadcast` denies; HTTP injection routes are
90
- not mounted without `httpAuth`. Do not "helpfully" relax either.
107
+ not mounted without `httpAuth`. Do not "helpfully" relax either. The documented
108
+ exception: `clientId` and `namespace` fall back to what the client asked for
109
+ (assigned → requested → generated), so a multi-tenant `verify` must return both.
110
+ Its third argument carries the client's proposals so they can be validated there
111
+ instead of being duplicated into the auth payload. `allowedOrigins` is the other
112
+ exception — opt-in, because a default allow-list would break every non-browser
113
+ deployment; unset means no check at all.
91
114
 
92
115
  **Delivery is at-most-once.** Transmitted-but-unacked sends are never resent.
93
116
  If that changes, the server needs deduplication first.
@@ -122,7 +145,9 @@ run `deno publish` then the npm build.
122
145
 
123
146
  ## Before Making Changes
124
147
 
125
- 1. `deno task test` — 39 tests, all real sockets against a real server
148
+ 1. `deno task test` — 66 tests, mostly real sockets against a real server:
149
+ `unit`, `integration`, `resilience`, `protocol` (server input hardening,
150
+ raw sockets) and `codec` (custom encode/decode, binary frames)
126
151
  2. `deno lint && deno fmt --check && deno check src/mod.ts src/server.ts src/protocol.ts`
127
152
  3. Touched a public signature? `deno doc --lint src/mod.ts src/server.ts
128
153
  src/protocol.ts` **and** `deno publish --dry-run --allow-dirty`
@@ -146,14 +171,16 @@ run `deno publish` then the npm build.
146
171
  - Presence is scoped to `(room, namespace)`; broadcast crosses namespaces but
147
172
  presence does not
148
173
  - Node/Bun cannot run the server (`Deno.upgradeWebSocket`)
174
+ - Origin checking is opt-in via `allowedOrigins`; unset means no check
149
175
 
150
176
  ## Documentation Index
151
177
 
152
- | Document | Purpose |
153
- | ------------------- | ------------------------------------------------ |
154
- | `README.md` | Human-facing overview and usage |
155
- | `API.md` | Complete API reference — every public export |
156
- | `example/README.md` | The reference app: what it demonstrates, and how |
178
+ | Document | Purpose |
179
+ | ------------------- | ------------------------------------------------------------- |
180
+ | `README.md` | Human-facing overview and usage |
181
+ | `API.md` | Complete API reference — every public export |
182
+ | `PROTOCOL.md` | Wire protocol spec + Python server, for other implementations |
183
+ | `example/README.md` | The reference app: what it demonstrates, and how |
157
184
 
158
185
  `tmp/spec.md` (design spec and decision log) is referenced in some commits but
159
186
  is **untracked** — `tmp/*` is gitignored, so it does not exist in a fresh clone.
package/API.md CHANGED
@@ -42,6 +42,11 @@ following the `PubSub` / `createPubSub` precedent.
42
42
  | `onOutboxDrop` | `(frames: ClientFrame[]) => void` | — | Called with evicted frames |
43
43
  | `encode` / `decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the server's |
44
44
 
45
+ `pingInterval: 0` disables the client's heartbeat, not the server's reaper: the
46
+ reference server still closes a connection that sent nothing for `idleTimeout`
47
+ (60 s) with `4008`, so a heartbeat-free client reconnects roughly every minute.
48
+ Disable both or neither.
49
+
45
50
  **Returns** `WSClient`
46
51
 
47
52
  **Example**
@@ -84,10 +89,14 @@ gate, not a prerequisite.
84
89
 
85
90
  Rejects **only** where retrying cannot help:
86
91
 
87
- - `WSTerminatedError` — a terminal close code
92
+ - `WSTerminatedError` — a terminal close code, or code `4900` when
93
+ `disconnect()` (or `dispose()`, which disconnects first) is called while this
94
+ is still pending
88
95
  - `WSConnectTimeoutError` — `connectTimeout` elapsed. Retrying continues in the
89
96
  background, so this bounds _your await_, not the connection attempt
90
- - `WSDisposedError` — the client was disposed
97
+ - `WSDisposedError` — called on an already disposed client. A `dispose()`
98
+ _during_ a pending connect settles it with the `4900` `WSTerminatedError`
99
+ above, not with this
91
100
 
92
101
  Ordinary network failure never rejects; that is what the infinite retry is for.
93
102
 
@@ -97,6 +106,10 @@ Stops retrying and closes the socket. **Resumable** — handlers, room
97
106
  subscriptions and buffered sends all survive, so a later `connect()` picks up
98
107
  where it left off.
99
108
 
109
+ Emits `close` with code `4900` (`CLOSE.CLIENT_GONE`) and `willReconnect: false`
110
+ when there was a socket to close; nothing when already idle, reconnecting or
111
+ terminated.
112
+
100
113
  ##### `dispose(): void`
101
114
 
102
115
  Terminal teardown: disconnects, then drops every handler, room, timer and
@@ -162,13 +175,20 @@ Publishes to a room within this client's namespace. Resolves with the recipient
162
175
  count once the server acknowledges.
163
176
 
164
177
  While disconnected the frame is buffered and the promise stays pending until it
165
- flushes — bounded by `sendTimeout`, never indefinitely.
178
+ flushes — bounded by `sendTimeout`, never indefinitely. A frame already in
179
+ flight when the socket closes rejects there and then with
180
+ `WSConnectionLostError`; it is not resent. After a terminal close nothing is
181
+ buffered at all: the promise rejects immediately with `WSTerminatedError`,
182
+ because only an explicit `connect()` leaves that state.
166
183
 
167
184
  `namespace` must equal the client's own; the server rejects anything else, so it
168
185
  is only useful for asserting the expected one.
169
186
 
170
- **Throws** `WSTimeoutError`, `WSOutboxDropError`, `WSNotConnectedError`,
171
- `WSRemoteError`, `WSDisposedError`
187
+ A payload `encode` refuses — a `BigInt` is enough for the JSON default — rejects
188
+ with the encoder's own error, unwrapped, and also surfaces as an `error` event.
189
+
190
+ **Throws** `WSTimeoutError`, `WSConnectionLostError`, `WSOutboxDropError`,
191
+ `WSNotConnectedError`, `WSTerminatedError`, `WSRemoteError`, `WSDisposedError`
172
192
 
173
193
  ##### `broadcast<T>(room, payload): Promise<WSPublishResult>`
174
194
 
@@ -277,6 +297,9 @@ stop delivery to the others.
277
297
  `error` means something failed but the client carried on (a decode failure, a
278
298
  throwing handler). `terminated` is the only non-retrying exit.
279
299
 
300
+ A local `disconnect()` is a `close` too: code `4900`, `willReconnect: false` —
301
+ that pair is how a deliberate teardown is told apart from a lost connection.
302
+
280
303
  ### `WSState`
281
304
 
282
305
  ```typescript
@@ -319,34 +342,36 @@ Creates a mountable demino app plus the service it is wired to.
319
342
 
320
343
  **Parameters**
321
344
 
322
- | Name | Type | Default | Description |
323
- | ---------------------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ |
324
- | `mountPath` | `string` | `"/ws"` | Demino mount path |
325
- | `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
326
- | `options.verify` | `(payload, req) => AuthResult \| null` | — | Return `null` (or throw) to reject with `4001`. Absent means no authentication |
327
- | `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
328
- | `options.httpAuth` | `DeminoHandler` | — | Guards the HTTP routes. **Without it they are not mounted** |
329
- | `options.deminoOptions` | `DeminoOptions` | — | Passed through to `demino()` |
330
- | `options.authTimeout` | `number` | `5_000` | Deadline for the `auth` frame → `4002` |
331
- | `options.idleTimeout` | `number` | `60_000` | Reap silent connections → `4008` |
332
- | `options.maxFrameSize` | `number` | `262144` | Oversized frames → `4013` |
333
- | `options.maxFramesPerSecond` | `number` | `100` | Rate cap → `4009` |
334
- | `options.adapter` | `WSPubSubAdapter` | `WSPubSubLocal` | Cross-instance fan-out |
335
- | `options.logger` | `Logger \| null` | `createClog("ws:server")` | `null` silences |
336
- | `options.encode` / `.decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the client's |
345
+ | Name | Type | Default | Description |
346
+ | ---------------------------- | ------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
347
+ | `mountPath` | `string` | `"/ws"` | Demino mount path |
348
+ | `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
349
+ | `options.verify` | `(payload, req, requested) => AuthResult \| null` | — | Return `null` (or throw) to reject with `4001`. Absent means no authentication. `requested` is the identity the client asked for — see [below](#security-namespace-isolation) |
350
+ | `options.allowedOrigins` | `string[] \| (origin, req) => boolean` | — (no check) | Origins allowed to upgrade → `403` — see [below](#security-cross-site-websocket-hijacking) |
351
+ | `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
352
+ | `options.httpAuth` | `DeminoHandler` | — | Guards the HTTP routes. **Without it they are not mounted** |
353
+ | `options.deminoOptions` | `DeminoOptions` | | Passed through to `demino()` |
354
+ | `options.authTimeout` | `number` | `5_000` | Deadline for the `auth` frame → `4002` |
355
+ | `options.idleTimeout` | `number` | `60_000` | Reap silent connections → `4008` |
356
+ | `options.maxFrameSize` | `number` | `262144` | Oversized frames → `4013` |
357
+ | `options.maxFramesPerSecond` | `number` | `100` | Rate cap → `4009` |
358
+ | `options.adapter` | `WSPubSubAdapter` | `WSPubSubLocal` | Cross-instance fan-out |
359
+ | `options.logger` | `Logger \| null` | `createClog("ws:server")` | `null` silences |
360
+ | `options.encode` / `.decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the client's |
337
361
 
338
362
  **Returns** `WSApp` — `{ app: Demino, service: WSService }`
339
363
 
340
364
  **Routes**, relative to `mountPath`:
341
365
 
342
- | Method | Path | Returns | Notes |
343
- | ------ | ----------------------------- | --------------------------- | ----------------------------------------- |
344
- | GET | `/` | 101, or 426 without upgrade | WebSocket upgrade |
345
- | GET | `/stats` | `WSStats` | Guarded by `httpAuth` when supplied |
346
- | POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
347
- | POST | `/broadcast/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
366
+ | Method | Path | Returns | Notes |
367
+ | ------ | ----------------------------- | -------------------------- | ----------------------------------------- |
368
+ | GET | `/` | 101, 426 without upgrade | WebSocket upgrade, `403` on a bad origin |
369
+ | GET | `/stats` | `WSStats` | Requires `httpAuth`, else **not mounted** |
370
+ | POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
371
+ | POST | `/broadcast/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
348
372
 
349
- The POST routes take the JSON request body as the message payload.
373
+ The POST routes take the JSON request body as the message payload; a body that
374
+ is not valid JSON answers `400`.
350
375
 
351
376
  **Example**
352
377
 
@@ -367,6 +392,63 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
367
392
  Deno.serve(app);
368
393
  ```
369
394
 
395
+ #### Security: namespace isolation
396
+
397
+ Namespace is the isolation boundary and `clientId` is the identity peers see in
398
+ `from` — and a claimed id evicts whoever holds it. Both fall back to what the
399
+ client asked for:
400
+
401
+ ```
402
+ assigned by verify → requested by the client → generated
403
+ ```
404
+
405
+ **In any multi-tenant deployment `verify` must return `namespace` and
406
+ `clientId`.** Return neither and the client's proposals are honoured verbatim,
407
+ so any authenticated user can enter any tenant. The third argument,
408
+ [`WSRequestedIdentity`](#wsrequestedidentity), carries those proposals, so they
409
+ can be validated there rather than duplicated into the auth payload:
410
+
411
+ ```typescript
412
+ createWSApp("/ws", [], {
413
+ verify: async (payload, req, requested) => {
414
+ const user = await authenticate((payload as any)?.token);
415
+ if (!user) return null;
416
+ // The namespace is checked, not trusted — and assigned either way.
417
+ if (!user.orgs.includes(requested.namespace)) return null;
418
+ return { clientId: user.id, namespace: requested.namespace };
419
+ },
420
+ });
421
+ ```
422
+
423
+ #### Security: cross-site WebSocket hijacking
424
+
425
+ The upgrade request is an ordinary browser request, so the browser attaches its
426
+ cookies for your origin no matter which site opened the socket. A `verify` that
427
+ authenticates from cookies therefore authenticates the attacker's page too —
428
+ same-origin policy does not apply to WebSockets, and there is no preflight.
429
+
430
+ `allowedOrigins` closes that, and is **opt-in**: unset, nothing is checked.
431
+
432
+ ```typescript
433
+ createWSApp("/ws", [], {
434
+ allowedOrigins: ["https://app.example"],
435
+ verify: (_payload, req) => sessionFromCookie(req),
436
+ });
437
+ ```
438
+
439
+ An unlisted `Origin` is answered `403 Origin not allowed` and never reaches
440
+ `verify`. The array form **permits a missing `Origin`**, because only browsers
441
+ send the header and non-browser clients (the stock Deno client included) send
442
+ none — the check exists to stop browsers. Pass a function instead when that is
443
+ too lax, or when the allowed set is dynamic:
444
+
445
+ ```typescript
446
+ allowedOrigins: (origin, req) => origin !== null && isTenantOrigin(origin),
447
+ ```
448
+
449
+ Token authentication in the `auth` payload is not exposed this way: another
450
+ site's page cannot read your token, only ride your cookies.
451
+
370
452
  ---
371
453
 
372
454
  ### `WSService`
@@ -378,7 +460,8 @@ Owns every connection, the room index, presence and delivery. Usable standalone
378
460
  ##### `handleUpgrade(request): Response`
379
461
 
380
462
  Upgrades an HTTP request and takes ownership of the socket. Return the 101
381
- response from your route handler unmodified.
463
+ response from your route handler unmodified. With `allowedOrigins` set, a
464
+ disallowed request is answered `403` instead and nothing is upgraded.
382
465
 
383
466
  ##### `publish(room, payload, namespace?, from?): Promise<number>`
384
467
 
@@ -402,8 +485,10 @@ Instance-local.
402
485
 
403
486
  ##### `stats(): WSStats`
404
487
 
405
- Counts only, never client ids, so it stays safe to expose unguarded in
406
- development.
488
+ Counts only, never client ids but `namespaces` is keyed by namespace name, so
489
+ in a multi-tenant deployment it enumerates the tenants that are online. Hence
490
+ the `/stats` route only exists behind `httpAuth`; this method is for in-process
491
+ use.
407
492
 
408
493
  ##### `close(): Promise<void>`
409
494
 
@@ -557,6 +642,19 @@ What the server's `verify()` hook returns. `null` rejects the connection.
557
642
  }
558
643
  ```
559
644
 
645
+ ### `WSRequestedIdentity`
646
+
647
+ The third argument to `verify()`: what the client proposed in its `auth` frame.
648
+ Hints, not facts — see
649
+ [Security: namespace isolation](#security-namespace-isolation).
650
+
651
+ ```typescript
652
+ {
653
+ clientId?: string; // absent unless the frame carried a usable one
654
+ namespace: string; // DEFAULT_NAMESPACE when the frame carried none
655
+ }
656
+ ```
657
+
560
658
  ### `WSErrorInfo`
561
659
 
562
660
  ```typescript
@@ -615,6 +713,7 @@ string-matching messages.
615
713
  | `WSTerminatedError` | Terminal close code | `code`, `reason` |
616
714
  | `WSConnectTimeoutError` | `connectTimeout` elapsed (retrying continues) | |
617
715
  | `WSTimeoutError` | `sendTimeout` elapsed with no acknowledgement | |
716
+ | `WSConnectionLostError` | Socket closed while the frame was in flight | |
618
717
  | `WSOutboxDropError` | Evicted from a full outbox | |
619
718
  | `WSRemoteError` | Server sent a `nack` | `code` |
620
719
  | `WSNotConnectedError` | Sent while disconnected with `outboxMaxSize: 0` | |
package/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  A WebSocket client with namespaces, rooms, presence and reconnect that actually
8
8
  survives real networks — plus a mountable reference server implementing the same
9
- protocol.
9
+ [protocol](./PROTOCOL.md).
10
10
 
11
11
  ## Features
12
12
 
13
13
  - **Reconnects forever** — capped exponential backoff with jitter, plus instant
14
- retry when the browser comes back online or the tab regains focus
14
+ retry when the browser comes back online or the tab becomes visible again
15
15
  - **Detects half-open connections** — the failure where the peer vanishes, no
16
16
  `onclose` ever fires, and a naive client sits "connected" receiving nothing
17
17
  - **Namespaces and rooms** — namespace isolates, rooms are channels within it
@@ -128,10 +128,12 @@ ws.members("room"); // last known membership
128
128
  import { createWSApp } from "@marianmeres/ws/server";
129
129
 
130
130
  const { app, service } = createWSApp("/ws", [], {
131
- verify: async (payload, req) => {
131
+ // `requested` is what the client asked for — hints, never facts.
132
+ verify: async (payload, req, requested) => {
132
133
  const user = await authenticate(payload?.token);
133
134
  // Returning null closes the socket with a terminal code.
134
- return user ? { clientId: user.id, namespace: user.orgId } : null;
135
+ if (!user || !user.orgs.includes(requested.namespace)) return null;
136
+ return { clientId: user.id, namespace: requested.namespace };
135
137
  },
136
138
  });
137
139
 
@@ -141,12 +143,21 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
141
143
  Deno.serve(app);
142
144
  ```
143
145
 
146
+ Namespace is the isolation boundary, and it falls back to what the client asked
147
+ for when `verify` returns none — so in a multi-tenant deployment `verify` must
148
+ return `namespace` and `clientId`, validating `requested` rather than trusting
149
+ it.
150
+
151
+ If `verify` authenticates from cookies, set `allowedOrigins` as well: browsers
152
+ attach cookies to a WebSocket opened from any site, and without an `Origin`
153
+ check that is cross-site WebSocket hijacking.
154
+
144
155
  Mounted routes, relative to the mount path:
145
156
 
146
157
  | Method | Path | Notes |
147
158
  | ------ | ----------------------------- | ----------------------------------------- |
148
159
  | GET | `/` | WebSocket upgrade |
149
- | GET | `/stats` | Guarded by `httpAuth` when supplied |
160
+ | GET | `/stats` | Requires `httpAuth`, else **not mounted** |
150
161
  | POST | `/publish/[namespace]/[room]` | Requires `httpAuth`, else **not mounted** |
151
162
  | POST | `/broadcast/[room]` | Requires `httpAuth`, else **not mounted** |
152
163
 
@@ -177,6 +188,12 @@ method rather than a flag on `publish()` precisely because crossing an isolation
177
188
  boundary deserves its own name and its own server-side check: `allowBroadcast`
178
189
  **denies by default**.
179
190
 
191
+ **Frame vs message** — a _frame_ is one JSON protocol envelope (`auth`, `sub`,
192
+ `pub`, `msg`, `ack`, …), carried in exactly one WebSocket text message — not an
193
+ RFC 6455 fragment. A _message_ is the application-level object a `msg` frame
194
+ delivers (`room`, `namespace`, `from`, `payload`, `timestamp`) — a `msg` frame
195
+ minus its `type` field _is_ a `WSMessage`, which is what your handlers receive.
196
+
180
197
  ## Behaviour worth knowing
181
198
 
182
199
  **Reconnect classification.** Everything reconnects except a local
@@ -191,8 +208,9 @@ silent one would be indistinguishable from a network that never recovered.
191
208
 
192
209
  **Delivery is at-most-once.** A publish that was transmitted but unacknowledged
193
210
  when the socket died is _not_ resent — that would risk duplicates, and the
194
- server has no deduplication. It rejects on `sendTimeout`. At-least-once would
195
- need server-side replay, which this version does not do.
211
+ server has no deduplication. It rejects immediately with `WSConnectionLostError`
212
+ rather than waiting out `sendTimeout`: the answer is already known at the close.
213
+ At-least-once would need server-side replay, which this version does not do.
196
214
 
197
215
  **Sends are bounded.** Every publish carries one deadline covering queue, flight
198
216
  _and_ acknowledgement. Without it, a publish issued while offline would pend
@@ -206,6 +224,13 @@ it left off.
206
224
 
207
225
  See [API.md](API.md) for complete API documentation.
208
226
 
227
+ ## Protocol
228
+
229
+ The reference server is Deno-only; the protocol is not.
230
+ [PROTOCOL.md](PROTOCOL.md) specifies the wire format frame by frame — with a
231
+ complete Python implementation and a conformance script that drives the real
232
+ client — so a compatible server can be written in any language.
233
+
209
234
  ## License
210
235
 
211
236
  [MIT](LICENSE)
@@ -58,8 +58,16 @@ export declare class Outbox {
58
58
  */
59
59
  failAll(error: Error): void;
60
60
  /**
61
- * Rejects only the frames still waiting to be transmitted, leaving
62
- * in-flight ones to their acks or timeouts.
61
+ * Settles every already-transmitted frame, leaving the queued ones to wait
62
+ * for the next connection.
63
+ *
64
+ * Used when the socket closes: the ack can no longer arrive, because the
65
+ * socket that would have carried it is gone and the next one is a new
66
+ * session. Waiting out `sendTimeout` would only delay an answer that is
67
+ * already known.
68
+ *
69
+ * @param decide - per frame: the error to reject with, or `null` to resolve
70
+ * it with no recipients
63
71
  */
64
- failQueued(error: Error): void;
72
+ settleInFlight(decide: (frame: ClientFrame) => Error | null): void;
65
73
  }
@@ -104,14 +104,28 @@ export class Outbox {
104
104
  }
105
105
  }
106
106
  /**
107
- * Rejects only the frames still waiting to be transmitted, leaving
108
- * in-flight ones to their acks or timeouts.
107
+ * Settles every already-transmitted frame, leaving the queued ones to wait
108
+ * for the next connection.
109
+ *
110
+ * Used when the socket closes: the ack can no longer arrive, because the
111
+ * socket that would have carried it is gone and the next one is a new
112
+ * session. Waiting out `sendTimeout` would only delay an answer that is
113
+ * already known.
114
+ *
115
+ * @param decide - per frame: the error to reject with, or `null` to resolve
116
+ * it with no recipients
109
117
  */
110
- failQueued(error) {
111
- const ids = this.#queue;
112
- this.#queue = [];
113
- for (const id of ids)
114
- this.fail(id, error);
118
+ settleInFlight(decide) {
119
+ for (const [id, entry] of [...this.#pending]) {
120
+ if (entry.queued)
121
+ continue;
122
+ const error = decide(entry.frame);
123
+ this.#discard(id);
124
+ if (error)
125
+ entry.reject(error);
126
+ else
127
+ entry.resolve({ recipients: 0 });
128
+ }
115
129
  }
116
130
  #discard(id) {
117
131
  const entry = this.#pending.get(id);
@@ -27,7 +27,10 @@ export interface WSEvents {
27
27
  message: WSMessage;
28
28
  /** Membership change in a room subscribed with presence enabled. */
29
29
  presence: WSPresenceEvent;
30
- /** Socket closed. `willReconnect` reflects the retry classification. */
30
+ /**
31
+ * Socket closed. `willReconnect` reflects the retry classification, and is
32
+ * `false` for the `4900` a local `disconnect()` emits.
33
+ */
31
34
  close: {
32
35
  code: number;
33
36
  reason: string;
@@ -229,6 +232,10 @@ export declare class WSClient<TAuth = unknown> {
229
232
  * Resumable: handlers, room subscriptions and buffered sends all survive,
230
233
  * so a later `connect()` picks up exactly where this left off. Use
231
234
  * {@link dispose} for terminal teardown.
235
+ *
236
+ * Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
237
+ * when there was a socket to close; nothing when already idle, reconnecting
238
+ * or terminated.
232
239
  */
233
240
  disconnect(): void;
234
241
  /**
@@ -309,6 +316,8 @@ export declare class WSClient<TAuth = unknown> {
309
316
  * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
310
317
  * @throws {WSOutboxDropError} evicted from a full outbox
311
318
  * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
319
+ * @throws {WSTerminatedError} sent after a terminal close, which only an
320
+ * explicit `connect()` recovers from — rejected at once, not buffered
312
321
  * @throws {WSRemoteError} the server rejected it with a `nack`
313
322
  */
314
323
  publish<T = unknown>(room: string, payload: T, namespace?: string): Promise<WSPublishResult>;
@@ -7,7 +7,7 @@ import { createClog } from "@marianmeres/clog";
7
7
  import { createPubSub } from "@marianmeres/pubsub";
8
8
  import { base36 } from "@marianmeres/uid";
9
9
  import { CLOSE, DEFAULT_NAMESPACE, DEFAULT_TERMINAL_CLOSE_CODES, FRAME, PROTOCOL_VERSION, } from "../protocol/constants.js";
10
- import { WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
10
+ import { WSConnectionLostError, WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
11
11
  import { backoffDelay } from "./backoff.js";
12
12
  import { Heartbeat } from "./heartbeat.js";
13
13
  import { Outbox } from "./outbox.js";
@@ -96,7 +96,12 @@ export class WSClient {
96
96
  #namespace = null;
97
97
  #attempt = 0;
98
98
  #lastError = null;
99
- #intentional = false;
99
+ /**
100
+ * Kept apart from `#lastError` on purpose: a handler that throws after the
101
+ * terminal close would overwrite `#lastError` and a send rejected from the
102
+ * `terminated` state would then blame the wrong thing.
103
+ */
104
+ #terminalError = null;
100
105
  #rooms = new RoomRegistry();
101
106
  #outbox;
102
107
  #heartbeat;
@@ -303,7 +308,6 @@ export class WSClient {
303
308
  this.#settleConnect(new WSConnectTimeoutError(this.#connectTimeout));
304
309
  }, this.#connectTimeout);
305
310
  }
306
- this.#intentional = false;
307
311
  if (this.#state === "idle" || this.#state === "terminated")
308
312
  this.#open();
309
313
  return promise;
@@ -314,16 +318,29 @@ export class WSClient {
314
318
  * Resumable: handlers, room subscriptions and buffered sends all survive,
315
319
  * so a later `connect()` picks up exactly where this left off. Use
316
320
  * {@link dispose} for terminal teardown.
321
+ *
322
+ * Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
323
+ * when there was a socket to close; nothing when already idle, reconnecting
324
+ * or terminated.
317
325
  */
318
326
  disconnect() {
319
327
  if (this.#state === "disposed")
320
328
  return;
321
329
  this.logger?.debug?.("disconnect()");
322
- this.#intentional = true;
323
330
  this.#clearTimers();
324
331
  this.#heartbeat.stop();
325
332
  this.#settleConnect(new WSTerminatedError(CLOSE.CLIENT_GONE, "disconnect() called"));
326
- this.#closeSocket(CLOSE.CLIENT_GONE, "client disconnect");
333
+ const reason = "client disconnect";
334
+ const closed = this.#socket !== null;
335
+ this.#closeSocket(CLOSE.CLIENT_GONE, reason);
336
+ if (closed) {
337
+ this.#emit("close", {
338
+ code: CLOSE.CLIENT_GONE,
339
+ reason,
340
+ willReconnect: false,
341
+ });
342
+ }
343
+ this.#settleInFlight();
327
344
  this.#setState("idle");
328
345
  }
329
346
  /**
@@ -386,6 +403,10 @@ export class WSClient {
386
403
  type: FRAME.UNSUB,
387
404
  id: this.#nextId(),
388
405
  rooms: [room],
406
+ }).catch((e) => {
407
+ // Not actionable: the handler is already detached locally and
408
+ // the server drops the room on close anyway.
409
+ this.logger?.debug?.(`unsub "${room}" unconfirmed: ${e.message}`);
389
410
  });
390
411
  }
391
412
  });
@@ -466,6 +487,8 @@ export class WSClient {
466
487
  * @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
467
488
  * @throws {WSOutboxDropError} evicted from a full outbox
468
489
  * @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
490
+ * @throws {WSTerminatedError} sent after a terminal close, which only an
491
+ * explicit `connect()` recovers from — rejected at once, not buffered
469
492
  * @throws {WSRemoteError} the server rejected it with a `nack`
470
493
  */
471
494
  publish(room, payload, namespace) {
@@ -512,14 +535,22 @@ export class WSClient {
512
535
  #send(frame, id) {
513
536
  if (this.#autoConnect)
514
537
  this.#ensureStarted();
538
+ // Nothing restarts from `terminated` except an explicit connect(), so
539
+ // buffering here would only defer the same answer by `sendTimeout`.
540
+ if (this.#terminalError && this.#state === "terminated") {
541
+ return Promise.reject(this.#terminalError);
542
+ }
515
543
  const canSendNow = this.connected &&
516
544
  this.#socket?.readyState === WebSocket.OPEN;
517
545
  if (!canSendNow && this.#outboxMaxSize === 0) {
518
546
  return Promise.reject(new WSNotConnectedError());
519
547
  }
520
548
  const promise = this.#outbox.track(id, frame, !canSendNow);
521
- if (canSendNow)
522
- this.#sendRaw(frame);
549
+ if (canSendNow) {
550
+ const error = this.#sendRaw(frame);
551
+ if (error)
552
+ this.#outbox.fail(id, error);
553
+ }
523
554
  return promise;
524
555
  }
525
556
  /**
@@ -532,19 +563,27 @@ export class WSClient {
532
563
  #sendControl(frame) {
533
564
  const id = "id" in frame ? frame.id : this.#nextId();
534
565
  const promise = this.#outbox.track(id, frame, false);
535
- this.#sendRaw(frame);
566
+ const error = this.#sendRaw(frame);
567
+ if (error)
568
+ this.#outbox.fail(id, error);
536
569
  return promise;
537
570
  }
571
+ /**
572
+ * @returns the error the send failed with — a frame that never left cannot
573
+ * be acknowledged, so the caller settles its promise instead of letting it
574
+ * wait out `sendTimeout`.
575
+ */
538
576
  #sendRaw(frame) {
539
577
  const socket = this.#socket;
540
578
  if (!socket || socket.readyState !== WebSocket.OPEN)
541
- return;
579
+ return null;
542
580
  try {
543
581
  socket.send(this.#encode(frame));
544
582
  }
545
583
  catch (e) {
546
- this.#fail(e, "send failed");
584
+ return this.#fail(e, "send failed");
547
585
  }
586
+ return null;
548
587
  }
549
588
  #open() {
550
589
  // Defensive: every caller already guards this, but a second socket
@@ -556,7 +595,7 @@ export class WSClient {
556
595
  }
557
596
  if (!this.#setState("connecting"))
558
597
  return;
559
- this.#intentional = false;
598
+ this.#terminalError = null;
560
599
  const generation = ++this.#generation;
561
600
  let socket;
562
601
  try {
@@ -568,6 +607,9 @@ export class WSClient {
568
607
  return;
569
608
  }
570
609
  this.#socket = socket;
610
+ // Without this a binary frame arrives as a Blob, which no synchronous
611
+ // decoder can read — the `WSDecoder` contract promises an `ArrayBuffer`.
612
+ socket.binaryType = "arraybuffer";
571
613
  this.logger?.debug?.(`connecting to ${this.#url.href}`);
572
614
  socket.onopen = () => {
573
615
  if (generation !== this.#generation)
@@ -601,6 +643,13 @@ export class WSClient {
601
643
  payload = (await this.#authFn?.()) ?? null;
602
644
  }
603
645
  catch (e) {
646
+ // The await yields, so this rejection may belong to a socket that a
647
+ // later connect() already replaced — closing on it would kill the
648
+ // healthy socket that took its place.
649
+ if (generation !== this.#generation) {
650
+ this.logger?.debug?.("superseded auth attempt failed");
651
+ return;
652
+ }
604
653
  this.#fail(e, "auth payload failed");
605
654
  this.#forceClose(CLOSE.PROTOCOL_ERROR, "auth payload failed");
606
655
  return;
@@ -700,8 +749,11 @@ export class WSClient {
700
749
  const buffered = this.#outbox.drain();
701
750
  if (buffered.length) {
702
751
  this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
703
- for (const frame of buffered)
704
- this.#sendRaw(frame);
752
+ for (const frame of buffered) {
753
+ const error = this.#sendRaw(frame);
754
+ if (error && "id" in frame)
755
+ this.#outbox.fail(frame.id, error);
756
+ }
705
757
  }
706
758
  }
707
759
  #onClose(code, reason) {
@@ -710,14 +762,14 @@ export class WSClient {
710
762
  this.#heartbeat.stop();
711
763
  this.#socket = null;
712
764
  const terminal = this.#terminalCodes.includes(code);
713
- const willReconnect = !this.#intentional && !terminal &&
714
- this.#state !== "disposed";
765
+ const willReconnect = !terminal && this.#state !== "disposed";
715
766
  this.logger?.debug?.(`closed (${code}${reason ? ` ${reason}` : ""}), reconnect=${willReconnect}`);
716
767
  this.#emit("close", { code, reason, willReconnect });
717
768
  if (terminal) {
718
769
  this.#setState("terminated");
719
770
  const error = new WSTerminatedError(code, reason);
720
771
  this.#lastError = error;
772
+ this.#terminalError = error;
721
773
  // Loud on purpose: this is the only path where a client that
722
774
  // otherwise retries forever gives up, and a silent one looks
723
775
  // exactly like a network that never came back.
@@ -727,12 +779,27 @@ export class WSClient {
727
779
  this.#emit("terminated", { code, reason });
728
780
  return;
729
781
  }
782
+ this.#settleInFlight();
730
783
  if (!willReconnect) {
731
784
  this.#setState("idle");
732
785
  return;
733
786
  }
734
787
  this.#scheduleReconnect();
735
788
  }
789
+ /**
790
+ * Answers the frames that were on the wire when the socket went away.
791
+ *
792
+ * `sub`/`unsub` resolve: the room registry is authoritative locally and the
793
+ * re-subscribe step will establish it on the next connection, which is
794
+ * exactly the contract of a `subscribe()` issued while offline — and the
795
+ * server forgets its rooms on close anyway. Publishes reject: they were not
796
+ * delivered, and at-most-once means they will not be resent.
797
+ */
798
+ #settleInFlight() {
799
+ this.#outbox.settleInFlight((frame) => frame.type === FRAME.SUB || frame.type === FRAME.UNSUB
800
+ ? null
801
+ : new WSConnectionLostError());
802
+ }
736
803
  #scheduleReconnect() {
737
804
  if (!this.#setState("reconnecting"))
738
805
  return;
@@ -864,6 +931,7 @@ export class WSClient {
864
931
  this.#lastError = err;
865
932
  this.logger?.error?.(`${context}: ${err.message}`);
866
933
  this.#emit("error", err);
934
+ return err;
867
935
  }
868
936
  }
869
937
  /**
@@ -71,6 +71,15 @@ export declare class WSTimeoutError extends WSError {
71
71
  export declare class WSOutboxDropError extends WSError {
72
72
  constructor();
73
73
  }
74
+ /**
75
+ * The socket closed while the frame was in flight.
76
+ *
77
+ * Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
78
+ * connected", that one means "the server is not answering".
79
+ */
80
+ export declare class WSConnectionLostError extends WSError {
81
+ constructor();
82
+ }
74
83
  /** The server rejected the operation with a `nack`. */
75
84
  export declare class WSRemoteError extends WSError {
76
85
  /** Machine-readable code from the server — see `ERROR_CODE`. */
@@ -81,6 +81,17 @@ export class WSOutboxDropError extends WSError {
81
81
  super("Dropped from outbox (capacity reached while disconnected)");
82
82
  }
83
83
  }
84
+ /**
85
+ * The socket closed while the frame was in flight.
86
+ *
87
+ * Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
88
+ * connected", that one means "the server is not answering".
89
+ */
90
+ export class WSConnectionLostError extends WSError {
91
+ constructor() {
92
+ super("The connection closed before the server acknowledged the frame; it was not resent");
93
+ }
94
+ }
84
95
  /** The server rejected the operation with a `nack`. */
85
96
  export class WSRemoteError extends WSError {
86
97
  /** Machine-readable code from the server — see `ERROR_CODE`. */
@@ -136,6 +136,16 @@ export interface WSPublishResult {
136
136
  */
137
137
  recipients: number;
138
138
  }
139
+ /**
140
+ * What the client proposed in its `auth` frame. Hints, not facts — they are
141
+ * whatever the socket sent, so validate them before honouring them.
142
+ */
143
+ export interface WSRequestedIdentity {
144
+ /** The claimed client id, when the frame carried a usable one. */
145
+ clientId?: string;
146
+ /** The requested namespace, or the default when the frame carried none. */
147
+ namespace: string;
148
+ }
139
149
  /** Outcome of the server's `verify()` hook. */
140
150
  export interface AuthResult {
141
151
  /** Assign a specific client id. Defaults to a generated one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/ws",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -27,7 +27,7 @@
27
27
  "@marianmeres/clog": "^3.21.0",
28
28
  "@marianmeres/pubsub": "^3.0.0",
29
29
  "@marianmeres/ticker": "^1.17.1",
30
- "@marianmeres/uid": "^1.1.0"
30
+ "@marianmeres/uid": "^1.2.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",