@pylonsync/realtime 0.18.1 → 0.19.1

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