@pylonsync/functions 0.4.11 → 0.4.12

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.
@@ -14,6 +14,16 @@ export type BoundaryFile = "not-found" | "error" | "loading";
14
14
  * the shared chunk instead.
15
15
  */
16
16
  export declare function nearestBoundaryComponent(component: string, fileName: BoundaryFile, routeKeys: Iterable<string>): string | null;
17
+ /**
18
+ * The URL-space directory a module sits in: its parent path with `(group)`
19
+ * segments removed, since a group contributes no URL segment.
20
+ *
21
+ * `web/app/(marketing)/pricing/page` → `web/app/pricing`, and
22
+ * `web/app/(marketing)/not-found` → `web/app`. That is what makes a group's
23
+ * boundary resolve for `/` — matching the server, which walks the real
24
+ * directories but treats group dirs as transparent the same way.
25
+ */
26
+ export declare function boundaryScope(componentPath: string): string;
17
27
  /** Runtime internals the boundary needs, injected so the module stays
18
28
  * browser-safe AND unit-testable. */
19
29
  export interface BoundaryDeps {
@@ -303,6 +303,12 @@ export interface SsrMetadata {
303
303
  export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
304
304
  /** Import a project-relative module, trying each common extension. */
305
305
  export declare function importModule(cwd: string, relPath: string): Promise<any>;
306
+ /**
307
+ * `findBoundary` with its filesystem and root injected, so the walk is
308
+ * testable against a fixture without `chdir` — which races every other test
309
+ * in the process.
310
+ */
311
+ export declare function findBoundaryIn(fs: any, path: any, cwd: string, componentPath: string, fileName: string): string | null;
306
312
  /** Pure origin resolution (exported for tests).
307
313
  *
308
314
  * SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
@@ -409,7 +415,30 @@ export declare function buildHydrationTail(args: {
409
415
  bucketAuth?: {
410
416
  signedIn: boolean;
411
417
  };
418
+ /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
419
+ * nothing else. The client already has the runtime and resolves the route
420
+ * entry from the build manifest, so the entry `<script>`, the preload tags
421
+ * and the dev-reload snippet are all dead weight on a navigation. Shares
422
+ * this function — and therefore its props strip — rather than rebuilding
423
+ * the payload somewhere the security-relevant parts could drift. */
424
+ dataOnly?: boolean;
412
425
  }): string;
