@dojo-ng/websocket 0.1.3 → 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/README.md CHANGED
@@ -46,6 +46,30 @@ bus.publish("notify:event", { event: "chat:message", params: { text: "Hi" } });
46
46
  after it is sent, so a failed send does not lose it.
47
47
  - After a lost connection, the client waits longer before each new try (Fibonacci backoff). A
48
48
  successful connect resets the delay. After `close()`, it does not reconnect.
49
+ - `reconnectBase` (seconds, default 15) is added to every reconnect delay. Lower it when the
50
+ server is on the same machine, where a disconnect is usually a quick restart. Keep it high for
51
+ a public site, so a server restart is not met by every tab at once.
52
+ - `url` can be a function (sync or async) instead of a string. It is called before every open,
53
+ the first connect and each reconnect, and what it returns is the URL for that attempt only.
54
+ Use it when the URL carries a single-use credential, such as a ticket in the query string
55
+ (a browser cannot set a header on a WebSocket). If the function throws or rejects, that counts
56
+ as a failed attempt and is retried through the backoff. To stop for good, call `close()` and
57
+ then throw. A string `url` works exactly as before.
58
+
59
+ ```js
60
+ const server = new WSDispatcher({
61
+ reconnectBase: 2,
62
+ url: async () => {
63
+ const res = await fetch("/api/ws-ticket", { method: "POST" });
64
+ if (res.status === 401) {
65
+ server.close(); // the credential was refused; do not retry
66
+ throw new Error("not authorized");
67
+ }
68
+ const { ticket } = await res.json();
69
+ return `wss://example.com/ws?ticket=${ticket}`;
70
+ },
71
+ });
72
+ ```
49
73
  - When the server sends `ping` messages, the client expects one at least every 40 seconds. If
50
74
  none arrives, it closes and reconnects through the same backoff. A server that never sends a
51
75
  ping turns this check off.
@@ -1,7 +1,28 @@
1
1
  import { type PubSub } from "@dojo-ng/pubsub";
2
2
  import { WSocket } from "./wsocket.js";
3
3
  export interface WSDispatcherOptions {
4
- url: string;
4
+ /**
5
+ * The socket URL, or a function returning it (sync or async). A function is called before every
6
+ * open, first connect and every reconnect alike, and its result is the URL for that attempt only
7
+ * — use it when the URL carries a single-use credential such as a ticket. A function that throws
8
+ * or rejects counts as a failed attempt and is retried through the reconnect backoff. To stop
9
+ * for good (say the credential was refused), call `close()` and then throw.
10
+ *
11
+ * ```ts
12
+ * url: async () => {
13
+ * const res = await fetch("/api/ws-ticket", { method: "POST" });
14
+ * if (res.status === 401) { dispatcher.close(); throw new Error("not authorized"); }
15
+ * return `wss://example.com/ws?ticket=${(await res.json()).ticket}`;
16
+ * }
17
+ * ```
18
+ */
19
+ url: string | (() => string | Promise<string>);
20
+ /**
21
+ * Seconds added to every reconnect delay, on top of the Fibonacci step. Default 15. Lower it for
22
+ * a client on the same machine as its server (a daemon restart); keep it high for a public site
23
+ * so a server restart is not met by every tab at once.
24
+ */
25
+ reconnectBase?: number;
5
26
  /** Frames to send first on every (re)connect, before the offline queue flushes. */
6
27
  onConnect?: () => unknown[];
7
28
  /** Shared with the rest of the app if given; otherwise a private instance is created. */
@@ -16,6 +37,7 @@ export interface WSDispatcherOptions {
16
37
  export declare class WSDispatcher {
17
38
  #private;
18
39
  constructor(options: WSDispatcherOptions);
40
+ /** The most recent URL opened; empty until a URL provider has resolved once. */
19
41
  get url(): string;
20
42
  /** The underlying `WSocket`, for connection-state inspection. */
21
43
  get socket(): WSocket;
@@ -7,8 +7,14 @@
7
7
  // - Offline send queue (`#drain`): peeks and sends, dequeuing only on a confirmed OKAY send, so a
8
8
  // failed send never drops the message. BUFFERING retries at 50ms; a confirmed send immediately
9
9
  // tries the next one.
10
- // - Reconnect with Fibonacci backoff (`#scheduleReopen`): `TIMEOUT_BASE` 15s plus the step table,
11
- // counter reset only on a successful open, suppressed entirely while `#closing` is true.
10
+ // - Reconnect with Fibonacci backoff (`#scheduleReopen`): `reconnectBase` (default 15s) plus the
11
+ // step table, counter reset only on a successful open, suppressed entirely while `#closing` is
12
+ // true.
13
+ // - URL provider: `url` may be a function, called before EVERY open (first connect and each
14
+ // reconnect) so a single-use ticket in the query string is fresh each time. A provider that
15
+ // throws or rejects is a failed attempt and goes through `#scheduleReopen`; one that wants to stop
16
+ // for good calls `close()` before throwing. `#closing` is rechecked after the provider resolves,
17
+ // so a `close()` that lands mid-await wins.
12
18
  // - Heartbeat watchdog (`#armHeartbeat`): each inbound "ping" frame arms a 40s timer. On expiry:
