react-websocket 3.3.0 → 3.5.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/dist/core.d.cts CHANGED
@@ -111,14 +111,42 @@ interface SendOptions {
111
111
  volatile?: boolean;
112
112
  /**
113
113
  * Keep only the newest queued message with this key. Sending again with the same key
114
- * while disconnected replaces the earlier one, which moves to the back of the queue.
114
+ * while disconnected replaces the earlier one, which moves to the back of the queue
115
+ * (or of the `priority` messages, for a priority one).
115
116
  * For state where only the latest value matters, such as "my position is now (x, y)".
116
117
  */
117
118
  key?: string;
119
+ /**
120
+ * Queue this message ahead of every message queued without `priority`, after earlier
121
+ * priority messages. For control messages that must not wait behind a backlog, such as
122
+ * cancel, interrupt or a token refresh. Messages sent from `open` listeners still go first.
123
+ *
124
+ * It only matters while the socket isn't open: once a message is handed to the browser,
125
+ * nothing can overtake it. To keep that buffer short, send bulky streams with `maxBuffered`.
126
+ * @default false
127
+ */
128
+ priority?: boolean;
129
+ /**
130
+ * Drop the message instead of sending it while more than this many bytes are already
131
+ * waiting in the browser's send buffer (`socket.bufferedAmount`). For high-rate streams on
132
+ * a slow link, such as audio frames or per-tick input, where a late message is worse than
133
+ * none. `send()` returns `false`, and `onDrop` isn't called.
134
+ *
135
+ * Only checked while the socket is open: a message queued while disconnected is sent on
136
+ * open regardless. `bufferedAmount` includes what you sent earlier in the same task, so set
137
+ * it well above one burst of messages.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * voice.send(frame, { volatile: true, maxBuffered: 64 * 1024 });
142
+ * ```
143
+ */
144
+ maxBuffered?: number;
118
145
  /**
119
146
  * Called if the message is dropped from the queue without being sent: replaced by a
120
147
  * newer message with the same `key`, or evicted by `queue.limit`. Not called when the
121
- * queue is cleared because the client closed or gave up.
148
+ * queue is cleared because the client closed or gave up, or when `send()` drops the
149
+ * message right away (`volatile` while not open, or `maxBuffered`) and returns `false`.
122
150
  */
123
151
  onDrop?: () => void;
124
152
  }
