react-websocket 3.0.0 → 3.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
@@ -13,9 +13,12 @@
13
13
 
14
14
  - **Shared connections.** Components using the same URL share one socket. StrictMode, `<Activity>` and route transitions never open duplicates.
15
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.
16
- - **Send queue.** `send()` before the socket is open just works. The message goes out on connect.
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.
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
+ - **Topic subscriptions.** Reference-counted `subscribe`/`unsubscribe` messages that are sent again after every reconnect, so feeds don't silently stop.
20
+ - **Message state.** Reduce messages into a store, and let each component re-render only when its slice changes.
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.
19
22
  - **SSR-safe.** Never connects during a server render, hydrates cleanly, and ships a `'use client'` entry for Next.js and other Server Components frameworks.
20
23
  - **Small and typed.** Zero dependencies, ESM + CJS, TypeScript declarations with inline docs, plus a React-free `react-websocket/core` entry.
21
24
 
@@ -108,7 +111,14 @@ function Room() {
108
111
  | `useWebSocket(url \| client, options?)` | Connect; returns `{ status, lastMessage, attempt, send, close, reconnect, client }` |
109
112
  | `useWebSocketEvent(client, type, listener)` | Handle `'open'`, `'message'`, `'close'` or `'error'` without re-rendering |
110
113
  | `useWebSocketStatus(client)` | The status; re-renders only when it changes |
114
+ | `useWebSocketTopic(topics, topic)` | Hold topic subscriptions that survive reconnects |
115
+ | `useWebSocketState(store, selector?)` | Read state reduced from messages; re-renders only when the selection changes |
116
+ | `useWebSocketLatency(latency)` | Round-trip time and server clock offset |
111
117
  | `createWebSocket(url, options?)` | A shareable client: `send`, `on`, `connect`, `close`, `reconnect`, `ready`, `getSnapshot`, `subscribe` |
118
+ | `createWebSocketTopics(client, { subscribe, unsubscribe })` | Topic subscription manager for `useWebSocketTopic` |
119
+ | `createWebSocketState(client, { initialState, reduce })` | Message-state store for `useWebSocketState` |
120
+ | `createWebSocketRequests(client, { send, reply })` | `request(message)` returns a promise of the reply, with timeouts |
121
+ | `createWebSocketLatency(client, { ping, pong })` | Ping tracker for `useWebSocketLatency`, plus `serverNow()` |
112
122
  | `MessageError` | Reported to `onError` when a message fails `parse` or `schema` |
113
123
 
114
124
  | Option | Default | |
@@ -130,7 +140,7 @@ function Room() {
130
140
 
131
141
  - [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)
132
142
  - [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)
133
- - [Authentication](https://mehmetkose.github.io/react-websocket/guide/authentication) · [Performance](https://mehmetkose.github.io/react-websocket/guide/performance) · [Testing](https://mehmetkose.github.io/react-websocket/guide/testing) · [Without React](https://mehmetkose.github.io/react-websocket/guide/without-react)
143
+ - [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) · [Testing](https://mehmetkose.github.io/react-websocket/guide/testing) · [Without React](https://mehmetkose.github.io/react-websocket/guide/without-react)
134
144
  - [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)
135
145
 
136
146
  The docs source lives in [`docs/`](docs). Run `npm run docs:dev` to browse it locally.
package/dist/core.cjs CHANGED
@@ -21,7 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var core_exports = {};
22
22
  __export(core_exports, {
23
23
  MessageError: () => MessageError,
24
- createWebSocket: () => createWebSocket
24
+ createWebSocket: () => createWebSocket,
25
+ createWebSocketLatency: () => createWebSocketLatency,
26
+ createWebSocketRequests: () => createWebSocketRequests,
27
+ createWebSocketState: () => createWebSocketState,
28
+ createWebSocketTopics: () => createWebSocketTopics
25
29
  });
26
30
  module.exports = __toCommonJS(core_exports);
27
31
 
@@ -247,7 +251,7 @@ function createClient(url, options = {}) {
247
251
  if (ws !== socket) return;
248
252
  const queued = pending;
249
253
  pending = [];
250
- for (const data of queued) socket.send(data);
254
+ for (const { data } of queued) socket.send(data);
251
255
  };
252
256
  socket.onmessage = (event) => receive(event, socket);
253
257
  socket.onerror = (event) => emit("error", event);
@@ -343,13 +347,13 @@ function createClient(url, options = {}) {
343
347
  set({ lastMessage: message });
344
348
  emit("message", message, event);
345
349
  }
346
- function send(message) {
350
+ function send(message, sendOptions) {
347
351
  const data = options.serialize ? options.serialize(message) : options.json ? JSON.stringify(message) : message;
348
352
  if (ws && ws.readyState === OPEN) {
349
353
  ws.send(data);
350
354
  return true;
351
355
  }
352
- if (queueLimit === 0) {
356
+ if (queueLimit === 0 || sendOptions?.volatile) {
353
357
  log("dropped message: socket is not open");
354
358
  return false;
355
359
  }
@@ -357,7 +361,9 @@ function createClient(url, options = {}) {
357
361
  log("dropped message: no WebSocket implementation available");
358
362
  return false;
359
363
  }
360
- pending.push(data);
364
+ const key = sendOptions?.key;
365
+ if (key !== void 0) pending = pending.filter((entry) => entry.key !== key);
366
+ pending.push({ data, key });
361
367
  if (pending.length > queueLimit) {
362
368
  pending.shift();
363
369
  log(`dropped oldest queued message: queue limit (${queueLimit}) reached`);
@@ -489,9 +495,289 @@ function createClient(url, options = {}) {
489
495
  function createWebSocket(url, options) {
490
496
  return createClient(url, options);
491
497
  }
498
+
499
+ // src/latency.ts
500
+ var EMPTY = Object.freeze({ rtt: void 0, offset: void 0 });
501
+ function median(values) {
502
+ const sorted = [...values].sort((a, b) => a - b);
503
+ const middle = sorted.length >> 1;
504
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
505
+ }
506
+ function createWebSocketLatency(client, options) {
507
+ const interval = options.interval ?? 2e3;
508
+ const keep = Math.max(1, options.samples ?? 5);
509
+ const listeners = /* @__PURE__ */ new Set();
510
+ let snapshot = EMPTY;
511
+ const inFlight = /* @__PURE__ */ new Map();
512
+ let samples = [];
513
+ let nextId = 0;
514
+ let timer;
515
+ function ping() {
516
+ if (client.getSnapshot().status !== "open") return;
517
+ const id = ++nextId;
518
+ inFlight.set(String(id), performance.now());
519
+ if (inFlight.size > keep * 2) inFlight.delete(inFlight.keys().next().value);
520
+ client.send(options.ping(id), { volatile: true });
521
+ }
522
+ function start() {
523
+ stop();
524
+ inFlight.clear();
525
+ samples = [];
526
+ ping();
527
+ timer = setInterval(ping, interval);
528
+ }
529
+ function stop() {
530
+ clearInterval(timer);
531
+ timer = void 0;
532
+ }
533
+ function measure(id, serverTime) {
534
+ const sentAt = inFlight.get(id);
535
+ if (sentAt === void 0) return;
536
+ inFlight.delete(id);
537
+ const rtt = performance.now() - sentAt;
538
+ const offset = serverTime === void 0 ? void 0 : serverTime + rtt / 2 - Date.now();
539
+ samples.push({ rtt, offset });
540
+ if (samples.length > keep) samples.shift();
541
+ let best;
542
+ for (const sample of samples) {
543
+ if (sample.offset !== void 0 && (!best || sample.rtt < best.rtt)) best = sample;
544
+ }
545
+ snapshot = {
546
+ rtt: median(samples.map((sample) => sample.rtt)),
547
+ offset: best ? best.offset : snapshot.offset
548
+ };
549
+ for (const listener of [...listeners]) listener();
550
+ }
551
+ const offs = [
552
+ client.on("open", start),
553
+ client.on("message", (message) => {
554
+ const pong = options.pong(message);
555
+ if (pong != null) measure(String(pong.id), pong.time);
556
+ }),
557
+ client.subscribe(() => {
558
+ if (client.getSnapshot().status !== "open") stop();
559
+ })
560
+ ];
561
+ if (client.getSnapshot().status === "open") start();
562
+ return {
563
+ client,
564
+ getSnapshot: () => snapshot,
565
+ subscribe(listener) {
566
+ listeners.add(listener);
567
+ return () => {
568
+ listeners.delete(listener);
569
+ };
570
+ },
571
+ serverNow: () => Date.now() + (snapshot.offset ?? 0),
572
+ ping,
573
+ dispose() {
574
+ stop();
575
+ offs.forEach((off) => off());
576
+ }
577
+ };
578
+ }
579
+
580
+ // src/requests.ts
581
+ function createWebSocketRequests(client, options) {
582
+ const waiting = /* @__PURE__ */ new Map();
583
+ let nextId = 0;
584
+ function rejectWhere(test, reason) {
585
+ for (const entry of [...waiting.values()]) if (test(entry)) entry.reject(reason());
586
+ }
587
+ const offs = [
588
+ client.on("message", (message) => {
589
+ const id = options.reply(message);
590
+ const entry = id == null ? void 0 : waiting.get(String(id));
591
+ if (!entry) return;
592
+ const error = options.error?.(message);
593
+ if (error === void 0) entry.resolve(message);
594
+ else entry.reject(error);
595
+ }),
596
+ // `open` listeners run before the client flushes its queue: queued requests go out on this socket.
597
+ client.on("open", () => {
598
+ for (const entry of waiting.values()) entry.socket ??= client.socket;
599
+ }),
600
+ client.subscribe(() => {
601
+ const { status } = client.getSnapshot();
602
+ if (status === "closed" || status === "idle") {
603
+ rejectWhere(
604
+ () => true,
605
+ () => new Error("WebSocket was closed before the request was answered")
606
+ );
607
+ } else {
608
+ rejectWhere(
609
+ (entry) => entry.socket !== null && entry.socket !== client.socket,
610
+ () => new Error("WebSocket connection was lost before the request was answered")
611
+ );
612
+ }
613
+ })
614
+ ];
615
+ return {
616
+ client,
617
+ request(message, requestOptions = {}) {
618
+ const { signal } = requestOptions;
619
+ if (signal?.aborted) return Promise.reject(signal.reason);
620
+ const id = ++nextId;
621
+ const key = String(id);
622
+ const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
623
+ return new Promise((resolve, reject) => {
624
+ let timer;
625
+ const release = client.acquire();
626
+ const onAbort = () => entry.reject(signal.reason);
627
+ function settle() {
628
+ waiting.delete(key);
629
+ clearTimeout(timer);
630
+ signal?.removeEventListener("abort", onAbort);
631
+ release();
632
+ }
633
+ const entry = {
634
+ socket: null,
635
+ resolve(reply) {
636
+ settle();
637
+ resolve(reply);
638
+ },
639
+ reject(reason) {
640
+ settle();
641
+ reject(reason);
642
+ }
643
+ };
644
+ waiting.set(key, entry);
645
+ signal?.addEventListener("abort", onAbort, { once: true });
646
+ if (timeout > 0 && timeout < Infinity) {
647
+ timer = setTimeout(
648
+ () => entry.reject(
649
+ new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError")
650
+ ),
651
+ timeout
652
+ );
653
+ }
654
+ const { status } = client.getSnapshot();
655
+ if (status === "closed" || status === "idle") {
656
+ entry.reject(new Error(`WebSocket is ${status}: request not sent`));
657
+ return;
658
+ }
659
+ let sent;
660
+ try {
661
+ sent = client.send(options.send(message, id), requestOptions);
662
+ } catch (error) {
663
+ entry.reject(error);
664
+ return;
665
+ }
666
+ if (sent) entry.socket = client.socket;
667
+ else if (requestOptions.volatile)
668
+ entry.reject(new Error("WebSocket is not open: request dropped"));
669
+ });
670
+ },
671
+ pending: () => waiting.size,
672
+ dispose() {
673
+ offs.forEach((off) => off());
674
+ rejectWhere(
675
+ () => true,
676
+ () => new Error("Requests were disposed")
677
+ );
678
+ }
679
+ };
680
+ }
681
+
682
+ // src/state.ts
683
+ function createWebSocketState(client, options) {
684
+ const { initialState, reduce } = options;
685
+ let state = initialState;
686
+ const listeners = /* @__PURE__ */ new Set();
687
+ function setState(update) {
688
+ const next = typeof update === "function" ? update(state) : update;
689
+ if (Object.is(next, state)) return;
690
+ state = next;
691
+ for (const listener of [...listeners]) listener();
692
+ }
693
+ const off = client.on("message", (message) => setState((current) => reduce(current, message)));
694
+ return {
695
+ client,
696
+ initialState,
697
+ getState: () => state,
698
+ subscribe(listener) {
699
+ listeners.add(listener);
700
+ return () => {
701
+ listeners.delete(listener);
702
+ };
703
+ },
704
+ setState,
705
+ reset: () => setState(initialState),
706
+ dispose: off
707
+ };
708
+ }
709
+
710
+ // src/topics.ts
711
+ function createWebSocketTopics(client, options) {
712
+ const counts = /* @__PURE__ */ new Map();
713
+ const pendingRemoval = /* @__PURE__ */ new Map();
714
+ let sentOn = null;
715
+ let sent = /* @__PURE__ */ new Set();
716
+ function isOpen() {
717
+ return client.getSnapshot().status === "open" && client.socket !== null;
718
+ }
719
+ function subscribe(topic) {
720
+ if (!isOpen()) return;
721
+ if (client.socket !== sentOn) {
722
+ sentOn = client.socket;
723
+ sent = /* @__PURE__ */ new Set();
724
+ }
725
+ if (sent.has(topic)) return;
726
+ sent.add(topic);
727
+ client.send(options.subscribe(topic));
728
+ }
729
+ function unsubscribe(topic) {
730
+ if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
731
+ if (options.unsubscribe) client.send(options.unsubscribe(topic));
732
+ }
733
+ const off = client.on("open", () => {
734
+ sentOn = client.socket;
735
+ sent = /* @__PURE__ */ new Set();
736
+ for (const topic of counts.keys()) subscribe(topic);
737
+ });
738
+ return {
739
+ client,
740
+ acquire(topic) {
741
+ const releaseConnection = client.acquire();
742
+ clearTimeout(pendingRemoval.get(topic));
743
+ pendingRemoval.delete(topic);
744
+ counts.set(topic, (counts.get(topic) ?? 0) + 1);
745
+ subscribe(topic);
746
+ let released = false;
747
+ return () => {
748
+ if (released) return;
749
+ released = true;
750
+ const remaining = (counts.get(topic) ?? 1) - 1;
751
+ if (remaining > 0) {
752
+ counts.set(topic, remaining);
753
+ } else {
754
+ counts.delete(topic);
755
+ pendingRemoval.set(
756
+ topic,
757
+ setTimeout(() => {
758
+ pendingRemoval.delete(topic);
759
+ if (!counts.has(topic)) unsubscribe(topic);
760
+ }, 0)
761
+ );
762
+ }
763
+ releaseConnection();
764
+ };
765
+ },
766
+ active: () => [...counts.keys()],
767
+ dispose() {
768
+ off();
769
+ for (const timer of pendingRemoval.values()) clearTimeout(timer);
770
+ pendingRemoval.clear();
771
+ }
772
+ };
773
+ }
492
774
  // Annotate the CommonJS export names for ESM import in node:
493
775
  0 && (module.exports = {
494
776
  MessageError,
495
- createWebSocket
777
+ createWebSocket,
778
+ createWebSocketLatency,
779
+ createWebSocketRequests,
780
+ createWebSocketState,
781
+ createWebSocketTopics
496
782
  });
497
783
  //# sourceMappingURL=core.cjs.map