@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 +1 -1
- package/src/data/queries/useBroadcasts.ts +62 -7
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/types/models.ts +18 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +6 -2
- package/src/ui/media/_tests/CallStage.spec.tsx +1 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +316 -0
- package/src/ui/styled/_tests/uiEntryServerSafety.spec.ts +127 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +233 -14
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +260 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +189 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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",
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
|
|
@@ -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
|
+
});
|