@pylonsync/functions 0.4.11 → 0.4.13
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/dist/ssr-client-boundary.d.ts +10 -0
- package/dist/ssr-runtime.d.ts +29 -0
- package/package.json +1 -1
- package/src/ssr-boundary-groups.test.ts +183 -0
- package/src/ssr-client-boundary.ts +31 -9
- package/src/ssr-client-bundler.test.ts +82 -1
- package/src/ssr-client-bundler.ts +10 -2
- package/src/ssr-nav-response.test.ts +95 -0
- package/src/ssr-runtime.ts +203 -11
|
@@ -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 {
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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,45 @@ 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
|
+
// The same file conventions a page gets, resolved against the BOUNDARY's
|
|
2283
|
+
// own directory: a colocated opengraph-image / icon next to not-found.tsx
|
|
2284
|
+
// is that boundary's, not the failing page's. Without these, one 404 URL
|
|
2285
|
+
// carried the social card and another didn't, depending only on whether
|
|
2286
|
+
// the route matched — the boundary was reached two ways and looked
|
|
2287
|
+
// different each way.
|
|
2288
|
+
boundaryMeta = applyAutoSocialImages(
|
|
2289
|
+
rel,
|
|
2290
|
+
compProps?.headers,
|
|
2291
|
+
boundaryMeta,
|
|
2292
|
+
compProps?.url,
|
|
2293
|
+
);
|
|
2294
|
+
boundaryMeta = applyAutoIcons(rel, boundaryMeta);
|
|
2295
|
+
const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
|
|
2149
2296
|
let tree = React.createElement(Comp, compProps);
|
|
2297
|
+
if (boundaryMetaFragment) {
|
|
2298
|
+
tree = React.createElement(
|
|
2299
|
+
React.Fragment,
|
|
2300
|
+
null,
|
|
2301
|
+
boundaryMetaFragment,
|
|
2302
|
+
tree,
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2150
2305
|
tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
|
|
2151
2306
|
await renderBoundaryToClient(
|
|
2152
2307
|
React,
|
|
@@ -2969,6 +3124,14 @@ export async function handleRenderRoute(
|
|
|
2969
3124
|
metadata = applyAutoIcons(msg.component, metadata);
|
|
2970
3125
|
const metaFragment = renderMetadata(React, metadata);
|
|
2971
3126
|
|
|
3127
|
+
// Client-side navigation: the browser already has the runtime, the layouts
|
|
3128
|
+
// and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
|
|
3129
|
+
// on a real route, the full document was 16442 bytes to deliver a 374-byte
|
|
3130
|
+
// payload — the markup is built, shipped, parsed, and thrown away. This
|
|
3131
|
+
// render still runs (it is what resolves serverData), but the response
|
|
3132
|
+
// carries only the head metadata and the data.
|
|
3133
|
+
const navRender = isNavRequest(msg.headers as Record<string, unknown>);
|
|
3134
|
+
|
|
2972
3135
|
// loading.tsx (#278): the nearest `loading` module — walked up from the
|
|
2973
3136
|
// page dir, like not-found/error — becomes ONE route-level Suspense
|
|
2974
3137
|
// fallback wrapping the page. When present, the shell (layouts) + this
|
|
@@ -3094,7 +3257,10 @@ export async function handleRenderRoute(
|
|
|
3094
3257
|
// the fully-resolved `ssrData` map) and after all of React's $RC reveals,
|
|
3095
3258
|
// so the client's `use()` reads a fulfilled value and never re-suspends —
|
|
3096
3259
|
// there is no progressive hydration racing the stream.
|
|
3097
|
-
|
|
3260
|
+
// A navigation response is data, so it must wait for the whole render even
|
|
3261
|
+
// on a page that would otherwise stream: `ssrData` is only complete once
|
|
3262
|
+
// every serverData read has resolved, and that map IS the response.
|
|
3263
|
+
if ((!wantsStream || navRender) && (stream as any).allReady) {
|
|
3098
3264
|
await (stream as any).allReady;
|
|
3099
3265
|
}
|
|
3100
3266
|
|
|
@@ -3314,7 +3480,26 @@ export async function handleRenderRoute(
|
|
|
3314
3480
|
data: Buffer.from(text, "utf8").toString("base64"),
|
|
3315
3481
|
});
|
|
3316
3482
|
};
|
|
3317
|
-
|
|
3483
|
+
if (navRender) {
|
|
3484
|
+
// Drain React's output and drop it. The render is what resolves the
|
|
3485
|
+
// serverData reads that fill `ssrData`; the markup it produces is the
|
|
3486
|
+
// part the client rebuilds for itself.
|
|
3487
|
+
const reader = stream.getReader();
|
|
3488
|
+
for (;;) {
|
|
3489
|
+
const { done } = await reader.read();
|
|
3490
|
+
if (done) break;
|
|
3491
|
+
}
|
|
3492
|
+
// A minimal document, so the client's existing parse path works
|
|
3493
|
+
// unchanged: it reads document.title, copies [data-pylon-meta], and
|
|
3494
|
+
// pulls __PYLON_DATA__ — all of which this carries.
|
|
3495
|
+
sendChunk(
|
|
3496
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">' +
|
|
3497
|
+
(await renderElementToString(renderToReadableStream, metaFragment)) +
|
|
3498
|
+
"</head><body>",
|
|
3499
|
+
);
|
|
3500
|
+
} else {
|
|
3501
|
+
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
3502
|
+
}
|
|
3318
3503
|
|
|
3319
3504
|
// #278: detect a late response.* mutation from a suspended subtree that the
|
|
3320
3505
|
// already-committed head couldn't carry, and warn loudly (a silently
|
|
@@ -3401,17 +3586,22 @@ export async function handleRenderRoute(
|
|
|
3401
3586
|
bucketAuth: bucketable
|
|
3402
3587
|
? { signedIn: msg.session_present === true }
|
|
3403
3588
|
: undefined,
|
|
3589
|
+
dataOnly: navRender,
|
|
3404
3590
|
});
|
|
3405
3591
|
sendChunk(tail);
|
|
3406
3592
|
}
|
|
3407
3593
|
|
|
3594
|
+
|
|
3408
3595
|
// Dev HUD (dev only): append the cache-verdict + render-timing blob + the
|
|
3409
3596
|
// floating overlay, and emit ONE structured log line. After the page tail so
|
|
3410
3597
|
// the HUD marker/probe are in place before the deferred client entry boots the
|
|
3411
3598
|
// sync engine. `devVerdict` was computed once above (reused here + in the
|
|
3412
3599
|
// x-pylon-dev header). The log rides the runtime's inherited stderr, so an
|
|
3413
3600
|
// agent running `pylon dev` sees the verdict without any extra call.
|
|
3414
|
-
|
|
3601
|
+
//
|
|
3602
|
+
// Never on a navigation response: there is no document for the overlay to
|
|
3603
|
+
// attach to, and its blob would dwarf the payload it rode in on.
|
|
3604
|
+
if (devVerdict && !navRender) {
|
|
3415
3605
|
const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
|
|
3416
3606
|
// Build-level failures that degraded this page (currently: the
|
|
3417
3607
|
// Tailwind compile). Dev-only and unmissable — the HUD paints a
|
|
@@ -3452,6 +3642,8 @@ export async function handleRenderRoute(
|
|
|
3452
3642
|
// [pylon] line — no duplicate console.error here.
|
|
3453
3643
|
}
|
|
3454
3644
|
|
|
3645
|
+
if (navRender) sendChunk("</body></html>");
|
|
3646
|
+
|
|
3455
3647
|
send({ type: "render_done", call_id: msg.call_id });
|
|
3456
3648
|
} catch (err: any) {
|
|
3457
3649
|
// A page/layout called response.redirect()/response.notFound(), or
|