@pylonsync/functions 0.3.249 → 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/ssr-hydration.test.ts +166 -0
- package/src/ssr-runtime.ts +184 -26
- 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": "*"
|
|
@@ -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
|
@@ -1134,6 +1134,78 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
|
|
|
1134
1134
|
return sd;
|
|
1135
1135
|
}
|
|
1136
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
|
+
|
|
1137
1209
|
export async function handleRenderRoute(
|
|
1138
1210
|
msg: RenderRouteMessage,
|
|
1139
1211
|
send: Send,
|
|
@@ -1307,6 +1379,16 @@ export async function handleRenderRoute(
|
|
|
1307
1379
|
);
|
|
1308
1380
|
}
|
|
1309
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
|
+
|
|
1310
1392
|
// Resolve the layout chain. Each layout module exports a default
|
|
1311
1393
|
// function that accepts the same props + `children`. Walk leaf →
|
|
1312
1394
|
// root: start with the page component as `tree`, then for each
|
|
@@ -1324,9 +1406,26 @@ export async function handleRenderRoute(
|
|
|
1324
1406
|
{
|
|
1325
1407
|
onError(err: unknown) {
|
|
1326
1408
|
// React captures render errors during the streaming render
|
|
1327
|
-
// and feeds them here.
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
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
|
+
}
|
|
1330
1429
|
// eslint-disable-next-line no-console
|
|
1331
1430
|
console.error("[ssr] renderToReadableStream onError:", err);
|
|
1332
1431
|
},
|
|
@@ -1343,15 +1442,17 @@ export async function handleRenderRoute(
|
|
|
1343
1442
|
// fallback). Pages with no async data have no boundaries, so `allReady`
|
|
1344
1443
|
// resolves immediately — zero cost for the common case.
|
|
1345
1444
|
//
|
|
1346
|
-
// EXCEPTION (#278):
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
|
|
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) {
|
|
1355
1456
|
await (stream as any).allReady;
|
|
1356
1457
|
}
|
|
1357
1458
|
|
|
@@ -1372,25 +1473,41 @@ export async function handleRenderRoute(
|
|
|
1372
1473
|
// public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
|
|
1373
1474
|
// (the host keeps no-cache / no-store). The 200-only guard avoids caching
|
|
1374
1475
|
// 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;
|
|
1476
|
+
const revalidateSecs = computeRevalidateSecs(mod);
|
|
1381
1477
|
const forceDynamic = (mod as any).dynamic === "force-dynamic";
|
|
1382
1478
|
const strictPolicies = process.env.PYLON_STRICT_FN_POLICIES === "1";
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
+
});
|
|
1391
1495
|
// Restore the raw auth before any serialization below (the Proxy was only
|
|
1392
1496
|
// for the render-time auth-touch probe).
|
|
1393
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;
|
|
1394
1511
|
send({
|
|
1395
1512
|
type: "response_start",
|
|
1396
1513
|
call_id: msg.call_id,
|
|
@@ -1464,6 +1581,47 @@ export async function handleRenderRoute(
|
|
|
1464
1581
|
};
|
|
1465
1582
|
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
1466
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
|
+
|
|
1467
1625
|
// Hydration tail. After React's stream EOFs we append the
|
|
1468
1626
|
// hydration markers so the browser can hydrate:
|
|
1469
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
|
+
});
|