@@ -126,7 +154,7 @@ interface SendOptions {
126
154
  interface QueueOptions {
127
155
  /**
128
156
  * Maximum number of messages held while not connected. When full, the oldest
129
- * message is dropped.
157
+ * message is dropped, skipping `priority` messages unless nothing else is queued.
130
158
  * @default Infinity
131
159
  */
132
160
  limit?: number;
@@ -309,12 +337,14 @@ interface WebSocketClient<In = unknown, Out = unknown> {
309
337
  * Send a message (encoded with `serialize`/`json` if configured). If the socket isn't
310
338
  * open, the message is queued and sent on open (see the `queue` option), unless
311
339
  * `options.volatile` is set.
312
- * @returns `true` if it was sent immediately, `false` if it was queued or dropped.
340
+ * @returns `true` if it was sent immediately, `false` if it was queued or dropped
341
+ * (`volatile` while not open, or `maxBuffered` while open).
313
342
  *
314
343
  * @example
315
344
  * ```ts
316
345
  * game.send({ type: 'input', dx, dy }, { volatile: true }); // never replayed after a reconnect
317
346
  * game.send({ type: 'position', x, y }, { key: 'position' }); // only the newest is queued
347
+ * game.send({ type: 'leave' }, { priority: true }); // ahead of everything already queued
318
348
  * ```
319
349
  */
320
350
  send(message: Out, options?: SendOptions): boolean;
@@ -649,6 +679,85 @@ interface WebSocketResponder<P = unknown, R = unknown> {
649
679
  */
650
680
  export declare function createWebSocketResponder<Q extends RequestFields, R, In, const Out>(client: WebSocketClient<In, Out>, options: WebSocketResponderOptions<Q, R, NoInfer<In>, NoInfer<Out>>): WebSocketResponder<ParamsOf<Q>, R>;
651
681
  //#endregion
682
+ //#region src/sendrate.d.ts
683
+ /** Options for {@link createWebSocketSendRate}. */
684
+ interface WebSocketSendRateOptions {
685
+ /**
686
+ * Ticks per second while the connection keeps up.
687
+ * @default 30
688
+ */
689
+ maxRate?: number;
690
+ /**
691
+ * The rate never drops below this, in ticks per second.
692
+ * @default 5
693
+ */
694
+ minRate?: number;
695
+ /**
696
+ * Bytes waiting in the browser's send buffer (`socket.bufferedAmount`) above which a tick
697
+ * sends nothing and the rate halves.
698
+ * @default 1024
699
+ */
700
+ maxBuffered?: number;
701
+ /**
702
+ * Measurements from `createWebSocketLatency` on the same client. With them, a round-trip time
703
+ * that rises also lowers the rate. Without them, only the send buffer is watched.
704
+ */
705
+ latency?: Pick<WebSocketLatency, 'getSnapshot'>;
706
+ /**
707
+ * How far `rtt` may rise above the lowest one measured on this connection before the rate
708
+ * halves, in ms. A steady high ping keeps the full rate; only a rising one counts.
709
+ * @default 100
710
+ */
711
+ maxRttIncrease?: number;
712
+ }
713
+ /** The current send rate. A new object is created on every change. */
714
+ interface SendRate {
715
+ /** Ticks per second, rounded. */
716
+ readonly rate: number;
717
+ /**
718
+ * Whether the last tick found the link congested: the send buffer above `maxBuffered`, or
719
+ * `rtt` risen more than `maxRttIncrease`.
720
+ */
721
+ readonly congested: boolean;
722
+ }
723
+ /** Keyed sends coalesced at a rate that adapts to the link. Create with {@link createWebSocketSendRate}. */
724
+ interface WebSocketSendRate<Out> {
725
+ /** The client the messages are sent on. */
726
+ readonly client: WebSocketClient<any, Out>;
727
+ /**
728
+ * Send `message` at the next tick. A message with the same `key` still waiting for it is
729
+ * replaced, so only the newest one goes out. Sent with `volatile`.
730
+ */
731
+ send(message: Out, key: string): void;
732
+ /** Send everything waiting now, e.g. right before a one-shot action that must carry the latest aim. */
733
+ flush(): void;
734
+ /** The current {@link SendRate}. Returns the same object until it changes. */
735
+ getSnapshot(): SendRate;
736
+ /** Called whenever the rate or `congested` changes. Compatible with `useSyncExternalStore`. */
737
+ subscribe(listener: () => void): () => void;
738
+ /** Stop sending and listening. Only needed for instances created dynamically. */
739
+ dispose(): void;
740
+ }
741
+ /**
742
+ * Send high-rate state, such as aim, cursor or position, at a rate that adapts to the
743
+ * connection. Messages are coalesced per key between ticks. The rate drops by half when the
744
+ * send buffer fills up or the round trip grows, and climbs back one tick per second at a time
745
+ * when the link keeps up, so players on a poor connection degrade smoothly instead of building
746
+ * up lag.
747
+ *
748
+ * Ticks run only while the socket is open and something is waiting. Messages waiting during a
749
+ * reconnect go out after it; `close()` drops them. It doesn't open the connection by itself.
750
+ *
751
+ * @example
752
+ * ```ts
753
+ * export const stream = createWebSocketSendRate(game, { latency, maxRate: 30 });
754
+ *
755
+ * // On every pointer move: at most 30 a second, fewer on a congested link.
756
+ * stream.send({ type: 'aim', angle }, 'aim');
757
+ * ```
758
+ */
759
+ export declare function createWebSocketSendRate<Out>(client: WebSocketClient<any, Out>, options?: WebSocketSendRateOptions): WebSocketSendRate<Out>;
760
+ //#endregion
652
761
  //#region src/state.d.ts
653
762
  /** Options for {@link createWebSocketState}. */
654
763
  interface WebSocketStateOptions<S, In> {
@@ -659,6 +768,17 @@ interface WebSocketStateOptions<S, In> {
659
768
  * change, or the same `state` to ignore the message (subscribers aren't notified).
660
769
  */
661
770
  reduce: (state: S, message: In) => S;
771
+ /**
772
+ * `'frame'`: notify subscribers at most once per animation frame instead of once per
773
+ * message, so a fast stream (tokens, tickers, game state) renders once per frame.
774
+ * `getState()` is always current; only notifications wait. `setState` and `reset` still
775
+ * notify right away.
776
+ *
777
+ * Browsers pause animation frames in background tabs: the state keeps up, and subscribers
778
+ * hear about it when the tab is visible again. Without `requestAnimationFrame` (Node, some
779
+ * workers), it waits about 16 ms instead.
780
+ */
781
+ batch?: 'frame';
662
782
  }
663
783
  /**
664
784
  * State derived from a client's messages. Read it with `useWebSocketState(store, selector)`,
@@ -748,5 +868,5 @@ interface WebSocketTopics {
748
868
  */
749
869
  export declare function createWebSocketTopics<const Out>(client: WebSocketClient<any, Out>, options: WebSocketTopicsOptions<NoInfer<Out>>): WebSocketTopics;
750
870
  //#endregion
751
- export type { CloseInfo, EventMap, HeartbeatOptions, IncomingRequest, Latency, QueueOptions, ReconnectOptions, RequestHandler, RequestOptions, SendData, SendOptions, Snapshot, StandardSchemaV1, Status, UrlSource, WebSocketClient, WebSocketLatency, WebSocketLatencyOptions, WebSocketOptions, WebSocketRequests, WebSocketRequestsOptions, WebSocketResponder, WebSocketResponderOptions, WebSocketState, WebSocketStateOptions, WebSocketTopics, WebSocketTopicsOptions };
871
+ export type { CloseInfo, EventMap, HeartbeatOptions, IncomingRequest, Latency, QueueOptions, ReconnectOptions, RequestHandler, RequestOptions, SendData, SendOptions, SendRate, Snapshot, StandardSchemaV1, Status, UrlSource, WebSocketClient, WebSocketLatency, WebSocketLatencyOptions, WebSocketOptions, WebSocketRequests, WebSocketRequestsOptions, WebSocketResponder, WebSocketResponderOptions, WebSocketSendRate, WebSocketSendRateOptions, WebSocketState, WebSocketStateOptions, WebSocketTopics, WebSocketTopicsOptions };
752
872
  //# sourceMappingURL=core.d.cts.map
package/dist/core.d.ts CHANGED
@@ -111,14 +111,42 @@ interface SendOptions {
111
111
  volatile?: boolean;
112
112
  /**
113
113
  * Keep only the newest queued message with this key. Sending again with the same key
114
- * while disconnected replaces the earlier one, which moves to the back of the queue.
114
+ * while disconnected replaces the earlier one, which moves to the back of the queue
115
+ * (or of the `priority` messages, for a priority one).
115
116
  * For state where only the latest value matters, such as "my position is now (x, y)".
116
117
  */
117
118
  key?: string;
119
+ /**
120
+ * Queue this message ahead of every message queued without `priority`, after earlier
121
+ * priority messages. For control messages that must not wait behind a backlog, such as
122
+ * cancel, interrupt or a token refresh. Messages sent from `open` listeners still go first.
123
+ *
124
+ * It only matters while the socket isn't open: once a message is handed to the browser,
125
+ * nothing can overtake it. To keep that buffer short, send bulky streams with `maxBuffered`.
126
+ * @default false
127
+ */
128
+ priority?: boolean;
129
+ /**
130
+ * Drop the message instead of sending it while more than this many bytes are already
131
+ * waiting in the browser's send buffer (`socket.bufferedAmount`). For high-rate streams on
132
+ * a slow link, such as audio frames or per-tick input, where a late message is worse than
133
+ * none. `send()` returns `false`, and `onDrop` isn't called.
134
+ *
135
+ * Only checked while the socket is open: a message queued while disconnected is sent on
136
+ * open regardless. `bufferedAmount` includes what you sent earlier in the same task, so set
137
+ * it well above one burst of messages.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * voice.send(frame, { volatile: true, maxBuffered: 64 * 1024 });
142
+ * ```
143
+ */
144
+ maxBuffered?: number;
118
145
  /**
119
146
  * Called if the message is dropped from the queue without being sent: replaced by a
120
147
  * newer message with the same `key`, or evicted by `queue.limit`. Not called when the
121
- * queue is cleared because the client closed or gave up.
148
+ * queue is cleared because the client closed or gave up, or when `send()` drops the
149
+ * message right away (`volatile` while not open, or `maxBuffered`) and returns `false`.
122
150
  */
123
151
  onDrop?: () => void;
124
152
  }
@@ -126,7 +154,7 @@ interface SendOptions {
126
154
  interface QueueOptions {
127
155
  /**
128
156
  * Maximum number of messages held while not connected. When full, the oldest
129
- * message is dropped.
157
+ * message is dropped, skipping `priority` messages unless nothing else is queued.
130
158
  * @default Infinity
131
159
  */
132
160
  limit?: number;
@@ -309,12 +337,14 @@ interface WebSocketClient<In = unknown, Out = unknown> {
309
337
  * Send a message (encoded with `serialize`/`json` if configured). If the socket isn't
310
338
  * open, the message is queued and sent on open (see the `queue` option), unless
311
339
  * `options.volatile` is set.
312
- * @returns `true` if it was sent immediately, `false` if it was queued or dropped.
340
+ * @returns `true` if it was sent immediately, `false` if it was queued or dropped
341
+ * (`volatile` while not open, or `maxBuffered` while open).
313
342
  *
314
343
  * @example
315
344
  * ```ts
316
345
  * game.send({ type: 'input', dx, dy }, { volatile: true }); // never replayed after a reconnect
317
346
  * game.send({ type: 'position', x, y }, { key: 'position' }); // only the newest is queued
347
+ * game.send({ type: 'leave' }, { priority: true }); // ahead of everything already queued
318
348
  * ```
319
349
  */
320
350
  send(message: Out, options?: SendOptions): boolean;
@@ -649,6 +679,85 @@ interface WebSocketResponder<P = unknown, R = unknown> {
649
679
  */
650
680
  export declare function createWebSocketResponder<Q extends RequestFields, R, In, const Out>(client: WebSocketClient<In, Out>, options: WebSocketResponderOptions<Q, R, NoInfer<In>, NoInfer<Out>>): WebSocketResponder<ParamsOf<Q>, R>;
651
681
  //#endregion
682
+ //#region src/sendrate.d.ts
683
+ /** Options for {@link createWebSocketSendRate}. */
684
+ interface WebSocketSendRateOptions {
685
+ /**
686
+ * Ticks per second while the connection keeps up.
687
+ * @default 30
688
+ */
689
+ maxRate?: number;
690
+ /**
691
+ * The rate never drops below this, in ticks per second.
692
+ * @default 5
693
+ */
694
+ minRate?: number;
695
+ /**
696
+ * Bytes waiting in the browser's send buffer (`socket.bufferedAmount`) above which a tick
697
+ * sends nothing and the rate halves.
698
+ * @default 1024
699
+ */
700
+ maxBuffered?: number;
701
+ /**
702
+ * Measurements from `createWebSocketLatency` on the same client. With them, a round-trip time
703
+ * that rises also lowers the rate. Without them, only the send buffer is watched.
704
+ */
705
+ latency?: Pick<WebSocketLatency, 'getSnapshot'>;
706
+ /**
707
+ * How far `rtt` may rise above the lowest one measured on this connection before the rate
708
+ * halves, in ms. A steady high ping keeps the full rate; only a rising one counts.
709
+ * @default 100
710
+ */
711
+ maxRttIncrease?: number;
712
+ }
713
+ /** The current send rate. A new object is created on every change. */
714
+ interface SendRate {
715
+ /** Ticks per second, rounded. */
716
+ readonly rate: number;
717
+ /**
718
+ * Whether the last tick found the link congested: the send buffer above `maxBuffered`, or
719
+ * `rtt` risen more than `maxRttIncrease`.
720
+ */
721
+ readonly congested: boolean;
722
+ }
723
+ /** Keyed sends coalesced at a rate that adapts to the link. Create with {@link createWebSocketSendRate}. */
724
+ interface WebSocketSendRate<Out> {
725
+ /** The client the messages are sent on. */
726
+ readonly client: WebSocketClient<any, Out>;
727
+ /**
728
+ * Send `message` at the next tick. A message with the same `key` still waiting for it is
729
+ * replaced, so only the newest one goes out. Sent with `volatile`.
730
+ */
731
+ send(message: Out, key: string): void;
732
+ /** Send everything waiting now, e.g. right before a one-shot action that must carry the latest aim. */
733
+ flush(): void;
734
+ /** The current {@link SendRate}. Returns the same object until it changes. */
735
+ getSnapshot(): SendRate;
736
+ /** Called whenever the rate or `congested` changes. Compatible with `useSyncExternalStore`. */
737
+ subscribe(listener: () => void): () => void;
738
+ /** Stop sending and listening. Only needed for instances created dynamically. */
739
+ dispose(): void;
740
+ }
741
+ /**
742
+ * Send high-rate state, such as aim, cursor or position, at a rate that adapts to the
743
+ * connection. Messages are coalesced per key between ticks. The rate drops by half when the
744
+ * send buffer fills up or the round trip grows, and climbs back one tick per second at a time
745
+ * when the link keeps up, so players on a poor connection degrade smoothly instead of building
746
+ * up lag.
747
+ *
748
+ * Ticks run only while the socket is open and something is waiting. Messages waiting during a
749
+ * reconnect go out after it; `close()` drops them. It doesn't open the connection by itself.
750
+ *
751
+ * @example
752
+ * ```ts
753
+ * export const stream = createWebSocketSendRate(game, { latency, maxRate: 30 });
754
+ *
755
+ * // On every pointer move: at most 30 a second, fewer on a congested link.
756
+ * stream.send({ type: 'aim', angle }, 'aim');
757
+ * ```
758
+ */
759
+ export declare function createWebSocketSendRate<Out>(client: WebSocketClient<any, Out>, options?: WebSocketSendRateOptions): WebSocketSendRate<Out>;
760
+ //#endregion
652
761
  //#region src/state.d.ts
653
762
  /** Options for {@link createWebSocketState}. */
654
763
  interface WebSocketStateOptions<S, In> {
@@ -659,6 +768,17 @@ interface WebSocketStateOptions<S, In> {
659
768
  * change, or the same `state` to ignore the message (subscribers aren't notified).
660
769
  */
661
770
  reduce: (state: S, message: In) => S;
771
+ /**
772
+ * `'frame'`: notify subscribers at most once per animation frame instead of once per
773
+ * message, so a fast stream (tokens, tickers, game state) renders once per frame.
774
+ * `getState()` is always current; only notifications wait. `setState` and `reset` still
775
+ * notify right away.
776
+ *
777
+ * Browsers pause animation frames in background tabs: the state keeps up, and subscribers
778
+ * hear about it when the tab is visible again. Without `requestAnimationFrame` (Node, some
779
+ * workers), it waits about 16 ms instead.
780
+ */
781
+ batch?: 'frame';
662
782
  }
663
783
  /**
664
784
  * State derived from a client's messages. Read it with `useWebSocketState(store, selector)`,
@@ -748,5 +868,5 @@ interface WebSocketTopics {
748
868
  */
749
869
  export declare function createWebSocketTopics<const Out>(client: WebSocketClient<any, Out>, options: WebSocketTopicsOptions<NoInfer<Out>>): WebSocketTopics;
750
870
  //#endregion
751
- export type { CloseInfo, EventMap, HeartbeatOptions, IncomingRequest, Latency, QueueOptions, ReconnectOptions, RequestHandler, RequestOptions, SendData, SendOptions, Snapshot, StandardSchemaV1, Status, UrlSource, WebSocketClient, WebSocketLatency, WebSocketLatencyOptions, WebSocketOptions, WebSocketRequests, WebSocketRequestsOptions, WebSocketResponder, WebSocketResponderOptions, WebSocketState, WebSocketStateOptions, WebSocketTopics, WebSocketTopicsOptions };
871
+ export type { CloseInfo, EventMap, HeartbeatOptions, IncomingRequest, Latency, QueueOptions, ReconnectOptions, RequestHandler, RequestOptions, SendData, SendOptions, SendRate, Snapshot, StandardSchemaV1, Status, UrlSource, WebSocketClient, WebSocketLatency, WebSocketLatencyOptions, WebSocketOptions, WebSocketRequests, WebSocketRequestsOptions, WebSocketResponder, WebSocketResponderOptions, WebSocketSendRate, WebSocketSendRateOptions, WebSocketState, WebSocketStateOptions, WebSocketTopics, WebSocketTopicsOptions };
752
872
  //# sourceMappingURL=core.d.ts.map
package/dist/core.js CHANGED
@@ -348,13 +348,18 @@ function createClient(url, options = {}) {
348
348
  set({ lastMessage: message });
349
349
  emit("message", message, event);
350
350
  }
351
- function send(message, sendOptions) {
351
+ function send(message, sendOptions = {}) {
352
352
  const data = options.serialize ? options.serialize(message) : options.json ? JSON.stringify(message) : message;
353
+ const { key, priority, maxBuffered } = sendOptions;
353
354
  if (ws && ws.readyState === OPEN) {
355
+ if (maxBuffered !== void 0 && ws.bufferedAmount > maxBuffered) {
356
+ log("dropped message: send buffer above maxBuffered");
357
+ return false;
358
+ }
354
359
  ws.send(data);
355
360
  return true;
356
361
  }
357
- if (queueLimit === 0 || sendOptions?.volatile) {
362
+ if (queueLimit === 0 || sendOptions.volatile) {
358
363
  log("dropped message: socket is not open");
359
364
  return false;
360
365
  }
@@ -362,19 +367,23 @@ function createClient(url, options = {}) {
362
367
  log("dropped message: no WebSocket implementation available");
363
368
  return false;
364
369
  }
365
- const key = sendOptions?.key;
366
370
  if (key !== void 0) pending = pending.filter((entry) => {
367
371
  if (entry.key !== key) return true;
368
372
  dropped(entry);
369
373
  return false;
370
374
  });
371
- pending.push({
375
+ let head = pending.findIndex((entry) => !entry.priority);
376
+ if (head < 0) head = pending.length;
377
+ const entry = {
372
378
  data,
373
379
  key,
374
- onDrop: sendOptions?.onDrop
375
- });
380
+ priority,
381
+ onDrop: sendOptions.onDrop
382
+ };
383
+ if (priority) pending.splice(head++, 0, entry);
384
+ else pending.push(entry);
376
385
  if (pending.length > queueLimit) {
377
- dropped(pending.shift());
386
+ dropped(pending.splice(head < pending.length ? head : 0, 1)[0]);
378
387
  log(`dropped oldest queued message: queue limit (${queueLimit}) reached`);
379
388
  }
380
389
  return false;
@@ -743,6 +752,7 @@ function createWebSocketRequests(client, options) {
743
752
  return;
744
753
  }
745
754
  if (sent) entry.socket = client.socket;
755
+ else if (client.socket?.readyState === 1) entry.reject(/* @__PURE__ */ new Error("WebSocket send buffer is full: request dropped"));
746
756
  else if (requestOptions.volatile) entry.reject(/* @__PURE__ */ new Error("WebSocket is not open: request dropped"));
747
757
  });
748
758
  },
@@ -755,7 +765,7 @@ function createWebSocketRequests(client, options) {
755
765
  }
756
766
  //#endregion
757
767
  //#region src/responder.ts
758
- const VOLATILE = { volatile: true };
768
+ const VOLATILE$1 = { volatile: true };
759
769
  const abortError = (message) => new DOMException(message, "AbortError");
760
770
  /**
761
771
  * Answer requests the server sends: run a handler for tools that only exist in the browser
@@ -796,9 +806,9 @@ function createWebSocketResponder(client, options) {
796
806
  /** Send a reply, or an `error` reply if building or encoding it throws. */
797
807
  function reply(id, build) {
798
808
  try {
799
- return client.send(build(), VOLATILE);
809
+ return client.send(build(), VOLATILE$1);
800
810
  } catch (error) {
801
- return client.send(options.error(id, error), VOLATILE);
811
+ return client.send(options.error(id, error), VOLATILE$1);
802
812
  }
803
813
  }
804
814
  function receive(incoming) {
@@ -897,6 +907,123 @@ function createWebSocketResponder(client, options) {
897
907
  };
898
908
  }
899
909
  //#endregion
910
+ //#region src/sendrate.ts
911
+ const VOLATILE = { volatile: true };
912
+ /**
913
+ * Send high-rate state, such as aim, cursor or position, at a rate that adapts to the
914
+ * connection. Messages are coalesced per key between ticks. The rate drops by half when the
915
+ * send buffer fills up or the round trip grows, and climbs back one tick per second at a time
916
+ * when the link keeps up, so players on a poor connection degrade smoothly instead of building
917
+ * up lag.
918
+ *
919
+ * Ticks run only while the socket is open and something is waiting. Messages waiting during a
920
+ * reconnect go out after it; `close()` drops them. It doesn't open the connection by itself.
921
+ *
922
+ * @example
923
+ * ```ts
924
+ * export const stream = createWebSocketSendRate(game, { latency, maxRate: 30 });
925
+ *
926
+ * // On every pointer move: at most 30 a second, fewer on a congested link.
927
+ * stream.send({ type: 'aim', angle }, 'aim');
928
+ * ```
929
+ */
930
+ function createWebSocketSendRate(client, options = {}) {
931
+ const { maxBuffered = 1024, latency, maxRttIncrease = 100 } = options;
932
+ const maxRate = options.maxRate ?? 30;
933
+ const minRate = Math.min(options.minRate ?? 5, maxRate);
934
+ const listeners = /* @__PURE__ */ new Set();
935
+ const pending = /* @__PURE__ */ new Map();
936
+ let rate = maxRate;
937
+ let snapshot = {
938
+ rate: maxRate,
939
+ congested: false
940
+ };
941
+ let last = -Infinity;
942
+ let best = Infinity;
943
+ let slow = false;
944
+ let measured;
945
+ let timer;
946
+ let disposed = false;
947
+ function flush() {
948
+ for (const [key, message] of pending) {
949
+ if (!client.send(message, VOLATILE)) return;
950
+ pending.delete(key);
951
+ }
952
+ }
953
+ function publish(congested) {
954
+ const rounded = Math.round(rate);
955
+ if (rounded === snapshot.rate && congested === snapshot.congested) return;
956
+ snapshot = {
957
+ rate: rounded,
958
+ congested
959
+ };
960
+ for (const listener of [...listeners]) listener();
961
+ }
962
+ function schedule() {
963
+ if (disposed || timer !== void 0 || !pending.size || client.getSnapshot().status !== "open") return;
964
+ timer = setTimeout(tick, Math.max(0, last + 1e3 / rate - performance.now()));
965
+ }
966
+ function stop() {
967
+ clearTimeout(timer);
968
+ timer = void 0;
969
+ }
970
+ function tick() {
971
+ timer = void 0;
972
+ last = performance.now();
973
+ const measurement = latency?.getSnapshot();
974
+ if (measurement !== measured) {
975
+ measured = measurement;
976
+ const rtt = measurement?.rtt;
977
+ if (rtt !== void 0) {
978
+ best = Math.min(best, rtt);
979
+ slow = rtt - best > maxRttIncrease;
980
+ if (slow) rate /= 2;
981
+ }
982
+ }
983
+ const full = (client.socket?.bufferedAmount ?? 0) > maxBuffered;
984
+ if (full) rate /= 2;
985
+ else {
986
+ flush();
987
+ if (!slow) rate++;
988
+ }
989
+ rate = Math.min(maxRate, Math.max(minRate, rate));
990
+ publish(full || slow);
991
+ schedule();
992
+ }
993
+ const offs = [client.on("open", () => {
994
+ best = Infinity;
995
+ slow = false;
996
+ measured = latency?.getSnapshot();
997
+ schedule();
998
+ }), client.subscribe(() => {
999
+ const { status } = client.getSnapshot();
1000
+ if (status !== "open") stop();
1001
+ if (status === "closed" || status === "idle") pending.clear();
1002
+ })];
1003
+ return {
1004
+ client,
1005
+ send(message, key) {
1006
+ pending.delete(key);
1007
+ pending.set(key, message);
1008
+ schedule();
1009
+ },
1010
+ flush,
1011
+ getSnapshot: () => snapshot,
1012
+ subscribe(listener) {
1013
+ listeners.add(listener);
1014
+ return () => {
1015
+ listeners.delete(listener);
1016
+ };
1017
+ },
1018
+ dispose() {
1019
+ disposed = true;
1020
+ stop();
1021
+ pending.clear();
1022
+ offs.forEach((off) => off());
1023
+ }
1024
+ };
1025
+ }
1026
+ //#endregion
900
1027
  //#region src/state.ts
901
1028
  /**
902
1029
  * Keep state derived from a client's messages, like a reducer that runs on every message.
@@ -917,14 +1044,30 @@ function createWebSocketResponder(client, options) {
917
1044
  * ```
918
1045
  */
919
1046
  function createWebSocketState(client, options) {
920
- const { initialState, reduce } = options;
1047
+ const { initialState, reduce, batch } = options;
921
1048
  let state = initialState;
1049
+ let notified = state;
1050
+ let frame = false;
922
1051
  const listeners = /* @__PURE__ */ new Set();
923
- function setState(update) {
924
- const next = typeof update === "function" ? update(state) : update;
1052
+ function notify() {
1053
+ if (Object.is(state, notified)) return;
1054
+ notified = state;
1055
+ for (const listener of [...listeners]) listener();
1056
+ }
1057
+ function flush() {
1058
+ frame = false;
1059
+ notify();
1060
+ }
1061
+ function update(change, later) {
1062
+ const next = typeof change === "function" ? change(state) : change;
925
1063
  if (Object.is(next, state)) return;
926
1064
  state = next;
927
- for (const listener of [...listeners]) listener();
1065
+ if (!later) notify();
1066
+ else if (!frame) {
1067
+ frame = true;
1068
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
1069
+ else setTimeout(flush, 16);
1070
+ }
928
1071
  }
929
1072
  return {
930
1073
  client,
@@ -936,9 +1079,9 @@ function createWebSocketState(client, options) {
936
1079
  listeners.delete(listener);
937
1080
  };
938
1081
  },
939
- setState,
940
- reset: () => setState(initialState),
941
- dispose: client.on("message", (message) => setState((current) => reduce(current, message)))
1082
+ setState: (change) => update(change),
1083
+ reset: () => update(initialState),
1084
+ dispose: client.on("message", (message) => update((current) => reduce(current, message), batch === "frame"))
942
1085
  };
943
1086
  }
944
1087
  //#endregion
@@ -1025,6 +1168,6 @@ function createWebSocketTopics(client, options) {
1025
1168
  };
1026
1169
  }
1027
1170
  //#endregion
1028
- export { MessageError, createWebSocket, createWebSocketLatency, createWebSocketRequests, createWebSocketResponder, createWebSocketState, createWebSocketTopics };
1171
+ export { MessageError, createWebSocket, createWebSocketLatency, createWebSocketRequests, createWebSocketResponder, createWebSocketSendRate, createWebSocketState, createWebSocketTopics };
1029
1172
 
1030
1173
  //# sourceMappingURL=core.js.map