@pylonsync/functions 0.3.247 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.247",
3
+ "version": "0.3.248",
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",
@@ -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
 
@@ -4,7 +4,22 @@ 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 { applyAutoIcons, applyAutoSocialImages, renderMetadata } from "./ssr-runtime";
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
+ }
8
23
 
9
24
  // `react` isn't a dependency of @pylonsync/functions — the SSR runtime
10
25
  // imports it dynamically from the host project at render time. For unit
@@ -226,3 +241,107 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
226
241
  expect(renderMetadata(fakeReact, {})).toBeNull();
227
242
  });
228
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
+ });
@@ -792,11 +792,116 @@ async function collectBoundaryHeadBlob(): Promise<string> {
792
792
  }
793
793
 
794
794
  /**
795
- * Render a boundary (not-found/error) tree and stream it as the response
796
- * body at `status`. Boundaries render server-side only (no hydration
797
- * payload) they're informational pages, consistent with the keystone's
798
- * fixed 404 body that this replaces but they DO get the app's global
799
- * stylesheet injected so they match the rest of the site.
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.
800
905
  */
801
906
  async function renderBoundaryToClient(
802
907
  React: any,
@@ -806,6 +911,14 @@ async function renderBoundaryToClient(
806
911
  callId: string,
807
912
  status: number,
808
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
+ },
809
922
  ): Promise<void> {
810
923
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
811
924
  onError(e: unknown) {
@@ -813,7 +926,35 @@ async function renderBoundaryToClient(
813
926
  console.error("[ssr] boundary render error:", e);
814
927
  },
815
928
  });
816
- const headBlob = await collectBoundaryHeadBlob();
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
+ }
817
958
  // renderToReadableStream resolved without throwing → safe to commit the
818
959
  // head now, then drain the (already-rendered) shell, injecting CSS.
819
960
  send({ type: "response_start", call_id: callId, status, headers });
@@ -826,6 +967,20 @@ async function renderBoundaryToClient(
826
967
  });
827
968
  };
828
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
+ }
829
984
  send({ type: "render_done", call_id: callId });
830
985
  }
831
986
 
@@ -848,7 +1003,7 @@ async function tryRenderBoundary(
848
1003
  headers: Record<string, string>;
849
1004
  },
