@pylonsync/functions 0.3.248 → 0.3.250
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 +9 -3
- package/src/runtime.ts +19 -1
- package/src/ssr-client-bundler.ts +46 -0
- package/src/ssr-form-runtime.ts +188 -0
- package/src/ssr-hydration.test.ts +166 -0
- package/src/ssr-runtime.ts +234 -20
- package/src/ssr-streaming.test.ts +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/functions",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.250",
|
|
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",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
-
"build": "tsc"
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"test": "bun test"
|
|
25
26
|
},
|
|
26
27
|
"keywords": [
|
|
27
28
|
"pylon",
|
|
@@ -31,7 +32,12 @@
|
|
|
31
32
|
],
|
|
32
33
|
"license": "MIT OR Apache-2.0",
|
|
33
34
|
"devDependencies": {
|
|
34
|
-
"typescript": "^5.5"
|
|
35
|
+
"typescript": "^5.5",
|
|
36
|
+
"react": "^19.0.0",
|
|
37
|
+
"react-dom": "^19.0.0",
|
|
38
|
+
"@types/react": "^19.0.0",
|
|
39
|
+
"@types/react-dom": "^19.0.0",
|
|
40
|
+
"happy-dom": "^15.0.0"
|
|
35
41
|
},
|
|
36
42
|
"peerDependencies": {
|
|
37
43
|
"bun-types": "*"
|
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
|
|
@@ -510,6 +510,52 @@ function installNavHandlers() {
|
|
|
510
510
|
window.addEventListener("popstate", () => {
|
|
511
511
|
navigate(location.pathname + location.search, { push: false });
|
|
512
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
|
+
});
|
|
513
559
|
}
|
|
514
560
|
|
|
515
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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #278 Stage 2 — streaming-hydration regression guard (the prod-killer check).
|
|
3
|
+
*
|
|
4
|
+
* Proves the load-bearing claim behind multi-boundary streaming: hydrating the
|
|
5
|
+
* RESOLVED SSR DOM (which is what a streamed page's DOM becomes after React's
|
|
6
|
+
* $RC reveals run) with a `fulfilledThenable`-backed serverData shim produces
|
|
7
|
+
* clean output — the inner <Suspense> boundary renders its RESOLVED rows on the
|
|
8
|
+
* first committed pass, NOT a fallback, and React logs NO hydration mismatch.
|
|
9
|
+
*
|
|
10
|
+
* This is why Pylon needs NO inline per-boundary patch scripts: it withholds
|
|
11
|
+
* the bootstrap from renderToReadableStream and runs hydrateRoot ONCE, after
|
|
12
|
+
* the full ssrData blob — so `use()` reads a fulfilled value synchronously and
|
|
13
|
+
* never re-suspends. If a future change made `use()` suspend at hydration (a
|
|
14
|
+
* broken shim handing back a pending promise), the boundary would mismatch /
|
|
15
|
+
* lose its content — caught here.
|
|
16
|
+
*
|
|
17
|
+
* Isolated in its own file because it registers DOM globals (window/document)
|
|
18
|
+
* for react-dom/client; they're restored after each test.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { Window } from "happy-dom";
|
|
22
|
+
import React, { Suspense, use } from "react";
|
|
23
|
+
import { renderToReadableStream } from "react-dom/server.browser";
|
|
24
|
+
|
|
25
|
+
// Mirrors `fulfilledThenable` in ssr-client-bundler.ts's CLIENT_RUNTIME_SOURCE:
|
|
26
|
+
// a React-recognized fulfilled thenable so use() reads `.value` synchronously
|
|
27
|
+
// (status === "fulfilled") instead of suspending.
|
|
28
|
+
function fulfilledThenable<T>(value: T) {
|
|
29
|
+
return {
|
|
30
|
+
status: "fulfilled" as const,
|
|
31
|
+
value,
|
|
32
|
+
then(onFulfilled?: (v: T) => any) {
|
|
33
|
+
return onFulfilled ? onFulfilled(value) : (value as any);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Rows({ p }: { p: PromiseLike<string[]> }) {
|
|
39
|
+
const rows = use(p as any) as string[];
|
|
40
|
+
return React.createElement(
|
|
41
|
+
"ul",
|
|
42
|
+
{ id: "rows" },
|
|
43
|
+
rows.map((r) => React.createElement("li", { key: r }, r)),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function Page({ p }: { p: PromiseLike<string[]> }) {
|
|
47
|
+
return React.createElement(
|
|
48
|
+
"div",
|
|
49
|
+
{ id: "app" },
|
|
50
|
+
React.createElement("h1", { id: "shell" }, "Shell"),
|
|
51
|
+
React.createElement(
|
|
52
|
+
Suspense,
|
|
53
|
+
{ fallback: React.createElement("p", { id: "fallback" }, "Loading…") },
|
|
54
|
+
React.createElement(Rows, { p }),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dec = new TextDecoder();
|
|
60
|
+
|
|
61
|
+
// Server-render the page (data pre-resolved) to its final resolved HTML — the
|
|
62
|
+
// same DOM a streamed page settles to once React's reveal scripts have run.
|
|
63
|
+
async function renderResolvedHtml(rows: string[]): Promise<string> {
|
|
64
|
+
const stream = await renderToReadableStream(
|
|
65
|
+
React.createElement(Page, { p: Promise.resolve(rows) }),
|
|
66
|
+
);
|
|
67
|
+
await (stream as any).allReady;
|
|
68
|
+
const reader = stream.getReader();
|
|
69
|
+
let html = "";
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { value, done } = await reader.read();
|
|
72
|
+
if (done) break;
|
|
73
|
+
html += dec.decode(value);
|
|
74
|
+
}
|
|
75
|
+
return html;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Globals react-dom/client touches; saved + restored so this file's DOM
|
|
79
|
+
// registration never bleeds into sibling test files.
|
|
80
|
+
const DOM_GLOBAL_KEYS = [
|
|
81
|
+
"window",
|
|
82
|
+
"document",
|
|
83
|
+
"navigator",
|
|
84
|
+
"HTMLElement",
|
|
85
|
+
"Node",
|
|
86
|
+
"Event",
|
|
87
|
+
"MutationObserver",
|
|
88
|
+
"requestAnimationFrame",
|
|
89
|
+
"cancelAnimationFrame",
|
|
90
|
+
];
|
|
91
|
+
let savedGlobals: Record<string, any> | null = null;
|
|
92
|
+
|
|
93
|
+
function registerDom(win: any) {
|
|
94
|
+
const g = globalThis as any;
|
|
95
|
+
savedGlobals = {};
|
|
96
|
+
for (const k of DOM_GLOBAL_KEYS) savedGlobals[k] = g[k];
|
|
97
|
+
g.window = win;
|
|
98
|
+
g.document = win.document;
|
|
99
|
+
g.navigator = win.navigator;
|
|
100
|
+
g.HTMLElement = win.HTMLElement;
|
|
101
|
+
g.Node = win.Node;
|
|
102
|
+
g.Event = win.Event;
|
|
103
|
+
g.MutationObserver = win.MutationObserver;
|
|
104
|
+
g.requestAnimationFrame = (cb: any) => setTimeout(() => cb(Date.now()), 0);
|
|
105
|
+
g.cancelAnimationFrame = (id: any) => clearTimeout(id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
if (!savedGlobals) return;
|
|
110
|
+
const g = globalThis as any;
|
|
111
|
+
for (const k of DOM_GLOBAL_KEYS) {
|
|
112
|
+
if (savedGlobals[k] === undefined) delete g[k];
|
|
113
|
+
else g[k] = savedGlobals[k];
|
|
114
|
+
}
|
|
115
|
+
savedGlobals = null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("streaming hydration (#278)", () => {
|
|
119
|
+
test("fulfilledThenable shim → boundary hydrates to RESOLVED rows, no fallback, no mismatch", async () => {
|
|
120
|
+
const rows = ["alpha", "beta"];
|
|
121
|
+
const serverHtml = await renderResolvedHtml(rows);
|
|
122
|
+
// Sanity: the SSR DOM has the resolved rows (not a fallback).
|
|
123
|
+
expect(serverHtml).toContain("<li>alpha</li>");
|
|
124
|
+
|
|
125
|
+
const win = new Window({ url: "http://localhost/" });
|
|
126
|
+
// Parse the server HTML into a container element via HTML parsing (this
|
|
127
|
+
// preserves React's <!--$--> boundary comment markers, which hydrateRoot
|
|
128
|
+
// reads to locate Suspense boundaries). insertAdjacentHTML is the parse
|
|
129
|
+
// entry point; the input is our own rendered markup, not untrusted data.
|
|
130
|
+
const root = win.document.createElement("div");
|
|
131
|
+
root.setAttribute("id", "root");
|
|
132
|
+
root.insertAdjacentHTML("afterbegin", serverHtml);
|
|
133
|
+
win.document.body.appendChild(root);
|
|
134
|
+
registerDom(win);
|
|
135
|
+
|
|
136
|
+
const errors: string[] = [];
|
|
137
|
+
const origErr = console.error;
|
|
138
|
+
console.error = (...a: any[]) => {
|
|
139
|
+
errors.push(a.map(String).join(" "));
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
const { hydrateRoot } = await import("react-dom/client");
|
|
143
|
+
// Client tree: same component, but serverData yields a fulfilled thenable
|
|
144
|
+
// (the value the server already streamed) — exactly the client shim's job.
|
|
145
|
+
hydrateRoot(
|
|
146
|
+
root as any,
|
|
147
|
+
React.createElement(Page, { p: fulfilledThenable(rows) }),
|
|
148
|
+
);
|
|
149
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
150
|
+
const html = (root as any).innerHTML as string;
|
|
151
|
+
|
|
152
|
+
// The boundary committed its RESOLVED content on hydration...
|
|
153
|
+
expect(html).toContain("alpha");
|
|
154
|
+
expect(html).toContain("beta");
|
|
155
|
+
// ...NOT the fallback (no flash / no stuck boundary)...
|
|
156
|
+
expect(html).not.toContain("Loading…");
|
|
157
|
+
// ...and React logged NO hydration mismatch.
|
|
158
|
+
const mismatch = errors.filter((e) =>
|
|
159
|
+
/hydrat|did not match|mismatch|Text content does not match/i.test(e),
|
|
160
|
+
);
|
|
161
|
+
expect(mismatch).toEqual([]);
|
|
162
|
+
} finally {
|
|
163
|
+
console.error = origErr;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
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) {
|
|
@@ -1127,6 +1134,78 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
|
|
|
1127
1134
|
return sd;
|
|
1128
1135
|
}
|
|
1129
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* #278: does this route STREAM (vs buffer the whole document)? Streaming is
|
|
1139
|
+
* opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
|
|
1140
|
+
* true` (inner-boundary). Pure for testing.
|
|
1141
|
+
*/
|
|
1142
|
+
export function computeWantsStream(hasLoading: boolean, mod: any): boolean {
|
|
1143
|
+
return hasLoading || mod?.streaming === true;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* #277: how long an opt-in page stays cacheable, in seconds — or null if it
|
|
1148
|
+
* never opted in. `export const revalidate = N` (N>0) → N; `dynamic:
|
|
1149
|
+
* "force-static"` → a year (only a deploy invalidates); else null. Pure.
|
|
1150
|
+
*/
|
|
1151
|
+
export function computeRevalidateSecs(mod: any): number | null {
|
|
1152
|
+
if (typeof mod?.revalidate === "number" && mod.revalidate > 0) {
|
|
1153
|
+
return Math.floor(mod.revalidate);
|
|
1154
|
+
}
|
|
1155
|
+
if (mod?.dynamic === "force-static") return 31536000;
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* #277 cache verdict — the security-critical predicate, extracted pure so the
|
|
1161
|
+
* leak class (a personalized/streaming render marked cacheable) is a TEST, not
|
|
1162
|
+
* a mental walkthrough. INVARIANT: result ⟹ !wantsStream (a streaming render
|
|
1163
|
+
* commits its head before auth/cookies/status are final, so it can never be
|
|
1164
|
+
* cached). Fail-closed: every condition must hold.
|
|
1165
|
+
*/
|
|
1166
|
+
export function computeCacheVerdict(args: {
|
|
1167
|
+
revalidateSecs: number | null;
|
|
1168
|
+
forceDynamic: boolean;
|
|
1169
|
+
authTouched: boolean;
|
|
1170
|
+
cookieCount: number;
|
|
1171
|
+
strictPolicies: boolean;
|
|
1172
|
+
wantsStream: boolean;
|
|
1173
|
+
status: number;
|
|
1174
|
+
}): boolean {
|
|
1175
|
+
return (
|
|
1176
|
+
args.revalidateSecs != null &&
|
|
1177
|
+
!args.forceDynamic &&
|
|
1178
|
+
!args.authTouched &&
|
|
1179
|
+
args.cookieCount === 0 &&
|
|
1180
|
+
!args.strictPolicies &&
|
|
1181
|
+
!args.wantsStream &&
|
|
1182
|
+
args.status === 200
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* #278: diff the response head committed at `response_start` against the final
|
|
1188
|
+
* state after EOF, to catch a late response.* mutation from a suspended subtree
|
|
1189
|
+
* that the already-sent head couldn't carry. Returns the dropped pieces, or
|
|
1190
|
+
* null if nothing was lost. Pure.
|
|
1191
|
+
*/
|
|
1192
|
+
export function diffCommittedResponse(
|
|
1193
|
+
snapshot: { status: number; cookies: string[]; headerKeys: string[] },
|
|
1194
|
+
final: { status: number; cookies: string[]; headers: Record<string, string> },
|
|
1195
|
+
): { droppedCookies: string[]; statusChanged: boolean; newHeaderKeys: string[] } | null {
|
|
1196
|
+
const droppedCookies = final.cookies.filter(
|
|
1197
|
+
(c) => !snapshot.cookies.includes(c),
|
|
1198
|
+
);
|
|
1199
|
+
const statusChanged = final.status !== snapshot.status;
|
|
1200
|
+
const newHeaderKeys = Object.keys(final.headers)
|
|
1201
|
+
.sort()
|
|
1202
|
+
.filter((k) => !snapshot.headerKeys.includes(k));
|
|
1203
|
+
if (droppedCookies.length || statusChanged || newHeaderKeys.length) {
|
|
1204
|
+
return { droppedCookies, statusChanged, newHeaderKeys };
|
|
1205
|
+
}
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1130
1209
|
export async function handleRenderRoute(
|
|
1131
1210
|
msg: RenderRouteMessage,
|
|
1132
1211
|
send: Send,
|
|
@@ -1221,13 +1300,28 @@ export async function handleRenderRoute(
|
|
|
1221
1300
|
ssrValueCache,
|
|
1222
1301
|
);
|
|
1223
1302
|
|
|
1303
|
+
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
1304
|
+
// if its output is auth-INDEPENDENT — so wrap props.auth in a Proxy that
|
|
1305
|
+
// flips `authTouched` the moment a page/layout reads it. Reading auth at
|
|
1306
|
+
// all (even for an anonymous request) opts the render OUT of caching,
|
|
1307
|
+
// because the output could differ by identity. The raw auth is restored
|
|
1308
|
+
// before serialization (so the hydration blob carries real values, and so
|
|
1309
|
+
// JSON.stringify doesn't trip the Proxy itself).
|
|
1310
|
+
let authTouched = false;
|
|
1311
|
+
const authProxy = new Proxy(msg.auth as Record<string, unknown>, {
|
|
1312
|
+
get(target, prop, receiver) {
|
|
1313
|
+
authTouched = true;
|
|
1314
|
+
return Reflect.get(target, prop, receiver);
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1224
1318
|
props = {
|
|
1225
1319
|
url: msg.url,
|
|
1226
1320
|
params: msg.params,
|
|
1227
1321
|
searchParams: msg.search_params,
|
|
1228
1322
|
headers: msg.headers,
|
|
1229
1323
|
cookies: msg.cookies,
|
|
1230
|
-
auth:
|
|
1324
|
+
auth: authProxy,
|
|
1231
1325
|
// Response controller — a page/layout calls response.setStatus /
|
|
1232
1326
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
1233
1327
|
response,
|
|
@@ -1285,6 +1379,16 @@ export async function handleRenderRoute(
|
|
|
1285
1379
|
);
|
|
1286
1380
|
}
|
|
1287
1381
|
|
|
1382
|
+
// Streaming decision (#278). Computed from STATIC module exports only —
|
|
1383
|
+
// knowable before any await, so the buffer/cache decision never reads
|
|
1384
|
+
// non-final render state. A page STREAMS (shell + each inner <Suspense>
|
|
1385
|
+
// fallback flush immediately, content reveals as data resolves) when it has
|
|
1386
|
+
// a loading.tsx (route-level boundary, #278 Stage 1) OR explicitly opts in
|
|
1387
|
+
// with `export const streaming = true` (inner-boundary streaming, Stage 2).
|
|
1388
|
+
// Every un-annotated page keeps the byte-identical BUFFERED path (allReady)
|
|
1389
|
+
// that 100% of today's prod traffic rides — this is opt-in, never default.
|
|
1390
|
+
const wantsStream = computeWantsStream(!!Loading, mod);
|
|
1391
|
+
|
|
1288
1392
|
// Resolve the layout chain. Each layout module exports a default
|
|
1289
1393
|
// function that accepts the same props + `children`. Walk leaf →
|
|
1290
1394
|
// root: start with the page component as `tree`, then for each
|
|
@@ -1302,9 +1406,26 @@ export async function handleRenderRoute(
|
|
|
1302
1406
|
{
|
|
1303
1407
|
onError(err: unknown) {
|
|
1304
1408
|
// React captures render errors during the streaming render
|
|
1305
|
-
// and feeds them here.
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1409
|
+
// and feeds them here. We log to stderr; we do NOT truncate or
|
|
1410
|
+
// rewrite the response here — on a streamed render the HTTP head is
|
|
1411
|
+
// already committed, so a mid-stream error just closes the body
|
|
1412
|
+
// (partial HTML); the dev overlay (#275) only covers failures BEFORE
|
|
1413
|
+
// response_start (host-side err channel). Buffered renders surface
|
|
1414
|
+
// their error through the catch/boundary path below.
|
|
1415
|
+
if (err instanceof PylonRouteControl) {
|
|
1416
|
+
// A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
|
|
1417
|
+
// the shell already committed the head, so React swallowed it and
|
|
1418
|
+
// it can't change the response. This is a known limitation on BOTH
|
|
1419
|
+
// the buffered and streamed paths (response.* must fire in the
|
|
1420
|
+
// synchronous shell). Surface it loudly instead of silently losing.
|
|
1421
|
+
// eslint-disable-next-line no-console
|
|
1422
|
+
console.error(
|
|
1423
|
+
`[ssr] response.${err.kind}() called below a <Suspense> boundary was ignored — ` +
|
|
1424
|
+
`the HTTP head was already sent. Call response.redirect()/notFound() in the ` +
|
|
1425
|
+
`synchronous shell render, before any await/<Suspense>.`,
|
|
1426
|
+
);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1308
1429
|
// eslint-disable-next-line no-console
|
|
1309
1430
|
console.error("[ssr] renderToReadableStream onError:", err);
|
|
1310
1431
|
},
|
|
@@ -1321,15 +1442,17 @@ export async function handleRenderRoute(
|
|
|
1321
1442
|
// fallback). Pages with no async data have no boundaries, so `allReady`
|
|
1322
1443
|
// resolves immediately — zero cost for the common case.
|
|
1323
1444
|
//
|
|
1324
|
-
// EXCEPTION (#278):
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
|
|
1445
|
+
// EXCEPTION (#278): a STREAMING render (loading.tsx route-level boundary,
|
|
1446
|
+
// or `export const streaming = true` for inner boundaries) DELIBERATELY
|
|
1447
|
+
// skips the buffer — the shell + each <Suspense> fallback flush first, then
|
|
1448
|
+
// React reveals each boundary's real content + its reveal script as that
|
|
1449
|
+
// boundary's `use()` resolves. Hydration stays clean for ANY number of
|
|
1450
|
+
// boundaries because Pylon runs hydrateRoot ONCE, post-EOF: the entry
|
|
1451
|
+
// <script> is appended AFTER the full `__PYLON_DATA__` blob (which carries
|
|
1452
|
+
// the fully-resolved `ssrData` map) and after all of React's $RC reveals,
|
|
1453
|
+
// so the client's `use()` reads a fulfilled value and never re-suspends —
|
|
1454
|
+
// there is no progressive hydration racing the stream.
|
|
1455
|
+
if (!wantsStream && (stream as any).allReady) {
|
|
1333
1456
|
await (stream as any).allReady;
|
|
1334
1457
|
}
|
|
1335
1458
|
|
|
@@ -1338,11 +1461,61 @@ export async function handleRenderRoute(
|
|
|
1338
1461
|
// The shell rendered without a redirect()/notFound() throw, so the
|
|
1339
1462
|
// page's chosen status (default 200) + headers + cookies go out now,
|
|
1340
1463
|
// before the first body byte.
|
|
1464
|
+
//
|
|
1465
|
+
// #277 cache verdict (Stage 1, buffered path only — a streaming render
|
|
1466
|
+
// commits its head before the body resolves, so `authTouched` isn't final
|
|
1467
|
+
// yet). A render is shareable (CDN-cacheable via `public, s-maxage`) ONLY
|
|
1468
|
+
// when ALL hold: it opted in (`export const revalidate` / `dynamic:
|
|
1469
|
+
// "force-static"`), never read props.auth (authTouched), set no cookie,
|
|
1470
|
+
// isn't `force-dynamic`, and per-caller strict policies are OFF — in strict
|
|
1471
|
+
// mode serverData reads are auth-filtered, so the output isn't shareable.
|
|
1472
|
+
// We emit an INTERNAL `x-pylon-cacheable` header the host turns into a
|
|
1473
|
+
// public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
|
|
1474
|
+
// (the host keeps no-cache / no-store). The 200-only guard avoids caching
|
|
1475
|
+
// an error/redirect.
|
|
1476
|
+
const revalidateSecs = computeRevalidateSecs(mod);
|
|
1477
|
+
const forceDynamic = (mod as any).dynamic === "force-dynamic";
|
|
1478
|
+
const strictPolicies = process.env.PYLON_STRICT_FN_POLICIES === "1";
|
|
1479
|
+
// INVARIANT: cacheable ⟹ !wantsStream. A streaming render commits its head
|
|
1480
|
+
// (response_start) BEFORE suspended subtrees finish, so `authTouched`,
|
|
1481
|
+
// `responseState.cookies`, and `.status` are NOT final here — caching it
|
|
1482
|
+
// could share a personalized/non-final body. So `!wantsStream` (NOT just
|
|
1483
|
+
// `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
|
|
1484
|
+
// `wantsStream` true, and must still be excluded. Fail-closed. (See
|
|
1485
|
+
// computeCacheVerdict — pure + unit-tested for the leak class.)
|
|
1486
|
+
const cacheable = computeCacheVerdict({
|
|
1487
|
+
revalidateSecs,
|
|
1488
|
+
forceDynamic,
|
|
1489
|
+
authTouched,
|
|
1490
|
+
cookieCount: responseState.cookies.length,
|
|
1491
|
+
strictPolicies,
|
|
1492
|
+
wantsStream,
|
|
1493
|
+
status: responseState.status,
|
|
1494
|
+
});
|
|
1495
|
+
// Restore the raw auth before any serialization below (the Proxy was only
|
|
1496
|
+
// for the render-time auth-touch probe).
|
|
1497
|
+
if (props) props.auth = msg.auth;
|
|
1498
|
+
// #278: on a STREAMING render the head commits NOW, before suspended
|
|
1499
|
+
// subtrees run. Snapshot what's committed so we can detect (after EOF) a
|
|
1500
|
+
// late response.setStatus/setCookie/setHeader from a suspended subtree that
|
|
1501
|
+
// got silently dropped — and warn loudly instead of leaving the dev to
|
|
1502
|
+
// debug a missing Set-Cookie. Buffered renders need no snapshot (the whole
|
|
1503
|
+
// render is done before this point, so nothing can change after).
|
|
1504
|
+
const committedSnapshot = wantsStream
|
|
1505
|
+
? {
|
|
1506
|
+
status: responseState.status,
|
|
1507
|
+
cookies: responseState.cookies.map((c) => String(c)),
|
|
1508
|
+
headerKeys: Object.keys(responseState.headers).sort(),
|
|
1509
|
+
}
|
|
1510
|
+
: null;
|
|
1341
1511
|
send({
|
|
1342
1512
|
type: "response_start",
|
|
1343
1513
|
call_id: msg.call_id,
|
|
1344
1514
|
status: responseState.status,
|
|
1345
|
-
headers: finalizeHeaders(
|
|
1515
|
+
headers: finalizeHeaders(
|
|
1516
|
+
responseState,
|
|
1517
|
+
cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
|
|
1518
|
+
),
|
|
1346
1519
|
});
|
|
1347
1520
|
|
|
1348
1521
|
// Pre-load the manifest BEFORE the React stream starts emitting
|
|
@@ -1408,6 +1581,47 @@ export async function handleRenderRoute(
|
|
|
1408
1581
|
};
|
|
1409
1582
|
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
1410
1583
|
|
|
1584
|
+
// #278: detect a late response.* mutation from a suspended subtree that the
|
|
1585
|
+
// already-committed head couldn't carry, and warn loudly (a silently
|
|
1586
|
+
// dropped Set-Cookie reads as "logged out" / missing CSRF in prod). Only
|
|
1587
|
+
// for streamed renders — a buffered render finalized before response_start,
|
|
1588
|
+
// so nothing changes after. The fix for the dev is to move the call into
|
|
1589
|
+
// the synchronous shell (or drop `streaming = true`); we name what was lost.
|
|
1590
|
+
if (committedSnapshot) {
|
|
1591
|
+
// Same diff the unit tests exercise — call the pure helper so the tested
|
|
1592
|
+
// path IS the prod path (no drift).
|
|
1593
|
+
const dropped = diffCommittedResponse(committedSnapshot, {
|
|
1594
|
+
status: responseState.status,
|
|
1595
|
+
cookies: responseState.cookies,
|
|
1596
|
+
headers: responseState.headers,
|
|
1597
|
+
});
|
|
1598
|
+
if (dropped) {
|
|
1599
|
+
const parts: string[] = [];
|
|
1600
|
+
if (dropped.droppedCookies.length)
|
|
1601
|
+
parts.push(
|
|
1602
|
+
`Set-Cookie [${dropped.droppedCookies
|
|
1603
|
+
.map((c) => {
|
|
1604
|
+
const eq = c.indexOf("="); // serialized "name=value; …"
|
|
1605
|
+
return eq >= 0 ? c.slice(0, eq) : c;
|
|
1606
|
+
})
|
|
1607
|
+
.join(", ")}]`,
|
|
1608
|
+
);
|
|
1609
|
+
if (dropped.statusChanged)
|
|
1610
|
+
parts.push(
|
|
1611
|
+
`status ${committedSnapshot.status}→${responseState.status}`,
|
|
1612
|
+
);
|
|
1613
|
+
if (dropped.newHeaderKeys.length)
|
|
1614
|
+
parts.push(`headers [${dropped.newHeaderKeys.join(", ")}]`);
|
|
1615
|
+
// eslint-disable-next-line no-console
|
|
1616
|
+
console.error(
|
|
1617
|
+
`[ssr] response.* called below a <Suspense> boundary on a streaming ` +
|
|
1618
|
+
`route was DROPPED (the HTTP head already shipped): ${parts.join("; ")}. ` +
|
|
1619
|
+
`Set response status/cookies/headers in the synchronous shell render, ` +
|
|
1620
|
+
`before any await/<Suspense> — or remove \`export const streaming = true\`.`,
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1411
1625
|
// Hydration tail. After React's stream EOFs we append the
|
|
1412
1626
|
// hydration markers so the browser can hydrate:
|
|
1413
1627
|
// 1. `__PYLON_DATA__` — JSON-typed script with the props the
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #278 Stage 2 — progressive streaming harness.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* 1. PURE verdict logic (computeWantsStream / computeRevalidateSecs /
|
|
6
|
+
* computeCacheVerdict / diffCommittedResponse) — the security-critical
|
|
7
|
+
* "is this render cacheable / should it stream" gate, tested directly
|
|
8
|
+
* including the leak-class invariant `cacheable ⟹ !wantsStream`.
|
|
9
|
+
* 2. The actual React streaming MECHANISM through react-dom/server.browser —
|
|
10
|
+
* proving the buffered (allReady) path emits clean inline HTML, and the
|
|
11
|
+
* streamed path flushes the shell + Suspense fallback first then reveals.
|
|
12
|
+
*
|
|
13
|
+
* Hydration (the prod-killer "stuck on fallback" check) lives in the sibling
|
|
14
|
+
* ssr-hydration.test.ts (it mutates DOM globals, so it's isolated).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import React, { Suspense, use } from "react";
|
|
18
|
+
import { renderToReadableStream } from "react-dom/server.browser";
|
|
19
|
+
import {
|
|
20
|
+
buildHydrationTail,
|
|
21
|
+
computeCacheVerdict,
|
|
22
|
+
computeRevalidateSecs,
|
|
23
|
+
computeWantsStream,
|
|
24
|
+
diffCommittedResponse,
|
|
25
|
+
} from "./ssr-runtime";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Pure verdict logic
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe("computeWantsStream", () => {
|
|
32
|
+
test("loading.tsx OR streaming=true opts in; nothing else does", () => {
|
|
33
|
+
expect(computeWantsStream(true, {})).toBe(true); // loading.tsx
|
|
34
|
+
expect(computeWantsStream(false, { streaming: true })).toBe(true); // opt-in
|
|
35
|
+
expect(computeWantsStream(true, { streaming: true })).toBe(true);
|
|
36
|
+
expect(computeWantsStream(false, {})).toBe(false); // default = buffered
|
|
37
|
+
expect(computeWantsStream(false, { streaming: false })).toBe(false);
|
|
38
|
+
expect(computeWantsStream(false, { streaming: 1 as any })).toBe(false); // strict ===
|
|
39
|
+
expect(computeWantsStream(false, null)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("computeRevalidateSecs", () => {
|
|
44
|
+
test("revalidate>0 → floor; force-static → a year; else null", () => {
|
|
45
|
+
expect(computeRevalidateSecs({ revalidate: 60 })).toBe(60);
|
|
46
|
+
expect(computeRevalidateSecs({ revalidate: 12.9 })).toBe(12);
|
|
47
|
+
expect(computeRevalidateSecs({ revalidate: 0 })).toBeNull();
|
|
48
|
+
expect(computeRevalidateSecs({ revalidate: -5 })).toBeNull();
|
|
49
|
+
expect(computeRevalidateSecs({ dynamic: "force-static" })).toBe(31536000);
|
|
50
|
+
expect(computeRevalidateSecs({ dynamic: "force-dynamic" })).toBeNull();
|
|
51
|
+
expect(computeRevalidateSecs({})).toBeNull();
|
|
52
|
+
expect(computeRevalidateSecs(null)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
57
|
+
const base = {
|
|
58
|
+
revalidateSecs: 60 as number | null,
|
|
59
|
+
forceDynamic: false,
|
|
60
|
+
authTouched: false,
|
|
61
|
+
cookieCount: 0,
|
|
62
|
+
strictPolicies: false,
|
|
63
|
+
wantsStream: false,
|
|
64
|
+
status: 200,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
test("a clean opted-in buffered 200 is cacheable", () => {
|
|
68
|
+
expect(computeCacheVerdict(base)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("every single veto flips it to non-cacheable (fail-closed)", () => {
|
|
72
|
+
expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
|
|
73
|
+
expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
|
|
74
|
+
expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
|
|
75
|
+
expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
|
|
76
|
+
expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
|
|
77
|
+
expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
|
|
78
|
+
expect(computeCacheVerdict({ ...base, status: 404 })).toBe(false);
|
|
79
|
+
expect(computeCacheVerdict({ ...base, status: 307 })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("INVARIANT: cacheable ⟹ !wantsStream over the full cross-product", () => {
|
|
83
|
+
const bools = [false, true];
|
|
84
|
+
const statuses = [200, 201, 302, 404, 500];
|
|
85
|
+
const revs: (number | null)[] = [null, 0 as any, 60, 31536000];
|
|
86
|
+
let checked = 0;
|
|
87
|
+
for (const forceDynamic of bools)
|
|
88
|
+
for (const authTouched of bools)
|
|
89
|
+
for (const strictPolicies of bools)
|
|
90
|
+
for (const wantsStream of bools)
|
|
91
|
+
for (const cookieCount of [0, 1])
|
|
92
|
+
for (const status of statuses)
|
|
93
|
+
for (const revalidateSecs of revs) {
|
|
94
|
+
const c = computeCacheVerdict({
|
|
95
|
+
revalidateSecs,
|
|
96
|
+
forceDynamic,
|
|
97
|
+
authTouched,
|
|
98
|
+
cookieCount,
|
|
99
|
+
strictPolicies,
|
|
100
|
+
wantsStream,
|
|
101
|
+
status,
|
|
102
|
+
});
|
|
103
|
+
// The load-bearing security invariant: a streaming render is
|
|
104
|
+
// NEVER cacheable (its head commits before auth/cookies/status
|
|
105
|
+
// are final).
|
|
106
|
+
if (c) expect(wantsStream).toBe(false);
|
|
107
|
+
checked++;
|
|
108
|
+
}
|
|
109
|
+
expect(checked).toBe(640); // 2^4 (bools) × 2 (cookies) × 5 (status) × 4 (revs)
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
|
|
114
|
+
const snap = (over: any = {}) => ({
|
|
115
|
+
status: 200,
|
|
116
|
+
cookies: ["sid=committed"],
|
|
117
|
+
headerKeys: ["x-base"],
|
|
118
|
+
...over,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no change → null", () => {
|
|
122
|
+
const r = diffCommittedResponse(snap(), {
|
|
123
|
+
status: 200,
|
|
124
|
+
cookies: ["sid=committed"],
|
|
125
|
+
headers: { "x-base": "1" },
|
|
126
|
+
});
|
|
127
|
+
expect(r).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a late Set-Cookie from a suspended subtree is reported", () => {
|
|
131
|
+
const r = diffCommittedResponse(snap(), {
|
|
132
|
+
status: 200,
|
|
133
|
+
cookies: ["sid=committed", "flash=late"],
|
|
134
|
+
headers: { "x-base": "1" },
|
|
135
|
+
});
|
|
136
|
+
expect(r).not.toBeNull();
|
|
137
|
+
expect(r!.droppedCookies).toEqual(["flash=late"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a late status change + new header are reported", () => {
|
|
141
|
+
const r = diffCommittedResponse(snap(), {
|
|
142
|
+
status: 201,
|
|
143
|
+
cookies: ["sid=committed"],
|
|
144
|
+
headers: { "x-base": "1", "x-late": "2" },
|
|
145
|
+
});
|
|
146
|
+
expect(r!.statusChanged).toBe(true);
|
|
147
|
+
expect(r!.newHeaderKeys).toEqual(["x-late"]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// React streaming mechanism (real renderToReadableStream)
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function makeDeferred<T>() {
|
|
156
|
+
let resolve!: (v: T) => void;
|
|
157
|
+
const promise = new Promise<T>((r) => (resolve = r));
|
|
158
|
+
return { promise, resolve };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function Rows({ p }: { p: Promise<string[]> }) {
|
|
162
|
+
const rows = use(p);
|
|
163
|
+
return React.createElement(
|
|
164
|
+
"ul",
|
|
165
|
+
{ id: "rows" },
|
|
166
|
+
rows.map((r) => React.createElement("li", { key: r }, r)),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A page with an inner <Suspense> reading async data — the /notes shape.
|
|
171
|
+
function Page({ p }: { p: Promise<string[]> }) {
|
|
172
|
+
return React.createElement(
|
|
173
|
+
"div",
|
|
174
|
+
{ id: "app" },
|
|
175
|
+
React.createElement("h1", { id: "shell" }, "Shell"),
|
|
176
|
+
React.createElement(
|
|
177
|
+
Suspense,
|
|
178
|
+
{ fallback: React.createElement("p", { id: "fallback" }, "Loading…") },
|
|
179
|
+
React.createElement(Rows, { p }),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const dec = new TextDecoder();
|
|
185
|
+
|
|
186
|
+
describe("react streaming mechanism", () => {
|
|
187
|
+
test("BUFFERED (await allReady, then drain) → clean inline, no reveal scripts", async () => {
|
|
188
|
+
// Mirrors the runtime's `if (!wantsStream) await stream.allReady` path.
|
|
189
|
+
const d = makeDeferred<string[]>();
|
|
190
|
+
setTimeout(() => d.resolve(["a", "b"]), 5);
|
|
191
|
+
const stream = await renderToReadableStream(
|
|
192
|
+
React.createElement(Page, { p: d.promise }),
|
|
193
|
+
);
|
|
194
|
+
await (stream as any).allReady;
|
|
195
|
+
const reader = stream.getReader();
|
|
196
|
+
let html = "";
|
|
197
|
+
for (;;) {
|
|
198
|
+
const { value, done } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
html += dec.decode(value);
|
|
201
|
+
}
|
|
202
|
+
// Resolved rows inline; NO fallback, NO pending marker, NO $RC reveal /
|
|
203
|
+
// <template>. This is the byte-identical buffered contract today's prod
|
|
204
|
+
// traffic rides — the regression lock.
|
|
205
|
+
expect(html).toContain("<li>a</li>");
|
|
206
|
+
expect(html).toContain("<li>b</li>");
|
|
207
|
+
expect(html).not.toContain("Loading…");
|
|
208
|
+
expect(html).not.toContain("<!--$?-->"); // no PENDING boundary marker
|
|
209
|
+
expect(html).not.toContain("<template");
|
|
210
|
+
expect(/\$RC|completeBoundary/.test(html)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("STREAMING (drain progressively) → shell+fallback first, rows+reveal later", async () => {
|
|
214
|
+
const d = makeDeferred<string[]>();
|
|
215
|
+
const stream = await renderToReadableStream(
|
|
216
|
+
React.createElement(Page, { p: d.promise }),
|
|
217
|
+
);
|
|
218
|
+
const reader = stream.getReader();
|
|
219
|
+
// First flush = the shell with the fallback (data still pending).
|
|
220
|
+
const first = await reader.read();
|
|
221
|
+
const firstHtml = dec.decode(first.value);
|
|
222
|
+
expect(firstHtml).toContain("Shell");
|
|
223
|
+
expect(firstHtml).toContain("Loading…"); // fallback streamed
|
|
224
|
+
expect(firstHtml).not.toContain("<li>a</li>"); // rows NOT here yet
|
|
225
|
+
expect(firstHtml).toContain("<template"); // boundary placeholder
|
|
226
|
+
// Now the data resolves → React reveals the boundary in a later chunk.
|
|
227
|
+
d.resolve(["a", "b"]);
|
|
228
|
+
let rest = "";
|
|
229
|
+
for (;;) {
|
|
230
|
+
const { value, done } = await reader.read();
|
|
231
|
+
if (done) break;
|
|
232
|
+
rest += dec.decode(value);
|
|
233
|
+
}
|
|
234
|
+
expect(rest).toContain("<li>a</li>");
|
|
235
|
+
expect(rest).toContain("<li>b</li>");
|
|
236
|
+
expect(/\$RC|completeBoundary|<template/.test(rest)).toBe(true); // reveal
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("hydration tail ordering (#278: data blob before entry script)", () => {
|
|
241
|
+
test("__PYLON_DATA__ carries ssrData and the entry <script type=module> is LAST", () => {
|
|
242
|
+
const ssrData = { 'list:["Note"]': [{ id: "1", body: "hi" }] };
|
|
243
|
+
const tail = buildHydrationTail({
|
|
244
|
+
component: "app/notes/page",
|
|
245
|
+
layouts: [],
|
|
246
|
+
props: { url: "/notes", params: {}, searchParams: {} },
|
|
247
|
+
ssrData,
|
|
248
|
+
manifestRoute: { file: "notes.js", imports: ["chunk.js"], css: [] },
|
|
249
|
+
publicPrefix: "/_pylon/build/",
|
|
250
|
+
manifestErr: null,
|
|
251
|
+
});
|
|
252
|
+
const dataIdx = tail.indexOf('id="__PYLON_DATA__"');
|
|
253
|
+
const entryIdx = tail.indexOf("notes.js");
|
|
254
|
+
expect(dataIdx).toBeGreaterThanOrEqual(0);
|
|
255
|
+
expect(entryIdx).toBeGreaterThanOrEqual(0);
|
|
256
|
+
// The entry script MUST come after the data blob so hydrateRoot (which the
|
|
257
|
+
// entry triggers) sees a fully-seeded ssrData — the whole reason multi-
|
|
258
|
+
// boundary streaming hydrates cleanly without inline patch scripts.
|
|
259
|
+
expect(entryIdx).toBeGreaterThan(dataIdx);
|
|
260
|
+
// ssrData round-trips into the blob.
|
|
261
|
+
expect(tail).toContain("Note");
|
|
262
|
+
expect(tail).toContain('"body":"hi"');
|
|
263
|
+
});
|
|
264
|
+
});
|