@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/src/connection.ts CHANGED
@@ -1,15 +1,23 @@
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, 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 ws: WebSocket | null = null;
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
- 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();
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 (!(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
- }
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
- 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();
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 (!ws || ws.readyState !== WebSocket.OPEN) {
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
- ws.send(encodeShardInput(codec, input, clientSeq));
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
- if (ws) ws.close();
959
+ attempts += 1;
960
+ opening?.abort();
961
+ link?.close();
454
962
  },
455
963
  };
456
964
  }