@pylonsync/functions 0.3.247 → 0.3.249

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.249",
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",
package/src/runtime.ts CHANGED
@@ -236,6 +236,24 @@ function dispatch(line: string): void {
236
236
  String(err),
237
237
  });
238
238
  });
239
+ } else if (msg.type === "handle_form") {
240
+ // route.ts form/method handler (#276) — lazy-imported like SSR so
241
+ // projects without route handlers pay nothing on startup.
242
+ import("./ssr-form-runtime")
243
+ .then((mod) =>
244
+ mod.handleForm(
245
+ msg as unknown as Parameters<typeof mod.handleForm>[0],
246
+ send,
247
+ ),
248
+ )
249
+ .catch((err) => {
250
+ send({
251
+ type: "error",
252
+ call_id: (msg as unknown as { call_id: string }).call_id,
253
+ code: "SSR_FORM_RUNTIME_CRASH",
254
+ message: err?.message || String(err),
255
+ });
256
+ });
239
257
  } else if (msg.type === "bundle_client") {
240
258
  // Hydration — build the client-side bundle once and report
241
259
  // the path back. Lazy-imported for the same reason as SSR.
@@ -426,7 +444,7 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
426
444
  return reader;
427
445
  }
428
446
 
429
- function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
447
+ export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
430
448
  // Drop the reader's `unsafe` shortcut before spreading — the
431
449
  // writer needs its own (which we attach below). Without this
432
450
  // strip, `writer.unsafe` would be a DbReader and the type
@@ -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
 
@@ -486,6 +510,52 @@ function installNavHandlers() {
486
510
  window.addEventListener("popstate", () => {
487
511
  navigate(location.pathname + location.search, { push: false });
488
512
  });
513
+ // Progressive-enhancement form submit (#276): intercept <form data-pylon-form>
514
+ // so the page doesn't full-reload. fetch the same route.ts endpoint, follow
515
+ // the handler's redirect, and swap the destination in via navigate(). Without
516
+ // JS this listener never runs and the browser submits natively (POST →
517
+ // handler → 303 → GET) — identical result, just a full navigation.
518
+ document.addEventListener("submit", (e) => {
519
+ if (e.defaultPrevented) return;
520
+ const form = e.target;
521
+ if (!form || form.tagName !== "FORM") return;
522
+ if (!form.hasAttribute("data-pylon-form")) return;
523
+ const action = form.getAttribute("action");
524
+ if (!action) return;
525
+ // Off-origin actions / new-tab targets → let the browser submit.
526
+ if (action.startsWith("http://") || action.startsWith("https://") || action.startsWith("//")) return;
527
+ const tgt = form.getAttribute("target");
528
+ if (tgt && tgt !== "" && tgt !== "_self") return;
529
+ const method = (form.getAttribute("method") || "post").toUpperCase();
530
+ e.preventDefault();
531
+ // urlencoded body (matches the server's parser; files use the native path).
532
+ const body = new URLSearchParams();
533
+ const fd = new FormData(form);
534
+ fd.forEach((v, k) => {
535
+ if (typeof v === "string") body.append(k, v);
536
+ });
537
+ fetch(action, {
538
+ method,
539
+ body,
540
+ credentials: "same-origin",
541
+ headers: { Accept: "text/html" },
542
+ })
543
+ .then((res) => {
544
+ // fetch followed the handler's 303 → res.url is the destination page.
545
+ // Drive a client navigation to it (re-renders without a full reload).
546
+ const dest = new URL(res.url || action, location.href);
547
+ if (dest.origin === location.origin) {
548
+ navigate(dest.pathname + dest.search);
549
+ } else {
550
+ window.location.href = dest.href;
551
+ }
552
+ })
553
+ .catch(() => {
554
+ // Network/abort — fall back to a native submit so the user isn't stuck.
555
+ form.removeAttribute("data-pylon-form");
556
+ form.submit();
557
+ });
558
+ });
489
559
  }
490
560
 
491
561
  // Expose for <Link> component prefetch.
