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 +12 -2
- package/dist/core.cjs +292 -6
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +266 -3
- package/dist/core.d.ts +266 -3
- package/dist/core.js +287 -5
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +45 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +309 -5
- package/dist/index.d.ts +309 -5
- package/dist/index.js +43 -2
- package/dist/index.js.map +1 -1
- package/package.json +20 -1
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
|
-
|
|
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
|