@marianmeres/ws 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  Three entry points:
4
4
 
5
- | Import | Contains | Runtime |
6
- | -------------------------- | ----------------------------------------- | --------- |
7
- | `@marianmeres/ws` | the client, plus everything from protocol | any |
8
- | `@marianmeres/ws/server` | the reference server | Deno only |
9
- | `@marianmeres/ws/protocol` | wire definitions only, dependency-free | any |
5
+ | Import | Contains | Runtime |
6
+ | -------------------------- | ------------------------------------------ | --------- |
7
+ | `@marianmeres/ws` | the client, plus everything from protocol | any |
8
+ | `@marianmeres/ws/server` | the reference server, plus `WSRemoteError` | Deno only |
9
+ | `@marianmeres/ws/protocol` | wire definitions only, dependency-free | any |
10
10
 
11
11
  ---
12
12
 
@@ -14,8 +14,13 @@ Three entry points:
14
14
 
15
15
  ### `createWSClient(options?)`
16
16
 
17
- Creates a client. Nothing connects until the first `connect()`, `subscribe()`
18
- or `publish()`.
17
+ Creates a client. Nothing connects until the first `connect()`, `send()`,
18
+ `subscribe()` or `publish()`.
19
+
20
+ The client works two ways, freely mixed on one connection — **messages**
21
+ (`send()` / the `message` event; the server only has to implement the protocol
22
+ core) and **rooms** (`subscribe()` / `publish()` / `broadcast()` / presence;
23
+ the rooms extension). See [Messages](#messages) and [Rooms](#rooms) below.
19
24
 
20
25
  `WSClient` is exported too — `new WSClient(options)` is the same thing,
21
26
  following the `PubSub` / `createPubSub` precedent.
@@ -25,11 +30,11 @@ following the `PubSub` / `createPubSub` precedent.
25
30
  | Name | Type | Default | Description |
26
31
  | -------------------- | ----------------------------------- | ------------------ | ----------------------------------------------------------------------------- |
27
32
  | `url` | `string \| URL` | `"/ws"` | `ws(s)://`, or `http(s)://` (upgraded), or a path resolved against `location` |
28
- | `namespace` | `string` | `"default"` | Isolation boundary |
29
- | `clientId` | `string` | generated | Preferred id; the server may override |
33
+ | `namespace` | `string` | `"default"` | Isolation boundary for rooms. Sent to the server only when set |
34
+ | `clientId` | `string` | | Preferred id; the server may override or ignore it |
30
35
  | `rooms` | `string[]` | `[]` | Rooms joined on every (re)connect |
31
36
  | `auth` | `() => unknown \| Promise<unknown>` | — | Auth payload; called before _every_ (re)connect |
32
- | `autoConnect` | `boolean` | `true` | First `subscribe()`/`publish()` starts the connection |
37
+ | `autoConnect` | `boolean` | `true` | First `send()`/`subscribe()`/`publish()` starts the connection |
33
38
  | `logger` | `Logger \| null` | `createClog("ws")` | `null` silences |
34
39
  | `reconnectDelay` | `number` | `500` | Initial backoff, ms |
35
40
  | `reconnectDelayMax` | `number` | `30_000` | Backoff ceiling, ms |
@@ -42,6 +47,11 @@ following the `PubSub` / `createPubSub` precedent.
42
47
  | `onOutboxDrop` | `(frames: ClientFrame[]) => void` | — | Called with evicted frames |
43
48
  | `encode` / `decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the server's |
44
49
 
50
+ `pingInterval: 0` disables the client's heartbeat, not the server's reaper: the
51
+ reference server still closes a connection that sent nothing for `idleTimeout`
52
+ (60 s) with `4008`, so a heartbeat-free client reconnects roughly every minute.
53
+ Disable both or neither.
54
+
45
55
  **Returns** `WSClient`
46
56
 
47
57
  **Example**
@@ -51,14 +61,17 @@ import { createWSClient } from "@marianmeres/ws";
51
61
 
52
62
  const ws = createWSClient({
53
63
  url: "wss://example.com/ws",
54
- namespace: "org-123",
55
64
  auth: () => session.token, // re-read on every reconnect
56
65
  });
57
66
 
67
+ // messages
68
+ ws.on("message", (msg) => console.log(msg.payload));
69
+ const reply = await ws.send({ op: "load", id: 42 }, { ack: true });
70
+
71
+ // rooms
58
72
  const unsub = await ws.subscribe("chat", (msg) => {
59
73
  console.log(msg.from, msg.payload, msg.timestamp);
60
74
  });
61
-
62
75
  const { recipients } = await ws.publish("chat", { text: "hello" });
63
76
 
64
77
  unsub();
@@ -84,10 +97,14 @@ gate, not a prerequisite.
84
97
 
85
98
  Rejects **only** where retrying cannot help:
86
99
 
87
- - `WSTerminatedError` — a terminal close code
100
+ - `WSTerminatedError` — a terminal close code, or code `4900` when
101
+ `disconnect()` (or `dispose()`, which disconnects first) is called while this
102
+ is still pending
88
103
  - `WSConnectTimeoutError` — `connectTimeout` elapsed. Retrying continues in the
89
104
  background, so this bounds _your await_, not the connection attempt
90
- - `WSDisposedError` — the client was disposed
105
+ - `WSDisposedError` — called on an already disposed client. A `dispose()`
106
+ _during_ a pending connect settles it with the `4900` `WSTerminatedError`
107
+ above, not with this
91
108
 
92
109
  Ordinary network failure never rejects; that is what the infinite retry is for.
93
110
 
@@ -97,13 +114,77 @@ Stops retrying and closes the socket. **Resumable** — handlers, room
97
114
  subscriptions and buffered sends all survive, so a later `connect()` picks up
98
115
  where it left off.
99
116
 
117
+ Emits `close` with code `4900` (`CLOSE.CLIENT_GONE`) and `willReconnect: false`
118
+ when there was a socket to close; nothing when already idle, reconnecting or
119
+ terminated.
120
+
100
121
  ##### `dispose(): void`
101
122
 
102
123
  Terminal teardown: disconnects, then drops every handler, room, timer and
103
124
  pending promise. Pending sends reject with `WSDisposedError`. The instance is
104
125
  unusable afterwards.
105
126
 
106
- #### Subscriptions
127
+ #### Messages
128
+
129
+ The core of the protocol: the client and the server talk to each other
130
+ directly. Incoming messages arrive through the [`message`](#wsevents) event.
131
+
132
+ ##### `send<T>(payload, options?): Promise<void>` / `send<R, T>(payload, { ack: true }): Promise<R>`
133
+
134
+ Sends a message to the server — all a core-only server has to understand.
135
+
136
+ **Fire-and-forget** by default: resolves as soon as the frame is written to the
137
+ socket. There is no delivery confirmation — a frame written into a connection
138
+ that turns out to be dead is lost, exactly as with a plain `WebSocket`. Calling
139
+ it without `await` is safe: the promise is marked handled, so a failure nobody
140
+ awaits (the queue timing out while offline, say) is not an unhandled rejection.
141
+ Await it to learn about one.
142
+
143
+ **With `{ ack: true }`** the frame carries an id and the promise waits for the
144
+ server's acknowledgement. It resolves with the reply the server put in the ack —
145
+ which makes this a request/response call — or with `undefined` for a bare ack. A
146
+ refusal (`nack`) rejects with `WSRemoteError` carrying the server's `code`, which
147
+ can be one of the server application's own. An acknowledged send in flight when
148
+ the socket closes rejects there and then with `WSConnectionLostError`; it is not
149
+ resent.
150
+
151
+ Either way, while disconnected the frame is buffered and flushed after the next
152
+ connect, in order — bounded by `sendTimeout`, which spans queue, flight and (with
153
+ an ack) acknowledgement. After a terminal close nothing is buffered: the promise
154
+ rejects immediately with `WSTerminatedError`.
155
+
156
+ A third overload, `send<T>(payload, options?: WSSendOptions): Promise<unknown>`,
157
+ covers an `ack` flag decided at runtime.
158
+
159
+ **Parameters**
160
+
161
+ - `payload` (`T`) — opaque application data; never inspected or mutated
162
+ - `options.ack` (boolean, optional) — wait for the server's acknowledgement and
163
+ its reply. Default `false`
164
+
165
+ **Throws** `WSRemoteError` (ack only), `WSTimeoutError`, `WSConnectionLostError`
166
+ (ack only), `WSOutboxDropError`, `WSNotConnectedError`, `WSTerminatedError`,
167
+ `WSDisposedError`
168
+
169
+ **Example**
170
+
171
+ ```typescript
172
+ ws.send({ op: "cursor", x: 10, y: 20 }); // fire-and-forget
173
+
174
+ const doc = await ws.send<Doc>({ op: "load", id: 42 }, { ack: true });
175
+
176
+ try {
177
+ await ws.send({ op: "delete", id: 42 }, { ack: true });
178
+ } catch (e) {
179
+ if (e instanceof WSRemoteError && e.code === "forbidden") showNotAllowed();
180
+ }
181
+ ```
182
+
183
+ #### Rooms
184
+
185
+ The rooms extension: namespaces, rooms, presence and broadcast. A server that
186
+ does not implement it answers these with `unsupported`, so they fail with
187
+ `WSRemoteError` code `"unsupported"` at once rather than after `sendTimeout`.
107
188
 
108
189
  ##### `subscribe<T>(room, handler, options?): Promise<Unsubscriber>`
109
190
 
@@ -124,7 +205,8 @@ connect, and a failure there surfaces as an `error` event.
124
205
  **Parameters**
125
206
 
126
207
  - `room` (string) — room name, scoped to this client's namespace
127
- - `handler` (`MessageHandler<T>`) — receives every message published to the room
208
+ - `handler` (`MessageHandler<T>`) — receives every message published to the
209
+ room, as a `WSRoomMessage<T>`
128
210
  - `options.presence` (`PresenceHandler`, optional) — enables presence for this
129
211
  room
130
212
 
@@ -154,21 +236,26 @@ room registered while offline reads `true` before the wire subscription exists.
154
236
  Last known membership of a presence-enabled room. Empty for rooms without
155
237
  presence.
156
238
 
157
- #### Sending
158
-
159
239
  ##### `publish<T>(room, payload, namespace?): Promise<WSPublishResult>`
160
240
 
161
241
  Publishes to a room within this client's namespace. Resolves with the recipient
162
242
  count once the server acknowledges.
163
243
 
164
244
  While disconnected the frame is buffered and the promise stays pending until it
165
- flushes — bounded by `sendTimeout`, never indefinitely.
245
+ flushes — bounded by `sendTimeout`, never indefinitely. A frame already in
246
+ flight when the socket closes rejects there and then with
247
+ `WSConnectionLostError`; it is not resent. After a terminal close nothing is
248
+ buffered at all: the promise rejects immediately with `WSTerminatedError`,
249
+ because only an explicit `connect()` leaves that state.
166
250
 
167
251
  `namespace` must equal the client's own; the server rejects anything else, so it
168
252
  is only useful for asserting the expected one.
169
253
 
170
- **Throws** `WSTimeoutError`, `WSOutboxDropError`, `WSNotConnectedError`,
171
- `WSRemoteError`, `WSDisposedError`
254
+ A payload `encode` refuses — a `BigInt` is enough for the JSON default — rejects
255
+ with the encoder's own error, unwrapped, and also surfaces as an `error` event.
256
+
257
+ **Throws** `WSTimeoutError`, `WSConnectionLostError`, `WSOutboxDropError`,
258
+ `WSNotConnectedError`, `WSTerminatedError`, `WSRemoteError`, `WSDisposedError`
172
259
 
173
260
  ##### `broadcast<T>(room, payload): Promise<WSPublishResult>`
174
261
 
@@ -188,18 +275,18 @@ unsubscriber is `Symbol.dispose`-compatible.
188
275
 
189
276
  #### Properties
190
277
 
191
- | Member | Type | Notes |
192
- | ----------------- | ------------------------- | ----------------------------------------------- |
193
- | `state` | Svelte store of `WSState` | Fires immediately, then on every change |
194
- | `connected` | `boolean` | `true` only in `open` — not merely socket-open |
195
- | `connectionState` | `WSConnectionState` | |
196
- | `clientId` | `string \| null` | Server-assigned; `null` until connected |
197
- | `namespace` | `string` | The server's assignment wins over the request |
198
- | `rooms` | `string[]` | Rooms currently held |
199
- | `socket` | `WebSocket \| null` | Escape hatch; sending on it bypasses the outbox |
200
- | `url` | `URL` | A copy — mutating it does nothing |
201
- | `logger` | `Logger \| null` | Assignable; set to `null` to silence |
202
- | `dump()` | `Record<string, unknown>` | Debug snapshot; shape is not stable API |
278
+ | Member | Type | Notes |
279
+ | ----------------- | ------------------------- | ------------------------------------------------------------------------- |
280
+ | `state` | Svelte store of `WSState` | Fires immediately, then on every change |
281
+ | `connected` | `boolean` | `true` only in `open` — not merely socket-open |
282
+ | `connectionState` | `WSConnectionState` | |
283
+ | `clientId` | `string \| null` | Server-assigned; `null` until connected, and when the server assigns none |
284
+ | `namespace` | `string` | The server's assignment wins over the request; else the requested one |
285
+ | `rooms` | `string[]` | Rooms currently held |
286
+ | `socket` | `WebSocket \| null` | Escape hatch; sending on it bypasses the outbox |
287
+ | `url` | `URL` | A copy — mutating it does nothing |
288
+ | `logger` | `Logger \| null` | Assignable; set to `null` to silence |
289
+ | `dump()` | `Record<string, unknown>` | Debug snapshot; shape is not stable API |
203
290
 
204
291
  ##### `WSClient.resolveUrl(input): URL` (static)
205
292
 
@@ -238,6 +325,17 @@ Exported mainly so the curve is testable.
238
325
  The options object documented under
239
326
  [`createWSClient`](#createwsclientoptions).
240
327
 
328
+ ### `WSSendOptions`
329
+
330
+ ```typescript
331
+ {
332
+ ack?: boolean; // default false
333
+ }
334
+ ```
335
+
336
+ Options for [`send()`](#messages). `ack: true` waits for the server's
337
+ acknowledgement and resolves with its reply.
338
+
241
339
  ### `SubscribeOptions`
242
340
 
243
341
  ```typescript
@@ -254,28 +352,44 @@ time the fleet reconnects.
254
352
  ### `MessageHandler<T>` / `PresenceHandler`
255
353
 
256
354
  ```typescript
257
- type MessageHandler<T = unknown> = (msg: WSMessage<T>) => void;
355
+ type MessageHandler<T = unknown> = (msg: WSRoomMessage<T>) => void;
258
356
  type PresenceHandler = (event: WSPresenceEvent) => void;
259
357
  ```
260
358
 
359
+ A room handler receives a `WSRoomMessage` — every routing field present. The
360
+ [`message`](#wsevents) event receives the looser `WSMessage`, because it also
361
+ carries direct messages from the server.
362
+
261
363
  A throwing handler is caught, reported through the `error` event, and does not
262
364
  stop delivery to the others.
263
365
 
264
366
  ### `WSEvents`
265
367
 
266
- | Event | Payload |
267
- | -------------- | ---------------------------------- |
268
- | `open` | `void` — socket open, pre-auth |
269
- | `connected` | `{ clientId, namespace }` |
270
- | `message` | `WSMessage` — firehose, every room |
271
- | `presence` | `WSPresenceEvent` |
272
- | `close` | `{ code, reason, willReconnect }` |
273
- | `reconnecting` | `{ attempt, delay }` |
274
- | `terminated` | `{ code, reason }` — gave up |
275
- | `error` | `Error` |
368
+ | Event | Payload |
369
+ | -------------- | ------------------------------------------------------------ |
370
+ | `open` | `void` — socket open, pre-auth |
371
+ | `connected` | `{ clientId: string \| null, namespace }` |
372
+ | `message` | `WSMessage` — every inbound message, direct or from any room |
373
+ | `presence` | `WSPresenceEvent` |
374
+ | `close` | `{ code, reason, willReconnect }` |
375
+ | `reconnecting` | `{ attempt, delay }` |
376
+ | `terminated` | `{ code, reason }` — gave up |
377
+ | `error` | `Error` |
378
+
379
+ `message` is the receiving side of [messages](#messages): a direct message from
380
+ the server carries only `payload`; a room delivery also carries `room`,
381
+ `namespace`, `from` and `timestamp`. Check `msg.room` to tell them apart.
382
+
383
+ `connected.clientId` is `null` when the server assigns no identity — a
384
+ core-only server need not.
276
385
 
277
386
  `error` means something failed but the client carried on (a decode failure, a
278
- throwing handler). `terminated` is the only non-retrying exit.
387
+ throwing handler, an `error` frame from the server for instance a
388
+ fire-and-forget `send()` the server refused). `terminated` is the only
389
+ non-retrying exit.
390
+
391
+ A local `disconnect()` is a `close` too: code `4900`, `willReconnect: false` —
392
+ that pair is how a deliberate teardown is told apart from a lost connection.
279
393
 
280
394
  ### `WSState`
281
395
 
@@ -319,34 +433,37 @@ Creates a mountable demino app plus the service it is wired to.
319
433
 
320
434
  **Parameters**
321
435
 
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 |
436
+ | Name | Type | Default | Description |
437
+ | ---------------------------- | ------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
438
+ | `mountPath` | `string` | `"/ws"` | Demino mount path |
439
+ | `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
440
+ | `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) |
441
+ | `options.allowedOrigins` | `string[] \| (origin, req) => boolean` | — (no check) | Origins allowed to upgrade → `403` — see [below](#security-cross-site-websocket-hijacking) |
442
+ | `options.onMessage` | `(ctx, payload) => unknown` | — (**unsupported**) | Receives every client `send()`; the return value is the reply see [below](#messages-onmessage) |
443
+ | `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
444
+ | `options.httpAuth` | `DeminoHandler` | | Guards the HTTP routes. **Without it they are not mounted** |
445
+ | `options.deminoOptions` | `DeminoOptions` | | Passed through to `demino()` |
446
+ | `options.authTimeout` | `number` | `5_000` | Deadline for the `auth` frame → `4002` |
447
+ | `options.idleTimeout` | `number` | `60_000` | Reap silent connections → `4008` |
448
+ | `options.maxFrameSize` | `number` | `262144` | Oversized frames → `4013` |
449
+ | `options.maxFramesPerSecond` | `number` | `100` | Rate cap → `4009` |
450
+ | `options.adapter` | `WSPubSubAdapter` | `WSPubSubLocal` | Cross-instance fan-out |
451
+ | `options.logger` | `Logger \| null` | `createClog("ws:server")` | `null` silences |
452
+ | `options.encode` / `.decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the client's |
337
453
 
338
454
  **Returns** `WSApp` — `{ app: Demino, service: WSService }`
339
455
 
340
456
  **Routes**, relative to `mountPath`:
341
457
 
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** |
458
+ | Method | Path | Returns | Notes |
459
+ | ------ | ----------------------------- | -------------------------- | ----------------------------------------- |
460
+ | GET | `/` | 101, 426 without upgrade | WebSocket upgrade, `403` on a bad origin |
461
+ | GET | `/stats` | `WSStats` | Requires `httpAuth`, else **not mounted** |
462
+ | POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
463
+ | POST | `/broadcast/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
348
464
 
349
- The POST routes take the JSON request body as the message payload.
465
+ The POST routes take the JSON request body as the message payload; a body that
466
+ is not valid JSON answers `400`.
350
467
 
351
468
  **Example**
352
469
 
@@ -367,22 +484,127 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
367
484
  Deno.serve(app);
368
485
  ```
369
486
 
487
+ #### Messages: `onMessage`
488
+
489
+ Every client `send()` reaches `onMessage(ctx, payload)`, with the sender's
490
+ [`WSConnectionContext`](#wsconnectioncontext).
491
+
492
+ - **The return value is the reply.** For a send with `{ ack: true }` it travels
493
+ back in the `ack` and resolves the client's promise (`undefined` makes a bare
494
+ ack). For a fire-and-forget send it is discarded. It may be a promise.
495
+ - **Throw a `WSRemoteError`** to refuse the message with your own `code` and
496
+ `message` — the client's `send()` rejects with exactly those. Any other throw
497
+ is logged and answered `internal`, without its text. The connection stays open
498
+ either way.
499
+ - **Called in arrival order, not awaited before the next frame.** An async hook
500
+ may finish out of order; chain the work yourself where order matters.
501
+ - **Unset, the server accepts no messages**: every `send()` is answered
502
+ `unsupported`.
503
+
504
+ To send a client a message of your own — now or later — use
505
+ [`service.send()`](#sendclientid-payload-boolean).
506
+
507
+ ```typescript
508
+ import { createWSApp, WSRemoteError } from "@marianmeres/ws/server";
509
+
510
+ const { app, service } = createWSApp("/ws", [], {
511
+ verify: (payload) => authenticate(payload),
512
+ onMessage: async (ctx, payload) => {
513
+ const { op, id } = payload as { op: string; id: number };
514
+ if (op !== "load") {
515
+ throw new WSRemoteError({ code: "unknown_op", message: `unknown op ${op}` });
516
+ }
517
+ service.send(ctx.clientId, { op: "progress", stage: "loading" });
518
+ return await loadDoc(id); // the reply
519
+ },
520
+ });
521
+ ```
522
+
523
+ #### Security: namespace isolation
524
+
525
+ Namespace is the isolation boundary and `clientId` is the identity peers see in
526
+ `from` — and a claimed id evicts whoever holds it. Both fall back to what the
527
+ client asked for:
528
+
529
+ ```
530
+ assigned by verify → requested by the client → generated
531
+ ```
532
+
533
+ **In any multi-tenant deployment `verify` must return `namespace` and
534
+ `clientId`.** Return neither and the client's proposals are honoured verbatim,
535
+ so any authenticated user can enter any tenant. The third argument,
536
+ [`WSRequestedIdentity`](#wsrequestedidentity), carries those proposals, so they
537
+ can be validated there rather than duplicated into the auth payload:
538
+
539
+ ```typescript
540
+ createWSApp("/ws", [], {
541
+ verify: async (payload, req, requested) => {
542
+ const user = await authenticate((payload as any)?.token);
543
+ if (!user) return null;
544
+ // The namespace is checked, not trusted — and assigned either way.
545
+ if (!user.orgs.includes(requested.namespace)) return null;
546
+ return { clientId: user.id, namespace: requested.namespace };
547
+ },
548
+ });
549
+ ```
550
+
551
+ #### Security: cross-site WebSocket hijacking
552
+
553
+ The upgrade request is an ordinary browser request, so the browser attaches its
554
+ cookies for your origin no matter which site opened the socket. A `verify` that
555
+ authenticates from cookies therefore authenticates the attacker's page too —
556
+ same-origin policy does not apply to WebSockets, and there is no preflight.
557
+
558
+ `allowedOrigins` closes that, and is **opt-in**: unset, nothing is checked.
559
+
560
+ ```typescript
561
+ createWSApp("/ws", [], {
562
+ allowedOrigins: ["https://app.example"],
563
+ verify: (_payload, req) => sessionFromCookie(req),
564
+ });
565
+ ```
566
+
567
+ An unlisted `Origin` is answered `403 Origin not allowed` and never reaches
568
+ `verify`. The array form **permits a missing `Origin`**, because only browsers
569
+ send the header and non-browser clients (the stock Deno client included) send
570
+ none — the check exists to stop browsers. Pass a function instead when that is
571
+ too lax, or when the allowed set is dynamic:
572
+
573
+ ```typescript
574
+ allowedOrigins: (origin, req) => origin !== null && isTenantOrigin(origin),
575
+ ```
576
+
577
+ Token authentication in the `auth` payload is not exposed this way: another
578
+ site's page cannot read your token, only ride your cookies.
579
+
370
580
  ---
371
581
 
372
582
  ### `WSService`
373
583
 
374
- Owns every connection, the room index, presence and delivery. Usable standalone
584
+ Owns every connection, direct messages, the room index, presence and delivery.
585
+ Usable standalone
375
586
  — `new WSService(options)`, driven from any `Deno.serve` handler — or through
376
587
  `createWSApp`, which mounts it as a demino app.
377
588
 
378
589
  ##### `handleUpgrade(request): Response`
379
590
 
380
591
  Upgrades an HTTP request and takes ownership of the socket. Return the 101
381
- response from your route handler unmodified.
592
+ response from your route handler unmodified. With `allowedOrigins` set, a
593
+ disallowed request is answered `403` instead and nothing is upgraded.
594
+
595
+ ##### `send(clientId, payload): boolean`
596
+
597
+ Sends a direct message to one connected client — the server-to-client half of
598
+ the protocol core. It arrives with nothing but `payload`, through the client's
599
+ `message` event; no room handler sees it.
600
+
601
+ Returns `true` when handed to an open socket, `false` when no such client is
602
+ connected **to this instance** (or the payload could not be encoded).
603
+ Instance-local: nothing is propagated through the adapter.
382
604
 
383
605
  ##### `publish(room, payload, namespace?, from?): Promise<number>`
384
606
 
385
- Injects a message from server-side code. Delivered messages carry `from: null`
607
+ Injects a message into a room from server-side code. Delivered messages carry `from: null`
386
608
  unless you pass one, which is how clients tell server pushes from peer traffic.
387
609
 
388
610
  `namespace` defaults to `"default"`. Resolves with the recipients on **this
@@ -402,8 +624,10 @@ Instance-local.
402
624
 
403
625
  ##### `stats(): WSStats`
404
626
 
405
- Counts only, never client ids, so it stays safe to expose unguarded in
406
- development.
627
+ Counts only, never client ids but `namespaces` is keyed by namespace name, so
628
+ in a multi-tenant deployment it enumerates the tenants that are online. Hence
629
+ the `/stats` route only exists behind `httpAuth`; this method is for in-process
630
+ use.
407
631
 
408
632
  ##### `close(): Promise<void>`
409
633
 
@@ -448,7 +672,7 @@ Everything in that table except `httpAuth` and `deminoOptions`.
448
672
  }
449
673
  ```
450
674
 
451
- Passed to `allowBroadcast`.
675
+ Passed to `onMessage` and `allowBroadcast`.
452
676
 
453
677
  ### `WSStats`
454
678
 
@@ -488,10 +712,12 @@ unimplemented seam.
488
712
  ```typescript
489
713
  {
490
714
  namespace: string | null; // null for a cross-namespace broadcast
491
- message: WSMessage;
715
+ message: WSRoomMessage;
492
716
  }
493
717
  ```
494
718
 
719
+ Room messages only — direct messages (`service.send()`) never cross instances.
720
+
495
721
  ---
496
722
 
497
723
  ## Protocol
@@ -503,20 +729,39 @@ implementing this protocol against a different server or client.
503
729
 
504
730
  ```typescript
505
731
  {
732
+ payload: T;
733
+ room?: string; // present on a room delivery only
734
+ namespace?: string; // 〃
735
+ from?: string | null; // 〃
736
+ timestamp?: number; // 〃
737
+ }
738
+ ```
739
+
740
+ Any message, as the [`message`](#wsevents) event delivers it. Only `payload` is
741
+ guaranteed: a direct message from the server carries nothing else, a room
742
+ delivery carries all of it (see `WSRoomMessage`). `room` tells them apart.
743
+
744
+ `payload` is **opaque**: never inspected, never mutated. Your payload may carry
745
+ its own `type` field and nothing collides.
746
+
747
+ ### `WSRoomMessage<T>`
748
+
749
+ ```typescript
750
+ {
751
+ payload: T;
506
752
  room: string;
507
753
  namespace: string;
508
754
  from: string | null;
509
- payload: T;
510
755
  timestamp: number; // server-assigned epoch ms
511
756
  }
512
757
  ```
513
758
 
759
+ A message delivered through a room — what room handlers receive, and a
760
+ `WSMessage` with every routing field present.
761
+
514
762
  `from` is `null` when the message was injected server-side. For a broadcast,
515
763
  `namespace` is the receiver's own — not the sender's.
516
764
 
517
- `payload` is **opaque**: never inspected, never mutated. Your payload may carry
518
- its own `type` field and nothing collides.
519
-
520
765
  ### `WSPresenceEvent`
521
766
 
522
767
  ```typescript
@@ -542,8 +787,9 @@ away.
542
787
  }
543
788
  ```
544
789
 
545
- Sockets the message was handed to **on the receiving server instance**.
546
- Best-effort telemetry, never a delivery guarantee.
790
+ Sockets the message was handed to **on the receiving server instance**, as
791
+ reported for `publish()` / `broadcast()`. Best-effort telemetry, never a
792
+ delivery guarantee.
547
793
 
548
794
  ### `AuthResult`
549
795
 
@@ -557,6 +803,19 @@ What the server's `verify()` hook returns. `null` rejects the connection.
557
803
  }
558
804
  ```
559
805
 
806
+ ### `WSRequestedIdentity`
807
+
808
+ The third argument to `verify()`: what the client proposed in its `auth` frame.
809
+ Hints, not facts — see
810
+ [Security: namespace isolation](#security-namespace-isolation).
811
+
812
+ ```typescript
813
+ {
814
+ clientId?: string; // absent unless the frame carried a usable one
815
+ namespace: string; // DEFAULT_NAMESPACE when the frame carried none
816
+ }
817
+ ```
818
+
560
819
  ### `WSErrorInfo`
561
820
 
562
821
  ```typescript
@@ -581,13 +840,14 @@ Discriminated unions over `FRAME`, keyed on `type`. `WSFrame` is either
581
840
  direction. You need these only to write a custom `encode`/`decode` or a
582
841
  third-party implementation.
583
842
 
584
- | Direction | Frames |
585
- | --------------- | ---------------------------------------------------------- |
586
- | client → server | `auth`, `sub`, `unsub`, `pub`, `broadcast`, `ping` |
587
- | server → client | `hello`, `ack`, `nack`, `msg`, `presence`, `pong`, `error` |
843
+ | Direction | Core | Rooms extension |
844
+ | --------------- | ---------------------------------------------- | ---------------------------------- |
845
+ | client → server | `auth`, `msg`, `ping` | `sub`, `unsub`, `pub`, `broadcast` |
846
+ | server → client | `hello`, `msg`, `ack`, `nack`, `pong`, `error` | `presence` |
588
847
 
589
848
  A `msg` frame minus its `type` field _is_ a `WSMessage` — no translation layer,
590
- no divergence between wire names and API names.
849
+ no divergence between wire names and API names. See [PROTOCOL.md](PROTOCOL.md)
850
+ for every frame's fields.
591
851
 
592
852
  ### `PresenceEventType`
593
853
 
@@ -614,19 +874,26 @@ string-matching messages.
614
874
  | ----------------------- | ----------------------------------------------- | ---------------- |
615
875
  | `WSTerminatedError` | Terminal close code | `code`, `reason` |
616
876
  | `WSConnectTimeoutError` | `connectTimeout` elapsed (retrying continues) | |
617
- | `WSTimeoutError` | `sendTimeout` elapsed with no acknowledgement | |
877
+ | `WSTimeoutError` | `sendTimeout` elapsed still queued, or no ack | |
878
+ | `WSConnectionLostError` | Socket closed while the frame awaited its ack | |
618
879
  | `WSOutboxDropError` | Evicted from a full outbox | |
619
- | `WSRemoteError` | Server sent a `nack` | `code` |
880
+ | `WSRemoteError` | Server sent a `nack` (or an `error` frame) | `code` |
620
881
  | `WSNotConnectedError` | Sent while disconnected with `outboxMaxSize: 0` | |
621
882
  | `WSDisposedError` | Client was disposed | |
622
883
 
884
+ `WSRemoteError` is also what a server-side `onMessage` throws to refuse a
885
+ message: `new WSRemoteError({ code, message })`. Its `code` and `message` reach
886
+ the client unchanged. It is re-exported from `@marianmeres/ws/server` for that.
887
+
623
888
  ---
624
889
 
625
890
  ## Constants
626
891
 
627
892
  ### `PROTOCOL_VERSION`
628
893
 
629
- `1`. Announced by the server in `hello`; a mismatch warns rather than fails.
894
+ `2`. Announced by the server in `hello`; a mismatch warns rather than fails.
895
+ Version 2 split the protocol into a required core and the optional rooms
896
+ extension; a version-1 server still works for rooms.
630
897
 
631
898
  ### `DEFAULT_NAMESPACE`
632
899
 
@@ -664,8 +931,11 @@ Frame type discriminators — the `type` field of every frame. See
664
931
 
665
932
  ### `ERROR_CODE`
666
933
 
667
- `"unauthorized" | "forbidden" | "bad_request" | "rate_limited" | "internal"`
668
- the `code` on `WSErrorInfo` and `WSRemoteError`.
934
+ `"unauthorized" | "forbidden" | "bad_request" | "rate_limited" | "unsupported" | "internal"`
935
+ the standard `code` values on `WSErrorInfo` and `WSRemoteError`.
936
+ `"unsupported"` answers a frame type the server does not implement, e.g. rooms
937
+ against a core-only server. A server application may also use codes of its own
938
+ when it refuses a message.
669
939
 
670
940
  ### `PRESENCE`
671
941