@@ -0,0 +1,188 @@
1
+ // route.ts form/method handler runtime (#276). Invoked by runtime.ts when the
2
+ // host sends a "handle_form" message. Imports the route.ts module, picks the
3
+ // handler by HTTP method, runs it with the parsed form + a read/write `db` +
4
+ // the SsrResponse controller, and replies over the SAME response_start /
5
+ // render_done protocol a render uses. A handler's common job is
6
+ // POST-redirect-GET: write something, then `response.redirect("/x?ok=1")`
7
+ // (303 by default here) so the no-JS browser follows with a GET.
8
+ import {
9
+ makeResponseController,
10
+ PylonRouteControl,
11
+ finalizeHeaders,
12
+ importModule,
13
+ type ResponseState,
14
+ } from "./ssr-runtime";
15
+ import { buildDbWriter } from "./runtime";
16
+
17
+ /** Matches HandleFormMessage in crates/functions/src/protocol.rs. */
18
+ export interface HandleFormMessage {
19
+ type: "handle_form";
20
+ call_id: string;
21
+ component: string;
22
+ route_path: string;
23
+ method: string;
24
+ url: string;
25
+ params: Record<string, string>;
26
+ search_params: Record<string, string>;
27
+ form: Record<string, string | string[]>;
28
+ headers: Record<string, string>;
29
+ cookies: Record<string, string>;
30
+ auth: {
31
+ user_id: string | null;
32
+ is_admin: boolean;
33
+ tenant_id: string | null;
34
+ roles: string[];
35
+ };
36
+ }
37
+
38
+ type Send = (msg: Record<string, unknown>) => void;
39
+
40
+ /** Parsed form fields. Mirrors URLSearchParams get/getAll/has semantics. */
41
+ export interface FormFields {
42
+ /** First value for `name`, or null. */
43
+ get(name: string): string | null;
44
+ /** All values for `name` (empty array if none). */
45
+ getAll(name: string): string[];
46
+ has(name: string): boolean;
47
+ /** Raw map: name → value | values. */
48
+ readonly fields: Record<string, string | string[]>;
49
+ }
50
+
51
+ function makeFormFields(raw: Record<string, string | string[]>): FormFields {
52
+ return {
53
+ get(name) {
54
+ const v = raw[name];
55
+ if (v == null) return null;
56
+ return Array.isArray(v) ? (v.length > 0 ? v[0] : null) : v;
57
+ },
58
+ getAll(name) {
59
+ const v = raw[name];
60
+ if (v == null) return [];
61
+ return Array.isArray(v) ? v : [v];
62
+ },
63
+ has(name) {
64
+ return Object.prototype.hasOwnProperty.call(raw, name);
65
+ },
66
+ fields: raw,
67
+ };
68
+ }
69
+
70
+ const HANDLER_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
71
+ const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
72
+ const isDev = () =>
73
+ process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
74
+
75
+ export async function handleForm(
76
+ msg: HandleFormMessage,
77
+ send: Send,
78
+ ): Promise<void> {
79
+ const cwd = process.cwd();
80
+ // Default 303 See Other — the correct POST-redirect-GET status for a form
81
+ // (307 would re-issue the POST to the redirect target).
82
+ const responseState: ResponseState = { status: 303, headers: {}, cookies: [] };
83
+ const response = makeResponseController(responseState, 303);
84
+ const req = {
85
+ form: makeFormFields(msg.form ?? {}),
86
+ params: msg.params,
87
+ searchParams: msg.search_params,
88
+ auth: msg.auth,
89
+ cookies: msg.cookies,
90
+ headers: msg.headers,
91
+ // Read+write DB handle (mutation-shaped; the host answers writes against a
92
+ // broadcast-capable store). serverData (read-only) isn't given — a handler
93
+ // writes via `db` and the developer enforces trust with `req.auth`.
94
+ db: buildDbWriter(msg.call_id),
95
+ response,
96
+ };
97
+
98
+ let mod: any;
99
+ try {
100
+ mod = await importModule(cwd, msg.component);
101
+ } catch (e: any) {
102
+ send({
103
+ type: "error",
104
+ call_id: msg.call_id,
105
+ code: "SSR_FORM_IMPORT_FAILED",
106
+ message: isDev() && e?.stack ? String(e.stack) : e?.message ?? String(e),
107
+ });
108
+ return;
109
+ }
110
+
111
+ const method = (msg.method || "POST").toUpperCase();
112
+ const handler = mod[method];
113
+ if (typeof handler !== "function") {
114
+ // 405 — advertise the methods this route.ts actually exports.
115
+ const allow = HANDLER_METHODS.filter(
116
+ (m) => typeof mod[m] === "function",
117
+ ).join(", ");
118
+ send({
119
+ type: "response_start",
120
+ call_id: msg.call_id,
121
+ status: 405,
122
+ headers: {
123
+ "content-type": "text/plain; charset=utf-8",
124
+ ...(allow ? { allow } : {}),
125
+ },
126
+ });
127
+ send({
128
+ type: "render_chunk",
129
+ call_id: msg.call_id,
130
+ data: b64(`Method ${method} not allowed`),
131
+ });
132
+ send({ type: "render_done", call_id: msg.call_id });
133
+ return;
134
+ }
135
+
136
+ try {
137
+ await handler(req);
138
+ // No redirect()/notFound() thrown → commit the handler's response: its
139
+ // status (default 303) + headers + cookies. A 303 with no explicit
140
+ // Location redirects back to the route path (POST-redirect-GET).
141
+ const extra: Record<string, string> = {};
142
+ if (responseState.status === 303 && !responseState.headers["location"]) {
143
+ extra["location"] = msg.route_path || "/";
144
+ }
145
+ send({
146
+ type: "response_start",
147
+ call_id: msg.call_id,
148
+ status: responseState.status,
149
+ headers: finalizeHeaders(responseState, extra),
150
+ });
151
+ send({ type: "render_done", call_id: msg.call_id });
152
+ } catch (err: any) {
153
+ // response.redirect()/notFound() throw PylonRouteControl — turn into a
154
+ // 3xx + Location / 404, carrying any cookies the handler set first.
155
+ if (err instanceof PylonRouteControl) {
156
+ if (err.kind === "redirect") {
157
+ send({
158
+ type: "response_start",
159
+ call_id: msg.call_id,
160
+ status: err.redirectStatus ?? 303,
161
+ headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
162
+ });
163
+ send({ type: "render_done", call_id: msg.call_id });
164
+ return;
165
+ }
166
+ send({
167
+ type: "response_start",
168
+ call_id: msg.call_id,
169
+ status: 404,
170
+ headers: finalizeHeaders(responseState),
171
+ });
172
+ send({
173
+ type: "render_chunk",
174
+ call_id: msg.call_id,
175
+ data: b64("Not found"),
176
+ });
177
+ send({ type: "render_done", call_id: msg.call_id });
178
+ return;
179
+ }
180
+ send({
181
+ type: "error",
182
+ call_id: msg.call_id,
183
+ code: err?.code ?? "SSR_FORM_FAILED",
184
+ message:
185
+ isDev() && err?.stack ? String(err.stack) : err?.message ?? String(err),
186
+ });
187
+ }
188
+ }
@@ -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
+ });
@@ -75,7 +75,7 @@ type Send = (msg: Record<string, unknown>) => void;
75
75
  * it's thrown during the shell render. (Throw it OUTSIDE an error
