@tribe-nest/forge 3.41.1 → 3.42.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.
@@ -28,7 +28,26 @@ import {
28
28
  // `mediasoup-client`, and a value import here would put a browser-only package
29
29
  // on the server path of every site that loads `@tribe-nest/forge/ui`. It is
30
30
  // reached through the `import()` in the effect below, which no server runs.
31
+ import type { BroadcastMoqStageProps } from "./BroadcastMoqStage";
31
32
  import type { BroadcastRealtimeStageProps } from "./BroadcastRealtimeStage";
33
+
34
+ /** The relay lane's half of a `viewer-token` response. */
35
+ type BroadcastMoqCredentials = { url: string; name: string; expiresAt: string; path: string };
36
+
37
+ /**
38
+ * Renders the lazily-loaded relay stage.
39
+ *
40
+ * A named component rather than `<moqStage ... />` inline: a component read out
41
+ * of state and rendered directly is a new type on every render that produces a
42
+ * different one, which unmounts and remounts the player underneath. Naming it
43
+ * here keeps the element type stable across renders.
44
+ */
45
+ function MoqStageRenderer({
46
+ Stage,
47
+ ...props
48
+ }: BroadcastMoqStageProps & { Stage: ComponentType<BroadcastMoqStageProps> }) {
49
+ return <Stage {...props} />;
50
+ }
32
51
  import { BroadcastPollPanel } from "./BroadcastPollPanel";
33
52
  import { useBroadcastPoll } from "../../headless/broadcast/useBroadcastPoll";
34
53
  import type { BroadcastPoll } from "../../../data/queries/useBroadcasts";
@@ -132,10 +151,22 @@ export interface BroadcastPlayerProps {
132
151
  *
133
152
  * ## Why the quality picker is not on the new path
134
153
  *
135
- * The plane has no simulcast. Every viewer receives the program feed's single
136
- * encoding, so High, Medium and Low would be three controls that change
137
- * nothing. A control that lies about what it does is worse than no control.
154
+ * The plane DOES publish three simulcast layers now (`REALTIME_PROGRAM_VIDEO`),
155
+ * so the old reason for leaving the picker off - "there is only one encoding,
156
+ * three buttons would all do nothing" - no longer holds. What holds instead is
157
+ * that the node's own layer choice tracks the viewer's actual bandwidth, and a
158
+ * manual override is a way to pin yourself to a layer your connection cannot
159
+ * carry. The picker below stays on the v1 Cloudflare path, which is where it
160
+ * was built and where it is wired.
138
161
  */
162
+ /**
163
+ * How long the v1 Cloudflare lane may deliver nothing before HLS takes over.
164
+ *
165
+ * Long enough that a slow negotiation is not cut off, short enough that a
166
+ * viewer is not staring at a spinner while the broadcast is being published.
167
+ */
168
+ const LEGACY_REALTIME_DEADLINE_MS = 12_000;
169
+
139
170
  export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
140
171
  const t = useForgeT();
141
172
  const theme = useForgeTheme();
@@ -152,6 +183,21 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
152
183
  const videoRef = useRef<HTMLVideoElement>(null);
153
184
  const audioRef = useRef<HTMLAudioElement>(null);
154
185
  const videoContainerRef = useRef<HTMLDivElement>(null);
186
+ /**
187
+ * The v1 Cloudflare lane gave up, so HLS may have the viewer.
188
+ *
189
+ * The plane and the relay both fall back: every way they can fail ends at
190
+ * HLS, and `chooseBroadcastStage` holds that rule. The v1 lane had no such
191
+ * exit. `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast
192
+ * whose Cloudflare room was gone - retired, expired, never created for a v2
193
+ * broadcast that still carries a stale `realtimeConfig` - showed a spinner
194
+ * and nothing else, for ever, with no way for the viewer to reach a stream
195
+ * that was being published the whole time.
196
+ *
197
+ * Reported from production as a live HLS broadcast showing nothing on the
198
+ * site.
199
+ */
200
+ const [legacyFailed, setLegacyFailed] = useState(false);
155
201
  const [isAudioLoaded, setIsAudioLoaded] = useState(false);
156
202
  const [isVideoLoaded, setIsVideoLoaded] = useState(false);
157
203
  const [isPlaying, setIsPlaying] = useState(false);
@@ -178,6 +224,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
178
224
  const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
179
225
  const [credentialsReady, setCredentialsReady] = useState(false);
180
226
  const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
227
+ /**
228
+ * The relay lane, as `viewer-token` reported it.
229
+ *
230
+ * Held separately from the plane credentials because the two are independent
231
+ * answers: an instance may offer a relay and no plane, a plane and no relay,
232
+ * or both. `planeAvailable` reads the plane's half off the same response.
233
+ */
234
+ const [moqStage, setMoqStage] = useState<ComponentType<BroadcastMoqStageProps> | null>(null);
235
+ const [moqCredentials, setMoqCredentials] = useState<BroadcastMoqCredentials | null>(null);
236
+ const [moqFailed, setMoqFailed] = useState(false);
237
+ const [planeAvailable, setPlaneAvailable] = useState(true);
181
238
  const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
182
239
  /** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
183
240
  const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
@@ -223,6 +280,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
223
280
  if (cancelled) return;
224
281
  primedCredentials.current = credentials;
225
282
  setPlaneStage(() => module.BroadcastRealtimeStage);
283
+ // A response with no `mediaUrl` is a relay-only instance. Recording it
284
+ // is what stops the chooser falling to a plane room that is not there
285
+ // if the relay lane later gives up.
286
+ setPlaneAvailable(Boolean((credentials as { mediaUrl?: string }).mediaUrl));
287
+ const moq = (credentials as { moq?: BroadcastMoqCredentials }).moq ?? null;
288
+ setMoqCredentials(moq);
289
+ if (moq) {
290
+ import("./BroadcastMoqStage")
291
+ .then((moqModule) => {
292
+ if (cancelled) return;
293
+ setMoqStage(() => moqModule.BroadcastMoqStage);
294
+ })
295
+ .catch(() => {
296
+ if (cancelled) return;
297
+ // The relay chunk did not load. The lane is over before it began.
298
+ setMoqFailed(true);
299
+ });
300
+ }
226
301
  setCredentialsReady(true);
227
302
  })
228
303
  .catch((error: unknown) => {
@@ -266,17 +341,33 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
266
341
  */
267
342
  const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
268
343
 
344
+ /**
345
+ * The relay lane gave up. Never cleared back to false, for the same reason
346
+ * `roomState` is never cleared: the failure is why we left, so forgetting it
347
+ * would choose the relay again, mount it again, and watch it fail again.
348
+ */
349
+ const handleMoqFailed = useCallback(() => setMoqFailed(true), []);
350
+
269
351
  const stageInput = useMemo(
270
- () => ({ realtimeAvailable, credentialsReady, credentialsError, roomState }),
271
- [realtimeAvailable, credentialsReady, credentialsError, roomState],
352
+ () => ({
353
+ realtimeAvailable,
354
+ credentialsReady,
355
+ credentialsError,
356
+ roomState,
357
+ moqAvailable: Boolean(moqCredentials && moqStage),
358
+ moqFailed,
359
+ planeAvailable,
360
+ }),
361
+ [realtimeAvailable, credentialsReady, credentialsError, roomState, moqCredentials, moqStage, moqFailed, planeAvailable],
272
362
  );
273
363
  const stage = chooseBroadcastStage(stageInput);
364
+ const showMoq = stage === "moq" && !!moqStage && !!moqCredentials;
274
365
  const showPlane = stage === "realtime" && !!PlaneStage;
275
366
  // Nothing is mounted while the ticket is in flight. `react-player` builds an
276
367
  // hls.js instance on mount and starts fetching immediately, so rendering the
277
368
  // HLS stage "meanwhile" made every realtime viewer download the broadcast a
278
369
  // second time, over the CDN, for a stage that was about to be replaced.
279
- const showHls = stage === "hls" && !isLegacyRealtime;
370
+ const showHls = stage === "hls" && (!isLegacyRealtime || legacyFailed);
280
371
  const fellBack = broadcastFellBack(stageInput);
281
372
 
282
373
  const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
@@ -295,6 +386,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
295
386
  });
