@pylonsync/realtime 0.18.0 → 0.19.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/connection.d.ts +32 -4
- package/dist/game.d.ts +6 -1
- package/dist/replication.d.ts +70 -1
- package/dist/wire.d.ts +84 -1
- package/package.json +1 -1
- package/src/connection-webtransport.test.ts +543 -0
- package/src/connection.test.ts +48 -1
- package/src/connection.ts +602 -94
- package/src/game.ts +16 -2
- package/src/replication.fixtures.json +2416 -0
- package/src/replication.test.ts +46 -0
- package/src/replication.ts +167 -2
- package/src/shard-restart.e2e.test.ts +150 -0
- package/src/wire.ts +185 -1
package/src/connection.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A shard connection with no framework: one WebSocket
|
|
3
|
-
* backoff, that decodes frames, applies
|
|
4
|
-
* `EntityTable`, and sends inputs. `useShard` in
|
|
5
|
-
* `connectShardGame` build on it.
|
|
2
|
+
* A shard connection with no framework: one WebSocket or WebTransport
|
|
3
|
+
* session, reconnected with backoff, that decodes frames, applies
|
|
4
|
+
* replication frames to an `EntityTable`, and sends inputs. `useShard` in
|
|
5
|
+
* `@pylonsync/react` and `connectShardGame` build on it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ShardClock } from "./clock";
|
|
9
|
-
import { EntityTable, type ReplicationSummary } from "./replication";
|
|
9
|
+
import { EntityTable, readDatagramHeader, type ReplicationSummary } from "./replication";
|
|
10
10
|
import {
|
|
11
11
|
SHARD_PROTOCOL_VERSION,
|
|
12
12
|
ShardFrameKind,
|
|
13
|
+
WEBTRANSPORT_CLOSE,
|
|
14
|
+
StreamFrames,
|
|
15
|
+
decodeWebTransportInfo,
|
|
16
|
+
type WebTransportInfo,
|
|
17
|
+
MAX_ACKS_PER_MESSAGE,
|
|
18
|
+
encodeDatagramAcks,
|
|
19
|
+
encodeWebTransportHello,
|
|
20
|
+
encodeWebTransportInput,
|
|
13
21
|
decodeShardPayload,
|
|
14
22
|
decodeShardRejection,
|
|
15
23
|
encodeShardInput,
|
|
@@ -19,6 +27,20 @@ import {
|
|
|
19
27
|
type ShardTransferNotice,
|
|
20
28
|
} from "./wire";
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How the client reaches the shard.
|
|
32
|
+
*
|
|
33
|
+
* - `"websocket"`: a WebSocket (TCP). Works everywhere.
|
|
34
|
+
* - `"webtransport"`: a WebTransport session (QUIC over UDP). Entity
|
|
35
|
+
* updates come as datagrams, so a lost packet delays only itself. Needs
|
|
36
|
+
* the browser API and an app that serves WebTransport
|
|
37
|
+
* (`PYLON_WEBTRANSPORT_PORT`); fails otherwise.
|
|
38
|
+
* - `"auto"`: WebTransport when the browser and the app support it, else a
|
|
39
|
+
* WebSocket. After a WebTransport session fails to open (UDP blocked, an
|
|
40
|
+
* old browser), the client uses WebSockets for the rest of its life.
|
|
41
|
+
*/
|
|
42
|
+
export type ShardTransport = "websocket" | "webtransport" | "auto";
|
|
43
|
+
|
|
22
44
|
export interface ShardConnectOptions {
|
|
23
45
|
/** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
|
|
24
46
|
subscriberId: string;
|
|
@@ -51,6 +73,19 @@ export interface ShardConnectOptions {
|
|
|
51
73
|
* `shard` query parameter is replaced with the new shard.
|
|
52
74
|
*/
|
|
53
75
|
wsUrl?: string;
|
|
76
|
+
/** Default `"websocket"`. See `ShardTransport`. */
|
|
77
|
+
transport?: ShardTransport;
|
|
78
|
+
/**
|
|
79
|
+
* Where to fetch the WebTransport URL and certificate hashes. Default
|
|
80
|
+
* `/_pylon/shard/webtransport` on `baseUrl` (or the page's host), or on
|
|
81
|
+
* the host of `wsUrl` when only that is set.
|
|
82
|
+
*/
|
|
83
|
+
webTransportInfoUrl?: string;
|
|
84
|
+
/**
|
|
85
|
+
* How long a WebTransport session may take to open before `"auto"`
|
|
86
|
+
* falls back to a WebSocket, in ms (default 3000).
|
|
87
|
+
*/
|
|
88
|
+
webTransportTimeoutMs?: number;
|
|
54
89
|
/** Reconnect on unexpected close (default: true). */
|
|
55
90
|
autoReconnect?: boolean;
|
|
56
91
|
/** First reconnect delay in ms (default 500; doubles to at most 10 000). */
|
|
@@ -116,8 +151,13 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
|
116
151
|
send: (input: TInput) => number;
|
|
117
152
|
close: () => void;
|
|
118
153
|
readonly connected: boolean;
|
|
154
|
+
/** The transport of the open (or last) connection, or null before one opened. */
|
|
155
|
+
readonly transport: "websocket" | "webtransport" | null;
|
|
119
156
|
}
|
|
120
157
|
|
|
158
|
+
/** WebSocket close code 1008: the server refused the connection by policy. */
|
|
159
|
+
const POLICY_CLOSE = 1008;
|
|
160
|
+
|
|
121
161
|
/**
|
|
122
162
|
* True when a shard ticket (`v1.<payload>.<signature>`, the payload
|
|
123
163
|
* base64url JSON with `exp` in Unix seconds) expires within `marginSecs`.
|
|
@@ -140,6 +180,79 @@ export function ticketExpired(ticket: string, nowMs = Date.now(), marginSecs = 5
|
|
|
140
180
|
/** Input send times kept for the round-trip estimate. */
|
|
141
181
|
const MAX_TIMED = 256;
|
|
142
182
|
|
|
183
|
+
/** The open connection, over either transport. */
|
|
184
|
+
interface Link {
|
|
185
|
+
readonly kind: "websocket" | "webtransport";
|
|
186
|
+
/** True while the link can send. */
|
|
187
|
+
readonly open: boolean;
|
|
188
|
+
/** Send an input, as `encodeShardInput` made it. */
|
|
189
|
+
send(input: string | Uint8Array): void;
|
|
190
|
+
/** Send datagram acks (`encodeDatagramAcks`). WebTransport only. */
|
|
191
|
+
ack(acks: Uint8Array): void;
|
|
192
|
+
/**
|
|
193
|
+
* What the server said before it closed the session (a closing frame):
|
|
194
|
+
* a WebTransport close from the server does not reach the browser's
|
|
195
|
+
* `closed` with its code.
|
|
196
|
+
*/
|
|
197
|
+
notice: { code: number; reason: string } | null;
|
|
198
|
+
close(): void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A tick's datagrams waiting until the tick is whole. */
|
|
202
|
+
interface PendingTick {
|
|
203
|
+
parts: number;
|
|
204
|
+
streamTick: number;
|
|
205
|
+
ack: number;
|
|
206
|
+
/** By datagram number (a duplicate replaces itself). */
|
|
207
|
+
datagrams: Map<number, Uint8Array>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Ticks of datagrams kept waiting at most; older ones count as lost. */
|
|
211
|
+
const MAX_PENDING_TICKS = 32;
|
|
212
|
+
|
|
213
|
+
/** The parts of the browser's `WebTransport` the client uses. */
|
|
214
|
+
interface WebTransportSession {
|
|
215
|
+
readonly ready: Promise<void>;
|
|
216
|
+
readonly closed: Promise<{ closeCode?: number; reason?: string } | undefined>;
|
|
217
|
+
readonly datagrams: {
|
|
218
|
+
readonly readable: ReadableStream<Uint8Array>;
|
|
219
|
+
readonly writable?: WritableStream<Uint8Array>;
|
|
220
|
+
createWritable?: () => WritableStream<Uint8Array>;
|
|
221
|
+
};
|
|
222
|
+
createBidirectionalStream(): Promise<{
|
|
223
|
+
readable: ReadableStream<Uint8Array>;
|
|
224
|
+
writable: WritableStream<Uint8Array>;
|
|
225
|
+
}>;
|
|
226
|
+
close(info?: { closeCode?: number; reason?: string }): void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type WebTransportConstructor = new (
|
|
230
|
+
url: string,
|
|
231
|
+
options?: {
|
|
232
|
+
serverCertificateHashes?: Array<{ algorithm: "sha-256"; value: Uint8Array }>;
|
|
233
|
+
requireUnreliable?: boolean;
|
|
234
|
+
},
|
|
235
|
+
) => WebTransportSession;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Why a WebTransport session did not open. `unsupported`: the browser has
|
|
239
|
+
* no API, or the app does not serve WebTransport. `transient`: the
|
|
240
|
+
* endpoint info could not be fetched. `failed`: the session did not open.
|
|
241
|
+
*/
|
|
242
|
+
class WebTransportUnavailable extends Error {
|
|
243
|
+
constructor(
|
|
244
|
+
readonly reason: "unsupported" | "transient" | "failed",
|
|
245
|
+
message: string,
|
|
246
|
+
) {
|
|
247
|
+
super(message);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const webTransportApi = () =>
|
|
252
|
+
(globalThis as { WebTransport?: WebTransportConstructor }).WebTransport;
|
|
253
|
+
|
|
254
|
+
const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
255
|
+
|
|
143
256
|
/**
|
|
144
257
|
* Connect to a shard without React. Returns a client you can wire into any
|
|
145
258
|
* framework or render loop.
|
|
@@ -154,7 +267,26 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
154
267
|
// frame (then a ticket function takes over), and for good with a fixed
|
|
155
268
|
// `ticket`, which names the old shard.
|
|
156
269
|
let transferTicket: string | null = null;
|
|
157
|
-
let
|
|
270
|
+
let link: Link | null = null;
|
|
271
|
+
// Each WebTransport attempt's number; a newer attempt or a close makes an
|
|
272
|
+
// older one stand down.
|
|
273
|
+
let attempts = 0;
|
|
274
|
+
// `"auto"` after WebTransport failed to open: WebSockets from now on.
|
|
275
|
+
let webSocketOnly = false;
|
|
276
|
+
// Stops the WebTransport attempt in progress (its endpoint request).
|
|
277
|
+
let opening: AbortController | null = null;
|
|
278
|
+
// The newest tick and ack this link has seen. Over WebTransport, stream
|
|
279
|
+
// frames and datagrams can arrive out of order.
|
|
280
|
+
let linkTick = -1;
|
|
281
|
+
let linkAck = 0;
|
|
282
|
+
// The tick handlers last got: they see ticks in order.
|
|
283
|
+
let reportedTick = -1;
|
|
284
|
+
// Over WebTransport: the newest tick whose state the table holds whole
|
|
285
|
+
// (all its datagrams and stream frames applied), and the datagrams of
|
|
286
|
+
// newer ticks that are not whole yet (see `pylon_replication::datagram`).
|
|
287
|
+
let wholeTick = -1;
|
|
288
|
+
const pending = new Map<number, PendingTick>();
|
|
289
|
+
let lastKind: Link["kind"] | null = null;
|
|
158
290
|
let clientSeq = 0;
|
|
159
291
|
let closed = false;
|
|
160
292
|
let connected = false;
|
|
@@ -280,8 +412,441 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
280
412
|
);
|
|
281
413
|
};
|
|
282
414
|
|
|
415
|
+
/** The link opened: its frames start over. */
|
|
416
|
+
const began = (l: Link) => {
|
|
417
|
+
link = l;
|
|
418
|
+
lastKind = l.kind;
|
|
419
|
+
connected = true;
|
|
420
|
+
linkTick = -1;
|
|
421
|
+
linkAck = 0;
|
|
422
|
+
reportedTick = -1;
|
|
423
|
+
wholeTick = -1;
|
|
424
|
+
pending.clear();
|
|
425
|
+
// Inputs sent on the old connection are never acknowledged on this
|
|
426
|
+
// one (acks restart with the connection).
|
|
427
|
+
sentAt.clear();
|
|
428
|
+
for (const h of openHandlers) h();
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/** A per-tick frame or datagram for `tick` arrived. */
|
|
432
|
+
const observeTick = (tick: number, at: number) => {
|
|
433
|
+
// The first arrival for a tick times the clock best; a late stream
|
|
434
|
+
// frame after a newer datagram would move it back.
|
|
435
|
+
if (tick > linkTick) {
|
|
436
|
+
clock.observe(tick, at);
|
|
437
|
+
linkTick = tick;
|
|
438
|
+
}
|
|
439
|
+
lastTick = linkTick;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/** The table now holds the shard's state after the inputs up to `ack`. */
|
|
443
|
+
const takeAck = (ack: number, at: number) => {
|
|
444
|
+
if (ack > linkAck) linkAck = ack;
|
|
445
|
+
lastAck = linkAck;
|
|
446
|
+
timeAcks(linkAck, at);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const report = (summary: ReplicationSummary, tick: number) => {
|
|
450
|
+
reportedTick = Math.max(reportedTick, tick);
|
|
451
|
+
for (const h of replicationHandlers) h(entities, summary, reportedTick, lastAck);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Apply the buffered datagrams of the newest whole tick and of every
|
|
456
|
+
* tick before it, oldest first, then take that tick's ack and ack the
|
|
457
|
+
* datagrams. A tick is whole when all its datagrams are here and the
|
|
458
|
+
* table has the stream frames sent by then.
|
|
459
|
+
*/
|
|
460
|
+
const applyWhole = (from: Link, at: number) => {
|
|
461
|
+
let whole = -1;
|
|
462
|
+
for (const [tick, p] of pending) {
|
|
463
|
+
if (tick > whole && p.datagrams.size === p.parts && entities.streamTick >= p.streamTick) {
|
|
464
|
+
whole = tick;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (whole < 0) return;
|
|
468
|
+
const ack = (pending.get(whole) as PendingTick).ack;
|
|
469
|
+
const ticks = [...pending.keys()].filter((t) => t <= whole).sort((a, b) => a - b);
|
|
470
|
+
const updated = new Set<number>();
|
|
471
|
+
const acks: Array<[number, number]> = [];
|
|
472
|
+
for (const t of ticks) {
|
|
473
|
+
const p = pending.get(t) as PendingTick;
|
|
474
|
+
pending.delete(t);
|
|
475
|
+
for (const number of [...p.datagrams.keys()].sort((a, b) => a - b)) {
|
|
476
|
+
const s = entities.applyDatagram(p.datagrams.get(number) as Uint8Array);
|
|
477
|
+
for (const id of s.updated) updated.add(id);
|
|
478
|
+
acks.push([s.frame, entities.streamTick]);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
wholeTick = whole;
|
|
482
|
+
takeAck(ack, at);
|
|
483
|
+
backoff = options.reconnectBackoffMs ?? 500;
|
|
484
|
+
report({ full: false, spawned: [], updated: [...updated], despawned: [] }, whole);
|
|
485
|
+
for (let i = 0; i < acks.length; i += MAX_ACKS_PER_MESSAGE) {
|
|
486
|
+
from.ack(encodeDatagramAcks(acks.slice(i, i + MAX_ACKS_PER_MESSAGE)));
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
/** A frame from the server: a WebSocket message, or one from the WebTransport stream. */
|
|
490
|
+
const onFrame = (from: Link, data: ArrayBuffer, at: number) => {
|
|
491
|
+
if (from !== link) return;
|
|
492
|
+
try {
|
|
493
|
+
const frame = parseShardFrame(data);
|
|
494
|
+
if (frame.kind === ShardFrameKind.Transfer) {
|
|
495
|
+
const notice = decodeShardPayload(frame.codec, frame.payload) as ShardTransferNotice;
|
|
496
|
+
const previous = currentShard;
|
|
497
|
+
currentShard = notice.shard;
|
|
498
|
+
transferTicket = notice.ticket;
|
|
499
|
+
// A new shard, or this one started on another machine (a deploy):
|
|
500
|
+
// its ticks, acks, and entities start over.
|
|
501
|
+
clock.reset();
|
|
502
|
+
entities.clear();
|
|
503
|
+
lastTick = -1;
|
|
504
|
+
lastAck = 0;
|
|
505
|
+
sentAt.clear();
|
|
506
|
+
codec = null;
|
|
507
|
+
backoff = options.reconnectBackoffMs ?? 500;
|
|
508
|
+
// The same shard on another machine is not a move for the app.
|
|
509
|
+
if (currentShard !== previous) {
|
|
510
|
+
for (const h of transferHandlers) h(currentShard, previous);
|
|
511
|
+
}
|
|
512
|
+
// The server closes this connection; reconnect at once.
|
|
513
|
+
transferring = true;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (frame.kind === ShardFrameKind.Closing) {
|
|
517
|
+
// The server is about to close the session: keep why, and close
|
|
518
|
+
// it from this side (see `Link.notice`).
|
|
519
|
+
const notice = decodeShardPayload(frame.codec, frame.payload) as {
|
|
520
|
+
code?: unknown;
|
|
521
|
+
reason?: unknown;
|
|
522
|
+
};
|
|
523
|
+
from.notice = { code: Number(notice.code ?? 0), reason: String(notice.reason ?? "") };
|
|
524
|
+
from.close();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// The new shard answered: a ticket function gives the next tickets.
|
|
528
|
+
if (typeof options.ticket === "function") transferTicket = null;
|
|
529
|
+
if (frame.kind === ShardFrameKind.Replication || frame.kind === ShardFrameKind.Snapshot) {
|
|
530
|
+
// Only the per-tick frames: a rejection can go out before its
|
|
531
|
+
// tick's frame is built, and would make the clock run early.
|
|
532
|
+
observeTick(frame.tick, at);
|
|
533
|
+
}
|
|
534
|
+
if (frame.kind === ShardFrameKind.Replication) {
|
|
535
|
+
let summary: ReplicationSummary;
|
|
536
|
+
try {
|
|
537
|
+
summary = entities.apply(frame.payload, frame.tick);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
// Out of sync with the server. Reconnecting gets a full baseline.
|
|
540
|
+
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
541
|
+
entities.clear();
|
|
542
|
+
from.close();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// Over WebTransport a stream frame holds only the tick's spawns
|
|
546
|
+
// and despawns; its ack describes the table once the tick's
|
|
547
|
+
// datagrams are in too. A full frame holds everything.
|
|
548
|
+
if (from.kind === "websocket" || summary.full) takeAck(frame.ack, at);
|
|
549
|
+
if (from.kind === "webtransport" && summary.full) {
|
|
550
|
+
// Datagrams built before it describe a table that is gone.
|
|
551
|
+
wholeTick = Math.max(wholeTick, frame.tick);
|
|
552
|
+
for (const t of [...pending.keys()]) if (t <= frame.tick) pending.delete(t);
|
|
553
|
+
}
|
|
554
|
+
// A frame applied: the connection works, so the next reconnect
|
|
555
|
+
// starts from the short delay again. (Resetting on open would
|
|
556
|
+
// retry a frame that always fails every 500 ms forever.)
|
|
557
|
+
backoff = options.reconnectBackoffMs ?? 500;
|
|
558
|
+
report(summary, frame.tick);
|
|
559
|
+
if (from.kind === "webtransport") applyWhole(from, at);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// The replication codec byte names the frame format, not the
|
|
563
|
+
// shard's input codec, so only other frames set it.
|
|
564
|
+
codec = frame.codec;
|
|
565
|
+
if (frame.kind === ShardFrameKind.Snapshot) {
|
|
566
|
+
takeAck(frame.ack, at);
|
|
567
|
+
const snapshot = decodeShardPayload(frame.codec, frame.payload, options.decode) as TSnapshot;
|
|
568
|
+
backoff = options.reconnectBackoffMs ?? 500;
|
|
569
|
+
for (const h of snapshotHandlers) h(snapshot, frame.tick, frame.ack);
|
|
570
|
+
} else if (frame.kind === ShardFrameKind.InputRejected) {
|
|
571
|
+
const rejection = decodeShardRejection(frame.codec, frame.payload, options.decode);
|
|
572
|
+
if (rejection.clientSeq !== null) sentAt.delete(rejection.clientSeq);
|
|
573
|
+
for (const h of rejectionHandlers) h(rejection);
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {
|
|
576
|
+
dispatchError(e instanceof Error ? e : new Error("Failed to decode shard frame"));
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* A WebTransport datagram: entity updates for one tick. It waits until
|
|
582
|
+
* its tick is whole (see `applyWhole`). One for a tick at or before the
|
|
583
|
+
* newest whole tick is dropped unacked, as if lost: the server sends its
|
|
584
|
+
* changes again.
|
|
585
|
+
*/
|
|
586
|
+
const onDatagram = (from: Link, datagram: Uint8Array, at: number) => {
|
|
587
|
+
if (from !== link) return;
|
|
588
|
+
try {
|
|
589
|
+
const h = readDatagramHeader(datagram);
|
|
590
|
+
if (h.tick <= wholeTick || h.parts < 1) return;
|
|
591
|
+
observeTick(h.tick, at);
|
|
592
|
+
let p = pending.get(h.tick);
|
|
593
|
+
if (!p) {
|
|
594
|
+
p = { parts: h.parts, streamTick: h.streamTick, ack: h.ack, datagrams: new Map() };
|
|
595
|
+
pending.set(h.tick, p);
|
|
596
|
+
if (pending.size > MAX_PENDING_TICKS) pending.delete(Math.min(...pending.keys()));
|
|
597
|
+
}
|
|
598
|
+
p.datagrams.set(h.frame, datagram);
|
|
599
|
+
applyWhole(from, at);
|
|
600
|
+
} catch (e) {
|
|
601
|
+
// Not a datagram this client can read: start over with a baseline.
|
|
602
|
+
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
603
|
+
entities.clear();
|
|
604
|
+
from.close();
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The link closed. `refusal` is the reason when the server refused the
|
|
610
|
+
* credentials (WebSocket close 1008 or WebTransport code 1 with an
|
|
611
|
+
* `unauthorized` reason).
|
|
612
|
+
*/
|
|
613
|
+
const ended = (from: Link, refusal: string | null) => {
|
|
614
|
+
if (from !== link) return;
|
|
615
|
+
link = null;
|
|
616
|
+
connected = false;
|
|
617
|
+
for (const h of closeHandlers) h();
|
|
618
|
+
if (transferring && !closed) {
|
|
619
|
+
transferring = false;
|
|
620
|
+
reconnectTimer = setTimeout(connect, 0);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
// The server refused these credentials (an expired ticket, one signed
|
|
624
|
+
// with an old secret, a session that ended). The same ones fail the
|
|
625
|
+
// same way on every retry; only a ticket function can bring new ones.
|
|
626
|
+
const sameCredentials = transferTicket !== null || typeof options.ticket !== "function";
|
|
627
|
+
if (refusal !== null && sameCredentials && !closed) {
|
|
628
|
+
dispatchError(
|
|
629
|
+
new Error(
|
|
630
|
+
`shard ${currentShard} refused the connection (${refusal}); pass a ticket function to get new tickets`,
|
|
631
|
+
),
|
|
632
|
+
);
|
|
633
|
+
closed = true;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
scheduleReconnect();
|
|
637
|
+
};
|
|
638
|
+
|
|
283
639
|
const open = (ticket: string | undefined) => {
|
|
640
|
+
const mode = options.transport ?? "websocket";
|
|
641
|
+
// Without the browser API, `"auto"` goes straight to the WebSocket.
|
|
642
|
+
if (mode === "auto" && typeof webTransportApi() !== "function") webSocketOnly = true;
|
|
643
|
+
if (mode === "websocket" || (mode === "auto" && webSocketOnly)) {
|
|
644
|
+
openWebSocket(ticket);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
attempts += 1;
|
|
648
|
+
const attempt = attempts;
|
|
649
|
+
openWebTransport(ticket, attempt).catch((e: unknown) => {
|
|
650
|
+
if (closed || attempt !== attempts) return;
|
|
651
|
+
const reason = e instanceof WebTransportUnavailable ? e.reason : "failed";
|
|
652
|
+
if (mode === "auto") {
|
|
653
|
+
// The WebSocket works where WebTransport does not: UDP blocked, an
|
|
654
|
+
// old browser, an app without it. A failed fetch of the endpoint
|
|
655
|
+
// info may pass; the rest do not.
|
|
656
|
+
if (reason !== "transient") webSocketOnly = true;
|
|
657
|
+
openWebSocket(ticket);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
dispatchError(new Error(`WebTransport to shard ${currentShard}: ${errorMessage(e)}`));
|
|
661
|
+
if (reason === "unsupported") closed = true;
|
|
662
|
+
else scheduleReconnect();
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const webTransportInfoUrl = (): string => {
|
|
667
|
+
if (options.webTransportInfoUrl) return options.webTransportInfoUrl;
|
|
668
|
+
const path = "/_pylon/shard/webtransport";
|
|
669
|
+
if (!options.baseUrl && options.wsUrl && options.wsPort === undefined) {
|
|
670
|
+
const url = new URL(options.wsUrl);
|
|
671
|
+
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
|
672
|
+
return `${proto}//${url.host}${path}`;
|
|
673
|
+
}
|
|
674
|
+
const proto =
|
|
675
|
+
typeof window !== "undefined" && window.location.protocol === "https:" ? "https" : "http";
|
|
676
|
+
const host =
|
|
677
|
+
options.baseUrl || (typeof window !== "undefined" ? window.location.host : "localhost:4321");
|
|
678
|
+
return `${proto}://${host}${path}`;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const openWebTransport = async (ticket: string | undefined, attempt: number) => {
|
|
682
|
+
const WT = webTransportApi();
|
|
683
|
+
if (typeof WT !== "function") {
|
|
684
|
+
throw new WebTransportUnavailable("unsupported", "this browser has no WebTransport");
|
|
685
|
+
}
|
|
686
|
+
// One deadline for the whole open: the endpoint request, its body,
|
|
687
|
+
// the session, and its stream. The request stops at the deadline, or
|
|
688
|
+
// when the client closes.
|
|
689
|
+
const timeoutMs = options.webTransportTimeoutMs ?? 3000;
|
|
690
|
+
const abort = new AbortController();
|
|
691
|
+
opening = abort;
|
|
692
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
693
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
694
|
+
timer = setTimeout(() => {
|
|
695
|
+
abort.abort();
|
|
696
|
+
reject(new WebTransportUnavailable("failed", `the session did not open in ${timeoutMs} ms`));
|
|
697
|
+
}, timeoutMs);
|
|
698
|
+
});
|
|
699
|
+
timeout.catch(() => {});
|
|
700
|
+
let wt: WebTransportSession | null = null;
|
|
701
|
+
let stream: Awaited<ReturnType<WebTransportSession["createBidirectionalStream"]>>;
|
|
702
|
+
try {
|
|
703
|
+
let info: WebTransportInfo;
|
|
704
|
+
try {
|
|
705
|
+
const response = await Promise.race([
|
|
706
|
+
fetch(webTransportInfoUrl(), { signal: abort.signal }),
|
|
707
|
+
timeout,
|
|
708
|
+
]);
|
|
709
|
+
if (response.status === 404) {
|
|
710
|
+
throw new WebTransportUnavailable("unsupported", "the app does not serve WebTransport");
|
|
711
|
+
}
|
|
712
|
+
if (!response.ok) {
|
|
713
|
+
throw new WebTransportUnavailable("transient", `fetching the endpoint: HTTP ${response.status}`);
|
|
714
|
+
}
|
|
715
|
+
info = decodeWebTransportInfo(await Promise.race([response.json(), timeout]));
|
|
716
|
+
} catch (e) {
|
|
717
|
+
if (e instanceof WebTransportUnavailable) throw e;
|
|
718
|
+
// The app did not answer: the WebSocket may still work, and a later
|
|
719
|
+
// attempt may reach the endpoint.
|
|
720
|
+
throw new WebTransportUnavailable("transient", `fetching the endpoint: ${errorMessage(e)}`);
|
|
721
|
+
}
|
|
722
|
+
if (closed || attempt !== attempts) return;
|
|
723
|
+
|
|
724
|
+
wt = new WT(info.url, {
|
|
725
|
+
// Pins the server's self-signed certificate; none for a CA-signed one.
|
|
726
|
+
...(info.certHashes.length > 0
|
|
727
|
+
? { serverCertificateHashes: info.certHashes.map((value) => ({ algorithm: "sha-256" as const, value })) }
|
|
728
|
+
: {}),
|
|
729
|
+
requireUnreliable: true,
|
|
730
|
+
});
|
|
731
|
+
// `closed` rejects when the session never opens; that is handled below.
|
|
732
|
+
wt.closed.catch(() => {});
|
|
733
|
+
await Promise.race([wt.ready, timeout]);
|
|
734
|
+
stream = await Promise.race([wt.createBidirectionalStream(), timeout]);
|
|
735
|
+
} catch (e) {
|
|
736
|
+
try {
|
|
737
|
+
wt?.close();
|
|
738
|
+
} catch {
|
|
739
|
+
// Already closed.
|
|
740
|
+
}
|
|
741
|
+
throw e instanceof WebTransportUnavailable
|
|
742
|
+
? e
|
|
743
|
+
: new WebTransportUnavailable("failed", `the session did not open: ${errorMessage(e)}`);
|
|
744
|
+
} finally {
|
|
745
|
+
clearTimeout(timer);
|
|
746
|
+
if (opening === abort) opening = null;
|
|
747
|
+
}
|
|
748
|
+
// Set whenever the stream is.
|
|
749
|
+
const session = wt as WebTransportSession | null;
|
|
750
|
+
if (!session) return;
|
|
751
|
+
if (closed || attempt !== attempts) {
|
|
752
|
+
session.close();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const writer = stream.writable.getWriter();
|
|
757
|
+
const datagrams = (session.datagrams.createWritable?.() ?? session.datagrams.writable)?.getWriter();
|
|
758
|
+
if (!datagrams) {
|
|
759
|
+
session.close();
|
|
760
|
+
throw new WebTransportUnavailable("unsupported", "this browser cannot send WebTransport datagrams");
|
|
761
|
+
}
|
|
762
|
+
// A failed write shows up as the session closing.
|
|
763
|
+
const ignore = () => {};
|
|
764
|
+
writer
|
|
765
|
+
.write(
|
|
766
|
+
encodeWebTransportHello({
|
|
767
|
+
shard: currentShard,
|
|
768
|
+
sid: options.subscriberId,
|
|
769
|
+
...(ticket ? { ticket } : {}),
|
|
770
|
+
...(options.token ? { token: options.token } : {}),
|
|
771
|
+
}),
|
|
772
|
+
)
|
|
773
|
+
.catch(ignore);
|
|
774
|
+
let isOpen = true;
|
|
775
|
+
const l: Link = {
|
|
776
|
+
kind: "webtransport",
|
|
777
|
+
get open() {
|
|
778
|
+
return isOpen;
|
|
779
|
+
},
|
|
780
|
+
send(input) {
|
|
781
|
+
writer.write(encodeWebTransportInput(input)).catch(ignore);
|
|
782
|
+
},
|
|
783
|
+
ack(acks) {
|
|
784
|
+
datagrams.write(acks).catch(ignore);
|
|
785
|
+
},
|
|
786
|
+
notice: null,
|
|
787
|
+
close() {
|
|
788
|
+
isOpen = false;
|
|
789
|
+
try {
|
|
790
|
+
session.close({ closeCode: WEBTRANSPORT_CLOSE.Normal, reason: "" });
|
|
791
|
+
} catch {
|
|
792
|
+
// Already closed.
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
began(l);
|
|
797
|
+
|
|
798
|
+
void (async () => {
|
|
799
|
+
const reader = stream.readable.getReader();
|
|
800
|
+
const frames = new StreamFrames();
|
|
801
|
+
for (;;) {
|
|
802
|
+
let chunk: Awaited<ReturnType<typeof reader.read>>;
|
|
803
|
+
try {
|
|
804
|
+
chunk = await reader.read();
|
|
805
|
+
} catch {
|
|
806
|
+
return; // The session closed.
|
|
807
|
+
}
|
|
808
|
+
if (chunk.done || link !== l) return;
|
|
809
|
+
let complete: ArrayBuffer[];
|
|
810
|
+
try {
|
|
811
|
+
complete = frames.push(chunk.value);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
814
|
+
l.close();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
for (const frame of complete) onFrame(l, frame, now());
|
|
818
|
+
}
|
|
819
|
+
})();
|
|
820
|
+
void (async () => {
|
|
821
|
+
const reader = session.datagrams.readable.getReader();
|
|
822
|
+
try {
|
|
823
|
+
for (;;) {
|
|
824
|
+
const { value, done } = await reader.read();
|
|
825
|
+
if (done || link !== l) return;
|
|
826
|
+
onDatagram(l, value, now());
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
// The session closed.
|
|
830
|
+
}
|
|
831
|
+
})();
|
|
832
|
+
// The server's closing frame names the code and reason; the browser's
|
|
833
|
+
// `closed` does not get them from a server close.
|
|
834
|
+
const closedWith = (info: { closeCode?: number; reason?: string } | null) => {
|
|
835
|
+
isOpen = false;
|
|
836
|
+
const code = l.notice?.code ?? info?.closeCode;
|
|
837
|
+
const reason = l.notice?.reason ?? info?.reason ?? "";
|
|
838
|
+
const refused = code === WEBTRANSPORT_CLOSE.Policy && reason.startsWith("unauthorized");
|
|
839
|
+
ended(l, refused ? reason : null);
|
|
840
|
+
};
|
|
841
|
+
session.closed.then(
|
|
842
|
+
(info) => closedWith(info ?? null),
|
|
843
|
+
() => closedWith(null),
|
|
844
|
+
);
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const openWebSocket = (ticket: string | undefined) => {
|
|
284
848
|
const url = buildWsUrl();
|
|
849
|
+
let ws: WebSocket;
|
|
285
850
|
try {
|
|
286
851
|
// Subprotocol values must be RFC 6455 tokens; encode them so spaces
|
|
287
852
|
// and punctuation do not break the handshake.
|
|
@@ -294,100 +859,38 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
294
859
|
return;
|
|
295
860
|
}
|
|
296
861
|
ws.binaryType = "arraybuffer";
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
862
|
+
const l: Link = {
|
|
863
|
+
kind: "websocket",
|
|
864
|
+
get open() {
|
|
865
|
+
return ws.readyState === WebSocket.OPEN;
|
|
866
|
+
},
|
|
867
|
+
send(input) {
|
|
868
|
+
ws.send(input);
|
|
869
|
+
},
|
|
870
|
+
ack() {},
|
|
871
|
+
notice: null,
|
|
872
|
+
close() {
|
|
873
|
+
ws.close();
|
|
874
|
+
},
|
|
304
875
|
};
|
|
876
|
+
// Frames and the close belong to this socket once it is the link; a
|
|
877
|
+
// socket that never opened reports its close all the same.
|
|
878
|
+
link = l;
|
|
879
|
+
|
|
880
|
+
ws.onopen = () => began(l);
|
|
305
881
|
|
|
306
882
|
ws.onmessage = (event) => {
|
|
307
|
-
if (
|
|
308
|
-
const at = now();
|
|
309
|
-
try {
|
|
310
|
-
const frame = parseShardFrame(event.data);
|
|
311
|
-
if (frame.kind === ShardFrameKind.Transfer) {
|
|
312
|
-
const notice = decodeShardPayload(frame.codec, frame.payload) as ShardTransferNotice;
|
|
313
|
-
const from = currentShard;
|
|
314
|
-
currentShard = notice.shard;
|
|
315
|
-
transferTicket = notice.ticket;
|
|
316
|
-
// A new shard, or this one started on another machine (a deploy):
|
|
317
|
-
// its ticks, acks, and entities start over.
|
|
318
|
-
clock.reset();
|
|
319
|
-
entities.clear();
|
|
320
|
-
lastTick = -1;
|
|
321
|
-
lastAck = 0;
|
|
322
|
-
sentAt.clear();
|
|
323
|
-
codec = null;
|
|
324
|
-
backoff = options.reconnectBackoffMs ?? 500;
|
|
325
|
-
// The same shard on another machine is not a move for the app.
|
|
326
|
-
if (currentShard !== from) {
|
|
327
|
-
for (const h of transferHandlers) h(currentShard, from);
|
|
328
|
-
}
|
|
329
|
-
// The server closes this connection; reconnect at once.
|
|
330
|
-
transferring = true;
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
// The new shard answered: a ticket function gives the next tickets.
|
|
334
|
-
if (typeof options.ticket === "function") transferTicket = null;
|
|
335
|
-
if (frame.kind === ShardFrameKind.Replication || frame.kind === ShardFrameKind.Snapshot) {
|
|
336
|
-
// Only the per-tick frames: a rejection can go out before its
|
|
337
|
-
// tick's frame is built, and would make the clock run early.
|
|
338
|
-
clock.observe(frame.tick, at);
|
|
339
|
-
lastTick = frame.tick;
|
|
340
|
-
lastAck = frame.ack;
|
|
341
|
-
timeAcks(frame.ack, at);
|
|
342
|
-
}
|
|
343
|
-
if (frame.kind === ShardFrameKind.Replication) {
|
|
344
|
-
let summary: ReplicationSummary;
|
|
345
|
-
try {
|
|
346
|
-
summary = entities.apply(frame.payload);
|
|
347
|
-
} catch (e) {
|
|
348
|
-
// Out of sync with the server. Reconnecting gets a full baseline.
|
|
349
|
-
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
350
|
-
entities.clear();
|
|
351
|
-
ws?.close();
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
// A frame applied: the connection works, so the next reconnect
|
|
355
|
-
// starts from the short delay again. (Resetting on open would
|
|
356
|
-
// retry a frame that always fails every 500 ms forever.)
|
|
357
|
-
backoff = options.reconnectBackoffMs ?? 500;
|
|
358
|
-
for (const h of replicationHandlers) h(entities, summary, frame.tick, frame.ack);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
// The replication codec byte names the frame format, not the
|
|
362
|
-
// shard's input codec, so only other frames set it.
|
|
363
|
-
codec = frame.codec;
|
|
364
|
-
if (frame.kind === ShardFrameKind.Snapshot) {
|
|
365
|
-
const snapshot = decodeShardPayload(frame.codec, frame.payload, options.decode) as TSnapshot;
|
|
366
|
-
backoff = options.reconnectBackoffMs ?? 500;
|
|
367
|
-
for (const h of snapshotHandlers) h(snapshot, frame.tick, frame.ack);
|
|
368
|
-
} else if (frame.kind === ShardFrameKind.InputRejected) {
|
|
369
|
-
const rejection = decodeShardRejection(frame.codec, frame.payload, options.decode);
|
|
370
|
-
if (rejection.clientSeq !== null) sentAt.delete(rejection.clientSeq);
|
|
371
|
-
for (const h of rejectionHandlers) h(rejection);
|
|
372
|
-
}
|
|
373
|
-
} catch (e) {
|
|
374
|
-
dispatchError(e instanceof Error ? e : new Error("Failed to decode shard frame"));
|
|
375
|
-
}
|
|
883
|
+
if (event.data instanceof ArrayBuffer) onFrame(l, event.data, now());
|
|
376
884
|
};
|
|
377
885
|
|
|
378
886
|
ws.onerror = () => {
|
|
379
887
|
dispatchError(new Error(`WebSocket error connecting to shard ${currentShard}`));
|
|
380
888
|
};
|
|
381
889
|
|
|
382
|
-
ws.onclose = () => {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
transferring = false;
|
|
387
|
-
reconnectTimer = setTimeout(connect, 0);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
scheduleReconnect();
|
|
890
|
+
ws.onclose = (event?: { code?: number; reason?: string }) => {
|
|
891
|
+
const refused =
|
|
892
|
+
event?.code === POLICY_CLOSE && (event.reason ?? "").startsWith("unauthorized");
|
|
893
|
+
ended(l, refused ? (event?.reason ?? "") : null);
|
|
391
894
|
};
|
|
392
895
|
};
|
|
393
896
|
|
|
@@ -397,6 +900,9 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
397
900
|
get connected() {
|
|
398
901
|
return connected;
|
|
399
902
|
},
|
|
903
|
+
get transport() {
|
|
904
|
+
return link?.kind ?? lastKind;
|
|
905
|
+
},
|
|
400
906
|
get shardId() {
|
|
401
907
|
return currentShard;
|
|
402
908
|
},
|
|
@@ -437,12 +943,12 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
437
943
|
closeHandlers.push(fn);
|
|
438
944
|
},
|
|
439
945
|
send(input: TInput): number {
|
|
440
|
-
if (!
|
|
946
|
+
if (!link || !connected || !link.open) {
|
|
441
947
|
dispatchError(new Error("Cannot send: shard connection is not open"));
|
|
442
948
|
return 0;
|
|
443
949
|
}
|
|
444
950
|
clientSeq += 1;
|
|
445
|
-
|
|
951
|
+
link.send(encodeShardInput(codec, input, clientSeq));
|
|
446
952
|
sentAt.set(clientSeq, now());
|
|
447
953
|
if (sentAt.size > MAX_TIMED) sentAt.delete(sentAt.keys().next().value as number);
|
|
448
954
|
return clientSeq;
|
|
@@ -450,7 +956,9 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
450
956
|
close() {
|
|
451
957
|
closed = true;
|
|
452
958
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
453
|
-
|
|
959
|
+
attempts += 1;
|
|
960
|
+
opening?.abort();
|
|
961
|
+
link?.close();
|
|
454
962
|
},
|
|
455
963
|
};
|
|
456
964
|
}
|