@tribe-nest/forge 3.37.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/headless/work/index.ts +4 -0
- package/src/ui/headless/work/useWorkPortal.ts +125 -10
- 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/src/ui/styled/work/WorkProjectDocuments.tsx +224 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +5 -3
- package/src/ui/styled/work/index.ts +1 -0
|
@@ -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
|
+
});
|