@pylonsync/functions 0.3.249 → 0.3.251

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.251",
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
+ });
@@ -7,6 +7,20 @@
7
7
  // the dispatch arm) so projects without SSR routes pay nothing — no
8
8
  // react-dom dependency requirement, no startup cost.
9
9
 
10
+ /**
11
+ * Is the runtime in dev mode? MUST match the Rust host's `is_dev_mode()`
12
+ * (crates/runtime/src/frontend.rs): `PYLON_DEV_MODE` is on ONLY for the exact
13
+ * strings "1" or "true" (case-insensitive). A bare `if (process.env.PYLON_DEV_MODE)`
14
+ * is WRONG — the string "false"/"0" is truthy in JS, so an explicit
15
+ * `PYLON_DEV_MODE=false` on a PROD machine would wrongly enable dev behavior
16
+ * (e.g. the live-reload `<script>` was being injected into prod pages, whose
17
+ * EventSource then 404-retried `/_pylon/dev/live` forever).
18
+ */
19
+ export function isDevMode(): boolean {
20
+ const v = process.env.PYLON_DEV_MODE;
21
+ return v === "1" || v?.toLowerCase() === "true";
22
+ }
23
+
10
24
  /**
11
25
  * The message payload the host sends. Matches RenderRouteMessage in
12
26
  * crates/functions/src/protocol.rs.
@@ -856,7 +870,7 @@ export function buildHydrationTail(args: {
856
870
  } else {
857
871
  tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
858
872
  }
859
- if (process.env.PYLON_DEV_MODE) tail += DEV_LIVE_RELOAD_SNIPPET;
873
+ if (isDevMode()) tail += DEV_LIVE_RELOAD_SNIPPET;
860
874
  return tail;
861
875
  }
862
876
 
@@ -1134,6 +1148,78 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
1134
1148
  return sd;
1135
1149
  }
1136
1150
 
1151
+ /**
1152
+ * #278: does this route STREAM (vs buffer the whole document)? Streaming is
1153
+ * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
1154
+ * true` (inner-boundary). Pure for testing.
1155
+ */
1156
+ export function computeWantsStream(hasLoading: boolean, mod: any): boolean {
1157
+ return hasLoading || mod?.streaming === true;
1158
+ }
1159
+
1160
+ /**
1161
+ * #277: how long an opt-in page stays cacheable, in seconds — or null if it
1162
+ * never opted in. `export const revalidate = N` (N>0) → N; `dynamic:
1163
+ * "force-static"` → a year (only a deploy invalidates); else null. Pure.
1164
+ */
1165
+ export function computeRevalidateSecs(mod: any): number | null {
1166
+ if (typeof mod?.revalidate === "number" && mod.revalidate > 0) {
1167
+ return Math.floor(mod.revalidate);
1168
+ }
1169
+ if (mod?.dynamic === "force-static") return 31536000;
1170
+ return null;
1171
+ }
1172
+
1173
+ /**
1174
+ * #277 cache verdict — the security-critical predicate, extracted pure so the
1175
+ * leak class (a personalized/streaming render marked cacheable) is a TEST, not
1176
+ * a mental walkthrough. INVARIANT: result ⟹ !wantsStream (a streaming render
1177
+ * commits its head before auth/cookies/status are final, so it can never be
1178
+ * cached). Fail-closed: every condition must hold.
1179
+ */
1180
+ export function computeCacheVerdict(args: {
1181
+ revalidateSecs: number | null;
1182
+ forceDynamic: boolean;
1183
+ authTouched: boolean;
1184
+ cookieCount: number;
1185
+ strictPolicies: boolean;
1186
+ wantsStream: boolean;
1187
+ status: number;
1188
+ }): boolean {
1189
+ return (
1190
+ args.revalidateSecs != null &&
1191
+ !args.forceDynamic &&
1192
+ !args.authTouched &&
1193
+ args.cookieCount === 0 &&
1194
+ !args.strictPolicies &&
1195
+ !args.wantsStream &&
1196
+ args.status === 200
1197
+ );
1198
+ }
1199
+
1200
+ /**
1201
+ * #278: diff the response head committed at `response_start` against the final
1202
+ * state after EOF, to catch a late response.* mutation from a suspended subtree
1203
+ * that the already-sent head couldn't carry. Returns the dropped pieces, or
1204
+ * null if nothing was lost. Pure.
1205
+ */
1206
+ export function diffCommittedResponse(
1207
+ snapshot: { status: number; cookies: string[]; headerKeys: string[] },
1208
+ final: { status: number; cookies: string[]; headers: Record<string, string> },
1209
+ ): { droppedCookies: string[]; statusChanged: boolean; newHeaderKeys: string[] } | null {
1210
+ const droppedCookies = final.cookies.filter(
1211
+ (c) => !snapshot.cookies.includes(c),
1212
+ );
1213
+ const statusChanged = final.status !== snapshot.status;
1214
+ const newHeaderKeys = Object.keys(final.headers)
1215
+ .sort()
1216
+ .filter((k) => !snapshot.headerKeys.includes(k));
1217
+ if (droppedCookies.length || statusChanged || newHeaderKeys.length) {
1218
+ return { droppedCookies, statusChanged, newHeaderKeys };
1219
+ }
1220
+ return null;
1221
+ }
1222
+
1137
1223
  export async function handleRenderRoute(
1138
1224
  msg: RenderRouteMessage,
1139
1225
  send: Send,
@@ -1307,6 +1393,16 @@ export async function handleRenderRoute(
1307
1393
  );
1308
1394
  }