13
19
  // close, wait 2s, then go through the SAME backoff-aware reopen path as any other reconnect —
14
20
  // not a bypass. `TIMEOUT_STEP`'s reset-only-on-success rule means a heartbeat failure right after
@@ -42,11 +48,11 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
42
48
  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");
43
49
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
44
50
  };
45
- 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;
51
+ var _WSDispatcher_instances, _WSDispatcher_urlSource, _WSDispatcher_reconnectBase, _WSDispatcher_ws, _WSDispatcher_pubsub, _WSDispatcher_onConnect, _WSDispatcher_msgQueue, _WSDispatcher_cbqueue, _WSDispatcher_closing, _WSDispatcher_resolvingUrl, _WSDispatcher_reopenCount, _WSDispatcher_nextId, _WSDispatcher_pingTimer, _WSDispatcher_openResolved, _WSDispatcher_scheduleReopen, _WSDispatcher_flushQueue, _WSDispatcher_enqueue, _WSDispatcher_drain, _WSDispatcher_dispatch, _WSDispatcher_armHeartbeat;
46
52
  import { createPubSub } from "@dojo-ng/pubsub";
47
53
  import { Queue } from "./queue.js";
48
54
  import { WSocket, SendState } from "./wsocket.js";
49
- const TIMEOUT_BASE = 15;
55
+ const DEFAULT_RECONNECT_BASE = 15;
50
56
  const TIMEOUT_STEP = [0, 0, 0, 0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233];
51
57
  const PING_TIMEOUT_MS = 40000;
52
58
  const PING_RECOVERY_DELAY_MS = 2000;
@@ -57,20 +63,23 @@ const PING_RECOVERY_DELAY_MS = 2000;
57
63
  export class WSDispatcher {
58
64
  constructor(options) {
59
65
  _WSDispatcher_instances.add(this);
60
- _WSDispatcher_url.set(this, void 0);
66
+ _WSDispatcher_urlSource.set(this, void 0);
67
+ _WSDispatcher_reconnectBase.set(this, void 0);
61
68
  _WSDispatcher_ws.set(this, void 0);
62
69
  _WSDispatcher_pubsub.set(this, void 0);
63
70
  _WSDispatcher_onConnect.set(this, void 0);
64
71
  _WSDispatcher_msgQueue.set(this, new Queue());
65
72
  _WSDispatcher_cbqueue.set(this, new Map());
66
73
  _WSDispatcher_closing.set(this, false);
74
+ _WSDispatcher_resolvingUrl.set(this, false);
67
75
  _WSDispatcher_reopenCount.set(this, 0);
68
76
  _WSDispatcher_nextId.set(this, 1);
69
77
  _WSDispatcher_pingTimer.set(this, null);
70
- __classPrivateFieldSet(this, _WSDispatcher_url, options.url, "f");
78
+ __classPrivateFieldSet(this, _WSDispatcher_urlSource, options.url, "f");
79
+ __classPrivateFieldSet(this, _WSDispatcher_reconnectBase, options.reconnectBase ?? DEFAULT_RECONNECT_BASE, "f");
71
80
  __classPrivateFieldSet(this, _WSDispatcher_onConnect, options.onConnect, "f");
72
81
  __classPrivateFieldSet(this, _WSDispatcher_pubsub, options.pubsub ?? createPubSub(), "f");
73
- __classPrivateFieldSet(this, _WSDispatcher_ws, new WSocket(__classPrivateFieldGet(this, _WSDispatcher_url, "f")), "f");
82
+ __classPrivateFieldSet(this, _WSDispatcher_ws, new WSocket(typeof __classPrivateFieldGet(this, _WSDispatcher_urlSource, "f") === "string" ? __classPrivateFieldGet(this, _WSDispatcher_urlSource, "f") : ""), "f");
74
83
  __classPrivateFieldGet(this, _WSDispatcher_ws, "f").addEventListener("message", (event) => {
75
84
  __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_dispatch).call(this, event.detail.data);
76
85
  });
@@ -84,8 +93,9 @@ export class WSDispatcher {
84
93
  const outboundTopic = options.outboundTopic ?? "notify:event";
85
94
  __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 });
86
95
  }
96
+ /** The most recent URL opened; empty until a URL provider has resolved once. */
87
97
  get url() {
88
- return __classPrivateFieldGet(this, _WSDispatcher_url, "f");
98
+ return __classPrivateFieldGet(this, _WSDispatcher_ws, "f").url;
89
99
  }
90
100
  /** The underlying `WSocket`, for connection-state inspection. */
91
101
  get socket() {
@@ -100,7 +110,14 @@ export class WSDispatcher {
100
110
  }
101
111
  open(force = false) {
102
112
  __classPrivateFieldSet(this, _WSDispatcher_closing, false, "f");
103
- __classPrivateFieldGet(this, _WSDispatcher_ws, "f").open(force);
113
+ if (typeof __classPrivateFieldGet(this, _WSDispatcher_urlSource, "f") === "string") {
114
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").open(force);
115
+ return;
116
+ }
117
+ // A second open while the provider is pending would spend a second single-use URL.
118
+ if (__classPrivateFieldGet(this, _WSDispatcher_resolvingUrl, "f"))
119
+ return;
120
+ void __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_openResolved).call(this, __classPrivateFieldGet(this, _WSDispatcher_urlSource, "f"), force);
104
121
  }
