@tribe-nest/forge 3.41.1 → 3.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/_tests/publishedResolvability.spec.ts +81 -3
- package/src/data/queries/useAuthActions.ts +15 -2
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -2
- package/src/ui/styled/_tests/broadcastStage.spec.ts +41 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +124 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +239 -16
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +6 -3
- package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +38 -2
- package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
- package/src/ui/styled/broadcast/relayReachable.ts +47 -0
- package/src/ui/styled/broadcast/vendor/moq-watch.bundle.d.ts +12 -0
- package/src/ui/styled/broadcast/vendor/moq-watch.bundle.js +549 -0
- package/src/ui/styled/forge-utilities.css +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.42.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@stripe/react-stripe-js": "^3.8.0",
|
|
32
32
|
"@stripe/stripe-js": "^7.6.1",
|
|
33
33
|
"@tanstack/react-query": "^5.62.7",
|
|
34
|
-
"@tribe-nest/media-client": "^0.1
|
|
34
|
+
"@tribe-nest/media-client": "^0.2.1",
|
|
35
35
|
"@tribe-nest/media-protocol": "^1.0.0",
|
|
36
36
|
"axios": "^1.7.9",
|
|
37
37
|
"lucide-react": "^0.510.0",
|
|
@@ -30,12 +30,23 @@ import { join } from "path";
|
|
|
30
30
|
* creator site, while the whole suite stayed green and the starter's release
|
|
31
31
|
* notes announced the feature.
|
|
32
32
|
*
|
|
33
|
-
* So there are
|
|
33
|
+
* So there are three properties here and they are separate:
|
|
34
34
|
*
|
|
35
35
|
* 1. every bare import in the published tree names a package the consumer's
|
|
36
36
|
* install will actually produce (a dependency, or a peer that npm installs
|
|
37
|
-
* because it is NOT marked optional),
|
|
38
|
-
* 2. every workspace package Forge depends on is one npm will publish
|
|
37
|
+
* because it is NOT marked optional),
|
|
38
|
+
* 2. every workspace package Forge depends on is one npm will publish, and
|
|
39
|
+
* 3. the RANGE Forge declares for a workspace package accepts the version that
|
|
40
|
+
* package is actually at.
|
|
41
|
+
*
|
|
42
|
+
* The third failed too, and silently. Forge went on declaring
|
|
43
|
+
* `"@tribe-nest/media-client": "^0.1.0"` after that package moved to `0.2.0`,
|
|
44
|
+
* and `^0.1.0` does not accept `0.2.x`. Inside the monorepo nothing shows it -
|
|
45
|
+
* the symlink means every build, every spec and every `check-types` run sees
|
|
46
|
+
* the 0.2 source - while a creator site resolved 0.1.1 from npm and got a
|
|
47
|
+
* `useRemoteTrack` that never detached a media element, so a remount left an
|
|
48
|
+
* orphan still playing the call's audio. The published surface was a minor
|
|
49
|
+
* version behind the surface the tests were green against.
|
|
39
50
|
*
|
|
40
51
|
* Specs are excluded: they never ship (`files` is `["src"]`, but a consumer
|
|
41
52
|
* never imports a `.spec` file), and they legitimately reach for vitest, jsdom
|
|
@@ -47,6 +58,7 @@ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
|
|
|
47
58
|
|
|
48
59
|
type PackageJson = {
|
|
49
60
|
name: string;
|
|
61
|
+
version?: string;
|
|
50
62
|
private?: boolean;
|
|
51
63
|
files?: string[];
|
|
52
64
|
exports?: Record<string, unknown>;
|
|
@@ -181,4 +193,70 @@ describe("what a creator site's install has to produce", () => {
|
|
|
181
193
|
it("still exports the media entry the Forge manifest advertises", () => {
|
|
182
194
|
expect(forge.exports?.["./media"]).toBe("./src/ui/media/index.ts");
|
|
183
195
|
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The declared range has to accept the version the package is AT.
|
|
199
|
+
*
|
|
200
|
+
* Deliberately hand-rolled rather than reaching for `semver`: this package
|
|
201
|
+
* declares its dependencies honestly (that is the whole subject of this
|
|
202
|
+
* file), and `semver` is only present here as somebody else's transitive.
|
|
203
|
+
* The cost is that only the range forms this repo actually uses are
|
|
204
|
+
* understood - anything else fails loudly rather than passing vacuously,
|
|
205
|
+
* which is the right way round for a guard.
|
|
206
|
+
*/
|
|
207
|
+
const satisfies = (range: string, version: string): boolean => {
|
|
208
|
+
const parse = (value: string): [number, number, number] => {
|
|
209
|
+
const parts = value.split(".").map((n) => Number.parseInt(n, 10));
|
|
210
|
+
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
|
|
211
|
+
throw new Error(`not a plain semver version: ${value}`);
|
|
212
|
+
}
|
|
213
|
+
return [parts[0]!, parts[1]!, parts[2]!];
|
|
214
|
+
};
|
|
215
|
+
const atLeast = (a: [number, number, number], b: [number, number, number]) =>
|
|
216
|
+
a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] >= b[2];
|
|
217
|
+
|
|
218
|
+
const [major, minor, patch] = parse(version);
|
|
219
|
+
if (range === "*") return true;
|
|
220
|
+
if (/^\d/.test(range)) return range === version; // exact pin
|
|
221
|
+
if (range.startsWith("^")) {
|
|
222
|
+
const floor = parse(range.slice(1));
|
|
223
|
+
if (!atLeast([major, minor, patch], floor)) return false;
|
|
224
|
+
// Caret on a 0.x version pins the MINOR, which is where a pre-1.0
|
|
225
|
+
// package puts its breaking changes. `^0.1.0` does not accept `0.2.0`,
|
|
226
|
+
// and that is exactly the case this test exists for.
|
|
227
|
+
return floor[0] === 0 ? minor === floor[1] : major === floor[0];
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`unsupported range form "${range}" - teach this helper before using it`);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
it("declares a range that accepts each workspace package's current version", () => {
|
|
233
|
+
const workspaceDeps = Object.entries(forge.dependencies ?? {}).filter(([name]) =>
|
|
234
|
+
name.startsWith("@tribe-nest/"),
|
|
235
|
+
);
|
|
236
|
+
expect(workspaceDeps.length).toBeGreaterThan(0);
|
|
237
|
+
|
|
238
|
+
const stale = workspaceDeps
|
|
239
|
+
.map(([name, range]) => {
|
|
240
|
+
const dependency = readPackage(join(PACKAGES_ROOT, name.replace("@tribe-nest/", "")));
|
|
241
|
+
return { name, range, version: dependency.version };
|
|
242
|
+
})
|
|
243
|
+
.filter(({ range, version }) => !satisfies(range, version!))
|
|
244
|
+
.map(({ name, range, version }) => `${name}: declared ${range}, package is at ${version}`);
|
|
245
|
+
|
|
246
|
+
expect(stale).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("the range check itself knows that a 0.x caret pins the minor", () => {
|
|
250
|
+
// Without this the test above is only as good as a helper nobody checked,
|
|
251
|
+
// and the bug it guards is precisely a 0.x caret being read as permissive.
|
|
252
|
+
expect(satisfies("^0.1.0", "0.1.1")).toBe(true);
|
|
253
|
+
expect(satisfies("^0.1.0", "0.2.0")).toBe(false);
|
|
254
|
+
expect(satisfies("^0.2.1", "0.2.1")).toBe(true);
|
|
255
|
+
expect(satisfies("^0.2.1", "0.2.0")).toBe(false);
|
|
256
|
+
expect(satisfies("^1.0.0", "1.0.1")).toBe(true);
|
|
257
|
+
expect(satisfies("^1.0.0", "2.0.0")).toBe(false);
|
|
258
|
+
expect(satisfies("1.0.1", "1.0.1")).toBe(true);
|
|
259
|
+
expect(satisfies("1.0.1", "1.0.2")).toBe(false);
|
|
260
|
+
expect(() => satisfies(">=1.0.0", "1.0.0")).toThrow();
|
|
261
|
+
});
|
|
184
262
|
});
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { useForge } from "../../provider/ForgeProvider";
|
|
2
2
|
import { useMutation } from "@tanstack/react-query";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Where a Forge site's reset-password page lives.
|
|
6
|
+
*
|
|
7
|
+
* Every functional page on a code website is under `/i/`, and the emailed link
|
|
8
|
+
* is built from whatever the site sends here.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_RESET_PASSWORD_PATH = "/i/reset-password";
|
|
11
|
+
|
|
4
12
|
// Stateless account actions (no session state) — kept out of PublicAuthContext.
|
|
5
13
|
//
|
|
6
14
|
// ## Why these hit `/public/sessions/*` and not `/public/accounts/*`
|
|
@@ -33,8 +41,8 @@ import { useMutation } from "@tanstack/react-query";
|
|
|
33
41
|
export function useForgotPassword() {
|
|
34
42
|
const { client, profileId } = useForge();
|
|
35
43
|
|
|
36
|
-
return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string }>({
|
|
37
|
-
mutationFn: async ({ email, origin, profileId: overrideProfileId }) => {
|
|
44
|
+
return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string; resetPath?: string }>({
|
|
45
|
+
mutationFn: async ({ email, origin, profileId: overrideProfileId, resetPath }) => {
|
|
38
46
|
const contextId = overrideProfileId ?? profileId;
|
|
39
47
|
if (!contextId) {
|
|
40
48
|
// Better than posting without it: the endpoint would 400 on schema
|
|
@@ -47,6 +55,11 @@ export function useForgotPassword() {
|
|
|
47
55
|
// The reset link is built server-side from this, and the association
|
|
48
56
|
// endpoint requires it (the global one treated it as optional).
|
|
49
57
|
origin: origin ?? (typeof window !== "undefined" ? window.location.origin : undefined),
|
|
58
|
+
// And the PATH, because the backend cannot guess it. Its default is
|
|
59
|
+
// `apps/client`'s `/reset-password`, which does not exist on a code
|
|
60
|
+
// website - every functional page there is under `/i/`, so the emailed
|
|
61
|
+
// link 404'd for everyone on the current default storefront.
|
|
62
|
+
resetPath: resetPath ?? DEFAULT_RESET_PASSWORD_PATH,
|
|
50
63
|
});
|
|
51
64
|
return res.data;
|
|
52
65
|
},
|
|
@@ -6,12 +6,18 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
6
6
|
export interface ForgotPasswordFormProps {
|
|
7
7
|
/** Href for the "Back to login" / "Login" links. */
|
|
8
8
|
loginHref: string;
|
|
9
|
+
/**
|
|
10
|
+
* The path the emailed reset link points at, on this site's own origin.
|
|
11
|
+
* Defaults to `/i/reset-password`, which is where the starter puts it. Pass
|
|
12
|
+
* one only if the site moved that route.
|
|
13
|
+
*/
|
|
14
|
+
resetPath?: string;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
12
18
|
|
|
13
19
|
/** Themed forgot-password request form, built on `useForgotPassword`. */
|
|
14
|
-
export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
20
|
+
export function ForgotPasswordForm({ loginHref, resetPath }: ForgotPasswordFormProps) {
|
|
15
21
|
const t = useForgeT();
|
|
16
22
|
const theme = useForgeTheme();
|
|
17
23
|
const forgotPassword = useForgotPassword();
|
|
@@ -50,7 +56,11 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
50
56
|
setError("");
|
|
51
57
|
setIsSubmitting(true);
|
|
52
58
|
try {
|
|
53
|
-
await forgotPassword.mutateAsync({
|
|
59
|
+
await forgotPassword.mutateAsync({
|
|
60
|
+
email,
|
|
61
|
+
origin: window.location.origin,
|
|
62
|
+
...(resetPath ? { resetPath } : {}),
|
|
63
|
+
});
|
|
54
64
|
setIsSuccess(true);
|
|
55
65
|
} catch (err) {
|
|
56
66
|
setError(msg(err) || t("forge.forgot_password_form.error"));
|
|
@@ -314,3 +314,44 @@ describe("programTracks", () => {
|
|
|
314
314
|
expect(tracks.audioProducerIds).toEqual(["a2"]);
|
|
315
315
|
});
|
|
316
316
|
});
|
|
317
|
+
|
|
318
|
+
describe("the relay lane", () => {
|
|
319
|
+
const base = {
|
|
320
|
+
realtimeAvailable: true,
|
|
321
|
+
credentialsReady: true,
|
|
322
|
+
credentialsError: null,
|
|
323
|
+
} as const;
|
|
324
|
+
|
|
325
|
+
it("prefers the relay over the plane when both are offered", () => {
|
|
326
|
+
expect(chooseBroadcastStage({ ...base, moqAvailable: true })).toBe("moq");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("is not a fallback: a working relay is the best lane there is", () => {
|
|
330
|
+
expect(broadcastFellBack({ ...base, moqAvailable: true })).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("falls to the plane when the relay lane is over and a plane is offered", () => {
|
|
334
|
+
expect(chooseBroadcastStage({ ...base, moqAvailable: true, moqFailed: true })).toBe("realtime");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// The shape of a relay-only instance: `viewer-token` answered with `moq` and
|
|
338
|
+
// `credentials: null`. Falling to "realtime" here would mount a room that
|
|
339
|
+
// does not exist and show a black rectangle for ever.
|
|
340
|
+
it("falls to hls when the relay is over and there is no plane behind it", () => {
|
|
341
|
+
expect(
|
|
342
|
+
chooseBroadcastStage({ ...base, moqAvailable: true, moqFailed: true, planeAvailable: false }),
|
|
343
|
+
).toBe("hls");
|
|
344
|
+
expect(
|
|
345
|
+
broadcastFellBack({ ...base, moqAvailable: true, moqFailed: true, planeAvailable: false }),
|
|
346
|
+
).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("still draws nothing while the ticket is in flight", () => {
|
|
350
|
+
expect(chooseBroadcastStage({ ...base, credentialsReady: false, moqAvailable: true })).toBe("pending");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("leaves every existing caller unchanged", () => {
|
|
354
|
+
expect(chooseBroadcastStage(base)).toBe("realtime");
|
|
355
|
+
expect(chooseBroadcastStage({ ...base, realtimeAvailable: false })).toBe("hls");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The relay stage: a broadcast watched over MoQ.
|
|
5
|
+
*
|
|
6
|
+
* The player is a VENDORED bundle (`vendor/moq-watch.bundle.js`), not an npm
|
|
7
|
+
* dependency, because Forge ships source: its `exports` point at `./src/*.ts`
|
|
8
|
+
* with no build step, so anything it depends on is something every creator site
|
|
9
|
+
* has to resolve too, and `@moq/json` peer-depends on zod 4 against this repo's
|
|
10
|
+
* zod 3. See `scripts/build-moq-player.mjs`.
|
|
11
|
+
*
|
|
12
|
+
* Loaded LAZILY by the player, never imported on this file's module path,
|
|
13
|
+
* for the same reason `BroadcastRealtimeStage` is: Forge renders inside
|
|
14
|
+
* TanStack Start on Cloudflare Workers, and `@moq/watch` reaches for
|
|
15
|
+
* WebTransport, WebCodecs and a decoder worker the moment it is evaluated.
|
|
16
|
+
* An effect is code no server ever runs.
|
|
17
|
+
*
|
|
18
|
+
* The element is created imperatively rather than written as JSX because it is
|
|
19
|
+
* a custom element: JSX would need an ambient declaration in every consuming
|
|
20
|
+
* app, and `document.createElement` needs none.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type BroadcastMoqStageProps = {
|
|
24
|
+
/** The relay URL with the viewer ticket already on it. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** The broadcast name beneath the ticket's root. */
|
|
27
|
+
name: string;
|
|
28
|
+
muted: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The lane is over: the player should fall back.
|
|
31
|
+
*
|
|
32
|
+
* Reported upward rather than handled here for the same reason the plane
|
|
33
|
+
* stage reports its room state upward: falling back means unmounting this
|
|
34
|
+
* component, and a component cannot unmount itself. There is one
|
|
35
|
+
* `chooseBroadcastStage` and it lives above.
|
|
36
|
+
*/
|
|
37
|
+
onFailed: (reason: string) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function BroadcastMoqStage({ url, name, muted, onFailed }: BroadcastMoqStageProps) {
|
|
41
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
// Held in a ref so the effect below does not re-run when the player
|
|
43
|
+
// re-renders with a new closure, which would tear down a working player.
|
|
44
|
+
const onFailedRef = useRef(onFailed);
|
|
45
|
+
onFailedRef.current = onFailed;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const host = hostRef.current;
|
|
49
|
+
if (!host) return;
|
|
50
|
+
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
let element: HTMLElement | undefined;
|
|
53
|
+
|
|
54
|
+
const mount = async () => {
|
|
55
|
+
try {
|
|
56
|
+
// Imported for its side effect: the module registers <moq-watch>.
|
|
57
|
+
await import("./vendor/moq-watch.bundle.js");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The chunk did not load at all. Identical in consequence to a refused
|
|
60
|
+
// ticket, and it falls back the same way.
|
|
61
|
+
onFailedRef.current(`moq player failed to load: ${(error as Error).message}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (cancelled) return;
|
|
65
|
+
|
|
66
|
+
element = document.createElement("moq-watch");
|
|
67
|
+
element.setAttribute("url", url);
|
|
68
|
+
element.setAttribute("name", name);
|
|
69
|
+
if (muted) element.setAttribute("muted", "");
|
|
70
|
+
/**
|
|
71
|
+
* `always`, not the default.
|
|
72
|
+
*
|
|
73
|
+
* The default is a visibility THRESHOLD ("20%") driven by an
|
|
74
|
+
* IntersectionObserver, and until it is met the renderer reports itself
|
|
75
|
+
* hidden, which disables the decoder, which never subscribes to the video
|
|
76
|
+
* track. A player inside a tab that has not been scrolled, or measured
|
|
77
|
+
* before layout settles, shows black for ever rather than late.
|
|
78
|
+
*/
|
|
79
|
+
element.setAttribute("visible", "always");
|
|
80
|
+
element.style.width = "100%";
|
|
81
|
+
element.style.height = "100%";
|
|
82
|
+
element.style.display = "block";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The nested canvas the renderer paints into, and it is REQUIRED.
|
|
86
|
+
*
|
|
87
|
+
* `<moq-watch>` finds its render target by querying its own children. With
|
|
88
|
+
* no canvas there is no target, so the renderer is never visible, so
|
|
89
|
+
* `video.in.enabled` stays false and the decoder never subscribes. Every
|
|
90
|
+
* symptom of this is silent: the connection is established, the catalog
|
|
91
|
+
* arrives with the video rendition, the config resolves, and the relay
|
|
92
|
+
* shows only a `catalog.json` subscription and never a `video` one.
|
|
93
|
+
*/
|
|
94
|
+
const canvas = document.createElement("canvas");
|
|
95
|
+
canvas.style.width = "100%";
|
|
96
|
+
canvas.style.height = "100%";
|
|
97
|
+
canvas.style.display = "block";
|
|
98
|
+
element.appendChild(canvas);
|
|
99
|
+
|
|
100
|
+
// A relay that refuses the ticket, or a broadcast that is not there,
|
|
101
|
+
// surfaces as an error event rather than a throw: the element owns its
|
|
102
|
+
// own reconnect loop and never rejects the caller.
|
|
103
|
+
element.addEventListener("error", () => {
|
|
104
|
+
onFailedRef.current("the relay connection failed");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
host.replaceChildren(element);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
void mount();
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
// `replaceChildren()` disconnects the element, and its
|
|
115
|
+
// `disconnectedCallback` closes the connection and the decoders. Leaving
|
|
116
|
+
// it attached would keep a QUIC session and a decode pipeline alive for a
|
|
117
|
+
// stage the viewer has already left.
|
|
118
|
+
host.replaceChildren();
|
|
119
|
+
element = undefined;
|
|
120
|
+
};
|
|
121
|
+
}, [url, name, muted]);
|
|
122
|
+
|
|
123
|
+
return <div ref={hostRef} className="h-full w-full bg-black" />;
|
|
124
|
+
}
|