850
1005
  ): Promise<boolean> {
851
- const { React, renderToReadableStream, cwd, componentPath, fileName, layouts, props, send, callId, status, headers } =
1006
+ const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
852
1007
  opts;
853
1008
  if (!React || !renderToReadableStream || !props) return false;
854
1009
  const rel = findBoundary(componentPath, fileName);
@@ -857,9 +1012,45 @@ async function tryRenderBoundary(
857
1012
  const mod = await importModule(cwd, rel);
858
1013
  const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
859
1014
  if (typeof Comp !== "function") return false;
860
- let tree = React.createElement(Comp, props);
861
- tree = await buildLayoutTree(cwd, tree, layouts, props, React);
862
- await renderBoundaryToClient(React, renderToReadableStream, tree, send, callId, status, headers);
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
+ );
863
1054
  return true;
864
1055
  } catch (e) {
865
1056
  // Boundary render itself failed — no tertiary fallback; let the caller
@@ -1059,6 +1250,41 @@ export async function handleRenderRoute(
1059
1250
  metadata = applyAutoIcons(msg.component, metadata);
1060
1251
  const metaFragment = renderMetadata(React, metadata);
1061
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
+
1062
1288
  // Resolve the layout chain. Each layout module exports a default
1063
1289
  // function that accepts the same props + `children`. Walk leaf →
1064
1290
  // root: start with the page component as `tree`, then for each
@@ -1067,13 +1293,8 @@ export async function handleRenderRoute(
1067
1293
  // the page. The metadata fragment is the FIRST child so React hoists
1068
1294
  // its <title>/<meta> into the <head> a layout renders.
1069
1295
  let tree: any = metaFragment
1070
- ? React.createElement(
1071
- React.Fragment,
1072
- null,
1073
- metaFragment,
1074
- React.createElement(Component, props),
1075
- )
1076
- : React.createElement(Component, props);
1296
+ ? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
1297
+ : pageLeaf;
1077
1298
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
1078
1299
  const element = tree;
1079
1300
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
@@ -1099,7 +1320,16 @@ export async function handleRenderRoute(
1099
1320
  // whole-document hydration (which leaves the boundary stuck on its
1100
1321
  // fallback). Pages with no async data have no boundaries, so `allReady`
1101
1322
  // resolves immediately — zero cost for the common case.
1102
- if ((stream as any).allReady) {
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) {
1103
1333
  await (stream as any).allReady;
1104
1334
  }
1105
1335
 
@@ -1196,67 +1426,29 @@ export async function handleRenderRoute(
1196
1426
  // and `getManifest` parses with mtime-keyed caching. Falls back
1197
1427
  // to a no-hydration warning if the manifest can't be loaded
1198
1428
  // (rare — usually means the bundler crashed).
1199
- if (!isBoundaryComponent) {
1200
- // Strip live, non-serializable handles from the props that seed
1201
- // hydration: `serverData` (an RPC handle the client rebuilds its
1202
- // own from `ssrData`) and `response` (a server-only controller — the
1203
- // client gets a no-op). Their resolved data rides along in `ssrData`.
1204
- //
1205
- // SECURITY: also strip the request `headers` + `cookies`. They were
1206
- // passed to the page for SERVER-side reads, but serializing them into
1207
- // the page HTML exposes the request's `Cookie` (the HttpOnly session
1208
- // token), `Authorization`, and client IP to any client-side JS —
1209
- // defeating HttpOnly and handing a same-page XSS an exfil target. The
1210
- // client gets empty maps (shape preserved so `props.headers`/`.cookies`
1211
- // aren't `undefined`); a page that must surface a request value to the
1212
- // browser should pass it explicitly via a prop or `serverData`.
1213
- const {
1214
- serverData: _sd,
1215
- response: _resp,
1216
- headers: _h,
1217
- cookies: _c,
1218
- ...restProps
1219
- } = props;
1220
- const serializableProps = { ...restProps, headers: {}, cookies: {} };
1221
- const hydrationPayload = {
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({
1222
1438
  component: msg.component,
1223
1439
  layouts: msg.layouts ?? [],
1224
- props: serializableProps,
1440
+ props,
1225
1441
  ssrData: ssrValueCache,
1226
- };
1227
- // Escape `<` (closes the </script> breakout) AND the U+2028/U+2029 line
1228
- // separators — valid in JSON but statement terminators in JS, so they'd
1229
- // break the page if the blob were ever read as executable JS rather than
1230
- // application/json. Defense-in-depth.
1231
- const json = JSON.stringify(hydrationPayload)
1232
- .replaceAll("<", "\\u003c")
1233
- .replaceAll("
", "\\u2028")
1234
- .replaceAll("
", "\\u2029");
1235
-
1236
- let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
1237
- if (preloadManifestRoute) {
1238
- // Per-route entry script comes last — it needs the inline
1239
- // `__PYLON_DATA__` above to have been parsed before it runs.
1240
- // CSS + modulepreload links were already injected into `<head>`
1241
- // above so they could start fetching as early as possible.
1242
- tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
1243
- } else {
1244
- tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
1245
- }
1246
- // Dev-only browser live-reload. `pylon dev` (PYLON_DEV_MODE=1) re-execs
1247
- // the whole process on every file edit; each process serves a fresh
1248
- // boot id from /_pylon/dev/live. This client subscribes via
1249
- // EventSource and reloads the tab when the boot id changes — so saving
1250
- // a page, a component, or app/globals.css refreshes the browser with
1251
- // no manual F5. Stripped entirely in production builds.
1252
- if (process.env.PYLON_DEV_MODE) {
1253
- tail += DEV_LIVE_RELOAD_SNIPPET;
1254
- }
1255
- send({
1256
- type: "render_chunk",
1257
- call_id: msg.call_id,
1258
- 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
+ .replaceAll("
" : undefined,
1259
1450
  });
1451
+ sendChunk(tail);
1260
1452
  }
1261
1453
 
1262
1454
  send({ type: "render_done", call_id: msg.call_id });