@pylonsync/functions 0.22.59 → 0.22.61

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.
@@ -35,6 +35,8 @@ describe("declaresClientNavigation", () => {
35
35
  `const navigation = "client";`,
36
36
  `export const navigationMode = "client";`,
37
37
  `// navigation = "client"`,
38
+ `// export const navigation = "client";`,
39
+ `/* export const navigation = "client"; */`,
38
40
  "",
39
41
  ]) {
40
42
  expect(declaresClientNavigation(src)).toBe(false);
@@ -42,6 +44,10 @@ describe("declaresClientNavigation", () => {
42
44
  });
43
45
  });
44
46
 
47
+ test("comments are stripped; strings that contain // are not", () => {
48
+ expect(declaresClientNavigation(`const u = "http://x"; export const navigation = "client";`)).toBe(true);
49
+ });
50
+
45
51
  describe("clientRouteFor", () => {
46
52
  test("a page that opted in", () => {
47
53
  expect(clientRouteFor(MANIFEST, "/leads", matchRoute)?.component).toBe("app/leads/page");
@@ -74,5 +80,6 @@ describe("exportsStaticMetadata", () => {
74
80
  expect(exportsStaticMetadata(`const metadata = {}; export { metadata };`)).toBe(true);
75
81
  expect(exportsStaticMetadata(`export async function generateMetadata() {}`)).toBe(false);
76
82
  expect(exportsStaticMetadata(`const metadata = {};`)).toBe(false);
83
+ expect(exportsStaticMetadata(`// export const metadata = { title: "x" };`)).toBe(false);
77
84
  });
78
85
  });
@@ -36,7 +36,48 @@ type MatchRoute = (manifest: any, pathname: string) => ClientRouteMatch | null;
36
36
  * A static scan: the bundler reads the page file without running it.
37
37
  */
38
38
  export function declaresClientNavigation(source: string): boolean {
39
- return /export\s+const\s+navigation\s*(?::[^=]+)?=\s*(["'`])client\1/.test(source);
39
+ return /export\s+const\s+navigation\s*(?::[^=]+)?=\s*(["'`])client\1/.test(
40
+ stripComments(source),
41
+ );
42
+ }
43
+
44
+ /**
45
+ * `source` with `//` and `/* *\/` comments replaced by spaces, so a static
46
+ * scan sees code only (a commented-out export is not an export). Strings
47
+ * and template literals are skipped over intact, so a `//` inside one is
48
+ * not taken for a comment.
49
+ */
50
+ export function stripComments(source: string): string {
51
+ let out = "";
52
+ let i = 0;
53
+ while (i < source.length) {
54
+ const c = source[i]!;
55
+ const next = source[i + 1];
56
+ if (c === "/" && next === "/") {
57
+ const end = source.indexOf("\n", i);
58
+ const stop = end === -1 ? source.length : end;
59
+ out += " ".repeat(stop - i);
60
+ i = stop;
61
+ } else if (c === "/" && next === "*") {
62
+ const end = source.indexOf("*/", i + 2);
63
+ const stop = end === -1 ? source.length : end + 2;
64
+ out += source.slice(i, stop).replace(/[^\n]/g, " ");
65
+ i = stop;
66
+ } else if (c === '"' || c === "'" || c === "`") {
67
+ let j = i + 1;
68
+ while (j < source.length && source[j] !== c) {
69
+ if (source[j] === "\\") j++;
70
+ else if (c !== "`" && source[j] === "\n") break;
71
+ j++;
72
+ }
73
+ out += source.slice(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ out += c;
77
+ i++;
78
+ }
79
+ }
80
+ return out;
40
81
  }
41
82
 
42
83
  /** Whether `pathname` is one of the app's offline routes. */
@@ -0,0 +1,251 @@
1
+ // Page metadata in the served HTML.
2
+ //
3
+ // Veritas pages exported `metadata` and the HTML had none of it: the server
4
+ // put the tags inside the React tree beside the page, and the field layout
5
+ // renders a loading screen on the server instead of its children (the
6
+ // member check runs in the browser), so the tags were never rendered. The
7
+ // server now writes the metadata into <head> itself, merges layout metadata
8
+ // under the page's, and replaces the tags a root layout writes in its own
9
+ // <head> instead of adding a second set.
10
+ //
11
+ // ssr-runtime.ts pulls in runtime.ts (the bun runner entrypoint, which runs
12
+ // main() on import), so each render runs in a child process, like
13
+ // ssr-offline-shell.test.ts.
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { dropReplacedHeadTags, headTagKeys, mergeMetadata } from "./ssr-runtime";
20
+
21
+ const SSR_RUNTIME = join(import.meta.dir, "ssr-runtime.ts");
22
+ const PKG_NODE_MODULES = join(import.meta.dir, "..", "node_modules");
23
+
24
+ function makeApp(files: Record<string, string>): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "pylon-metadata-"));
26
+ mkdirSync(join(dir, "app"), { recursive: true });
27
+ symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
28
+ for (const [p, src] of Object.entries(files)) {
29
+ mkdirSync(join(dir, p, ".."), { recursive: true });
30
+ writeFileSync(join(dir, p), src);
31
+ }
32
+ return dir;
33
+ }
34
+
35
+ async function render(
36
+ dir: string,
37
+ layouts: string[],
38
+ headers: Record<string, string> = {},
39
+ component = "app/page",
40
+ ): Promise<string> {
41
+ const msg = {
42
+ type: "render_route", call_id: "c1", component, layouts,
43
+ route_path: "/", url: "/", host: "app.example", params: {}, search_params: {},
44
+ headers: { host: "app.example", ...headers }, cookies: {}, auth: {}, session_present: false,
45
+ };
46
+ const probe = `
47
+ import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
48
+ const frames = [];
49
+ await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
50
+ console.error("FRAMES " + JSON.stringify(frames));
51
+ setTimeout(() => process.exit(0), 50);
52
+ `;
53
+ const probePath = join(dir, "probe.ts");
54
+ writeFileSync(probePath, probe);
55
+ const proc = Bun.spawn(["bun", "run", probePath], { cwd: dir, env: { ...process.env, PYLON_DEV_MODE: "0" }, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
56
+ const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
57
+ const code = await proc.exited;
58
+ const line = err.split("\n").find((l) => l.includes("FRAMES "));
59
+ if (code !== 0 || !line) throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
60
+ const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + 7)) as any[];
61
+ return frames.filter((f) => f.type === "render_chunk").map((f) => Buffer.from(f.data, "base64").toString("utf8")).join("");
62
+ }
63
+
64
+
65
+ // A root layout like Veritas's: its own <head> with default link-preview tags.
66
+ const ROOT_LAYOUT = `export default function RootLayout({ children }: any) {
67
+ return (
68
+ <html lang="en">
69
+ <head>
70
+ <meta charSet="utf-8" />
71
+ <title>Field Sales</title>
72
+ <meta name="description" content="The field app." />
73
+ <meta property="og:title" content="Field Sales" />
74
+ <meta property="og:image" content="https://app.example/og.png" />
75
+ <meta property="og:image:width" content="1200" />
76
+ <meta name="twitter:title" content="Field Sales" />
77
+ <script dangerouslySetInnerHTML={{ __html: 'var t = "<title>kept</title>";' }} />
78
+ </head>
79
+ <body>{children}</body>
80
+ </html>
81
+ );
82
+ }
83
+ `;
84
+
85
+ // A layout that decides access in the browser: on the server it renders a
86
+ // loading screen, never its children.
87
+ const GATE_LAYOUT = `export default function Gate({ children }: any) {
88
+ return typeof window === "undefined" ? <p>Loading</p> : children;
89
+ }
90
+ `;
91
+
92
+ const count = (html: string, re: RegExp) => (html.match(re) ?? []).length;
93
+ // The document up to </head>, without script text (the root layout's script
94
+ // holds a <title> string that is not a tag).
95
+ const headOf = (html: string) =>
96
+ html.slice(0, html.indexOf("</head>")).replace(/<script\b[\s\S]*?<\/script>/g, "");
97
+
98
+ describe("page metadata in the HTML", () => {
99
+ test("reaches <head> when a layout does not render the page, and replaces the root layout's tags", async () => {
100
+ const dir = makeApp({
101
+ "app/layout.tsx": ROOT_LAYOUT,
102
+ "app/gate/layout.tsx": GATE_LAYOUT,
103
+ "app/page.tsx": `export const metadata = {
104
+ title: "Home · Field Sales",
105
+ openGraph: { title: "Home", description: "Today's doors." },
106
+ twitter: { title: "Home" },
107
+ };
108
+ export default function P() { return <p>page body</p>; }
109
+ `,
110
+ });
111
+ const html = await render(dir, ["app/layout", "app/gate/layout"]);
112
+ expect(html).toContain("Loading");
113
+ const head = headOf(html);
114
+ expect(html).not.toContain("page body");
115
+ expect(count(head, /<title>/g)).toBe(1);
116
+ expect(head).toContain("<title>Home · Field Sales</title>");
117
+ expect(count(head, /property="og:title"/g)).toBe(1);
118
+ expect(head).toContain('property="og:title" content="Home"');
119
+ expect(head).toContain('property="og:description" content="Today&#x27;s doors."');
120
+ expect(count(head, /name="twitter:title"/g)).toBe(1);
121
+ expect(head).toContain('name="twitter:title" content="Home"');
122
+ // Tags the page does not set stay as the layout wrote them.
123
+ expect(head).toContain('name="description" content="The field app."');
124
+ expect(head).toContain('property="og:image" content="https://app.example/og.png"');
125
+ expect(head).toContain('property="og:image:width" content="1200"');
126
+ // Text inside a script is not a tag.
127
+ expect(html).toContain('var t = "<title>kept</title>";');
128
+ });
129
+
130
+ test("a page's og:image replaces the layout's image and its dimensions", async () => {
131
+ const dir = makeApp({
132
+ "app/layout.tsx": ROOT_LAYOUT,
133
+ "app/page.tsx": `export const metadata = { openGraph: { image: "https://app.example/home.png" } };
134
+ export default function P() { return <p>x</p>; }
135
+ `,
136
+ });
137
+ const head = headOf(await render(dir, ["app/layout"]));
138
+ expect(count(head, /property="og:image"/g)).toBe(1);
139
+ expect(head).toContain('property="og:image" content="https://app.example/home.png"');
140
+ expect(head).not.toContain("og:image:width");
141
+ expect(head).toContain("<title>Field Sales</title>");
142
+ });
143
+
144
+ test("a layout's metadata applies to its pages, and the page's values win", async () => {
145
+ const dir = makeApp({
146
+ "app/layout.tsx": `export const metadata = { title: "Site", description: "From the layout.", openGraph: { siteName: "Site" } };
147
+ export default function L({ children }: any) { return <html><head><meta charSet="utf-8" /></head><body>{children}</body></html>; }
148
+ `,
149
+ "app/inner/layout.tsx": `export async function generateMetadata() { return { robots: "noindex" }; }
150
+ export default function L({ children }: any) { return children; }
151
+ `,
152
+ "app/page.tsx": `export const metadata = { title: "Page", openGraph: { title: "Page OG" } };
153
+ export default function P() { return <p>x</p>; }
154
+ `,
155
+ });
156
+ const head = headOf(await render(dir, ["app/layout", "app/inner/layout"]));
157
+ expect(count(head, /<title>/g)).toBe(1);
158
+ expect(head).toContain("<title>Page</title>");
159
+ expect(head).toContain('name="description" content="From the layout."');
160
+ expect(head).toContain('name="robots" content="noindex"');
161
+ expect(head).toContain('property="og:site_name" content="Site"');
162
+ expect(head).toContain('property="og:title" content="Page OG"');
163
+ });
164
+
165
+ test("an offline shell request carries the metadata", async () => {
166
+ const dir = makeApp({
167
+ "app/layout.tsx": ROOT_LAYOUT,
168
+ "app/page.tsx": `export const metadata = { title: "Home · Field Sales" };
169
+ export default function P() { return <p>x</p>; }
170
+ `,
171
+ });
172
+ const head = headOf(await render(dir, ["app/layout"], { "x-pylon-offline-shell": "1" }));
173
+ expect(count(head, /<title>/g)).toBe(1);
174
+ expect(head).toContain("<title>Home · Field Sales</title>");
175
+ });
176
+
177
+ test("a navigation response carries the page's title, or the layout's when the page has none", async () => {
178
+ const titled = makeApp({
179
+ "app/layout.tsx": ROOT_LAYOUT,
180
+ "app/page.tsx": `export const metadata = { title: "Jobs" };
181
+ export default function P() { return <p>x</p>; }
182
+ `,
183
+ });
184
+ const nav = await render(titled, ["app/layout"], { "x-pylon-nav": "1" });
185
+ expect(count(nav, /<title>/g)).toBe(1);
186
+ expect(nav).toContain("<title>Jobs</title>");
187
+
188
+ const untitled = makeApp({
189
+ "app/layout.tsx": ROOT_LAYOUT,
190
+ "app/page.tsx": `export default function P() { return <p>x</p>; }`,
191
+ });
192
+ const nav2 = await render(untitled, ["app/layout"], { "x-pylon-nav": "1" });
193
+ expect(count(nav2, /<title>/g)).toBe(1);
194
+ expect(nav2).toContain("<title>Field Sales</title>");
195
+ });
196
+
197
+ test("a not-found boundary's metadata replaces the layout's title", async () => {
198
+ const dir = makeApp({
199
+ "app/layout.tsx": ROOT_LAYOUT,
200
+ "app/not-found.tsx": `export const metadata = { title: "Not found" };
201
+ export default function NF() { return <p>missing</p>; }
202
+ `,
203
+ });
204
+ // The host renders `app/not-found` by name for an unmatched URL.
205
+ const head = headOf(await render(dir, ["app/layout"], {}, "app/not-found"));
206
+ expect(count(head, /<title>/g)).toBe(1);
207
+ expect(head).toContain("<title>Not found</title>");
208
+
209
+ // A page that calls notFound() renders the same boundary.
210
+ writeFileSync(
211
+ join(dir, "app", "page.tsx"),
212
+ `export default function P(props: any) { props.response.notFound(); }`,
213
+ );
214
+ const caught = headOf(await render(dir, ["app/layout"]));
215
+ expect(count(caught, /<title>/g)).toBe(1);
216
+ expect(caught).toContain("<title>Not found</title>");
217
+ expect(caught).toContain('property="og:title" content="Field Sales"');
218
+ });
219
+ });
220
+
221
+ describe("dropReplacedHeadTags", () => {
222
+ test("drops only the tags the metadata sets, by title, name, property and canonical", () => {
223
+ const head =
224
+ '<head><title>Old</title><meta name="description" content="a > b"/>' +
225
+ '<meta property="og:image" content="x"/><meta property="og:image:alt" content="y"/>' +
226
+ '<meta name="viewport" content="w"/><link rel="canonical" href="/old"/>' +
227
+ '<link rel="stylesheet" href="/s.css"/><style>title{}</style>';
228
+ const keys = headTagKeys(
229
+ '<title>New</title><meta data-pylon-meta="" name="description" content="n"/>' +
230
+ '<meta data-pylon-meta="" property="og:image" content="z"/><link data-pylon-meta="" rel="canonical" href="/new"/>',
231
+ );
232
+ expect(dropReplacedHeadTags(head, keys)).toBe(
233
+ '<head><meta name="viewport" content="w"/><link rel="stylesheet" href="/s.css"/><style>title{}</style>',
234
+ );
235
+ });
236
+ });
237
+
238
+ describe("mergeMetadata", () => {
239
+ test("merges openGraph field by field; a new image drops the old image's fields", () => {
240
+ expect(
241
+ mergeMetadata(
242
+ { title: "A", openGraph: { siteName: "S", image: "/a.png", imageWidth: 1200 }, twitter: { image: "/a.png", imageAlt: "a" } },
243
+ { title: "B", openGraph: { image: "/b.png" }, twitter: { image: "/b.png" } },
244
+ ),
245
+ ).toEqual({
246
+ title: "B",
247
+ openGraph: { siteName: "S", image: "/b.png" },
248
+ twitter: { image: "/b.png" },
249
+ });
250
+ });
251
+ });
@@ -251,3 +251,29 @@ test("the worker precaches exactly the files the route's HTML loads", async () =
251
251
  expect(config.precache).toContain("/icons/512.png");
252
252
  expect(config.shells).toEqual(["/"]);
253
253
  });
254
+
255
+ test("the head modulepreloads the route entry and its whole static graph", async () => {
256
+ const dir = makeApp(SESSION_ONLY_PAGE);
257
+ const { html } = await render(dir);
258
+ const head = html.slice(0, html.indexOf("</head>"));
259
+ const preloaded = [...head.matchAll(/<link rel="modulepreload" href="([^"]+)">/g)].map((m) => m[1]);
260
+ const route = BUNDLE_MANIFEST.routes["app/page"];
261
+ expect(preloaded).toEqual([route.file, ...route.imports].map((f) => "/_pylon/build/" + f));
262
+ });
263
+
264
+ test("modulepreloads are capped for a pathological graph", async () => {
265
+ const { MAX_MODULEPRELOADS } = await import("./ssr-route-assets");
266
+ const imports = Array.from({ length: MAX_MODULEPRELOADS + 20 }, (_, i) => `chunks/c${i}-aaaa1111.js`);
267
+ const manifest = {
268
+ ...BUNDLE_MANIFEST,
269
+ routes: { "app/page": { ...BUNDLE_MANIFEST.routes["app/page"], imports } },
270
+ };
271
+ const dir = makeApp(SESSION_ONLY_PAGE, manifest);
272
+ const { html } = await render(dir);
273
+ const head = html.slice(0, html.indexOf("</head>"));
274
+ const preloaded = [...head.matchAll(/<link rel="modulepreload" href="([^"]+)">/g)];
275
+ expect(preloaded.length).toBe(MAX_MODULEPRELOADS);
276
+ // The entry comes first, then the nearest imports.
277
+ expect(preloaded[0]![1]).toBe("/_pylon/build/" + BUNDLE_MANIFEST.routes["app/page"].file);
278
+ expect(preloaded[1]![1]).toBe("/_pylon/build/chunks/c0-aaaa1111.js");
279
+ });
@@ -3,19 +3,16 @@
3
3
  // imports this file as `./page-tree`).
4
4
  //
5
5
  // React derives `useId` values from the tree's shape, including how many
6
- // children each element has. The server renders the page's metadata (<title>,
7
- // <meta>, <link>, which React hoists into <head>) beside the page; the browser
8
- // never renders those tags. So both sides give the page a fragment with two
9
- // slots: on the server the first slot holds the metadata, in the browser it
10
- // holds null. React counts a null slot, so every id inside the page is the
11
- // same on both sides. Without the slot, every Radix menu, dialog and label id
12
- // on a page with metadata mismatched on hydration.
6
+ // children each element has. Both sides give the page a fragment with two
7
+ // slots, the first one null. The server once put the page's metadata there;
8
+ // it now writes the metadata into <head> as HTML, outside the tree. The slot
9
+ // stays so the tree keeps the shape that built clients hydrate against.
13
10
  //
14
11
  // No imports: the client bundler copies this file next to the runtime.
15
12
 
16
13
  type CreateElement = (type: any, props: any, ...children: any[]) => any;
17
14
 
18
- /** The page with its metadata slot. `meta` is null in the browser. */
15
+ /** The page with its metadata slot. Both sides pass null for `meta`. */
19
16
  export function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any {
20
17
  return createElement(Fragment, null, meta ?? null, page);
21
18
  }
@@ -21,7 +21,8 @@ export interface RouteAssetManifest {
21
21
  export interface RouteAssetFiles {
22
22
  /** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
23
23
  styles: string[];
24
- /** `<link rel="modulepreload">` in the head. */
24
+ /** `<link rel="modulepreload">` in the head: the route entry, then its
25
+ * static import graph (see `modulepreloadFiles`). */
25
26
  preloads: string[];
26
27
  /** `<link rel="preload" as="font">` in the head. */
27
28
  fonts: string[];
@@ -30,6 +31,25 @@ export interface RouteAssetFiles {
30
31
  scripts: string[];
31
32
  }
32
33
 
34
+ /**
35
+ * The most `<link rel="modulepreload">` tags a page's head carries. Every
36
+ * file in the route's static graph is needed before hydration, so the list
37
+ * is complete for any real route; the cap keeps a pathological graph from
38
+ * bloating the head. Past it, the browser finds the rest by import.
39
+ */
40
+ export const MAX_MODULEPRELOADS = 64;
41
+
42
+ /**
43
+ * The files to modulepreload for a route: its entry first, then its static
44
+ * import graph (the manifest's `imports`, nearest first), capped at
45
+ * `MAX_MODULEPRELOADS`. The entry's `<script>` sits at the end of the body,
46
+ * after a streamed page's Suspense boundaries, so without its preload the
47
+ * page's own code started last.
48
+ */
49
+ export function modulepreloadFiles(route: { file: string; imports?: string[] }): string[] {
50
+ return [route.file, ...(route.imports ?? [])].slice(0, MAX_MODULEPRELOADS);
51
+ }
52
+
33
53
  /** The scripts the hydration tail loads for a route entry. */
34
54
  export function routeScriptFiles(
35
55
  route: { file: string },
@@ -48,7 +68,7 @@ export function routeAssetFiles(
48
68
  if (!route) return null;
49
69
  return {
50
70
  styles: route.css ?? [],
51
- preloads: route.imports ?? [],
71
+ preloads: modulepreloadFiles(route),
52
72
  fonts: manifest.fonts?.preload ?? [],
53
73
  scripts: routeScriptFiles(route, manifest.polyfills),
54
74
  };
@@ -62,7 +82,8 @@ export function routeAssetUrls(
62
82
  const files = routeAssetFiles(manifest, component);
63
83
  if (!files) return [];
64
84
  const prefix = manifest.public_prefix || "/_pylon/build/";
65
- return [...files.styles, ...files.preloads, ...files.fonts, ...files.scripts].map(
85
+ // The entry is both preloaded and loaded as a script: list it once.
86
+ return [...new Set([...files.styles, ...files.preloads, ...files.fonts, ...files.scripts])].map(
66
87
  (f) => prefix + f,
67
88
  );
68
89
  }