@tribe-nest/forge 3.53.0 → 3.57.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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -1,4 +1,5 @@
1
- import { createContext, useContext, type ReactNode } from "react";
1
+ import { useContext, type ReactNode } from "react";
2
+ import { createSharedContext } from "../runtime/registry";
2
3
  import type { SiteConfig } from "../data/queries/useWebsite";
3
4
  import { setSiteLocale } from "../client/activeLocale";
4
5
 
@@ -9,7 +10,7 @@ import { setSiteLocale } from "../client/activeLocale";
9
10
  * wasn't fetched on the server, in which case `useSiteConfig` just fetches on
10
11
  * the client as before.
11
12
  */
12
- const SiteConfigContext = createContext<SiteConfig | undefined>(undefined);
13
+ const SiteConfigContext = createSharedContext<SiteConfig | undefined>("siteConfig", undefined);
13
14
 
14
15
  /** Seed the SSR-fetched site config (see `fetchSiteConfig` in `@tribe-nest/forge/server`). */
15
16
  export function SiteConfigProvider({
@@ -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
+ });