@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 +1 -1
- package/src/runtime.ts +19 -1
- package/src/ssr-client-bundler.ts +70 -0
- package/src/ssr-form-runtime.ts +188 -0
- package/src/ssr-runtime.test.ts +120 -1
- package/src/ssr-runtime.ts +331 -83
package/package.json
CHANGED
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
|
+
}
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
|
1496
|
+
props,
|
|
1225
1497
|
ssrData: ssrValueCache,
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
+
|
|
1259
1506
|
});
|
|
1507
|
+
sendChunk(tail);
|
|
1260
1508
|
}
|
|
1261
1509
|
|
|
1262
1510
|
send({ type: "render_done", call_id: msg.call_id });
|