@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/AGENTS.md +91 -16
- package/API.md +360 -90
- package/README.md +192 -71
- package/dist/client/outbox.d.ts +51 -11
- package/dist/client/outbox.js +56 -18
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +142 -16
- package/dist/client/ws-client.js +170 -37
- 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 +21 -2
- package/dist/protocol/errors.js +24 -3
- package/dist/protocol/frames.d.ts +68 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +2 -2
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 |
|
|
@@ -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` —
|
|
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
|
-
####
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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:
|
|
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` —
|
|
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
|
|
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
|
|
323
|
-
| ---------------------------- |
|
|
324
|
-
| `mountPath` | `string`
|
|
325
|
-
| `middlewares` | `DeminoHandler[]`
|
|
326
|
-
| `options.verify` | `(payload, req) => AuthResult \| null` | — | Return `null` (or throw) to reject with `4001`. Absent means no authentication |
|
|
327
|
-
| `options.
|
|
328
|
-
| `options.
|
|
329
|
-
| `options.
|
|
330
|
-
| `options.
|
|
331
|
-
| `options.
|
|
332
|
-
| `options.
|
|
333
|
-
| `options.
|
|
334
|
-
| `options.
|
|
335
|
-
| `options.
|
|
336
|
-
| `options.
|
|
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
|
|
343
|
-
| ------ | ----------------------------- |
|
|
344
|
-
| GET | `/` | 101,
|
|
345
|
-
| GET | `/stats` | `WSStats`
|
|
346
|
-
| POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }`
|
|
347
|
-
| POST | `/broadcast/[room]` | `{ ok: true, recipients }`
|
|
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.
|
|
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
|
|
406
|
-
|
|
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:
|
|
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
|
|
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 |
|
|
585
|
-
| --------------- |
|
|
586
|
-
| client → server | `auth`, `sub`, `unsub`, `pub`, `broadcast
|
|
587
|
-
| 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` |
|
|
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
|
|
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`
|
|
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
|
-
`
|
|
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
|
|