426
+ /**
427
+ * Is this render answering a client-side navigation rather than a document
428
+ * load? The client runtime sets `X-Pylon-Nav: 1`; the Rust host reads the same
429
+ * header to key the ISR cache, so the two always agree on which shape a URL
430
+ * is being asked for.
431
+ */
432
+ export declare function isNavRequest(headers: Record<string, unknown> | undefined): boolean;
433
+ /**
434
+ * Render a detached element (the metadata fragment) to an HTML string.
435
+ *
436
+ * A navigation response carries the page's `<title>`/`<meta>`/`<link>` so the
437
+ * client can swap them, but not the page body — which the client re-renders
438
+ * from `__PYLON_DATA__` anyway. Rendering the fragment on its own is what
439
+ * lets the body be dropped without losing the head.
440
+ */
441
+ export declare function renderElementToString(renderToReadableStream: any, element: any): Promise<string>;
413
442
  /**
414
443
  * A short, non-reversible correlation id for an error — surfaced to the
415
444
  * client error boundary as `error.digest` (matching server logs) WITHOUT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,183 @@
1
+ // The server's boundary walk must agree with the client's on route groups.
2
+ //
3
+ // Regression: `app/(marketing)/not-found.tsx` served unmatched URLs — the host
4
+ // matches those on the manifest's URL path, where a group adds no segment —
5
+ // but NOT a notFound() thrown by a page, which walked real directories and so
6
+ // never crossed out of the page's own subtree. One class of 404 got the app's
7
+ // boundary and the other got the built-in, while ROUTE_PATH_DUPLICATE blocked
8
+ // keeping a root copy alongside the group's.
9
+ //
10
+ // These drive the SERVER walk (findBoundaryIn) over a real fixture tree and
11
+ // assert the same answers the client's nearestBoundaryComponent gives for the
12
+ // equivalent key set — the two diverging is what caused the bug.
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
18
+
19
+ import { findBoundaryIn } from "./ssr-runtime";
20
+ import { nearestBoundaryComponent } from "./ssr-client-boundary";
21
+
22
+ let tmp: string | null = null;
23
+
24
+ afterEach(() => {
25
+ if (tmp) {
26
+ try {
27
+ fs.rmSync(tmp, { recursive: true, force: true });
28
+ } catch {
29
+ /* best effort */
30
+ }
31
+ tmp = null;
32
+ }
33
+ });
34
+
35
+ /** Create a tree of empty module files and return its root. */
36
+ function tree(files: string[]): string {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-boundary-"));
38
+ for (const f of files) {
39
+ const full = path.join(dir, f);
40
+ fs.mkdirSync(path.dirname(full), { recursive: true });
41
+ fs.writeFileSync(full, "export default function X(){return null}\n", "utf8");
42
+ }
43
+ tmp = dir;
44
+ return dir;
45
+ }
46
+
47
+ /** Module paths (no extension) for the client-side equivalent. */
48
+ const keysOf = (files: string[]) => files.map((f) => f.replace(/\.tsx$/, ""));
49
+
50
+ describe("server boundary walk treats route groups as transparent", () => {
51
+ test("a group's not-found covers a page outside the group", () => {
52
+ const files = ["app/(marketing)/not-found.tsx", "app/gone/page.tsx"];
53
+ const root = tree(files);
54
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
55
+ "app/(marketing)/not-found",
56
+ );
57
+ // ...and the client agrees, which is the property that broke.
58
+ expect(
59
+ nearestBoundaryComponent("app/gone/page", "not-found", keysOf(files)),
60
+ ).toBe("app/(marketing)/not-found");
61
+ });
62
+
63
+ test("nested groups resolve too", () => {
64
+ const files = ["app/(a)/(b)/not-found.tsx", "app/gone/page.tsx"];
65
+ const root = tree(files);
66
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
67
+ "app/(a)/(b)/not-found",
68
+ );
69
+ expect(
70
+ nearestBoundaryComponent("app/gone/page", "not-found", keysOf(files)),
71
+ ).toBe("app/(a)/(b)/not-found");
72
+ });
73
+
74
+ test("a real directory's own boundary wins over a group beside it", () => {
75
+ const files = [
76
+ "app/not-found.tsx",
77
+ "app/(marketing)/not-found.tsx",
78
+ "app/gone/page.tsx",
79
+ ];
80
+ const root = tree(files);
81
+ // (An app can't actually ship both — both claim "/" and the duplicate
82
+ // check rejects it — but the walk must still be deterministic.)
83
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
84
+ "app/not-found",
85
+ );
86
+ });
87
+
88
+ test("a nearer boundary still beats a group one higher up", () => {
89
+ const files = [
90
+ "app/(marketing)/not-found.tsx",
91
+ "app/dashboard/not-found.tsx",
92
+ "app/dashboard/settings/page.tsx",
93
+ ];
94
+ const root = tree(files);
95
+ expect(
96
+ findBoundaryIn(fs, path, root, "app/dashboard/settings/page", "not-found"),
97
+ ).toBe("app/dashboard/not-found");
98
+ expect(
99
+ nearestBoundaryComponent(
100
+ "app/dashboard/settings/page",
101
+ "not-found",
102
+ keysOf(files),
103
+ ),
104
+ ).toBe("app/dashboard/not-found");
105
+ });
106
+
107
+ test("a group boundary in a deeper URL scope does not leak upward", () => {
108
+ // app/(marketing)/pricing/not-found is at "/pricing"; a page at
109
+ // "/dashboard" must not get it.
110
+ const files = [
111
+ "app/(marketing)/pricing/not-found.tsx",
112
+ "app/dashboard/page.tsx",
113
+ ];
114
+ const root = tree(files);
115
+ expect(
116
+ findBoundaryIn(fs, path, root, "app/dashboard/page", "not-found"),
117
+ ).toBeNull();
118
+ expect(
119
+ nearestBoundaryComponent("app/dashboard/page", "not-found", keysOf(files)),
120
+ ).toBeNull();
121
+ });
122
+
123
+ test("a boundary inside a group still covers that group's own pages", () => {
124
+ const files = [
125
+ "app/(marketing)/not-found.tsx",
126
+ "app/(marketing)/pricing/page.tsx",
127
+ ];
128
+ const root = tree(files);
129
+ expect(
130
+ findBoundaryIn(fs, path, root, "app/(marketing)/pricing/page", "not-found"),
131
+ ).toBe("app/(marketing)/not-found");
132
+ });
133
+
134
+ test("error.tsx and loading.tsx follow the same rule", () => {
135
+ const files = [
136
+ "app/(shell)/error.tsx",
137
+ "app/(shell)/loading.tsx",
138
+ "app/reports/page.tsx",
139
+ ];
140
+ const root = tree(files);
141
+ expect(findBoundaryIn(fs, path, root, "app/reports/page", "error")).toBe(
142
+ "app/(shell)/error",
143
+ );
144
+ expect(findBoundaryIn(fs, path, root, "app/reports/page", "loading")).toBe(
145
+ "app/(shell)/loading",
146
+ );
147
+ });
148
+
149
+ test("an app with no boundary anywhere still resolves to null", () => {
150
+ const root = tree(["app/page.tsx"]);
151
+ expect(findBoundaryIn(fs, path, root, "app/page", "not-found")).toBeNull();
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Boundary metadata.
157
+ //
158
+ // Regression: `export const metadata` on not-found.tsx / error.tsx was ignored
159
+ // on the catch path (a page calling notFound(), or throwing). The rendered
160
+ // head had no <title> at all — and because the framework's built-in 404 body
161
+ // supplies one, taking over the boundary LOST the title rather than replacing
162
+ // it, leaving the tab showing the raw URL.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ import { renderMetadata } from "./ssr-runtime";
166
+
167
+ describe("renderMetadata drives boundary heads too", () => {
168
+ test("emits a title element a boundary can hoist", () => {
169
+ const React = require("react");
170
+ const frag = renderMetadata(React, { title: "Missing", description: "d" });
171
+ expect(frag).toBeTruthy();
172
+ const kids = React.Children.toArray(frag.props.children);
173
+ const types = kids.map((k: any) => k.type);
174
+ expect(types).toContain("title");
175
+ expect(types).toContain("meta");
176
+ });
177
+
178
+ test("no metadata yields nothing to wrap", () => {
179
+ // The boundary tree must be left alone rather than wrapped in an empty
180
+ // fragment, so hydration matches what the bundler baked for it.
181
+ expect(renderMetadata(require("react"), undefined)).toBeNull();
182
+ });
183
+ });
@@ -42,16 +42,38 @@ export function nearestBoundaryComponent(
42
42
  fileName: BoundaryFile,
43
43
  routeKeys: Iterable<string>,
44
44
  ): string | null {
45
- const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
46
- let dir = String(component);
47
- dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
48
- while (dir) {
49
- const key = `${dir}/${fileName}`;
50
- if (keys.has(key)) return key;
51
- const slash = dir.lastIndexOf("/");
52
- dir = slash >= 0 ? dir.slice(0, slash) : "";
45
+ const target = boundaryScope(component);
46
+ let best: { key: string; depth: number } | null = null;
47
+ for (const key of routeKeys) {
48
+ if (!key.endsWith(`/${fileName}`)) continue;
49
+ const scope = boundaryScope(key);
50
+ // Same URL scope, or an ancestor of it.
51
+ if (scope !== target && !target.startsWith(`${scope}/`)) continue;
52
+ const depth = scope === "" ? 0 : scope.split("/").length;
53
+ // Nearest ancestor wins; ties (a directory's own file vs one in a group
54
+ // beside it) resolve by key order so the answer is deterministic.
55
+ if (!best || depth > best.depth || (depth === best.depth && key < best.key)) {
56
+ best = { key, depth };
57
+ }
53
58
  }
54
- return null;
59
+ return best ? best.key : null;
60
+ }
61
+
62
+ /**
63
+ * The URL-space directory a module sits in: its parent path with `(group)`
64
+ * segments removed, since a group contributes no URL segment.
65
+ *
66
+ * `web/app/(marketing)/pricing/page` → `web/app/pricing`, and
67
+ * `web/app/(marketing)/not-found` → `web/app`. That is what makes a group's
68
+ * boundary resolve for `/` — matching the server, which walks the real
69
+ * directories but treats group dirs as transparent the same way.
70
+ */
71
+ export function boundaryScope(componentPath: string): string {
72
+ const parts = String(componentPath).replace(/\\/g, "/").split("/");
73
+ parts.pop(); // the module's own basename
74
+ return parts
75
+ .filter((p) => !(p.startsWith("(") && p.endsWith(")")))
76
+ .join("/");
55
77
  }
56
78
 
57
79
  /** Runtime internals the boundary needs, injected so the module stays
@@ -29,7 +29,10 @@ import {
29
29
  generateLoadingRegistry,
30
30
  type PylonBundleManifest,
31
31
  } from "./ssr-client-bundler";
32
- import { nearestBoundaryComponent } from "./ssr-client-boundary";
32
+ import {
33
+ boundaryScope,
34
+ nearestBoundaryComponent,
35
+ } from "./ssr-client-boundary";
33
36
 
34
37
  // State that needs cleanup between tests.
35
38
  let originalCwd: string | null = null;
@@ -650,3 +653,81 @@ describe("loading.tsx is wired into the client build", () => {
650
653
  ).toBe(true);
651
654
  });
652
655
  });
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Route groups and boundary resolution.
659
+ //
660
+ // Regression: a `not-found.tsx` inside a route group served unmatched URLs
661
+ // (the host matches those on the manifest's URL path, where a group adds no
662
+ // segment, so it sits at "/") but NOT a notFound() thrown by a page (which
663
+ // walked real directories and never crossed out of the page's own subtree).
664
+ // So a group boundary silently covered one class of 404 and not the other,
665
+ // while ROUTE_PATH_DUPLICATE blocked keeping a root copy alongside it.
666
+ // ---------------------------------------------------------------------------
667
+
668
+ describe("boundaryScope", () => {
669
+ test("drops group segments, keeps real ones", () => {
670
+ expect(boundaryScope("web/app/(marketing)/pricing/page")).toBe("web/app/pricing");
671
+ expect(boundaryScope("web/app/(marketing)/not-found")).toBe("web/app");
672
+ expect(boundaryScope("app/not-found")).toBe("app");
673
+ });
674
+
675
+ test("handles nested groups", () => {
676
+ expect(boundaryScope("app/(a)/(b)/not-found")).toBe("app");
677
+ });
678
+ });
679
+
680
+ describe("nearestBoundaryComponent across route groups", () => {
681
+ test("a group's boundary covers a page outside the group", () => {
682
+ // Both are at URL "/", so the group copy IS the root boundary.
683
+ expect(
684
+ nearestBoundaryComponent(
685
+ "app/gone/page",
686
+ "not-found",
687
+ new Set(["app/(marketing)/not-found", "app/gone/page"]),
688
+ ),
689
+ ).toBe("app/(marketing)/not-found");
690
+ });
691
+
692
+ test("a nested group's boundary resolves the same way", () => {
693
+ expect(
694
+ nearestBoundaryComponent(
695
+ "app/gone/page",
696
+ "not-found",
697
+ new Set(["app/(a)/(b)/not-found"]),
698
+ ),
699
+ ).toBe("app/(a)/(b)/not-found");
700
+ });
701
+
702
+ test("a nearer real boundary still beats a group one higher up", () => {
703
+ expect(
704
+ nearestBoundaryComponent(
705
+ "app/dashboard/settings/page",
706
+ "not-found",
707
+ new Set(["app/(marketing)/not-found", "app/dashboard/not-found"]),
708
+ ),
709
+ ).toBe("app/dashboard/not-found");
710
+ });
711
+
712
+ test("a group boundary does not leak into a deeper URL scope", () => {
713
+ // app/(marketing)/pricing/not-found is at "/pricing" — it must not answer
714
+ // for a page at "/dashboard".
715
+ expect(
716
+ nearestBoundaryComponent(
717
+ "app/dashboard/page",
718
+ "not-found",
719
+ new Set(["app/(marketing)/pricing/not-found"]),
720
+ ),
721
+ ).toBeNull();
722
+ });
723
+
724
+ test("a boundary inside a group still covers that group's own pages", () => {
725
+ expect(
726
+ nearestBoundaryComponent(
727
+ "app/(marketing)/pricing/page",
728
+ "not-found",
729
+ new Set(["app/(marketing)/not-found"]),
730
+ ),
731
+ ).toBe("app/(marketing)/not-found");
732
+ });
733
+ });
@@ -598,6 +598,14 @@ function whenLoaded(fn) {
598
598
  window.addEventListener("load", fn, { once: true });
599
599
  }
600
600
 
601
+ // Marks a request as a client-side NAVIGATION rather than a document load.
602
+ // The server answers with the head metadata + __PYLON_DATA__ and skips the
603
+ // page markup, which this runtime re-renders from that data anyway. The host
604
+ // keys its render cache on the same header, so the two shapes never share an
605
+ // entry. Prefetch and navigate MUST send identical headers, or the prefetched
606
+ // response is the wrong shape for the click that consumes it.
607
+ const NAV_REQUEST_HEADERS = { Accept: "text/html", "X-Pylon-Nav": "1" };
608
+
601
609
  // Payloads warmed by a hover, consumed by the click that follows. See
602
610
  // ./nav-cache for why this is in memory rather than an HTTP-level prefetch.
603
611
  const navPayloads = createNavPayloadCache({
@@ -605,7 +613,7 @@ const navPayloads = createNavPayloadCache({
605
613
  fetchPage: (target) =>
606
614
  fetch(target, {
607
615
  credentials: "same-origin",
608
- headers: { Accept: "text/html" },
616
+ headers: NAV_REQUEST_HEADERS,
609
617
  }).then((res) => {
610
618
  // A redirect means the URL navigate() would commit isn't the one that
611
619
  // answered; let the real navigation resolve that itself.
@@ -878,7 +886,7 @@ async function navigate(href, opts) {
878
886
  if (html == null) {
879
887
  const res = await fetch(target, {
880
888
  credentials: "same-origin",
881
- headers: { Accept: "text/html" },
889
+ headers: NAV_REQUEST_HEADERS,
882
890
  });
883
891
  if (!res.ok) {
884
892
  fullLoad();
@@ -0,0 +1,95 @@
1
+ // Regression tests for the client-navigation response shape.
2
+ //
3
+ // A client-side navigation re-renders the page from __PYLON_DATA__, so the
4
+ // markup the server built for it is parsed and thrown away. Measured on a real
5
+ // route, that was 16442 bytes of HTML to deliver a 374-byte payload. The nav
6
+ // response carries the head metadata and the data, and nothing else.
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { buildHydrationTail, isNavRequest } from "./ssr-runtime";
10
+
11
+ describe("isNavRequest", () => {
12
+ it("recognizes the header the client sends", () => {
13
+ expect(isNavRequest({ "x-pylon-nav": "1" })).toBe(true);
14
+ });
15
+
16
+ it("tolerates surrounding whitespace", () => {
17
+ expect(isNavRequest({ "x-pylon-nav": " 1 " })).toBe(true);
18
+ });
19
+
20
+ it("treats a document load as a document load", () => {
21
+ expect(isNavRequest({})).toBe(false);
22
+ expect(isNavRequest(undefined)).toBe(false);
23
+ expect(isNavRequest({ accept: "text/html" })).toBe(false);
24
+ });
25
+
26
+ it("refuses any value other than 1", () => {
27
+ // Fail closed: an unrecognized value serves the full page, which is
28
+ // always correct, rather than a payload the caller can't use.
29
+ expect(isNavRequest({ "x-pylon-nav": "0" })).toBe(false);
30
+ expect(isNavRequest({ "x-pylon-nav": "true" })).toBe(false);
31
+ expect(isNavRequest({ "x-pylon-nav": "" })).toBe(false);
32
+ });
33
+ });
34
+
35
+ const TAIL_ARGS = {
36
+ component: "app/dashboard/page",
37
+ layouts: ["app/layout"],
38
+ props: { url: "/dashboard", params: {}, auth: { user_id: "u_1" } },
39
+ ssrData: { "list:[\"Todo\"]": [{ id: "t1" }] },
40
+ manifestRoute: { file: "entry-a1.js", imports: ["chunks/s.js"], css: [] },
41
+ publicPrefix: "/_pylon/build/",
42
+ manifestErr: null,
43
+ };
44
+
45
+ describe("hydration tail, dataOnly", () => {
46
+ it("carries the same payload as a full page tail", () => {
47
+ const data = (html: string) =>
48
+ html.match(/<script id="__PYLON_DATA__"[^>]*>(.*?)<\/script>/s)?.[1];
49
+ expect(data(buildHydrationTail({ ...TAIL_ARGS, dataOnly: true }))).toBe(
50
+ data(buildHydrationTail(TAIL_ARGS))!,
51
+ );
52
+ });
53
+
54
+ it("drops the entry script and preloads a navigation doesn't need", () => {
55
+ // The client already has the runtime and resolves the route entry from the
56
+ // build manifest, so these are dead weight on every navigation.
57
+ const nav = buildHydrationTail({ ...TAIL_ARGS, dataOnly: true });
58
+ expect(nav).not.toContain("<script type=\"module\"");
59
+ expect(nav).not.toContain("entry-a1.js");
60
+ const full = buildHydrationTail(TAIL_ARGS);
61
+ expect(full).toContain("entry-a1.js");
62
+ });
63
+
64
+ it("still strips the live handles and the request's own headers/cookies", () => {
65
+ // SECURITY: the strip is why this shares buildHydrationTail rather than
66
+ // rebuilding the payload — the session cookie must never reach client JS.
67
+ const nav = buildHydrationTail({
68
+ ...TAIL_ARGS,
69
+ props: {
70
+ ...TAIL_ARGS.props,
71
+ headers: { cookie: "pylon_session=secret" },
72
+ cookies: { pylon_session: "secret" },
73
+ serverData: { list: () => {} },
74
+ response: { setStatus: () => {} },
75
+ },
76
+ dataOnly: true,
77
+ });
78
+ expect(nav).not.toContain("secret");
79
+ expect(nav).not.toContain("serverData");
80
+ });
81
+
82
+ it("carries only the binary signed-in bit on a bucketed render", () => {
83
+ // A bucketed render is stored SHARED, so its payload must never carry the
84
+ // rendering user's identity — same rule, data-only or not.
85
+ const nav = buildHydrationTail({
86
+ ...TAIL_ARGS,
87
+ props: { ...TAIL_ARGS.props, auth: { user_id: "u_1", email: "a@b.c" } },
88
+ bucketAuth: { signedIn: true },
89
+ dataOnly: true,
90
+ });
91
+ expect(nav).not.toContain("u_1");
92
+ expect(nav).not.toContain("a@b.c");
93
+ expect(nav).toContain('"signedIn":true');
94
+ });
95
+ });
@@ -763,24 +763,85 @@ async function buildLayoutTree(
763
763
  * Returns the project-relative path (no extension) or null.
764
764
  */
765
765
  function findBoundary(componentPath: string, fileName: string): string | null {
766
- const fs = require("node:fs");
767
- const path = require("node:path");
768
- const cwd = process.cwd();
766
+ return findBoundaryIn(
767
+ require("node:fs"),
768
+ require("node:path"),
769
+ process.cwd(),
770
+ componentPath,
771
+ fileName,
772
+ );
773
+ }
774
+
775
+ /**
776
+ * `findBoundary` with its filesystem and root injected, so the walk is
777
+ * testable against a fixture without `chdir` — which races every other test
778
+ * in the process.
779
+ */
780
+ export function findBoundaryIn(
781
+ fs: any,
782
+ path: any,
783
+ cwd: string,
784
+ componentPath: string,
785
+ fileName: string,
786
+ ): string | null {
769
787
  // Component paths use "/" — walk up directory by directory.
770
788
  let dir = componentPath.replace(/\\/g, "/");
771
789
  dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
772
790
  while (dir && dir !== "." && dir !== "/") {
773
- for (const ext of MODULE_EXTS) {
774
- if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
775
- return `${dir}/${fileName}`;
776
- }
777
- }
791
+ const hit = boundaryInDirOrGroups(fs, path, cwd, dir, fileName);
792
+ if (hit) return hit;
778
793
  const slash = dir.lastIndexOf("/");
779
794
  dir = slash >= 0 ? dir.slice(0, slash) : "";
780
795
  }
781
796
  return null;
782
797
  }
783
798
 
799
+ /**
800
+ * The boundary for one URL-space level: `<dir>/<fileName>`, or the same file
801
+ * inside a route group under it.
802
+ *
803
+ * A route group contributes no URL segment, so `app/(marketing)/not-found` is
804
+ * the boundary for `/` exactly as `app/not-found` is — and an app can't ship
805
+ * both, because the duplicate-path check rejects two routes claiming `/`.
806
+ * Without this, putting the file in a group left `/` with no boundary at all
807
+ * while the build insisted the group's copy owned it.
808
+ *
809
+ * Groups nest, so this recurses. The directory's own file wins over a group's,
810
+ * and groups are searched in name order so the answer never depends on
811
+ * readdir ordering.
812
+ */
813
+ function boundaryInDirOrGroups(
814
+ fs: any,
815
+ path: any,
816
+ cwd: string,
817
+ dir: string,
818
+ fileName: string,
819
+ ): string | null {
820
+ for (const ext of MODULE_EXTS) {
821
+ if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
822
+ return `${dir}/${fileName}`;
823
+ }
824
+ }
825
+ let entries: any[];
826
+ try {
827
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
828
+ } catch {
829
+ return null;
830
+ }
831
+ const groups = entries
832
+ .filter(
833
+ (e: any) =>
834
+ e.isDirectory() && e.name.startsWith("(") && e.name.endsWith(")"),
835
+ )
836
+ .map((e: any) => e.name as string)
837
+ .sort();
838
+ for (const g of groups) {
839
+ const hit = boundaryInDirOrGroups(fs, path, cwd, `${dir}/${g}`, fileName);
840
+ if (hit) return hit;
841
+ }
842
+ return null;
843
+ }
844
+
784
845
  // ---------------------------------------------------------------------------
785
846
  // Social-card image file convention (Next-style `opengraph-image.png` /
786
847
  // `twitter-image.png` colocated with a `page.tsx`). Drop the file in a
@@ -1904,6 +1965,13 @@ export function buildHydrationTail(args: {
1904
1965
  // user's identity to another (the #277 leak class, at the body level). The
1905
1966
  // raw `auth` is replaced AFTER the live-handle strip below.
1906
1967
  bucketAuth?: { signedIn: boolean };
1968
+ /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
1969
+ * nothing else. The client already has the runtime and resolves the route
1970
+ * entry from the build manifest, so the entry `<script>`, the preload tags
1971
+ * and the dev-reload snippet are all dead weight on a navigation. Shares
1972
+ * this function — and therefore its props strip — rather than rebuilding
1973
+ * the payload somewhere the security-relevant parts could drift. */
1974
+ dataOnly?: boolean;
1907
1975
  }): string {
1908
1976
  // Strip live, non-serializable handles (serverData / response / reset) + the
1909
1977
  // request headers/cookies (SECURITY: never expose the session cookie to
@@ -1950,6 +2018,7 @@ export function buildHydrationTail(args: {
1950
2018
  if (args.kind) hydrationPayload.kind = args.kind;
1951
2019
  const json = escapeScriptJson(JSON.stringify(hydrationPayload));
1952
2020
  let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
2021
+ if (args.dataOnly) return tail;
1953
2022
  if (args.manifestRoute) {
1954
2023
  // A cross-origin CDN module (`public_prefix` is an absolute http(s) URL) is
1955
2024
  // fetched in CORS mode; `crossorigin` makes the matching modulepreload
@@ -1967,6 +2036,54 @@ export function buildHydrationTail(args: {
1967
2036
  return tail;
1968
2037
  }
1969
2038
 
2039
+ /**
2040
+ * Is this render answering a client-side navigation rather than a document
2041
+ * load? The client runtime sets `X-Pylon-Nav: 1`; the Rust host reads the same
2042
+ * header to key the ISR cache, so the two always agree on which shape a URL
2043
+ * is being asked for.
2044
+ */
2045
+ export function isNavRequest(
2046
+ headers: Record<string, unknown> | undefined,
2047
+ ): boolean {
2048
+ if (!headers) return false;
2049
+ const v = headers["x-pylon-nav"] ?? headers["X-Pylon-Nav"];
2050
+ return typeof v === "string" && v.trim() === "1";
2051
+ }
2052
+
2053
+ /**
2054
+ * Render a detached element (the metadata fragment) to an HTML string.
2055
+ *
2056
+ * A navigation response carries the page's `<title>`/`<meta>`/`<link>` so the
2057
+ * client can swap them, but not the page body — which the client re-renders
2058
+ * from `__PYLON_DATA__` anyway. Rendering the fragment on its own is what
2059
+ * lets the body be dropped without losing the head.
2060
+ */
2061
+ export async function renderElementToString(
2062
+ renderToReadableStream: any,
2063
+ element: any,
2064
+ ): Promise<string> {
2065
+ if (!element) return "";
2066
+ try {
2067
+ const stream = await renderToReadableStream(element);
2068
+ if ((stream as any).allReady) await (stream as any).allReady;
2069
+ const reader = stream.getReader();
2070
+ // One decoder across the whole read: a multi-byte character split across
2071
+ // chunk boundaries decodes correctly only if the decoder carries state.
2072
+ const decoder = new TextDecoder("utf-8");
2073
+ let out = "";
2074
+ for (;;) {
2075
+ const { done, value } = await reader.read();
2076
+ if (done) break;
2077
+ out += decoder.decode(value, { stream: true });
2078
+ }
2079
+ out += decoder.decode();
2080
+ return out;
2081
+ } catch {
2082
+ // Metadata is never worth failing a navigation over.
2083
+ return "";
2084
+ }
2085
+ }
2086
+
1970
2087
  /**
1971
2088
  * The layout chain for a component, walked top-down from `app/` to the
1972
2089
  * component's own directory — IDENTICAL to the bundler's `discoverRoutes`
@@ -2146,7 +2263,32 @@ async function tryRenderBoundary(
2146
2263
  };
2147
2264
  compProps = { ...props, error: errorForClient, reset: () => {} };
2148
2265
  }
2266
+ // A boundary's own `metadata` / `generateMetadata`, same as a page's.
2267
+ // Without this a not-found.tsx rendered no <title> at all — and because
2268
+ // the framework's built-in 404 body supplies one, taking over the boundary
2269
+ // silently LOST the title rather than replacing it, leaving the tab
2270
+ // showing the raw URL. The fragment goes first so React hoists its
2271
+ // <title>/<meta> into the <head> a layout renders.
2272
+ let boundaryMeta: SsrMetadata | undefined = mod.metadata;
2273
+ if (typeof mod.generateMetadata === "function") {
2274
+ try {
2275
+ boundaryMeta = await mod.generateMetadata(compProps);
2276
+ } catch {
2277
+ // A boundary is already the failure path; don't fail it again over
2278
+ // its title.
2279
+ boundaryMeta = mod.metadata;
2280
+ }
2281
+ }
2282
+ const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
2149
2283
  let tree = React.createElement(Comp, compProps);
2284
+ if (boundaryMetaFragment) {
2285
+ tree = React.createElement(
2286
+ React.Fragment,
2287
+ null,
2288
+ boundaryMetaFragment,
2289
+ tree,
2290
+ );
2291
+ }
2150
2292
  tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
2151
2293
  await renderBoundaryToClient(
2152
2294
  React,
@@ -2969,6 +3111,14 @@ export async function handleRenderRoute(
2969
3111
  metadata = applyAutoIcons(msg.component, metadata);
2970
3112
  const metaFragment = renderMetadata(React, metadata);
2971
3113
 
3114
+ // Client-side navigation: the browser already has the runtime, the layouts
3115
+ // and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
3116
+ // on a real route, the full document was 16442 bytes to deliver a 374-byte
3117
+ // payload — the markup is built, shipped, parsed, and thrown away. This
3118
+ // render still runs (it is what resolves serverData), but the response
3119
+ // carries only the head metadata and the data.
3120
+ const navRender = isNavRequest(msg.headers as Record<string, unknown>);
3121
+
2972
3122
  // loading.tsx (#278): the nearest `loading` module — walked up from the
2973
3123
  // page dir, like not-found/error — becomes ONE route-level Suspense
2974
3124
  // fallback wrapping the page. When present, the shell (layouts) + this
@@ -3094,7 +3244,10 @@ export async function handleRenderRoute(
3094
3244
  // the fully-resolved `ssrData` map) and after all of React's $RC reveals,
3095
3245
  // so the client's `use()` reads a fulfilled value and never re-suspends —
3096
3246
  // there is no progressive hydration racing the stream.
3097
- if (!wantsStream && (stream as any).allReady) {
3247
+ // A navigation response is data, so it must wait for the whole render even
3248
+ // on a page that would otherwise stream: `ssrData` is only complete once
3249
+ // every serverData read has resolved, and that map IS the response.
3250
+ if ((!wantsStream || navRender) && (stream as any).allReady) {
3098
3251
  await (stream as any).allReady;
3099
3252
  }
3100
3253
 
@@ -3314,7 +3467,26 @@ export async function handleRenderRoute(
3314
3467
  data: Buffer.from(text, "utf8").toString("base64"),
3315
3468
  });
3316
3469
  };
3317
- await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
3470
+ if (navRender) {
3471
+ // Drain React's output and drop it. The render is what resolves the
3472
+ // serverData reads that fill `ssrData`; the markup it produces is the
3473
+ // part the client rebuilds for itself.
3474
+ const reader = stream.getReader();
3475
+ for (;;) {
3476
+ const { done } = await reader.read();
3477
+ if (done) break;
3478
+ }
3479
+ // A minimal document, so the client's existing parse path works
3480
+ // unchanged: it reads document.title, copies [data-pylon-meta], and
3481
+ // pulls __PYLON_DATA__ — all of which this carries.
3482
+ sendChunk(
3483
+ '<!DOCTYPE html><html><head><meta charset="utf-8">' +
3484
+ (await renderElementToString(renderToReadableStream, metaFragment)) +
3485
+ "</head><body>",
3486
+ );
3487
+ } else {
3488
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
3489
+ }
3318
3490
 
3319
3491
  // #278: detect a late response.* mutation from a suspended subtree that the
3320
3492
  // already-committed head couldn't carry, and warn loudly (a silently
@@ -3401,17 +3573,22 @@ export async function handleRenderRoute(
3401
3573
  bucketAuth: bucketable
3402
3574
  ? { signedIn: msg.session_present === true }
3403
3575
  : undefined,
3576
+ dataOnly: navRender,
3404
3577
  });
3405
3578
  sendChunk(tail);
3406
3579
  }
3407
3580
 
3581
+
3408
3582
  // Dev HUD (dev only): append the cache-verdict + render-timing blob + the
3409
3583
  // floating overlay, and emit ONE structured log line. After the page tail so
3410
3584
  // the HUD marker/probe are in place before the deferred client entry boots the
3411
3585
  // sync engine. `devVerdict` was computed once above (reused here + in the
3412
3586
  // x-pylon-dev header). The log rides the runtime's inherited stderr, so an
3413
3587
  // agent running `pylon dev` sees the verdict without any extra call.
3414
- if (devVerdict) {
3588
+ //
3589
+ // Never on a navigation response: there is no document for the overlay to
3590
+ // attach to, and its blob would dwarf the payload it rode in on.
3591
+ if (devVerdict && !navRender) {
3415
3592
  const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
3416
3593
  // Build-level failures that degraded this page (currently: the
3417
3594
  // Tailwind compile). Dev-only and unmissable — the HUD paints a
@@ -3452,6 +3629,8 @@ export async function handleRenderRoute(
3452
3629
  // [pylon] line — no duplicate console.error here.
3453
3630
  }
3454
3631
 
3632
+ if (navRender) sendChunk("</body></html>");
3633
+
3455
3634
  send({ type: "render_done", call_id: msg.call_id });
3456
3635
  } catch (err: any) {
3457
3636
  // A page/layout called response.redirect()/response.notFound(), or