@marianmeres/ws 0.3.0 → 0.4.1
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 +38 -11
- package/API.md +129 -30
- package/README.md +32 -7
- package/dist/client/outbox.d.ts +11 -3
- package/dist/client/outbox.js +21 -7
- package/dist/client/ws-client.d.ts +10 -1
- package/dist/client/ws-client.js +83 -15
- package/dist/protocol/errors.d.ts +9 -0
- package/dist/protocol/errors.js +11 -0
- package/dist/protocol/frames.d.ts +10 -0
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -43,11 +43,13 @@ example/ # standalone reference app — `deno task example`
|
|
|
43
43
|
├── server.ts # demino apps: /ws (createWSApp), /api, / (static)
|
|
44
44
|
├── history.ts # WSPubSubAdapter decorator -> in-memory backlog
|
|
45
45
|
├── shared.ts # the APPLICATION protocol (what goes in `payload`)
|
|
46
|
+
├── build-styles.ts # generates public/theme.css, copies vui-base.css
|
|
46
47
|
├── client/ # @marianmeres/vanilla views + store, bundled by
|
|
47
48
|
│ # @marianmeres/deno-build to public/dist/bundle.js
|
|
48
|
-
└── public/ # index.html +
|
|
49
|
-
#
|
|
50
|
-
#
|
|
49
|
+
└── public/ # index.html + CSS. theme.css (design tokens, prefix
|
|
50
|
+
# "vui-") and vui-base.css (@marianmeres/vanilla-ui's
|
|
51
|
+
# base style layer, copied verbatim) are generated by
|
|
52
|
+
# build-styles.ts and ARE committed; dist/ is not
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
`src/server.ts` and `src/protocol.ts` exist because the npm build maps subpath
|
|
@@ -73,9 +75,24 @@ not registered yet. There is a test that fails if you break it
|
|
|
73
75
|
**`sub`/`unsub` bypass the outbox.** They are replayed wholesale by the
|
|
74
76
|
re-subscribe step, so buffering them too would apply them twice.
|
|
75
77
|
|
|
78
|
+
**A control-frame promise the client does not await must carry a `.catch()`.**
|
|
79
|
+
`#sendControl` tracks the frame in the outbox, so its promise rejects on dispose,
|
|
80
|
+
on a terminal close and on the send timeout. An unawaited one — the unsubscriber's
|
|
81
|
+
`unsub`, the `#onHello` re-subscribe — is an uncaught rejection that exits a Deno
|
|
82
|
+
or Node process.
|
|
83
|
+
|
|
84
|
+
**The server validates every frame field before it uses it, and wraps the whole
|
|
85
|
+
dispatch.** A malformed but parseable frame is a `nack`/`error` `bad_request`
|
|
86
|
+
and the socket stays open; an unexpected throw is `error` `internal` and a 1011
|
|
87
|
+
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.
|
|
89
|
+
|
|
76
90
|
**Every send carries one deadline** spanning queue + flight + ack — not an
|
|
77
91
|
ack-only timeout. Combining acks with infinite retry otherwise produces promises
|
|
78
|
-
that pend forever.
|
|
92
|
+
that pend forever. And a socket close settles in-flight frames at once, rather
|
|
93
|
+
than waiting out a deadline for an answer that can no longer arrive: `sub` and
|
|
94
|
+
`unsub` resolve, everything else rejects with `WSConnectionLostError`. Queued
|
|
95
|
+
frames are untouched — they are still waiting for a connection, not an ack.
|
|
79
96
|
|
|
80
97
|
**The pong deadline is not restarted by later pings.** It measures time since
|
|
81
98
|
the _oldest_ unanswered ping. Restarting it means any `pingInterval <=
|
|
@@ -87,7 +104,13 @@ checks its captured generation. Without it a slow `onclose` from a dead socket
|
|
|
87
104
|
cancels the reconnect that replaced it.
|
|
88
105
|
|
|
89
106
|
**Safe defaults are deny.** `allowBroadcast` denies; HTTP injection routes are
|
|
90
|
-
not mounted without `httpAuth`. Do not "helpfully" relax either.
|
|
107
|
+
not mounted without `httpAuth`. Do not "helpfully" relax either. The documented
|
|
108
|
+
exception: `clientId` and `namespace` fall back to what the client asked for
|
|
109
|
+
(assigned → requested → generated), so a multi-tenant `verify` must return both.
|
|
110
|
+
Its third argument carries the client's proposals so they can be validated there
|
|
111
|
+
instead of being duplicated into the auth payload. `allowedOrigins` is the other
|
|
112
|
+
exception — opt-in, because a default allow-list would break every non-browser
|
|
113
|
+
deployment; unset means no check at all.
|
|
91
114
|
|
|
92
115
|
**Delivery is at-most-once.** Transmitted-but-unacked sends are never resent.
|
|
93
116
|
If that changes, the server needs deduplication first.
|
|
@@ -122,7 +145,9 @@ run `deno publish` then the npm build.
|
|
|
122
145
|
|
|
123
146
|
## Before Making Changes
|
|
124
147
|
|
|
125
|
-
1. `deno task test` —
|
|
148
|
+
1. `deno task test` — 66 tests, mostly real sockets against a real server:
|
|
149
|
+
`unit`, `integration`, `resilience`, `protocol` (server input hardening,
|
|
150
|
+
raw sockets) and `codec` (custom encode/decode, binary frames)
|
|
126
151
|
2. `deno lint && deno fmt --check && deno check src/mod.ts src/server.ts src/protocol.ts`
|
|
127
152
|
3. Touched a public signature? `deno doc --lint src/mod.ts src/server.ts
|
|
128
153
|
src/protocol.ts` **and** `deno publish --dry-run --allow-dirty`
|
|
@@ -146,14 +171,16 @@ run `deno publish` then the npm build.
|
|
|
146
171
|
- Presence is scoped to `(room, namespace)`; broadcast crosses namespaces but
|
|
147
172
|
presence does not
|
|
148
173
|
- Node/Bun cannot run the server (`Deno.upgradeWebSocket`)
|
|
174
|
+
- Origin checking is opt-in via `allowedOrigins`; unset means no check
|
|
149
175
|
|
|
150
176
|
## Documentation Index
|
|
151
177
|
|
|
152
|
-
| Document | Purpose
|
|
153
|
-
| ------------------- |
|
|
154
|
-
| `README.md` | Human-facing overview and usage
|
|
155
|
-
| `API.md` | Complete API reference — every public export
|
|
156
|
-
| `
|
|
178
|
+
| Document | Purpose |
|
|
179
|
+
| ------------------- | ------------------------------------------------------------- |
|
|
180
|
+
| `README.md` | Human-facing overview and usage |
|
|
181
|
+
| `API.md` | Complete API reference — every public export |
|
|
182
|
+
| `PROTOCOL.md` | Wire protocol spec + Python server, for other implementations |
|
|
183
|
+
| `example/README.md` | The reference app: what it demonstrates, and how |
|
|
157
184
|
|
|
158
185
|
`tmp/spec.md` (design spec and decision log) is referenced in some commits but
|
|
159
186
|
is **untracked** — `tmp/*` is gitignored, so it does not exist in a fresh clone.
|
package/API.md
CHANGED
|
@@ -42,6 +42,11 @@ following the `PubSub` / `createPubSub` precedent.
|
|
|
42
42
|
| `onOutboxDrop` | `(frames: ClientFrame[]) => void` | — | Called with evicted frames |
|
|
43
43
|
| `encode` / `decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the server's |
|
|
44
44
|
|
|
45
|
+
`pingInterval: 0` disables the client's heartbeat, not the server's reaper: the
|
|
46
|
+
reference server still closes a connection that sent nothing for `idleTimeout`
|
|
47
|
+
(60 s) with `4008`, so a heartbeat-free client reconnects roughly every minute.
|
|
48
|
+
Disable both or neither.
|
|
49
|
+
|
|
45
50
|
**Returns** `WSClient`
|
|
46
51
|
|
|
47
52
|
**Example**
|
|
@@ -84,10 +89,14 @@ gate, not a prerequisite.
|
|
|
84
89
|
|
|
85
90
|
Rejects **only** where retrying cannot help:
|
|
86
91
|
|
|
87
|
-
- `WSTerminatedError` — a terminal close code
|
|
92
|
+
- `WSTerminatedError` — a terminal close code, or code `4900` when
|
|
93
|
+
`disconnect()` (or `dispose()`, which disconnects first) is called while this
|
|
94
|
+
is still pending
|
|
88
95
|
- `WSConnectTimeoutError` — `connectTimeout` elapsed. Retrying continues in the
|
|
89
96
|
background, so this bounds _your await_, not the connection attempt
|
|
90
|
-
- `WSDisposedError` —
|
|
97
|
+
- `WSDisposedError` — called on an already disposed client. A `dispose()`
|
|
98
|
+
_during_ a pending connect settles it with the `4900` `WSTerminatedError`
|
|
99
|
+
above, not with this
|
|
91
100
|
|
|
92
101
|
Ordinary network failure never rejects; that is what the infinite retry is for.
|
|
93
102
|
|
|
@@ -97,6 +106,10 @@ Stops retrying and closes the socket. **Resumable** — handlers, room
|
|
|
97
106
|
subscriptions and buffered sends all survive, so a later `connect()` picks up
|
|
98
107
|
where it left off.
|
|
99
108
|
|
|
109
|
+
Emits `close` with code `4900` (`CLOSE.CLIENT_GONE`) and `willReconnect: false`
|
|
110
|
+
when there was a socket to close; nothing when already idle, reconnecting or
|
|
111
|
+
terminated.
|
|
112
|
+
|
|
100
113
|
##### `dispose(): void`
|
|
101
114
|
|
|
102
115
|
Terminal teardown: disconnects, then drops every handler, room, timer and
|
|
@@ -162,13 +175,20 @@ Publishes to a room within this client's namespace. Resolves with the recipient
|
|
|
162
175
|
count once the server acknowledges.
|
|
163
176
|
|
|
164
177
|
While disconnected the frame is buffered and the promise stays pending until it
|
|
165
|
-
flushes — bounded by `sendTimeout`, never indefinitely.
|
|
178
|
+
flushes — bounded by `sendTimeout`, never indefinitely. A frame already in
|
|
179
|
+
flight when the socket closes rejects there and then with
|
|
180
|
+
`WSConnectionLostError`; it is not resent. After a terminal close nothing is
|
|
181
|
+
buffered at all: the promise rejects immediately with `WSTerminatedError`,
|
|
182
|
+
because only an explicit `connect()` leaves that state.
|
|
166
183
|
|
|
167
184
|
`namespace` must equal the client's own; the server rejects anything else, so it
|
|
168
185
|
is only useful for asserting the expected one.
|
|
169
186
|
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
A payload `encode` refuses — a `BigInt` is enough for the JSON default — rejects
|
|
188
|
+
with the encoder's own error, unwrapped, and also surfaces as an `error` event.
|
|
189
|
+
|
|
190
|
+
**Throws** `WSTimeoutError`, `WSConnectionLostError`, `WSOutboxDropError`,
|
|
191
|
+
`WSNotConnectedError`, `WSTerminatedError`, `WSRemoteError`, `WSDisposedError`
|
|
172
192
|
|
|
173
193
|
##### `broadcast<T>(room, payload): Promise<WSPublishResult>`
|
|
174
194
|
|
|
@@ -277,6 +297,9 @@ stop delivery to the others.
|
|
|
277
297
|
`error` means something failed but the client carried on (a decode failure, a
|
|
278
298
|
throwing handler). `terminated` is the only non-retrying exit.
|
|
279
299
|
|
|
300
|
+
A local `disconnect()` is a `close` too: code `4900`, `willReconnect: false` —
|
|
301
|
+
that pair is how a deliberate teardown is told apart from a lost connection.
|
|
302
|
+
|
|
280
303
|
### `WSState`
|
|
281
304
|
|
|
282
305
|
```typescript
|
|
@@ -319,34 +342,36 @@ Creates a mountable demino app plus the service it is wired to.
|
|
|
319
342
|
|
|
320
343
|
**Parameters**
|
|
321
344
|
|
|
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.
|
|
345
|
+
| Name | Type | Default | Description |
|
|
346
|
+
| ---------------------------- | ------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
347
|
+
| `mountPath` | `string` | `"/ws"` | Demino mount path |
|
|
348
|
+
| `middlewares` | `DeminoHandler[]` | `[]` | Applied to all routes |
|
|
349
|
+
| `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
|
+
| `options.allowedOrigins` | `string[] \| (origin, req) => boolean` | — (no check) | Origins allowed to upgrade → `403` — see [below](#security-cross-site-websocket-hijacking) |
|
|
351
|
+
| `options.allowBroadcast` | `(ctx, room) => boolean` | **deny** | Gate for cross-namespace broadcast |
|
|
352
|
+
| `options.httpAuth` | `DeminoHandler` | — | Guards the HTTP routes. **Without it they are not mounted** |
|
|
353
|
+
| `options.deminoOptions` | `DeminoOptions` | — | Passed through to `demino()` |
|
|
354
|
+
| `options.authTimeout` | `number` | `5_000` | Deadline for the `auth` frame → `4002` |
|
|
355
|
+
| `options.idleTimeout` | `number` | `60_000` | Reap silent connections → `4008` |
|
|
356
|
+
| `options.maxFrameSize` | `number` | `262144` | Oversized frames → `4013` |
|
|
357
|
+
| `options.maxFramesPerSecond` | `number` | `100` | Rate cap → `4009` |
|
|
358
|
+
| `options.adapter` | `WSPubSubAdapter` | `WSPubSubLocal` | Cross-instance fan-out |
|
|
359
|
+
| `options.logger` | `Logger \| null` | `createClog("ws:server")` | `null` silences |
|
|
360
|
+
| `options.encode` / `.decode` | `WSEncoder` / `WSDecoder` | JSON | Must match the client's |
|
|
337
361
|
|
|
338
362
|
**Returns** `WSApp` — `{ app: Demino, service: WSService }`
|
|
339
363
|
|
|
340
364
|
**Routes**, relative to `mountPath`:
|
|
341
365
|
|
|
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 }`
|
|
366
|
+
| Method | Path | Returns | Notes |
|
|
367
|
+
| ------ | ----------------------------- | -------------------------- | ----------------------------------------- |
|
|
368
|
+
| GET | `/` | 101, 426 without upgrade | WebSocket upgrade, `403` on a bad origin |
|
|
369
|
+
| GET | `/stats` | `WSStats` | Requires `httpAuth`, else **not mounted** |
|
|
370
|
+
| POST | `/publish/[namespace]/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
|
|
371
|
+
| POST | `/broadcast/[room]` | `{ ok: true, recipients }` | Requires `httpAuth`, else **not mounted** |
|
|
348
372
|
|
|
349
|
-
The POST routes take the JSON request body as the message payload
|
|
373
|
+
The POST routes take the JSON request body as the message payload; a body that
|
|
374
|
+
is not valid JSON answers `400`.
|
|
350
375
|
|
|
351
376
|
**Example**
|
|
352
377
|
|
|
@@ -367,6 +392,63 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
|
|
|
367
392
|
Deno.serve(app);
|
|
368
393
|
```
|
|
369
394
|
|
|
395
|
+
#### Security: namespace isolation
|
|
396
|
+
|
|
397
|
+
Namespace is the isolation boundary and `clientId` is the identity peers see in
|
|
398
|
+
`from` — and a claimed id evicts whoever holds it. Both fall back to what the
|
|
399
|
+
client asked for:
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
assigned by verify → requested by the client → generated
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**In any multi-tenant deployment `verify` must return `namespace` and
|
|
406
|
+
`clientId`.** Return neither and the client's proposals are honoured verbatim,
|
|
407
|
+
so any authenticated user can enter any tenant. The third argument,
|
|
408
|
+
[`WSRequestedIdentity`](#wsrequestedidentity), carries those proposals, so they
|
|
409
|
+
can be validated there rather than duplicated into the auth payload:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
createWSApp("/ws", [], {
|
|
413
|
+
verify: async (payload, req, requested) => {
|
|
414
|
+
const user = await authenticate((payload as any)?.token);
|
|
415
|
+
if (!user) return null;
|
|
416
|
+
// The namespace is checked, not trusted — and assigned either way.
|
|
417
|
+
if (!user.orgs.includes(requested.namespace)) return null;
|
|
418
|
+
return { clientId: user.id, namespace: requested.namespace };
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
#### Security: cross-site WebSocket hijacking
|
|
424
|
+
|
|
425
|
+
The upgrade request is an ordinary browser request, so the browser attaches its
|
|
426
|
+
cookies for your origin no matter which site opened the socket. A `verify` that
|
|
427
|
+
authenticates from cookies therefore authenticates the attacker's page too —
|
|
428
|
+
same-origin policy does not apply to WebSockets, and there is no preflight.
|
|
429
|
+
|
|
430
|
+
`allowedOrigins` closes that, and is **opt-in**: unset, nothing is checked.
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
createWSApp("/ws", [], {
|
|
434
|
+
allowedOrigins: ["https://app.example"],
|
|
435
|
+
verify: (_payload, req) => sessionFromCookie(req),
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
An unlisted `Origin` is answered `403 Origin not allowed` and never reaches
|
|
440
|
+
`verify`. The array form **permits a missing `Origin`**, because only browsers
|
|
441
|
+
send the header and non-browser clients (the stock Deno client included) send
|
|
442
|
+
none — the check exists to stop browsers. Pass a function instead when that is
|
|
443
|
+
too lax, or when the allowed set is dynamic:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
allowedOrigins: (origin, req) => origin !== null && isTenantOrigin(origin),
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Token authentication in the `auth` payload is not exposed this way: another
|
|
450
|
+
site's page cannot read your token, only ride your cookies.
|
|
451
|
+
|
|
370
452
|
---
|
|
371
453
|
|
|
372
454
|
### `WSService`
|
|
@@ -378,7 +460,8 @@ Owns every connection, the room index, presence and delivery. Usable standalone
|
|
|
378
460
|
##### `handleUpgrade(request): Response`
|
|
379
461
|
|
|
380
462
|
Upgrades an HTTP request and takes ownership of the socket. Return the 101
|
|
381
|
-
response from your route handler unmodified.
|
|
463
|
+
response from your route handler unmodified. With `allowedOrigins` set, a
|
|
464
|
+
disallowed request is answered `403` instead and nothing is upgraded.
|
|
382
465
|
|
|
383
466
|
##### `publish(room, payload, namespace?, from?): Promise<number>`
|
|
384
467
|
|
|
@@ -402,8 +485,10 @@ Instance-local.
|
|
|
402
485
|
|
|
403
486
|
##### `stats(): WSStats`
|
|
404
487
|
|
|
405
|
-
Counts only, never client ids
|
|
406
|
-
|
|
488
|
+
Counts only, never client ids — but `namespaces` is keyed by namespace name, so
|
|
489
|
+
in a multi-tenant deployment it enumerates the tenants that are online. Hence
|
|
490
|
+
the `/stats` route only exists behind `httpAuth`; this method is for in-process
|
|
491
|
+
use.
|
|
407
492
|
|
|
408
493
|
##### `close(): Promise<void>`
|
|
409
494
|
|
|
@@ -557,6 +642,19 @@ What the server's `verify()` hook returns. `null` rejects the connection.
|
|
|
557
642
|
}
|
|
558
643
|
```
|
|
559
644
|
|
|
645
|
+
### `WSRequestedIdentity`
|
|
646
|
+
|
|
647
|
+
The third argument to `verify()`: what the client proposed in its `auth` frame.
|
|
648
|
+
Hints, not facts — see
|
|
649
|
+
[Security: namespace isolation](#security-namespace-isolation).
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
{
|
|
653
|
+
clientId?: string; // absent unless the frame carried a usable one
|
|
654
|
+
namespace: string; // DEFAULT_NAMESPACE when the frame carried none
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
560
658
|
### `WSErrorInfo`
|
|
561
659
|
|
|
562
660
|
```typescript
|
|
@@ -615,6 +713,7 @@ string-matching messages.
|
|
|
615
713
|
| `WSTerminatedError` | Terminal close code | `code`, `reason` |
|
|
616
714
|
| `WSConnectTimeoutError` | `connectTimeout` elapsed (retrying continues) | |
|
|
617
715
|
| `WSTimeoutError` | `sendTimeout` elapsed with no acknowledgement | |
|
|
716
|
+
| `WSConnectionLostError` | Socket closed while the frame was in flight | |
|
|
618
717
|
| `WSOutboxDropError` | Evicted from a full outbox | |
|
|
619
718
|
| `WSRemoteError` | Server sent a `nack` | `code` |
|
|
620
719
|
| `WSNotConnectedError` | Sent while disconnected with `outboxMaxSize: 0` | |
|
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
A WebSocket client with namespaces, rooms, presence and reconnect that actually
|
|
8
8
|
survives real networks — plus a mountable reference server implementing the same
|
|
9
|
-
protocol.
|
|
9
|
+
[protocol](./PROTOCOL.md).
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Reconnects forever** — capped exponential backoff with jitter, plus instant
|
|
14
|
-
retry when the browser comes back online or the tab
|
|
14
|
+
retry when the browser comes back online or the tab becomes visible again
|
|
15
15
|
- **Detects half-open connections** — the failure where the peer vanishes, no
|
|
16
16
|
`onclose` ever fires, and a naive client sits "connected" receiving nothing
|
|
17
17
|
- **Namespaces and rooms** — namespace isolates, rooms are channels within it
|
|
@@ -128,10 +128,12 @@ ws.members("room"); // last known membership
|
|
|
128
128
|
import { createWSApp } from "@marianmeres/ws/server";
|
|
129
129
|
|
|
130
130
|
const { app, service } = createWSApp("/ws", [], {
|
|
131
|
-
|
|
131
|
+
// `requested` is what the client asked for — hints, never facts.
|
|
132
|
+
verify: async (payload, req, requested) => {
|
|
132
133
|
const user = await authenticate(payload?.token);
|
|
133
134
|
// Returning null closes the socket with a terminal code.
|
|
134
|
-
|
|
135
|
+
if (!user || !user.orgs.includes(requested.namespace)) return null;
|
|
136
|
+
return { clientId: user.id, namespace: requested.namespace };
|
|
135
137
|
},
|
|
136
138
|
});
|
|
137
139
|
|
|
@@ -141,12 +143,21 @@ await service.publish("notifications", { text: "deploy finished" }, "org-123");
|
|
|
141
143
|
Deno.serve(app);
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
Namespace is the isolation boundary, and it falls back to what the client asked
|
|
147
|
+
for when `verify` returns none — so in a multi-tenant deployment `verify` must
|
|
148
|
+
return `namespace` and `clientId`, validating `requested` rather than trusting
|
|
149
|
+
it.
|
|
150
|
+
|
|
151
|
+
If `verify` authenticates from cookies, set `allowedOrigins` as well: browsers
|
|
152
|
+
attach cookies to a WebSocket opened from any site, and without an `Origin`
|
|
153
|
+
check that is cross-site WebSocket hijacking.
|
|
154
|
+
|
|
144
155
|
Mounted routes, relative to the mount path:
|
|
145
156
|
|
|
146
157
|
| Method | Path | Notes |
|
|
147
158
|
| ------ | ----------------------------- | ----------------------------------------- |
|
|
148
159
|
| GET | `/` | WebSocket upgrade |
|
|
149
|
-
| GET | `/stats` |
|
|
160
|
+
| GET | `/stats` | Requires `httpAuth`, else **not mounted** |
|
|
150
161
|
| POST | `/publish/[namespace]/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
151
162
|
| POST | `/broadcast/[room]` | Requires `httpAuth`, else **not mounted** |
|
|
152
163
|
|
|
@@ -177,6 +188,12 @@ method rather than a flag on `publish()` precisely because crossing an isolation
|
|
|
177
188
|
boundary deserves its own name and its own server-side check: `allowBroadcast`
|
|
178
189
|
**denies by default**.
|
|
179
190
|
|
|
191
|
+
**Frame vs message** — a _frame_ is one JSON protocol envelope (`auth`, `sub`,
|
|
192
|
+
`pub`, `msg`, `ack`, …), carried in exactly one WebSocket text message — not an
|
|
193
|
+
RFC 6455 fragment. A _message_ is the application-level object a `msg` frame
|
|
194
|
+
delivers (`room`, `namespace`, `from`, `payload`, `timestamp`) — a `msg` frame
|
|
195
|
+
minus its `type` field _is_ a `WSMessage`, which is what your handlers receive.
|
|
196
|
+
|
|
180
197
|
## Behaviour worth knowing
|
|
181
198
|
|
|
182
199
|
**Reconnect classification.** Everything reconnects except a local
|
|
@@ -191,8 +208,9 @@ silent one would be indistinguishable from a network that never recovered.
|
|
|
191
208
|
|
|
192
209
|
**Delivery is at-most-once.** A publish that was transmitted but unacknowledged
|
|
193
210
|
when the socket died is _not_ resent — that would risk duplicates, and the
|
|
194
|
-
server has no deduplication. It rejects
|
|
195
|
-
|
|
211
|
+
server has no deduplication. It rejects immediately with `WSConnectionLostError`
|
|
212
|
+
rather than waiting out `sendTimeout`: the answer is already known at the close.
|
|
213
|
+
At-least-once would need server-side replay, which this version does not do.
|
|
196
214
|
|
|
197
215
|
**Sends are bounded.** Every publish carries one deadline covering queue, flight
|
|
198
216
|
_and_ acknowledgement. Without it, a publish issued while offline would pend
|
|
@@ -206,6 +224,13 @@ it left off.
|
|
|
206
224
|
|
|
207
225
|
See [API.md](API.md) for complete API documentation.
|
|
208
226
|
|
|
227
|
+
## Protocol
|
|
228
|
+
|
|
229
|
+
The reference server is Deno-only; the protocol is not.
|
|
230
|
+
[PROTOCOL.md](PROTOCOL.md) specifies the wire format frame by frame — with a
|
|
231
|
+
complete Python implementation and a conformance script that drives the real
|
|
232
|
+
client — so a compatible server can be written in any language.
|
|
233
|
+
|
|
209
234
|
## License
|
|
210
235
|
|
|
211
236
|
[MIT](LICENSE)
|
package/dist/client/outbox.d.ts
CHANGED
|
@@ -58,8 +58,16 @@ export declare class Outbox {
|
|
|
58
58
|
*/
|
|
59
59
|
failAll(error: Error): void;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* Settles every already-transmitted frame, leaving the queued ones to wait
|
|
62
|
+
* for the next connection.
|
|
63
|
+
*
|
|
64
|
+
* Used when the socket closes: the ack can no longer arrive, because the
|
|
65
|
+
* socket that would have carried it is gone and the next one is a new
|
|
66
|
+
* session. Waiting out `sendTimeout` would only delay an answer that is
|
|
67
|
+
* already known.
|
|
68
|
+
*
|
|
69
|
+
* @param decide - per frame: the error to reject with, or `null` to resolve
|
|
70
|
+
* it with no recipients
|
|
63
71
|
*/
|
|
64
|
-
|
|
72
|
+
settleInFlight(decide: (frame: ClientFrame) => Error | null): void;
|
|
65
73
|
}
|
package/dist/client/outbox.js
CHANGED
|
@@ -104,14 +104,28 @@ export class Outbox {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* Settles every already-transmitted frame, leaving the queued ones to wait
|
|
108
|
+
* for the next connection.
|
|
109
|
+
*
|
|
110
|
+
* Used when the socket closes: the ack can no longer arrive, because the
|
|
111
|
+
* socket that would have carried it is gone and the next one is a new
|
|
112
|
+
* session. Waiting out `sendTimeout` would only delay an answer that is
|
|
113
|
+
* already known.
|
|
114
|
+
*
|
|
115
|
+
* @param decide - per frame: the error to reject with, or `null` to resolve
|
|
116
|
+
* it with no recipients
|
|
109
117
|
*/
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
settleInFlight(decide) {
|
|
119
|
+
for (const [id, entry] of [...this.#pending]) {
|
|
120
|
+
if (entry.queued)
|
|
121
|
+
continue;
|
|
122
|
+
const error = decide(entry.frame);
|
|
123
|
+
this.#discard(id);
|
|
124
|
+
if (error)
|
|
125
|
+
entry.reject(error);
|
|
126
|
+
else
|
|
127
|
+
entry.resolve({ recipients: 0 });
|
|
128
|
+
}
|
|
115
129
|
}
|
|
116
130
|
#discard(id) {
|
|
117
131
|
const entry = this.#pending.get(id);
|
|
@@ -27,7 +27,10 @@ export interface WSEvents {
|
|
|
27
27
|
message: WSMessage;
|
|
28
28
|
/** Membership change in a room subscribed with presence enabled. */
|
|
29
29
|
presence: WSPresenceEvent;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Socket closed. `willReconnect` reflects the retry classification, and is
|
|
32
|
+
* `false` for the `4900` a local `disconnect()` emits.
|
|
33
|
+
*/
|
|
31
34
|
close: {
|
|
32
35
|
code: number;
|
|
33
36
|
reason: string;
|
|
@@ -229,6 +232,10 @@ export declare class WSClient<TAuth = unknown> {
|
|
|
229
232
|
* Resumable: handlers, room subscriptions and buffered sends all survive,
|
|
230
233
|
* so a later `connect()` picks up exactly where this left off. Use
|
|
231
234
|
* {@link dispose} for terminal teardown.
|
|
235
|
+
*
|
|
236
|
+
* Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
|
|
237
|
+
* when there was a socket to close; nothing when already idle, reconnecting
|
|
238
|
+
* or terminated.
|
|
232
239
|
*/
|
|
233
240
|
disconnect(): void;
|
|
234
241
|
/**
|
|
@@ -309,6 +316,8 @@ export declare class WSClient<TAuth = unknown> {
|
|
|
309
316
|
* @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
|
|
310
317
|
* @throws {WSOutboxDropError} evicted from a full outbox
|
|
311
318
|
* @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
|
|
319
|
+
* @throws {WSTerminatedError} sent after a terminal close, which only an
|
|
320
|
+
* explicit `connect()` recovers from — rejected at once, not buffered
|
|
312
321
|
* @throws {WSRemoteError} the server rejected it with a `nack`
|
|
313
322
|
*/
|
|
314
323
|
publish<T = unknown>(room: string, payload: T, namespace?: string): Promise<WSPublishResult>;
|
package/dist/client/ws-client.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createClog } from "@marianmeres/clog";
|
|
|
7
7
|
import { createPubSub } from "@marianmeres/pubsub";
|
|
8
8
|
import { base36 } from "@marianmeres/uid";
|
|
9
9
|
import { CLOSE, DEFAULT_NAMESPACE, DEFAULT_TERMINAL_CLOSE_CODES, FRAME, PROTOCOL_VERSION, } from "../protocol/constants.js";
|
|
10
|
-
import { WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
|
|
10
|
+
import { WSConnectionLostError, WSConnectTimeoutError, WSDisposedError, WSError, WSNotConnectedError, WSRemoteError, WSTerminatedError, } from "../protocol/errors.js";
|
|
11
11
|
import { backoffDelay } from "./backoff.js";
|
|
12
12
|
import { Heartbeat } from "./heartbeat.js";
|
|
13
13
|
import { Outbox } from "./outbox.js";
|
|
@@ -96,7 +96,12 @@ export class WSClient {
|
|
|
96
96
|
#namespace = null;
|
|
97
97
|
#attempt = 0;
|
|
98
98
|
#lastError = null;
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Kept apart from `#lastError` on purpose: a handler that throws after the
|
|
101
|
+
* terminal close would overwrite `#lastError` and a send rejected from the
|
|
102
|
+
* `terminated` state would then blame the wrong thing.
|
|
103
|
+
*/
|
|
104
|
+
#terminalError = null;
|
|
100
105
|
#rooms = new RoomRegistry();
|
|
101
106
|
#outbox;
|
|
102
107
|
#heartbeat;
|
|
@@ -303,7 +308,6 @@ export class WSClient {
|
|
|
303
308
|
this.#settleConnect(new WSConnectTimeoutError(this.#connectTimeout));
|
|
304
309
|
}, this.#connectTimeout);
|
|
305
310
|
}
|
|
306
|
-
this.#intentional = false;
|
|
307
311
|
if (this.#state === "idle" || this.#state === "terminated")
|
|
308
312
|
this.#open();
|
|
309
313
|
return promise;
|
|
@@ -314,16 +318,29 @@ export class WSClient {
|
|
|
314
318
|
* Resumable: handlers, room subscriptions and buffered sends all survive,
|
|
315
319
|
* so a later `connect()` picks up exactly where this left off. Use
|
|
316
320
|
* {@link dispose} for terminal teardown.
|
|
321
|
+
*
|
|
322
|
+
* Emits `close` with {@link CLOSE.CLIENT_GONE} and `willReconnect: false`
|
|
323
|
+
* when there was a socket to close; nothing when already idle, reconnecting
|
|
324
|
+
* or terminated.
|
|
317
325
|
*/
|
|
318
326
|
disconnect() {
|
|
319
327
|
if (this.#state === "disposed")
|
|
320
328
|
return;
|
|
321
329
|
this.logger?.debug?.("disconnect()");
|
|
322
|
-
this.#intentional = true;
|
|
323
330
|
this.#clearTimers();
|
|
324
331
|
this.#heartbeat.stop();
|
|
325
332
|
this.#settleConnect(new WSTerminatedError(CLOSE.CLIENT_GONE, "disconnect() called"));
|
|
326
|
-
|
|
333
|
+
const reason = "client disconnect";
|
|
334
|
+
const closed = this.#socket !== null;
|
|
335
|
+
this.#closeSocket(CLOSE.CLIENT_GONE, reason);
|
|
336
|
+
if (closed) {
|
|
337
|
+
this.#emit("close", {
|
|
338
|
+
code: CLOSE.CLIENT_GONE,
|
|
339
|
+
reason,
|
|
340
|
+
willReconnect: false,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
this.#settleInFlight();
|
|
327
344
|
this.#setState("idle");
|
|
328
345
|
}
|
|
329
346
|
/**
|
|
@@ -386,6 +403,10 @@ export class WSClient {
|
|
|
386
403
|
type: FRAME.UNSUB,
|
|
387
404
|
id: this.#nextId(),
|
|
388
405
|
rooms: [room],
|
|
406
|
+
}).catch((e) => {
|
|
407
|
+
// Not actionable: the handler is already detached locally and
|
|
408
|
+
// the server drops the room on close anyway.
|
|
409
|
+
this.logger?.debug?.(`unsub "${room}" unconfirmed: ${e.message}`);
|
|
389
410
|
});
|
|
390
411
|
}
|
|
391
412
|
});
|
|
@@ -466,6 +487,8 @@ export class WSClient {
|
|
|
466
487
|
* @throws {WSTimeoutError} `sendTimeout` elapsed with no acknowledgement
|
|
467
488
|
* @throws {WSOutboxDropError} evicted from a full outbox
|
|
468
489
|
* @throws {WSNotConnectedError} sent while offline with `outboxMaxSize: 0`
|
|
490
|
+
* @throws {WSTerminatedError} sent after a terminal close, which only an
|
|
491
|
+
* explicit `connect()` recovers from — rejected at once, not buffered
|
|
469
492
|
* @throws {WSRemoteError} the server rejected it with a `nack`
|
|
470
493
|
*/
|
|
471
494
|
publish(room, payload, namespace) {
|
|
@@ -512,14 +535,22 @@ export class WSClient {
|
|
|
512
535
|
#send(frame, id) {
|
|
513
536
|
if (this.#autoConnect)
|
|
514
537
|
this.#ensureStarted();
|
|
538
|
+
// Nothing restarts from `terminated` except an explicit connect(), so
|
|
539
|
+
// buffering here would only defer the same answer by `sendTimeout`.
|
|
540
|
+
if (this.#terminalError && this.#state === "terminated") {
|
|
541
|
+
return Promise.reject(this.#terminalError);
|
|
542
|
+
}
|
|
515
543
|
const canSendNow = this.connected &&
|
|
516
544
|
this.#socket?.readyState === WebSocket.OPEN;
|
|
517
545
|
if (!canSendNow && this.#outboxMaxSize === 0) {
|
|
518
546
|
return Promise.reject(new WSNotConnectedError());
|
|
519
547
|
}
|
|
520
548
|
const promise = this.#outbox.track(id, frame, !canSendNow);
|
|
521
|
-
if (canSendNow)
|
|
522
|
-
this.#sendRaw(frame);
|
|
549
|
+
if (canSendNow) {
|
|
550
|
+
const error = this.#sendRaw(frame);
|
|
551
|
+
if (error)
|
|
552
|
+
this.#outbox.fail(id, error);
|
|
553
|
+
}
|
|
523
554
|
return promise;
|
|
524
555
|
}
|
|
525
556
|
/**
|
|
@@ -532,19 +563,27 @@ export class WSClient {
|
|
|
532
563
|
#sendControl(frame) {
|
|
533
564
|
const id = "id" in frame ? frame.id : this.#nextId();
|
|
534
565
|
const promise = this.#outbox.track(id, frame, false);
|
|
535
|
-
this.#sendRaw(frame);
|
|
566
|
+
const error = this.#sendRaw(frame);
|
|
567
|
+
if (error)
|
|
568
|
+
this.#outbox.fail(id, error);
|
|
536
569
|
return promise;
|
|
537
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* @returns the error the send failed with — a frame that never left cannot
|
|
573
|
+
* be acknowledged, so the caller settles its promise instead of letting it
|
|
574
|
+
* wait out `sendTimeout`.
|
|
575
|
+
*/
|
|
538
576
|
#sendRaw(frame) {
|
|
539
577
|
const socket = this.#socket;
|
|
540
578
|
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
541
|
-
return;
|
|
579
|
+
return null;
|
|
542
580
|
try {
|
|
543
581
|
socket.send(this.#encode(frame));
|
|
544
582
|
}
|
|
545
583
|
catch (e) {
|
|
546
|
-
this.#fail(e, "send failed");
|
|
584
|
+
return this.#fail(e, "send failed");
|
|
547
585
|
}
|
|
586
|
+
return null;
|
|
548
587
|
}
|
|
549
588
|
#open() {
|
|
550
589
|
// Defensive: every caller already guards this, but a second socket
|
|
@@ -556,7 +595,7 @@ export class WSClient {
|
|
|
556
595
|
}
|
|
557
596
|
if (!this.#setState("connecting"))
|
|
558
597
|
return;
|
|
559
|
-
this.#
|
|
598
|
+
this.#terminalError = null;
|
|
560
599
|
const generation = ++this.#generation;
|
|
561
600
|
let socket;
|
|
562
601
|
try {
|
|
@@ -568,6 +607,9 @@ export class WSClient {
|
|
|
568
607
|
return;
|
|
569
608
|
}
|
|
570
609
|
this.#socket = socket;
|
|
610
|
+
// Without this a binary frame arrives as a Blob, which no synchronous
|
|
611
|
+
// decoder can read — the `WSDecoder` contract promises an `ArrayBuffer`.
|
|
612
|
+
socket.binaryType = "arraybuffer";
|
|
571
613
|
this.logger?.debug?.(`connecting to ${this.#url.href}`);
|
|
572
614
|
socket.onopen = () => {
|
|
573
615
|
if (generation !== this.#generation)
|
|
@@ -601,6 +643,13 @@ export class WSClient {
|
|
|
601
643
|
payload = (await this.#authFn?.()) ?? null;
|
|
602
644
|
}
|
|
603
645
|
catch (e) {
|
|
646
|
+
// The await yields, so this rejection may belong to a socket that a
|
|
647
|
+
// later connect() already replaced — closing on it would kill the
|
|
648
|
+
// healthy socket that took its place.
|
|
649
|
+
if (generation !== this.#generation) {
|
|
650
|
+
this.logger?.debug?.("superseded auth attempt failed");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
604
653
|
this.#fail(e, "auth payload failed");
|
|
605
654
|
this.#forceClose(CLOSE.PROTOCOL_ERROR, "auth payload failed");
|
|
606
655
|
return;
|
|
@@ -700,8 +749,11 @@ export class WSClient {
|
|
|
700
749
|
const buffered = this.#outbox.drain();
|
|
701
750
|
if (buffered.length) {
|
|
702
751
|
this.logger?.debug?.(`flushing ${buffered.length} buffered frame(s)`);
|
|
703
|
-
for (const frame of buffered)
|
|
704
|
-
this.#sendRaw(frame);
|
|
752
|
+
for (const frame of buffered) {
|
|
753
|
+
const error = this.#sendRaw(frame);
|
|
754
|
+
if (error && "id" in frame)
|
|
755
|
+
this.#outbox.fail(frame.id, error);
|
|
756
|
+
}
|
|
705
757
|
}
|
|
706
758
|
}
|
|
707
759
|
#onClose(code, reason) {
|
|
@@ -710,14 +762,14 @@ export class WSClient {
|
|
|
710
762
|
this.#heartbeat.stop();
|
|
711
763
|
this.#socket = null;
|
|
712
764
|
const terminal = this.#terminalCodes.includes(code);
|
|
713
|
-
const willReconnect = !this.#
|
|
714
|
-
this.#state !== "disposed";
|
|
765
|
+
const willReconnect = !terminal && this.#state !== "disposed";
|
|
715
766
|
this.logger?.debug?.(`closed (${code}${reason ? ` ${reason}` : ""}), reconnect=${willReconnect}`);
|
|
716
767
|
this.#emit("close", { code, reason, willReconnect });
|
|
717
768
|
if (terminal) {
|
|
718
769
|
this.#setState("terminated");
|
|
719
770
|
const error = new WSTerminatedError(code, reason);
|
|
720
771
|
this.#lastError = error;
|
|
772
|
+
this.#terminalError = error;
|
|
721
773
|
// Loud on purpose: this is the only path where a client that
|
|
722
774
|
// otherwise retries forever gives up, and a silent one looks
|
|
723
775
|
// exactly like a network that never came back.
|
|
@@ -727,12 +779,27 @@ export class WSClient {
|
|
|
727
779
|
this.#emit("terminated", { code, reason });
|
|
728
780
|
return;
|
|
729
781
|
}
|
|
782
|
+
this.#settleInFlight();
|
|
730
783
|
if (!willReconnect) {
|
|
731
784
|
this.#setState("idle");
|
|
732
785
|
return;
|
|
733
786
|
}
|
|
734
787
|
this.#scheduleReconnect();
|
|
735
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Answers the frames that were on the wire when the socket went away.
|
|
791
|
+
*
|
|
792
|
+
* `sub`/`unsub` resolve: the room registry is authoritative locally and the
|
|
793
|
+
* re-subscribe step will establish it on the next connection, which is
|
|
794
|
+
* exactly the contract of a `subscribe()` issued while offline — and the
|
|
795
|
+
* server forgets its rooms on close anyway. Publishes reject: they were not
|
|
796
|
+
* delivered, and at-most-once means they will not be resent.
|
|
797
|
+
*/
|
|
798
|
+
#settleInFlight() {
|
|
799
|
+
this.#outbox.settleInFlight((frame) => frame.type === FRAME.SUB || frame.type === FRAME.UNSUB
|
|
800
|
+
? null
|
|
801
|
+
: new WSConnectionLostError());
|
|
802
|
+
}
|
|
736
803
|
#scheduleReconnect() {
|
|
737
804
|
if (!this.#setState("reconnecting"))
|
|
738
805
|
return;
|
|
@@ -864,6 +931,7 @@ export class WSClient {
|
|
|
864
931
|
this.#lastError = err;
|
|
865
932
|
this.logger?.error?.(`${context}: ${err.message}`);
|
|
866
933
|
this.#emit("error", err);
|
|
934
|
+
return err;
|
|
867
935
|
}
|
|
868
936
|
}
|
|
869
937
|
/**
|
|
@@ -71,6 +71,15 @@ export declare class WSTimeoutError extends WSError {
|
|
|
71
71
|
export declare class WSOutboxDropError extends WSError {
|
|
72
72
|
constructor();
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The socket closed while the frame was in flight.
|
|
76
|
+
*
|
|
77
|
+
* Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
|
|
78
|
+
* connected", that one means "the server is not answering".
|
|
79
|
+
*/
|
|
80
|
+
export declare class WSConnectionLostError extends WSError {
|
|
81
|
+
constructor();
|
|
82
|
+
}
|
|
74
83
|
/** The server rejected the operation with a `nack`. */
|
|
75
84
|
export declare class WSRemoteError extends WSError {
|
|
76
85
|
/** Machine-readable code from the server — see `ERROR_CODE`. */
|
package/dist/protocol/errors.js
CHANGED
|
@@ -81,6 +81,17 @@ export class WSOutboxDropError extends WSError {
|
|
|
81
81
|
super("Dropped from outbox (capacity reached while disconnected)");
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The socket closed while the frame was in flight.
|
|
86
|
+
*
|
|
87
|
+
* Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
|
|
88
|
+
* connected", that one means "the server is not answering".
|
|
89
|
+
*/
|
|
90
|
+
export class WSConnectionLostError extends WSError {
|
|
91
|
+
constructor() {
|
|
92
|
+
super("The connection closed before the server acknowledged the frame; it was not resent");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
84
95
|
/** The server rejected the operation with a `nack`. */
|
|
85
96
|
export class WSRemoteError extends WSError {
|
|
86
97
|
/** Machine-readable code from the server — see `ERROR_CODE`. */
|
|
@@ -136,6 +136,16 @@ export interface WSPublishResult {
|
|
|
136
136
|
*/
|
|
137
137
|
recipients: number;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* What the client proposed in its `auth` frame. Hints, not facts — they are
|
|
141
|
+
* whatever the socket sent, so validate them before honouring them.
|
|
142
|
+
*/
|
|
143
|
+
export interface WSRequestedIdentity {
|
|
144
|
+
/** The claimed client id, when the frame carried a usable one. */
|
|
145
|
+
clientId?: string;
|
|
146
|
+
/** The requested namespace, or the default when the frame carried none. */
|
|
147
|
+
namespace: string;
|
|
148
|
+
}
|
|
139
149
|
/** Outcome of the server's `verify()` hook. */
|
|
140
150
|
export interface AuthResult {
|
|
141
151
|
/** Assign a specific client id. Defaults to a generated one. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@marianmeres/clog": "^3.21.0",
|
|
28
28
|
"@marianmeres/pubsub": "^3.0.0",
|
|
29
29
|
"@marianmeres/ticker": "^1.17.1",
|
|
30
|
-
"@marianmeres/uid": "^1.
|
|
30
|
+
"@marianmeres/uid": "^1.2.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|