@marianmeres/ws 0.4.1 → 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/AGENTS.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # @marianmeres/ws — Agent Guide
2
2
 
3
- WebSocket client with namespaces, rooms, presence and reconnect, plus a
4
- demino-mountable reference server.
3
+ WebSocket client with reconnect, half-open detection and buffered sends, used
4
+ two ways: plain messages to/from the server (protocol **core**), or namespaces,
5
+ rooms and presence (the optional **rooms extension**). Plus a demino-mountable
6
+ reference server implementing both.
5
7
 
6
8
  ## Quick Reference
7
9
 
@@ -16,6 +18,8 @@ test: "deno task test"
16
18
  | Task | File | Key export |
17
19
  | ---------------------------- | ------------------------- | ------------------------------------- |
18
20
  | Client | `src/client/ws-client.ts` | `createWSClient`, `WSClient` |
21
+ | Messages (core), client | `src/client/ws-client.ts` | `WSClient.send`, `message` event |
22
+ | Messages (core), server | `src/server/service.ts` | `onMessage` option, `WSService.send` |
19
23
  | Reconnect curve | `src/client/backoff.ts` | `backoffDelay` |
20
24
  | Outbox + pending acks | `src/client/outbox.ts` | `Outbox` |
21
25
  | Room refcounting | `src/client/rooms.ts` | `RoomRegistry` |
@@ -33,7 +37,7 @@ src/
33
37
  ├── protocol.ts # ./protocol entry -> protocol/mod.ts
34
38
  ├── protocol/ # dependency-free; imported by BOTH sides
35
39
  │ ├── constants.ts # PROTOCOL_VERSION, FRAME, CLOSE, ERROR_CODE, PRESENCE
36
- │ ├── frames.ts # ClientFrame / ServerFrame unions, WSMessage
40
+ │ ├── frames.ts # ClientFrame / ServerFrame unions, WSMessage, WSRoomMessage
37
41
  │ └── errors.ts # typed errors
38
42
  ├── client/
39
43
  └── server/
@@ -66,8 +70,34 @@ to compensate — do not reintroduce that.
66
70
  frame shape means changing it there, and bumping `PROTOCOL_VERSION` if the
67
71
  change is breaking.
68
72
 
73
+ **The protocol is two layers; keep them apart.** Core: `auth`/`hello`, `msg`
74
+ (both directions), `ack`/`nack`, `ping`/`pong`, `error`. Rooms extension: `sub`,
75
+ `unsub`, `pub`, `broadcast`, `presence`, and the routing fields (`room`,
76
+ `namespace`, `from`, `timestamp`) on a delivered `msg`. A core-only server
77
+ (PROTOCOL.md §8) must stay sufficient: never make a rooms concept — room,
78
+ namespace, `clientId` — required in the core. That is why `hello` identity is
79
+ optional, `auth` sends `clientId`/`namespace` only when the app set them, and
80
+ `WSMessage` has only `payload` required (`WSRoomMessage` is the room-delivery
81
+ refinement room handlers get). `tests/core.test.ts` runs the client against a
82
+ core-only server (`startCoreServer`) and fails if the core stops being enough.
83
+
84
+ **Every frame with an `id` gets exactly one answer.** A server answers an unknown
85
+ or unimplemented frame type with `unsupported` — `nack` when it has a string
86
+ `id`, `error` when not. Ignoring it would leave the client waiting out
87
+ `sendTimeout`; answering it is what makes the rooms extension safely optional.
88
+
89
+ **A `send()` without `{ ack: true }` completes when written.** It carries no
90
+ wire id; the outbox tracks it under a local key with `awaitAck: false` only so
91
+ it can be buffered and bounded offline, and `Outbox.transmitted()` resolves it.
92
+ It is therefore never "in flight" — `settleInFlight` never sees it. Its returned
93
+ promise is marked handled (`.catch(noop)` on it, then returned): fire-and-forget
94
+ invites not awaiting, and it can still reject (queue timeout, outbox drop,
95
+ terminal close). Removing that makes an ignored send fatal in Deno/Node; a
96
+ resilience test fails if you do.
97
+
69
98
  **Ordering on reconnect is load-bearing.** `#onHello` must re-subscribe _before_
70
- flushing the outbox, and the server must handle `sub`/`unsub` **synchronously**.
99
+ flushing the outbox (which holds `pub`, `broadcast` and `msg` frames alike), and
100
+ the server must handle `sub`/`unsub` **synchronously**.
71
101
  Together these guarantee a buffered publish cannot land in a room the server has
72
102
  not registered yet. There is a test that fails if you break it
73
103
  (`buffered publishes flush *after* re-subscribe`).
@@ -85,7 +115,16 @@ or Node process.
85
115
  dispatch.** A malformed but parseable frame is a `nack`/`error` `bad_request`
