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/dist/core.d.ts
CHANGED
|
@@ -100,6 +100,21 @@ interface HeartbeatOptions {
|
|
|
100
100
|
*/
|
|
101
101
|
timeout?: number;
|
|
102
102
|
}
|
|
103
|
+
/** Per-message options for `send()`. */
|
|
104
|
+
interface SendOptions {
|
|
105
|
+
/**
|
|
106
|
+
* Drop the message instead of queueing it when the socket isn't open. For data that
|
|
107
|
+
* is stale by the time a reconnect finishes, such as player input or cursor positions.
|
|
108
|
+
* @default false
|
|
109
|
+
*/
|
|
110
|
+
volatile?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Keep only the newest queued message with this key. Sending again with the same key
|
|
113
|
+
* while disconnected replaces the earlier one, which moves to the back of the queue.
|
|
114
|
+
* For state where only the latest value matters, such as "my position is now (x, y)".
|
|
115
|
+
*/
|
|
116
|
+
key?: string;
|
|
117
|
+
}
|
|
103
118
|
/** Bounds for the send queue. */
|
|
104
119
|
interface QueueOptions {
|
|
105
120
|
/**
|
|
@@ -285,10 +300,17 @@ interface WebSocketClient<In = unknown, Out = unknown> {
|
|
|
285
300
|
on<K extends keyof EventMap<In>>(type: K, listener: EventMap<In>[K]): () => void;
|
|
286
301
|
/**
|
|
287
302
|
* Send a message (encoded with `serialize`/`json` if configured). If the socket isn't
|
|
288
|
-
* open, the message is queued and sent on open (see the `queue` option)
|
|
303
|
+
* open, the message is queued and sent on open (see the `queue` option), unless
|
|
304
|
+
* `options.volatile` is set.
|
|
289
305
|
* @returns `true` if it was sent immediately, `false` if it was queued or dropped.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* game.send({ type: 'input', dx, dy }, { volatile: true }); // never replayed after a reconnect
|
|
310
|
+
* game.send({ type: 'position', x, y }, { key: 'position' }); // only the newest is queued
|
|
311
|
+
* ```
|
|
290
312
|
*/
|
|
291
|
-
send(message: Out): boolean;
|
|
313
|
+
send(message: Out, options?: SendOptions): boolean;
|
|
292
314
|
/**
|
|
293
315
|
* Start connecting, and keep the connection open even when no hook is using the
|
|
294
316
|
* client, until `close()` is called. Does nothing if already connecting or open.
|
|
@@ -335,4 +357,245 @@ interface WebSocketClient<In = unknown, Out = unknown> {
|
|
|
335
357
|
*/
|
|
336
358
|
declare function createWebSocket<In = unknown, Out = unknown>(url: UrlSource, options?: WebSocketOptions<In, Out>): WebSocketClient<In, Out>;
|
|
337
359
|
|
|
338
|
-
|
|
360
|
+
/** Options for {@link createWebSocketLatency}: how pings and pongs look on the wire. */
|
|
361
|
+
interface WebSocketLatencyOptions<In, Out> {
|
|
362
|
+
/** The ping message, tagged with `id`. Sent with `volatile`, so pings are never queued. */
|
|
363
|
+
ping: (id: number) => Out;
|
|
364
|
+
/**
|
|
365
|
+
* Recognize a pong: return the `id` of the ping it answers, and the server's clock
|
|
366
|
+
* (`Date.now()` on the server, in ms) when it replied. Return `undefined` (or `null`)
|
|
367
|
+
* for any other message. Without `time`, only the round-trip time is measured.
|
|
368
|
+
*/
|
|
369
|
+
pong: (message: In) => {
|
|
370
|
+
id: number | string;
|
|
371
|
+
time?: number;
|
|
372
|
+
} | null | undefined;
|
|
373
|
+
/**
|
|
374
|
+
* How often to ping while open, in ms. The first ping goes out as soon as the socket opens.
|
|
375
|
+
* @default 2000
|
|
376
|
+
*/
|
|
377
|
+
interval?: number;
|
|
378
|
+
/**
|
|
379
|
+
* How many recent measurements to keep. `rtt` is their median, and `offset` comes from
|
|
380
|
+
* the one with the lowest round trip, the most accurate estimate.
|
|
381
|
+
* @default 5
|
|
382
|
+
*/
|
|
383
|
+
samples?: number;
|
|
384
|
+
}
|
|
385
|
+
/** Latest latency measurements. A new object is created on every change. */
|
|
386
|
+
interface Latency {
|
|
387
|
+
/** Round-trip time in ms: the median of recent pings. `undefined` until the first pong. */
|
|
388
|
+
readonly rtt: number | undefined;
|
|
389
|
+
/**
|
|
390
|
+
* Estimated server clock minus local clock, in ms. Add it to `Date.now()` for server time.
|
|
391
|
+
* `undefined` until a pong carries `time`.
|
|
392
|
+
*/
|
|
393
|
+
readonly offset: number | undefined;
|
|
394
|
+
}
|
|
395
|
+
/** Round-trip time and server clock offset for one client. Create with {@link createWebSocketLatency}. */
|
|
396
|
+
interface WebSocketLatency {
|
|
397
|
+
/** The client being measured. Hooks reading the latency keep it connected. */
|
|
398
|
+
readonly client: WebSocketClient<any, any>;
|
|
399
|
+
/** The current {@link Latency}. Returns the same object until it changes. */
|
|
400
|
+
getSnapshot(): Latency;
|
|
401
|
+
/** Called whenever a measurement changes. Compatible with `useSyncExternalStore`. */
|
|
402
|
+
subscribe(listener: () => void): () => void;
|
|
403
|
+
/** The estimated server time: `Date.now() + offset`, or `Date.now()` before any estimate. */
|
|
404
|
+
serverNow(): number;
|
|
405
|
+
/** Ping now instead of waiting for the next interval. Does nothing unless the socket is open. */
|
|
406
|
+
ping(): void;
|
|
407
|
+
/** Stop pinging and listening. Only needed for instances created dynamically. */
|
|
408
|
+
dispose(): void;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Measure round-trip time and estimate the server's clock, for ping displays, countdowns
|
|
412
|
+
* and timestamps that must agree across players.
|
|
413
|
+
*
|
|
414
|
+
* Pings are sent on an interval while the socket is open, and paused otherwise. Measurements
|
|
415
|
+
* from a previous connection are discarded on reconnect, but the last values stay readable
|
|
416
|
+
* until new ones arrive. It doesn't open the connection by itself.
|
|
417
|
+
*
|
|
418
|
+
* Pongs are ordinary messages: they also reach `onMessage`, `lastMessage` and state stores.
|
|
419
|
+
*
|
|
420
|
+
* @example
|
|
421
|
+
* ```ts
|
|
422
|
+
* export const latency = createWebSocketLatency(game, {
|
|
423
|
+
* ping: id => ({ type: 'ping', id }),
|
|
424
|
+
* pong: message => (message.type === 'pong' ? { id: message.id, time: message.time } : undefined)
|
|
425
|
+
* });
|
|
426
|
+
*
|
|
427
|
+
* // In a component:
|
|
428
|
+
* const { rtt } = useWebSocketLatency(latency);
|
|
429
|
+
*
|
|
430
|
+
* // Anywhere: seconds left in a round that ends at the server's `endsAt`.
|
|
431
|
+
* const left = (endsAt - latency.serverNow()) / 1000;
|
|
432
|
+
* ```
|
|
433
|
+
*/
|
|
434
|
+
declare function createWebSocketLatency<In, const Out>(client: WebSocketClient<In, Out>, options: WebSocketLatencyOptions<NoInfer<In>, NoInfer<Out>>): WebSocketLatency;
|
|
435
|
+
|
|
436
|
+
/** Options for {@link createWebSocketRequests}: how requests and replies look on the wire. */
|
|
437
|
+
interface WebSocketRequestsOptions<Req, In, Out> {
|
|
438
|
+
/**
|
|
439
|
+
* Build the outgoing message for `request(message)`, tagged with `id`. Annotate `message`
|
|
440
|
+
* to type what `request()` accepts.
|
|
441
|
+
*/
|
|
442
|
+
send: (message: Req, id: number) => Out;
|
|
443
|
+
/**
|
|
444
|
+
* The id an incoming message replies to, or `undefined` (or `null`) if it isn't a reply.
|
|
445
|
+
* A string id matches the number it was sent as, so either echo works.
|
|
446
|
+
*/
|
|
447
|
+
reply: (message: In) => number | string | null | undefined;
|
|
448
|
+
/**
|
|
449
|
+
* Called on a reply. Return something other than `undefined` to reject the request with
|
|
450
|
+
* it instead of resolving, e.g. for a server-side error reply.
|
|
451
|
+
*/
|
|
452
|
+
error?: (message: In) => unknown;
|
|
453
|
+
/**
|
|
454
|
+
* Default time to wait for a reply, in ms. `Infinity` waits forever.
|
|
455
|
+
* @default 10000
|
|
456
|
+
*/
|
|
457
|
+
timeout?: number;
|
|
458
|
+
}
|
|
459
|
+
/** Per-call options for {@link WebSocketRequests.request}. */
|
|
460
|
+
interface RequestOptions extends SendOptions {
|
|
461
|
+
/** Time to wait for a reply, in ms. Overrides the manager's `timeout`. */
|
|
462
|
+
timeout?: number;
|
|
463
|
+
/** Abort the request. It rejects with `signal.reason`. A late reply is ignored. */
|
|
464
|
+
signal?: AbortSignal;
|
|
465
|
+
}
|
|
466
|
+
/** Request/response over one client. Create with {@link createWebSocketRequests}. */
|
|
467
|
+
interface WebSocketRequests<Req, In> {
|
|
468
|
+
/** The client requests are sent on. */
|
|
469
|
+
readonly client: WebSocketClient<any, any>;
|
|
470
|
+
/**
|
|
471
|
+
* Send a request and resolve with its reply. Holds the connection until it settles.
|
|
472
|
+
*
|
|
473
|
+
* Rejects with a `TimeoutError` `DOMException` after `timeout`, with `signal.reason` when
|
|
474
|
+
* aborted, and with an `Error` when the connection it was sent on closes (the reply can
|
|
475
|
+
* no longer arrive) or the client stops. A request made while disconnected is queued like
|
|
476
|
+
* any other message and waits for the connection.
|
|
477
|
+
*/
|
|
478
|
+
request(message: Req, options?: RequestOptions): Promise<In>;
|
|
479
|
+
/** Number of requests waiting for a reply. */
|
|
480
|
+
pending(): number;
|
|
481
|
+
/** Reject every pending request and stop listening. Only needed for managers created dynamically. */
|
|
482
|
+
dispose(): void;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Send requests and await their replies, matched by an id the library generates.
|
|
486
|
+
*
|
|
487
|
+
* Replies are ordinary messages: they also reach `onMessage`, `lastMessage` and state stores.
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```ts
|
|
491
|
+
* type Request = { type: 'join'; room: string } | { type: 'leave' };
|
|
492
|
+
*
|
|
493
|
+
* export const rpc = createWebSocketRequests(game, {
|
|
494
|
+
* send: (message: Request, id) => ({ ...message, id }),
|
|
495
|
+
* reply: message => (message.type === 'reply' ? message.id : undefined),
|
|
496
|
+
* error: message => (message.type === 'reply' && message.error ? new Error(message.error) : undefined)
|
|
497
|
+
* });
|
|
498
|
+
*
|
|
499
|
+
* const reply = await rpc.request({ type: 'join', room: 'lobby' }, { timeout: 5000 });
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
declare function createWebSocketRequests<Req, In, const Out>(client: WebSocketClient<In, Out>, options: WebSocketRequestsOptions<Req, NoInfer<In>, NoInfer<Out>>): WebSocketRequests<Req, In>;
|
|
503
|
+
|
|
504
|
+
/** Options for {@link createWebSocketState}. */
|
|
505
|
+
interface WebSocketStateOptions<S, In> {
|
|
506
|
+
/** The state before any message arrives, and what `reset()` returns to. */
|
|
507
|
+
initialState: S;
|
|
508
|
+
/**
|
|
509
|
+
* Fold one incoming message into the state. Must be pure: return a new object for a
|
|
510
|
+
* change, or the same `state` to ignore the message (subscribers aren't notified).
|
|
511
|
+
*/
|
|
512
|
+
reduce: (state: S, message: In) => S;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* State derived from a client's messages. Read it with `useWebSocketState(store, selector)`,
|
|
516
|
+
* or with `getState`/`subscribe` outside React.
|
|
517
|
+
*
|
|
518
|
+
* @typeParam S - The state type. Don't use a function type: `setState` treats functions as updaters.
|
|
519
|
+
*/
|
|
520
|
+
interface WebSocketState<S> {
|
|
521
|
+
/** The client whose messages this store reduces. Hooks reading the store keep it connected. */
|
|
522
|
+
readonly client: WebSocketClient<any, any>;
|
|
523
|
+
/** The `initialState` it was created with. Used for server rendering and hydration. */
|
|
524
|
+
readonly initialState: S;
|
|
525
|
+
/** The current state. Returns the same object until it changes. */
|
|
526
|
+
getState(): S;
|
|
527
|
+
/** Called whenever the state changes. Compatible with `useSyncExternalStore`. */
|
|
528
|
+
subscribe(listener: () => void): () => void;
|
|
529
|
+
/** Replace the state, or update it from the current one, e.g. for optimistic updates. */
|
|
530
|
+
setState(update: S | ((state: S) => S)): void;
|
|
531
|
+
/** Go back to `initialState`. */
|
|
532
|
+
reset(): void;
|
|
533
|
+
/** Stop reducing messages. Only needed for stores created dynamically rather than at module level. */
|
|
534
|
+
dispose(): void;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Keep state derived from a client's messages, like a reducer that runs on every message.
|
|
538
|
+
* It starts listening immediately and keeps the state across reconnects; it doesn't open
|
|
539
|
+
* the connection by itself.
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```ts
|
|
543
|
+
* const prices = createWebSocket<Tick>('wss://api.example.com/prices', { json: true });
|
|
544
|
+
*
|
|
545
|
+
* export const priceState = createWebSocketState(prices, {
|
|
546
|
+
* initialState: {} as Record<string, number>,
|
|
547
|
+
* reduce: (state, tick) => ({ ...state, [tick.symbol]: tick.price })
|
|
548
|
+
* });
|
|
549
|
+
*
|
|
550
|
+
* // In a component: re-renders only when ACME's price changes.
|
|
551
|
+
* const acme = useWebSocketState(priceState, state => state.ACME);
|
|
552
|
+
* ```
|
|
553
|
+
*/
|
|
554
|
+
declare function createWebSocketState<S, In>(client: WebSocketClient<In, any>, options: WebSocketStateOptions<S, NoInfer<In>>): WebSocketState<S>;
|
|
555
|
+
|
|
556
|
+
/** Options for {@link createWebSocketTopics}: the messages your server expects. */
|
|
557
|
+
interface WebSocketTopicsOptions<Out> {
|
|
558
|
+
/** The message that subscribes to `topic`. */
|
|
559
|
+
subscribe: (topic: string) => Out;
|
|
560
|
+
/** The message that unsubscribes from `topic`. Omit it if your server has none. */
|
|
561
|
+
unsubscribe?: (topic: string) => Out;
|
|
562
|
+
}
|
|
563
|
+
/** Reference-counted topic subscriptions on one client. Create with {@link createWebSocketTopics}. */
|
|
564
|
+
interface WebSocketTopics {
|
|
565
|
+
/** The client the subscription messages are sent on. */
|
|
566
|
+
readonly client: WebSocketClient<any, any>;
|
|
567
|
+
/**
|
|
568
|
+
* Hold a subscription to `topic`, and the client's connection, until the returned
|
|
569
|
+
* function is called. The release function is safe to call more than once.
|
|
570
|
+
*/
|
|
571
|
+
acquire(topic: string): () => void;
|
|
572
|
+
/** The topics currently held, in the order they were first acquired. */
|
|
573
|
+
active(): string[];
|
|
574
|
+
/** Stop managing subscriptions. Only needed for managers created dynamically. */
|
|
575
|
+
dispose(): void;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Manage topic subscriptions that survive reconnects.
|
|
579
|
+
*
|
|
580
|
+
* - The first holder of a topic sends `subscribe(topic)`. Later holders share it.
|
|
581
|
+
* - A tick after the last holder releases it, `unsubscribe(topic)` is sent, so StrictMode
|
|
582
|
+
* and route transitions don't churn subscriptions.
|
|
583
|
+
* - After every reconnect, every held topic is subscribed again on the new socket, before
|
|
584
|
+
* messages queued while disconnected are sent.
|
|
585
|
+
*
|
|
586
|
+
* Subscriptions are only sent while the socket is open; nothing is queued for them.
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* ```ts
|
|
590
|
+
* export const topics = createWebSocketTopics(live, {
|
|
591
|
+
* subscribe: topic => ({ type: 'subscribe', topic }),
|
|
592
|
+
* unsubscribe: topic => ({ type: 'unsubscribe', topic })
|
|
593
|
+
* });
|
|
594
|
+
*
|
|
595
|
+
* // In a component:
|
|
596
|
+
* useWebSocketTopic(topics, `prices:${symbol}`);
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
declare function createWebSocketTopics<const Out>(client: WebSocketClient<any, Out>, options: WebSocketTopicsOptions<NoInfer<Out>>): WebSocketTopics;
|
|
600
|
+
|
|
601
|
+
export { type CloseInfo, type EventMap, type HeartbeatOptions, type Latency, MessageError, type QueueOptions, type ReconnectOptions, type RequestOptions, type SendData, type SendOptions, type Snapshot, type StandardSchemaV1, type Status, type UrlSource, type WebSocketClient, type WebSocketLatency, type WebSocketLatencyOptions, type WebSocketOptions, type WebSocketRequests, type WebSocketRequestsOptions, type WebSocketState, type WebSocketStateOptions, type WebSocketTopics, type WebSocketTopicsOptions, createWebSocket, createWebSocketLatency, createWebSocketRequests, createWebSocketState, createWebSocketTopics };
|
package/dist/core.js
CHANGED
|
@@ -220,7 +220,7 @@ function createClient(url, options = {}) {
|
|
|
220
220
|
if (ws !== socket) return;
|
|
221
221
|
const queued = pending;
|
|
222
222
|
pending = [];
|
|
223
|
-
for (const data of queued) socket.send(data);
|
|
223
|
+
for (const { data } of queued) socket.send(data);
|
|
224
224
|
};
|
|
225
225
|
socket.onmessage = (event) => receive(event, socket);
|
|
226
226
|
socket.onerror = (event) => emit("error", event);
|
|
@@ -316,13 +316,13 @@ function createClient(url, options = {}) {
|
|
|
316
316
|
set({ lastMessage: message });
|
|
317
317
|
emit("message", message, event);
|
|
318
318
|
}
|
|
319
|
-
function send(message) {
|
|
319
|
+
function send(message, sendOptions) {
|
|
320
320
|
const data = options.serialize ? options.serialize(message) : options.json ? JSON.stringify(message) : message;
|
|
321
321
|
if (ws && ws.readyState === OPEN) {
|
|
322
322
|
ws.send(data);
|
|
323
323
|
return true;
|
|
324
324
|
}
|
|
325
|
-
if (queueLimit === 0) {
|
|
325
|
+
if (queueLimit === 0 || sendOptions?.volatile) {
|
|
326
326
|
log("dropped message: socket is not open");
|
|
327
327
|
return false;
|
|
328
328
|
}
|
|
@@ -330,7 +330,9 @@ function createClient(url, options = {}) {
|
|
|
330
330
|
log("dropped message: no WebSocket implementation available");
|
|
331
331
|
return false;
|
|
332
332
|
}
|
|
333
|
-
|
|
333
|
+
const key = sendOptions?.key;
|
|
334
|
+
if (key !== void 0) pending = pending.filter((entry) => entry.key !== key);
|
|
335
|
+
pending.push({ data, key });
|
|
334
336
|
if (pending.length > queueLimit) {
|
|
335
337
|
pending.shift();
|
|
336
338
|
log(`dropped oldest queued message: queue limit (${queueLimit}) reached`);
|
|
@@ -462,8 +464,288 @@ function createClient(url, options = {}) {
|
|
|
462
464
|
function createWebSocket(url, options) {
|
|
463
465
|
return createClient(url, options);
|
|
464
466
|
}
|
|
467
|
+
|
|
468
|
+
// src/latency.ts
|
|
469
|
+
var EMPTY = Object.freeze({ rtt: void 0, offset: void 0 });
|
|
470
|
+
function median(values) {
|
|
471
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
472
|
+
const middle = sorted.length >> 1;
|
|
473
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
474
|
+
}
|
|
475
|
+
function createWebSocketLatency(client, options) {
|
|
476
|
+
const interval = options.interval ?? 2e3;
|
|
477
|
+
const keep = Math.max(1, options.samples ?? 5);
|
|
478
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
479
|
+
let snapshot = EMPTY;
|
|
480
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
481
|
+
let samples = [];
|
|
482
|
+
let nextId = 0;
|
|
483
|
+
let timer;
|
|
484
|
+
function ping() {
|
|
485
|
+
if (client.getSnapshot().status !== "open") return;
|
|
486
|
+
const id = ++nextId;
|
|
487
|
+
inFlight.set(String(id), performance.now());
|
|
488
|
+
if (inFlight.size > keep * 2) inFlight.delete(inFlight.keys().next().value);
|
|
489
|
+
client.send(options.ping(id), { volatile: true });
|
|
490
|
+
}
|
|
491
|
+
function start() {
|
|
492
|
+
stop();
|
|
493
|
+
inFlight.clear();
|
|
494
|
+
samples = [];
|
|
495
|
+
ping();
|
|
496
|
+
timer = setInterval(ping, interval);
|
|
497
|
+
}
|
|
498
|
+
function stop() {
|
|
499
|
+
clearInterval(timer);
|
|
500
|
+
timer = void 0;
|
|
501
|
+
}
|
|
502
|
+
function measure(id, serverTime) {
|
|
503
|
+
const sentAt = inFlight.get(id);
|
|
504
|
+
if (sentAt === void 0) return;
|
|
505
|
+
inFlight.delete(id);
|
|
506
|
+
const rtt = performance.now() - sentAt;
|
|
507
|
+
const offset = serverTime === void 0 ? void 0 : serverTime + rtt / 2 - Date.now();
|
|
508
|
+
samples.push({ rtt, offset });
|
|
509
|
+
if (samples.length > keep) samples.shift();
|
|
510
|
+
let best;
|
|
511
|
+
for (const sample of samples) {
|
|
512
|
+
if (sample.offset !== void 0 && (!best || sample.rtt < best.rtt)) best = sample;
|
|
513
|
+
}
|
|
514
|
+
snapshot = {
|
|
515
|
+
rtt: median(samples.map((sample) => sample.rtt)),
|
|
516
|
+
offset: best ? best.offset : snapshot.offset
|
|
517
|
+
};
|
|
518
|
+
for (const listener of [...listeners]) listener();
|
|
519
|
+
}
|
|
520
|
+
const offs = [
|
|
521
|
+
client.on("open", start),
|
|
522
|
+
client.on("message", (message) => {
|
|
523
|
+
const pong = options.pong(message);
|
|
524
|
+
if (pong != null) measure(String(pong.id), pong.time);
|
|
525
|
+
}),
|
|
526
|
+
client.subscribe(() => {
|
|
527
|
+
if (client.getSnapshot().status !== "open") stop();
|
|
528
|
+
})
|
|
529
|
+
];
|
|
530
|
+
if (client.getSnapshot().status === "open") start();
|
|
531
|
+
return {
|
|
532
|
+
client,
|
|
533
|
+
getSnapshot: () => snapshot,
|
|
534
|
+
subscribe(listener) {
|
|
535
|
+
listeners.add(listener);
|
|
536
|
+
return () => {
|
|
537
|
+
listeners.delete(listener);
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
serverNow: () => Date.now() + (snapshot.offset ?? 0),
|
|
541
|
+
ping,
|
|
542
|
+
dispose() {
|
|
543
|
+
stop();
|
|
544
|
+
offs.forEach((off) => off());
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/requests.ts
|
|
550
|
+
function createWebSocketRequests(client, options) {
|
|
551
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
552
|
+
let nextId = 0;
|
|
553
|
+
function rejectWhere(test, reason) {
|
|
554
|
+
for (const entry of [...waiting.values()]) if (test(entry)) entry.reject(reason());
|
|
555
|
+
}
|
|
556
|
+
const offs = [
|
|
557
|
+
client.on("message", (message) => {
|
|
558
|
+
const id = options.reply(message);
|
|
559
|
+
const entry = id == null ? void 0 : waiting.get(String(id));
|
|
560
|
+
if (!entry) return;
|
|
561
|
+
const error = options.error?.(message);
|
|
562
|
+
if (error === void 0) entry.resolve(message);
|
|
563
|
+
else entry.reject(error);
|
|
564
|
+
}),
|
|
565
|
+
// `open` listeners run before the client flushes its queue: queued requests go out on this socket.
|
|
566
|
+
client.on("open", () => {
|
|
567
|
+
for (const entry of waiting.values()) entry.socket ??= client.socket;
|
|
568
|
+
}),
|
|
569
|
+
client.subscribe(() => {
|
|
570
|
+
const { status } = client.getSnapshot();
|
|
571
|
+
if (status === "closed" || status === "idle") {
|
|
572
|
+
rejectWhere(
|
|
573
|
+
() => true,
|
|
574
|
+
() => new Error("WebSocket was closed before the request was answered")
|
|
575
|
+
);
|
|
576
|
+
} else {
|
|
577
|
+
rejectWhere(
|
|
578
|
+
(entry) => entry.socket !== null && entry.socket !== client.socket,
|
|
579
|
+
() => new Error("WebSocket connection was lost before the request was answered")
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
];
|
|
584
|
+
return {
|
|
585
|
+
client,
|
|
586
|
+
request(message, requestOptions = {}) {
|
|
587
|
+
const { signal } = requestOptions;
|
|
588
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
589
|
+
const id = ++nextId;
|
|
590
|
+
const key = String(id);
|
|
591
|
+
const timeout = requestOptions.timeout ?? options.timeout ?? 1e4;
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
let timer;
|
|
594
|
+
const release = client.acquire();
|
|
595
|
+
const onAbort = () => entry.reject(signal.reason);
|
|
596
|
+
function settle() {
|
|
597
|
+
waiting.delete(key);
|
|
598
|
+
clearTimeout(timer);
|
|
599
|
+
signal?.removeEventListener("abort", onAbort);
|
|
600
|
+
release();
|
|
601
|
+
}
|
|
602
|
+
const entry = {
|
|
603
|
+
socket: null,
|
|
604
|
+
resolve(reply) {
|
|
605
|
+
settle();
|
|
606
|
+
resolve(reply);
|
|
607
|
+
},
|
|
608
|
+
reject(reason) {
|
|
609
|
+
settle();
|
|
610
|
+
reject(reason);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
waiting.set(key, entry);
|
|
614
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
615
|
+
if (timeout > 0 && timeout < Infinity) {
|
|
616
|
+
timer = setTimeout(
|
|
617
|
+
() => entry.reject(
|
|
618
|
+
new DOMException(`Request ${id} timed out after ${timeout}ms`, "TimeoutError")
|
|
619
|
+
),
|
|
620
|
+
timeout
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
const { status } = client.getSnapshot();
|
|
624
|
+
if (status === "closed" || status === "idle") {
|
|
625
|
+
entry.reject(new Error(`WebSocket is ${status}: request not sent`));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
let sent;
|
|
629
|
+
try {
|
|
630
|
+
sent = client.send(options.send(message, id), requestOptions);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
entry.reject(error);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (sent) entry.socket = client.socket;
|
|
636
|
+
else if (requestOptions.volatile)
|
|
637
|
+
entry.reject(new Error("WebSocket is not open: request dropped"));
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
pending: () => waiting.size,
|
|
641
|
+
dispose() {
|
|
642
|
+
offs.forEach((off) => off());
|
|
643
|
+
rejectWhere(
|
|
644
|
+
() => true,
|
|
645
|
+
() => new Error("Requests were disposed")
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/state.ts
|
|
652
|
+
function createWebSocketState(client, options) {
|
|
653
|
+
const { initialState, reduce } = options;
|
|
654
|
+
let state = initialState;
|
|
655
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
656
|
+
function setState(update) {
|
|
657
|
+
const next = typeof update === "function" ? update(state) : update;
|
|
658
|
+
if (Object.is(next, state)) return;
|
|
659
|
+
state = next;
|
|
660
|
+
for (const listener of [...listeners]) listener();
|
|
661
|
+
}
|
|
662
|
+
const off = client.on("message", (message) => setState((current) => reduce(current, message)));
|
|
663
|
+
return {
|
|
664
|
+
client,
|
|
665
|
+
initialState,
|
|
666
|
+
getState: () => state,
|
|
667
|
+
subscribe(listener) {
|
|
668
|
+
listeners.add(listener);
|
|
669
|
+
return () => {
|
|
670
|
+
listeners.delete(listener);
|
|
671
|
+
};
|
|
672
|
+
},
|
|
673
|
+
setState,
|
|
674
|
+
reset: () => setState(initialState),
|
|
675
|
+
dispose: off
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/topics.ts
|
|
680
|
+
function createWebSocketTopics(client, options) {
|
|
681
|
+
const counts = /* @__PURE__ */ new Map();
|
|
682
|
+
const pendingRemoval = /* @__PURE__ */ new Map();
|
|
683
|
+
let sentOn = null;
|
|
684
|
+
let sent = /* @__PURE__ */ new Set();
|
|
685
|
+
function isOpen() {
|
|
686
|
+
return client.getSnapshot().status === "open" && client.socket !== null;
|
|
687
|
+
}
|
|
688
|
+
function subscribe(topic) {
|
|
689
|
+
if (!isOpen()) return;
|
|
690
|
+
if (client.socket !== sentOn) {
|
|
691
|
+
sentOn = client.socket;
|
|
692
|
+
sent = /* @__PURE__ */ new Set();
|
|
693
|
+
}
|
|
694
|
+
if (sent.has(topic)) return;
|
|
695
|
+
sent.add(topic);
|
|
696
|
+
client.send(options.subscribe(topic));
|
|
697
|
+
}
|
|
698
|
+
function unsubscribe(topic) {
|
|
699
|
+
if (!isOpen() || client.socket !== sentOn || !sent.delete(topic)) return;
|
|
700
|
+
if (options.unsubscribe) client.send(options.unsubscribe(topic));
|
|
701
|
+
}
|
|
702
|
+
const off = client.on("open", () => {
|
|
703
|
+
sentOn = client.socket;
|
|
704
|
+
sent = /* @__PURE__ */ new Set();
|
|
705
|
+
for (const topic of counts.keys()) subscribe(topic);
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
client,
|
|
709
|
+
acquire(topic) {
|
|
710
|
+
const releaseConnection = client.acquire();
|
|
711
|
+
clearTimeout(pendingRemoval.get(topic));
|
|
712
|
+
pendingRemoval.delete(topic);
|
|
713
|
+
counts.set(topic, (counts.get(topic) ?? 0) + 1);
|
|
714
|
+
subscribe(topic);
|
|
715
|
+
let released = false;
|
|
716
|
+
return () => {
|
|
717
|
+
if (released) return;
|
|
718
|
+
released = true;
|
|
719
|
+
const remaining = (counts.get(topic) ?? 1) - 1;
|
|
720
|
+
if (remaining > 0) {
|
|
721
|
+
counts.set(topic, remaining);
|
|
722
|
+
} else {
|
|
723
|
+
counts.delete(topic);
|
|
724
|
+
pendingRemoval.set(
|
|
725
|
+
topic,
|
|
726
|
+
setTimeout(() => {
|
|
727
|
+
pendingRemoval.delete(topic);
|
|
728
|
+
if (!counts.has(topic)) unsubscribe(topic);
|
|
729
|
+
}, 0)
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
releaseConnection();
|
|
733
|
+
};
|
|
734
|
+
},
|
|
735
|
+
active: () => [...counts.keys()],
|
|
736
|
+
dispose() {
|
|
737
|
+
off();
|
|
738
|
+
for (const timer of pendingRemoval.values()) clearTimeout(timer);
|
|
739
|
+
pendingRemoval.clear();
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
}
|
|
465
743
|
export {
|
|
466
744
|
MessageError,
|
|
467
|
-
createWebSocket
|
|
745
|
+
createWebSocket,
|
|
746
|
+
createWebSocketLatency,
|
|
747
|
+
createWebSocketRequests,
|
|
748
|
+
createWebSocketState,
|
|
749
|
+
createWebSocketTopics
|
|
468
750
|
};
|
|
469
751
|
//# sourceMappingURL=core.js.map
|