@marianmeres/ws 0.2.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 +161 -0
- package/API.md +672 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/client/backoff.d.ts +25 -0
- package/dist/client/backoff.js +31 -0
- package/dist/client/heartbeat.d.ts +38 -0
- package/dist/client/heartbeat.js +78 -0
- package/dist/client/outbox.d.ts +65 -0
- package/dist/client/outbox.js +155 -0
- package/dist/client/rooms.d.ts +62 -0
- package/dist/client/rooms.js +120 -0
- package/dist/client/ws-client.d.ts +351 -0
- package/dist/client/ws-client.js +892 -0
- package/dist/mod.d.ts +30 -0
- package/dist/mod.js +29 -0
- package/dist/protocol/constants.d.ts +113 -0
- package/dist/protocol/constants.js +118 -0
- package/dist/protocol/errors.d.ts +92 -0
- package/dist/protocol/errors.js +109 -0
- package/dist/protocol/frames.d.ts +151 -0
- package/dist/protocol/frames.js +12 -0
- package/dist/protocol/mod.d.ts +10 -0
- package/dist/protocol/mod.js +10 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.js +9 -0
- package/package.json +39 -0
package/API.md
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
# API
|
|
2
|
+
|
|
3
|
+
Three entry points:
|
|
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 |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Client
|
|
14
|
+
|
|
15
|
+
### `createWSClient(options?)`
|
|
16
|
+
|
|
17
|
+
Creates a client. Nothing connects until the first `connect()`, `subscribe()`
|
|
18
|
+
or `publish()`.
|
|
19
|
+
|
|
20
|
+
`WSClient` is exported too — `new WSClient(options)` is the same thing,
|
|
21
|
+
following the `PubSub` / `createPubSub` precedent.
|
|
22
|
+
|
|
23
|
+
**Parameters**
|
|
24
|
+
|
|
25
|
+
| Name | Type | Default | Description |
|
|
26
|
+
| -------------------- | ----------------------------------- | ------------------ | ----------------------------------------------------------------------------- |
|
|
27
|
+
| `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 |
|
|
30
|
+
| `rooms` | `string[]` | `[]` | Rooms joined on every (re)connect |
|
|
31
|
+
| `auth` | `() => unknown \| Promise<unknown>` | — | Auth payload; called before _every_ (re)connect |
|
|
32
|
+
| `autoConnect` | `boolean` | `true` | First `subscribe()`/`publish()` starts the connection |
|
|
33
|
+
| `logger` | `Logger \| null` | `createClog("ws")` | `null` silences |
|
|
34
|
+
| `reconnectDelay` | `number` | `500` | Initial backoff, ms |
|
|
35
|
+
| `reconnectDelayMax` | `number` | `30_000` | Backoff ceiling, ms |
|
|
36
|
+
| `terminalCloseCodes` | `number[]` | `[4001, 4003]` | Codes after which retrying stops |
|
|
37
|
+
| `pingInterval` | `number` | `25_000` | Ping cadence, ms. `0` disables |
|
|
38
|
+
| `pongTimeout` | `number` | `10_000` | Liveness deadline; also bounds the auth handshake |
|
|
39
|
+
| `connectTimeout` | `number` | `0` | Bounds the first `connect()` await. `0` waits indefinitely |
|
|
40
|
+
| `sendTimeout` | `number` | `30_000` | Per-send deadline covering queue + flight + ack |
|
|
41
|
+
| `outboxMaxSize` | `number` | `100` | Frames buffered while offline. `0` disables buffering |
|
|
42
|
+
| `onOutboxDrop` | `(frames: ClientFrame[]) => void` | — | Called with evicted frames |
|
|
43
|
+
| `encode` / `decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the server's |
|
|
44
|
+
|
|
45
|
+
**Returns** `WSClient`
|
|
46
|
+
|
|
47
|
+
**Example**
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { createWSClient } from "@marianmeres/ws";
|
|
51
|
+
|
|
52
|
+
const ws = createWSClient({
|
|
53
|
+
url: "wss://example.com/ws",
|
|
54
|
+
namespace: "org-123",
|
|
55
|
+
auth: () => session.token, // re-read on every reconnect
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const unsub = await ws.subscribe("chat", (msg) => {
|
|
59
|
+
console.log(msg.from, msg.payload, msg.timestamp);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { recipients } = await ws.publish("chat", { text: "hello" });
|
|
63
|
+
|
|
64
|
+
unsub();
|
|
65
|
+
ws.dispose();
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### `WSClient`
|
|
71
|
+
|
|
72
|
+
The type behind `createWSClient()`. Generic over the auth payload:
|
|
73
|
+
`WSClient<TAuth>`.
|
|
74
|
+
|
|
75
|
+
#### Lifecycle
|
|
76
|
+
|
|
77
|
+
##### `connect(): Promise<void>`
|
|
78
|
+
|
|
79
|
+
Starts the connection and resolves once authenticated.
|
|
80
|
+
|
|
81
|
+
Idempotent — concurrent calls share one promise, and it resolves immediately
|
|
82
|
+
when already connected. Optional when `autoConnect` is on; it is a readiness
|
|
83
|
+
gate, not a prerequisite.
|
|
84
|
+
|
|
85
|
+
Rejects **only** where retrying cannot help:
|
|
86
|
+
|
|
87
|
+
- `WSTerminatedError` — a terminal close code
|
|
88
|
+
- `WSConnectTimeoutError` — `connectTimeout` elapsed. Retrying continues in the
|
|
89
|
+
background, so this bounds _your await_, not the connection attempt
|
|
90
|
+
- `WSDisposedError` — the client was disposed
|
|
91
|
+
|
|
92
|
+
Ordinary network failure never rejects; that is what the infinite retry is for.
|
|
93
|
+
|
|
94
|
+
##### `disconnect(): void`
|
|
95
|
+
|
|
96
|
+
Stops retrying and closes the socket. **Resumable** — handlers, room
|
|
97
|
+
subscriptions and buffered sends all survive, so a later `connect()` picks up
|
|
98
|
+
where it left off.
|
|
99
|
+
|
|
100
|
+
##### `dispose(): void`
|
|
101
|
+
|
|
102
|
+
Terminal teardown: disconnects, then drops every handler, room, timer and
|
|
103
|
+
pending promise. Pending sends reject with `WSDisposedError`. The instance is
|
|
104
|
+
unusable afterwards.
|
|
105
|
+
|
|
106
|
+
#### Subscriptions
|
|
107
|
+
|
|
108
|
+
##### `subscribe<T>(room, handler, options?): Promise<Unsubscriber>`
|
|
109
|
+
|
|
110
|
+
Subscribes to a room and attaches a handler.
|
|
111
|
+
|
|
112
|
+
The handler is attached **synchronously**, before any frame goes out, so
|
|
113
|
+
nothing arriving between the request and its acknowledgement is lost.
|
|
114
|
+
|
|
115
|
+
Rooms are refcounted: N handlers produce one wire subscription, and the returned
|
|
116
|
+
unsubscriber detaches only this handler — the `unsub` frame goes out when it was
|
|
117
|
+
the last one. The unsubscriber is idempotent and `Symbol.dispose`-compatible.
|
|
118
|
+
|
|
119
|
+
When connected this awaits the server's acknowledgement, so a refused
|
|
120
|
+
subscription rejects here. When not connected it resolves once the room is
|
|
121
|
+
registered; the subscription is then established by the re-subscribe on the next
|
|
122
|
+
connect, and a failure there surfaces as an `error` event.
|
|
123
|
+
|
|
124
|
+
**Parameters**
|
|
125
|
+
|
|
126
|
+
- `room` (string) — room name, scoped to this client's namespace
|
|
127
|
+
- `handler` (`MessageHandler<T>`) — receives every message published to the room
|
|
128
|
+
- `options.presence` (`PresenceHandler`, optional) — enables presence for this
|
|
129
|
+
room
|
|
130
|
+
|
|
131
|
+
**Example**
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const unsub = await ws.subscribe("room", (msg) => render(msg.payload), {
|
|
135
|
+
presence: (e) => {
|
|
136
|
+
// e.event is "sync" | "join" | "leave"
|
|
137
|
+
setMembers(e.members);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
##### `unsubscribe(room): Promise<void>`
|
|
143
|
+
|
|
144
|
+
Removes **every** handler for a room and unsubscribes it — the blunt
|
|
145
|
+
counterpart to the refcounted unsubscriber above. Unknown rooms are a no-op.
|
|
146
|
+
|
|
147
|
+
##### `isSubscribed(room): boolean`
|
|
148
|
+
|
|
149
|
+
Whether the room is held locally. Reflects local intent, not server state: a
|
|
150
|
+
room registered while offline reads `true` before the wire subscription exists.
|
|
151
|
+
|
|
152
|
+
##### `members(room): string[]`
|
|
153
|
+
|
|
154
|
+
Last known membership of a presence-enabled room. Empty for rooms without
|
|
155
|
+
presence.
|
|
156
|
+
|
|
157
|
+
#### Sending
|
|
158
|
+
|
|
159
|
+
##### `publish<T>(room, payload, namespace?): Promise<WSPublishResult>`
|
|
160
|
+
|
|
161
|
+
Publishes to a room within this client's namespace. Resolves with the recipient
|
|
162
|
+
count once the server acknowledges.
|
|
163
|
+
|
|
164
|
+
While disconnected the frame is buffered and the promise stays pending until it
|
|
165
|
+
flushes — bounded by `sendTimeout`, never indefinitely.
|
|
166
|
+
|
|
167
|
+
`namespace` must equal the client's own; the server rejects anything else, so it
|
|
168
|
+
is only useful for asserting the expected one.
|
|
169
|
+
|
|
170
|
+
**Throws** `WSTimeoutError`, `WSOutboxDropError`, `WSNotConnectedError`,
|
|
171
|
+
`WSRemoteError`, `WSDisposedError`
|
|
172
|
+
|
|
173
|
+
##### `broadcast<T>(room, payload): Promise<WSPublishResult>`
|
|
174
|
+
|
|
175
|
+
Publishes to a room across **all** namespaces.
|
|
176
|
+
|
|
177
|
+
A separate method rather than a flag on `publish()` because crossing an
|
|
178
|
+
isolation boundary deserves its own name and its own server-side check:
|
|
179
|
+
`allowBroadcast` **denies by default**, and a refusal arrives as a
|
|
180
|
+
`WSRemoteError` with code `"forbidden"`.
|
|
181
|
+
|
|
182
|
+
#### Events
|
|
183
|
+
|
|
184
|
+
##### `on<K>(event, cb): Unsubscriber` / `once<K>(event, cb): Unsubscriber`
|
|
185
|
+
|
|
186
|
+
Subscribe to a lifecycle event; see [`WSEvents`](#wsevents). The returned
|
|
187
|
+
unsubscriber is `Symbol.dispose`-compatible.
|
|
188
|
+
|
|
189
|
+
#### Properties
|
|
190
|
+
|
|
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 |
|
|
203
|
+
|
|
204
|
+
##### `WSClient.resolveUrl(input): URL` (static)
|
|
205
|
+
|
|
206
|
+
Normalizes an endpoint: relative paths resolve against `location`, and
|
|
207
|
+
`http(s)` is upgraded to `ws(s)`. Throws `WSError` when it cannot resolve —
|
|
208
|
+
outside a browser there is no `location` for a relative path.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
### `backoffDelay(attempt, base, max, rnd?)`
|
|
213
|
+
|
|
214
|
+
Exponential backoff with **equal jitter**. Returns a delay in `[d/2, d]` where
|
|
215
|
+
`d = min(max, base * 2^(attempt-1))`.
|
|
216
|
+
|
|
217
|
+
Equal jitter rather than full jitter: full jitter can produce near-zero waits,
|
|
218
|
+
which means a server coming back up gets hammered by the very clients it just
|
|
219
|
+
dropped.
|
|
220
|
+
|
|
221
|
+
**Parameters**
|
|
222
|
+
|
|
223
|
+
- `attempt` (number) — 1-based attempt number
|
|
224
|
+
- `base` (number) — initial delay in ms
|
|
225
|
+
- `max` (number) — ceiling in ms
|
|
226
|
+
- `rnd` (`() => number`, optional) — randomness source. Default `Math.random`
|
|
227
|
+
|
|
228
|
+
**Returns** `number` — delay in ms
|
|
229
|
+
|
|
230
|
+
Exported mainly so the curve is testable.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Client types
|
|
235
|
+
|
|
236
|
+
### `WSClientOptions<TAuth>`
|
|
237
|
+
|
|
238
|
+
The options object documented under
|
|
239
|
+
[`createWSClient`](#createwsclientoptions).
|
|
240
|
+
|
|
241
|
+
### `SubscribeOptions`
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
{
|
|
245
|
+
presence?: PresenceHandler;
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Presence is enabled by _providing a handler_ rather than by a separate boolean —
|
|
250
|
+
one way to express the intent instead of two that can disagree. It is opt-in
|
|
251
|
+
per room because a 10k-subscriber room does not want a join event per peer every
|
|
252
|
+
time the fleet reconnects.
|
|
253
|
+
|
|
254
|
+
### `MessageHandler<T>` / `PresenceHandler`
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
type MessageHandler<T = unknown> = (msg: WSMessage<T>) => void;
|
|
258
|
+
type PresenceHandler = (event: WSPresenceEvent) => void;
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
A throwing handler is caught, reported through the `error` event, and does not
|
|
262
|
+
stop delivery to the others.
|
|
263
|
+
|
|
264
|
+
### `WSEvents`
|
|
265
|
+
|
|
266
|
+
| Event | Payload |
|
|
267
|
+
| -------------- | ---------------------------------- |
|
|
268
|
+
| `open` | `void` — socket open, pre-auth |
|
|
269
|
+
| `connected` | `{ clientId, namespace }` |
|
|
270
|
+
| `message` | `WSMessage` — firehose, every room |
|
|
271
|
+
| `presence` | `WSPresenceEvent` |
|
|
272
|
+
| `close` | `{ code, reason, willReconnect }` |
|
|
273
|
+
| `reconnecting` | `{ attempt, delay }` |
|
|
274
|
+
| `terminated` | `{ code, reason }` — gave up |
|
|
275
|
+
| `error` | `Error` |
|
|
276
|
+
|
|
277
|
+
`error` means something failed but the client carried on (a decode failure, a
|
|
278
|
+
throwing handler). `terminated` is the only non-retrying exit.
|
|
279
|
+
|
|
280
|
+
### `WSState`
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
{
|
|
284
|
+
state: WSConnectionState;
|
|
285
|
+
connected: boolean; // true only in "open"
|
|
286
|
+
connecting: boolean; // connecting | authenticating | reconnecting
|
|
287
|
+
attempt: number; // consecutive failures; resets to 0 on success
|
|
288
|
+
lastError: Error | null;
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Delivered through the Svelte store contract, so `$state` works directly in a
|
|
293
|
+
component and any other store-compatible consumer works too:
|
|
294
|
+
|
|
295
|
+
```svelte
|
|
296
|
+
<script>
|
|
297
|
+
const state = ws.state;
|
|
298
|
+
</script>
|
|
299
|
+
|
|
300
|
+
{#if $state.connected}<Online />{:else if $state.attempt > 0}
|
|
301
|
+
<p>Reconnecting… (attempt {$state.attempt})</p>
|
|
302
|
+
{/if}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### `WSConnectionState`
|
|
306
|
+
|
|
307
|
+
`"idle" | "connecting" | "authenticating" | "open" | "reconnecting" | "terminated" | "disposed"`
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Server
|
|
312
|
+
|
|
313
|
+
Import from `@marianmeres/ws/server`. **JSR only** — it needs
|
|
314
|
+
`Deno.upgradeWebSocket`, so the npm package ships the client alone.
|
|
315
|
+
|
|
316
|
+
### `createWSApp(mountPath?, middlewares?, options?)`
|
|
317
|
+
|
|
318
|
+
Creates a mountable demino app plus the service it is wired to.
|
|
319
|
+
|
|
320
|
+
**Parameters**
|
|
321
|
+
|
|
322
|
+
| Name | Type | Default | Description |
|
|
323
|
+
| ---------------------------- | -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ |
|
|
324
|
+
| `mountPath` | `string` | `"/ws"` | Demino mount path |
|
|
325
|
+
| `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
|
|
326
|
+
| `options.verify` | `(payload, req) => AuthResult \| null` | — | Return `null` (or throw) to reject with `4001`. Absent means no authentication |
|
|
327
|
+
| `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
|
|
328
|
+
| `options.httpAuth` | `DeminoHandler` | — | Guards the HTTP routes. **Without it they are not mounted** |
|
|
329
|
+
| `options.deminoOptions` | `DeminoOptions` | — | Passed through to `demino()` |
|
|
330
|
+
| `options.authTimeout` | `number` | `5_000` | Deadline for the `auth` frame → `4002` |
|
|
331
|
+
| `options.idleTimeout` | `number` | `60_000` | Reap silent connections → `4008` |
|
|
332
|
+
| `options.maxFrameSize` | `number` | `262144` | Oversized frames → `4013` |
|
|
333
|
+
| `options.maxFramesPerSecond` | `number` | `100` | Rate cap → `4009` |
|
|
334
|
+
| `options.adapter` | `WSPubSubAdapter` | `WSPubSubLocal` | Cross-instance fan-out |
|
|
335
|
+
| `options.logger` | `Logger \| null` | `createClog("ws:server")` | `null` silences |
|
|
336
|
+
| `options.encode` / `.decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the client's |
|
|
337
|
+
|
|
338
|
+
**Returns** `WSApp` — `{ app: Demino, service: WSService }`
|
|
339
|
+
|
|
340
|
+
**Routes**, relative to `mountPath`:
|
|
341
|
+
|
|
342
|
+
| Method | Path | Returns | Notes |
|
|
343
|
+
| ------ | ----------------------------- | --------------------------- | ----------------------------------------- |
|
|
344
|
+
| GET | `/` | 101, or 426 without upgrade | WebSocket upgrade |
|
|
345
|
+
| GET | `/stats` | `WSStats` | Guarded by `httpAuth` when supplied |
|
|
346
|
+
| POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
|
|
347
|
+
| POST | `/broadcast/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
|
|
348
|
+
|
|
349
|
+
The POST routes take the JSON request body as the message payload.
|
|
350
|
+
|
|
351
|
+
**Example**
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
import { createWSApp } from "@marianmeres/ws/server";
|
|
355
|
+
|
|
356
|
+
const { app, service } = createWSApp("/ws", [], {
|
|
357
|
+
verify: async (payload, req) => {
|
|
358
|
+
const user = await authenticate((payload as any)?.token);
|
|
359
|
+
// Returning null closes the socket with 4001.
|
|
360
|
+
return user ? { clientId: user.id, namespace: user.orgId } : null;
|
|
361
|
+
},
|
|
362
|
+
allowBroadcast: (ctx, room) => room === "announcements" && ctx.meta.admin === true,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await service.publish("notifications", { text: "deploy finished" }, "org-123");
|
|
366
|
+
|
|
367
|
+
Deno.serve(app);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### `WSService`
|
|
373
|
+
|
|
374
|
+
Owns every connection, the room index, presence and delivery. Usable standalone
|
|
375
|
+
— `new WSService(options)`, driven from any `Deno.serve` handler — or through
|
|
376
|
+
`createWSApp`, which mounts it as a demino app.
|
|
377
|
+
|
|
378
|
+
##### `handleUpgrade(request): Response`
|
|
379
|
+
|
|
380
|
+
Upgrades an HTTP request and takes ownership of the socket. Return the 101
|
|
381
|
+
response from your route handler unmodified.
|
|
382
|
+
|
|
383
|
+
##### `publish(room, payload, namespace?, from?): Promise<number>`
|
|
384
|
+
|
|
385
|
+
Injects a message from server-side code. Delivered messages carry `from: null`
|
|
386
|
+
unless you pass one, which is how clients tell server pushes from peer traffic.
|
|
387
|
+
|
|
388
|
+
`namespace` defaults to `"default"`. Resolves with the recipients on **this
|
|
389
|
+
instance**; peers are propagated to but not counted.
|
|
390
|
+
|
|
391
|
+
##### `broadcast(room, payload, from?): Promise<number>`
|
|
392
|
+
|
|
393
|
+
Publishes into a room across every namespace. `allowBroadcast` does not apply —
|
|
394
|
+
that gate exists to stop _clients_ crossing the boundary, and code calling this
|
|
395
|
+
is already inside the trust boundary.
|
|
396
|
+
|
|
397
|
+
##### `members(room, namespace?): string[]`
|
|
398
|
+
|
|
399
|
+
Every subscriber of the room, whether or not they asked for presence — presence
|
|
400
|
+
controls who gets _told_ about membership, not who counts as a member.
|
|
401
|
+
Instance-local.
|
|
402
|
+
|
|
403
|
+
##### `stats(): WSStats`
|
|
404
|
+
|
|
405
|
+
Counts only, never client ids, so it stays safe to expose unguarded in
|
|
406
|
+
development.
|
|
407
|
+
|
|
408
|
+
##### `close(): Promise<void>`
|
|
409
|
+
|
|
410
|
+
Closes every connection and releases all timers. Idempotent. Sockets close with
|
|
411
|
+
`1001 GOING_AWAY`, which is _recoverable_ — clients reconnect, which is what you
|
|
412
|
+
want for a rolling deploy.
|
|
413
|
+
|
|
414
|
+
##### `logger`
|
|
415
|
+
|
|
416
|
+
Assignable. Set to `null` to silence.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Server types
|
|
421
|
+
|
|
422
|
+
### `WSApp`
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
{
|
|
426
|
+
app: Demino; // mount it, or serve it directly
|
|
427
|
+
service: WSService; // inject messages, read stats, shut down
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### `WSAppOptions`
|
|
432
|
+
|
|
433
|
+
`WSServiceOptions` plus `httpAuth` and `deminoOptions` — see the
|
|
434
|
+
[`createWSApp` table](#createwsappmountpath-middlewares-options).
|
|
435
|
+
|
|
436
|
+
### `WSServiceOptions`
|
|
437
|
+
|
|
438
|
+
Everything in that table except `httpAuth` and `deminoOptions`.
|
|
439
|
+
|
|
440
|
+
### `WSConnectionContext`
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
{
|
|
444
|
+
clientId: string;
|
|
445
|
+
namespace: string;
|
|
446
|
+
meta: Record<string, unknown>; // whatever verify() returned
|
|
447
|
+
request: Request; // the original upgrade request
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Passed to `allowBroadcast`.
|
|
452
|
+
|
|
453
|
+
### `WSStats`
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
{
|
|
457
|
+
connections: number; // authenticated
|
|
458
|
+
pending: number; // not yet authenticated
|
|
459
|
+
rooms: number; // distinct room names in use
|
|
460
|
+
namespaces: Record<string, number>; // connections per namespace
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### `WSPubSubAdapter`
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
publish(envelope: WSBroadcastEnvelope): Promise<void>
|
|
468
|
+
onRemote(cb: (envelope: WSBroadcastEnvelope) => void): () => void
|
|
469
|
+
close(): Promise<void>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Local delivery is always the service's job; an adapter only propagates to peer
|
|
473
|
+
instances and receives what peers send. That division is why `recipients` counts
|
|
474
|
+
are instance-local and documented as best-effort telemetry.
|
|
475
|
+
|
|
476
|
+
A rejection from `publish()` is logged and swallowed — failed gossip must not
|
|
477
|
+
fail a publish that already succeeded locally.
|
|
478
|
+
|
|
479
|
+
### `WSPubSubLocal`
|
|
480
|
+
|
|
481
|
+
The default adapter, and the only one that ships today: there are no peer
|
|
482
|
+
instances, so propagation is a no-op. Everything still works — the service
|
|
483
|
+
delivers locally regardless of adapter. Redis / Deno-KV adapters are an
|
|
484
|
+
unimplemented seam.
|
|
485
|
+
|
|
486
|
+
### `WSBroadcastEnvelope`
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
{
|
|
490
|
+
namespace: string | null; // null for a cross-namespace broadcast
|
|
491
|
+
message: WSMessage;
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Protocol
|
|
498
|
+
|
|
499
|
+
Also re-exported from `@marianmeres/ws`. Dependency-free, for anyone
|
|
500
|
+
implementing this protocol against a different server or client.
|
|
501
|
+
|
|
502
|
+
### `WSMessage<T>`
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
{
|
|
506
|
+
room: string;
|
|
507
|
+
namespace: string;
|
|
508
|
+
from: string | null;
|
|
509
|
+
payload: T;
|
|
510
|
+
timestamp: number; // server-assigned epoch ms
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`from` is `null` when the message was injected server-side. For a broadcast,
|
|
515
|
+
`namespace` is the receiver's own — not the sender's.
|
|
516
|
+
|
|
517
|
+
`payload` is **opaque**: never inspected, never mutated. Your payload may carry
|
|
518
|
+
its own `type` field and nothing collides.
|
|
519
|
+
|
|
520
|
+
### `WSPresenceEvent`
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
{
|
|
524
|
+
event: "sync" | "join" | "leave";
|
|
525
|
+
room: string;
|
|
526
|
+
namespace: string;
|
|
527
|
+
clientId: string | null; // who joined/left; null for sync
|
|
528
|
+
members: string[]; // full membership after this event
|
|
529
|
+
timestamp: number;
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
`sync` carries the full snapshot and fires on every (re)subscribe, including
|
|
534
|
+
after a reconnect — membership may have changed entirely while the client was
|
|
535
|
+
away.
|
|
536
|
+
|
|
537
|
+
### `WSPublishResult`
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
{
|
|
541
|
+
recipients: number;
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Sockets the message was handed to **on the receiving server instance**.
|
|
546
|
+
Best-effort telemetry, never a delivery guarantee.
|
|
547
|
+
|
|
548
|
+
### `AuthResult`
|
|
549
|
+
|
|
550
|
+
What the server's `verify()` hook returns. `null` rejects the connection.
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
{
|
|
554
|
+
clientId?: string; // default: generated
|
|
555
|
+
namespace?: string; // overrides the client's request
|
|
556
|
+
meta?: Record<string, unknown>; // surfaces on WSConnectionContext
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### `WSErrorInfo`
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
{
|
|
564
|
+
code: string; // machine-readable — see ERROR_CODE
|
|
565
|
+
message: string; // human-readable. Never parse this
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### `SubRequest`
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
{
|
|
573
|
+
room: string;
|
|
574
|
+
presence?: boolean;
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### `ClientFrame` / `ServerFrame` / `WSFrame`
|
|
579
|
+
|
|
580
|
+
Discriminated unions over `FRAME`, keyed on `type`. `WSFrame` is either
|
|
581
|
+
direction. You need these only to write a custom `encode`/`decode` or a
|
|
582
|
+
third-party implementation.
|
|
583
|
+
|
|
584
|
+
| Direction | Frames |
|
|
585
|
+
| --------------- | ---------------------------------------------------------- |
|
|
586
|
+
| client → server | `auth`, `sub`, `unsub`, `pub`, `broadcast`, `ping` |
|
|
587
|
+
| server → client | `hello`, `ack`, `nack`, `msg`, `presence`, `pong`, `error` |
|
|
588
|
+
|
|
589
|
+
A `msg` frame minus its `type` field _is_ a `WSMessage` — no translation layer,
|
|
590
|
+
no divergence between wire names and API names.
|
|
591
|
+
|
|
592
|
+
### `PresenceEventType`
|
|
593
|
+
|
|
594
|
+
`"sync" | "join" | "leave"` — the value union of `PRESENCE`.
|
|
595
|
+
|
|
596
|
+
### `WSEncoder` / `WSDecoder`
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
type WSEncoder = (frame: WSFrame) => string | ArrayBufferView | ArrayBuffer;
|
|
600
|
+
type WSDecoder = (raw: string | ArrayBuffer) => WSFrame;
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Default to JSON on both sides. Override both ends together — a mismatch closes
|
|
604
|
+
the socket with `4400 PROTOCOL_ERROR`.
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Errors
|
|
609
|
+
|
|
610
|
+
All extend `WSError`, so callers can branch on `instanceof` rather than
|
|
611
|
+
string-matching messages.
|
|
612
|
+
|
|
613
|
+
| Error | Thrown when | Extra |
|
|
614
|
+
| ----------------------- | ----------------------------------------------- | ---------------- |
|
|
615
|
+
| `WSTerminatedError` | Terminal close code | `code`, `reason` |
|
|
616
|
+
| `WSConnectTimeoutError` | `connectTimeout` elapsed (retrying continues) | |
|
|
617
|
+
| `WSTimeoutError` | `sendTimeout` elapsed with no acknowledgement | |
|
|
618
|
+
| `WSOutboxDropError` | Evicted from a full outbox | |
|
|
619
|
+
| `WSRemoteError` | Server sent a `nack` | `code` |
|
|
620
|
+
| `WSNotConnectedError` | Sent while disconnected with `outboxMaxSize: 0` | |
|
|
621
|
+
| `WSDisposedError` | Client was disposed | |
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
## Constants
|
|
626
|
+
|
|
627
|
+
### `PROTOCOL_VERSION`
|
|
628
|
+
|
|
629
|
+
`1`. Announced by the server in `hello`; a mismatch warns rather than fails.
|
|
630
|
+
|
|
631
|
+
### `DEFAULT_NAMESPACE`
|
|
632
|
+
|
|
633
|
+
`"default"` — used when the client does not specify one.
|
|
634
|
+
|
|
635
|
+
### `CLOSE`
|
|
636
|
+
|
|
637
|
+
WebSocket close codes. The `4xxx` range is reserved for application use by
|
|
638
|
+
RFC 6455.
|
|
639
|
+
|
|
640
|
+
| Name | Code | Reconnects? |
|
|
641
|
+
| ----------------- | ---- | -------------------- |
|
|
642
|
+
| `NORMAL` | 1000 | yes (server restart) |
|
|
643
|
+
| `GOING_AWAY` | 1001 | yes |
|
|
644
|
+
| `ABNORMAL` | 1006 | yes |
|
|
645
|
+
| `INTERNAL_ERROR` | 1011 | yes |
|
|
646
|
+
| `AUTH_FAILED` | 4001 | **no** |
|
|
647
|
+
| `AUTH_TIMEOUT` | 4002 | yes |
|
|
648
|
+
| `FORBIDDEN` | 4003 | **no** |
|
|
649
|
+
| `IDLE_TIMEOUT` | 4008 | yes |
|
|
650
|
+
| `RATE_LIMITED` | 4009 | yes |
|
|
651
|
+
| `FRAME_TOO_LARGE` | 4013 | yes |
|
|
652
|
+
| `PROTOCOL_ERROR` | 4400 | yes |
|
|
653
|
+
| `CLIENT_GONE` | 4900 | n/a (local) |
|
|
654
|
+
|
|
655
|
+
### `DEFAULT_TERMINAL_CLOSE_CODES`
|
|
656
|
+
|
|
657
|
+
`[4001, 4003]` — the default for `terminalCloseCodes`. Everything _not_ listed
|
|
658
|
+
reconnects, including a server-sent `1000`.
|
|
659
|
+
|
|
660
|
+
### `FRAME`
|
|
661
|
+
|
|
662
|
+
Frame type discriminators — the `type` field of every frame. See
|
|
663
|
+
[`ClientFrame` / `ServerFrame`](#clientframe--serverframe--wsframe).
|
|
664
|
+
|
|
665
|
+
### `ERROR_CODE`
|
|
666
|
+
|
|
667
|
+
`"unauthorized" | "forbidden" | "bad_request" | "rate_limited" | "internal"` —
|
|
668
|
+
the `code` on `WSErrorInfo` and `WSRemoteError`.
|
|
669
|
+
|
|
670
|
+
### `PRESENCE`
|
|
671
|
+
|
|
672
|
+
`"sync" | "join" | "leave"` — presence event discriminators.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marian Meres
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|