@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 +63 -15
- package/API.md +232 -61
- package/README.md +176 -80
- package/dist/client/outbox.d.ts +40 -8
- package/dist/client/outbox.js +35 -11
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +132 -15
- package/dist/client/ws-client.js +101 -36
- package/dist/mod.d.ts +23 -4
- package/dist/mod.js +22 -3
- package/dist/protocol/constants.d.ts +35 -16
- package/dist/protocol/constants.js +40 -18
- package/dist/protocol/errors.d.ts +12 -2
- package/dist/protocol/errors.js +13 -3
- package/dist/protocol/frames.d.ts +58 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# @marianmeres/ws — Agent Guide
|
|
2
2
|
|
|
3
|
-
WebSocket client with
|
|
4
|
-
|
|
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
|
|
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` —
|
|
149
|
-
`unit`, `integration`, `resilience`, `
|
|
150
|
-
|
|
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
|
|
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
|
|
6
|
-
| -------------------------- |
|
|
7
|
-
| `@marianmeres/ws` | the client, plus everything from protocol
|
|
8
|
-
| `@marianmeres/ws/server` | the reference server
|
|
9
|
-
| `@marianmeres/ws/protocol` | wire definitions only, dependency-free
|
|
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()`, `
|
|
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` |
|
|
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
|
-
####
|
|
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
|
|
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:
|
|
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` —
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
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 |
|
|
683
|
-
| --------------- |
|
|
684
|
-
| client → server | `auth`, `sub`, `unsub`, `pub`, `broadcast
|
|
685
|
-
| server → client | `hello`, `
|
|
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
|
|
716
|
-
| `WSConnectionLostError` | Socket closed while the frame
|
|
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`
|
|
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
|
-
`
|
|
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
|
|