@tribe-nest/forge 3.52.0 → 3.54.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/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useEvents.ts +14 -0
- package/src/data/queries/useMyCourses.ts +42 -0
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useWebsiteAgent.ts +2 -0
- package/src/i18n/de.json +47 -0
- package/src/i18n/en.json +47 -0
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +23 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +92 -0
- package/src/types/models.ts +58 -0
- package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
- package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
- package/src/ui/headless/agent/pathExclusion.ts +53 -0
- package/src/ui/headless/agent/useAiAgent.ts +10 -1
- package/src/ui/headless/agent/useCurrentPath.ts +49 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
- package/src/ui/payment/ForgeStripePayment.tsx +37 -3
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +181 -14
- package/src/ui/styled/AiAgentWidget.tsx +3 -2
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType } from "react";
|
|
2
|
+
import { missingRuntimeKeys } from "./registry";
|
|
3
|
+
import { REQUIRED_RUNTIME_KEYS } from "./hostRuntime";
|
|
4
|
+
import type { RemotePageResult } from "./pagesClient";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A hosted `/i/*` page, inside the creator's own shell.
|
|
8
|
+
*
|
|
9
|
+
* Two halves arrive from different places. The markup was rendered by the
|
|
10
|
+
* central pages Worker and comes down with the document, so a crawler and a
|
|
11
|
+
* link preview see a complete page. The interactive version comes from the
|
|
12
|
+
* central browser bundle, which loads after paint and takes over.
|
|
13
|
+
*
|
|
14
|
+
* The takeover is a REPLACEMENT, not a hydration. The bundle hands back an
|
|
15
|
+
* ordinary component and it renders inside this tree, under the host's
|
|
16
|
+
* providers, so there is one React root and one of everything else. Hydrating
|
|
17
|
+
* across two independently built bundles would mean matching markup exactly
|
|
18
|
+
* between two Reacts, which is the brittleness this design exists to avoid.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type HostedPageProps = { params: Record<string, string>; search: URLSearchParams; data: unknown };
|
|
22
|
+
|
|
23
|
+
type PagesBundle = {
|
|
24
|
+
getPage(path: string): ComponentType<HostedPageProps> | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Bundles already fetched, by URL, so a second page view does not refetch. */
|
|
28
|
+
const loaded = new Map<string, Promise<PagesBundle>>();
|
|
29
|
+
|
|
30
|
+
function loadBundle(url: string): Promise<PagesBundle> {
|
|
31
|
+
const existing = loaded.get(url);
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
// The URL is only known at runtime, from the render response, so the bundler
|
|
34
|
+
// must not try to resolve it.
|
|
35
|
+
const pending = import(/* @vite-ignore */ url) as Promise<PagesBundle>;
|
|
36
|
+
loaded.set(url, pending);
|
|
37
|
+
return pending;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureStylesheet(href: string): void {
|
|
41
|
+
if (!href || typeof document === "undefined") return;
|
|
42
|
+
if (document.querySelector(`link[data-forge-pages="${CSS.escape(href)}"]`)) return;
|
|
43
|
+
const link = document.createElement("link");
|
|
44
|
+
link.rel = "stylesheet";
|
|
45
|
+
link.href = href;
|
|
46
|
+
link.dataset.forgePages = href;
|
|
47
|
+
document.head.appendChild(link);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Status = "server" | "live" | "failed";
|
|
51
|
+
|
|
52
|
+
export function RemotePage({ result, path, search = "" }: { result: RemotePageResult; path: string; search?: string }) {
|
|
53
|
+
const [Page, setPage] = useState<ComponentType<HostedPageProps> | null>(null);
|
|
54
|
+
const [status, setStatus] = useState<Status>("server");
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!result.bundle.js) {
|
|
58
|
+
// No server render happened either, so there is nothing on screen and
|
|
59
|
+
// nothing to load. Say so rather than showing an empty page for ever.
|
|
60
|
+
setStatus(result.html ? "server" : "failed");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Check the contract BEFORE importing. A site built against an older
|
|
64
|
+
// runtime cannot satisfy this bundle, and finding that out halfway through
|
|
65
|
+
// a render produces a thrown hook rather than a message anyone can act on.
|
|
66
|
+
const missing = missingRuntimeKeys(REQUIRED_RUNTIME_KEYS);
|
|
67
|
+
if (missing.length > 0) {
|
|
68
|
+
console.error(`[forge-pages] this site does not publish: ${missing.join(", ")}`);
|
|
69
|
+
setStatus("failed");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
ensureStylesheet(result.bundle.css);
|
|
74
|
+
loadBundle(result.bundle.js)
|
|
75
|
+
.then((bundle) => {
|
|
76
|
+
if (cancelled) return;
|
|
77
|
+
const component = bundle.getPage(path);
|
|
78
|
+
if (!component) {
|
|
79
|
+
setStatus("failed");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setPage(() => component);
|
|
83
|
+
setStatus("live");
|
|
84
|
+
})
|
|
85
|
+
.catch((err: unknown) => {
|
|
86
|
+
if (cancelled) return;
|
|
87
|
+
console.error("[forge-pages] bundle failed to load", err);
|
|
88
|
+
setStatus("failed");
|
|
89
|
+
});
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
};
|
|
93
|
+
}, [result.bundle.js, result.bundle.css, result.html, path]);
|
|
94
|
+
|
|
95
|
+
if (Page && status === "live") return <Page params={{}} search={new URLSearchParams(search)} data={result.data} />;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<div dangerouslySetInnerHTML={{ __html: result.html }} />
|
|
100
|
+
{status === "failed" && (
|
|
101
|
+
<div className="fg:mx-auto fg:my-6 fg:max-w-md fg:rounded-lg fg:border fg:p-4 fg:text-center fg:text-sm">
|
|
102
|
+
<p>This page could not finish loading.</p>
|
|
103
|
+
<button type="button" className="fg:mt-2 fg:underline" onClick={() => window.location.reload()}>
|
|
104
|
+
Reload the page
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as ReactJsxRuntime from "react/jsx-runtime";
|
|
3
|
+
import * as ReactDom from "react-dom";
|
|
4
|
+
import * as ReactQuery from "@tanstack/react-query";
|
|
5
|
+
import { shareRuntimeValue, readRuntimeValue } from "./registry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a built site publishes for the hosted pages bundle to use.
|
|
9
|
+
*
|
|
10
|
+
* Only libraries that OWN A CONTEXT of their own need to be here. Forge's
|
|
11
|
+
* contexts do not: they go through `createSharedContext`, so even two copies of
|
|
12
|
+
* Forge resolve to the same context object and the host's cart is the cart the
|
|
13
|
+
* bundle reads. React, the router and React Query have contexts we did not
|
|
14
|
+
* create and cannot route through the registry, so the bundle must use the
|
|
15
|
+
* host's module rather than its own.
|
|
16
|
+
*
|
|
17
|
+
* React is the strict one. Two Reacts in one tree means hooks throw, because
|
|
18
|
+
* the second copy has no current dispatcher during the first copy's render.
|
|
19
|
+
*/
|
|
20
|
+
export const RUNTIME_KEYS = {
|
|
21
|
+
react: "react",
|
|
22
|
+
reactDom: "react-dom",
|
|
23
|
+
reactJsxRuntime: "react/jsx-runtime",
|
|
24
|
+
reactQuery: "@tanstack/react-query",
|
|
25
|
+
router: "@tanstack/react-router",
|
|
26
|
+
queryClient: "queryClient",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Every key a hosted page needs before it can render. */
|
|
30
|
+
export const REQUIRED_RUNTIME_KEYS: readonly string[] = [
|
|
31
|
+
RUNTIME_KEYS.react,
|
|
32
|
+
RUNTIME_KEYS.reactDom,
|
|
33
|
+
RUNTIME_KEYS.reactJsxRuntime,
|
|
34
|
+
RUNTIME_KEYS.reactQuery,
|
|
35
|
+
RUNTIME_KEYS.router,
|
|
36
|
+
RUNTIME_KEYS.queryClient,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Publish what Forge itself can reach. Called at module scope by
|
|
41
|
+
* `TribeNestApp`, so it has run long before any page asks for a bundle.
|
|
42
|
+
*
|
|
43
|
+
* The router is NOT here. Forge does not depend on `@tanstack/react-router`,
|
|
44
|
+
* and adding the dependency just to republish the host's copy would give Forge
|
|
45
|
+
* a second one. The site registers it instead, from the catch-all route that
|
|
46
|
+
* already imports it (see `registerRouterRuntime`).
|
|
47
|
+
*/
|
|
48
|
+
export function registerHostRuntime(): void {
|
|
49
|
+
shareRuntimeValue(RUNTIME_KEYS.react, () => React);
|
|
50
|
+
shareRuntimeValue(RUNTIME_KEYS.reactDom, () => ReactDom);
|
|
51
|
+
shareRuntimeValue(RUNTIME_KEYS.reactJsxRuntime, () => ReactJsxRuntime);
|
|
52
|
+
shareRuntimeValue(RUNTIME_KEYS.reactQuery, () => ReactQuery);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Publish the site's router module. Called by the `/i/$` catch-all, which is a
|
|
57
|
+
* chassis file, so this stays platform-owned even though it lives in the site.
|
|
58
|
+
*/
|
|
59
|
+
export function registerRouterRuntime(routerModule: unknown): void {
|
|
60
|
+
shareRuntimeValue(RUNTIME_KEYS.router, () => routerModule);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Publish the query client the provider tree actually mounted.
|
|
65
|
+
*
|
|
66
|
+
* Unlike the modules above this is per render, not per module: `ForgeProvider`
|
|
67
|
+
* makes one client per instance (per SSR request). The bundle needs THAT one,
|
|
68
|
+
* or a hosted page would fetch into a cache nothing else reads and a fan would
|
|
69
|
+
* watch the same data load twice.
|
|
70
|
+
*/
|
|
71
|
+
export function registerQueryClient(client: unknown): void {
|
|
72
|
+
// Browser only, and this guard is load-bearing. The registry is a global, and
|
|
73
|
+
// on a Worker one global serves every request in the isolate. Publishing a
|
|
74
|
+
// per-request client there would hand request 2 the client request 1 created,
|
|
75
|
+
// which is a cross-request cache and a data leak between two fans. Server
|
|
76
|
+
// rendering does not need it: the pages Worker mounts its own providers.
|
|
77
|
+
if (typeof window === "undefined") return;
|
|
78
|
+
shareRuntimeValue(RUNTIME_KEYS.queryClient, () => client);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The host's query client, for the bundle. */
|
|
82
|
+
export function hostQueryClient<T>(): T | undefined {
|
|
83
|
+
return readRuntimeValue<T>(RUNTIME_KEYS.queryClient);
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { INTERNAL_PAGES, internalLink, internalPath, isInternalPage } from "./pages";
|
|
3
|
+
|
|
4
|
+
describe("internal page links", () => {
|
|
5
|
+
it("builds a path for a page with no parameters", () => {
|
|
6
|
+
expect(internalPath("checkout")).toBe("/i/checkout");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("fills parameters in order", () => {
|
|
10
|
+
expect(internalPath("films/$slug/watch/$episodeId", { slug: "dune", episodeId: "e2" })).toBe(
|
|
11
|
+
"/i/films/dune/watch/e2",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns a router target that spreads into a Link", () => {
|
|
16
|
+
expect(internalLink("store/$slug", { slug: "tee" })).toEqual({ to: "/i/$", params: { _splat: "store/tee" } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("encodes a value so it cannot become two segments", () => {
|
|
20
|
+
// A slug carrying a slash would otherwise silently address a different page.
|
|
21
|
+
expect(internalPath("store/$slug", { slug: "a/b" })).toBe("/i/store/a%2Fb");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("refuses to build a half-formed path", () => {
|
|
25
|
+
// Throwing beats emitting "/i/store/undefined", which returns a 404 page to
|
|
26
|
+
// the fan and looks like a missing product rather than a missing argument.
|
|
27
|
+
expect(() => internalPath("store/$slug", { slug: "" } as { slug: string })).toThrow(/missing the "slug" parameter/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("recognises its own pages and nothing else", () => {
|
|
31
|
+
expect(isInternalPage("store/tee")).toBe(true);
|
|
32
|
+
expect(isInternalPage("account")).toBe(true);
|
|
33
|
+
expect(isInternalPage("store")).toBe(true);
|
|
34
|
+
expect(isInternalPage("store/tee/extra")).toBe(false);
|
|
35
|
+
expect(isInternalPage("not-a-page")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("carries every page exactly once", () => {
|
|
39
|
+
expect(new Set(INTERNAL_PAGES).size).toBe(INTERNAL_PAGES.length);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every built-in page a site can link to, and the helpers for linking to one.
|
|
3
|
+
*
|
|
4
|
+
* These pages no longer live in the site. They are published once and rendered
|
|
5
|
+
* through the catch-all route `/i/$`, so a literal `to="/i/store/abc"` no
|
|
6
|
+
* longer type-checks against anything: there is no `/i/store/$slug` route file
|
|
7
|
+
* for TanStack to know about. That is the reason this helper exists, and it is
|
|
8
|
+
* why the build guide forbids writing an `/i/` path by hand.
|
|
9
|
+
*
|
|
10
|
+
* The page list is the CONTRACT between a site and the hosted pages, so it only
|
|
11
|
+
* ever grows. Removing a page breaks links on sites already published, so a
|
|
12
|
+
* retired page keeps its entry and redirects.
|
|
13
|
+
*
|
|
14
|
+
* <Link {...internalLink("store/$slug", { slug: product.slug })}>Buy</Link>
|
|
15
|
+
* navigate(internalLink("checkout"))
|
|
16
|
+
* const href = internalPath("events/$slug", { slug });
|
|
17
|
+
*
|
|
18
|
+
* A wrong page name, a missing parameter or one that does not exist on that
|
|
19
|
+
* page all fail type-check.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The catch-all that renders every hosted page. */
|
|
23
|
+
export const INTERNAL_ROUTE = "/i/$" as const;
|
|
24
|
+
|
|
25
|
+
/** Every hosted page, by its path pattern. Generated from the route table. */
|
|
26
|
+
export const INTERNAL_PAGES = [
|
|
27
|
+
"account",
|
|
28
|
+
"blog",
|
|
29
|
+
"blog-category/$slug",
|
|
30
|
+
"blog/$slug",
|
|
31
|
+
"chat",
|
|
32
|
+
"checkout",
|
|
33
|
+
"checkout/finalise",
|
|
34
|
+
"checkout/resume",
|
|
35
|
+
"coaching",
|
|
36
|
+
"coaching/$slug",
|
|
37
|
+
"coaching/$slug/finalise",
|
|
38
|
+
"cohorts/$cohortId",
|
|
39
|
+
"cohorts/$cohortId/finalise",
|
|
40
|
+
"community",
|
|
41
|
+
"community/leaderboard",
|
|
42
|
+
"community/notifications",
|
|
43
|
+
"community/posts/$postId",
|
|
44
|
+
"community/spaces/$spaceId",
|
|
45
|
+
"confirm-subscription",
|
|
46
|
+
"course-access/$accessId",
|
|
47
|
+
"courses",
|
|
48
|
+
"courses/$slug",
|
|
49
|
+
"courses/$slug/finalise",
|
|
50
|
+
"courses/claim",
|
|
51
|
+
"documents/$token",
|
|
52
|
+
"donation",
|
|
53
|
+
"events",
|
|
54
|
+
"events/$slug",
|
|
55
|
+
"events/$slug/finalise",
|
|
56
|
+
"films",
|
|
57
|
+
"films/$slug",
|
|
58
|
+
"films/$slug/watch/$episodeId",
|
|
59
|
+
"films/library",
|
|
60
|
+
"forgot-password",
|
|
61
|
+
"forms/$formId",
|
|
62
|
+
"invoice/$invoiceId",
|
|
63
|
+
"invoice/$invoiceId/finalise",
|
|
64
|
+
"lead/$leadMagnetId",
|
|
65
|
+
"live",
|
|
66
|
+
"live/$broadcastId",
|
|
67
|
+
"login",
|
|
68
|
+
"members",
|
|
69
|
+
"members/calendar",
|
|
70
|
+
"members/chat",
|
|
71
|
+
"members/collections",
|
|
72
|
+
"members/collections/$slug",
|
|
73
|
+
"members/spaces",
|
|
74
|
+
"members/spaces/$slug",
|
|
75
|
+
"members/spaces/leaderboard",
|
|
76
|
+
"members/spaces/notifications",
|
|
77
|
+
"members/spaces/profile/$accountId",
|
|
78
|
+
"members/spaces/thread/$postId",
|
|
79
|
+
"membership",
|
|
80
|
+
"membership/checkout",
|
|
81
|
+
"payment-link/$paymentLinkId",
|
|
82
|
+
"payment-link/$paymentLinkId/finalise",
|
|
83
|
+
"podcasts",
|
|
84
|
+
"podcasts/$showSlug",
|
|
85
|
+
"podcasts/$showSlug/$episodeSlug",
|
|
86
|
+
"replay",
|
|
87
|
+
"reset-password",
|
|
88
|
+
"reviews/$token",
|
|
89
|
+
"series/$slug",
|
|
90
|
+
"signup",
|
|
91
|
+
"store",
|
|
92
|
+
"store/$slug",
|
|
93
|
+
"tickets/claim",
|
|
94
|
+
"video-calls/$callId",
|
|
95
|
+
"work",
|
|
96
|
+
"work/accept-invite",
|
|
97
|
+
"work/projects/$projectId",
|
|
98
|
+
"work/projects/$projectId/tasks/$taskId",
|
|
99
|
+
"work/time-report/$token",
|
|
100
|
+
] as const;
|
|
101
|
+
|
|
102
|
+
export type InternalPage = (typeof INTERNAL_PAGES)[number];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The `$name` segments in a pattern, as a union of their names.
|
|
106
|
+
*
|
|
107
|
+
* `"films/$slug/watch/$episodeId"` gives `"slug" | "episodeId"`, so the params
|
|
108
|
+
* object is checked against the page rather than being a loose record.
|
|
109
|
+
*/
|
|
110
|
+
type PathParamNames<S extends string> = S extends `${string}$${infer Rest}`
|
|
111
|
+
? Rest extends `${infer Name}/${infer Tail}`
|
|
112
|
+
? Name | PathParamNames<Tail>
|
|
113
|
+
: Rest
|
|
114
|
+
: never;
|
|
115
|
+
|
|
116
|
+
type ParamsFor<P extends InternalPage> = Record<PathParamNames<P>, string>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The pages that take no parameters.
|
|
120
|
+
*
|
|
121
|
+
* Useful when a list of pages is built at runtime (a nav menu, say): with the
|
|
122
|
+
* whole union, TypeScript cannot prove no parameters are needed and demands the
|
|
123
|
+
* second argument for every entry. Narrowing to these makes `internalLink(page)`
|
|
124
|
+
* type-check with one argument, and adding a parameterised page to such a list
|
|
125
|
+
* then fails at the list rather than at the call.
|
|
126
|
+
*/
|
|
127
|
+
export type StaticInternalPage = {
|
|
128
|
+
[P in InternalPage]: [PathParamNames<P>] extends [never] ? P : never;
|
|
129
|
+
}[InternalPage];
|
|
130
|
+
|
|
131
|
+
/** Pages with no `$` take no second argument; pages with one require it. */
|
|
132
|
+
type ParamsArg<P extends InternalPage> = [PathParamNames<P>] extends [never]
|
|
133
|
+
? [params?: undefined]
|
|
134
|
+
: [params: ParamsFor<P>];
|
|
135
|
+
|
|
136
|
+
function fill(page: string, params?: Record<string, string>): string {
|
|
137
|
+
return page
|
|
138
|
+
.split("/")
|
|
139
|
+
.map((segment) => {
|
|
140
|
+
if (!segment.startsWith("$")) return segment;
|
|
141
|
+
const value = params?.[segment.slice(1)];
|
|
142
|
+
if (value === undefined || value === "") {
|
|
143
|
+
throw new Error(`internalPath("${page}") is missing the "${segment.slice(1)}" parameter`);
|
|
144
|
+
}
|
|
145
|
+
// The value is one path segment, so it is encoded as one. A slug with a
|
|
146
|
+
// slash in it would otherwise silently become two segments and match a
|
|
147
|
+
// different page, or no page at all.
|
|
148
|
+
return encodeURIComponent(value);
|
|
149
|
+
})
|
|
150
|
+
.join("/");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The plain URL for a hosted page. Use it for `href`, redirects, emails and
|
|
155
|
+
* anywhere server-side that needs a string rather than a router target.
|
|
156
|
+
*/
|
|
157
|
+
export function internalPath<P extends InternalPage>(page: P, ...args: ParamsArg<P>): string {
|
|
158
|
+
return `/i/${fill(page, args[0] as Record<string, string> | undefined)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The router target for a hosted page, ready to spread into `<Link>` or pass to
|
|
163
|
+
* `navigate()`.
|
|
164
|
+
*
|
|
165
|
+
* It resolves to the catch-all with the rest of the path as the splat, which is
|
|
166
|
+
* what makes it type-check with no casts: `/i/$` is a real route in the site.
|
|
167
|
+
*/
|
|
168
|
+
export function internalLink<P extends InternalPage>(
|
|
169
|
+
page: P,
|
|
170
|
+
...args: ParamsArg<P>
|
|
171
|
+
): { to: typeof INTERNAL_ROUTE; params: { _splat: string } } {
|
|
172
|
+
return { to: INTERNAL_ROUTE, params: { _splat: fill(page, args[0] as Record<string, string> | undefined) } };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Is this path one of ours? Used by the catch-all to 404 an unknown one. */
|
|
176
|
+
export function isInternalPage(splat: string): boolean {
|
|
177
|
+
const segments = splat.split("/").filter(Boolean);
|
|
178
|
+
return INTERNAL_PAGES.some((page) => {
|
|
179
|
+
const parts = page.split("/");
|
|
180
|
+
if (parts.length !== segments.length) return false;
|
|
181
|
+
return parts.every((part, i) => part.startsWith("$") || part === segments[i]);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { PAGES_RUNTIME_CONTRACT } from "./registry";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Talking to the central pages Worker.
|
|
5
|
+
*
|
|
6
|
+
* A site's catch-all route calls `fetchRemotePage` in its loader and renders
|
|
7
|
+
* `<RemotePage>` with the result. Everything else about hosted pages is an
|
|
8
|
+
* implementation detail of the bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type RemotePageBundle = {
|
|
12
|
+
/** Absolute URL of the browser bundle that rendered this HTML. */
|
|
13
|
+
js: string;
|
|
14
|
+
/** Its stylesheet. Forge utilities are `fg:` prefixed and ship with it. */
|
|
15
|
+
css: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RemotePageResult = {
|
|
19
|
+
/** Server-rendered markup for the page body. */
|
|
20
|
+
html: string;
|
|
21
|
+
/** Head for TanStack's `head()`, in the shape `buildHeadMeta` already returns. */
|
|
22
|
+
head: {
|
|
23
|
+
meta?: Array<Record<string, string>>;
|
|
24
|
+
links?: Array<Record<string, string>>;
|
|
25
|
+
scripts?: Array<Record<string, string>>;
|
|
26
|
+
};
|
|
27
|
+
status: number;
|
|
28
|
+
/** Full-bleed pages opt out of the `/i` layout's reading column. */
|
|
29
|
+
fullWidth: boolean;
|
|
30
|
+
/** Set when the page wants the visitor somewhere else. */
|
|
31
|
+
redirect?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Whatever the page's server loader returned.
|
|
34
|
+
*
|
|
35
|
+
* It rides back with the markup so the browser bundle can render the live
|
|
36
|
+
* page from the data that produced the HTML, rather than fetching it a second
|
|
37
|
+
* time and making the fan watch the same product load twice.
|
|
38
|
+
*/
|
|
39
|
+
data: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* The bundle that produced this HTML. It comes back with the render rather
|
|
42
|
+
* than being baked into the site, so the browser always runs the same version
|
|
43
|
+
* that rendered the markup it is replacing. A build-time URL would drift the
|
|
44
|
+
* moment a new bundle was published.
|
|
45
|
+
*/
|
|
46
|
+
bundle: RemotePageBundle;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FetchRemotePageInput = {
|
|
50
|
+
/** The page path below `/i/`, e.g. `store/tee`. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** Query string as it arrived, without the `?`. Pages parse their own. */
|
|
53
|
+
search?: string;
|
|
54
|
+
profileId: string;
|
|
55
|
+
websiteId?: string;
|
|
56
|
+
websiteVersionId?: string;
|
|
57
|
+
state: "draft" | "published";
|
|
58
|
+
/**
|
|
59
|
+
* How to reach the pages Worker. Three ways in, one Worker behind them:
|
|
60
|
+
*
|
|
61
|
+
* - `service`: the `PAGES` binding a published site is deployed with. The
|
|
62
|
+
* Worker has no public route, so this is the only path in production.
|
|
63
|
+
* - `endpoint`: an absolute URL, for a laptop and the preview sandbox, which
|
|
64
|
+
* have no bindings.
|
|
65
|
+
* - also `endpoint`: a same-origin path, used by the BROWSER on a client-side
|
|
66
|
+
* navigation. The site proxies it to the binding, because a browser cannot
|
|
67
|
+
* reach a Worker that has no public route, and a page navigated to rather
|
|
68
|
+
* than landed on still needs its title and its meta tags.
|
|
69
|
+
*
|
|
70
|
+
* There is no fourth path and no local-only shortcut, so the seam is
|
|
71
|
+
* exercised on every page view in every environment.
|
|
72
|
+
*/
|
|
73
|
+
transport: { service: { fetch: typeof fetch } } | { endpoint: string };
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The placeholder a site renders when the pages Worker cannot be reached. */
|
|
78
|
+
export const REMOTE_PAGE_UNAVAILABLE: RemotePageResult = {
|
|
79
|
+
html: "",
|
|
80
|
+
head: {},
|
|
81
|
+
data: null,
|
|
82
|
+
status: 200,
|
|
83
|
+
fullWidth: false,
|
|
84
|
+
bundle: { js: "", css: "" },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ask the pages Worker to render one page.
|
|
89
|
+
*
|
|
90
|
+
* Never throws. A page that cannot be server-rendered still works: the browser
|
|
91
|
+
* bundle renders it in full, and the cost is that this one request served no
|
|
92
|
+
* markup to a crawler. Throwing instead would turn a rendering outage into a
|
|
93
|
+
* broken site.
|
|
94
|
+
*/
|
|
95
|
+
export async function fetchRemotePage(input: FetchRemotePageInput): Promise<RemotePageResult> {
|
|
96
|
+
const body = JSON.stringify({
|
|
97
|
+
contract: PAGES_RUNTIME_CONTRACT,
|
|
98
|
+
path: input.path,
|
|
99
|
+
search: input.search ?? "",
|
|
100
|
+
profileId: input.profileId,
|
|
101
|
+
websiteId: input.websiteId,
|
|
102
|
+
websiteVersionId: input.websiteVersionId,
|
|
103
|
+
state: input.state,
|
|
104
|
+
});
|
|
105
|
+
const request = new Request("https://pages.internal/render", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body,
|
|
109
|
+
signal: input.signal,
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
const response =
|
|
113
|
+
"service" in input.transport
|
|
114
|
+
? await input.transport.service.fetch(request)
|
|
115
|
+
: await fetch(input.transport.endpoint, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
body,
|
|
119
|
+
signal: input.signal,
|
|
120
|
+
});
|
|
121
|
+
if (response.status === 404) return { ...REMOTE_PAGE_UNAVAILABLE, status: 404 };
|
|
122
|
+
if (!response.ok) return REMOTE_PAGE_UNAVAILABLE;
|
|
123
|
+
return (await response.json()) as RemotePageResult;
|
|
124
|
+
} catch {
|
|
125
|
+
return REMOTE_PAGE_UNAVAILABLE;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createSharedContext, missingRuntimeKeys, readRuntimeValue, shareRuntimeValue } from "./registry";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The registry's whole job is that a second caller gets the FIRST caller's
|
|
6
|
+
* object. In production the two callers are two separately built bundles (the
|
|
7
|
+
* creator's site and the central pages bundle); here they are two calls, which
|
|
8
|
+
* exercises the same global and the same first-writer-wins rule.
|
|
9
|
+
*/
|
|
10
|
+
describe("pages runtime registry", () => {
|
|
11
|
+
it("hands the second caller the first caller's context", () => {
|
|
12
|
+
const first = createSharedContext<string | null>("spec:greeting", null);
|
|
13
|
+
const second = createSharedContext<string | null>("spec:greeting", "ignored");
|
|
14
|
+
// Identity, not equality. Two contexts that merely look alike are exactly
|
|
15
|
+
// the bug this prevents: a provider mounted from one is invisible to a
|
|
16
|
+
// consumer reading the other.
|
|
17
|
+
expect(second).toBe(first);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("keeps the value the first caller published", () => {
|
|
21
|
+
shareRuntimeValue("spec:client", () => ({ id: "host" }));
|
|
22
|
+
const value = shareRuntimeValue("spec:client", () => ({ id: "bundle" }));
|
|
23
|
+
expect(value).toEqual({ id: "host" });
|
|
24
|
+
expect(readRuntimeValue("spec:client")).toEqual({ id: "host" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("never runs the second factory at all", () => {
|
|
28
|
+
let calls = 0;
|
|
29
|
+
shareRuntimeValue("spec:once", () => ++calls);
|
|
30
|
+
shareRuntimeValue("spec:once", () => ++calls);
|
|
31
|
+
expect(calls).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reports nothing for a key that was never published", () => {
|
|
35
|
+
expect(readRuntimeValue("spec:absent")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("names the keys a bundle needs that the host never published", () => {
|
|
39
|
+
shareRuntimeValue("spec:present", () => true);
|
|
40
|
+
// The bundle checks this BEFORE rendering: a site built against an older
|
|
41
|
+
// runtime must produce a named missing key, not a blank page.
|
|
42
|
+
expect(missingRuntimeKeys(["spec:present", "spec:gone", "spec:alsoGone"])).toEqual(["spec:gone", "spec:alsoGone"]);
|
|
43
|
+
expect(missingRuntimeKeys(["spec:present"])).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|