1309
1395
 
1396
+ // Streaming decision (#278). Computed from STATIC module exports only —
1397
+ // knowable before any await, so the buffer/cache decision never reads
1398
+ // non-final render state. A page STREAMS (shell + each inner <Suspense>
1399
+ // fallback flush immediately, content reveals as data resolves) when it has
1400
+ // a loading.tsx (route-level boundary, #278 Stage 1) OR explicitly opts in
1401
+ // with `export const streaming = true` (inner-boundary streaming, Stage 2).
1402
+ // Every un-annotated page keeps the byte-identical BUFFERED path (allReady)
1403
+ // that 100% of today's prod traffic rides — this is opt-in, never default.
1404
+ const wantsStream = computeWantsStream(!!Loading, mod);
1405
+
1310
1406
  // Resolve the layout chain. Each layout module exports a default
1311
1407
  // function that accepts the same props + `children`. Walk leaf →
1312
1408
  // root: start with the page component as `tree`, then for each
@@ -1324,9 +1420,26 @@ export async function handleRenderRoute(
1324
1420
  {
1325
1421
  onError(err: unknown) {
1326
1422
  // 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.
1423
+ // and feeds them here. We log to stderr; we do NOT truncate or
1424
+ // rewrite the response here on a streamed render the HTTP head is
1425
+ // already committed, so a mid-stream error just closes the body
1426
+ // (partial HTML); the dev overlay (#275) only covers failures BEFORE
1427
+ // response_start (host-side err channel). Buffered renders surface
1428
+ // their error through the catch/boundary path below.
1429
+ if (err instanceof PylonRouteControl) {
1430
+ // A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
1431
+ // the shell already committed the head, so React swallowed it and
1432
+ // it can't change the response. This is a known limitation on BOTH
1433
+ // the buffered and streamed paths (response.* must fire in the
1434
+ // synchronous shell). Surface it loudly instead of silently losing.
1435
+ // eslint-disable-next-line no-console
1436
+ console.error(
1437
+ `[ssr] response.${err.kind}() called below a <Suspense> boundary was ignored — ` +
1438
+ `the HTTP head was already sent. Call response.redirect()/notFound() in the ` +
1439
+ `synchronous shell render, before any await/<Suspense>.`,
1440
+ );
1441
+ return;
1442
+ }
1330
1443
  // eslint-disable-next-line no-console
1331
1444
  console.error("[ssr] renderToReadableStream onError:", err);
1332
1445
  },
@@ -1343,15 +1456,17 @@ export async function handleRenderRoute(
1343
1456
  // fallback). Pages with no async data have no boundaries, so `allReady`
1344
1457
  // resolves immediately — zero cost for the common case.
1345
1458
  //
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) {
1459
+ // EXCEPTION (#278): a STREAMING render (loading.tsx route-level boundary,
1460
+ // or `export const streaming = true` for inner boundaries) DELIBERATELY
1461
+ // skips the buffer the shell + each <Suspense> fallback flush first, then
1462
+ // React reveals each boundary's real content + its reveal script as that
1463
+ // boundary's `use()` resolves. Hydration stays clean for ANY number of
1464
+ // boundaries because Pylon runs hydrateRoot ONCE, post-EOF: the entry
1465
+ // <script> is appended AFTER the full `__PYLON_DATA__` blob (which carries
1466
+ // the fully-resolved `ssrData` map) and after all of React's $RC reveals,
1467
+ // so the client's `use()` reads a fulfilled value and never re-suspends —
1468
+ // there is no progressive hydration racing the stream.
1469
+ if (!wantsStream && (stream as any).allReady) {
1355
1470
  await (stream as any).allReady;
1356
1471
  }
1357
1472
 
@@ -1372,25 +1487,41 @@ export async function handleRenderRoute(
1372
1487
  // public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
1373
1488
  // (the host keeps no-cache / no-store). The 200-only guard avoids caching
1374
1489
  // 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;
1490
+ const revalidateSecs = computeRevalidateSecs(mod);
1381
1491
  const forceDynamic = (mod as any).dynamic === "force-dynamic";
1382
1492
  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;
1493
+ // INVARIANT: cacheable ⟹ !wantsStream. A streaming render commits its head
1494
+ // (response_start) BEFORE suspended subtrees finish, so `authTouched`,
1495
+ // `responseState.cookies`, and `.status` are NOT final here — caching it
1496
+ // could share a personalized/non-final body. So `!wantsStream` (NOT just
1497
+ // `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
1498
+ // `wantsStream` true, and must still be excluded. Fail-closed. (See
1499
+ // computeCacheVerdict — pure + unit-tested for the leak class.)
1500
+ const cacheable = computeCacheVerdict({
1501
+ revalidateSecs,
1502
+ forceDynamic,
1503
+ authTouched,
1504
+ cookieCount: responseState.cookies.length,
1505
+ strictPolicies,
1506
+ wantsStream,
1507
+ status: responseState.status,
1508
+ });
1391
1509
  // Restore the raw auth before any serialization below (the Proxy was only
1392
1510
  // for the render-time auth-touch probe).
1393
1511
  if (props) props.auth = msg.auth;
1512
+ // #278: on a STREAMING render the head commits NOW, before suspended
1513
+ // subtrees run. Snapshot what's committed so we can detect (after EOF) a
1514
+ // late response.setStatus/setCookie/setHeader from a suspended subtree that
1515
+ // got silently dropped — and warn loudly instead of leaving the dev to
1516
+ // debug a missing Set-Cookie. Buffered renders need no snapshot (the whole
1517
+ // render is done before this point, so nothing can change after).
1518
+ const committedSnapshot = wantsStream
1519
+ ? {
1520
+ status: responseState.status,
1521
+ cookies: responseState.cookies.map((c) => String(c)),
1522
+ headerKeys: Object.keys(responseState.headers).sort(),
1523
+ }
1524
+ : null;
1394
1525
  send({
1395
1526
  type: "response_start",
1396
1527
  call_id: msg.call_id,
@@ -1464,6 +1595,47 @@ export async function handleRenderRoute(
1464
1595
  };
1465
1596
  await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
1466
1597
 
1598
+ // #278: detect a late response.* mutation from a suspended subtree that the
1599
+ // already-committed head couldn't carry, and warn loudly (a silently
1600
+ // dropped Set-Cookie reads as "logged out" / missing CSRF in prod). Only
1601
+ // for streamed renders — a buffered render finalized before response_start,
1602
+ // so nothing changes after. The fix for the dev is to move the call into
1603
+ // the synchronous shell (or drop `streaming = true`); we name what was lost.
1604
+ if (committedSnapshot) {
1605
+ // Same diff the unit tests exercise — call the pure helper so the tested
1606
+ // path IS the prod path (no drift).
1607
+ const dropped = diffCommittedResponse(committedSnapshot, {
1608
+ status: responseState.status,
1609
+ cookies: responseState.cookies,
1610
+ headers: responseState.headers,
1611
+ });
1612
+ if (dropped) {
1613
+ const parts: string[] = [];
1614
+ if (dropped.droppedCookies.length)
1615
+ parts.push(
1616
+ `Set-Cookie [${dropped.droppedCookies
1617
+ .map((c) => {
1618
+ const eq = c.indexOf("="); // serialized "name=value; …"
1619
+ return eq >= 0 ? c.slice(0, eq) : c;
1620
+ })
1621
+ .join(", ")}]`,
1622
+ );
1623
+ if (dropped.statusChanged)
1624
+ parts.push(
1625
+ `status ${committedSnapshot.status}→${responseState.status}`,
1626
+ );
1627
+ if (dropped.newHeaderKeys.length)
1628
+ parts.push(`headers [${dropped.newHeaderKeys.join(", ")}]`);
1629
+ // eslint-disable-next-line no-console
1630
+ console.error(
1631
+ `[ssr] response.* called below a <Suspense> boundary on a streaming ` +
1632
+ `route was DROPPED (the HTTP head already shipped): ${parts.join("; ")}. ` +
1633
+ `Set response status/cookies/headers in the synchronous shell render, ` +
1634
+ `before any await/<Suspense> — or remove \`export const streaming = true\`.`,
1635
+ );
1636
+ }
1637
+ }
1638
+
1467
1639
  // Hydration tail. After React's stream EOFs we append the
1468
1640
  // hydration markers so the browser can hydrate:
1469
1641
  // 1. `__PYLON_DATA__` — JSON-typed script with the props the
@@ -1583,8 +1755,7 @@ export async function handleRenderRoute(
1583
1755
  // In dev, send the full stack as the message so the host can paint a
1584
1756
  // useful error overlay instead of an opaque 500. In prod, send only the
1585
1757
  // message (the host shows a generic page; the stack stays in logs).
1586
- const devMode =
1587
- process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
1758
+ const devMode = isDevMode();
1588
1759
  send({
1589
1760
  type: "error",
1590
1761
  call_id: msg.call_id,
@@ -0,0 +1,302 @@
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
+ isDevMode,
26
+ } from "./ssr-runtime";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // isDevMode — must match the Rust host's is_dev_mode() exactly
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe("isDevMode (PYLON_DEV_MODE parity with Rust host)", () => {
33
+ const orig = process.env.PYLON_DEV_MODE;
34
+ const set = (v: string | undefined) => {
35
+ if (v === undefined) delete process.env.PYLON_DEV_MODE;
36
+ else process.env.PYLON_DEV_MODE = v;
37
+ };
38
+
39
+ test("ONLY '1' / 'true' (case-insensitive) are dev; 'false'/'0'/unset are NOT", () => {
40
+ try {
41
+ set("1");
42
+ expect(isDevMode()).toBe(true);
43
+ set("true");
44
+ expect(isDevMode()).toBe(true);
45
+ set("TRUE");
46
+ expect(isDevMode()).toBe(true);
47
+ // The bug this guards: a prod machine explicitly set PYLON_DEV_MODE=false,
48
+ // and `if (process.env.PYLON_DEV_MODE)` treated the truthy STRING "false"
49
+ // as dev → injected the live-reload script → EventSource 404-looped on
50
+ // /_pylon/dev/live in prod.
51
+ set("false");
52
+ expect(isDevMode()).toBe(false);
53
+ set("0");
54
+ expect(isDevMode()).toBe(false);
55
+ set("");
56
+ expect(isDevMode()).toBe(false);
57
+ set(undefined);
58
+ expect(isDevMode()).toBe(false);
59
+ } finally {
60
+ set(orig);
61
+ }
62
+ });
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Pure verdict logic
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe("computeWantsStream", () => {
70
+ test("loading.tsx OR streaming=true opts in; nothing else does", () => {
71
+ expect(computeWantsStream(true, {})).toBe(true); // loading.tsx
72
+ expect(computeWantsStream(false, { streaming: true })).toBe(true); // opt-in
73
+ expect(computeWantsStream(true, { streaming: true })).toBe(true);
74
+ expect(computeWantsStream(false, {})).toBe(false); // default = buffered
75
+ expect(computeWantsStream(false, { streaming: false })).toBe(false);
76
+ expect(computeWantsStream(false, { streaming: 1 as any })).toBe(false); // strict ===
77
+ expect(computeWantsStream(false, null)).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe("computeRevalidateSecs", () => {
82
+ test("revalidate>0 → floor; force-static → a year; else null", () => {
83
+ expect(computeRevalidateSecs({ revalidate: 60 })).toBe(60);
84
+ expect(computeRevalidateSecs({ revalidate: 12.9 })).toBe(12);
85
+ expect(computeRevalidateSecs({ revalidate: 0 })).toBeNull();
86
+ expect(computeRevalidateSecs({ revalidate: -5 })).toBeNull();
87
+ expect(computeRevalidateSecs({ dynamic: "force-static" })).toBe(31536000);
88
+ expect(computeRevalidateSecs({ dynamic: "force-dynamic" })).toBeNull();
89
+ expect(computeRevalidateSecs({})).toBeNull();
90
+ expect(computeRevalidateSecs(null)).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
95
+ const base = {
96
+ revalidateSecs: 60 as number | null,
97
+ forceDynamic: false,
98
+ authTouched: false,
99
+ cookieCount: 0,
100
+ strictPolicies: false,
101
+ wantsStream: false,
102
+ status: 200,
103
+ };
104
+
105
+ test("a clean opted-in buffered 200 is cacheable", () => {
106
+ expect(computeCacheVerdict(base)).toBe(true);
107
+ });
108
+
109
+ test("every single veto flips it to non-cacheable (fail-closed)", () => {
110
+ expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
111
+ expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
112
+ expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
113
+ expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
114
+ expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
115
+ expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
116
+ expect(computeCacheVerdict({ ...base, status: 404 })).toBe(false);
117
+ expect(computeCacheVerdict({ ...base, status: 307 })).toBe(false);
118
+ });
119
+
120
+ test("INVARIANT: cacheable ⟹ !wantsStream over the full cross-product", () => {
121
+ const bools = [false, true];
122
+ const statuses = [200, 201, 302, 404, 500];
123
+ const revs: (number | null)[] = [null, 0 as any, 60, 31536000];
124
+ let checked = 0;
125
+ for (const forceDynamic of bools)
126
+ for (const authTouched of bools)
127
+ for (const strictPolicies of bools)
128
+ for (const wantsStream of bools)
129
+ for (const cookieCount of [0, 1])
130
+ for (const status of statuses)
131
+ for (const revalidateSecs of revs) {
132
+ const c = computeCacheVerdict({
133
+ revalidateSecs,
134
+ forceDynamic,
135
+ authTouched,
136
+ cookieCount,
137
+ strictPolicies,
138
+ wantsStream,
139
+ status,
140
+ });
141
+ // The load-bearing security invariant: a streaming render is
142
+ // NEVER cacheable (its head commits before auth/cookies/status
143
+ // are final).
144
+ if (c) expect(wantsStream).toBe(false);
145
+ checked++;
146
+ }
147
+ expect(checked).toBe(640); // 2^4 (bools) × 2 (cookies) × 5 (status) × 4 (revs)
148
+ });
149
+ });
150
+
151
+ describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
152
+ const snap = (over: any = {}) => ({
153
+ status: 200,
154
+ cookies: ["sid=committed"],
155
+ headerKeys: ["x-base"],
156
+ ...over,
157
+ });
158
+
159
+ test("no change → null", () => {
160
+ const r = diffCommittedResponse(snap(), {
161
+ status: 200,
162
+ cookies: ["sid=committed"],
163
+ headers: { "x-base": "1" },
164
+ });
165
+ expect(r).toBeNull();
166
+ });
167
+
168
+ test("a late Set-Cookie from a suspended subtree is reported", () => {
169
+ const r = diffCommittedResponse(snap(), {
170
+ status: 200,
171
+ cookies: ["sid=committed", "flash=late"],
172
+ headers: { "x-base": "1" },
173
+ });
174
+ expect(r).not.toBeNull();
175
+ expect(r!.droppedCookies).toEqual(["flash=late"]);
176
+ });
177
+
178
+ test("a late status change + new header are reported", () => {
179
+ const r = diffCommittedResponse(snap(), {
180
+ status: 201,
181
+ cookies: ["sid=committed"],
182
+ headers: { "x-base": "1", "x-late": "2" },
183
+ });
184
+ expect(r!.statusChanged).toBe(true);
185
+ expect(r!.newHeaderKeys).toEqual(["x-late"]);
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // React streaming mechanism (real renderToReadableStream)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function makeDeferred<T>() {
194
+ let resolve!: (v: T) => void;
195
+ const promise = new Promise<T>((r) => (resolve = r));
196
+ return { promise, resolve };
197
+ }
198
+
199
+ function Rows({ p }: { p: Promise<string[]> }) {
200
+ const rows = use(p);
201
+ return React.createElement(
202
+ "ul",
203
+ { id: "rows" },
204
+ rows.map((r) => React.createElement("li", { key: r }, r)),
205
+ );
206
+ }
207
+
208
+ // A page with an inner <Suspense> reading async data — the /notes shape.
209
+ function Page({ p }: { p: Promise<string[]> }) {
210
+ return React.createElement(
211
+ "div",
212
+ { id: "app" },
213
+ React.createElement("h1", { id: "shell" }, "Shell"),
214
+ React.createElement(
215
+ Suspense,
216
+ { fallback: React.createElement("p", { id: "fallback" }, "Loading…") },
217
+ React.createElement(Rows, { p }),
218
+ ),
219
+ );
220
+ }
221
+
222
+ const dec = new TextDecoder();
223
+
224
+ describe("react streaming mechanism", () => {
225
+ test("BUFFERED (await allReady, then drain) → clean inline, no reveal scripts", async () => {
226
+ // Mirrors the runtime's `if (!wantsStream) await stream.allReady` path.
227
+ const d = makeDeferred<string[]>();
228
+ setTimeout(() => d.resolve(["a", "b"]), 5);
229
+ const stream = await renderToReadableStream(
230
+ React.createElement(Page, { p: d.promise }),
231
+ );
232
+ await (stream as any).allReady;
233
+ const reader = stream.getReader();
234
+ let html = "";
235
+ for (;;) {
236
+ const { value, done } = await reader.read();
237
+ if (done) break;
238
+ html += dec.decode(value);
239
+ }
240
+ // Resolved rows inline; NO fallback, NO pending marker, NO $RC reveal /
241
+ // <template>. This is the byte-identical buffered contract today's prod
242
+ // traffic rides — the regression lock.
243
+ expect(html).toContain("<li>a</li>");
244
+ expect(html).toContain("<li>b</li>");
245
+ expect(html).not.toContain("Loading…");
246
+ expect(html).not.toContain("<!--$?-->"); // no PENDING boundary marker
247
+ expect(html).not.toContain("<template");
248
+ expect(/\$RC|completeBoundary/.test(html)).toBe(false);
249
+ });
250
+
251
+ test("STREAMING (drain progressively) → shell+fallback first, rows+reveal later", async () => {
252
+ const d = makeDeferred<string[]>();
253
+ const stream = await renderToReadableStream(
254
+ React.createElement(Page, { p: d.promise }),
255
+ );
256
+ const reader = stream.getReader();
257
+ // First flush = the shell with the fallback (data still pending).
258
+ const first = await reader.read();
259
+ const firstHtml = dec.decode(first.value);
260
+ expect(firstHtml).toContain("Shell");
261
+ expect(firstHtml).toContain("Loading…"); // fallback streamed
262
+ expect(firstHtml).not.toContain("<li>a</li>"); // rows NOT here yet
263
+ expect(firstHtml).toContain("<template"); // boundary placeholder
264
+ // Now the data resolves → React reveals the boundary in a later chunk.
265
+ d.resolve(["a", "b"]);
266
+ let rest = "";
267
+ for (;;) {
268
+ const { value, done } = await reader.read();
269
+ if (done) break;
270
+ rest += dec.decode(value);
271
+ }
272
+ expect(rest).toContain("<li>a</li>");
273
+ expect(rest).toContain("<li>b</li>");
274
+ expect(/\$RC|completeBoundary|<template/.test(rest)).toBe(true); // reveal
275
+ });
276
+ });
277
+
278
+ describe("hydration tail ordering (#278: data blob before entry script)", () => {
279
+ test("__PYLON_DATA__ carries ssrData and the entry <script type=module> is LAST", () => {
280
+ const ssrData = { 'list:["Note"]': [{ id: "1", body: "hi" }] };
281
+ const tail = buildHydrationTail({
282
+ component: "app/notes/page",
283
+ layouts: [],
284
+ props: { url: "/notes", params: {}, searchParams: {} },
285
+ ssrData,
286
+ manifestRoute: { file: "notes.js", imports: ["chunk.js"], css: [] },
287
+ publicPrefix: "/_pylon/build/",
288
+ manifestErr: null,
289
+ });
290
+ const dataIdx = tail.indexOf('id="__PYLON_DATA__"');
291
+ const entryIdx = tail.indexOf("notes.js");
292
+ expect(dataIdx).toBeGreaterThanOrEqual(0);
293
+ expect(entryIdx).toBeGreaterThanOrEqual(0);
294
+ // The entry script MUST come after the data blob so hydrateRoot (which the
295
+ // entry triggers) sees a fully-seeded ssrData — the whole reason multi-
296
+ // boundary streaming hydrates cleanly without inline patch scripts.
297
+ expect(entryIdx).toBeGreaterThan(dataIdx);
298
+ // ssrData round-trips into the blob.
299
+ expect(tail).toContain("Note");
300
+ expect(tail).toContain('"body":"hi"');
301
+ });
302
+ });