react-websocket 3.5.0 → 4.0.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
@@ -12,13 +12,13 @@
12
12
  **[Documentation](https://mehmetkose.github.io/react-websocket/)** · [Getting started](https://mehmetkose.github.io/react-websocket/guide/getting-started) · [API](https://mehmetkose.github.io/react-websocket/api/) · [Recipes](https://mehmetkose.github.io/react-websocket/recipes) · [Changelog](CHANGELOG.md)
13
13
 
14
14
  - **Shared connections.** Components using the same URL share one socket. StrictMode, `<Activity>` and route transitions never open duplicates.
15
- - **Auto-reconnect.** Exponential backoff with jitter, plus an immediate retry when the browser comes back online or the tab becomes visible. Optional heartbeats detect dead links.
15
+ - **Auto-reconnect.** Exponential backoff with full jitter, plus an immediate retry when the browser comes back online or the tab becomes visible. It backs off from a server that closes right after accepting, and stops on close codes that retrying can't fix. Optional heartbeats detect dead links.
16
16
  - **Send queue.** `send()` before the socket is open just works. The message goes out on connect. Mark input `volatile` so it's never replayed late, or `key` it so only the newest is sent. Put control messages first with `priority`, and drop stream data on a slow link with `maxBuffered`.
17
17
  - **Typed messages.** `json: true`, your own codec, or validation with any [Standard Schema](https://standardschema.dev) library (zod, valibot, arktype), with inferred types.
18
18
  - **Suspense.** `use(client.ready)` waits for the first connection, and reconnects never flash a fallback.
19
19
  - **Topic subscriptions.** Reference-counted `subscribe`/`unsubscribe` messages that are sent again after every reconnect, so feeds don't silently stop.
20
20
  - **Message state.** Reduce messages into a store, and let each component re-render only when its slice changes, at most once per frame if you like.
21
- - **Requests and latency.** Await replies with timeouts and cancellation, and measure round-trip time and the server's clock for ping displays and synchronized countdowns.
21
+ - **Requests, streams and latency.** Await a reply, or iterate over streamed ones such as an LLM's tokens, with timeouts and a cancel that tells the server to stop. Measure round-trip time and the server's clock for ping displays and synchronized countdowns.
22
22
  - **Games.** Snapshot interpolation and client-side prediction with server reconciliation, from `react-websocket/timeline`, so other players move smoothly and your own controls respond at once. A send rate that adapts to the connection keeps players on a poor link from building up lag.
23
23
  - **Server requests.** Answer requests from the server, such as an AI agent's, in code for browser-only tools, or from a dialog for human approval. Every request gets exactly one reply, and none is sent to the wrong connection.
24
24
  - **SSR-safe.** Never connects during a server render, hydrates cleanly, and ships a `'use client'` entry for Next.js and other Server Components frameworks.
@@ -117,14 +117,16 @@ function Room() {
117
117
  | `useWebSocketState(store, selector?)` | Read state reduced from messages; re-renders only when the selection changes |
118
118
  | `useWebSocketLatency(latency)` | Round-trip time and server clock offset |
119
119
  | `useAgentApproval(responder, method)` | Requests from the server waiting for a person to answer |
120
+ | `useWebSocketStream(rpc, options?)` | Stream the replies to a request into a value, e.g. tokens into an answer; returns `{ value, status, error, start, abort }` |
120
121
  | `createWebSocket(url, options?)` | A shareable client: `send`, `on`, `connect`, `close`, `reconnect`, `ready`, `getSnapshot`, `subscribe` |
121
122
  | `createWebSocketTopics(client, { subscribe, unsubscribe })` | Topic subscription manager for `useWebSocketTopic` |
122
123
  | `createWebSocketState(client, { initialState, reduce })` | Message-state store for `useWebSocketState` |
123
- | `createWebSocketRequests(client, { send, reply })` | `request(message)` returns a promise of the reply, with timeouts |
124
+ | `createWebSocketRequests(client, { send, reply })` | `request(message)` returns a promise of the reply, and `stream(message)` an async iterator of replies, with timeouts and `cancel` |
124
125
  | `createWebSocketLatency(client, { ping, pong })` | Ping tracker for `useWebSocketLatency`, plus `serverNow()` |
125
126
  | `createWebSocketSendRate(client, options?)` | `send(message, key)` coalesced per key at a rate that drops on a congested link |
126
127
  | `createWebSocketResponder(client, { request, respond, error })` | Answer the server's requests: `onRequest(method, handler)`, or a person through `useAgentApproval` |
127
128
  | `MessageError` | Reported to `onError` when a message fails `parse` or `schema` |
129
+ | `retryableClose(close)` | The default `reconnect.shouldReconnect`: retries every close except `1002`, `1003` and `1008` |
128
130
  | `createWebSocketTimeline(client, { snapshot, interpolate, input })` | From `react-websocket/timeline`: interpolated server snapshots (`at()`) and predicted inputs (`predicted()`, `input()`) for multiplayer games |
129
131
  | `createChaosWebSocket(options)` | From `react-websocket/chaos`: a `WebSocket` class that adds latency, lost messages and dropped connections in development |
130
132
 
@@ -135,9 +137,9 @@ function Room() {
135
137
  | `json` | `false` | `JSON.parse` incoming and `JSON.stringify` outgoing messages |
136
138
  | `parse(data)` / `serialize(message)` | | Custom codec; takes precedence over `json` |
137
139
  | `schema` | | Standard Schema validation; types are inferred |
138
- | `reconnect` | `true` | `false`, or `{ maxAttempts, minDelay, maxDelay, factor, jitter, shouldReconnect }` |
140
+ | `reconnect` | `true` | `false`, or `{ maxAttempts, minDelay, maxDelay, factor, jitter, minUptime, shouldReconnect }` |
139
141
  | `heartbeat` | | `{ interval, message, pong, timeout }` |
140
- | `queue` | `true` | `false`, or `{ limit }` |
142
+ | `queue` | `true` (100 messages) | `false`, or `{ limit }` |
141
143
  | `logger` | | e.g. `console.debug` |
142
144
  | `WebSocket` | global | Custom implementation for tests, Node, or chaos testing |
143
145
 
@@ -148,7 +150,7 @@ function Room() {
148
150
  - [Shared clients](https://mehmetkose.github.io/react-websocket/guide/shared-clients) · [Messages and types](https://mehmetkose.github.io/react-websocket/guide/messages) · [Reconnecting and heartbeats](https://mehmetkose.github.io/react-websocket/guide/reconnecting)
149
151
  - [Suspense](https://mehmetkose.github.io/react-websocket/guide/suspense) · [Server rendering and Next.js](https://mehmetkose.github.io/react-websocket/guide/ssr) · [StrictMode, Activity and lifecycle](https://mehmetkose.github.io/react-websocket/guide/lifecycle)
150
152
  - [Authentication](https://mehmetkose.github.io/react-websocket/guide/authentication) · [Performance](https://mehmetkose.github.io/react-websocket/guide/performance) · [Real-time games](https://mehmetkose.github.io/react-websocket/guide/games) · [AI agents](https://mehmetkose.github.io/react-websocket/guide/agents) · [Testing](https://mehmetkose.github.io/react-websocket/guide/testing) · [Chaos testing](https://mehmetkose.github.io/react-websocket/guide/chaos) · [Without React](https://mehmetkose.github.io/react-websocket/guide/without-react)
151
- - [Recipes](https://mehmetkose.github.io/react-websocket/recipes) · [Troubleshooting](https://mehmetkose.github.io/react-websocket/troubleshooting) · [Migrating from v2](https://mehmetkose.github.io/react-websocket/migration)
153
+ - [Recipes](https://mehmetkose.github.io/react-websocket/recipes) · [Troubleshooting](https://mehmetkose.github.io/react-websocket/troubleshooting) · [Migrating from v3](https://mehmetkose.github.io/react-websocket/migration-v3) · [Migrating from v2](https://mehmetkose.github.io/react-websocket/migration)
152
154
 
153
155
  The docs source lives in [`docs/`](docs). Run `npm run docs:dev` to browse it locally.
154
156
 
@@ -160,6 +162,10 @@ The docs source lives in [`docs/`](docs). Run `npm run docs:dev` to browse it lo
160
162
  | Runtimes | Modern browsers; Node via the `WebSocket` option |
161
163
  | Frameworks | Vite, Next.js (App and Pages Router), Remix/React Router, Astro islands, and any React setup |
162
164
 
165
+ ## Migrating from v3
166
+
167
+ v4 changes no APIs, only four defaults: the backoff resets once a connection has stayed open for 5 seconds, close codes `1002`, `1003` and `1008` aren't retried, jitter is full, and the send queue holds 100 messages. Each has a one-line opt-out. See [Migrating from v3](https://mehmetkose.github.io/react-websocket/migration-v3).
168
+
163
169
  ## Migrating from v2
164
170
 
165
171
  v2 was a `<Websocket>` class component. v3 replaces it with hooks: `<Websocket url onMessage />` becomes `useWebSocket(url, { onMessage })`, and `ref.sendMessage(msg)` becomes `send(msg)`. See the [migration guide](https://mehmetkose.github.io/react-websocket/migration) for the full mapping.
package/dist/core.cjs CHANGED
@@ -31,8 +31,26 @@ const RECONNECT_DEFAULTS = {
31
31
  minDelay: 1e3,
32
32
  maxDelay: 3e4,
33
33
  factor: 2,
34
- jitter: .5
34
+ jitter: 1,
35
+ minUptime: 5e3
35
36
  };
37
+ const QUEUE_LIMIT = 100;
38
+ /**
39
+ * The default `reconnect.shouldReconnect`: retry every close except `1002` (protocol error),
40
+ * `1003` (unsupported data) and `1008` (policy violation, often a rejected login), which
41
+ * retrying the same way can't fix. Call it from your own `shouldReconnect` to keep that rule.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * createWebSocket(url, {
46
+ * // Also stop on our server's "invalid token" code.
47
+ * reconnect: { shouldReconnect: close => close.code !== 4001 && retryableClose(close) }
48
+ * });
49
+ * ```
50
+ */
51
+ function retryableClose(close) {
52
+ return close.code !== 1002 && close.code !== 1003 && close.code !== 1008;
53
+ }
36
54
  const READY_GRACE = 5e3;
37
55
  function createDeferred() {
38
56
  let resolve;
@@ -84,7 +102,7 @@ function createClient(url, options = {}) {
84
102
  ...RECONNECT_DEFAULTS,
85
103
  ...typeof options.reconnect === "object" ? options.reconnect : {}
86
104
  };
87
- const queueLimit = options.queue === false ? 0 : typeof options.queue === "object" ? Math.max(0, options.queue.limit ?? Infinity) : Infinity;
105
+ const queueLimit = options.queue === false ? 0 : Math.max(0, (typeof options.queue === "object" ? options.queue.limit : void 0) ?? QUEUE_LIMIT);
88
106
  let snapshot = {
89
107
  status: "idle",
90
108
  lastMessage: void 0,
@@ -109,8 +127,10 @@ function createClient(url, options = {}) {
109
127
  let inbox = null;
110
128
  let generation = 0;
111
129
  let windowBound = false;
130
+ let failures = 0;
112
131
  let retryTimer;
113
132
  let teardownTimer;
133
+ let stableTimer;
114
134
  let heartbeatTimer;
115
135
  let pongTimer;
116
136
  function set(patch) {
@@ -177,10 +197,12 @@ function createClient(url, options = {}) {
177
197
  }, heartbeat.timeout);
178
198
  }, heartbeat.interval);
179
199
  }
180
- function stopHeartbeat() {
200
+ /** Stop the timers that belong to the current open socket. */
201
+ function stopSocketTimers() {
181
202
  clearInterval(heartbeatTimer);
182
203
  clearTimeout(pongTimer);
183
- heartbeatTimer = pongTimer = void 0;
204
+ clearTimeout(stableTimer);
205
+ heartbeatTimer = pongTimer = stableTimer = void 0;
184
206
  }
185
207
  /** Stop tracking `socket`. Its eventual close event is still reported unless `reportClose` is false. */
186
208
  function detach(socket, reportClose) {
@@ -251,6 +273,7 @@ function createClient(url, options = {}) {
251
273
  status: "open",
252
274
  attempt: 0
253
275
  });
276
+ if (retry) stableTimer = setTimeout(() => failures = 0, retry.minUptime);
254
277
  startHeartbeat(socket);
255
278
  ready?.resolve();
256
279
  if (refs === 0 && !pinned) {
@@ -275,14 +298,14 @@ function createClient(url, options = {}) {
275
298
  };
276
299
  }
277
300
  function handleClose(info) {
278
- stopHeartbeat();
301
+ stopSocketTimers();
279
302
  log("closed", info);
280
303
  retryOrFail(info);
281
304
  emit("close", info);
282
305
  }
283
306
  function retryOrFail(info, error) {
284
- if (wanted && retry && snapshot.attempt < retry.maxAttempts && (retry.shouldReconnect?.(info) ?? true)) {
285
- const attempt = snapshot.attempt + 1;
307
+ if (wanted && retry && failures < retry.maxAttempts && (retry.shouldReconnect ?? retryableClose)(info)) {
308
+ const attempt = ++failures;
286
309
  const delay = backoff(attempt);
287
310
  set({
288
311
  status: "reconnecting",
@@ -297,7 +320,10 @@ function createClient(url, options = {}) {
297
320
  opened = false;
298
321
  unbindWindow();
299
322
  pending = [];
300
- set({ status: "closed" });
323
+ set({
324
+ status: "closed",
325
+ attempt: failures
326
+ });
301
327
  rejectReady(reason);
302
328
  }
303
329
  function receive(event, socket) {
@@ -402,6 +428,7 @@ function createClient(url, options = {}) {
402
428
  opened = false;
403
429
  if (ready?.settled) ready = null;
404
430
  }
431
+ failures = 0;
405
432
  snapshot = {
406
433
  ...snapshot,
407
434
  attempt: 0
@@ -423,7 +450,8 @@ function createClient(url, options = {}) {
423
450
  generation++;
424
451
  clearTimeout(retryTimer);
425
452
  retryTimer = void 0;
426
- stopHeartbeat();
453
+ failures = 0;
454
+ stopSocketTimers();
427
455
  unbindWindow();
428
456
  pending = [];
429
457
  if (ws) {
@@ -446,7 +474,7 @@ function createClient(url, options = {}) {
446
474
  if (!getImpl()) return;
447
475
  clearTimeout(retryTimer);
448
476
  retryTimer = void 0;
449
- stopHeartbeat();
477
+ stopSocketTimers();
450
478
  if (ws) {
451
479
  const socket = ws;
452
480
  detach(socket, true);
@@ -660,6 +688,8 @@ function createWebSocketLatency(client, options) {
660
688
  //#endregion
661
689
  //#region src/requests.ts
662
690
  const lastIds = /* @__PURE__ */ new WeakMap();
691
+ const VOLATILE$2 = { volatile: true };
692
+ const noop = () => {};
663
693
  /**
664
694
  * Send requests and await their replies, matched by an id the library generates.
665
695
  *
@@ -680,8 +710,8 @@ const lastIds = /* @__PURE__ */ new WeakMap();
680
710
  */
681
711
  function createWebSocketRequests(client, options) {
682
712
  const waiting = /* @__PURE__ */ new Map();
683
- function rejectWhere(test, reason) {
684
- for (const entry of [...waiting.values()]) if (test(entry)) entry.reject(reason());
713
+ function endWhere(test, reason, cancel) {
714
+ for (const entry of [...waiting.values()]) if (test(entry)) entry.end(true, reason(), cancel);
685
715
  }
686
716
  const offs = [
687
717
  client.on("message", (message) => {
@@ -689,78 +719,131 @@ function createWebSocketRequests(client, options) {
689
719
  const entry = id == null ? void 0 : waiting.get(String(id));
690
720
  if (!entry) return;
691
721
  const error = options.error?.(message);
692
- if (error === void 0) entry.resolve(message);
693
- else entry.reject(error);
722
+ if (error === void 0) entry.receive(message);
723
+ else entry.end(true, error);
694
724
  }),
695
725
  client.on("open", () => {
696
726
  for (const entry of waiting.values()) entry.socket ??= client.socket;
697
727
  }),
698
728
  client.subscribe(() => {
699
729
  const { status } = client.getSnapshot();
700
- if (status === "closed" || status === "idle") rejectWhere(() => true, () => /* @__PURE__ */ new Error("WebSocket was closed before the request was answered"));
701
- else rejectWhere((entry) => entry.socket !== null && entry.socket !== client.socket, () => /* @__PURE__ */ new Error("WebSocket connection was lost before the request was answered"));
730
+ if (status === "closed" || status === "idle") endWhere(() => true, () => /* @__PURE__ */ new Error("WebSocket was closed before the request was answered"));
731
+ else endWhere((entry) => entry.socket !== null && entry.socket !== client.socket, () => /* @__PURE__ */ new Error("WebSocket connection was lost before the request was answered"));
702
732
  })
703
733
  ];
734
+ /**
735
+ * Send `message` as a new request. `next` gets each reply that isn't an error and returns
736
+ * whether it was the last; `done` runs once, when the request ends. Returns a function that
737
+ * ends it from this side and tells the server.
738
+ */
739
+ function start(message, requestOptions, next, done) {
740
+ const { signal, priority } = requestOptions;
741
+ if (signal?.aborted) {
742
+ done(true, signal.reason);
743
+ return noop;
744
+ }
745
+ const id = (lastIds.get(client) ?? 0) + 1;
746
+ lastIds.set(client, id);
747
+ const key = String(id);
748
+ const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
749
+ const release = client.acquire();
750
+ let timer;
751
+ const onAbort = () => entry.end(true, signal.reason, true);
752
+ function arm() {
753
+ clearTimeout(timer);
754
+ if (timeout > 0 && timeout < Infinity) timer = setTimeout(() => entry.end(true, new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError"), true), timeout);
755
+ }
756
+ const entry = {
757
+ socket: null,
758
+ receive(reply) {
759
+ if (next(reply)) entry.end(false);
760
+ else arm();
761
+ },
762
+ end(failed, reason, cancel) {
763
+ if (!waiting.delete(key)) return;
764
+ clearTimeout(timer);
765
+ signal?.removeEventListener("abort", onAbort);
766
+ release();
767
+ done(failed, reason);
768
+ if (cancel && options.cancel) try {
769
+ client.send(options.cancel(id), entry.socket ? VOLATILE$2 : { priority });
770
+ } catch (error) {
771
+ queueMicrotask(() => {
772
+ throw error;
773
+ });
774
+ }
775
+ }
776
+ };
777
+ waiting.set(key, entry);
778
+ signal?.addEventListener("abort", onAbort, { once: true });
779
+ arm();
780
+ const { status } = client.getSnapshot();
781
+ if (status === "closed" || status === "idle") entry.end(true, /* @__PURE__ */ new Error(`WebSocket is ${status}: request not sent`));
782
+ else try {
783
+ if (client.send(options.send(message, id), {
784
+ ...requestOptions,
785
+ onDrop() {
786
+ entry.end(true, /* @__PURE__ */ new Error("Request was dropped from the send queue before it was sent"));
787
+ requestOptions.onDrop?.();
788
+ }
789
+ })) entry.socket = client.socket;
790
+ else if (client.socket?.readyState === 1) entry.end(true, /* @__PURE__ */ new Error("WebSocket send buffer is full: request dropped"));
791
+ else if (requestOptions.volatile) entry.end(true, /* @__PURE__ */ new Error("WebSocket is not open: request dropped"));
792
+ } catch (error) {
793
+ entry.end(true, error);
794
+ }
795
+ return () => entry.end(false, void 0, true);
796
+ }
704
797
  return {
705
798
  client,
706
799
  request(message, requestOptions = {}) {
707
- const { signal } = requestOptions;
708
- if (signal?.aborted) return Promise.reject(signal.reason);
709
- const id = (lastIds.get(client) ?? 0) + 1;
710
- lastIds.set(client, id);
711
- const key = String(id);
712
- const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
713
800
  return new Promise((resolve, reject) => {
714
- let timer;
715
- const release = client.acquire();
716
- const onAbort = () => entry.reject(signal.reason);
717
- function settle() {
718
- waiting.delete(key);
719
- clearTimeout(timer);
720
- signal?.removeEventListener("abort", onAbort);
721
- release();
722
- }
723
- const entry = {
724
- socket: null,
725
- resolve(reply) {
726
- settle();
727
- resolve(reply);
728
- },
729
- reject(reason) {
730
- settle();
731
- reject(reason);
732
- }
733
- };
734
- waiting.set(key, entry);
735
- signal?.addEventListener("abort", onAbort, { once: true });
736
- if (timeout > 0 && timeout < Infinity) timer = setTimeout(() => entry.reject(new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError")), timeout);
737
- const { status } = client.getSnapshot();
738
- if (status === "closed" || status === "idle") {
739
- entry.reject(/* @__PURE__ */ new Error(`WebSocket is ${status}: request not sent`));
740
- return;
741
- }
742
- let sent;
801
+ start(message, requestOptions, (reply) => {
802
+ resolve(reply);
803
+ return true;
804
+ }, (failed, reason) => {
805
+ if (failed) reject(reason);
806
+ });
807
+ });
808
+ },
809
+ stream(message, requestOptions = {}) {
810
+ const last = options.done;
811
+ if (!last) throw new TypeError("stream() needs the `done` option");
812
+ const replies = [];
813
+ let ended;
814
+ let wake = noop;
815
+ const stop = start(message, requestOptions, (reply) => {
816
+ replies.push(reply);
817
+ wake();
818
+ return last(reply);
819
+ }, (failed, reason) => {
820
+ if (requestOptions.signal?.aborted) replies.length = 0;
821
+ ended = [failed, reason];
822
+ wake();
823
+ });
824
+ async function* read() {
743
825
  try {
744
- sent = client.send(options.send(message, id), {
745
- ...requestOptions,
746
- onDrop() {
747
- entry.reject(/* @__PURE__ */ new Error("Request was dropped from the send queue before it was sent"));
748
- requestOptions.onDrop?.();
749
- }
750
- });
751
- } catch (error) {
752
- entry.reject(error);
753
- return;
826
+ for (;;) if (replies.length) yield replies.shift();
827
+ else if (ended) {
828
+ if (ended[0]) throw ended[1];
829
+ return;
830
+ } else await new Promise((resolve) => wake = resolve);
831
+ } finally {
832
+ stop();
754
833
  }
755
- if (sent) entry.socket = client.socket;
756
- else if (client.socket?.readyState === 1) entry.reject(/* @__PURE__ */ new Error("WebSocket send buffer is full: request dropped"));
757
- else if (requestOptions.volatile) entry.reject(/* @__PURE__ */ new Error("WebSocket is not open: request dropped"));
758
- });
834
+ }
835
+ const iterator = read();
836
+ const close = iterator.return;
837
+ iterator.return = (value) => {
838
+ stop();
839
+ return close.call(iterator, value);
840
+ };
841
+ return iterator;
759
842
  },
760
843
  pending: () => waiting.size,
761
844
  dispose() {
762
845
  offs.forEach((off) => off());
763
- rejectWhere(() => true, () => /* @__PURE__ */ new Error("Requests were disposed"));
846
+ endWhere(() => true, () => /* @__PURE__ */ new Error("Requests were disposed"), true);
764
847
  }
765
848
  };
766
849
  }
@@ -1177,5 +1260,6 @@ exports.createWebSocketResponder = createWebSocketResponder;
1177
1260
  exports.createWebSocketSendRate = createWebSocketSendRate;
1178
1261
  exports.createWebSocketState = createWebSocketState;
1179
1262
  exports.createWebSocketTopics = createWebSocketTopics;
1263
+ exports.retryableClose = retryableClose;
1180
1264
 
1181
1265
  //# sourceMappingURL=core.cjs.map