76
76
  * boundary — an enclosing boundary would swallow the signal.)
77
77
  */
78
- class PylonRouteControl extends Error {
78
+ export class PylonRouteControl extends Error {
79
79
  kind: "redirect" | "notFound";
80
80
  url?: string;
81
81
  redirectStatus?: number;
@@ -146,7 +146,7 @@ function serializeCookie(
146
146
  return c;
147
147
  }
148
148
 
149
- interface ResponseState {
149
+ export interface ResponseState {
150
150
  status: number;
151
151
  headers: Record<string, string>;
152
152
  cookies: string[];
@@ -184,7 +184,14 @@ export interface SsrResponse {
184
184
  notFound(): never;
185
185
  }
186
186
 
187
- function makeResponseController(state: ResponseState): SsrResponse {
187
+ // `defaultRedirectStatus` is the status `response.redirect(url)` uses when the
188
+ // caller doesn't pass one: 307 for a page render (preserve the method on the
189
+ // rare redirecting GET), 303 for a route.ts form handler (POST-redirect-GET —
190
+ // the browser must follow with a GET, not re-POST).
191
+ export function makeResponseController(
192
+ state: ResponseState,
193
+ defaultRedirectStatus = 307,
194
+ ): SsrResponse {
188
195
  return {
189
196
  setStatus(code) {
190
197
  if (!Number.isInteger(code) || code < 100 || code > 599) {
@@ -204,7 +211,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
204
211
  setCookie(name, value, opts) {
205
212
  state.cookies.push(serializeCookie(name, value, opts));
206
213
  },
207
- redirect(url, status = 307): never {
214
+ redirect(url, status = defaultRedirectStatus): never {
208
215
  assertNoControlChars(url, "redirect url");
209
216
  if (!Number.isInteger(status) || status < 300 || status > 399) {
210
217
  throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
@@ -226,7 +233,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
226
233
  * into one `Set-Cookie` header each (newline is forbidden inside a
227
234
  * cookie, so it can't be turned into header injection).
228
235
  */
229
- function finalizeHeaders(
236
+ export function finalizeHeaders(
230
237
  state: ResponseState,
231
238
  extra?: Record<string, string>,
232
239
  ): Record<string, string> {
@@ -373,7 +380,7 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
373
380
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
374
381
 
375
382
  /** Import a project-relative module, trying each common extension. */
376
- async function importModule(cwd: string, relPath: string): Promise<any> {
383
+ export async function importModule(cwd: string, relPath: string): Promise<any> {
377
384
  const base = `${cwd}/${relPath}`;
378
385
  let lastErr: unknown = null;
379
386
  for (const ext of MODULE_EXTS) {
@@ -792,11 +799,116 @@ async function collectBoundaryHeadBlob(): Promise<string> {
792
799
  }
793
800
 
794
801
  /**
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.
802
+ * Build the hydration tail appended after React's stream EOFs: the
803
+ * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
804
+ * `<script>` that hydrates it, + (dev) the live-reload snippet. Shared by the
805
+ * page render AND the now-hydrated boundary render (#279) so a boundary
806
+ * hydrates through the EXACT same path as a page.
807
+ *
808
+ * `kind` marks an error/not-found boundary so the client knows whether to
809
+ * synthesize a `reset()`. For an error boundary, `errorForClient` is the SAFE
810
+ * projection ({message, digest}) — the raw `Error` (and its stack) is NEVER
811
+ * serialized (the dev overlay owns dev stacks; preserves the #270 posture).
812
+ */
813
+ export function buildHydrationTail(args: {
814
+ component: string;
815
+ layouts: string[];
816
+ props: any;
817
+ ssrData: Record<string, any>;
818
+ manifestRoute: { file: string; imports: string[]; css: string[] } | null;
819
+ publicPrefix: string;
820
+ manifestErr: string | null;
821
+ kind?: "error" | "not-found";
822
+ errorForClient?: { message: string; digest?: string };
823
+ }): string {
824
+ // Strip live, non-serializable handles (serverData / response / reset) + the
825
+ // request headers/cookies (SECURITY: never expose the session cookie to
826
+ // client JS — see #270). The raw `error` Error is dropped too; an error
827
+ // boundary's client-visible error rides in `errorForClient` instead.
828
+ const {
829
+ serverData: _sd,
830
+ response: _resp,
831
+ reset: _reset,
832
+ headers: _h,
833
+ cookies: _c,
834
+ error: _err,
835
+ ...restProps
836
+ } = args.props ?? {};
837
+ const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
838
+ if (args.errorForClient) serializableProps.error = args.errorForClient;
839
+ const hydrationPayload: any = {
840
+ component: args.component,
841
+ layouts: args.layouts ?? [],
842
+ props: serializableProps,
843
+ ssrData: args.ssrData,
844
+ };
845
+ if (args.kind) hydrationPayload.kind = args.kind;
846
+ // Escape `<` (closes a </script> breakout) + U+2028/U+2029 (JSON-valid but
847
+ // JS statement terminators). Regex form keeps the separators visible in
848
+ // source rather than as invisible literals.
849
+ const json = JSON.stringify(hydrationPayload)
850
+ .replace(/</g, "\\u003c")
851
+ .replace(/\u2028/g, "\\u2028")
852
+ .replace(/\u2029/g, "\\u2029");
853
+ let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
854
+ if (args.manifestRoute) {
855
+ tail += `<script type="module" src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
856
+ } else {
857
+ tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
858
+ }
859
+ if (process.env.PYLON_DEV_MODE) tail += DEV_LIVE_RELOAD_SNIPPET;
860
+ return tail;
861
+ }
862
+
863
+ /**
864
+ * The layout chain for a component, walked top-down from `app/` to the
865
+ * component's own directory — IDENTICAL to the bundler's `discoverRoutes`
866
+ * accumulation (and the SDK's `discoverAppRoutes`). A boundary's hydration
867
+ * needs the SERVER tree wrapped in the SAME layouts the bundler baked into
868
+ * its client entry; the catch path otherwise has only the failing PAGE's
869
+ * layouts, which would mismatch a root boundary covering a nested page.
870
+ */
871
+ function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
872
+ const fs = require("node:fs");
873
+ const path = require("node:path");
874
+ const rel = componentRelPath.replace(/\\/g, "/");
875
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
876
+ const parts = dir.split("/").filter(Boolean);
877
+ const layouts: string[] = [];
878
+ let acc = "";
879
+ for (const part of parts) {
880
+ acc = acc ? `${acc}/${part}` : part;
881
+ for (const ext of MODULE_EXTS) {
882
+ if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
883
+ layouts.push(`${acc}/layout`);
884
+ break;
885
+ }
886
+ }
887
+ }
888
+ return layouts;
889
+ }
890
+
891
+ /**
892
+ * A short, non-reversible correlation id for an error — surfaced to the
893
+ * client error boundary as `error.digest` (matching server logs) WITHOUT
894
+ * carrying any stack content. FNV-1a over message+stack, 8 hex chars.
895
+ */
896
+ export function errorDigest(err: any): string {
897
+ const s = `${err?.message ?? ""}\n${err?.stack ?? ""}`;
898
+ let h = 0x811c9dc5;
899
+ for (let i = 0; i < s.length; i++) {
900
+ h ^= s.charCodeAt(i);
901
+ h = Math.imul(h, 0x01000193);
902
+ }
903
+ return (h >>> 0).toString(16).padStart(8, "0");
904
+ }
905
+
906
+ /**
907
+ * Render a boundary (not-found/error) tree, stream it at `status`, and —
908
+ * when the boundary has a client bundle entry (#279) — append the hydration
909
+ * tail so onClick/useState/`reset()` work. With no manifest entry the
910
+ * boundary still renders (server-only, styled) — CSS/hydration must never
911
+ * block the error path.
800
912
  */
801
913
  async function renderBoundaryToClient(
802
914
  React: any,
@@ -806,6 +918,14 @@ async function renderBoundaryToClient(
806
918
  callId: string,
807
919
  status: number,
808
920
  headers: Record<string, string>,
921
+ tail?: {
922
+ component: string;
923
+ layouts: string[];
924
+ props: any;
925
+ ssrData: Record<string, any>;
926
+ kind: "error" | "not-found";
927
+ errorForClient?: { message: string; digest?: string };
928
+ },
809
929
  ): Promise<void> {
810
930
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
811
931
  onError(e: unknown) {
@@ -813,7 +933,35 @@ async function renderBoundaryToClient(
813
933
  console.error("[ssr] boundary render error:", e);
814
934
  },
815
935
  });
816
- const headBlob = await collectBoundaryHeadBlob();
936
+ // Resolve the boundary's own client entry (keyed by its component path) so
937
+ // the head gets ITS css/modulepreloads and the body-tail loads ITS script.
938
+ let manifestRoute:
939
+ | { file: string; imports: string[]; css: string[] }
940
+ | null = null;
941
+ let publicPrefix = "/_pylon/build/";
942
+ let headBlob = "";
943
+ if (tail) {
944
+ try {
945
+ const { getManifest } = await import("./ssr-client-bundler");
946
+ const manifest = await getManifest();
947
+ publicPrefix = manifest.public_prefix || publicPrefix;
948
+ manifestRoute = manifest.routes[tail.component] ?? null;
949
+ } catch {
950
+ manifestRoute = null;
951
+ }
952
+ }
953
+ if (manifestRoute) {
954
+ for (const css of manifestRoute.css) {
955
+ headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
956
+ }
957
+ for (const chunk of manifestRoute.imports) {
958
+ headBlob += `<link rel="modulepreload" href="${publicPrefix}${chunk}">`;
959
+ }
960
+ } else {
961
+ // No per-boundary entry → fall back to the global stylesheet union so the
962
+ // page is at least styled (static).
963
+ headBlob = await collectBoundaryHeadBlob();
964
+ }
817
965
  // renderToReadableStream resolved without throwing → safe to commit the
818
966
  // head now, then drain the (already-rendered) shell, injecting CSS.
819
967
  send({ type: "response_start", call_id: callId, status, headers });
@@ -826,6 +974,20 @@ async function renderBoundaryToClient(
826
974
  });
827
975
  };
828
976
  await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
977
+ if (tail && manifestRoute) {
978
+ const tailHtml = buildHydrationTail({
979
+ component: tail.component,
980
+ layouts: tail.layouts,
981
+ props: tail.props,
982
+ ssrData: tail.ssrData,
983
+ manifestRoute,
984
+ publicPrefix,
985
+ manifestErr: null,
986
+ kind: tail.kind,
987
+ errorForClient: tail.errorForClient,
988
+ });
989
+ sendChunk(tailHtml);
990
+ }
829
991
  send({ type: "render_done", call_id: callId });
830
992
  }
831
993
 
@@ -848,7 +1010,7 @@ async function tryRenderBoundary(
848
1010
  headers: Record<string, string>;
849
1011
  },
850
1012
  ): Promise<boolean> {
851
- const { React, renderToReadableStream, cwd, componentPath, fileName, layouts, props, send, callId, status, headers } =
1013
+ const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
852
1014
  opts;
853
1015
  if (!React || !renderToReadableStream || !props) return false;
854
1016
  const rel = findBoundary(componentPath, fileName);
@@ -857,9 +1019,45 @@ async function tryRenderBoundary(
857
1019
  const mod = await importModule(cwd, rel);
858
1020
  const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
859
1021
  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);
1022
+ // The boundary hydrates through its OWN layout chain (walked from app/ to
1023
+ // the boundary's dir) NOT the failing page's chain — so the server tree
1024
+ // matches the client entry the bundler baked for this boundary (#279).
1025
+ const boundaryLayouts = resolveLayoutChain(rel, cwd);
1026
+ // For an error boundary, project the thrown Error to the SAFE client shape
1027
+ // ({message, digest}) and give BOTH server + client the SAME value (zero
1028
+ // hydration mismatch) + a no-op reset() server-side. The raw Error/stack
1029
+ // never reaches the client (the dev overlay owns dev stacks; #270).
1030
+ let errorForClient: { message: string; digest?: string } | undefined;
1031
+ let compProps = props;
1032
+ if (fileName === "error") {
1033
+ const rawErr = props.error;
1034
+ errorForClient = {
1035
+ message: rawErr?.message ?? String(rawErr ?? "Error"),
1036
+ digest: errorDigest(rawErr),
1037
+ };
1038
+ compProps = { ...props, error: errorForClient, reset: () => {} };
1039
+ }
1040
+ let tree = React.createElement(Comp, compProps);
1041
+ tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
1042
+ await renderBoundaryToClient(
1043
+ React,
1044
+ renderToReadableStream,
1045
+ tree,
1046
+ send,
1047
+ callId,
1048
+ status,
1049
+ headers,
1050
+ {
1051
+ component: rel,
1052
+ layouts: boundaryLayouts,
1053
+ props: compProps,
1054
+ // Catch-path boundaries don't set up serverData/use() (the by-name
1055
+ // not-found dispatch through handleRenderRoute does); empty ssrData.
1056
+ ssrData: {},
1057
+ kind: fileName === "error" ? "error" : "not-found",
1058
+ errorForClient,
1059
+ },
1060
+ );
863
1061
  return true;
864
1062
  } catch (e) {
865
1063
  // Boundary render itself failed — no tertiary fallback; let the caller
@@ -1030,13 +1228,28 @@ export async function handleRenderRoute(
1030
1228
  ssrValueCache,
1031
1229
  );
1032
1230
 
1231
+ // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
1232
+ // if its output is auth-INDEPENDENT — so wrap props.auth in a Proxy that
1233
+ // flips `authTouched` the moment a page/layout reads it. Reading auth at
1234
+ // all (even for an anonymous request) opts the render OUT of caching,
1235
+ // because the output could differ by identity. The raw auth is restored
1236
+ // before serialization (so the hydration blob carries real values, and so
1237
+ // JSON.stringify doesn't trip the Proxy itself).
1238
+ let authTouched = false;
1239
+ const authProxy = new Proxy(msg.auth as Record<string, unknown>, {
1240
+ get(target, prop, receiver) {
1241
+ authTouched = true;
1242
+ return Reflect.get(target, prop, receiver);
1243
+ },
1244
+ });
1245
+
1033
1246
  props = {
1034
1247
  url: msg.url,
1035
1248
  params: msg.params,
1036
1249
  searchParams: msg.search_params,
1037
1250
  headers: msg.headers,
1038
1251
  cookies: msg.cookies,
1039
- auth: msg.auth,
1252
+ auth: authProxy,
1040
1253
  // Response controller — a page/layout calls response.setStatus /
1041
1254
  // setHeader / setCookie / redirect / notFound to shape the reply.
1042
1255
  response,
@@ -1059,6 +1272,41 @@ export async function handleRenderRoute(
1059
1272
  metadata = applyAutoIcons(msg.component, metadata);
1060
1273
  const metaFragment = renderMetadata(React, metadata);
1061
1274
 
1275
+ // loading.tsx (#278): the nearest `loading` module — walked up from the
1276
+ // page dir, like not-found/error — becomes ONE route-level Suspense
1277
+ // fallback wrapping the page. When present, the shell (layouts) + this
1278
+ // skeleton flush immediately and React reveals the real page content when
1279
+ // the page's top-level `use()` resolves, instead of buffering the whole
1280
+ // document (see the `allReady` gate below). A page with no loading.tsx
1281
+ // keeps the byte-identical buffered single-flush path.
1282
+ //
1283
+ // The skeleton is SERVER-ONLY and must not read `serverData` (a read would
1284
+ // suspend the FALLBACK itself, delaying the shell). It gets the page props
1285
+ // for url/params/searchParams/auth.
1286
+ const loadingRel = findBoundary(msg.component, "loading");
1287
+ let Loading: any = null;
1288
+ if (loadingRel) {
1289
+ try {
1290
+ const lMod = await importModule(cwd, loadingRel);
1291
+ const L = lMod.default ?? lMod.Loading ?? lMod.loading;
1292
+ if (typeof L === "function") Loading = L;
1293
+ } catch {
1294
+ // A broken loading.tsx must never block the page — fall back to the
1295
+ // buffered path.
1296
+ Loading = null;
1297
+ }
1298
+ }
1299
+
1300
+ // The page leaf, optionally wrapped in the single Suspense boundary.
1301
+ let pageLeaf: any = React.createElement(Component, props);
1302
+ if (Loading) {
1303
+ pageLeaf = React.createElement(
1304
+ React.Suspense,
1305
+ { fallback: React.createElement(Loading, props) },
1306
+ pageLeaf,
1307
+ );
1308
+ }
1309
+
1062
1310
  // Resolve the layout chain. Each layout module exports a default
1063
1311
  // function that accepts the same props + `children`. Walk leaf →
1064
1312
  // root: start with the page component as `tree`, then for each
@@ -1067,13 +1315,8 @@ export async function handleRenderRoute(
1067
1315
  // the page. The metadata fragment is the FIRST child so React hoists
1068
1316
  // its <title>/<meta> into the <head> a layout renders.
1069
1317
  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);
1318
+ ? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
1319
+ : pageLeaf;
1077
1320
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
1078
1321
  const element = tree;
1079
1322
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
@@ -1099,7 +1342,16 @@ export async function handleRenderRoute(
1099
1342
  // whole-document hydration (which leaves the boundary stuck on its
1100
1343
  // fallback). Pages with no async data have no boundaries, so `allReady`
1101
1344
  // resolves immediately — zero cost for the common case.
1102
- if ((stream as any).allReady) {
1345
+ //
1346
+ // EXCEPTION (#278): when a loading.tsx wraps the page in a Suspense
1347
+ // boundary, we DELIBERATELY skip the buffer and stream — the shell +
1348
+ // skeleton flush first, then React reveals the real content + its reveal
1349
+ // script as the page's `use()` resolves. Hydration stays clean because
1350
+ // there's exactly ONE route-level boundary and the tail `__PYLON_DATA__`
1351
+ // (emitted below, after the stream drains to EOF) still carries a fully
1352
+ // resolved `ssrData` map — so the client's `use()` reads a fulfilled
1353
+ // value and never re-suspends.
1354
+ if (!Loading && (stream as any).allReady) {
1103
1355
  await (stream as any).allReady;
1104
1356
  }
1105
1357
 
@@ -1108,11 +1360,45 @@ export async function handleRenderRoute(
1108
1360
  // The shell rendered without a redirect()/notFound() throw, so the
1109
1361
  // page's chosen status (default 200) + headers + cookies go out now,
1110
1362
  // before the first body byte.
1363
+ //
1364
+ // #277 cache verdict (Stage 1, buffered path only — a streaming render
1365
+ // commits its head before the body resolves, so `authTouched` isn't final
1366
+ // yet). A render is shareable (CDN-cacheable via `public, s-maxage`) ONLY
1367
+ // when ALL hold: it opted in (`export const revalidate` / `dynamic:
1368
+ // "force-static"`), never read props.auth (authTouched), set no cookie,
1369
+ // isn't `force-dynamic`, and per-caller strict policies are OFF — in strict
1370
+ // mode serverData reads are auth-filtered, so the output isn't shareable.
1371
+ // We emit an INTERNAL `x-pylon-cacheable` header the host turns into a
1372
+ // public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
1373
+ // (the host keeps no-cache / no-store). The 200-only guard avoids caching
1374
+ // an error/redirect.
1375
+ const revalidateSecs =
1376
+ typeof (mod as any).revalidate === "number" && (mod as any).revalidate > 0
1377
+ ? Math.floor((mod as any).revalidate)
1378
+ : (mod as any).dynamic === "force-static"
1379
+ ? 31536000 // a year — only a deploy invalidates a force-static page
1380
+ : null;
1381
+ const forceDynamic = (mod as any).dynamic === "force-dynamic";
1382
+ const strictPolicies = process.env.PYLON_STRICT_FN_POLICIES === "1";
1383
+ const cacheable =
1384
+ revalidateSecs != null &&
1385
+ !forceDynamic &&
1386
+ !authTouched &&
1387
+ responseState.cookies.length === 0 &&
1388
+ !strictPolicies &&
1389
+ !Loading &&
1390
+ responseState.status === 200;
1391
+ // Restore the raw auth before any serialization below (the Proxy was only
1392
+ // for the render-time auth-touch probe).
1393
+ if (props) props.auth = msg.auth;
1111
1394
  send({
1112
1395
  type: "response_start",
1113
1396
  call_id: msg.call_id,
1114
1397
  status: responseState.status,
1115
- headers: finalizeHeaders(responseState),
1398
+ headers: finalizeHeaders(
1399
+ responseState,
1400
+ cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
1401
+ ),
1116
1402
  });
1117
1403
 
1118
1404
  // Pre-load the manifest BEFORE the React stream starts emitting
@@ -1196,67 +1482,29 @@ export async function handleRenderRoute(
1196
1482
  // and `getManifest` parses with mtime-keyed caching. Falls back
1197
1483
  // to a no-hydration warning if the manifest can't be loaded
1198
1484
  // (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 = {
1485
+ // Pages always hydrate. A boundary dispatched BY NAME here (the host
1486
+ // rendering `app/not-found` at 404) now hydrates too (#279) when it has a
1487
+ // client entry - only stay server-only (no tail) when there's no entry to
1488
+ // load. `buildHydrationTail` does the props strip (serverData/response +
1489
+ // the security headers/cookies strip) + the </script> + U+2028/2029
1490
+ // escaping. The CSS/modulepreload links were already injected into <head>.
1491
+ const wantsHydration = !isBoundaryComponent || !!preloadManifestRoute;
1492
+ if (wantsHydration) {
1493
+ const tail = buildHydrationTail({
1222
1494
  component: msg.component,
1223
1495
  layouts: msg.layouts ?? [],
1224
- props: serializableProps,
1496
+ props,
1225
1497
  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"),
1498
+ manifestRoute: preloadManifestRoute,
1499
+ publicPrefix: preloadPublicPrefix,
1500
+ manifestErr: preloadManifestErr,
1501
+ kind: isBoundaryComponent
1502
+ ? /(^|\/)error$/.test(msg.component)
1503
+ ? "error"
1504
+ : "not-found"
1505
+ .replaceAll("
" : undefined,
1259
1506
  });
1507
+ sendChunk(tail);
1260
1508
  }
1261
1509
 
1262
1510
  send({ type: "render_done", call_id: msg.call_id });