86
116
  and the socket stays open; an unexpected throw is `error` `internal` and a 1011
87
117
  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.
118
+ throws must never be able to take the process down. The one exception is the
119
+ application's `onMessage`: its throw is an application failure, not broken
120
+ bookkeeping, so it is answered (`WSRemoteError` → its own `code`/`message`,
121
+ anything else → `internal` with no text) and the socket stays open.
122
+
123
+ **`onMessage` is not serialized.** It is called in arrival order but not awaited
124
+ before the next frame, like every other async path in the service. The Python
125
+ servers in PROTOCOL.md do serialize (a per-connection worker) — both are
126
+ documented as such; do not "fix" either to match the other without updating
127
+ PROTOCOL.md §3.2.
89
128
 
90
129
  **Every send carries one deadline** spanning queue + flight + ack — not an
91
130
  ack-only timeout. Combining acks with infinite retry otherwise produces promises
@@ -145,13 +184,20 @@ run `deno publish` then the npm build.
145
184
 
146
185
  ## Before Making Changes
147
186
 
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)
187
+ 1. `deno task test` — 83 tests, mostly real sockets against a real server:
188
+ `unit`, `integration`, `resilience`, `core` (messages: a core-only server,
189
+ `onMessage`, `service.send`), `protocol` (server input hardening, raw
190
+ sockets) and `codec` (custom encode/decode, binary frames)
151
191
  2. `deno lint && deno fmt --check && deno check src/mod.ts src/server.ts src/protocol.ts`
152
192
  3. Touched a public signature? `deno doc --lint src/mod.ts src/server.ts
153
193
  src/protocol.ts` **and** `deno publish --dry-run --allow-dirty`
154
- 4. Touching the wire? Update `src/protocol/` first, then both sides
194
+ 4. Touching the wire? Update `src/protocol/` first, then both sides, then
195
+ PROTOCOL.md — including its two Python servers and the Appendix A script.
196
+ Re-verify them, don't eyeball them: copy each code block out, point the
197
+ script's `jsr:@marianmeres/ws` import at `src/mod.ts`, run each server with
198
+ `WS_AUTH_TIMEOUT=1 WS_IDLE_TIMEOUT=2` (Python 3.10+, `websockets>=13`, in a
199
+ venv) and run the script — core server without `WS_ROOMS`, full one with
200
+ `WS_ROOMS=1`
155
201
  5. Touching reconnect, outbox or heartbeat? Read `tests/resilience.test.ts`
156
202
  first — those tests encode the failure modes the design exists to handle