105
122
  close() {
106
123
  __classPrivateFieldSet(this, _WSDispatcher_closing, true, "f");
@@ -126,12 +143,28 @@ export class WSDispatcher {
126
143
  }
127
144
  }
128
145
  }
129
- _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() {
146
+ _WSDispatcher_urlSource = new WeakMap(), _WSDispatcher_reconnectBase = 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_resolvingUrl = new WeakMap(), _WSDispatcher_reopenCount = new WeakMap(), _WSDispatcher_nextId = new WeakMap(), _WSDispatcher_pingTimer = new WeakMap(), _WSDispatcher_instances = new WeakSet(), _WSDispatcher_openResolved = async function _WSDispatcher_openResolved(provider, force) {
147
+ __classPrivateFieldSet(this, _WSDispatcher_resolvingUrl, true, "f");
148
+ let url;
149
+ try {
150
+ url = await provider();
151
+ }
152
+ catch {
153
+ __classPrivateFieldSet(this, _WSDispatcher_resolvingUrl, false, "f");
154
+ __classPrivateFieldGet(this, _WSDispatcher_instances, "m", _WSDispatcher_scheduleReopen).call(this);
155
+ return;
156
+ }
157
+ __classPrivateFieldSet(this, _WSDispatcher_resolvingUrl, false, "f");
158
+ // A close() that landed while the provider was pending must win.
159
+ if (__classPrivateFieldGet(this, _WSDispatcher_closing, "f"))
160
+ return;
161
+ __classPrivateFieldGet(this, _WSDispatcher_ws, "f").open(force, url);
162
+ }, _WSDispatcher_scheduleReopen = function _WSDispatcher_scheduleReopen() {
130
163
  var _a;
131
164
  if (__classPrivateFieldGet(this, _WSDispatcher_closing, "f"))
132
165
  return;
133
166
  const step = Math.min(__classPrivateFieldGet(this, _WSDispatcher_reopenCount, "f"), TIMEOUT_STEP.length - 1);
134
- const timeoutSeconds = TIMEOUT_STEP[step] + TIMEOUT_BASE;
167
+ const timeoutSeconds = TIMEOUT_STEP[step] + __classPrivateFieldGet(this, _WSDispatcher_reconnectBase, "f");
135
168
  __classPrivateFieldSet(this, _WSDispatcher_reopenCount, (_a = __classPrivateFieldGet(this, _WSDispatcher_reopenCount, "f"), _a++, _a), "f");
136
169
  setTimeout(() => {
137
170
  this.open();
package/dist/wsocket.d.ts CHANGED
@@ -33,8 +33,11 @@ export declare class WSocket extends EventTarget {
33
33
  get socket(): WebSocket | null;
34
34
  /** Our own tracked state, not a live re-check of `socket.readyState` — see `send()` for why. */
35
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;
36
+ /**
37
+ * With no existing socket, or `force: true`, tears down any prior socket and opens a fresh one.
38
+ * A `url` replaces the stored one for this socket and onward, so `url` reports the latest.
39
+ */
40
+ open(force?: boolean, url?: string): void;
38
41
  close(code?: number, reason?: string): void;
39
42
  send(message: string | Blob | BufferSource): SendState;
40
43
  }
package/dist/wsocket.js CHANGED
@@ -73,8 +73,11 @@ export class WSocket extends EventTarget {
73
73
  isOpen() {
74
74
  return __classPrivateFieldGet(this, _WSocket_socket, "f") !== null && __classPrivateFieldGet(this, _WSocket_state, "f") === ReadyState.OPEN;
75
75
  }
76
- /** With no existing socket, or `force: true`, tears down any prior socket and opens a fresh one. */
77
- open(force = false) {
76
+ /**
77
+ * With no existing socket, or `force: true`, tears down any prior socket and opens a fresh one.
78
+ * A `url` replaces the stored one for this socket and onward, so `url` reports the latest.
79
+ */
80
+ open(force = false, url) {
78
81
  if (__classPrivateFieldGet(this, _WSocket_socket, "f")) {
79
82
  if (!force)
80
83
  return;
@@ -86,6 +89,8 @@ export class WSocket extends EventTarget {
86
89
  stale.close();
87
90
  __classPrivateFieldSet(this, _WSocket_socket, null, "f");
88
91
  }
92
+ if (url !== undefined)
93
+ __classPrivateFieldSet(this, _WSocket_url, url, "f");
89
94
  __classPrivateFieldSet(this, _WSocket_state, ReadyState.CONNECTING, "f");
90
95
  const socket = (__classPrivateFieldSet(this, _WSocket_socket, new WebSocket(__classPrivateFieldGet(this, _WSocket_url, "f")), "f"));
91
96
  // Pre-open error: straight to close, no `error` event — see the file header.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dojo-ng/websocket",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
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",