@tribe-nest/forge 3.38.0 → 3.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.38.0",
3
+ "version": "3.39.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,14 +38,24 @@ export function useValidateBroadcastPass() {
38
38
  * page (the "broadcast ended, keep this tab open" screen). Reading the detail
39
39
  * endpoint is what makes that page survive the end of the stream.
40
40
  */
41
- export function useLiveBroadcast(broadcastId?: string) {
41
+ /**
42
+ * The viewer's validated pass, sent on every read that can return playback
43
+ * credentials.
44
+ *
45
+ * A gated broadcast withholds `liveUrl` and `realtimeConfig` from a caller who
46
+ * cannot show one, because the paywall used to be drawn in the browser only:
47
+ * the public read handed the manifest and the realtime session to anybody who
48
+ * asked, and the ticket box in front of it was decoration. Passing it here is
49
+ * what keeps a paying viewer playing.
50
+ */
51
+ export function useLiveBroadcast(broadcastId?: string, passId?: string) {
42
52
  const { client, profileId } = useForge();
43
53
 
44
54
  return useQuery<ILiveBroadcast>({
45
- queryKey: ["live-broadcast", profileId, broadcastId],
55
+ queryKey: ["live-broadcast", profileId, broadcastId, passId ?? null],
46
56
  queryFn: async () => {
47
57
  const res = await client.get(`/public/broadcasts/${broadcastId}`, {
48
- params: { profileId },
58
+ params: { profileId, ...(passId ? { passId } : {}) },
49
59
  });
50
60
  return res.data;
51
61
  },
@@ -61,14 +71,14 @@ export function useLiveBroadcast(broadcastId?: string) {
61
71
  * poll, so a mid-stream edit to the title or the thumbnail cannot swap the
62
72
  * player out from under someone who is watching.
63
73
  */
64
- export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000) {
74
+ export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000, passId?: string) {
65
75
  const { client, profileId } = useForge();
66
76
 
67
77
  return useQuery<ILiveBroadcast>({
68
- queryKey: ["live-broadcast-poll", profileId, broadcastId],
78
+ queryKey: ["live-broadcast-poll", profileId, broadcastId, passId ?? null],
69
79
  queryFn: async () => {
70
80
  const res = await client.get(`/public/broadcasts/${broadcastId}`, {
71
- params: { profileId },
81
+ params: { profileId, ...(passId ? { passId } : {}) },
72
82
  });
73
83
  return res.data;
74
84
  },
@@ -138,6 +148,30 @@ export type BroadcastSubscribeAnswer = {
138
148
  sessionDescription: { sdp: string; type: string };
139
149
  };
140
150
 
151
+ /**
152
+ * A join ticket for a broadcast's live room on the platform's media plane.
153
+ *
154
+ * A superset of what the media SDK reads (`mediaUrl` and `token` are the only
155
+ * two it touches). The rest is what the server says about the ticket it just
156
+ * minted, useful to a site that wants to show it and ignored by the room.
157
+ *
158
+ * Written out here rather than imported from `@tribe-nest/media-client` on
159
+ * purpose: this module is loaded by every page of every site, and the media
160
+ * SDK is browser-only. A type import would be erased, but the next person to
161
+ * reach for a value from that package would not notice they had crossed the
162
+ * line.
163
+ */
164
+ export type BroadcastViewerCredentials = {
165
+ /** The signalling entrypoint. A load balancer, never a node the client picked. */
166
+ mediaUrl: string;
167
+ token: string;
168
+ /** ISO. When THIS ticket dies, not when the broadcast does. */
169
+ expiresAt?: string;
170
+ /** This viewer's identity in the live room. Minted per viewer, never reused. */
171
+ identity?: string;
172
+ roomId?: string;
173
+ };
174
+
141
175
  /**
142
176
  * The imperative half of watching a broadcast: the calls that are made in
143
177
  * response to something happening (a pass validated, a heartbeat, a viewer
@@ -164,18 +198,39 @@ export function useBroadcastSessionApi() {
164
198
  leave: async (broadcastId: string, sessionId?: string): Promise<void> => {
165
199
  await client.post(`/public/broadcasts/${broadcastId}/leave`, { sessionId });
166
200
  },
201
+ /**
202
+ * Mint a viewer credential for the broadcast's live room on the media plane.
203
+ *
204
+ * The PASS is the credential here: the endpoint is public and takes no
205
+ * bearer token, and it validates the pass exactly as `validate-session`
206
+ * does before minting anything for a gated broadcast.
207
+ *
208
+ * It answers 409 when the room is full, when realtime is switched off, or
209
+ * when nothing is live. That is a designed answer rather than a fault, and
210
+ * the caller's whole response to it is to watch the HLS stream instead.
211
+ */
212
+ viewerToken: async (payload: { broadcastId: string; passId?: string }): Promise<BroadcastViewerCredentials> => {
213
+ const res = await client.post(`/streams/v2/public/viewer-token`, payload);
214
+ return res.data;
215
+ },
167
216
  /** Subscribe to the WebRTC tracks of a realtime broadcast. */
168
217
  subscribe: async (payload: {
169
218
  sdp?: string;
170
219
  type?: string;
171
220
  trackIds?: string[];
172
221
  sessionId?: string;
222
+ /** The validated pass. A gated broadcast refuses a subscribe without it. */
223
+ passId?: string;
173
224
  }): Promise<BroadcastSubscribeAnswer> => {
174
225
  const res = await client.post(`/public/broadcasts/subscribe`, payload);
175
226
  return res.data;
176
227
  },
177
228
  /** Pin the viewer to one simulcast layer, or back to auto. */
178
- switchQuality: async (payload: { track: Record<string, unknown>; sessionId?: string }): Promise<void> => {
229
+ switchQuality: async (payload: {
230
+ track: Record<string, unknown>;
231
+ sessionId?: string;
232
+ passId?: string;
233
+ }): Promise<void> => {
179
234
  await client.post(`/public/broadcasts/switch-quality`, payload);
180
235
  },
181
236
  };
package/src/i18n/de.json CHANGED
@@ -159,6 +159,9 @@
159
159
  "forge.broadcast_pass_validation.validating": "Wird geprüft…",
160
160
  "forge.broadcast_player.anonymous": "Anonym",
161
161
  "forge.broadcast_player.chat": "Chat",
162
+ "forge.broadcast_player.fallback_help": "Der Stream mit geringer Verzögerung hat nur begrenzt Plätze und ist gerade entweder voll oder nicht aktiv. Der Standard-Stream zeigt dieselbe Übertragung einige Sekunden später. Sonst ändert sich nichts an der Seite.",
163
+ "forge.broadcast_player.fallback_help_label": "Warum ist das nicht der Stream mit geringer Verzögerung?",
164
+ "forge.broadcast_player.fallback_notice": "Du siehst den Standard-Stream",
162
165
  "forge.broadcast_player.fullscreen": "Vollbild",
163
166
  "forge.broadcast_player.message_label": "Nachricht",
164
167
  "forge.broadcast_player.pinned": "Angepinnt",
@@ -168,6 +171,8 @@
168
171
  "forge.broadcast_player.quality_low": "Niedrig",
169
172
  "forge.broadcast_player.quality_medium": "Mittel",
170
173
  "forge.broadcast_player.send": "Senden",
174
+ "forge.broadcast_player.sound_off": "Ton ausschalten",
175
+ "forge.broadcast_player.sound_on": "Ton einschalten",
171
176
  "forge.broadcast_player.started": "Vor {duration} gestartet",
172
177
  "forge.broadcast_player.watching_now": "{count} schauen gerade zu",
173
178
  "forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
package/src/i18n/en.json CHANGED
@@ -159,6 +159,9 @@
159
159
  "forge.broadcast_pass_validation.validating": "Validating…",
160
160
  "forge.broadcast_player.anonymous": "Anonymous",
161
161
  "forge.broadcast_player.chat": "Chat",
162
+ "forge.broadcast_player.fallback_help": "The low-latency stream holds a limited number of viewers, and right now it is either full or not running. The standard stream carries the same broadcast a few seconds behind. Nothing else about the page changes.",
163
+ "forge.broadcast_player.fallback_help_label": "Why is this not the low-latency stream?",
164
+ "forge.broadcast_player.fallback_notice": "Watching the standard stream",
162
165
  "forge.broadcast_player.fullscreen": "Fullscreen",
163
166
  "forge.broadcast_player.message_label": "Message",
164
167
  "forge.broadcast_player.pinned": "Pinned",
@@ -168,6 +171,8 @@
168
171
  "forge.broadcast_player.quality_low": "Low",
169
172
  "forge.broadcast_player.quality_medium": "Medium",
170
173
  "forge.broadcast_player.send": "Send",
174
+ "forge.broadcast_player.sound_off": "Turn sound off",
175
+ "forge.broadcast_player.sound_on": "Turn sound on",
171
176
  "forge.broadcast_player.started": "Started {duration} ago",
172
177
  "forge.broadcast_player.watching_now": "{count} watching now",
173
178
  "forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
@@ -1709,6 +1709,11 @@ export type ILiveEvent = {
1709
1709
 
1710
1710
  export type ILiveBroadcast = {
1711
1711
  id: string;
1712
+ /**
1713
+ * The v1 realtime path, over Cloudflare Calls. Kept exactly as it is until v1
1714
+ * itself is retired. Not the same thing as `realtime` below, and a broadcast
1715
+ * carries one or the other, never both.
1716
+ */
1712
1717
  realtimeConfig?: {
1713
1718
  sessionId: string;
1714
1719
  tracks: {
@@ -1716,6 +1721,19 @@ export type ILiveBroadcast = {
1716
1721
  trackName: string;
1717
1722
  }[];
1718
1723
  };
1724
+ /**
1725
+ * Can this broadcast be watched over the platform's own media plane?
1726
+ *
1727
+ * `available` says the live room exists and the feature is on, and it says
1728
+ * nothing about whether THIS viewer will get in: the room has a hard cap and
1729
+ * the token endpoint is the only thing that knows how full it is. So it is a
1730
+ * flag to ATTEMPT the plane on, never a promise, and a player that treats it
1731
+ * as one leaves the hundred-and-first viewer on a black rectangle.
1732
+ *
1733
+ * It deliberately carries no room id and no token. The read that returns it
1734
+ * is unauthenticated, so everything in it is public.
1735
+ */
1736
+ realtime?: { available: boolean };
1719
1737
  events: {
1720
1738
  eventId: string;
1721
1739
  eventTitle: string;
@@ -38,8 +38,12 @@ const readSessionId = (broadcastId?: string): string | null => {
38
38
  * be checked without a browser.
39
39
  */
40
40
  export function useBroadcastWatch(broadcastId?: string) {
41
- const broadcastQuery = useLiveBroadcast(broadcastId);
42
- const pollQuery = useLiveBroadcastPoll(broadcastId);
41
+ // The stored pass rides along on both reads: a gated broadcast withholds its
42
+ // playback credentials from a caller who cannot show one, so a read without
43
+ // it comes back playable-looking and empty.
44
+ const storedSessionId = readSessionId(broadcastId) ?? undefined;
45
+ const broadcastQuery = useLiveBroadcast(broadcastId, storedSessionId);
46
+ const pollQuery = useLiveBroadcastPoll(broadcastId, 5000, storedSessionId);
43
47
  const validatePass = useValidateBroadcastPass();
44
48
  const sessionApi = useBroadcastSessionApi();
45
49
 
@@ -73,6 +73,7 @@ const fakeTransport = (): MediaTransport => ({
73
73
  id: `p-local-${produced.length}`,
74
74
  kind: "audio",
75
75
  closed: false,
76
+ replaceTrack: vi.fn(async () => undefined),
76
77
  pause: vi.fn(),
77
78
  resume: vi.fn(),
78
79
  close: vi.fn(),
@@ -0,0 +1,316 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ConnectionState, DisconnectCause, ProducerEntry } from "@tribe-nest/media-client";
4
+
5
+ import {
6
+ broadcastFellBack,
7
+ chooseBroadcastStage,
8
+ programTracks,
9
+ type BroadcastRoomState,
10
+ type BroadcastStageInput,
11
+ } from "../broadcast/broadcastStage";
12
+
13
+ /**
14
+ * Which stage a live broadcast plays on, and how a viewer gets back to a
15
+ * picture when the good one is not available.
16
+ *
17
+ * Tested as PURE FUNCTIONS rather than by rendering. The realtime stage is a
18
+ * mediasoup transport over a WebSocket, and standing that up in jsdom tests the
19
+ * mock rather than the rule: the mock would answer whatever the test wanted, and
20
+ * the thing worth being sure about here is the RULE, which is four independent
21
+ * signals arriving at four different times and every combination of them
22
+ * resolving to a viewer who can see the broadcast.
23
+ *
24
+ * The table below is written out combination by combination on purpose. The
25
+ * failure this guards against is not a wrong answer to one question, it is a
26
+ * gap: a state nobody thought about that falls through to `realtime` and leaves
27
+ * somebody looking at a black rectangle while the show goes on without them.
28
+ */
29
+
30
+ const HLS_ONLY: BroadcastStageInput = {
31
+ realtimeAvailable: false,
32
+ credentialsReady: false,
33
+ credentialsError: null,
34
+ };
35
+
36
+ /** A viewer who is on the plane, connected, with a live room. */
37
+ const CONNECTED: BroadcastStageInput = {
38
+ realtimeAvailable: true,
39
+ credentialsReady: true,
40
+ credentialsError: null,
41
+ roomState: { connectionState: "connected", phase: "joined", recovering: false },
42
+ };
43
+
44
+ const room = (overrides: Partial<BroadcastRoomState> = {}): BroadcastRoomState => ({
45
+ connectionState: "connected",
46
+ phase: "joined",
47
+ recovering: false,
48
+ ...overrides,
49
+ });
50
+
51
+ const on = (roomState: BroadcastRoomState): BroadcastStageInput => ({ ...CONNECTED, roomState });
52
+
53
+ describe("chooseBroadcastStage: what the broadcast offers", () => {
54
+ it("plays HLS when the broadcast does not advertise realtime at all", () => {
55
+ expect(chooseBroadcastStage(HLS_ONLY)).toBe("hls");
56
+ });
57
+
58
+ it("REGRESSION: does not take the plane on the flag alone, before a ticket exists", () => {
59
+ // `realtime.available` says the room is open, never that THIS viewer got in:
60
+ // the cap is enforced by the token endpoint and nothing else knows how full
61
+ // the room is. Mounting on the flag would put the five-hundred-and-first
62
+ // viewer on a stage that is never going to connect.
63
+ //
64
+ // It waits rather than falling to HLS, which is the second half of the
65
+ // same rule: answering "hls" here mounted an HLS engine for the moment the
66
+ // ticket was in flight, and that engine starts fetching segments as soon
67
+ // as it exists, so a realtime viewer downloaded the whole broadcast a
68
+ // second time for a stage about to be thrown away.
69
+ expect(chooseBroadcastStage({ realtimeAvailable: true, credentialsReady: false, credentialsError: null })).toBe(
70
+ "pending",
71
+ );
72
+ });
73
+
74
+ it("never waits on a broadcast that does not offer realtime", () => {
75
+ // `pending` is only ever reachable when there is something to wait FOR.
76
+ // An ordinary broadcast must reach its player on the first render.
77
+ expect(chooseBroadcastStage({ realtimeAvailable: false, credentialsReady: false, credentialsError: null })).toBe(
78
+ "hls",
79
+ );
80
+ });
81
+
82
+ it("takes the plane once the flag and a minted ticket agree", () => {
83
+ expect(chooseBroadcastStage({ realtimeAvailable: true, credentialsReady: true, credentialsError: null })).toBe(
84
+ "realtime",
85
+ );
86
+ });
87
+
88
+ it("stays on HLS when the flag is off even if a ticket somehow exists", () => {
89
+ expect(chooseBroadcastStage({ realtimeAvailable: false, credentialsReady: true, credentialsError: null })).toBe(
90
+ "hls",
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("chooseBroadcastStage: the token endpoint said no", () => {
96
+ /**
97
+ * 409 is the designed refusal (room full, realtime off, nothing live) and it
98
+ * is deliberately not privileged over the others. A viewer refused for any
99
+ * reason watches the same stream, so a rule that treated one status specially
100
+ * would be a branch with no behaviour behind it and one more place to be
101
+ * wrong.
102
+ */
103
+ const REFUSALS = [
104
+ { name: "409, the room is full", error: { status: 409, message: "room is full" } },
105
+ { name: "409, realtime is switched off", error: { status: 409 } },
106
+ { name: "401, the pass was not accepted", error: { status: 401 } },
107
+ { name: "404, no live room for this broadcast", error: { status: 404 } },
108
+ { name: "500, the server fell over", error: { status: 500 } },
109
+ { name: "a network failure with no status at all", error: {} },
110
+ ];
111
+
112
+ for (const refusal of REFUSALS) {
113
+ it(`falls back on ${refusal.name}`, () => {
114
+ const input: BroadcastStageInput = { ...CONNECTED, credentialsError: refusal.error };
115
+ expect(chooseBroadcastStage(input)).toBe("hls");
116
+ expect(broadcastFellBack(input)).toBe(true);
117
+ });
118
+ }
119
+
120
+ it("REGRESSION: a refusal outranks a ticket that was already minted", () => {
121
+ // This is the RECONNECT case. The probe succeeded, the viewer watched, and
122
+ // the ticket minted for the next attempt was refused because the room
123
+ // filled up or ended while they were in it. Reading `credentialsReady`
124
+ // first would keep them on a room they can no longer enter.
125
+ const input: BroadcastStageInput = { ...CONNECTED, credentialsError: { status: 409 } };
126
+ expect(chooseBroadcastStage(input)).toBe("hls");
127
+ });
128
+ });
129
+
130
+ describe("chooseBroadcastStage: what the SDK is doing", () => {
131
+ it("stays on the plane while the first connection is still in flight", () => {
132
+ expect(chooseBroadcastStage(on(room({ connectionState: "idle", phase: "idle" })))).toBe("realtime");
133
+ expect(chooseBroadcastStage(on(room({ connectionState: "connecting", phase: "idle" })))).toBe("realtime");
134
+ });
135
+
136
+ it("stays on the plane while the SDK is genuinely coming back", () => {
137
+ // A blink. Tearing the room down to swap in an HLS player would turn a
138
+ // second of held frame into a restart, and the SDK reconnects on its own.
139
+ const input = on(room({ connectionState: "reconnecting", recovering: true }));
140
+ expect(chooseBroadcastStage(input)).toBe("realtime");
141
+ expect(broadcastFellBack(input)).toBe(false);
142
+ });
143
+
144
+ it("REGRESSION: falls back once the reconnect policy has given up", () => {
145
+ // `connectionState` is `reconnecting` both while an attempt is booked and
146
+ // after the SDK has stopped booking them. Reading it without `recovering`
147
+ // leaves a viewer on a frozen frame for the rest of the broadcast.
148
+ const input = on(room({ connectionState: "reconnecting", recovering: false }));
149
+ expect(chooseBroadcastStage(input)).toBe("hls");
150
+ expect(broadcastFellBack(input)).toBe(true);
151
+ });
152
+
153
+ it("stays on the plane while the node is moving this viewer to another one", () => {
154
+ const draining: DisconnectCause = { type: "draining", reconnectAfterMs: 500 };
155
+ expect(chooseBroadcastStage(on(room({ connectionState: "reconnecting", recovering: true, error: draining })))).toBe(
156
+ "realtime",
157
+ );
158
+ });
159
+
160
+ it("falls back when a drain is NOT being recovered from", () => {
161
+ const draining: DisconnectCause = { type: "draining", reconnectAfterMs: 500 };
162
+ const input = on(room({ connectionState: "reconnecting", recovering: false, error: draining }));
163
+ expect(chooseBroadcastStage(input)).toBe("hls");
164
+ expect(broadcastFellBack(input)).toBe(true);
165
+ });
166
+
167
+ it("falls back when the node refuses the token, however hopeful the SDK is", () => {
168
+ // A refusal is not a network problem and retrying it changes nothing, so
169
+ // `recovering` does not rescue it.
170
+ const refused: DisconnectCause = { type: "refused", code: "capacity", message: "room is full" };
171
+ const input = on(room({ connectionState: "reconnecting", recovering: true, error: refused }));
172
+ expect(chooseBroadcastStage(input)).toBe("hls");
173
+ expect(broadcastFellBack(input)).toBe(true);
174
+ });
175
+
176
+ it("falls back when the room closes under a socket that is still open", () => {
177
+ // The broadcast ended. `connectionState` is still `connected` for as long
178
+ // as it takes the node to hang up, so reading only the connection would
179
+ // hold a viewer on a room that has finished.
180
+ const input = on(room({ connectionState: "connected", phase: "closed" }));
181
+ expect(chooseBroadcastStage(input)).toBe("hls");
182
+ expect(broadcastFellBack(input)).toBe(true);
183
+ });
184
+
185
+ it("falls back when the room close arrives as a disconnect cause", () => {
186
+ const closed: DisconnectCause = { type: "room_closed", reason: "broadcast ended" };
187
+ const input = on(room({ connectionState: "reconnecting", recovering: true, error: closed }));
188
+ expect(chooseBroadcastStage(input)).toBe("hls");
189
+ });
190
+
191
+ it("falls back when the connection is closed for good", () => {
192
+ const input = on(room({ connectionState: "closed" }));
193
+ expect(chooseBroadcastStage(input)).toBe("hls");
194
+ expect(broadcastFellBack(input)).toBe(true);
195
+ });
196
+
197
+ it("covers every connection state the SDK can report", () => {
198
+ // A state added to the SDK and forgotten here is the gap this guards: the
199
+ // list is exhaustive by construction, so a new one breaks the type rather
200
+ // than falling through to `realtime` unnoticed.
201
+ const expected: Record<ConnectionState, "realtime" | "hls"> = {
202
+ idle: "realtime",
203
+ connecting: "realtime",
204
+ connected: "realtime",
205
+ // With nothing booked. The recovering half is asserted above.
206
+ reconnecting: "hls",
207
+ closed: "hls",
208
+ };
209
+
210
+ for (const [connectionState, stage] of Object.entries(expected)) {
211
+ expect(chooseBroadcastStage(on(room({ connectionState: connectionState as ConnectionState })))).toBe(stage);
212
+ }
213
+ });
214
+ });
215
+
216
+ describe("broadcastFellBack: what the viewer is told", () => {
217
+ it("says nothing on a broadcast that never offered realtime", () => {
218
+ // Most broadcasts. A line saying "watching the standard stream" on every
219
+ // ordinary stream teaches viewers to ignore the one time it matters.
220
+ expect(broadcastFellBack(HLS_ONLY)).toBe(false);
221
+ });
222
+
223
+ it("says nothing while the ticket is still in flight", () => {
224
+ // It resolves in a moment. Announcing a fallback that is about to be
225
+ // withdrawn is a line that flickers on every single page load.
226
+ expect(broadcastFellBack({ realtimeAvailable: true, credentialsReady: false, credentialsError: null })).toBe(false);
227
+ });
228
+
229
+ it("says nothing while the viewer is actually on the plane", () => {
230
+ expect(broadcastFellBack(CONNECTED)).toBe(false);
231
+ });
232
+
233
+ it("never claims a fallback the stage did not actually make", () => {
234
+ // The two functions have to agree, because the line explains the stage. A
235
+ // notice over a realtime stage is nonsense, and a silent fallback is a
236
+ // viewer wondering why the chat is ahead of the picture.
237
+ const cases: BroadcastStageInput[] = [
238
+ HLS_ONLY,
239
+ CONNECTED,
240
+ { realtimeAvailable: true, credentialsReady: false, credentialsError: null },
241
+ { ...CONNECTED, credentialsError: { status: 409 } },
242
+ on(room({ connectionState: "reconnecting", recovering: false })),
243
+ on(room({ connectionState: "reconnecting", recovering: true })),
244
+ on(room({ phase: "closed" })),
245
+ on(room({ connectionState: "closed" })),
246
+ ];
247
+
248
+ for (const input of cases) {
249
+ if (broadcastFellBack(input)) expect(chooseBroadcastStage(input)).toBe("hls");
250
+ }
251
+ });
252
+ });
253
+
254
+ describe("programTracks", () => {
255
+ const producer = (overrides: Partial<ProducerEntry> & Pick<ProducerEntry, "producerId" | "kind">): ProducerEntry => ({
256
+ identity: "program:b1",
257
+ paused: false,
258
+ ...overrides,
259
+ });
260
+
261
+ it("finds nothing before the node has announced anything", () => {
262
+ expect(programTracks([])).toEqual({ videoPaused: false, audioProducerIds: [] });
263
+ });
264
+
265
+ it("pairs the program's video with its audio", () => {
266
+ const tracks = programTracks([
267
+ producer({ producerId: "v1", kind: "video" }),
268
+ producer({ producerId: "a1", kind: "audio" }),
269
+ ]);
270
+ expect(tracks.videoProducerId).toBe("v1");
271
+ expect(tracks.audioProducerIds).toEqual(["a1"]);
272
+ });
273
+
274
+ it("REGRESSION: does not lose the audio when the video arrives second", () => {
275
+ // `producerAppeared` frames have no ordering guarantee, so a rule that
276
+ // depended on the video being first would produce a silent broadcast for
277
+ // whichever half of viewers got the frames the other way round.
278
+ const tracks = programTracks([
279
+ producer({ producerId: "a1", kind: "audio" }),
280
+ producer({ producerId: "v1", kind: "video" }),
281
+ ]);
282
+ expect(tracks.videoProducerId).toBe("v1");
283
+ expect(tracks.audioProducerIds).toEqual(["a1"]);
284
+ });
285
+
286
+ it("keeps the FIRST video when a republish briefly announces two", () => {
287
+ // Picking the newer one would swap the element's stream under a viewer
288
+ // mid-sentence, for a producer that is about to disappear again.
289
+ const tracks = programTracks([
290
+ producer({ producerId: "v1", kind: "video" }),
291
+ producer({ producerId: "v2", kind: "video" }),
292
+ ]);
293
+ expect(tracks.videoProducerId).toBe("v1");
294
+ });
295
+
296
+ it("reports a video the publisher paused at source rather than hiding it", () => {
297
+ const tracks = programTracks([producer({ producerId: "v1", kind: "video", paused: true })]);
298
+ expect(tracks.videoProducerId).toBe("v1");
299
+ expect(tracks.videoPaused).toBe(true);
300
+ });
301
+
302
+ it("plays ONE soundtrack when the room briefly holds two audio producers", () => {
303
+ // A guard rather than a fix for anything observed: a republish after a
304
+ // reconnect can overlap the old producer's teardown, and two soundtracks
305
+ // is never the right answer, so the newer publication wins.
306
+ //
307
+ // The doubled audio that was actually reported came from the player
308
+ // mounting the HLS stage while the ticket was in flight, which is pinned
309
+ // by the `pending` cases above.
310
+ const tracks = programTracks([
311
+ producer({ producerId: "a1", kind: "audio" }),
312
+ producer({ producerId: "a2", kind: "audio" }),
313
+ ]);
314
+ expect(tracks.audioProducerIds).toEqual(["a2"]);
315
+ });
316
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { dirname, join, relative } from "path";
4
+
5
+ /**
6
+ * Nothing on the `./ui` entry's module graph may pull in the media SDK.
7
+ *
8
+ * Forge renders inside TanStack Start on Cloudflare Workers, and `./ui` is the
9
+ * entry EVERY site loads on EVERY page, server side included.
10
+ * `@tribe-nest/media-client` (through `mediasoup-client`) touches
11
+ * `RTCPeerConnection` at module scope and does not exist on a server, so one
12
+ * static import anywhere in this graph takes down the server render of every
13
+ * page of every site, including the sites that have never held a broadcast.
14
+ *
15
+ * That is not a hypothetical shape. The graph already dodges it once on
16
+ * purpose: `AccountDashboard` decides whether to draw a Join button by
17
+ * importing `media/bookingWindow`, a module whose own docblock says it imports
18
+ * nothing from the SDK for exactly this reason, rather than the neighbouring
19
+ * `media/index.ts` that does. `BroadcastPlayer` now dodges it a second time, by
20
+ * reaching its realtime stage through a dynamic `import()` fired from an
21
+ * effect. Both are one careless auto-import away from being undone, and neither
22
+ * fails a type check, a lint or any other spec when it is.
23
+ *
24
+ * `./media` is the separate entry that a site opts into (`BookingCallProvider`,
25
+ * `CallStage`), and it may import the SDK freely. This walk never enters it
26
+ * except through the files `./ui` actually names.
27
+ *
28
+ * A dynamic `import()` is deliberately NOT followed. It is the mechanism this
29
+ * guards, and following it would report the very thing that makes the player
30
+ * safe as the defect.
31
+ */
32
+
33
+ /** `src`, three levels up from `src/ui/styled/_tests`. */
34
+ const SRC = join(__dirname, "..", "..", "..");
35
+
36
+ /** Packages that cannot be loaded on a server, whatever the bundler does. */
37
+ const BROWSER_ONLY = ["@tribe-nest/media-client", "mediasoup-client"];
38
+
39
+ /**
40
+ * Static imports only, and whether the compiler erases them.
41
+ *
42
+ * `import type ...` is gone before a bundler ever sees it, so it is free: the
43
+ * whole realtime path is typed against the SDK and none of that types reaches a
44
+ * server. Only the leading `import type` form counts as erased here. The
45
+ * per-specifier `import { type X }` form is erased too, but reading it
46
+ * correctly means parsing the clause, and being wrong in THAT direction is a
47
+ * guard that waves a real edge through. So it is treated as a value edge, which
48
+ * costs a walk of a few more files and nothing else.
49
+ */
50
+ const STATIC_IMPORT = /(?:^|\n)[ \t]*(?:import|export)\s+(type\s+)?[^;]*?\bfrom\s*["']([^"']+)["']/g;
51
+ const SIDE_EFFECT_IMPORT = /(?:^|\n)[ \t]*import\s*["']([^"']+)["']/g;
52
+
53
+ type Edge = { specifier: string; typeOnly: boolean };
54
+
55
+ function edgesIn(contents: string): Edge[] {
56
+ const edges: Edge[] = [];
57
+ for (const match of contents.matchAll(STATIC_IMPORT)) {
58
+ edges.push({ specifier: match[2]!, typeOnly: !!match[1] });
59
+ }
60
+ for (const match of contents.matchAll(SIDE_EFFECT_IMPORT)) {
61
+ edges.push({ specifier: match[1]!, typeOnly: false });
62
+ }
63
+ return edges;
64
+ }
65
+
66
+ /** `./x` to a real file, trying the extensions a bundler would try. */
67
+ function resolveRelative(fromFile: string, specifier: string): string | null {
68
+ const base = join(dirname(fromFile), specifier);
69
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
70
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Every file reachable from an entry by STATIC value imports, and how. */
76
+ function valueGraph(entry: string): Map<string, string[]> {
77
+ const paths = new Map<string, string[]>([[entry, [relative(SRC, entry)]]]);
78
+ const queue = [entry];
79
+
80
+ while (queue.length > 0) {
81
+ const file = queue.shift()!;
82
+ const trail = paths.get(file)!;
83
+ for (const edge of edgesIn(readFileSync(file, "utf-8"))) {
84
+ if (edge.typeOnly) continue;
85
+ if (!edge.specifier.startsWith(".")) continue;
86
+ const resolved = resolveRelative(file, edge.specifier);
87
+ if (!resolved || paths.has(resolved)) continue;
88
+ paths.set(resolved, [...trail, relative(SRC, resolved)]);
89
+ queue.push(resolved);
90
+ }
91
+ }
92
+ return paths;
93
+ }
94
+
95
+ describe("the ./ui entry stays loadable on a server", () => {
96
+ const graph = valueGraph(join(SRC, "ui", "index.ts"));
97
+
98
+ it("reaches a real graph, so a pass here is not vacuous", () => {
99
+ expect(graph.size).toBeGreaterThan(50);
100
+ expect([...graph.keys()].some((file) => file.endsWith("BroadcastPlayer.tsx"))).toBe(true);
101
+ });
102
+
103
+ it("REGRESSION: imports no browser-only package anywhere on it", () => {
104
+ const offences: string[] = [];
105
+
106
+ for (const [file, trail] of graph) {
107
+ for (const edge of edgesIn(readFileSync(file, "utf-8"))) {
108
+ if (edge.typeOnly) continue;
109
+ if (!BROWSER_ONLY.some((name) => edge.specifier === name || edge.specifier.startsWith(`${name}/`))) continue;
110
+ offences.push(`${edge.specifier} via ${trail.join(" -> ")}`);
111
+ }
112
+ }
113
+
114
+ expect(offences).toEqual([]);
115
+ });
116
+
117
+ it("REGRESSION: keeps the realtime stage behind a dynamic import", () => {
118
+ // The player must never name this module in a static value import. It is
119
+ // the one file under `styled/` that loads the SDK, and the `import()` in
120
+ // the effect is what keeps it off every server render.
121
+ const player = readFileSync(join(SRC, "ui", "styled", "broadcast", "BroadcastPlayer.tsx"), "utf-8");
122
+ expect(player).toContain('import("./BroadcastRealtimeStage")');
123
+
124
+ const staticEdges = edgesIn(player).filter((edge) => edge.specifier.includes("BroadcastRealtimeStage"));
125
+ expect(staticEdges.every((edge) => edge.typeOnly)).toBe(true);
126
+ });
127
+ });