157
203
  6. `deno task npm:build` if packaging changed (npm ships the **client only**;
@@ -167,6 +213,8 @@ run `deno publish` then the npm build.
167
213
  - No server-side replay, so no at-least-once delivery
168
214
  - `recipients` counts are instance-local; they stay that way under a
169
215
  distributed adapter
216
+ - Direct messages (`WSService.send`) are instance-local; the adapter carries
217
+ room messages only
170
218
  - Only `WSPubSubLocal` exists — Redis/Deno-KV adapters are an unimplemented seam
171
219
  - Presence is scoped to `(room, namespace)`; broadcast crosses namespaces but
172
220
  presence does not
@@ -175,12 +223,12 @@ run `deno publish` then the npm build.
175
223
 
176
224
  ## Documentation Index
177
225
 
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 |
226
+ | Document | Purpose |
227
+ | ------------------- | ----------------------------------------------------------------------------------- |
228
+ | `README.md` | Human-facing overview and usage |
229
+ | `API.md` | Complete API reference — every public export |
230
+ | `PROTOCOL.md` | Wire protocol spec (core + rooms extension), two Python servers, conformance script |
231
+ | `example/README.md` | The reference app: what it demonstrates, and how |
184
232
 
185
233
  `tmp/spec.md` (design spec and decision log) is referenced in some commits but
186
234
  is **untracked** — `tmp/*` is gitignored, so it does not exist in a fresh clone.
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 |
@@ -56,14 +61,17 @@ import { createWSClient } from "@marianmeres/ws";
56
61
 
57
62
  const ws = createWSClient({
58
63
  url: "wss://example.com/ws",
59
- namespace: "org-123",
60
64
  auth: () => session.token, // re-read on every reconnect
61
65
  });
62
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
63
72
  const unsub = await ws.subscribe("chat", (msg) => {
64
73
  console.log(msg.from, msg.payload, msg.timestamp);
65
74
  });
66
-
67
75
  const { recipients } = await ws.publish("chat", { text: "hello" });
68
76
 
69
77
  unsub();
@@ -116,7 +124,67 @@ Terminal teardown: disconnects, then drops every handler, room, timer and
116
124
  pending promise. Pending sends reject with `WSDisposedError`. The instance is
117
125
  unusable afterwards.
118
126
 
119
- #### 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`.
120
188
 
121
189
  ##### `subscribe<T>(room, handler, options?): Promise<Unsubscriber>`
122
190
 
@@ -137,7 +205,8 @@ connect, and a failure there surfaces as an `error` event.
137
205
  **Parameters**
138
206
 
139
207
  - `room` (string) — room name, scoped to this client's namespace
140
- - `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>`
141
210
  - `options.presence` (`PresenceHandler`, optional) — enables presence for this
142
211
  room
143
212
 
@@ -167,8 +236,6 @@ room registered while offline reads `true` before the wire subscription exists.
167
236
  Last known membership of a presence-enabled room. Empty for rooms without
168
237
  presence.
169
238
 
170
- #### Sending
171
-
172
239
  ##### `publish<T>(room, payload, namespace?): Promise<WSPublishResult>`
173
240
 
174
241
  Publishes to a room within this client's namespace. Resolves with the recipient
@@ -208,18 +275,18 @@ unsubscriber is `Symbol.dispose`-compatible.
208
275
 
209
276
  #### Properties
210
277
 
211
- | Member | Type | Notes |
212
- | ----------------- | ------------------------- | ----------------------------------------------- |
213
- | `state` | Svelte store of `WSState` | Fires immediately, then on every change |
214
- | `connected` | `boolean` | `true` only in `open` — not merely socket-open |
215
- | `connectionState` | `WSConnectionState` | |
216
- | `clientId` | `string \| null` | Server-assigned; `null` until connected |
217
- | `namespace` | `string` | The server's assignment wins over the request |
218
- | `rooms` | `string[]` | Rooms currently held |
219
- | `socket` | `WebSocket \| null` | Escape hatch; sending on it bypasses the outbox |
220
- | `url` | `URL` | A copy — mutating it does nothing |
221
- | `logger` | `Logger \| null` | Assignable; set to `null` to silence |
222
- | `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 |
223
290
 
224
291
  ##### `WSClient.resolveUrl(input): URL` (static)
225
292
 
@@ -258,6 +325,17 @@ Exported mainly so the curve is testable.
258
325
  The options object documented under
259
326
  [`createWSClient`](#createwsclientoptions).
260
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
+
261
339
  ### `SubscribeOptions`
262
340
 
263
341
  ```typescript
@@ -274,28 +352,41 @@ time the fleet reconnects.
274
352
  ### `MessageHandler<T>` / `PresenceHandler`
275
353
 
276
354
  ```typescript
277
- type MessageHandler<T = unknown> = (msg: WSMessage<T>) => void;
355
+ type MessageHandler<T = unknown> = (msg: WSRoomMessage<T>) => void;
278
356
  type PresenceHandler = (event: WSPresenceEvent) => void;
279
357
  ```
280
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
+
281
363
  A throwing handler is caught, reported through the `error` event, and does not
282
364
  stop delivery to the others.
283
365
 
284
366
  ### `WSEvents`
285
367
 
286
- | Event | Payload |
287
- | -------------- | ---------------------------------- |
288
- | `open` | `void` — socket open, pre-auth |
289
- | `connected` | `{ clientId, namespace }` |
290
- | `message` | `WSMessage` — firehose, every room |
291
- | `presence` | `WSPresenceEvent` |
292
- | `close` | `{ code, reason, willReconnect }` |
293
- | `reconnecting` | `{ attempt, delay }` |
294
- | `terminated` | `{ code, reason }` — gave up |
295
- | `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.
296
385
 
297
386
  `error` means something failed but the client carried on (a decode failure, a
298
- 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.
299
390
 
300
391
  A local `disconnect()` is a `close` too: code `4900`, `willReconnect: false` —
301
392
  that pair is how a deliberate teardown is told apart from a lost connection.
@@ -348,6 +439,7 @@ Creates a mountable demino app plus the service it is wired to.
348
439
  | `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
349
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) |
350
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) |
351
443
  | `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
352
444
  | `options.httpAuth` | `DeminoHandler` | — | Guards the HTTP routes. **Without it they are not mounted** |
353
445
  | `options.deminoOptions` | `DeminoOptions` | — | Passed through to `demino()` |
@@ -392,6 +484,42 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
392
484
  Deno.serve(app);
393
485
  ```
394
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
+
395
523
  #### Security: namespace isolation
396
524
 
397
525
  Namespace is the isolation boundary and `clientId` is the identity peers see in
@@ -453,7 +581,8 @@ site's page cannot read your token, only ride your cookies.
453
581
 
454
582
  ### `WSService`
455
583
 
456
- 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
457
586
  — `new WSService(options)`, driven from any `Deno.serve` handler — or through
458
587
  `createWSApp`, which mounts it as a demino app.
459
588
 
