react-websocket 3.3.0 → 3.6.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 +15 -6
- package/dist/chaos.cjs +284 -0
- package/dist/chaos.cjs.map +1 -0
- package/dist/chaos.d.cts +131 -0
- package/dist/chaos.d.ts +131 -0
- package/dist/chaos.js +283 -0
- package/dist/chaos.js.map +1 -0
- package/dist/core.cjs +271 -72
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +166 -11
- package/dist/core.d.ts +166 -11
- package/dist/core.js +271 -73
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +64 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +221 -11
- package/dist/index.d.ts +140 -5
- package/dist/index.js +65 -2
- package/dist/index.js.map +1 -1
- package/dist/timeline.cjs +162 -0
- package/dist/timeline.cjs.map +1 -0
- package/dist/timeline.d.cts +379 -0
- package/dist/timeline.d.ts +140 -0
- package/dist/timeline.js +161 -0
- package/dist/timeline.js.map +1 -0
- package/package.json +67 -16
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Where to connect: a URL, or a function that returns one (sync or async).
|
|
4
|
+
*
|
|
5
|
+
* A function is called before **every** connection attempt, including reconnects,
|
|
6
|
+
* so it can put a fresh auth token in the URL. If it throws or rejects, that counts
|
|
7
|
+
* as a failed attempt and is retried with backoff.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* createWebSocket(async () => `wss://api.example.com/live?token=${await getToken()}`);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
type UrlSource = string | (() => string | Promise<string>);
|
|
15
|
+
/**
|
|
16
|
+
* Connection state.
|
|
17
|
+
*
|
|
18
|
+
* - `idle`: not connected and not trying to be. Nobody is using the client yet, or everyone stopped.
|
|
19
|
+
* - `connecting`: the first attempt is in progress.
|
|
20
|
+
* - `open`: connected. `send()` goes out immediately.
|
|
21
|
+
* - `reconnecting`: the connection dropped (or an attempt failed) and a retry is scheduled or in progress.
|
|
22
|
+
* - `closed`: stopped for good, by `close()` or after giving up on reconnecting.
|
|
23
|
+
* It stays closed until `connect()` or `reconnect()` is called, or every hook unmounts.
|
|
24
|
+
*/
|
|
25
|
+
type Status = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
26
|
+
/** The client's state as seen by React. A new object is created on every change. */
|
|
27
|
+
interface Snapshot<In> {
|
|
28
|
+
/** Connection state. */
|
|
29
|
+
readonly status: Status;
|
|
30
|
+
/** Most recent message (after `parse`/`json` and `schema`), kept across reconnects. */
|
|
31
|
+
readonly lastMessage: In | undefined;
|
|
32
|
+
/** Consecutive failed attempts since the last successful open. `0` while open. */
|
|
33
|
+
readonly attempt: number;
|
|
34
|
+
}
|
|
35
|
+
/** Details of a closed connection, from the browser's `CloseEvent`. */
|
|
36
|
+
interface CloseInfo {
|
|
37
|
+
/** Close code: `1000` is a normal close, `1006` an abnormal one (e.g. network loss), `4000`–`4999` are yours. */
|
|
38
|
+
readonly code: number;
|
|
39
|
+
/** Close reason sent by the side that closed the connection. Often empty. */
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
/** Whether the closing handshake completed. */
|
|
42
|
+
readonly wasClean: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** Per-message options for `send()`. */
|
|
45
|
+
interface SendOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Drop the message instead of queueing it when the socket isn't open. For data that
|
|
48
|
+
* is stale by the time a reconnect finishes, such as player input or cursor positions.
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
volatile?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Keep only the newest queued message with this key. Sending again with the same key
|
|
54
|
+
* while disconnected replaces the earlier one, which moves to the back of the queue
|
|
55
|
+
* (or of the `priority` messages, for a priority one).
|
|
56
|
+
* For state where only the latest value matters, such as "my position is now (x, y)".
|
|
57
|
+
*/
|
|
58
|
+
key?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Queue this message ahead of every message queued without `priority`, after earlier
|
|
61
|
+
* priority messages. For control messages that must not wait behind a backlog, such as
|
|
62
|
+
* cancel, interrupt or a token refresh. Messages sent from `open` listeners still go first.
|
|
63
|
+
*
|
|
64
|
+
* It only matters while the socket isn't open: once a message is handed to the browser,
|
|
65
|
+
* nothing can overtake it. To keep that buffer short, send bulky streams with `maxBuffered`.
|
|
66
|
+
* @default false
|
|
67
|
+
*/
|
|
68
|
+
priority?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Drop the message instead of sending it while more than this many bytes are already
|
|
71
|
+
* waiting in the browser's send buffer (`socket.bufferedAmount`). For high-rate streams on
|
|
72
|
+
* a slow link, such as audio frames or per-tick input, where a late message is worse than
|
|
73
|
+
* none. `send()` returns `false`, and `onDrop` isn't called.
|
|
74
|
+
*
|
|
75
|
+
* Only checked while the socket is open: a message queued while disconnected is sent on
|
|
76
|
+
* open regardless. `bufferedAmount` includes what you sent earlier in the same task, so set
|
|
77
|
+
* it well above one burst of messages.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* voice.send(frame, { volatile: true, maxBuffered: 64 * 1024 });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
maxBuffered?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Called if the message is dropped from the queue without being sent: replaced by a
|
|
87
|
+
* newer message with the same `key`, or evicted by `queue.limit`. Not called when the
|
|
88
|
+
* queue is cleared because the client closed or gave up, or when `send()` drops the
|
|
89
|
+
* message right away (`volatile` while not open, or `maxBuffered`) and returns `false`.
|
|
90
|
+
*/
|
|
91
|
+
onDrop?: () => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reported via the `error` event (and `onError`) when an incoming message can't be
|
|
95
|
+
* decoded by `parse`/`json` or fails `schema` validation. The message is not delivered.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* onError(error) {
|
|
100
|
+
* if (error instanceof MessageError) report(error.data, error.issues);
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
declare class MessageError extends Error {
|
|
105
|
+
/** The raw `event.data` that failed. */
|
|
106
|
+
readonly data: unknown;
|
|
107
|
+
/** Schema validation issues, when the message failed `schema`. */
|
|
108
|
+
readonly issues: ReadonlyArray<{
|
|
109
|
+
readonly message: string;
|
|
110
|
+
}> | undefined;
|
|
111
|
+
constructor(message: string, data: unknown, options?: {
|
|
112
|
+
cause?: unknown;
|
|
113
|
+
issues?: ReadonlyArray<{
|
|
114
|
+
readonly message: string;
|
|
115
|
+
}>;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Events emitted by a client. Subscribe with `client.on(type, listener)` or `useWebSocketEvent`. */
|
|
119
|
+
interface EventMap<In> {
|
|
120
|
+
/**
|
|
121
|
+
* The socket opened (initially or after a reconnect). Listeners run before queued
|
|
122
|
+
* messages are sent, so a handshake sent from here goes out first.
|
|
123
|
+
*/
|
|
124
|
+
open: (event: Event) => void;
|
|
125
|
+
/** A message arrived and passed `parse` and `schema`. */
|
|
126
|
+
message: (message: In, event: MessageEvent) => void;
|
|
127
|
+
/** A socket closed, whether it will be retried or not. */
|
|
128
|
+
close: (close: CloseInfo) => void;
|
|
129
|
+
/**
|
|
130
|
+
* A socket error `Event`, a {@link MessageError} for an undecodable or invalid message,
|
|
131
|
+
* or the `Error` thrown by the `WebSocket` constructor or a `url` function.
|
|
132
|
+
*/
|
|
133
|
+
error: (error: Event | MessageError | Error) => void;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A WebSocket connection that reconnects, queues, decodes and can be shared.
|
|
137
|
+
* Create one with `createWebSocket()`.
|
|
138
|
+
*
|
|
139
|
+
* The connection is reference-counted: it opens when the first hook mounts (or
|
|
140
|
+
* `ready` is read) and closes one tick after the last hook unmounts. Calling
|
|
141
|
+
* `connect()` keeps it open regardless, until `close()`.
|
|
142
|
+
*
|
|
143
|
+
* @typeParam In - Type of incoming messages.
|
|
144
|
+
* @typeParam Out - Type accepted by `send()`.
|
|
145
|
+
*/
|
|
146
|
+
interface WebSocketClient<In = unknown, Out = unknown> {
|
|
147
|
+
/** The URL (or URL function) the client was created with. */
|
|
148
|
+
readonly url: UrlSource;
|
|
149
|
+
/**
|
|
150
|
+
* A promise that resolves once the socket has opened. Reading it starts an idle client.
|
|
151
|
+
*
|
|
152
|
+
* It stays resolved through automatic reconnects, so `use(client.ready)` shows a
|
|
153
|
+
* Suspense fallback only for the first connection, never on a network blip; use
|
|
154
|
+
* `status` to show reconnects. It rejects when the client gives up reconnecting, when
|
|
155
|
+
* `close()` is called, and during a server render. After that, `connect()` or
|
|
156
|
+
* `reconnect()` make it a new pending promise.
|
|
157
|
+
*
|
|
158
|
+
* It is safe to pass to React 19's `use()`: the same promise is returned until its state
|
|
159
|
+
* changes, and it carries `status`/`value`/`reason` so `use()` doesn't suspend when it is
|
|
160
|
+
* already settled.
|
|
161
|
+
*/
|
|
162
|
+
readonly ready: Promise<void>;
|
|
163
|
+
/** The current underlying `WebSocket`, if any. An escape hatch; prefer the client's methods. */
|
|
164
|
+
readonly socket: WebSocket | null;
|
|
165
|
+
/** Subscribe to snapshot changes. Compatible with `useSyncExternalStore`. Returns an unsubscribe function. */
|
|
166
|
+
subscribe(listener: () => void): () => void;
|
|
167
|
+
/** The current {@link Snapshot}. Returns the same object until something changes. */
|
|
168
|
+
getSnapshot(): Snapshot<In>;
|
|
169
|
+
/**
|
|
170
|
+
* Listen to an event. Returns an unsubscribe function. Listener errors are re-thrown
|
|
171
|
+
* asynchronously, so one failing listener doesn't break the others.
|
|
172
|
+
*/
|
|
173
|
+
on<K extends keyof EventMap<In>>(type: K, listener: EventMap<In>[K]): () => void;
|
|
174
|
+
/**
|
|
175
|
+
* Send a message (encoded with `serialize`/`json` if configured). If the socket isn't
|
|
176
|
+
* open, the message is queued and sent on open (see the `queue` option), unless
|
|
177
|
+
* `options.volatile` is set.
|
|
178
|
+
* @returns `true` if it was sent immediately, `false` if it was queued or dropped
|
|
179
|
+
* (`volatile` while not open, or `maxBuffered` while open).
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* game.send({ type: 'input', dx, dy }, { volatile: true }); // never replayed after a reconnect
|
|
184
|
+
* game.send({ type: 'position', x, y }, { key: 'position' }); // only the newest is queued
|
|
185
|
+
* game.send({ type: 'leave' }, { priority: true }); // ahead of everything already queued
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
send(message: Out, options?: SendOptions): boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Start connecting, and keep the connection open even when no hook is using the
|
|
191
|
+
* client, until `close()` is called. Does nothing if already connecting or open.
|
|
192
|
+
*/
|
|
193
|
+
connect(): void;
|
|
194
|
+
/**
|
|
195
|
+
* Close the connection and stop reconnecting. The status becomes `closed` and the
|
|
196
|
+
* send queue is cleared. Mounted hooks stay mounted; call `connect()` or `reconnect()`
|
|
197
|
+
* to start again.
|
|
198
|
+
* @param code - Close code. @default 1000
|
|
199
|
+
*/
|
|
200
|
+
close(code?: number, reason?: string): void;
|
|
201
|
+
/**
|
|
202
|
+
* Drop the current socket (if any) and connect again immediately, resetting the
|
|
203
|
+
* backoff. Also restarts a `closed` client.
|
|
204
|
+
*/
|
|
205
|
+
reconnect(): void;
|
|
206
|
+
/**
|
|
207
|
+
* Hold the connection open until the returned function is called. This is what the
|
|
208
|
+
* hooks use; call it yourself to integrate with non-React code.
|
|
209
|
+
* @returns A release function (safe to call more than once).
|
|
210
|
+
*/
|
|
211
|
+
acquire(): () => void;
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/latency.d.ts
|
|
215
|
+
/** Latest latency measurements. A new object is created on every change. */
|
|
216
|
+
interface Latency {
|
|
217
|
+
/** Round-trip time in ms: the median of recent pings. `undefined` until the first pong. */
|
|
218
|
+
readonly rtt: number | undefined;
|
|
219
|
+
/**
|
|
220
|
+
* Estimated server clock minus local clock, in ms. Add it to `Date.now()` for server time.
|
|
221
|
+
* `undefined` until a pong carries `time`.
|
|
222
|
+
*/
|
|
223
|
+
readonly offset: number | undefined;
|
|
224
|
+
}
|
|
225
|
+
/** Round-trip time and server clock offset for one client. Create with {@link createWebSocketLatency}. */
|
|
226
|
+
interface WebSocketLatency {
|
|
227
|
+
/** The client being measured. Hooks reading the latency keep it connected. */
|
|
228
|
+
readonly client: WebSocketClient<any, any>;
|
|
229
|
+
/** The current {@link Latency}. Returns the same object until it changes. */
|
|
230
|
+
getSnapshot(): Latency;
|
|
231
|
+
/** Called whenever a measurement changes. Compatible with `useSyncExternalStore`. */
|
|
232
|
+
subscribe(listener: () => void): () => void;
|
|
233
|
+
/** The estimated server time: `Date.now() + offset`, or `Date.now()` before any estimate. */
|
|
234
|
+
serverNow(): number;
|
|
235
|
+
/** Ping now instead of waiting for the next interval. Does nothing unless the socket is open. */
|
|
236
|
+
ping(): void;
|
|
237
|
+
/** Stop pinging and listening. Only needed for instances created dynamically. */
|
|
238
|
+
dispose(): void;
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/timeline.d.ts
|
|
242
|
+
/** A server snapshot, as returned by the `snapshot` option. */
|
|
243
|
+
export interface TimelineSnapshot<S> {
|
|
244
|
+
/** Server time the state is for, in ms. Must increase from one snapshot to the next. */
|
|
245
|
+
readonly time: number;
|
|
246
|
+
/** The authoritative state at `time`. */
|
|
247
|
+
readonly state: S;
|
|
248
|
+
/**
|
|
249
|
+
* `seq` of the last input from this client that the server applied before taking the
|
|
250
|
+
* snapshot. Inputs up to it are confirmed and no longer predicted. Omit it to keep the
|
|
251
|
+
* previous one.
|
|
252
|
+
*/
|
|
253
|
+
readonly ack?: number;
|
|
254
|
+
}
|
|
255
|
+
/** Client-side prediction for your own inputs. See {@link WebSocketTimelineOptions.input}. */
|
|
256
|
+
export interface TimelineInputOptions<S, I, Out> {
|
|
257
|
+
/**
|
|
258
|
+
* Build the message for an input, tagged with its `seq`. Annotate `input`: that's what
|
|
259
|
+
* `timeline.input()` accepts. Sent with `volatile`, so inputs are never replayed late.
|
|
260
|
+
*/
|
|
261
|
+
send: (input: I, seq: number) => Out;
|
|
262
|
+
/**
|
|
263
|
+
* Apply one input to a state, exactly as the server does. Must be pure: return a new state
|
|
264
|
+
* instead of changing `state`, which is a snapshot from the server.
|
|
265
|
+
*/
|
|
266
|
+
predict: (state: S, input: I) => S;
|
|
267
|
+
/**
|
|
268
|
+
* Drop an input instead of sending it while more than this many bytes are already waiting in
|
|
269
|
+
* the browser's send buffer (see `SendOptions.maxBuffered`). For input sent every tick on a
|
|
270
|
+
* slow link. A dropped input isn't predicted.
|
|
271
|
+
*/
|
|
272
|
+
maxBuffered?: number;
|
|
273
|
+
}
|
|
274
|
+
/** Options for {@link createWebSocketTimeline}. */
|
|
275
|
+
export interface WebSocketTimelineOptions<S, I, In, Out> {
|
|
276
|
+
/** Recognize a snapshot. Return `undefined` (or `null`) for any other message. */
|
|
277
|
+
snapshot: (message: In) => TimelineSnapshot<S> | null | undefined;
|
|
278
|
+
/**
|
|
279
|
+
* Blend two states. `t` runs from 0 (`from`) to 1 (`to`), and above 1 only when
|
|
280
|
+
* extrapolating. Without it, `at()` steps from one snapshot to the next.
|
|
281
|
+
*/
|
|
282
|
+
interpolate?: (from: S, to: S, t: number) => S;
|
|
283
|
+
/**
|
|
284
|
+
* How far behind the newest snapshot to render, in ms: enough to cover the snapshot
|
|
285
|
+
* interval plus jitter, so there are usually two snapshots to blend between. A function is
|
|
286
|
+
* read on every call, for a delay that adapts.
|
|
287
|
+
* @default 100
|
|
288
|
+
*/
|
|
289
|
+
delay?: number | (() => number);
|
|
290
|
+
/**
|
|
291
|
+
* How far past the newest snapshot `at()` may extrapolate when the next one is late, in ms.
|
|
292
|
+
* Needs `interpolate`. `0` holds the newest state instead.
|
|
293
|
+
* @default 0
|
|
294
|
+
*/
|
|
295
|
+
extrapolate?: number;
|
|
296
|
+
/**
|
|
297
|
+
* How many snapshots to keep (at least 2).
|
|
298
|
+
* @default 30
|
|
299
|
+
*/
|
|
300
|
+
buffer?: number;
|
|
301
|
+
/**
|
|
302
|
+
* Take the server clock from `createWebSocketLatency` on the same client. Its pongs must use
|
|
303
|
+
* the same clock as snapshot `time`. Without it, or before the first pong with a time, the
|
|
304
|
+
* server clock is estimated from when snapshots arrive.
|
|
305
|
+
*/
|
|
306
|
+
latency?: Pick<WebSocketLatency, 'getSnapshot' | 'serverNow'>;
|
|
307
|
+
/** Send inputs tagged with a sequence number, and predict their effect until the server confirms them. */
|
|
308
|
+
input?: TimelineInputOptions<S, I, Out>;
|
|
309
|
+
}
|
|
310
|
+
/** Server snapshots on a timeline, with your own inputs predicted on top. Create with {@link createWebSocketTimeline}. */
|
|
311
|
+
export interface WebSocketTimeline<S, I> {
|
|
312
|
+
/** The client whose snapshots this timeline keeps. */
|
|
313
|
+
readonly client: WebSocketClient<any, any>;
|
|
314
|
+
/**
|
|
315
|
+
* The state at `time` (by default `renderTime()`), interpolated between the snapshots around
|
|
316
|
+
* it. For what the server moves, such as other players. `undefined` before any snapshot.
|
|
317
|
+
*/
|
|
318
|
+
at(time?: number): S | undefined;
|
|
319
|
+
/**
|
|
320
|
+
* The newest snapshot with your unconfirmed inputs replayed on top. For what your inputs
|
|
321
|
+
* move, such as your own player. Cached, so it's cheap to call every frame.
|
|
322
|
+
*/
|
|
323
|
+
predicted(): S | undefined;
|
|
324
|
+
/**
|
|
325
|
+
* Send an input and predict it. Returns its `seq`, or `undefined` if it couldn't be sent
|
|
326
|
+
* (the socket isn't open, or `maxBuffered` dropped it), in which case it isn't predicted.
|
|
327
|
+
*/
|
|
328
|
+
input(input: I): number | undefined;
|
|
329
|
+
/** The newest snapshot, as `snapshot` returned it. */
|
|
330
|
+
latest(): TimelineSnapshot<S> | undefined;
|
|
331
|
+
/**
|
|
332
|
+
* The server time `at()` renders by default: the newest snapshot's time, give or take, minus
|
|
333
|
+
* `delay`. It never goes backward until the timeline starts over. `undefined` before any snapshot.
|
|
334
|
+
*/
|
|
335
|
+
renderTime(): number | undefined;
|
|
336
|
+
/** How many inputs were sent but not confirmed yet. */
|
|
337
|
+
pending(): number;
|
|
338
|
+
/**
|
|
339
|
+
* Called after each new snapshot and on `reset()`. Compatible with `useSyncExternalStore` for
|
|
340
|
+
* `latest` and `pending`, but not for `at`, `predicted` or `renderTime`, which change with time.
|
|
341
|
+
*/
|
|
342
|
+
subscribe(listener: () => void): () => void;
|
|
343
|
+
/** Forget all snapshots and pending inputs now. `seq` keeps counting. */
|
|
344
|
+
reset(): void;
|
|
345
|
+
/** Stop listening. Only needed for timelines created dynamically. */
|
|
346
|
+
dispose(): void;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Keep server snapshots on a timeline for multiplayer games: render what the server moves
|
|
350
|
+
* slightly in the past, interpolated between snapshots, and what your inputs move right away,
|
|
351
|
+
* predicted and corrected when the server confirms them.
|
|
352
|
+
*
|
|
353
|
+
* Read it once per frame (in `requestAnimationFrame` or React Three Fiber's `useFrame`): `at()`
|
|
354
|
+
* for everyone else, `predicted()` for you. It listens from creation, starts over on reconnect,
|
|
355
|
+
* and doesn't open the connection by itself.
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* ```ts
|
|
359
|
+
* import { createWebSocketTimeline } from 'react-websocket/timeline';
|
|
360
|
+
*
|
|
361
|
+
* export const timeline = createWebSocketTimeline(game, {
|
|
362
|
+
* snapshot: m => (m.type === 'snapshot' ? { time: m.time, ack: m.ack, state: m.world } : undefined),
|
|
363
|
+
* interpolate: (from, to, t) => lerpWorld(from, to, t),
|
|
364
|
+
* latency,
|
|
365
|
+
* input: {
|
|
366
|
+
* send: (input: Move, seq) => ({ type: 'input', seq, ...input }),
|
|
367
|
+
* predict: (world, input) => move(world, me, input)
|
|
368
|
+
* }
|
|
369
|
+
* });
|
|
370
|
+
*
|
|
371
|
+
* // Every frame:
|
|
372
|
+
* draw(timeline.at(), timeline.predicted());
|
|
373
|
+
* // On a key press:
|
|
374
|
+
* timeline.input({ dx: 1, dy: 0 });
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
export declare function createWebSocketTimeline<In, const Out, S, I = never>(client: WebSocketClient<In, Out>, options: WebSocketTimelineOptions<S, I, NoInfer<In>, NoInfer<Out>>): WebSocketTimeline<S, I>;
|
|
378
|
+
//#endregion
|
|
379
|
+
//# sourceMappingURL=timeline.d.cts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { WebSocketClient, WebSocketLatency } from "./core.js";
|
|
2
|
+
//#region src/timeline.d.ts
|
|
3
|
+
/** A server snapshot, as returned by the `snapshot` option. */
|
|
4
|
+
export interface TimelineSnapshot<S> {
|
|
5
|
+
/** Server time the state is for, in ms. Must increase from one snapshot to the next. */
|
|
6
|
+
readonly time: number;
|
|
7
|
+
/** The authoritative state at `time`. */
|
|
8
|
+
readonly state: S;
|
|
9
|
+
/**
|
|
10
|
+
* `seq` of the last input from this client that the server applied before taking the
|
|
11
|
+
* snapshot. Inputs up to it are confirmed and no longer predicted. Omit it to keep the
|
|
12
|
+
* previous one.
|
|
13
|
+
*/
|
|
14
|
+
readonly ack?: number;
|
|
15
|
+
}
|
|
16
|
+
/** Client-side prediction for your own inputs. See {@link WebSocketTimelineOptions.input}. */
|
|
17
|
+
export interface TimelineInputOptions<S, I, Out> {
|
|
18
|
+
/**
|
|
19
|
+
* Build the message for an input, tagged with its `seq`. Annotate `input`: that's what
|
|
20
|
+
* `timeline.input()` accepts. Sent with `volatile`, so inputs are never replayed late.
|
|
21
|
+
*/
|
|
22
|
+
send: (input: I, seq: number) => Out;
|
|
23
|
+
/**
|
|
24
|
+
* Apply one input to a state, exactly as the server does. Must be pure: return a new state
|
|
25
|
+
* instead of changing `state`, which is a snapshot from the server.
|
|
26
|
+
*/
|
|
27
|
+
predict: (state: S, input: I) => S;
|
|
28
|
+
/**
|
|
29
|
+
* Drop an input instead of sending it while more than this many bytes are already waiting in
|
|
30
|
+
* the browser's send buffer (see `SendOptions.maxBuffered`). For input sent every tick on a
|
|
31
|
+
* slow link. A dropped input isn't predicted.
|
|
32
|
+
*/
|
|
33
|
+
maxBuffered?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Options for {@link createWebSocketTimeline}. */
|
|
36
|
+
export interface WebSocketTimelineOptions<S, I, In, Out> {
|
|
37
|
+
/** Recognize a snapshot. Return `undefined` (or `null`) for any other message. */
|
|
38
|
+
snapshot: (message: In) => TimelineSnapshot<S> | null | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Blend two states. `t` runs from 0 (`from`) to 1 (`to`), and above 1 only when
|
|
41
|
+
* extrapolating. Without it, `at()` steps from one snapshot to the next.
|
|
42
|
+
*/
|
|
43
|
+
interpolate?: (from: S, to: S, t: number) => S;
|
|
44
|
+
/**
|
|
45
|
+
* How far behind the newest snapshot to render, in ms: enough to cover the snapshot
|
|
46
|
+
* interval plus jitter, so there are usually two snapshots to blend between. A function is
|
|
47
|
+
* read on every call, for a delay that adapts.
|
|
48
|
+
* @default 100
|
|
49
|
+
*/
|
|
50
|
+
delay?: number | (() => number);
|
|
51
|
+
/**
|
|
52
|
+
* How far past the newest snapshot `at()` may extrapolate when the next one is late, in ms.
|
|
53
|
+
* Needs `interpolate`. `0` holds the newest state instead.
|
|
54
|
+
* @default 0
|
|
55
|
+
*/
|
|
56
|
+
extrapolate?: number;
|
|
57
|
+
/**
|
|
58
|
+
* How many snapshots to keep (at least 2).
|
|
59
|
+
* @default 30
|
|
60
|
+
*/
|
|
61
|
+
buffer?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Take the server clock from `createWebSocketLatency` on the same client. Its pongs must use
|
|
64
|
+
* the same clock as snapshot `time`. Without it, or before the first pong with a time, the
|
|
65
|
+
* server clock is estimated from when snapshots arrive.
|
|
66
|
+
*/
|
|
67
|
+
latency?: Pick<WebSocketLatency, 'getSnapshot' | 'serverNow'>;
|
|
68
|
+
/** Send inputs tagged with a sequence number, and predict their effect until the server confirms them. */
|
|
69
|
+
input?: TimelineInputOptions<S, I, Out>;
|
|
70
|
+
}
|
|
71
|
+
/** Server snapshots on a timeline, with your own inputs predicted on top. Create with {@link createWebSocketTimeline}. */
|
|
72
|
+
export interface WebSocketTimeline<S, I> {
|
|
73
|
+
/** The client whose snapshots this timeline keeps. */
|
|
74
|
+
readonly client: WebSocketClient<any, any>;
|
|
75
|
+
/**
|
|
76
|
+
* The state at `time` (by default `renderTime()`), interpolated between the snapshots around
|
|
77
|
+
* it. For what the server moves, such as other players. `undefined` before any snapshot.
|
|
78
|
+
*/
|
|
79
|
+
at(time?: number): S | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* The newest snapshot with your unconfirmed inputs replayed on top. For what your inputs
|
|
82
|
+
* move, such as your own player. Cached, so it's cheap to call every frame.
|
|
83
|
+
*/
|
|
84
|
+
predicted(): S | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Send an input and predict it. Returns its `seq`, or `undefined` if it couldn't be sent
|
|
87
|
+
* (the socket isn't open, or `maxBuffered` dropped it), in which case it isn't predicted.
|
|
88
|
+
*/
|
|
89
|
+
input(input: I): number | undefined;
|
|
90
|
+
/** The newest snapshot, as `snapshot` returned it. */
|
|
91
|
+
latest(): TimelineSnapshot<S> | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* The server time `at()` renders by default: the newest snapshot's time, give or take, minus
|
|
94
|
+
* `delay`. It never goes backward until the timeline starts over. `undefined` before any snapshot.
|
|
95
|
+
*/
|
|
96
|
+
renderTime(): number | undefined;
|
|
97
|
+
/** How many inputs were sent but not confirmed yet. */
|
|
98
|
+
pending(): number;
|
|
99
|
+
/**
|
|
100
|
+
* Called after each new snapshot and on `reset()`. Compatible with `useSyncExternalStore` for
|
|
101
|
+
* `latest` and `pending`, but not for `at`, `predicted` or `renderTime`, which change with time.
|
|
102
|
+
*/
|
|
103
|
+
subscribe(listener: () => void): () => void;
|
|
104
|
+
/** Forget all snapshots and pending inputs now. `seq` keeps counting. */
|
|
105
|
+
reset(): void;
|
|
106
|
+
/** Stop listening. Only needed for timelines created dynamically. */
|
|
107
|
+
dispose(): void;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Keep server snapshots on a timeline for multiplayer games: render what the server moves
|
|
111
|
+
* slightly in the past, interpolated between snapshots, and what your inputs move right away,
|
|
112
|
+
* predicted and corrected when the server confirms them.
|
|
113
|
+
*
|
|
114
|
+
* Read it once per frame (in `requestAnimationFrame` or React Three Fiber's `useFrame`): `at()`
|
|
115
|
+
* for everyone else, `predicted()` for you. It listens from creation, starts over on reconnect,
|
|
116
|
+
* and doesn't open the connection by itself.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* import { createWebSocketTimeline } from 'react-websocket/timeline';
|
|
121
|
+
*
|
|
122
|
+
* export const timeline = createWebSocketTimeline(game, {
|
|
123
|
+
* snapshot: m => (m.type === 'snapshot' ? { time: m.time, ack: m.ack, state: m.world } : undefined),
|
|
124
|
+
* interpolate: (from, to, t) => lerpWorld(from, to, t),
|
|
125
|
+
* latency,
|
|
126
|
+
* input: {
|
|
127
|
+
* send: (input: Move, seq) => ({ type: 'input', seq, ...input }),
|
|
128
|
+
* predict: (world, input) => move(world, me, input)
|
|
129
|
+
* }
|
|
130
|
+
* });
|
|
131
|
+
*
|
|
132
|
+
* // Every frame:
|
|
133
|
+
* draw(timeline.at(), timeline.predicted());
|
|
134
|
+
* // On a key press:
|
|
135
|
+
* timeline.input({ dx: 1, dy: 0 });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export declare function createWebSocketTimeline<In, const Out, S, I = never>(client: WebSocketClient<In, Out>, options: WebSocketTimelineOptions<S, I, NoInfer<In>, NoInfer<Out>>): WebSocketTimeline<S, I>;
|
|
139
|
+
//#endregion
|
|
140
|
+
//# sourceMappingURL=timeline.d.ts.map
|
package/dist/timeline.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
//#region src/timeline.ts
|
|
2
|
+
const MAX_PENDING = 1e3;
|
|
3
|
+
/**
|
|
4
|
+
* Keep server snapshots on a timeline for multiplayer games: render what the server moves
|
|
5
|
+
* slightly in the past, interpolated between snapshots, and what your inputs move right away,
|
|
6
|
+
* predicted and corrected when the server confirms them.
|
|
7
|
+
*
|
|
8
|
+
* Read it once per frame (in `requestAnimationFrame` or React Three Fiber's `useFrame`): `at()`
|
|
9
|
+
* for everyone else, `predicted()` for you. It listens from creation, starts over on reconnect,
|
|
10
|
+
* and doesn't open the connection by itself.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { createWebSocketTimeline } from 'react-websocket/timeline';
|
|
15
|
+
*
|
|
16
|
+
* export const timeline = createWebSocketTimeline(game, {
|
|
17
|
+
* snapshot: m => (m.type === 'snapshot' ? { time: m.time, ack: m.ack, state: m.world } : undefined),
|
|
18
|
+
* interpolate: (from, to, t) => lerpWorld(from, to, t),
|
|
19
|
+
* latency,
|
|
20
|
+
* input: {
|
|
21
|
+
* send: (input: Move, seq) => ({ type: 'input', seq, ...input }),
|
|
22
|
+
* predict: (world, input) => move(world, me, input)
|
|
23
|
+
* }
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Every frame:
|
|
27
|
+
* draw(timeline.at(), timeline.predicted());
|
|
28
|
+
* // On a key press:
|
|
29
|
+
* timeline.input({ dx: 1, dy: 0 });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
function createWebSocketTimeline(client, options) {
|
|
33
|
+
const { interpolate, latency, input: inputs } = options;
|
|
34
|
+
const delay = options.delay ?? 100;
|
|
35
|
+
const extrapolate = options.extrapolate ?? 0;
|
|
36
|
+
const keep = Math.max(2, options.buffer ?? 30);
|
|
37
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
38
|
+
let buffer = [];
|
|
39
|
+
let arrival = -Infinity;
|
|
40
|
+
let rendered = -Infinity;
|
|
41
|
+
let stale = false;
|
|
42
|
+
let seq = 0;
|
|
43
|
+
let pending = [];
|
|
44
|
+
let prediction;
|
|
45
|
+
let folded = -1;
|
|
46
|
+
function notify() {
|
|
47
|
+
for (const listener of [...listeners]) listener();
|
|
48
|
+
}
|
|
49
|
+
function clear() {
|
|
50
|
+
buffer = [];
|
|
51
|
+
arrival = rendered = -Infinity;
|
|
52
|
+
stale = false;
|
|
53
|
+
}
|
|
54
|
+
function receive(snapshot) {
|
|
55
|
+
if (stale) clear();
|
|
56
|
+
const newest = buffer.at(-1);
|
|
57
|
+
if (newest && snapshot.time <= newest.snapshot.time) return;
|
|
58
|
+
buffer.push({
|
|
59
|
+
snapshot,
|
|
60
|
+
arrived: performance.now()
|
|
61
|
+
});
|
|
62
|
+
if (buffer.length > keep) buffer.shift();
|
|
63
|
+
arrival = -Infinity;
|
|
64
|
+
for (const entry of buffer) arrival = Math.max(arrival, entry.snapshot.time - entry.arrived);
|
|
65
|
+
const { ack } = snapshot;
|
|
66
|
+
if (ack != null) pending = pending.filter((entry) => entry.seq > ack);
|
|
67
|
+
folded = -1;
|
|
68
|
+
notify();
|
|
69
|
+
}
|
|
70
|
+
function renderTime() {
|
|
71
|
+
if (!buffer.length) return;
|
|
72
|
+
const measured = latency?.getSnapshot();
|
|
73
|
+
const edge = measured?.offset !== void 0 && measured.rtt !== void 0 ? latency.serverNow() - measured.rtt / 2 : performance.now() + arrival;
|
|
74
|
+
rendered = Math.max(rendered, edge - (typeof delay === "function" ? delay() : delay));
|
|
75
|
+
return rendered;
|
|
76
|
+
}
|
|
77
|
+
function at(time) {
|
|
78
|
+
const n = buffer.length;
|
|
79
|
+
if (!n) return;
|
|
80
|
+
const t = time ?? renderTime();
|
|
81
|
+
const last = buffer[n - 1].snapshot;
|
|
82
|
+
if (t >= last.time) {
|
|
83
|
+
const previous = buffer[n - 2]?.snapshot;
|
|
84
|
+
if (!previous || !interpolate || !extrapolate || t === last.time) return last.state;
|
|
85
|
+
const capped = Math.min(t, last.time + extrapolate);
|
|
86
|
+
return interpolate(previous.state, last.state, (capped - previous.time) / (last.time - previous.time));
|
|
87
|
+
}
|
|
88
|
+
let i = n - 2;
|
|
89
|
+
while (i >= 0 && buffer[i].snapshot.time > t) i--;
|
|
90
|
+
if (i < 0) return buffer[0].snapshot.state;
|
|
91
|
+
const from = buffer[i].snapshot;
|
|
92
|
+
const to = buffer[i + 1].snapshot;
|
|
93
|
+
return !interpolate || t === from.time ? from.state : interpolate(from.state, to.state, (t - from.time) / (to.time - from.time));
|
|
94
|
+
}
|
|
95
|
+
function predicted() {
|
|
96
|
+
const newest = buffer.at(-1);
|
|
97
|
+
if (!newest) return;
|
|
98
|
+
if (folded < 0) {
|
|
99
|
+
prediction = newest.snapshot.state;
|
|
100
|
+
folded = 0;
|
|
101
|
+
}
|
|
102
|
+
while (folded < pending.length) {
|
|
103
|
+
prediction = inputs.predict(prediction, pending[folded].input);
|
|
104
|
+
folded++;
|
|
105
|
+
}
|
|
106
|
+
return prediction;
|
|
107
|
+
}
|
|
108
|
+
const offs = [client.on("open", () => {
|
|
109
|
+
pending = [];
|
|
110
|
+
folded = -1;
|
|
111
|
+
stale = true;
|
|
112
|
+
}), client.on("message", (message) => {
|
|
113
|
+
const snapshot = options.snapshot(message);
|
|
114
|
+
if (snapshot != null) receive(snapshot);
|
|
115
|
+
})];
|
|
116
|
+
return {
|
|
117
|
+
client,
|
|
118
|
+
at,
|
|
119
|
+
predicted,
|
|
120
|
+
input(value) {
|
|
121
|
+
const next = seq + 1;
|
|
122
|
+
const message = inputs.send(value, next);
|
|
123
|
+
if (!client.send(message, {
|
|
124
|
+
volatile: true,
|
|
125
|
+
maxBuffered: inputs.maxBuffered
|
|
126
|
+
})) return;
|
|
127
|
+
seq = next;
|
|
128
|
+
pending.push({
|
|
129
|
+
seq,
|
|
130
|
+
input: value
|
|
131
|
+
});
|
|
132
|
+
if (pending.length > MAX_PENDING) {
|
|
133
|
+
pending.shift();
|
|
134
|
+
folded = -1;
|
|
135
|
+
}
|
|
136
|
+
return seq;
|
|
137
|
+
},
|
|
138
|
+
latest: () => buffer.at(-1)?.snapshot,
|
|
139
|
+
renderTime,
|
|
140
|
+
pending: () => pending.length,
|
|
141
|
+
subscribe(listener) {
|
|
142
|
+
listeners.add(listener);
|
|
143
|
+
return () => {
|
|
144
|
+
listeners.delete(listener);
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
reset() {
|
|
148
|
+
clear();
|
|
149
|
+
pending = [];
|
|
150
|
+
folded = -1;
|
|
151
|
+
notify();
|
|
152
|
+
},
|
|
153
|
+
dispose() {
|
|
154
|
+
offs.forEach((off) => off());
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
export { createWebSocketTimeline };
|
|
160
|
+
|
|
161
|
+
//# sourceMappingURL=timeline.js.map
|