@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/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.