@@ -463,9 +592,19 @@ Upgrades an HTTP request and takes ownership of the socket. Return the 101
463
592
  response from your route handler unmodified. With `allowedOrigins` set, a
464
593
  disallowed request is answered `403` instead and nothing is upgraded.
465
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.
604
+
466
605
  ##### `publish(room, payload, namespace?, from?): Promise<number>`
467
606
 
468
- 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`
469
608
  unless you pass one, which is how clients tell server pushes from peer traffic.
470
609
 
471
610
  `namespace` defaults to `"default"`. Resolves with the recipients on **this
@@ -533,7 +672,7 @@ Everything in that table except `httpAuth` and `deminoOptions`.
533
672
  }
534
673
  ```
535
674
 
536
- Passed to `allowBroadcast`.
675
+ Passed to `onMessage` and `allowBroadcast`.
537
676
 
538
677
  ### `WSStats`
539
678
 
@@ -573,10 +712,12 @@ unimplemented seam.
573
712
  ```typescript
574
713
  {
575
714
  namespace: string | null; // null for a cross-namespace broadcast
576
- message: WSMessage;
715
+ message: WSRoomMessage;
577
716
  }
578
717
  ```
579
718
 
719
+ Room messages only — direct messages (`service.send()`) never cross instances.
720
+
580
721
  ---
581
722
 
582
723
  ## Protocol
@@ -588,20 +729,39 @@ implementing this protocol against a different server or client.
588
729
 
589
730
  ```typescript
590
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;
591
752
  room: string;
592
753
  namespace: string;
593
754
  from: string | null;
594
- payload: T;
595
755
  timestamp: number; // server-assigned epoch ms
596
756
  }
597
757
  ```
598
758
 
759
+ A message delivered through a room — what room handlers receive, and a
760
+ `WSMessage` with every routing field present.
761
+
599
762
  `from` is `null` when the message was injected server-side. For a broadcast,
600
763
  `namespace` is the receiver's own — not the sender's.
601
764
 
602
- `payload` is **opaque**: never inspected, never mutated. Your payload may carry
603
- its own `type` field and nothing collides.
604
-
605
765
  ### `WSPresenceEvent`
606
766
 
607
767
  ```typescript
@@ -627,8 +787,9 @@ away.
627
787
  }
628
788
  ```
629
789
 
630
- Sockets the message was handed to **on the receiving server instance**.
631
- 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.
632
793
 
633
794
  ### `AuthResult`
634
795
 
@@ -679,13 +840,14 @@ Discriminated unions over `FRAME`, keyed on `type`. `WSFrame` is either
679
840
  direction. You need these only to write a custom `encode`/`decode` or a
680
841
  third-party implementation.
681
842
 
682
- | Direction | Frames |
683
- | --------------- | ---------------------------------------------------------- |
684
- | client → server | `auth`, `sub`, `unsub`, `pub`, `broadcast`, `ping` |
685
- | 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` |
686
847
 
687
848
  A `msg` frame minus its `type` field _is_ a `WSMessage` — no translation layer,
688
- 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.
689
851
 
690
852
  ### `PresenceEventType`
691
853
 
@@ -712,20 +874,26 @@ string-matching messages.
712
874
  | ----------------------- | ----------------------------------------------- | ---------------- |
713
875
  | `WSTerminatedError` | Terminal close code | `code`, `reason` |
714
876
  | `WSConnectTimeoutError` | `connectTimeout` elapsed (retrying continues) | |
715
- | `WSTimeoutError` | `sendTimeout` elapsed with no acknowledgement | |
716
- | `WSConnectionLostError` | Socket closed while the frame was in flight | |
877
+ | `WSTimeoutError` | `sendTimeout` elapsed still queued, or no ack | |
878
+ | `WSConnectionLostError` | Socket closed while the frame awaited its ack | |
717
879
  | `WSOutboxDropError` | Evicted from a full outbox | |
718
- | `WSRemoteError` | Server sent a `nack` | `code` |
880
+ | `WSRemoteError` | Server sent a `nack` (or an `error` frame) | `code` |
719
881
  | `WSNotConnectedError` | Sent while disconnected with `outboxMaxSize: 0` | |
720
882
  | `WSDisposedError` | Client was disposed | |
721
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
+
722
888
  ---
723
889
 
724
890
  ## Constants
725
891
 
726
892
  ### `PROTOCOL_VERSION`
727
893
 
728
- `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.
729
897
 
730
898
  ### `DEFAULT_NAMESPACE`
731
899
 
@@ -763,8 +931,11 @@ Frame type discriminators — the `type` field of every frame. See
763
931
 
764
932
  ### `ERROR_CODE`
765
933
 
766
- `"unauthorized" | "forbidden" | "bad_request" | "rate_limited" | "internal"`
767
- 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.
768
939
 
769
940
  ### `PRESENCE`
770
941