@pylonsync/functions 0.3.246 → 0.3.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime.ts +7 -1
- package/src/ssr-client-bundler.ts +63 -3
- package/src/ssr-runtime.test.ts +166 -1
- package/src/ssr-runtime.ts +286 -55
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -223,11 +223,17 @@ function dispatch(line: string): void {
|
|
|
223
223
|
),
|
|
224
224
|
)
|
|
225
225
|
.catch((err) => {
|
|
226
|
+
const devMode =
|
|
227
|
+
process.env.PYLON_DEV_MODE === "1" ||
|
|
228
|
+
process.env.PYLON_DEV_MODE === "true";
|
|
226
229
|
send({
|
|
227
230
|
type: "error",
|
|
228
231
|
call_id: (msg as unknown as { call_id: string }).call_id,
|
|
229
232
|
code: "SSR_RUNTIME_CRASH",
|
|
230
|
-
|
|
233
|
+
// Dev: full stack for the host's error overlay. Prod: message only.
|
|
234
|
+
message:
|
|
235
|
+
(devMode && err?.stack ? String(err.stack) : err?.message) ||
|
|
236
|
+
String(err),
|
|
231
237
|
});
|
|
232
238
|
});
|
|
233
239
|
} else if (msg.type === "bundle_client") {
|
|
@@ -130,6 +130,22 @@ function discoverRoutes(
|
|
|
130
130
|
layouts: nextLayouts,
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
+
// Boundary modules (not-found.tsx / error.tsx) are hydrated like pages
|
|
134
|
+
// (#279) so onClick/useState/reset() work — that means each needs its own
|
|
135
|
+
// client entry + manifest key, keyed by component path exactly like a page.
|
|
136
|
+
// They wrap in the layouts ABOVE them (nextLayouts), same as a page here.
|
|
137
|
+
for (const base of ["not-found", "error"]) {
|
|
138
|
+
const bHere = [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`]
|
|
139
|
+
.map((n: string) => path.join(dir, n))
|
|
140
|
+
.find((p: string) => fs.existsSync(p));
|
|
141
|
+
if (bHere) {
|
|
142
|
+
pages.push({
|
|
143
|
+
segments: [...segments],
|
|
144
|
+
component: path.relative(cwd, bHere).replace(/\.(tsx?|jsx?)$/, ""),
|
|
145
|
+
layouts: nextLayouts,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
133
149
|
for (const e of entries) {
|
|
134
150
|
if (!e.isDirectory()) continue;
|
|
135
151
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
@@ -271,10 +287,18 @@ function makeNoopResponse() {
|
|
|
271
287
|
|
|
272
288
|
// Rehydrate the live, server-only props (serverData + response) that were
|
|
273
289
|
// stripped before serialization, so the client tree matches the server's.
|
|
290
|
+
// For a hydrated error boundary (#279), synthesize the reset() the server
|
|
291
|
+
// rendered as a no-op: re-fetch + re-render the current URL (a transient
|
|
292
|
+
// error clears to the page; a deterministic one re-shows the boundary).
|
|
274
293
|
function withClientProps(data) {
|
|
275
294
|
const props = { ...(data.props || {}) };
|
|
276
295
|
props.serverData = makeClientServerData(data.ssrData);
|
|
277
296
|
props.response = makeNoopResponse();
|
|
297
|
+
if (data.kind === "error") {
|
|
298
|
+
props.reset = function () {
|
|
299
|
+
navigate(location.pathname + location.search, { replace: true });
|
|
300
|
+
};
|
|
301
|
+
}
|
|
278
302
|
return props;
|
|
279
303
|
}
|
|
280
304
|
|
|
@@ -382,6 +406,27 @@ export function hydrate(component, Page, Layouts) {
|
|
|
382
406
|
// only need to populate the cache (already done above).
|
|
383
407
|
}
|
|
384
408
|
|
|
409
|
+
// Swap the page's SEO/social <head> tags on a client-side navigation.
|
|
410
|
+
// The SSR runtime marks every page-metadata <meta>/<link> with
|
|
411
|
+
// data-pylon-meta; we drop the current set and import the incoming page's
|
|
412
|
+
// set, so description / canonical / og:* / twitter:* / icons track the new
|
|
413
|
+
// route. The layout's charset/viewport and Pylon's injected stylesheet
|
|
414
|
+
// links carry no marker, so they survive untouched (no FOUC). The page
|
|
415
|
+
// component never renders these tags on the client, so React doesn't own
|
|
416
|
+
// them — this manual swap can't fight hydration.
|
|
417
|
+
function syncHeadMeta(doc) {
|
|
418
|
+
const head = document.head;
|
|
419
|
+
if (!head) return;
|
|
420
|
+
const current = head.querySelectorAll("[data-pylon-meta]");
|
|
421
|
+
for (let i = 0; i < current.length; i++) current[i].remove();
|
|
422
|
+
const incoming = doc.head
|
|
423
|
+
? doc.head.querySelectorAll("[data-pylon-meta]")
|
|
424
|
+
: [];
|
|
425
|
+
for (let i = 0; i < incoming.length; i++) {
|
|
426
|
+
head.appendChild(document.importNode(incoming[i], true));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
385
430
|
async function navigate(href, opts) {
|
|
386
431
|
const push = !opts || opts.push !== false;
|
|
387
432
|
const url = new URL(href, location.href);
|
|
@@ -426,11 +471,19 @@ async function navigate(href, opts) {
|
|
|
426
471
|
return;
|
|
427
472
|
}
|
|
428
473
|
document.title = doc.title || document.title;
|
|
474
|
+
syncHeadMeta(doc);
|
|
429
475
|
const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
|
|
430
476
|
activeRoot.render(tree);
|
|
431
|
-
|
|
432
|
-
|
|
477
|
+
const target = url.pathname + url.search;
|
|
478
|
+
if (opts && opts.replace) {
|
|
479
|
+
history.replaceState({ component: data.component }, "", target);
|
|
480
|
+
} else if (push) {
|
|
481
|
+
history.pushState({ component: data.component }, "", target);
|
|
433
482
|
}
|
|
483
|
+
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
484
|
+
// re-read location after a Link click or a router.push(). popstate already
|
|
485
|
+
// covers back/forward, but pushState/replaceState fire no event.
|
|
486
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
434
487
|
// After a successful nav, scroll to top (Next.js default).
|
|
435
488
|
window.scrollTo(0, 0);
|
|
436
489
|
}
|
|
@@ -635,7 +688,14 @@ async function buildTailwind(
|
|
|
635
688
|
// Mix in the discovered routes so adding/removing pages changes
|
|
636
689
|
// the hash (Tailwind v4 auto-discovers `@source` paths; we still
|
|
637
690
|
// want the cache to bust on layout changes).
|
|
638
|
-
|
|
691
|
+
//
|
|
692
|
+
// Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
|
|
693
|
+
// (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8
|
|
694
|
+
// chars (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars,
|
|
695
|
+
// so WITHOUT the pad the content-hashed CSS was served `no-cache` —
|
|
696
|
+
// browsers + any CDN refetched it on every page load (defeats the cache
|
|
697
|
+
// and, behind Cloudflare, needlessly wakes an autostopped origin).
|
|
698
|
+
const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
|
|
639
699
|
const outPath = path.join(outdir, stylesName);
|
|
640
700
|
|
|
641
701
|
// Spawn the CLI. Bun is already running; reuse it as the
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -4,7 +4,36 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
applyAutoIcons,
|
|
9
|
+
applyAutoSocialImages,
|
|
10
|
+
renderMetadata,
|
|
11
|
+
buildHydrationTail,
|
|
12
|
+
errorDigest,
|
|
13
|
+
} from "./ssr-runtime";
|
|
14
|
+
|
|
15
|
+
// Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
|
|
16
|
+
function extractPylonData(tail: string): any {
|
|
17
|
+
const m = tail.match(
|
|
18
|
+
/<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
|
|
19
|
+
);
|
|
20
|
+
if (!m) throw new Error("no __PYLON_DATA__ in tail");
|
|
21
|
+
return JSON.parse(m[1]); // JSON.parse natively decodes the < escaping
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `react` isn't a dependency of @pylonsync/functions — the SSR runtime
|
|
25
|
+
// imports it dynamically from the host project at render time. For unit
|
|
26
|
+
// tests we hand renderMetadata a fake React that records each created
|
|
27
|
+
// element's shape, which is all renderMetadata touches.
|
|
28
|
+
const FAKE_FRAGMENT = Symbol("Fragment");
|
|
29
|
+
const fakeReact = {
|
|
30
|
+
Fragment: FAKE_FRAGMENT,
|
|
31
|
+
createElement: (type: any, props: any, ...children: any[]) => ({
|
|
32
|
+
type,
|
|
33
|
+
props: props ?? {},
|
|
34
|
+
children,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
8
37
|
|
|
9
38
|
// A minimal PNG: 8-byte signature + an IHDR chunk carrying width/height.
|
|
10
39
|
// `readSocialImageMeta` only reads the first 32 bytes, so a full valid
|
|
@@ -180,3 +209,139 @@ describe("icon / apple-icon / favicon file convention", () => {
|
|
|
180
209
|
expect(applyAutoIcons("app/page", input)).toEqual(input);
|
|
181
210
|
});
|
|
182
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("renderMetadata head-tag marking (client-nav sync)", () => {
|
|
214
|
+
test("every <meta>/<link> carries data-pylon-meta; <title> does not", () => {
|
|
215
|
+
const frag = renderMetadata(fakeReact, {
|
|
216
|
+
title: "Hello",
|
|
217
|
+
description: "A page",
|
|
218
|
+
canonical: "https://x.test/p",
|
|
219
|
+
openGraph: { title: "OG", image: "https://x.test/og.png" },
|
|
220
|
+
twitter: { card: "summary" },
|
|
221
|
+
icons: { icon: { url: "/icon.png" } },
|
|
222
|
+
});
|
|
223
|
+
expect(frag.type).toBe(FAKE_FRAGMENT);
|
|
224
|
+
const kids: any[] = frag.children;
|
|
225
|
+
const metaLink = kids.filter((k) => k.type === "meta" || k.type === "link");
|
|
226
|
+
const titles = kids.filter((k) => k.type === "title");
|
|
227
|
+
expect(metaLink.length).toBeGreaterThan(0);
|
|
228
|
+
// The marker is what the client runtime swaps on navigation — without it,
|
|
229
|
+
// SEO/social tags go stale on client-side nav. Every meta/link must carry
|
|
230
|
+
// it; <title> must NOT (the client syncs document.title separately).
|
|
231
|
+
for (const el of metaLink) {
|
|
232
|
+
expect(el.props["data-pylon-meta"]).toBe("");
|
|
233
|
+
}
|
|
234
|
+
expect(titles.length).toBe(1);
|
|
235
|
+
expect(titles[0].props["data-pylon-meta"]).toBeUndefined();
|
|
236
|
+
expect(titles[0].children).toEqual(["Hello"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("returns null when there's nothing to emit", () => {
|
|
240
|
+
expect(renderMetadata(fakeReact, undefined)).toBeNull();
|
|
241
|
+
expect(renderMetadata(fakeReact, {})).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () => {
|
|
246
|
+
const manifestRoute = { file: "app__error-x.js", imports: [], css: [] };
|
|
247
|
+
|
|
248
|
+
test("error boundary serializes {message,digest}; raw error/stack/cookies NEVER cross the wire", () => {
|
|
249
|
+
const tail = buildHydrationTail({
|
|
250
|
+
component: "app/error",
|
|
251
|
+
layouts: ["app/layout"],
|
|
252
|
+
props: {
|
|
253
|
+
url: "/boom",
|
|
254
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null, roles: [] },
|
|
255
|
+
// live, non-serializable + sensitive handles that MUST be stripped:
|
|
256
|
+
error: new Error("DB exploded at secretHost:5432"),
|
|
257
|
+
serverData: { get() {} },
|
|
258
|
+
response: { setStatus() {} },
|
|
259
|
+
reset: () => {},
|
|
260
|
+
headers: { cookie: "pylon_session=SUPERSECRET" },
|
|
261
|
+
cookies: { pylon_session: "SUPERSECRET" },
|
|
262
|
+
},
|
|
263
|
+
ssrData: {},
|
|
264
|
+
manifestRoute,
|
|
265
|
+
publicPrefix: "/_pylon/build/",
|
|
266
|
+
manifestErr: null,
|
|
267
|
+
kind: "error",
|
|
268
|
+
errorForClient: { message: "Something went wrong", digest: "deadbeef" },
|
|
269
|
+
});
|
|
270
|
+
const data = extractPylonData(tail);
|
|
271
|
+
expect(data.kind).toBe("error");
|
|
272
|
+
expect(data.component).toBe("app/error");
|
|
273
|
+
// The client error.tsx gets ONLY the safe projection.
|
|
274
|
+
expect(data.props.error).toEqual({
|
|
275
|
+
message: "Something went wrong",
|
|
276
|
+
digest: "deadbeef",
|
|
277
|
+
});
|
|
278
|
+
// Live handles stripped; headers/cookies emptied (shape preserved).
|
|
279
|
+
expect(data.props.serverData).toBeUndefined();
|
|
280
|
+
expect(data.props.response).toBeUndefined();
|
|
281
|
+
expect(data.props.reset).toBeUndefined();
|
|
282
|
+
expect(data.props.headers).toEqual({});
|
|
283
|
+
expect(data.props.cookies).toEqual({});
|
|
284
|
+
// The session cookie + the raw error message/stack are nowhere in the blob.
|
|
285
|
+
expect(tail).not.toContain("SUPERSECRET");
|
|
286
|
+
expect(tail).not.toContain("secretHost");
|
|
287
|
+
expect(tail).not.toContain("stack");
|
|
288
|
+
// The per-boundary entry script is appended.
|
|
289
|
+
expect(tail).toContain('src="/_pylon/build/app__error-x.js"');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("not-found boundary carries kind but no error/reset", () => {
|
|
293
|
+
const tail = buildHydrationTail({
|
|
294
|
+
component: "app/not-found",
|
|
295
|
+
layouts: [],
|
|
296
|
+
props: { url: "/missing", auth: {}, response: {}, serverData: {} },
|
|
297
|
+
ssrData: {},
|
|
298
|
+
manifestRoute,
|
|
299
|
+
publicPrefix: "/_pylon/build/",
|
|
300
|
+
manifestErr: null,
|
|
301
|
+
kind: "not-found",
|
|
302
|
+
});
|
|
303
|
+
const data = extractPylonData(tail);
|
|
304
|
+
expect(data.kind).toBe("not-found");
|
|
305
|
+
expect(data.props.error).toBeUndefined();
|
|
306
|
+
expect(data.props.reset).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a page (no kind) hydrates without a kind field", () => {
|
|
310
|
+
const tail = buildHydrationTail({
|
|
311
|
+
component: "app/page",
|
|
312
|
+
layouts: ["app/layout"],
|
|
313
|
+
props: { url: "/", auth: {}, response: {}, serverData: {} },
|
|
314
|
+
ssrData: { "list:Note": [] },
|
|
315
|
+
manifestRoute,
|
|
316
|
+
publicPrefix: "/_pylon/build/",
|
|
317
|
+
manifestErr: null,
|
|
318
|
+
});
|
|
319
|
+
const data = extractPylonData(tail);
|
|
320
|
+
expect(data.kind).toBeUndefined();
|
|
321
|
+
expect(data.ssrData).toEqual({ "list:Note": [] });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("no manifest entry → hydration-disabled warning, not an entry script", () => {
|
|
325
|
+
const tail = buildHydrationTail({
|
|
326
|
+
component: "app/page",
|
|
327
|
+
layouts: [],
|
|
328
|
+
props: { url: "/" },
|
|
329
|
+
ssrData: {},
|
|
330
|
+
manifestRoute: null,
|
|
331
|
+
publicPrefix: "/_pylon/build/",
|
|
332
|
+
manifestErr: "manifest crashed",
|
|
333
|
+
});
|
|
334
|
+
expect(tail).toContain("hydration disabled");
|
|
335
|
+
expect(tail).not.toContain('type="module" src=');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("errorDigest is deterministic, stack-free, 8 hex chars", () => {
|
|
339
|
+
const e = new Error("boom");
|
|
340
|
+
const d1 = errorDigest(e);
|
|
341
|
+
const d2 = errorDigest(e);
|
|
342
|
+
expect(d1).toBe(d2);
|
|
343
|
+
expect(d1).toMatch(/^[0-9a-f]{8}$/);
|
|
344
|
+
// A different error yields a different digest.
|
|
345
|
+
expect(errorDigest(new Error("other"))).not.toBe(d1);
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -306,9 +306,19 @@ export interface SsrMetadata {
|
|
|
306
306
|
* host's </head> splice preserves them. React escapes all text/attrs, so
|
|
307
307
|
* there's no manual XSS handling. Returns null when there's nothing to emit.
|
|
308
308
|
*/
|
|
309
|
-
function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
309
|
+
export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
310
310
|
if (!m) return null;
|
|
311
|
-
|
|
311
|
+
// Mark every emitted <meta>/<link> with `data-pylon-meta` so the client
|
|
312
|
+
// runtime can swap exactly these tags on a client-side navigation — and
|
|
313
|
+
// leave the layout's charset/viewport and Pylon's injected stylesheet
|
|
314
|
+
// links untouched. <title> is excluded (the client syncs document.title
|
|
315
|
+
// directly). The metadata fragment is server-only (the client renders the
|
|
316
|
+
// page component alone), so React on the client never owns these nodes;
|
|
317
|
+
// this manual marking is what makes the nav-time swap safe.
|
|
318
|
+
const el = (type: any, props: any, ...children: any[]) =>
|
|
319
|
+
type === "meta" || type === "link"
|
|
320
|
+
? React.createElement(type, { "data-pylon-meta": "", ...props }, ...children)
|
|
321
|
+
: React.createElement(type, props, ...children);
|
|
312
322
|
const kids: any[] = [];
|
|
313
323
|
if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
|
|
314
324
|
if (m.description != null) {
|
|
@@ -782,11 +792,116 @@ async function collectBoundaryHeadBlob(): Promise<string> {
|
|
|
782
792
|
}
|
|
783
793
|
|
|
784
794
|
/**
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
795
|
+
* Build the hydration tail appended after React's stream EOFs: the
|
|
796
|
+
* `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
|
|
797
|
+
* `<script>` that hydrates it, + (dev) the live-reload snippet. Shared by the
|
|
798
|
+
* page render AND the now-hydrated boundary render (#279) so a boundary
|
|
799
|
+
* hydrates through the EXACT same path as a page.
|
|
800
|
+
*
|
|
801
|
+
* `kind` marks an error/not-found boundary so the client knows whether to
|
|
802
|
+
* synthesize a `reset()`. For an error boundary, `errorForClient` is the SAFE
|
|
803
|
+
* projection ({message, digest}) — the raw `Error` (and its stack) is NEVER
|
|
804
|
+
* serialized (the dev overlay owns dev stacks; preserves the #270 posture).
|
|
805
|
+
*/
|
|
806
|
+
export function buildHydrationTail(args: {
|
|
807
|
+
component: string;
|
|
808
|
+
layouts: string[];
|
|
809
|
+
props: any;
|
|
810
|
+
ssrData: Record<string, any>;
|
|
811
|
+
manifestRoute: { file: string; imports: string[]; css: string[] } | null;
|
|
812
|
+
publicPrefix: string;
|
|
813
|
+
manifestErr: string | null;
|
|
814
|
+
kind?: "error" | "not-found";
|
|
815
|
+
errorForClient?: { message: string; digest?: string };
|
|
816
|
+
}): string {
|
|
817
|
+
// Strip live, non-serializable handles (serverData / response / reset) + the
|
|
818
|
+
// request headers/cookies (SECURITY: never expose the session cookie to
|
|
819
|
+
// client JS — see #270). The raw `error` Error is dropped too; an error
|
|
820
|
+
// boundary's client-visible error rides in `errorForClient` instead.
|
|
821
|
+
const {
|
|
822
|
+
serverData: _sd,
|
|
823
|
+
response: _resp,
|
|
824
|
+
reset: _reset,
|
|
825
|
+
headers: _h,
|
|
826
|
+
cookies: _c,
|
|
827
|
+
error: _err,
|
|
828
|
+
...restProps
|
|
829
|
+
} = args.props ?? {};
|
|
830
|
+
const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
|
|
831
|
+
if (args.errorForClient) serializableProps.error = args.errorForClient;
|
|
832
|
+
const hydrationPayload: any = {
|
|
833
|
+
component: args.component,
|
|
834
|
+
layouts: args.layouts ?? [],
|
|
835
|
+
props: serializableProps,
|
|
836
|
+
ssrData: args.ssrData,
|
|
837
|
+
};
|
|
838
|
+
if (args.kind) hydrationPayload.kind = args.kind;
|
|
839
|
+
// Escape `<` (closes a </script> breakout) + U+2028/U+2029 (JSON-valid but
|
|
840
|
+
// JS statement terminators). Regex form keeps the separators visible in
|
|
841
|
+
// source rather than as invisible literals.
|
|
842
|
+
const json = JSON.stringify(hydrationPayload)
|
|
843
|
+
.replace(/</g, "\\u003c")
|
|
844
|
+
.replace(/\u2028/g, "\\u2028")
|
|
845
|
+
.replace(/\u2029/g, "\\u2029");
|
|
846
|
+
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
847
|
+
if (args.manifestRoute) {
|
|
848
|
+
tail += `<script type="module" src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
849
|
+
} else {
|
|
850
|
+
tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
|
|
851
|
+
}
|
|
852
|
+
if (process.env.PYLON_DEV_MODE) tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
853
|
+
return tail;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The layout chain for a component, walked top-down from `app/` to the
|
|
858
|
+
* component's own directory — IDENTICAL to the bundler's `discoverRoutes`
|
|
859
|
+
* accumulation (and the SDK's `discoverAppRoutes`). A boundary's hydration
|
|
860
|
+
* needs the SERVER tree wrapped in the SAME layouts the bundler baked into
|
|
861
|
+
* its client entry; the catch path otherwise has only the failing PAGE's
|
|
862
|
+
* layouts, which would mismatch a root boundary covering a nested page.
|
|
863
|
+
*/
|
|
864
|
+
function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
|
|
865
|
+
const fs = require("node:fs");
|
|
866
|
+
const path = require("node:path");
|
|
867
|
+
const rel = componentRelPath.replace(/\\/g, "/");
|
|
868
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
869
|
+
const parts = dir.split("/").filter(Boolean);
|
|
870
|
+
const layouts: string[] = [];
|
|
871
|
+
let acc = "";
|
|
872
|
+
for (const part of parts) {
|
|
873
|
+
acc = acc ? `${acc}/${part}` : part;
|
|
874
|
+
for (const ext of MODULE_EXTS) {
|
|
875
|
+
if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
|
|
876
|
+
layouts.push(`${acc}/layout`);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return layouts;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* A short, non-reversible correlation id for an error — surfaced to the
|
|
886
|
+
* client error boundary as `error.digest` (matching server logs) WITHOUT
|
|
887
|
+
* carrying any stack content. FNV-1a over message+stack, 8 hex chars.
|
|
888
|
+
*/
|
|
889
|
+
export function errorDigest(err: any): string {
|
|
890
|
+
const s = `${err?.message ?? ""}\n${err?.stack ?? ""}`;
|
|
891
|
+
let h = 0x811c9dc5;
|
|
892
|
+
for (let i = 0; i < s.length; i++) {
|
|
893
|
+
h ^= s.charCodeAt(i);
|
|
894
|
+
h = Math.imul(h, 0x01000193);
|
|
895
|
+
}
|
|
896
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Render a boundary (not-found/error) tree, stream it at `status`, and —
|
|
901
|
+
* when the boundary has a client bundle entry (#279) — append the hydration
|
|
902
|
+
* tail so onClick/useState/`reset()` work. With no manifest entry the
|
|
903
|
+
* boundary still renders (server-only, styled) — CSS/hydration must never
|
|
904
|
+
* block the error path.
|
|
790
905
|
*/
|
|
791
906
|
async function renderBoundaryToClient(
|
|
792
907
|
React: any,
|
|
@@ -796,6 +911,14 @@ async function renderBoundaryToClient(
|
|
|
796
911
|
callId: string,
|
|
797
912
|
status: number,
|
|
798
913
|
headers: Record<string, string>,
|
|
914
|
+
tail?: {
|
|
915
|
+
component: string;
|
|
916
|
+
layouts: string[];
|
|
917
|
+
props: any;
|
|
918
|
+
ssrData: Record<string, any>;
|
|
919
|
+
kind: "error" | "not-found";
|
|
920
|
+
errorForClient?: { message: string; digest?: string };
|
|
921
|
+
},
|
|
799
922
|
): Promise<void> {
|
|
800
923
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
|
|
801
924
|
onError(e: unknown) {
|
|
@@ -803,7 +926,35 @@ async function renderBoundaryToClient(
|
|
|
803
926
|
console.error("[ssr] boundary render error:", e);
|
|
804
927
|
},
|
|
805
928
|
});
|
|
806
|
-
|
|
929
|
+
// Resolve the boundary's own client entry (keyed by its component path) so
|
|
930
|
+
// the head gets ITS css/modulepreloads and the body-tail loads ITS script.
|
|
931
|
+
let manifestRoute:
|
|
932
|
+
| { file: string; imports: string[]; css: string[] }
|
|
933
|
+
| null = null;
|
|
934
|
+
let publicPrefix = "/_pylon/build/";
|
|
935
|
+
let headBlob = "";
|
|
936
|
+
if (tail) {
|
|
937
|
+
try {
|
|
938
|
+
const { getManifest } = await import("./ssr-client-bundler");
|
|
939
|
+
const manifest = await getManifest();
|
|
940
|
+
publicPrefix = manifest.public_prefix || publicPrefix;
|
|
941
|
+
manifestRoute = manifest.routes[tail.component] ?? null;
|
|
942
|
+
} catch {
|
|
943
|
+
manifestRoute = null;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (manifestRoute) {
|
|
947
|
+
for (const css of manifestRoute.css) {
|
|
948
|
+
headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
|
|
949
|
+
}
|
|
950
|
+
for (const chunk of manifestRoute.imports) {
|
|
951
|
+
headBlob += `<link rel="modulepreload" href="${publicPrefix}${chunk}">`;
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
// No per-boundary entry → fall back to the global stylesheet union so the
|
|
955
|
+
// page is at least styled (static).
|
|
956
|
+
headBlob = await collectBoundaryHeadBlob();
|
|
957
|
+
}
|
|
807
958
|
// renderToReadableStream resolved without throwing → safe to commit the
|
|
808
959
|
// head now, then drain the (already-rendered) shell, injecting CSS.
|
|
809
960
|
send({ type: "response_start", call_id: callId, status, headers });
|
|
@@ -816,6 +967,20 @@ async function renderBoundaryToClient(
|
|
|
816
967
|
});
|
|
817
968
|
};
|
|
818
969
|
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
970
|
+
if (tail && manifestRoute) {
|
|
971
|
+
const tailHtml = buildHydrationTail({
|
|
972
|
+
component: tail.component,
|
|
973
|
+
layouts: tail.layouts,
|
|
974
|
+
props: tail.props,
|
|
975
|
+
ssrData: tail.ssrData,
|
|
976
|
+
manifestRoute,
|
|
977
|
+
publicPrefix,
|
|
978
|
+
manifestErr: null,
|
|
979
|
+
kind: tail.kind,
|
|
980
|
+
errorForClient: tail.errorForClient,
|
|
981
|
+
});
|
|
982
|
+
sendChunk(tailHtml);
|
|
983
|
+
}
|
|
819
984
|
send({ type: "render_done", call_id: callId });
|
|
820
985
|
}
|
|
821
986
|
|
|
@@ -838,7 +1003,7 @@ async function tryRenderBoundary(
|
|
|
838
1003
|
headers: Record<string, string>;
|
|
839
1004
|
},
|
|
840
1005
|
): Promise<boolean> {
|
|
841
|
-
const { React, renderToReadableStream, cwd, componentPath, fileName,
|
|
1006
|
+
const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
|
|
842
1007
|
opts;
|
|
843
1008
|
if (!React || !renderToReadableStream || !props) return false;
|
|
844
1009
|
const rel = findBoundary(componentPath, fileName);
|
|
@@ -847,9 +1012,45 @@ async function tryRenderBoundary(
|
|
|
847
1012
|
const mod = await importModule(cwd, rel);
|
|
848
1013
|
const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
|
|
849
1014
|
if (typeof Comp !== "function") return false;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1015
|
+
// The boundary hydrates through its OWN layout chain (walked from app/ to
|
|
1016
|
+
// the boundary's dir) — NOT the failing page's chain — so the server tree
|
|
1017
|
+
// matches the client entry the bundler baked for this boundary (#279).
|
|
1018
|
+
const boundaryLayouts = resolveLayoutChain(rel, cwd);
|
|
1019
|
+
// For an error boundary, project the thrown Error to the SAFE client shape
|
|
1020
|
+
// ({message, digest}) and give BOTH server + client the SAME value (zero
|
|
1021
|
+
// hydration mismatch) + a no-op reset() server-side. The raw Error/stack
|
|
1022
|
+
// never reaches the client (the dev overlay owns dev stacks; #270).
|
|
1023
|
+
let errorForClient: { message: string; digest?: string } | undefined;
|
|
1024
|
+
let compProps = props;
|
|
1025
|
+
if (fileName === "error") {
|
|
1026
|
+
const rawErr = props.error;
|
|
1027
|
+
errorForClient = {
|
|
1028
|
+
message: rawErr?.message ?? String(rawErr ?? "Error"),
|
|
1029
|
+
digest: errorDigest(rawErr),
|
|
1030
|
+
};
|
|
1031
|
+
compProps = { ...props, error: errorForClient, reset: () => {} };
|
|
1032
|
+
}
|
|
1033
|
+
let tree = React.createElement(Comp, compProps);
|
|
1034
|
+
tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
|
|
1035
|
+
await renderBoundaryToClient(
|
|
1036
|
+
React,
|
|
1037
|
+
renderToReadableStream,
|
|
1038
|
+
tree,
|
|
1039
|
+
send,
|
|
1040
|
+
callId,
|
|
1041
|
+
status,
|
|
1042
|
+
headers,
|
|
1043
|
+
{
|
|
1044
|
+
component: rel,
|
|
1045
|
+
layouts: boundaryLayouts,
|
|
1046
|
+
props: compProps,
|
|
1047
|
+
// Catch-path boundaries don't set up serverData/use() (the by-name
|
|
1048
|
+
// not-found dispatch through handleRenderRoute does); empty ssrData.
|
|
1049
|
+
ssrData: {},
|
|
1050
|
+
kind: fileName === "error" ? "error" : "not-found",
|
|
1051
|
+
errorForClient,
|
|
1052
|
+
},
|
|
1053
|
+
);
|
|
853
1054
|
return true;
|
|
854
1055
|
} catch (e) {
|
|
855
1056
|
// Boundary render itself failed — no tertiary fallback; let the caller
|
|
@@ -1049,6 +1250,41 @@ export async function handleRenderRoute(
|
|
|
1049
1250
|
metadata = applyAutoIcons(msg.component, metadata);
|
|
1050
1251
|
const metaFragment = renderMetadata(React, metadata);
|
|
1051
1252
|
|
|
1253
|
+
// loading.tsx (#278): the nearest `loading` module — walked up from the
|
|
1254
|
+
// page dir, like not-found/error — becomes ONE route-level Suspense
|
|
1255
|
+
// fallback wrapping the page. When present, the shell (layouts) + this
|
|
1256
|
+
// skeleton flush immediately and React reveals the real page content when
|
|
1257
|
+
// the page's top-level `use()` resolves, instead of buffering the whole
|
|
1258
|
+
// document (see the `allReady` gate below). A page with no loading.tsx
|
|
1259
|
+
// keeps the byte-identical buffered single-flush path.
|
|
1260
|
+
//
|
|
1261
|
+
// The skeleton is SERVER-ONLY and must not read `serverData` (a read would
|
|
1262
|
+
// suspend the FALLBACK itself, delaying the shell). It gets the page props
|
|
1263
|
+
// for url/params/searchParams/auth.
|
|
1264
|
+
const loadingRel = findBoundary(msg.component, "loading");
|
|
1265
|
+
let Loading: any = null;
|
|
1266
|
+
if (loadingRel) {
|
|
1267
|
+
try {
|
|
1268
|
+
const lMod = await importModule(cwd, loadingRel);
|
|
1269
|
+
const L = lMod.default ?? lMod.Loading ?? lMod.loading;
|
|
1270
|
+
if (typeof L === "function") Loading = L;
|
|
1271
|
+
} catch {
|
|
1272
|
+
// A broken loading.tsx must never block the page — fall back to the
|
|
1273
|
+
// buffered path.
|
|
1274
|
+
Loading = null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// The page leaf, optionally wrapped in the single Suspense boundary.
|
|
1279
|
+
let pageLeaf: any = React.createElement(Component, props);
|
|
1280
|
+
if (Loading) {
|
|
1281
|
+
pageLeaf = React.createElement(
|
|
1282
|
+
React.Suspense,
|
|
1283
|
+
{ fallback: React.createElement(Loading, props) },
|
|
1284
|
+
pageLeaf,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1052
1288
|
// Resolve the layout chain. Each layout module exports a default
|
|
1053
1289
|
// function that accepts the same props + `children`. Walk leaf →
|
|
1054
1290
|
// root: start with the page component as `tree`, then for each
|
|
@@ -1057,13 +1293,8 @@ export async function handleRenderRoute(
|
|
|
1057
1293
|
// the page. The metadata fragment is the FIRST child so React hoists
|
|
1058
1294
|
// its <title>/<meta> into the <head> a layout renders.
|
|
1059
1295
|
let tree: any = metaFragment
|
|
1060
|
-
? React.createElement(
|
|
1061
|
-
|
|
1062
|
-
null,
|
|
1063
|
-
metaFragment,
|
|
1064
|
-
React.createElement(Component, props),
|
|
1065
|
-
)
|
|
1066
|
-
: React.createElement(Component, props);
|
|
1296
|
+
? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
|
|
1297
|
+
: pageLeaf;
|
|
1067
1298
|
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
1068
1299
|
const element = tree;
|
|
1069
1300
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
|
|
@@ -1089,7 +1320,16 @@ export async function handleRenderRoute(
|
|
|
1089
1320
|
// whole-document hydration (which leaves the boundary stuck on its
|
|
1090
1321
|
// fallback). Pages with no async data have no boundaries, so `allReady`
|
|
1091
1322
|
// resolves immediately — zero cost for the common case.
|
|
1092
|
-
|
|
1323
|
+
//
|
|
1324
|
+
// EXCEPTION (#278): when a loading.tsx wraps the page in a Suspense
|
|
1325
|
+
// boundary, we DELIBERATELY skip the buffer and stream — the shell +
|
|
1326
|
+
// skeleton flush first, then React reveals the real content + its reveal
|
|
1327
|
+
// script as the page's `use()` resolves. Hydration stays clean because
|
|
1328
|
+
// there's exactly ONE route-level boundary and the tail `__PYLON_DATA__`
|
|
1329
|
+
// (emitted below, after the stream drains to EOF) still carries a fully
|
|
1330
|
+
// resolved `ssrData` map — so the client's `use()` reads a fulfilled
|
|
1331
|
+
// value and never re-suspends.
|
|
1332
|
+
if (!Loading && (stream as any).allReady) {
|
|
1093
1333
|
await (stream as any).allReady;
|
|
1094
1334
|
}
|
|
1095
1335
|
|
|
@@ -1186,44 +1426,29 @@ export async function handleRenderRoute(
|
|
|
1186
1426
|
// and `getManifest` parses with mtime-keyed caching. Falls back
|
|
1187
1427
|
// to a no-hydration warning if the manifest can't be loaded
|
|
1188
1428
|
// (rare — usually means the bundler crashed).
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1429
|
+
// Pages always hydrate. A boundary dispatched BY NAME here (the host
|
|
1430
|
+
// rendering `app/not-found` at 404) now hydrates too (#279) when it has a
|
|
1431
|
+
// client entry - only stay server-only (no tail) when there's no entry to
|
|
1432
|
+
// load. `buildHydrationTail` does the props strip (serverData/response +
|
|
1433
|
+
// the security headers/cookies strip) + the </script> + U+2028/2029
|
|
1434
|
+
// escaping. The CSS/modulepreload links were already injected into <head>.
|
|
1435
|
+
const wantsHydration = !isBoundaryComponent || !!preloadManifestRoute;
|
|
1436
|
+
if (wantsHydration) {
|
|
1437
|
+
const tail = buildHydrationTail({
|
|
1196
1438
|
component: msg.component,
|
|
1197
1439
|
layouts: msg.layouts ?? [],
|
|
1198
|
-
props
|
|
1440
|
+
props,
|
|
1199
1441
|
ssrData: ssrValueCache,
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
// above so they could start fetching as early as possible.
|
|
1209
|
-
tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
|
|
1210
|
-
} else {
|
|
1211
|
-
tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
|
|
1212
|
-
}
|
|
1213
|
-
// Dev-only browser live-reload. `pylon dev` (PYLON_DEV_MODE=1) re-execs
|
|
1214
|
-
// the whole process on every file edit; each process serves a fresh
|
|
1215
|
-
// boot id from /_pylon/dev/live. This client subscribes via
|
|
1216
|
-
// EventSource and reloads the tab when the boot id changes — so saving
|
|
1217
|
-
// a page, a component, or app/globals.css refreshes the browser with
|
|
1218
|
-
// no manual F5. Stripped entirely in production builds.
|
|
1219
|
-
if (process.env.PYLON_DEV_MODE) {
|
|
1220
|
-
tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
1221
|
-
}
|
|
1222
|
-
send({
|
|
1223
|
-
type: "render_chunk",
|
|
1224
|
-
call_id: msg.call_id,
|
|
1225
|
-
data: Buffer.from(tail, "utf8").toString("base64"),
|
|
1442
|
+
manifestRoute: preloadManifestRoute,
|
|
1443
|
+
publicPrefix: preloadPublicPrefix,
|
|
1444
|
+
manifestErr: preloadManifestErr,
|
|
1445
|
+
kind: isBoundaryComponent
|
|
1446
|
+
? /(^|\/)error$/.test(msg.component)
|
|
1447
|
+
? "error"
|
|
1448
|
+
: "not-found"
|
|
1449
|
+
: undefined,
|
|
1226
1450
|
});
|
|
1451
|
+
sendChunk(tail);
|
|
1227
1452
|
}
|
|
1228
1453
|
|
|
1229
1454
|
send({ type: "render_done", call_id: msg.call_id });
|
|
@@ -1299,11 +1524,17 @@ export async function handleRenderRoute(
|
|
|
1299
1524
|
) {
|
|
1300
1525
|
return;
|
|
1301
1526
|
}
|
|
1527
|
+
// In dev, send the full stack as the message so the host can paint a
|
|
1528
|
+
// useful error overlay instead of an opaque 500. In prod, send only the
|
|
1529
|
+
// message (the host shows a generic page; the stack stays in logs).
|
|
1530
|
+
const devMode =
|
|
1531
|
+
process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
|
|
1302
1532
|
send({
|
|
1303
1533
|
type: "error",
|
|
1304
1534
|
call_id: msg.call_id,
|
|
1305
1535
|
code: err?.code ?? "SSR_RENDER_FAILED",
|
|
1306
|
-
message:
|
|
1536
|
+
message:
|
|
1537
|
+
devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
|
|
1307
1538
|
});
|
|
1308
1539
|
}
|
|
1309
1540
|
}
|