@dojo-ng/websocket 0.1.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/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @dojo-ng/websocket
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. Ported from app3's `core/socket`,
6
+ de-Holmesed — see `websocket-spec.md` in the project root for the full task-by-task history and
7
+ the reasoning behind every non-obvious decision below.
8
+
9
+ Part of the [Dojo NG framework](../../README.md) monorepo (the non-Lit runtime that complements
10
+ `components`). BSD-3-Clause.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @dojo-ng/websocket
16
+ ```
17
+
18
+ ## `WSocket`
19
+
20
+ Canonicalizes differences across `WebSocket` implementations: out-of-sync detection (a live
21
+ re-check of `socket.readyState` on every send, since the browser's own state can move before its
22
+ event fires), backpressure (`bufferedAmount > 0` returns `BUFFERING` instead of sending),
23
+ listener teardown before close (the reconnect-storm fix), and `open(force)` semantics.
24
+
25
+ ```ts
26
+ import { WSocket, SendState } from "@dojo-ng/websocket";
27
+
28
+ const ws = new WSocket("wss://example.test");
29
+ ws.addEventListener("open", () => console.log("open"));
30
+ ws.addEventListener("message", (e) => console.log(e.detail.data));
31
+ ws.open();
32
+
33
+ const state = ws.send("hello"); // SendState.OKAY | NOT_READY | BUFFERING | SEND_ERROR
34
+ ```
35
+
36
+ Most apps want `WSDispatcher`, below, rather than `WSocket` directly — it's the layer that
37
+ carries the actual value (queueing, correlation, reconnect, heartbeat). `WSocket` is exported
38
+ because it's a complete, independently useful primitive on its own, not because most callers need
39
+ to reach for it.
40
+
41
+ ## `WSDispatcher`
42
+
43
+ ```ts
44
+ import { WSDispatcher } from "@dojo-ng/websocket";
45
+
46
+ const dispatcher = new WSDispatcher({
47
+ url: "wss://example.test/ws",
48
+ // Sent on every (re)connect, before the offline queue flushes. The app owns what identity
49
+ // means here — this package has no opinion beyond "send these frames first."
50
+ onConnect: () => [{ action: "event", id: 0, data: { event: "identify", params: { sessionId } } }],
51
+ });
52
+
53
+ dispatcher.open();
54
+
55
+ // A correlated request/response, queued if the socket isn't open yet and flushed on connect.
56
+ const result = await dispatcher.call({ method: "getUser", args: [42] });
57
+
58
+ // Events flow through pub/sub in both directions — dispatcher.pubsub is a @dojo-ng/pubsub
59
+ // instance (the caller's own, if one was passed via options.pubsub, otherwise a private one).
60
+ // Inbound "event" frames publish under their own name; publishing to options.outboundTopic
61
+ // (default "notify:event") sends one outbound. No new subscription concept invented.
62
+ dispatcher.pubsub.subscribe("chat:message", (payload) => render(payload));
63
+ dispatcher.pubsub.publish("notify:event", { event: "chat:message", params: { text: "hi" } });
64
+ ```
65
+
66
+ What it carries, and why each exists, is documented at length in the header comment of
67
+ `src/wsdispatcher.ts` — request/response correlation via `Promise.withResolvers()`, an offline
68
+ send queue that peeks and dequeues only on a confirmed send, Fibonacci-backoff reconnect (reset
69
+ only on a successful open, suppressed entirely by an explicit `close()`), and a heartbeat
70
+ watchdog that closes and reopens — through the same backoff path, not a bypass — if the server
71
+ goes quiet for 40s. `dispatcher.simulate("disconnect" | "connect")` is a deliberate testing
72
+ affordance; the playground (below) uses it.
73
+
74
+ ## The wire format
75
+
76
+ `schema/envelope.schema.json` (JSON Schema draft-07) specifies the `{action, id, data}` frame
77
+ `WSDispatcher` sends and receives, independently of the TypeScript `Envelope` type that binds it
78
+ — the schema is the source of truth, the type is one binding of it. Chosen over app3's original
79
+ positional-array form by measurement, not argument: compressed (raw DEFLATE, context carried
80
+ across a session — the realistic case), the object form is only 3.4% larger than positional over
81
+ a representative session. See `websocket-spec.md` T5's dated note for the full byte counts and
82
+ methodology.
83
+
84
+ ## Playground
85
+
86
+ A live connection against a local echo server, demonstrating queued sends while disconnected, a
87
+ real reconnect with Fibonacci backoff, and the heartbeat watchdog — each one driven by a control
88
+ a real client can't perform on itself (the server dropping the connection with no handshake;
89
+ the server going silent without closing the socket), because `simulate("disconnect")` alone
90
+ proves the opposite case: an explicit close schedules no reconnect at all.
91
+
92
+ ```bash
93
+ npm run playground
94
+ # → http://localhost:8901/packages/websocket/playground/index.html
95
+ ```
96
+
97
+ See the comment at the top of `playground/index.html` for exactly what each button demonstrates.
98
+
99
+ ## Testing
100
+
101
+ | Command | What it runs |
102
+ |---|---|
103
+ | `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 |
104
+ | `npm run typecheck` | `tsc --noEmit` |
105
+
106
+ No real network in any test — `test/helpers/fake-websocket.mjs` is a synchronous fake shared
107
+ across the `WSocket` and `WSDispatcher` suites.
108
+
109
+ ## License
110
+
111
+ BSD-3-Clause. See [LICENSE](../../LICENSE).
@@ -0,0 +1,4 @@
1
+ export { WSocket, ReadyState, SendState } from "./wsocket.js";
2
+ export type { WSocketEventMap } from "./wsocket.js";
3
+ export { WSDispatcher } from "./wsdispatcher.js";
4
+ export type { WSDispatcherOptions } from "./wsdispatcher.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
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.
3
+ export { WSocket, ReadyState, SendState } from "./wsocket.js";
4
+ export { WSDispatcher } from "./wsdispatcher.js";
@@ -0,0 +1,22 @@
1
+ /** A FIFO queue backed by a singly linked list: enqueue, dequeue, and pushfront are all O(1). */
2
+ export declare class Queue<T> {
3
+ private head;
4
+ private tail;
5
+ private length;
6
+ /** Inserts the element at the end of the queue. Returns false if `elem` is undefined. */
7
+ enqueue(elem: T): boolean;
8
+ /** Same as `enqueue` — kept because app3 call sites use both names. */
9
+ add(elem: T): boolean;
10
+ /** Inserts the element at the front of the queue. Returns false if `elem` is undefined. */
11
+ pushfront(elem: T): boolean;
12
+ /** Removes and returns the head of the queue, or undefined if empty. */
13
+ dequeue(): T | undefined;
14
+ /** Returns the head of the queue without removing it, or undefined if empty. */
15
+ peek(): T | undefined;
16
+ size(): number;
17
+ count(): number;
18
+ isEmpty(): boolean;
19
+ clear(): void;
20
+ /** Visits each element FIFO order. A callback returning `false` stops the iteration. */
21
+ forEach(callback: (elem: T) => boolean | void): void;
22
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,80 @@
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).
6
+ /** A FIFO queue backed by a singly linked list: enqueue, dequeue, and pushfront are all O(1). */
7
+ export class Queue {
8
+ constructor() {
9
+ this.head = null;
10
+ this.tail = null;
11
+ this.length = 0;
12
+ }
13
+ /** Inserts the element at the end of the queue. Returns false if `elem` is undefined. */
14
+ enqueue(elem) {
15
+ return this.add(elem);
16
+ }
17
+ /** Same as `enqueue` — kept because app3 call sites use both names. */
18
+ add(elem) {
19
+ if (elem === undefined)
20
+ return false;
21
+ const node = { value: elem, next: null };
22
+ if (this.tail) {
23
+ this.tail.next = node;
24
+ }
25
+ else {
26
+ this.head = node;
27
+ }
28
+ this.tail = node;
29
+ this.length++;
30
+ return true;
31
+ }
32
+ /** Inserts the element at the front of the queue. Returns false if `elem` is undefined. */
33
+ pushfront(elem) {
34
+ if (elem === undefined)
35
+ return false;
36
+ this.head = { value: elem, next: this.head };
37
+ if (!this.tail)
38
+ this.tail = this.head;
39
+ this.length++;
40
+ return true;
41
+ }
42
+ /** Removes and returns the head of the queue, or undefined if empty. */
43
+ dequeue() {
44
+ const node = this.head;
45
+ if (!node)
46
+ return undefined;
47
+ this.head = node.next;
48
+ if (!this.head)
49
+ this.tail = null;
50
+ this.length--;
51
+ return node.value;
52
+ }
53
+ /** Returns the head of the queue without removing it, or undefined if empty. */
54
+ peek() {
55
+ return this.head?.value;
56
+ }
57
+ size() {
58
+ return this.length;
59
+ }
60
+ count() {
61
+ return this.length;
62
+ }
63
+ isEmpty() {
64
+ return this.length === 0;
65
+ }
66
+ clear() {
67
+ this.head = null;
68
+ this.tail = null;
69
+ this.length = 0;
70
+ }
71
+ /** Visits each element FIFO order. A callback returning `false` stops the iteration. */
72
+ forEach(callback) {
73
+ let node = this.head;
74
+ while (node !== null) {
75
+ if (callback(node.value) === false)
76
+ break;
77
+ node = node.next;
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,31 @@
1
+ import { type PubSub } from "@dojo-ng/pubsub";
2
+ import { WSocket } from "./wsocket.js";
3
+ export interface WSDispatcherOptions {
4
+ url: string;
5
+ /** Frames to send first on every (re)connect, before the offline queue flushes. */
6
+ onConnect?: () => unknown[];
7
+ /** Shared with the rest of the app if given; otherwise a private instance is created. */
8
+ pubsub?: PubSub;
9
+ /** Publishing here sends an outbound "event" frame. Default `"notify:event"`. */
10
+ outboundTopic?: string;
11
+ }
12
+ /**
13
+ * WebSocket message dispatch: frames are routed by `action`. Messages sent while the socket isn't
14
+ * open are queued and flushed on connect. See the file header for the full behavior list.
15
+ */
16
+ export declare class WSDispatcher {
17
+ #private;
18
+ constructor(options: WSDispatcherOptions);
19
+ get url(): string;
20
+ /** The underlying `WSocket`, for connection-state inspection. */
21
+ get socket(): WSocket;
22
+ /** The pub/sub instance in use — the caller's own if supplied, otherwise the private default. */
23
+ get pubsub(): PubSub;
24
+ isOpen(): boolean;
25
+ open(force?: boolean): void;
26
+ close(): void;
27
+ /** A correlated request: resolves with the `data` of the response frame carrying the same id. */
28
+ call(data: unknown): Promise<unknown>;
29
+ /** A deliberate testing affordance — reconnect logic is otherwise nearly untestable. */
30
+ simulate(action: "disconnect" | "connect"): void;
31
+ }
@@ -0,0 +1,200 @@
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:
5
+ //
6
+ // - 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.
10
+ // - Offline send queue (`#drain`): peeks and sends, dequeuing only on a confirmed OKAY send, so a
11
+ // failed send never drops the message. BUFFERING retries at 50ms; a confirmed send immediately
12
+ // tries the next one.
13
+ // - Reconnect with Fibonacci backoff (`#scheduleReopen`): `TIMEOUT_BASE` 15s plus the step table,
14
+ // counter reset only on a successful open, suppressed entirely while `#closing` is true.
15
+ // - Heartbeat watchdog (`#armHeartbeat`): each inbound "ping" frame arms a 40s timer. On expiry:
16
+ // close, wait 2s, then go through the SAME backoff-aware reopen path as any other reconnect —
17
+ // not a bypass. `TIMEOUT_STEP`'s reset-only-on-success rule means a heartbeat failure right after
18
+ // a fresh open still reopens promptly, but a string of them backs off like any other flapping
19
+ // 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.
24
+ // - Events over pub/sub, not a bespoke API: an inbound "event" frame publishes under its own name;
25
+ // publishing to `outboundTopic` sends an "event" frame. No new subscription concept invented —
26
+ // pub/sub is the whole app-facing surface, in both directions.
27
+ // - `simulate("disconnect" | "connect")`: a deliberate testing affordance. Reconnect logic is
28
+ // otherwise nearly untestable against a real backend.
29
+ //
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.
39
+ //
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.
44
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
45
+ if (kind === "m") throw new TypeError("Private method is not writable");
46
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
47
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
48
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
49
+ };
50
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
51
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
52
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
53
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
54
+ };
55
+ var _WSDispatcher_instances, _WSDispatcher_url, _WSDispatcher_ws, _WSDispatcher_pubsub, _WSDispatcher_onConnect, _WSDispatcher_msgQueue, _WSDispatcher_cbqueue, _WSDispatcher_closing, _WSDispatcher_reopenCount, _WSDispatcher_nextId, _WSDispatcher_pingTimer, _WSDispatcher_scheduleReopen, _WSDispatcher_flushQueue, _WSDispatcher_enqueue, _WSDispatcher_drain, _WSDispatcher_dispatch, _WSDispatcher_armHeartbeat;
56
+ import { createPubSub } from "@dojo-ng/pubsub";
57
+ import { Queue } from "./queue.js";
58
+ import { WSocket, SendState } from "./wsocket.js";
59
+ const TIMEOUT_BASE = 15;
60
+ const TIMEOUT_STEP = [0, 0, 0, 0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233];
61
+ const PING_TIMEOUT_MS = 40000;
62
+ const PING_RECOVERY_DELAY_MS = 2000;
63
+ /**
64
+ * WebSocket message dispatch: frames are routed by `action`. Messages sent while the socket isn't
65
+ * open are queued and flushed on connect. See the file header for the full behavior list.
66
+ */
67
+ export class WSDispatcher {
68
+ constructor(options) {
69
+ _WSDispatcher_instances.add(this);
70
+ _WSDispatcher_url.set(this, void 0);
71
+ _WSDispatcher_ws.set(this, void 0);
72
+ _WSDispatcher_pubsub.set(this, void 0);
73
+ _WSDispatcher_onConnect.set(this, void 0);
74
+ _WSDispatcher_msgQueue.set(this, new Queue());
75
+ _WSDispatcher_cbqueue.set(this, new Map());
76
+ _WSDispatcher_closing.set(this, false);
77
+ _WSDispatcher_reopenCount.set(this, 0);
78
+ _WSDispatcher_nextId.set(this, 1);
79
+ _WSDispatcher_pingTimer.set(this, null);
80
+ __classPrivateFieldSet(this, _WSDispatcher_url, options.url, "f");
81
+ __classPrivateFieldSet(this, _WSDispatcher_onConnect, options.onConnect, "f");
82
+ __classPrivateFieldSet(this, _WSDispatcher_pubsub, options.pubsub ?? createPubSub(), "f");
83
+ __classPrivateFieldSet(this, _WSDispatcher_ws, new WSocket(__classPrivateFieldGet(this, _WSDispatcher_url, "f")), "f");
84
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").addEventListener("message", (event) => {
85
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_dispatch).call(this, event.detail.data);
86
+ });
87
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").addEventListener("open", () => {
88
+ __classPrivateFieldSet(this, _WSDispatcher_reopenCount, 0, "f");
89
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_flushQueue).call(this);
90
+ });
91
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").addEventListener("close", () => {
92
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_scheduleReopen).call(this);
93
+ });
94
+ const outboundTopic = options.outboundTopic ?? "notify:event";
95
+ __classPrivateFieldGet(this, _WSDispatcher_pubsub, "f").subscribe(outboundTopic, (payload) => __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_enqueue).call(this, { action: "event", id: 0, data: payload }), { replay: false });
96
+ }
97
+ get url() {
98
+ return __classPrivateFieldGet(this, _WSDispatcher_url, "f");
99
+ }
100
+ /** The underlying `WSocket`, for connection-state inspection. */
101
+ get socket() {
102
+ return __classPrivateFieldGet(this, _WSDispatcher_ws, "f");
103
+ }
104
+ /** The pub/sub instance in use — the caller's own if supplied, otherwise the private default. */
105
+ get pubsub() {
106
+ return __classPrivateFieldGet(this, _WSDispatcher_pubsub, "f");
107
+ }
108
+ isOpen() {
109
+ return __classPrivateFieldGet(this, _WSDispatcher_ws, "f").isOpen();
110
+ }
111
+ open(force = false) {
112
+ __classPrivateFieldSet(this, _WSDispatcher_closing, false, "f");
113
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").open(force);
114
+ }
115
+ close() {
116
+ __classPrivateFieldSet(this, _WSDispatcher_closing, true, "f");
117
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").close();
118
+ }
119
+ /** A correlated request: resolves with the `data` of the response frame carrying the same id. */
120
+ call(data) {
121
+ var _a, _b;
122
+ const id = (__classPrivateFieldSet(this, _WSDispatcher_nextId, (_b = __classPrivateFieldGet(this, _WSDispatcher_nextId, "f"), _a = _b++, _b), "f"), _a);
123
+ const { promise, resolve, reject } = Promise.withResolvers();
124
+ __classPrivateFieldGet(this, _WSDispatcher_cbqueue, "f").set(id, { resolve, reject });
125
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_enqueue).call(this, { action: "call", id, data });
126
+ return promise;
127
+ }
128
+ /** A deliberate testing affordance — reconnect logic is otherwise nearly untestable. */
129
+ simulate(action) {
130
+ if (action === "disconnect") {
131
+ if (!__classPrivateFieldGet(this, _WSDispatcher_closing, "f"))
132
+ this.close();
133
+ }
134
+ else if (!this.isOpen()) {
135
+ this.open();
136
+ }
137
+ }
138
+ }
139
+ _WSDispatcher_url = new WeakMap(), _WSDispatcher_ws = new WeakMap(), _WSDispatcher_pubsub = new WeakMap(), _WSDispatcher_onConnect = new WeakMap(), _WSDispatcher_msgQueue = new WeakMap(), _WSDispatcher_cbqueue = new WeakMap(), _WSDispatcher_closing = new WeakMap(), _WSDispatcher_reopenCount = new WeakMap(), _WSDispatcher_nextId = new WeakMap(), _WSDispatcher_pingTimer = new WeakMap(), _WSDispatcher_instances = new WeakSet(), _WSDispatcher_scheduleReopen = function _WSDispatcher_scheduleReopen() {
140
+ var _a;
141
+ if (__classPrivateFieldGet(this, _WSDispatcher_closing, "f"))
142
+ return;
143
+ const step = Math.min(__classPrivateFieldGet(this, _WSDispatcher_reopenCount, "f"), TIMEOUT_STEP.length - 1);
144
+ const timeoutSeconds = TIMEOUT_STEP[step] + TIMEOUT_BASE;
145
+ __classPrivateFieldSet(this, _WSDispatcher_reopenCount, (_a = __classPrivateFieldGet(this, _WSDispatcher_reopenCount, "f"), _a++, _a), "f");
146
+ setTimeout(() => {
147
+ this.open();
148
+ }, timeoutSeconds * 1000);
149
+ }, _WSDispatcher_flushQueue = function _WSDispatcher_flushQueue() {
150
+ for (const frame of __classPrivateFieldGet(this, _WSDispatcher_onConnect, "f")?.call(this) ?? []) {
151
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").send(JSON.stringify(frame));
152
+ }
153
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_drain).call(this);
154
+ }, _WSDispatcher_enqueue = function _WSDispatcher_enqueue(frame) {
155
+ __classPrivateFieldGet(this, _WSDispatcher_msgQueue, "f").enqueue(frame);
156
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_drain).call(this);
157
+ }, _WSDispatcher_drain = function _WSDispatcher_drain() {
158
+ if (!__classPrivateFieldGet(this, _WSDispatcher_ws, "f").isOpen() || __classPrivateFieldGet(this, _WSDispatcher_msgQueue, "f").isEmpty())
159
+ return;
160
+ const frame = __classPrivateFieldGet(this, _WSDispatcher_msgQueue, "f").peek();
161
+ const sendState = __classPrivateFieldGet(this, _WSDispatcher_ws, "f").send(JSON.stringify(frame));
162
+ if (sendState === SendState.OKAY) {
163
+ __classPrivateFieldGet(this, _WSDispatcher_msgQueue, "f").dequeue();
164
+ }
165
+ if (!__classPrivateFieldGet(this, _WSDispatcher_msgQueue, "f").isEmpty() && (sendState === SendState.OKAY || sendState === SendState.BUFFERING)) {
166
+ const delayMs = sendState === SendState.OKAY ? 0 : 50;
167
+ setTimeout(() => __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_drain).call(this), delayMs);
168
+ }
169
+ }, _WSDispatcher_dispatch = function _WSDispatcher_dispatch(raw) {
170
+ const frame = JSON.parse(raw);
171
+ switch (frame.action) {
172
+ case "ping":
173
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_armHeartbeat).call(this);
174
+ break;
175
+ case "event": {
176
+ const { event, params } = frame.data;
177
+ __classPrivateFieldGet(this, _WSDispatcher_pubsub, "f").publish(event, params);
178
+ break;
179
+ }
180
+ case "call": {
181
+ const pending = __classPrivateFieldGet(this, _WSDispatcher_cbqueue, "f").get(frame.id);
182
+ if (pending) {
183
+ __classPrivateFieldGet(this, _WSDispatcher_cbqueue, "f").delete(frame.id);
184
+ pending.resolve(frame.data);
185
+ }
186
+ break;
187
+ }
188
+ }
189
+ }, _WSDispatcher_armHeartbeat = function _WSDispatcher_armHeartbeat() {
190
+ if (__classPrivateFieldGet(this, _WSDispatcher_pingTimer, "f"))
191
+ clearTimeout(__classPrivateFieldGet(this, _WSDispatcher_pingTimer, "f"));
192
+ __classPrivateFieldSet(this, _WSDispatcher_pingTimer, setTimeout(() => {
193
+ __classPrivateFieldSet(this, _WSDispatcher_pingTimer, null, "f");
194
+ this.close();
195
+ setTimeout(() => {
196
+ __classPrivateFieldSet(this, _WSDispatcher_closing, false, "f");
197
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_scheduleReopen).call(this);
198
+ }, PING_RECOVERY_DELAY_MS);
199
+ }, PING_TIMEOUT_MS), "f");
200
+ };
@@ -0,0 +1,40 @@
1
+ export declare enum ReadyState {
2
+ CONNECTING = 0,
3
+ OPEN = 1,
4
+ CLOSING = 2,
5
+ CLOSED = 3
6
+ }
7
+ export declare enum SendState {
8
+ OKAY = 0,
9
+ NOT_READY = 1,
10
+ BUFFERING = 2,
11
+ SEND_ERROR = 3
12
+ }
13
+ export interface WSocketEventMap {
14
+ open: CustomEvent<{
15
+ event: Event;
16
+ }>;
17
+ close: CustomEvent<{
18
+ event: CloseEvent | Record<string, unknown>;
19
+ }>;
20
+ message: CustomEvent<{
21
+ data: unknown;
22
+ }>;
23
+ error: CustomEvent<{
24
+ event: Event;
25
+ }>;
26
+ }
27
+ /** Canonicalizes differences across `WebSocket` implementations. See the file header for why. */
28
+ export declare class WSocket extends EventTarget {
29
+ #private;
30
+ constructor(url: string);
31
+ get state(): ReadyState;
32
+ get url(): string;
33
+ get socket(): WebSocket | null;
34
+ /** Our own tracked state, not a live re-check of `socket.readyState` — see `send()` for why. */
35
+ isOpen(): boolean;
36
+ /** With no existing socket, or `force: true`, tears down any prior socket and opens a fresh one. */
37
+ open(force?: boolean): void;
38
+ close(code?: number, reason?: string): void;
39
+ send(message: string | Blob | BufferSource): SendState;
40
+ }
@@ -0,0 +1,146 @@
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
4
+ // global, at call time rather than a module-scope import — that's also what lets a test substitute
5
+ // a fake one via `globalThis.WebSocket` with no constructor injection seam to design around.
6
+ //
7
+ // Four behaviors carried forward on purpose. Each is a real bug someone would otherwise
8
+ // reintroduce by "simplifying" this file once it looks like it's just wrapping `new WebSocket()`:
9
+ //
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`),
14
+ // so `send()`'s live re-check of `socket.readyState` is the one thing that actually catches a
15
+ // socket whose `readyState` moved to CLOSING/CLOSED before its own `onclose`/`onerror` fired —
16
+ // which is legal per the WebSocket spec and is exactly the state this check exists to catch.
17
+ // - Backpressure: `bufferedAmount > 0` returns BUFFERING rather than sending, so a caller can back
18
+ // off instead of piling messages into the OS send buffer.
19
+ // - Listener teardown before close: `#handleClose`, when IT decides to actively close a still-open
20
+ // socket (as opposed to a close already in progress), nulls `onclose`/`onerror`/`onmessage`
21
+ // before calling the underlying `close()`. Skip that and `close()` re-fires the still-wired
22
+ // native `onclose`, which re-enters `#handleClose`, which closes again — the reconnect-storm bug.
23
+ // - `open(force)` semantics and handler wiring inside `onopen`: with no existing socket, or with
24
+ // `force: true`, any prior socket is torn down and a fresh one opened. `message`/`error`/`close`
25
+ // 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
27
+ // distinguishing "never connected" from "connected, then errored" depends on it.
28
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
29
+ if (kind === "m") throw new TypeError("Private method is not writable");
30
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
31
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
32
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
33
+ };
34
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
35
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
36
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
37
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
38
+ };
39
+ var _WSocket_instances, _WSocket_state, _WSocket_socket, _WSocket_url, _WSocket_handleClose;
40
+ export var ReadyState;
41
+ (function (ReadyState) {
42
+ ReadyState[ReadyState["CONNECTING"] = 0] = "CONNECTING";
43
+ ReadyState[ReadyState["OPEN"] = 1] = "OPEN";
44
+ ReadyState[ReadyState["CLOSING"] = 2] = "CLOSING";
45
+ ReadyState[ReadyState["CLOSED"] = 3] = "CLOSED";
46
+ })(ReadyState || (ReadyState = {}));
47
+ export var SendState;
48
+ (function (SendState) {
49
+ SendState[SendState["OKAY"] = 0] = "OKAY";
50
+ SendState[SendState["NOT_READY"] = 1] = "NOT_READY";
51
+ SendState[SendState["BUFFERING"] = 2] = "BUFFERING";
52
+ SendState[SendState["SEND_ERROR"] = 3] = "SEND_ERROR";
53
+ })(SendState || (SendState = {}));
54
+ /** Canonicalizes differences across `WebSocket` implementations. See the file header for why. */
55
+ export class WSocket extends EventTarget {
56
+ constructor(url) {
57
+ super();
58
+ _WSocket_instances.add(this);
59
+ _WSocket_state.set(this, ReadyState.CLOSED);
60
+ _WSocket_socket.set(this, null);
61
+ _WSocket_url.set(this, void 0);
62
+ __classPrivateFieldSet(this, _WSocket_url, url, "f");
63
+ }
64
+ get state() {
65
+ return __classPrivateFieldGet(this, _WSocket_state, "f");
66
+ }
67
+ get url() {
68
+ return __classPrivateFieldGet(this, _WSocket_url, "f");
69
+ }
70
+ get socket() {
71
+ return __classPrivateFieldGet(this, _WSocket_socket, "f");
72
+ }
73
+ /** Our own tracked state, not a live re-check of `socket.readyState` — see `send()` for why. */
74
+ isOpen() {
75
+ return __classPrivateFieldGet(this, _WSocket_socket, "f") !== null && __classPrivateFieldGet(this, _WSocket_state, "f") === ReadyState.OPEN;
76
+ }
77
+ /** With no existing socket, or `force: true`, tears down any prior socket and opens a fresh one. */
78
+ open(force = false) {
79
+ if (__classPrivateFieldGet(this, _WSocket_socket, "f")) {
80
+ if (!force)
81
+ return;
82
+ const stale = __classPrivateFieldGet(this, _WSocket_socket, "f");
83
+ stale.onclose = null;
84
+ stale.onerror = null;
85
+ stale.onmessage = null;
86
+ if (stale.readyState < WebSocket.CLOSING)
87
+ stale.close();
88
+ __classPrivateFieldSet(this, _WSocket_socket, null, "f");
89
+ }
90
+ __classPrivateFieldSet(this, _WSocket_state, ReadyState.CONNECTING, "f");
91
+ const socket = (__classPrivateFieldSet(this, _WSocket_socket, new WebSocket(__classPrivateFieldGet(this, _WSocket_url, "f")), "f"));
92
+ // Pre-open error: straight to close, no `error` event — see the file header.
93
+ socket.onerror = () => __classPrivateFieldGet(this, _WSocket_instances, "m", _WSocket_handleClose).call(this, {});
94
+ socket.onopen = (event) => {
95
+ __classPrivateFieldSet(this, _WSocket_state, ReadyState.OPEN, "f");
96
+ this.dispatchEvent(new CustomEvent("open", { detail: { event } }));
97
+ socket.onmessage = (event) => {
98
+ this.dispatchEvent(new CustomEvent("message", { detail: { data: event.data } }));
99
+ };
100
+ socket.onerror = (event) => {
101
+ this.dispatchEvent(new CustomEvent("error", { detail: { event } }));
102
+ __classPrivateFieldGet(this, _WSocket_instances, "m", _WSocket_handleClose).call(this, {});
103
+ };
104
+ socket.onclose = (event) => __classPrivateFieldGet(this, _WSocket_instances, "m", _WSocket_handleClose).call(this, event);
105
+ };
106
+ }
107
+ close(code, reason) {
108
+ __classPrivateFieldSet(this, _WSocket_state, ReadyState.CLOSING, "f");
109
+ __classPrivateFieldGet(this, _WSocket_socket, "f")?.close(code, reason);
110
+ }
111
+ send(message) {
112
+ if (!this.isOpen() || __classPrivateFieldGet(this, _WSocket_socket, "f") === null) {
113
+ return SendState.NOT_READY;
114
+ }
115
+ const socket = __classPrivateFieldGet(this, _WSocket_socket, "f");
116
+ // Out-of-sync detection — see the file header.
117
+ if (socket.readyState !== WebSocket.OPEN) {
118
+ __classPrivateFieldGet(this, _WSocket_instances, "m", _WSocket_handleClose).call(this, { reason: "readyState out of sync with tracked state" });
119
+ return SendState.NOT_READY;
120
+ }
121
+ if (socket.bufferedAmount > 0) {
122
+ return SendState.BUFFERING;
123
+ }
124
+ try {
125
+ socket.send(message);
126
+ return SendState.OKAY;
127
+ }
128
+ catch {
129
+ __classPrivateFieldGet(this, _WSocket_instances, "m", _WSocket_handleClose).call(this, { reason: "send threw" });
130
+ return SendState.SEND_ERROR;
131
+ }
132
+ }
133
+ }
134
+ _WSocket_state = new WeakMap(), _WSocket_socket = new WeakMap(), _WSocket_url = new WeakMap(), _WSocket_instances = new WeakSet(), _WSocket_handleClose = function _WSocket_handleClose(event) {
135
+ __classPrivateFieldSet(this, _WSocket_state, ReadyState.CLOSED, "f");
136
+ const socket = __classPrivateFieldGet(this, _WSocket_socket, "f");
137
+ if (socket && socket.readyState < WebSocket.CLOSING) {
138
+ // Teardown before close — see the file header; this is the reconnect-storm fix.
139
+ socket.onclose = null;
140
+ socket.onerror = null;
141
+ socket.onmessage = null;
142
+ socket.close();
143
+ }
144
+ this.dispatchEvent(new CustomEvent("close", { detail: { event } }));
145
+ __classPrivateFieldSet(this, _WSocket_socket, null, "f");
146
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@dojo-ng/websocket",
3
+ "version": "0.1.0",
4
+ "description": "Dojo NG websocket client",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "schema"
12
+ ],
13
+ "license": "BSD-3-Clause",
14
+ "dependencies": {
15
+ "@dojo-ng/pubsub": "^0.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "ajv": "^8.20.0",
19
+ "ws": "^8.21.3"
20
+ },
21
+ "scripts": {
22
+ "build": "rm -rf dist && tsc -p tsconfig.json",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "npm run build && node --test test/*.test.mjs",
25
+ "playground": "node playground/echo-server.mjs"
26
+ }
27
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://dojo-ng.dev/schemas/websocket-envelope.schema.json",
4
+ "title": "Dojo NG WebSocket envelope",
5
+ "description": "The wire frame WSDispatcher (websocket-spec.md T4) sends and receives, specified independently of the TypeScript types that bind it (src/wsdispatcher.ts's Envelope interface is one binding of this, not the source of truth). Documented as a T4-local working assumption pending T5's own measurement — see the dated note in websocket-spec.md T5 for the byte-count comparison against a positional-array alternative and why this object shape was kept. Every field is always present; there is no undefined-vs-missing distinction on the wire, only JSON-representable values, so this survives JSON.stringify/json.loads round-trips without losing meaning.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["action", "id", "data"],
9
+ "properties": {
10
+ "action": {
11
+ "description": "Routes dispatch. \"ping\" arms the heartbeat watchdog (inbound only). \"event\" carries a named payload published on pub/sub in either direction. \"call\" is a correlated request/response, matched by id.",
12
+ "type": "string",
13
+ "enum": ["ping", "event", "call"]
14
+ },
15
+ "id": {
16
+ "description": "0 for \"ping\" and \"event\" frames, which aren't correlated. A positive integer for \"call\", assigned by the caller and echoed back unchanged on the matching response.",
17
+ "type": "integer",
18
+ "minimum": 0
19
+ },
20
+ "data": {
21
+ "description": "Action-specific payload. Any JSON value for \"ping\" and \"call\"; a fixed shape for \"event\", refined below."
22
+ }
23
+ },
24
+ "if": {
25
+ "properties": { "action": { "const": "event" } }
26
+ },
27
+ "then": {
28
+ "properties": {
29
+ "data": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["event", "params"],
33
+ "properties": {
34
+ "event": {
35
+ "description": "The pub/sub topic this frame publishes to (inbound) or was published from (outbound).",
36
+ "type": "string"
37
+ },
38
+ "params": {
39
+ "description": "The payload delivered to pub/sub subscribers under `event`. Any JSON value."
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }