@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.249",
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
+ });
@@ -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. Phase 1 logs to stderr; Phase 1.5
1328
- // sends a structured signal so the host can truncate the
1329
- // body + emit a debug overlay.
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): 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) {
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
- const cacheable =
1384
- revalidateSecs != null &&
1385
- !forceDynamic &&
1386
- !authTouched &&
1387
- responseState.cookies.length === 0 &&
1388
- !strictPolicies &&
1389
- !Loading &&
1390
- responseState.status === 200;
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
+ });