@dojo-ng/websocket 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -1,12 +1,11 @@
1
1
  # @dojo-ng/websocket
2
2
 
3
- A framework-agnostic WebSocket client: a raw-socket wrapper (`WSocket`) and a convenience
4
- dispatcher on top of it (`WSDispatcher`) with request/response correlation, an offline send
5
- queue, reconnect with backoff, and a heartbeat watchdog. The reasoning behind each non-obvious
6
- behavior below is in the header comments of `src/wsocket.ts` and `src/wsdispatcher.ts`.
3
+ A WebSocket client that reconnects, queues messages while offline, and matches each response to
4
+ its request. Events flow through a [`@dojo-ng/pubsub`](https://www.npmjs.com/package/@dojo-ng/pubsub)
5
+ bus in both directions.
7
6
 
8
- Part of the [Dojo NG framework](../../README.md) monorepo (the non-Lit runtime that complements
9
- `components`). BSD-3-Clause.
7
+ Part of the [Dojo NG framework](https://foss.heptapod.net/dojo-ng/framework) packages: plain ESM
8
+ and no Lit. BSD-3-Clause.
10
9
 
11
10
  ## Install
12
11
 
@@ -14,97 +13,101 @@ Part of the [Dojo NG framework](../../README.md) monorepo (the non-Lit runtime t
14
13
  npm install @dojo-ng/websocket
15
14
  ```
16
15
 
17
- ## `WSocket`
16
+ ## Usage
18
17
 
19
- Canonicalizes differences across `WebSocket` implementations: out-of-sync detection (a live
20
- re-check of `socket.readyState` on every send, since the browser's own state can move before its
21
- event fires), backpressure (`bufferedAmount > 0` returns `BUFFERING` instead of sending),
22
- listener teardown before close (the reconnect-storm fix), and `open(force)` semantics.
18
+ ```js
19
+ import { createPubSub } from "@dojo-ng/pubsub";
20
+ import { WSDispatcher } from "@dojo-ng/websocket";
23
21
 
24
- ```ts
25
- import { WSocket, SendState } from "@dojo-ng/websocket";
22
+ const bus = createPubSub();
23
+ const server = new WSDispatcher({
24
+ url: "wss://example.com/ws",
25
+ pubsub: bus, // share the app's bus; without this you get a private one
26
+ onConnect: () => [{ action: "event", id: 0, data: { event: "hello", params: { user: 7 } } }],
27
+ });
26
28
 
27
- const ws = new WSocket("wss://example.test");
28
- ws.addEventListener("open", () => console.log("open"));
29
- ws.addEventListener("message", (e) => console.log(e.detail.data));
30
- ws.open();
29
+ server.open();
31
30
 
32
- const state = ws.send("hello"); // SendState.OKAY | NOT_READY | BUFFERING | SEND_ERROR
33
- ```
31
+ // Request and response. Sent now if the socket is open, otherwise queued.
32
+ const user = await server.call({ method: "getUser", args: [7] });
34
33
 
35
- Most apps want `WSDispatcher`, below, rather than `WSocket` directly — it's the layer that
36
- carries the actual value (queueing, correlation, reconnect, heartbeat). `WSocket` is exported
37
- because it's a complete, independently useful primitive on its own, not because most callers need
38
- to reach for it.
34
+ // Events from the server arrive on the bus under their own name.
35
+ bus.subscribe("chat:message", (msg) => console.log(msg), { replay: false });
39
36
 
40
- ## `WSDispatcher`
37
+ // Publishing to the outbound topic sends an event to the server.
38
+ bus.publish("notify:event", { event: "chat:message", params: { text: "Hi" } });
39
+ ```
41
40
 
42
- ```ts
43
- import { WSDispatcher } from "@dojo-ng/websocket";
41
+ ## What WSDispatcher handles
42
+
43
+ - `onConnect` returns messages to send first on every connect and reconnect, before the queue.
44
+ Use it to tell the server who the user is.
45
+ - Messages sent while the connection is down wait in a queue. A message leaves the queue only
46
+ after it is sent, so a failed send does not lose it.
47
+ - After a lost connection, the client waits longer before each new try (Fibonacci backoff). A
48
+ successful connect resets the delay. After `close()`, it does not reconnect.
49
+ - When the server sends `ping` messages, the client expects one at least every 40 seconds. If
50
+ none arrives, it closes and reconnects through the same backoff. A server that never sends a
51
+ ping turns this check off.
52
+ - `call()` has no timeout. If the server never answers, the promise never settles. Use
53
+ `Promise.race` with a timer if you need a limit.
54
+ - Publishing to `outboundTopic` sends an event to the server. The default topic is
55
+ `"notify:event"`.
56
+ - `simulate("disconnect")` and `simulate("connect")` let you test your reconnect handling without
57
+ a real outage.
44
58
 
45
- const dispatcher = new WSDispatcher({
46
- url: "wss://example.test/ws",
47
- // Sent on every (re)connect, before the offline queue flushes. The app owns what identity
48
- // means here — this package has no opinion beyond "send these frames first."
49
- onConnect: () => [{ action: "event", id: 0, data: { event: "identify", params: { sessionId } } }],
50
- });
51
-
52
- dispatcher.open();
59
+ ## The wire format
53
60
 
54
- // A correlated request/response, queued if the socket isn't open yet and flushed on connect.
55
- const result = await dispatcher.call({ method: "getUser", args: [42] });
61
+ Every message is one JSON object with `action`, `id`, and `data`. A `call` response sends back the
62
+ same `id`, and its `data` becomes the value of the promise. An `event` carries the topic name and
63
+ the payload.
56
64
 
57
- // Events flow through pub/sub in both directions — dispatcher.pubsub is a @dojo-ng/pubsub
58
- // instance (the caller's own, if one was passed via options.pubsub, otherwise a private one).
59
- // Inbound "event" frames publish under their own name; publishing to options.outboundTopic
60
- // (default "notify:event") sends one outbound. No new subscription concept invented.
61
- dispatcher.pubsub.subscribe("chat:message", (payload) => render(payload));
62
- dispatcher.pubsub.publish("notify:event", { event: "chat:message", params: { text: "hi" } });
65
+ ```json
66
+ { "action": "call", "id": 12, "data": { "method": "getUser", "args": [7] } }
67
+ { "action": "event", "id": 0, "data": { "event": "chat:message", "params": { "text": "Hi" } } }
68
+ { "action": "ping", "id": 0, "data": null }
63
69
  ```
64
70
 
65
- What it carries, and why each exists, is documented at length in the header comment of
66
- `src/wsdispatcher.ts` — request/response correlation via `Promise.withResolvers()`, an offline
67
- send queue that peeks and dequeues only on a confirmed send, Fibonacci-backoff reconnect (reset
68
- only on a successful open, suppressed entirely by an explicit `close()`), and a heartbeat
69
- watchdog that closes and reopens — through the same backoff path, not a bypass — if the server
70
- goes quiet for 40s. `dispatcher.simulate("disconnect" | "connect")` is a deliberate testing
71
- affordance; the playground (below) uses it.
71
+ The full rules are in
72
+ [the JSON Schema](https://foss.heptapod.net/dojo-ng/framework/-/blob/branch/default/packages/websocket/schema/envelope.schema.json).
73
+ The object form was chosen after measuring: with compression, a typical session is only about
74
+ 3% larger than with a positional array.
72
75
 
73
- ## The wire format
76
+ ## WSocket, the thinner layer
74
77
 
75
- `schema/envelope.schema.json` (JSON Schema draft-07) specifies the `{action, id, data}` frame
76
- `WSDispatcher` sends and receives, independently of the TypeScript `Envelope` type that binds it
77
- — the schema is the source of truth, the type is one binding of it. The object form was chosen
78
- over a positional-array form (`[action, id, data]`) by measurement, not argument: compressed (raw
79
- DEFLATE, context carried across a session — the realistic case), the object form is only 3.4%
80
- larger than positional over a representative session. `spike/measure-envelope.mjs` reproduces
81
- the measurement.
78
+ Most apps want `WSDispatcher`. If you only want a thin wrapper, the package also exports `WSocket`.
82
79
 
83
- ## Playground
80
+ - It smooths over differences between WebSocket implementations.
81
+ - `send()` returns a `SendState`: `OKAY`, `NOT_READY`, `BUFFERING`, or `SEND_ERROR`. It reports
82
+ `BUFFERING` instead of sending while the browser still has unsent data.
83
+ - It removes its listeners before closing, so a reconnect does not stack old handlers.
84
84
 
85
- A live connection against a local echo server, demonstrating queued sends while disconnected, a
86
- real reconnect with Fibonacci backoff, and the heartbeat watchdog — each one driven by a control
87
- a real client can't perform on itself (the server dropping the connection with no handshake;
88
- the server going silent without closing the socket), because `simulate("disconnect")` alone
89
- proves the opposite case: an explicit close schedules no reconnect at all.
85
+ ```js
86
+ import { WSocket, SendState } from "@dojo-ng/websocket";
90
87
 
91
- ```bash
92
- npm run playground
93
- # → http://localhost:8901/packages/websocket/playground/index.html
88
+ const ws = new WSocket("wss://example.com");
89
+ ws.addEventListener("open", () => console.log("open"));
90
+ ws.addEventListener("message", (e) => console.log(e.detail.data));
91
+ ws.open();
92
+
93
+ const state = ws.send("hello"); // SendState.OKAY, NOT_READY, BUFFERING, or SEND_ERROR
94
94
  ```
95
95
 
96
- See the comment at the top of `playground/index.html` for exactly what each button demonstrates.
96
+ ## Learn more
97
97
 
98
- ## Testing
98
+ The [framework runtime guide](https://dojo-ng.com/docs/framework/) shows the client together with
99
+ [`@dojo-ng/framework`](https://www.npmjs.com/package/@dojo-ng/framework) and
100
+ [`@dojo-ng/pubsub`](https://www.npmjs.com/package/@dojo-ng/pubsub).
99
101
 
100
- | Command | What it runs |
101
- |---|---|
102
- | `npm test` | `node --test` over `test/*.test.mjs` — `Queue`, `WSocket`, `WSDispatcher`, and the envelope schema, against fake sockets and (for the reconnect/heartbeat cases) `node:test`'s built-in fake timers |
103
- | `npm run typecheck` | `tsc --noEmit` |
102
+ ## Development
104
103
 
105
- No real network in any test — `test/helpers/fake-websocket.mjs` is a synchronous fake shared
106
- across the `WSocket` and `WSDispatcher` suites.
104
+ - `npm test` runs the unit tests with `node --test`, against a fake socket and fake timers. No
105
+ test uses the network.
106
+ - `npm run typecheck` runs `tsc --noEmit`.
107
+ - `npm run playground` starts a local echo server and a demo page at
108
+ `http://localhost:8901/packages/websocket/playground/index.html`. Its buttons show the offline
109
+ queue, a real reconnect with backoff, and the heartbeat check.
107
110
 
108
111
  ## License
109
112
 
110
- BSD-3-Clause. See [LICENSE](../../LICENSE).
113
+ BSD-3-Clause. See [LICENSE](https://foss.heptapod.net/dojo-ng/framework/-/blob/branch/default/LICENSE).
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // Public entry point for @dojo-ng/websocket. `queue.ts`'s Queue is deliberately not re-exported
2
- // from this file (websocket-spec.md T2) — everything else built here is meant to be used directly.
2
+ // from this file — everything else built here is meant to be used directly.
3
3
  export { WSocket, ReadyState, SendState } from "./wsocket.js";
4
4
  export { WSDispatcher } from "./wsdispatcher.js";
package/dist/queue.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare class Queue<T> {
5
5
  private length;
6
6
  /** Inserts the element at the end of the queue. Returns false if `elem` is undefined. */
7
7
  enqueue(elem: T): boolean;
8
- /** Same as `enqueue` — kept because app3 call sites use both names. */
8
+ /** Same as `enqueue`. */
9
9
  add(elem: T): boolean;
10
10
  /** Inserts the element at the front of the queue. Returns false if `elem` is undefined. */
11
11
  pushfront(elem: T): boolean;
package/dist/queue.js CHANGED
@@ -1,8 +1,6 @@
1
- // Internal module. Ported from app3/core/collections/src/Queue.ts (websocket-spec.md T2), as a
2
- // self-contained singly linked list rather than importing LinkedList.ts — LinkedList has no other
3
- // consumer in this tree, and the T2 decision is to port only what Queue itself needs, not the rest
4
- // of app3's collections module (lodash-es included). Never re-export this module from index.ts:
5
- // that is the whole of the T2 decision (framework-monorepo-spec.md B3, websocket-spec.md T2).
1
+ // Internal module: a self-contained singly linked list, with only what the dispatcher's send
2
+ // queue needs and no dependencies. Never re-export this module from index.ts: it is an
3
+ // implementation detail, not public API.
6
4
  /** A FIFO queue backed by a singly linked list: enqueue, dequeue, and pushfront are all O(1). */
7
5
  export class Queue {
8
6
  constructor() {
@@ -14,7 +12,7 @@ export class Queue {
14
12
  enqueue(elem) {
15
13
  return this.add(elem);
16
14
  }
17
- /** Same as `enqueue` — kept because app3 call sites use both names. */
15
+ /** Same as `enqueue`. */
18
16
  add(elem) {
19
17
  if (elem === undefined)
20
18
  return false;
@@ -1,12 +1,9 @@
1
- // Ported from app3/core/socket/src/socket.ts (350 lines, websocket-spec.md T4). This is the task
2
- // that carries the value — everything in the proposal's carry list lands here except the
3
- // transport/envelope registries (Phase 2, deliberately unwritten until Phase 1 meets a real
4
- // backend). Carried behaviors, each with its own reason:
1
+ // WebSocket message dispatch on top of `WSocket`. Behaviors, each with its own reason:
5
2
  //
6
3
  // - Request/response correlation (`call`): `#nextId++` into a callback map, resolved by id on the
7
- // matching inbound frame. No timeout/reject path — app3 never had one either, and it's not on
8
- // the carry list, so a pending call can wait forever on a connection that never responds. Not a
9
- // bug to "fix" here; a deliberate fidelity choice, revisit only if Bill asks for it.
4
+ // matching inbound frame. There is no timeout/reject path, so a pending call can wait forever
5
+ // on a connection that never responds. This is deliberate and documented; a caller that needs a
6
+ // limit races the promise against a timer.
10
7
  // - Offline send queue (`#drain`): peeks and sends, dequeuing only on a confirmed OKAY send, so a
11
8
  // failed send never drops the message. BUFFERING retries at 50ms; a confirmed send immediately
12
9
  // tries the next one.
@@ -17,30 +14,23 @@
17
14
  // not a bypass. `TIMEOUT_STEP`'s reset-only-on-success rule means a heartbeat failure right after
18
15
  // a fresh open still reopens promptly, but a string of them backs off like any other flapping
19
16
  // connection.
20
- // - Identity handshake (`onConnect`): app3 hardcoded a SESSID frame built from
21
- // `window.hcstatic.sessionid`. Generalized into a caller-supplied hook returning frames to send
22
- // first, before the queue flush — the app owns what identity means (DYNLS separation lesson),
23
- // this package doesn't know what "who this is" means for any given backend.
17
+ // - Identity handshake (`onConnect`): a caller-supplied hook returning frames to send first on
18
+ // every (re)connect, before the queue flush. The app owns what identity means; this package
19
+ // does not know what "who this is" means for any given backend.
24
20
  // - Events over pub/sub, not a bespoke API: an inbound "event" frame publishes under its own name;
25
21
  // publishing to `outboundTopic` sends an "event" frame. No new subscription concept invented —
26
22
  // pub/sub is the whole app-facing surface, in both directions.
27
23
  // - `simulate("disconnect" | "connect")`: a deliberate testing affordance. Reconnect logic is
28
24
  // otherwise nearly untestable against a real backend.
29
25
  //
30
- // Dropped, not carried: the `queue_available` Defer-based gate around the send queue. It only ever
31
- // guarded synchronous code (`Queue`'s ops don't await anything), so in single-threaded JS it
32
- // serialized nothing that wasn't already serial — every method here runs to completion before the
33
- // next scheduled one starts, so there's no reentrancy hazard for it to guard against. Also dropped:
34
- // `createDefer` (→ `Promise.withResolvers()`), the `WeakMap` private-props pattern (→ `#private`
35
- // fields), `Evented`/`ws.on()` (→ `WSocket`'s `EventTarget`, from T3), lodash-es `defer`/`delay` (→
36
- // native `setTimeout`), all `window.hcstatic.*` coupling, and the MANHOLE/CDPPUSH actions
37
- // (Holmes-specific). JSON-RPC's header/response-object wrapping is also dropped — that's `core/rpc`
38
- // territory, out of scope for this package per the proposal's layering.
26
+ // There is no lock around the send queue. `Queue`'s operations are synchronous, so in
27
+ // single-threaded JS every method here runs to completion before the next scheduled one starts,
28
+ // and there is no reentrancy hazard to guard against. Request/response wrapping such as JSON-RPC
29
+ // headers is out of scope; that belongs in a layer above this package.
39
30
  //
40
- // The envelope shape below (`{ action, id, data }`) is a T4-local working assumption, not T5's
41
- // decision. websocket-spec.md T5 measures compressed bytes on the wire and picks the shape for
42
- // real; until then this object form (matching the proposal's standing lean) is what dispatch and
43
- // the tests here operate on.
31
+ // The envelope shape (`{ action, id, data }`) is specified in schema/envelope.schema.json. It was
32
+ // chosen over a positional array by measuring compressed bytes on the wire
33
+ // (spike/measure-envelope.mjs).
44
34
  var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
45
35
  if (kind === "m") throw new TypeError("Private method is not writable");
46
36
  if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
package/dist/wsocket.js CHANGED
@@ -1,16 +1,15 @@
1
- // Ported from app3/core/socket/src/WebSocket.ts (216 lines, websocket-spec.md T3), de-Holmesed:
2
- // `logSystem` calls dropped, `Evented` replaced with `EventTarget`, and `getWS.ts`'s
3
- // `MozWebSocket` fallback dropped entirely (dead for over a decade). `WebSocket` is read as the
1
+ // A thin wrapper over the browser `WebSocket`, exposed as an `EventTarget`. There is no
2
+ // `MozWebSocket` fallback (dead for over a decade). `WebSocket` is read as the
4
3
  // global, at call time rather than a module-scope import — that's also what lets a test substitute
5
4
  // a fake one via `globalThis.WebSocket` with no constructor injection seam to design around.
6
5
  //
7
6
  // Four behaviors carried forward on purpose. Each is a real bug someone would otherwise
8
7
  // reintroduce by "simplifying" this file once it looks like it's just wrapping `new WebSocket()`:
9
8
  //
10
- // - Out-of-sync detection (`send`, below): app3's `is_open()` re-checked `socket.readyState` on
11
- // every call, which made its own out-of-sync check in `send()` unreachable dead code — `is_open`
12
- // already returned false for the same reason before `send()` ever got there. Fixed here by
13
- // making `isOpen()` reflect only OUR tracked state (set on `onopen`, cleared by `#handleClose`),
9
+ // - Out-of-sync detection (`send`, below): if `isOpen()` re-checked `socket.readyState` on every
10
+ // call, the out-of-sync check in `send()` would be unreachable dead code — `isOpen` would
11
+ // already return false for the same reason before `send()` got there. So `isOpen()` reflects
12
+ // only OUR tracked state (set on `onopen`, cleared by `#handleClose`),
14
13
  // so `send()`'s live re-check of `socket.readyState` is the one thing that actually catches a
15
14
  // socket whose `readyState` moved to CLOSING/CLOSED before its own `onclose`/`onerror` fired —
16
15
  // which is legal per the WebSocket spec and is exactly the state this check exists to catch.
@@ -23,7 +22,7 @@
23
22
  // - `open(force)` semantics and handler wiring inside `onopen`: with no existing socket, or with
24
23
  // `force: true`, any prior socket is torn down and a fresh one opened. `message`/`error`/`close`
25
24
  // handlers are wired only once `onopen` fires, which is also why a pre-open error goes straight
26
- // to `#handleClose` with no `error` event — the original's asymmetry, preserved because a caller
25
+ // to `#handleClose` with no `error` event — an asymmetry kept on purpose, because a caller
27
26
  // distinguishing "never connected" from "connected, then errored" depends on it.
28
27
  var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
29
28
  if (kind === "m") throw new TypeError("Private method is not writable");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dojo-ng/websocket",
3
- "version": "0.1.1",
4
- "description": "Dojo NG websocket client",
3
+ "version": "0.1.3",
4
+ "description": "Dojo NG WebSocket client that reconnects, queues messages while offline, and matches responses to requests",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "license": "BSD-3-Clause",
14
14
  "dependencies": {
15
- "@dojo-ng/pubsub": "^0.1.1"
15
+ "@dojo-ng/pubsub": "^0.1.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "ajv": "^8.20.0",