296
387
  }, [comments]);
297
388
 
389
+ /**
390
+ * The v1 lane also fails by never arriving, which no `catch` will report.
391
+ *
392
+ * A peer connection that negotiates and then delivers no track raises
393
+ * nothing at all: `subscribe()` resolves, and the viewer watches a spinner.
394
+ * So the deadline is the backstop, and it is what makes the fallback
395
+ * guaranteed rather than dependent on the failure being the polite kind.
396
+ *
397
+ * Twelve seconds: long enough that a slow negotiation is not cut off, short
398
+ * enough that a viewer is not staring at nothing while the broadcast runs.
399
+ */
400
+ useEffect(() => {
401
+ if (!isLegacyRealtime || legacyFailed) return;
402
+ if (isVideoLoaded || isAudioLoaded) return;
403
+ const timer = setTimeout(() => setLegacyFailed(true), LEGACY_REALTIME_DEADLINE_MS);
404
+ return () => clearTimeout(timer);
405
+ }, [isLegacyRealtime, legacyFailed, isVideoLoaded, isAudioLoaded]);
406
+
298
407
  // ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
299
408
  useEffect(() => {
300
409
  const config = broadcast.realtimeConfig;
@@ -345,9 +454,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
345
454
  };
346
455
 
347
456
  subscribe().catch(() => {
348
- // Nothing useful to say to a viewer here: the spinner over the stage is
349
- // already the honest report that no track arrived.
457
+ // A spinner is not an honest report when there is a stream the viewer
458
+ // could be watching. Falling back is.
350
459
  isSubscribingRef.current = false;
460
+ setLegacyFailed(true);
351
461
  });
352
462
  // `sessionApi` is rebuilt each render and is deliberately not a dependency:
353
463
  // including it would re-negotiate the peer connection on every render.
@@ -469,10 +579,45 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
469
579
  return (
470
580
  <div
471
581
  style={{
582
+ /**
583
+ * The video dominates, and the chat takes what is left.
584
+ *
585
+ * `flexWrap` wraps rather than shrinking, so the two panels stacked
586
+ * the moment their BASES no longer fit on a line - and with both
587
+ * growing at the same rate, the chat took half of every extra pixel,
588
+ * so the video was never big even side by side. On an ordinary
589
+ * desktop that read as a small video sitting on top of a chat.
590
+ *
591
+ * The bases below stay on one line down to about 800px, and the video
592
+ * grows three times faster than the chat, so the space a wider screen
593
+ * provides goes almost entirely to the picture. Stacking is then what
594
+ * happens on a genuinely narrow screen, which is where it is right.
595
+ */
472
596
  display: "flex",
473
597
  flexWrap: "wrap",
474
598
  gap: 32,
475
- padding: 16,
599
+ // No padding at the TOP: the shell and the close button above already
600
+ // put the player well down the page, and on a phone that was most of
601
+ // the first screen spent before any video.
602
+ padding: "0 16px 16px",
603
+ /**
604
+ * FILL the parent rather than being sized by what is inside.
605
+ *
606
+ * Without this the container was shrink-to-fit, so its width came from
607
+ * its content - and the HLS stage puts its player in an absolutely
608
+ * positioned `inset: 0` box (which is what stops the player resizing
609
+ * its own container on a rendition change). Absolutely positioned
610
+ * children contribute NO intrinsic width, so the content measured as
611
+ * almost nothing and the whole player collapsed to about 400px inside a
612
+ * 1300px column, then wrapped.
613
+ *
614
+ * The realtime stages hid it: they render an in-flow `<video>` with a
615
+ * real intrinsic width, so the same container came out wide enough. It
616
+ * looked like "HLS stacks and the SFU does not", and it was neither -
617
+ * it was a container taking its width from whatever happened to be
618
+ * inside it.
619
+ */
620
+ width: "100%",
476
621
  maxWidth: 1700,
477
622
  marginInline: "auto",
478
623
  flex: 1,
@@ -480,7 +625,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
480
625
  fontFamily: theme.fontFamily,
481
626
  }}
482
627
  >
483
- <div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
628
+ <div style={{ flex: "3 1 420px", minWidth: 0, ...panel }}>
629
+ {showMoq && moqStage && moqCredentials && (
630
+ <MoqStageRenderer
631
+ Stage={moqStage}
632
+ url={moqCredentials.url}
633
+ name={moqCredentials.name}
634
+ // Not muted: this is the broadcast the viewer came for, and the
635
+ // plane stage does not mute either. The element paints video to a
636
+ // canvas independently of its audio emitter, so a browser that
637
+ // blocks autoplay audio still shows a picture rather than nothing.
638
+ muted={false}
639
+ onFailed={handleMoqFailed}
640
+ />
641
+ )}
484
642
  {showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
485
643
 
486
644
  {stage === "pending" && (
@@ -490,7 +648,26 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
490
648
  )}
491
649
 
492
650
  {showHls && (
493
- <div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
651
+ /*
652
+ The ratio box, and INSIDE it an absolutely filled box that the
653
+ player cannot resize.
654
+
655
+ `renderPlayer` is host-supplied (the starter passes `react-player`),
656
+ so this cannot dictate how the player sizes itself - and the
657
+ conventional `height: "100%"` a player is given resolves against a
658
+ parent whose height comes from `aspect-ratio`, which is not reliably
659
+ a definite height. The player then falls back to the stream's
660
+ intrinsic size and the panel jumps every time HLS changes rendition.
661
+
662
+ An absolutely positioned `inset: 0` box HAS a definite height, so a
663
+ child asking for 100% gets a real answer, and nothing the child does
664
+ can change the box it is in.
665
+ */
666
+ <div
667
+ data-testid="broadcast-hls-stage"
668
+ style={{ aspectRatio: "16/9", position: "relative", lineHeight: 0 }}
669
+ >
670
+ <div style={{ position: "absolute", inset: 0 }}>
494
671
  {renderPlayer ? (
495
672
  renderPlayer({ src: broadcast.liveUrl })
496
673
  ) : (
@@ -499,9 +676,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
499
676
  controls
500
677
  autoPlay
501
678
  playsInline
502
- style={{ width: "100%", height: "100%", background: "#000" }}
679
+ // Same reasoning as the realtime stage below: HLS renditions
680
+ // change resolution mid-stream too, and `height: 100%` off an
681
+ // `aspect-ratio` parent leaves the box following the source.
682
+ style={{
683
+ display: "block",
684
+ width: "100%",
685
+ height: "auto",
686
+ aspectRatio: "16 / 9",
687
+ objectFit: "contain",
688
+ background: "#000",
689
+ }}
503
690
  />
504
691
  )}
692
+ </div>
505
693
  </div>
506
694
  )}
507
695
 
@@ -532,16 +720,51 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
532
720
  </div>
533
721
  )}
534
722
 
535
- {isLegacyRealtime && (
723
+ {isLegacyRealtime && !legacyFailed && (
536
724
  <div
537
725
  ref={videoContainerRef}
538
726
  data-testid="broadcast-realtime-stage"
539
727
  onClick={togglePlay}
540
728
  onMouseEnter={() => setShowControls(true)}
541
729
  onMouseLeave={() => setShowControls(false)}
542
- style={{ aspectRatio: "16/9", position: "relative", background: "#000", cursor: "pointer" }}
730
+ style={{
731
+ aspectRatio: "16/9",
732
+ position: "relative",
733
+ background: "#000",
734
+ cursor: "pointer",
735
+ lineHeight: 0,
736
+ }}
543
737
  >
544
- <video ref={videoRef} playsInline style={{ width: "100%", height: "100%" }} />
738
+ {/*
739
+ The RATIO is on the video element itself, and `height` is `auto`.
740
+
741
+ This is the same fix `BroadcastRealtimeStage` carries, and this
742
+ path is where it was actually needed: it is the one with a quality
743
+ picker, so the incoming resolution changes whenever the viewer
744
+ chooses a layer (or the auto policy chooses one for them).
745
+
746
+ `height: 100%` did not hold the box still. A percentage height
747
+ needs a definite parent height, and one derived from the parent's
748
+ own `aspect-ratio` is not reliably that, so the replaced element
749
+ fell back to the track's INTRINSIC height and the whole panel
750
+ jumped on every quality change. `aspect-ratio` with `height: auto`
751
+ overrides the intrinsic ratio outright: the box is 16/9 of
752
+ whatever width it is given, whatever arrives on the wire.
753
+
754
+ `object-fit: contain` then letterboxes a source that is not 16/9
755
+ inside that fixed box rather than resizing the box to suit it.
756
+ */}
757
+ <video
758
+ ref={videoRef}
759
+ playsInline
760
+ style={{
761
+ display: "block",
762
+ width: "100%",
763
+ height: "auto",
764
+ aspectRatio: "16 / 9",
765
+ objectFit: "contain",
766
+ }}
767
+ />
545
768
  <audio ref={audioRef} />
546
769
 
547
770
  {(!isAudioLoaded || !isVideoLoaded) && (
@@ -669,7 +892,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
669
892
  </div>
670
893
  </div>
671
894
 
672
- <div style={{ flex: "1 1 340px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
895
+ <div style={{ flex: "1 1 300px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
673
896
  <p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
674
897
  {t("forge.broadcast_player.chat")}
675
898
  </p>
@@ -48,9 +48,12 @@ import { programTracks, type BroadcastRoomState } from "./broadcastStage";
48
48
  * mute plus a blank screen plus a lie about being able to go back. The HLS
49
49
  * stage keeps its transport controls because there the buffer makes them true.
50
50
  *
51
- * There is no quality selector either. The plane has no simulcast: every viewer
52
- * receives the program feed's single encoding, so a picker offering High,
53
- * Medium and Low would be three buttons that all do the same nothing.
51
+ * There is no quality selector either, though not for the reason there used to
52
+ * be: the program feed publishes three simulcast layers, and the node moves a
53
+ * viewer between them on its own reading of their connection. A manual override
54
+ * is mostly a way to pin yourself above what your link can carry, so the choice
55
+ * stays with the node. What this file owes the viewer instead is that the
56
+ * SWITCH is invisible, which is what the sizing below is about.
54
57
  */
55
58
 
56
59
  export type BroadcastRealtimeStageProps = {
@@ -0,0 +1,32 @@
1
+
2
+ /**
3
+ * The v1 Cloudflare lane must end at HLS like the other two.
4
+ *
5
+ * `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast whose
6
+ * Cloudflare room was gone - retired, expired, or never created for a v2
7
+ * broadcast still carrying a stale `realtimeConfig` - showed a spinner and
8
+ * nothing else, for ever, while HLS was being published the whole time.
9
+ * Reported from production as a live broadcast showing nothing on the site.
10
+ *
11
+ * The rule lives in the component (it needs the lane's load state, which this
12
+ * pure chooser has no business knowing), so this pins the SHAPE of it: three
13
+ * lanes, and every one of them has an exit to HLS.
14
+ */
15
+ describe("every realtime lane has an exit to HLS", () => {
16
+ const showHls = (input: { stage: string; isLegacyRealtime: boolean; legacyFailed: boolean }) =>
17
+ input.stage === "hls" && (!input.isLegacyRealtime || input.legacyFailed);
18
+
19
+ it("keeps HLS hidden while the v1 lane is still trying", () => {
20
+ // Not a regression: mounting an HLS engine beside a working realtime lane
21
+ // makes the viewer download the broadcast twice.
22
+ expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: false })).toBe(false);
23
+ });
24
+
25
+ it("shows HLS once the v1 lane has given up", () => {
26
+ expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: true })).toBe(true);
27
+ });
28
+
29
+ it("shows HLS normally when there is no v1 lane at all", () => {
30
+ expect(showHls({ stage: "hls", isLegacyRealtime: false, legacyFailed: false })).toBe(true);
31
+ });
32
+ });
@@ -28,7 +28,16 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
28
28
  * picture.
29
29
  */
30
30
 
31
- /** `realtime` is the media plane. `hls` is the stream everybody can always watch. */
31
+ /**
32
+ * `moq` is a relay, `realtime` is the media plane, and `hls` is the stream
33
+ * everybody can always watch.
34
+ *
35
+ * `moq` outranks `realtime` because the two show the same picture at the same
36
+ * latency and cost very different amounts: a relay viewer is one more
37
+ * subscriber on a cached object, and a plane viewer is a WebRTC consumer with
38
+ * its own SRTP context and its own bandwidth estimate, capped at
39
+ * `STREAM_V2_REALTIME_VIEWER_CAP` because the node runs out.
40
+ */
32
41
  /**
33
42
  * Which stage the player shows.
34
43
  *
@@ -37,7 +46,7 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
37
46
  * was showing HLS meanwhile, and an HLS engine starts fetching the moment it
38
47
  * mounts, so a realtime viewer pulled segments they would never watch.
39
48
  */
40
- export type BroadcastStage = "realtime" | "hls" | "pending";
49
+ export type BroadcastStage = "moq" | "realtime" | "hls" | "pending";
41
50
 
42
51
  /**
43
52
  * The viewer-token endpoint said no.
@@ -78,6 +87,25 @@ export type BroadcastStageInput = {
78
87
  /** The refusal, when the fetch failed. `null` while it has not. */
79
88
  credentialsError: BroadcastCredentialsError | null;
80
89
  roomState?: BroadcastRoomState;
90
+ /** The token endpoint returned a relay ticket for this viewer. */
91
+ moqAvailable?: boolean;
92
+ /**
93
+ * The relay lane was tried and is over.
94
+ *
95
+ * Separate from `moqAvailable` for the same reason `realtimeIsOver` is
96
+ * separate from `credentialsReady`: a ticket that exists is not a lane that
97
+ * is working, and the fallback has to be able to happen after a connection
98
+ * that started fine.
99
+ */
100
+ moqFailed?: boolean;
101
+ /**
102
+ * Is the plane lane offered at all?
103
+ *
104
+ * `false` on an instance that runs relays and no media plane, which is what
105
+ * `credentials: null` from `viewer-token` means. Without it the chooser would
106
+ * fall to `realtime` after a relay failure and mount a room that is not there.
107
+ */
108
+ planeAvailable?: boolean;
81
109
  };
82
110
 
83
111
  /**
@@ -115,7 +143,12 @@ export function chooseBroadcastStage(input: BroadcastStageInput): BroadcastStage
115
143
  // about to be replaced, so a realtime viewer downloaded the broadcast twice.
116
144
  // Nothing is drawn until the answer is known.
117
145
  if (!input.credentialsReady) return "pending";
146
+ // The relay first, and only while it is actually working.
147
+ if (input.moqAvailable && !input.moqFailed) return "moq";
118
148
  if (realtimeIsOver(input.roomState)) return "hls";
149
+ // The relay failed and there is no plane behind it. `planeAvailable` defaults
150
+ // to true so every existing caller keeps its behaviour unchanged.
151
+ if (input.planeAvailable === false) return "hls";
119
152
  return "realtime";
120
153
  }
121
154
 
@@ -135,6 +168,9 @@ export function broadcastFellBack(input: BroadcastStageInput): boolean {
135
168
  if (!input.realtimeAvailable) return false;
136
169
  if (input.credentialsError) return true;
137
170
  if (!input.credentialsReady) return false;
171
+ // A working relay is not a fallback, it is the best lane there is.
172
+ if (input.moqAvailable && !input.moqFailed) return false;
173
+ if (input.moqAvailable && input.moqFailed && input.planeAvailable === false) return true;
138
174
  return realtimeIsOver(input.roomState);
139
175
  }
140
176
 
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { relayIsReachable } from "./relayReachable";
4
+
5
+ /**
6
+ * The lane must be able to give up. `<moq-watch>` retries a refused connection
7
+ * for ever and never raises an `error` event, so without this the viewer sits
8
+ * on a dead stage while HLS is being published.
9
+ */
10
+ describe("probing the MoQ relay before handing a viewer to it", () => {
11
+ it("is reachable when the fingerprint responds", async () => {
12
+ expect(await relayIsReachable("https://relay.example/room/name", vi.fn(async () => new Response("")))).toBe(true);
13
+ });
14
+
15
+ it("is NOT reachable when the connection is refused", async () => {
16
+ // The dev case: no relay running on localhost:4444.
17
+ const refused = vi.fn(async () => {
18
+ throw new TypeError("Failed to fetch");
19
+ });
20
+ expect(await relayIsReachable("http://localhost:4444/room/name", refused)).toBe(false);
21
+ });
22
+
23
+ it("asks the relay ORIGIN, not the ticketed path", async () => {
24
+ const fetchImpl = vi.fn(async () => new Response(""));
25
+ await relayIsReachable("https://relay.example/room/name?jwt=abc", fetchImpl);
26
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://relay.example/certificate.sha256");
27
+ });
28
+
29
+ it("gives up rather than hanging when nothing answers", async () => {
30
+ const hangs = vi.fn(
31
+ (_url: string, init?: { signal?: AbortSignal }) =>
32
+ new Promise<Response>((_resolve, reject) => {
33
+ init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
34
+ }),
35
+ );
36
+ expect(await relayIsReachable("https://relay.example/x", hangs as unknown as typeof fetch, 10)).toBe(false);
37
+ });
38
+
39
+ it("treats an unparseable URL as unreachable rather than throwing", async () => {
40
+ expect(await relayIsReachable("not a url", vi.fn())).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Is the MoQ relay actually there, before a viewer is handed to it?
3
+ *
4
+ * `<moq-watch>` owns its own reconnect loop: a refused connection produces a
5
+ * console error and a retry, for ever, and never an `error` event. So the
6
+ * lane's `onFailed` never fires, `chooseBroadcastStage` keeps answering "moq",
7
+ * and the HLS stage is never mounted - the viewer watches nothing while the
8
+ * broadcast is being published the whole time.
9
+ *
10
+ * A blind timeout would be the wrong fix: it cannot tell a slow relay from a
11
+ * dead one and would cut off a stream that was about to work. This asks the
12
+ * question that actually has an answer. The element fetches
13
+ * `/certificate.sha256` from the relay origin before it connects; if that is
14
+ * unreachable, so is the relay, and the lane is over before it began.
15
+ *
16
+ * The common case is not an outage. It is a developer with no relay running
17
+ * locally, where the URL is `http://localhost:4444` and correct.
18
+ */
19
+ export const RELAY_PROBE_TIMEOUT_MS = 3000;
20
+
21
+ export async function relayIsReachable(
22
+ url: string,
23
+ fetchImpl: typeof fetch = fetch,
24
+ timeoutMs: number = RELAY_PROBE_TIMEOUT_MS,
25
+ ): Promise<boolean> {
26
+ let origin: string;
27
+ try {
28
+ origin = new URL("/certificate.sha256", url).toString();
29
+ } catch {
30
+ // An unparseable relay URL is not reachable by any definition.
31
+ return false;
32
+ }
33
+
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ // `no-cors` on purpose: the answer wanted is "did the connection happen",
38
+ // not what the body says. An opaque response is a reachable relay, and it
39
+ // keeps this probe from needing CORS the element itself may not need.
40
+ await fetchImpl(origin, { mode: "no-cors", signal: controller.signal });
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@moq/watch`, vendored as a self-contained ESM bundle.
3
+ *
4
+ * Side-effect only: importing it registers the `<moq-watch>` custom element.
5
+ * There is nothing to import by name, which is why this declares an empty
6
+ * module rather than a surface.
7
+ *
8
+ * Built by `scripts/build-moq-player.mjs`; see its header for why this is an
9
+ * asset rather than a dependency.
10
+ */
11
+ declare const _default: void;
12
+ export default _default;