@pylonsync/functions 0.3.299 → 0.3.301
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/dist/ssr-runtime.d.ts +32 -0
- package/package.json +1 -1
- package/src/ssr-runtime.ts +281 -9
- package/src/ssr-streaming.test.ts +56 -0
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -335,6 +335,13 @@ export declare function isSafeRedirect(url: string, opts: {
|
|
|
335
335
|
* into a page's metadata. Explicit `metadata.icons.*` wins. */
|
|
336
336
|
export declare function applyAutoIcons(component: string, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
|
|
337
337
|
export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
|
|
338
|
+
/**
|
|
339
|
+
* Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
|
|
340
|
+
* mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
|
|
341
|
+
* marker + probe are in place before the deferred client entry boots the sync
|
|
342
|
+
* engine. `<` is escaped so the JSON can't break out of the script.
|
|
343
|
+
*/
|
|
344
|
+
export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
|
|
338
345
|
/**
|
|
339
346
|
* Build the hydration tail appended after React's stream EOFs: the
|
|
340
347
|
* `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
|
|
@@ -444,6 +451,31 @@ export declare function computeBucketVerdict(args: {
|
|
|
444
451
|
wantsStream: boolean;
|
|
445
452
|
status: number;
|
|
446
453
|
}): boolean;
|
|
454
|
+
/**
|
|
455
|
+
* Dev HUD: explain the cache verdict for a finished render in human terms — the
|
|
456
|
+
* verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
|
|
457
|
+
* it's unit-tested and matches `computeCacheVerdict`/`computeBucketVerdict`
|
|
458
|
+
* exactly. The "why isn't my page caching?" answer the framework otherwise throws
|
|
459
|
+
* away.
|
|
460
|
+
*/
|
|
461
|
+
export declare function describeCacheVerdict(v: {
|
|
462
|
+
bucketOptIn: boolean;
|
|
463
|
+
cacheable: boolean;
|
|
464
|
+
bucketable: boolean;
|
|
465
|
+
revalidateSecs: number | null;
|
|
466
|
+
authTouched: boolean;
|
|
467
|
+
dynamicTouched: boolean;
|
|
468
|
+
sessionTouched: boolean;
|
|
469
|
+
cookieCount: number;
|
|
470
|
+
strictPolicies: boolean;
|
|
471
|
+
wantsStream: boolean;
|
|
472
|
+
forceDynamic: boolean;
|
|
473
|
+
status: number;
|
|
474
|
+
}): {
|
|
475
|
+
verdict: "bucketed" | "cacheable" | "dynamic";
|
|
476
|
+
secs: number | null;
|
|
477
|
+
reason: string;
|
|
478
|
+
};
|
|
447
479
|
/**
|
|
448
480
|
* #278: diff the response head committed at `response_start` against the final
|
|
449
481
|
* state after EOF, to catch a late response.* mutation from a suspended subtree
|
package/package.json
CHANGED
package/src/ssr-runtime.ts
CHANGED
|
@@ -1158,6 +1158,155 @@ const DEV_LIVE_RELOAD_SNIPPET =
|
|
|
1158
1158
|
"if(b!==null&&e.data!==b){s.close();location.reload();return;}b=e.data;});" +
|
|
1159
1159
|
"}catch(_){}})();</script>";
|
|
1160
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* The dev HUD (a floating bottom-left overlay, dev-only): surfaces the
|
|
1163
|
+
* framework decisions a dev otherwise can't see — the cache verdict + WHY a page
|
|
1164
|
+
* isn't cached, render mode + timing, sync connection + offline-outbox depth, and
|
|
1165
|
+
* client error count. Written as a plain function and embedded via `.toString()`
|
|
1166
|
+
* (Bun strips the TS annotations), so it ships as self-contained browser JS that
|
|
1167
|
+
* closes over nothing. Browser globals go through `g` so it typechecks without a
|
|
1168
|
+
* DOM lib. Mirrors Next's dev indicator, tuned to Pylon's hidden state.
|
|
1169
|
+
*/
|
|
1170
|
+
function pylonDevHud() {
|
|
1171
|
+
const g: any = globalThis;
|
|
1172
|
+
const d: any = g.document;
|
|
1173
|
+
if (!d || g.__pylonHudMounted) return;
|
|
1174
|
+
g.__pylonHudMounted = true;
|
|
1175
|
+
|
|
1176
|
+
let info: any = {};
|
|
1177
|
+
try {
|
|
1178
|
+
const el = d.getElementById("__PYLON_DEV__");
|
|
1179
|
+
if (el) info = JSON.parse(el.textContent || "{}");
|
|
1180
|
+
} catch (_e) {}
|
|
1181
|
+
// Marker the sync engine checks before publishing its dev status probe.
|
|
1182
|
+
g.__PYLON_DEV__ = info;
|
|
1183
|
+
|
|
1184
|
+
const errs: string[] = [];
|
|
1185
|
+
const onErr = (m: any) => {
|
|
1186
|
+
errs.push(String(m));
|
|
1187
|
+
if (errs.length > 50) errs.shift();
|
|
1188
|
+
};
|
|
1189
|
+
if (g.addEventListener) {
|
|
1190
|
+
g.addEventListener("error", (e: any) => onErr((e && (e.message || e.error)) || e));
|
|
1191
|
+
g.addEventListener("unhandledrejection", (e: any) => onErr((e && e.reason) || e));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const C = { ok: "#3fb950", warn: "#d29922", bad: "#f85149", dim: "#6e7681" };
|
|
1195
|
+
const make = (tag: string, css: string, text?: string) => {
|
|
1196
|
+
const n = d.createElement(tag);
|
|
1197
|
+
n.style.cssText = css;
|
|
1198
|
+
if (text != null) n.textContent = text;
|
|
1199
|
+
return n;
|
|
1200
|
+
};
|
|
1201
|
+
const dot = (color: string) =>
|
|
1202
|
+
make("span", "display:inline-block;width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:" + color);
|
|
1203
|
+
|
|
1204
|
+
const box = make(
|
|
1205
|
+
"div",
|
|
1206
|
+
"position:fixed;left:12px;bottom:12px;z-index:2147483646;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
|
|
1207
|
+
);
|
|
1208
|
+
const panel = make(
|
|
1209
|
+
"div",
|
|
1210
|
+
"background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:9px 11px;margin-bottom:6px;min-width:240px;max-width:360px;box-shadow:0 8px 28px rgba(0,0,0,.45)",
|
|
1211
|
+
);
|
|
1212
|
+
const pill = make(
|
|
1213
|
+
"button",
|
|
1214
|
+
"display:flex;align-items:center;gap:6px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:5px 11px;color:#e6edf3;cursor:pointer;font:inherit;box-shadow:0 2px 10px rgba(0,0,0,.35)",
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
const rowEl = (label: string) => {
|
|
1218
|
+
const r = make("div", "display:flex;align-items:flex-start;gap:8px;margin:3px 0");
|
|
1219
|
+
r.appendChild(make("span", "color:#8b949e;flex:0 0 60px", label));
|
|
1220
|
+
const v = make("span", "color:#e6edf3;word-break:break-word;flex:1");
|
|
1221
|
+
r.appendChild(v);
|
|
1222
|
+
panel.appendChild(r);
|
|
1223
|
+
return v;
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const cache = info.cache || {};
|
|
1227
|
+
const cacheLabel =
|
|
1228
|
+
cache.verdict === "dynamic"
|
|
1229
|
+
? "dynamic"
|
|
1230
|
+
: cache.verdict + (cache.secs ? " · " + cache.secs + "s" : "");
|
|
1231
|
+
rowEl("route").textContent = info.route || "—";
|
|
1232
|
+
rowEl("page").textContent = info.component || "—";
|
|
1233
|
+
const cacheV = rowEl("cache");
|
|
1234
|
+
cacheV.textContent = cacheLabel + (cache.reason ? " · " + cache.reason : "");
|
|
1235
|
+
cacheV.style.color = cache.verdict === "dynamic" ? C.warn : C.ok;
|
|
1236
|
+
rowEl("render").textContent =
|
|
1237
|
+
(info.renderMode || "ssr") + (info.renderMs != null ? " · " + info.renderMs + "ms" : "");
|
|
1238
|
+
const syncV = rowEl("sync");
|
|
1239
|
+
const errV = rowEl("errors");
|
|
1240
|
+
|
|
1241
|
+
pill.appendChild(dot(cache.verdict === "dynamic" ? C.warn : C.ok));
|
|
1242
|
+
pill.appendChild(make("span", "font-weight:600;color:#e6edf3", "pylon"));
|
|
1243
|
+
const pillSyncDot = dot(C.dim);
|
|
1244
|
+
pill.appendChild(pillSyncDot);
|
|
1245
|
+
|
|
1246
|
+
let open = false;
|
|
1247
|
+
try {
|
|
1248
|
+
open = g.localStorage && g.localStorage.getItem("pylon.hud.open") === "1";
|
|
1249
|
+
} catch (_e) {}
|
|
1250
|
+
panel.style.display = open ? "block" : "none";
|
|
1251
|
+
pill.onclick = () => {
|
|
1252
|
+
open = !open;
|
|
1253
|
+
panel.style.display = open ? "block" : "none";
|
|
1254
|
+
try {
|
|
1255
|
+
g.localStorage && g.localStorage.setItem("pylon.hud.open", open ? "1" : "0");
|
|
1256
|
+
} catch (_e) {}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const refresh = () => {
|
|
1260
|
+
const s = g.__pylonDevSync;
|
|
1261
|
+
if (s) {
|
|
1262
|
+
let st = "?";
|
|
1263
|
+
let pend = 0;
|
|
1264
|
+
let rows = 0;
|
|
1265
|
+
try {
|
|
1266
|
+
st = s.status();
|
|
1267
|
+
pend = s.pending();
|
|
1268
|
+
rows = s.rows();
|
|
1269
|
+
} catch (_e) {}
|
|
1270
|
+
syncV.textContent = st + " · " + pend + " pending · " + rows + " rows";
|
|
1271
|
+
const col = st === "connected" ? C.ok : st === "offline" ? C.bad : C.warn;
|
|
1272
|
+
syncV.style.color = col;
|
|
1273
|
+
pillSyncDot.style.background = col;
|
|
1274
|
+
} else {
|
|
1275
|
+
const online = g.navigator ? g.navigator.onLine : true;
|
|
1276
|
+
syncV.textContent = "no sync engine · " + (online ? "online" : "offline");
|
|
1277
|
+
syncV.style.color = C.dim;
|
|
1278
|
+
pillSyncDot.style.background = online ? C.dim : C.bad;
|
|
1279
|
+
}
|
|
1280
|
+
errV.textContent = errs.length === 0 ? "0" : errs.length + " · " + errs[errs.length - 1];
|
|
1281
|
+
errV.style.color = errs.length ? C.bad : C.dim;
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
box.appendChild(panel);
|
|
1285
|
+
box.appendChild(pill);
|
|
1286
|
+
const mount = () => (d.body || d.documentElement).appendChild(box);
|
|
1287
|
+
if (d.body) mount();
|
|
1288
|
+
else if (g.addEventListener) g.addEventListener("DOMContentLoaded", mount);
|
|
1289
|
+
refresh();
|
|
1290
|
+
if (g.setInterval) g.setInterval(refresh, 1000);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
|
|
1295
|
+
* mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
|
|
1296
|
+
* marker + probe are in place before the deferred client entry boots the sync
|
|
1297
|
+
* engine. `<` is escaped so the JSON can't break out of the script.
|
|
1298
|
+
*/
|
|
1299
|
+
export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
|
|
1300
|
+
const json = JSON.stringify(devInfo)
|
|
1301
|
+
.replace(/</g, "\\u003c")
|
|
1302
|
+
.replace(/\u2028/g, "\\u2028")
|
|
1303
|
+
.replace(/\u2029/g, "\\u2029");
|
|
1304
|
+
return (
|
|
1305
|
+
`<script id="__PYLON_DEV__" type="application/json">${json}</script>` +
|
|
1306
|
+
`<script>(${pylonDevHud.toString()})();</script>`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1161
1310
|
/**
|
|
1162
1311
|
* Build the <head> blob for a boundary render: the union of every route's
|
|
1163
1312
|
* stylesheet links from the client build manifest. Boundary modules aren't
|
|
@@ -1705,6 +1854,69 @@ export function computeBucketVerdict(args: {
|
|
|
1705
1854
|
);
|
|
1706
1855
|
}
|
|
1707
1856
|
|
|
1857
|
+
/**
|
|
1858
|
+
* Dev HUD: explain the cache verdict for a finished render in human terms — the
|
|
1859
|
+
* verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
|
|
1860
|
+
* it's unit-tested and matches `computeCacheVerdict`/`computeBucketVerdict`
|
|
1861
|
+
* exactly. The "why isn't my page caching?" answer the framework otherwise throws
|
|
1862
|
+
* away.
|
|
1863
|
+
*/
|
|
1864
|
+
export function describeCacheVerdict(v: {
|
|
1865
|
+
bucketOptIn: boolean;
|
|
1866
|
+
cacheable: boolean;
|
|
1867
|
+
bucketable: boolean;
|
|
1868
|
+
revalidateSecs: number | null;
|
|
1869
|
+
authTouched: boolean;
|
|
1870
|
+
dynamicTouched: boolean;
|
|
1871
|
+
sessionTouched: boolean;
|
|
1872
|
+
cookieCount: number;
|
|
1873
|
+
strictPolicies: boolean;
|
|
1874
|
+
wantsStream: boolean;
|
|
1875
|
+
forceDynamic: boolean;
|
|
1876
|
+
status: number;
|
|
1877
|
+
}): { verdict: "bucketed" | "cacheable" | "dynamic"; secs: number | null; reason: string } {
|
|
1878
|
+
if (v.bucketable) {
|
|
1879
|
+
return {
|
|
1880
|
+
verdict: "bucketed",
|
|
1881
|
+
secs: v.revalidateSecs,
|
|
1882
|
+
reason: "shared cache, keyed on session presence",
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
if (v.cacheable) {
|
|
1886
|
+
return {
|
|
1887
|
+
verdict: "cacheable",
|
|
1888
|
+
secs: v.revalidateSecs,
|
|
1889
|
+
reason: "anonymous shared cache",
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
// Dynamic — surface the single most useful reason (mirrors the verdict order).
|
|
1893
|
+
let reason: string;
|
|
1894
|
+
if (!v.bucketOptIn && v.revalidateSecs == null) {
|
|
1895
|
+
reason = "not opted in — add `export const revalidate = N` (or `cache = \"auth-bucketed\"`)";
|
|
1896
|
+
} else if (v.status !== 200) {
|
|
1897
|
+
reason = `status ${v.status} (only 200 is cacheable)`;
|
|
1898
|
+
} else if (v.forceDynamic) {
|
|
1899
|
+
reason = "dynamic = \"force-dynamic\"";
|
|
1900
|
+
} else if (v.wantsStream) {
|
|
1901
|
+
reason = "streaming render (loading.tsx / streaming = true)";
|
|
1902
|
+
} else if (v.cookieCount > 0) {
|
|
1903
|
+
reason = "the render set a cookie";
|
|
1904
|
+
} else if (v.strictPolicies) {
|
|
1905
|
+
reason = "PYLON_STRICT_FN_POLICIES (serverData reads are auth-filtered)";
|
|
1906
|
+
} else if (v.authTouched) {
|
|
1907
|
+
reason = "read props.auth (identity-specific)";
|
|
1908
|
+
} else if (v.dynamicTouched) {
|
|
1909
|
+
reason = "read request headers/cookies";
|
|
1910
|
+
} else if (v.sessionTouched) {
|
|
1911
|
+
reason = v.bucketOptIn
|
|
1912
|
+
? "read props.session"
|
|
1913
|
+
: "read props.session — add `export const cache = \"auth-bucketed\"` to bucket-cache it";
|
|
1914
|
+
} else {
|
|
1915
|
+
reason = "not cacheable";
|
|
1916
|
+
}
|
|
1917
|
+
return { verdict: "dynamic", secs: null, reason };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1708
1920
|
/**
|
|
1709
1921
|
* #278: diff the response head committed at `response_start` against the final
|
|
1710
1922
|
* state after EOF, to catch a late response.* mutation from a suspended subtree
|
|
@@ -1902,6 +2114,10 @@ export async function handleRenderRoute(
|
|
|
1902
2114
|
: null;
|
|
1903
2115
|
if (dataKind) return handleDataRoute(msg, dataKind, send);
|
|
1904
2116
|
|
|
2117
|
+
// Dev HUD: wall-clock for the whole render, surfaced in the dev overlay's
|
|
2118
|
+
// render row. `performance.now()` is monotonic; harmless in prod (unused).
|
|
2119
|
+
const renderStart = isDevMode() ? performance.now() : 0;
|
|
2120
|
+
|
|
1905
2121
|
// Declared OUTSIDE the try so the catch can read page-set status/
|
|
1906
2122
|
// cookies when turning a redirect()/notFound() throw into a response.
|
|
1907
2123
|
const responseState: ResponseState = {
|
|
@@ -2269,6 +2485,27 @@ export async function handleRenderRoute(
|
|
|
2269
2485
|
wantsStream,
|
|
2270
2486
|
status: responseState.status,
|
|
2271
2487
|
});
|
|
2488
|
+
// Dev diagnostics (the agent + HUD signal): the cache verdict + the single
|
|
2489
|
+
// actionable reason, computed ONCE here and reused for the dev HUD blob, the
|
|
2490
|
+
// `x-pylon-dev` header (→ the host's diagnostics ring + `pylon diagnostics`),
|
|
2491
|
+
// and the structured dev log. Null in prod.
|
|
2492
|
+
const renderMode = wantsStream ? "ssr-streaming" : "ssr-buffered";
|
|
2493
|
+
const devVerdict = isDevMode()
|
|
2494
|
+
? describeCacheVerdict({
|
|
2495
|
+
bucketOptIn,
|
|
2496
|
+
cacheable,
|
|
2497
|
+
bucketable,
|
|
2498
|
+
revalidateSecs,
|
|
2499
|
+
authTouched,
|
|
2500
|
+
dynamicTouched,
|
|
2501
|
+
sessionTouched,
|
|
2502
|
+
cookieCount: responseState.cookies.length,
|
|
2503
|
+
strictPolicies,
|
|
2504
|
+
wantsStream,
|
|
2505
|
+
forceDynamic,
|
|
2506
|
+
status: responseState.status,
|
|
2507
|
+
})
|
|
2508
|
+
: null;
|
|
2272
2509
|
// Serialization view of props built from the RAW `msg` values — the live
|
|
2273
2510
|
// `props` (with its read-tracking proxies) is NEVER mutated back to raw, so
|
|
2274
2511
|
// it can't become a vehicle that leaks a prior request's identity to a later
|
|
@@ -2306,19 +2543,32 @@ export async function handleRenderRoute(
|
|
|
2306
2543
|
type: "response_start",
|
|
2307
2544
|
call_id: msg.call_id,
|
|
2308
2545
|
status: responseState.status,
|
|
2309
|
-
headers: finalizeHeaders(
|
|
2310
|
-
responseState,
|
|
2311
|
-
undefined,
|
|
2546
|
+
headers: finalizeHeaders(responseState, undefined, {
|
|
2312
2547
|
// The #277 anon proof and the Phase 0 bucket proof both ride the TRUSTED
|
|
2313
|
-
// `internal` channel (never stripped
|
|
2314
|
-
//
|
|
2315
|
-
// exclusive (bucketOptIn splits
|
|
2316
|
-
|
|
2548
|
+
// `internal` channel (never stripped here, always stripped by the host
|
|
2549
|
+
// before the client), so userland (page setHeader / route-handler headers
|
|
2550
|
+
// via `extra`) can't forge either. Mutually exclusive (bucketOptIn splits
|
|
2551
|
+
// the two verdicts).
|
|
2552
|
+
...(bucketable
|
|
2317
2553
|
? { "x-pylon-bucket": String(revalidateSecs) }
|
|
2318
2554
|
: cacheable
|
|
2319
2555
|
? { "x-pylon-cacheable": String(revalidateSecs) }
|
|
2320
|
-
:
|
|
2321
|
-
|
|
2556
|
+
: {}),
|
|
2557
|
+
// Dev-only: the host parses this into its diagnostics ring (served at
|
|
2558
|
+
// /_pylon/dev/diagnostics + `pylon diagnostics`). Single-line JSON, no
|
|
2559
|
+
// newlines. Stripped before the client like every x-pylon-* header.
|
|
2560
|
+
...(devVerdict
|
|
2561
|
+
? {
|
|
2562
|
+
"x-pylon-dev": JSON.stringify({
|
|
2563
|
+
verdict: devVerdict.verdict,
|
|
2564
|
+
secs: devVerdict.secs,
|
|
2565
|
+
reason: devVerdict.reason,
|
|
2566
|
+
mode: renderMode,
|
|
2567
|
+
component: msg.component,
|
|
2568
|
+
}),
|
|
2569
|
+
}
|
|
2570
|
+
: {}),
|
|
2571
|
+
}),
|
|
2322
2572
|
});
|
|
2323
2573
|
|
|
2324
2574
|
// Pre-load the manifest BEFORE the React stream starts emitting
|
|
@@ -2475,6 +2725,28 @@ export async function handleRenderRoute(
|
|
|
2475
2725
|
sendChunk(tail);
|
|
2476
2726
|
}
|
|
2477
2727
|
|
|
2728
|
+
// Dev HUD (dev only): append the cache-verdict + render-timing blob + the
|
|
2729
|
+
// floating overlay, and emit ONE structured log line. After the page tail so
|
|
2730
|
+
// the HUD marker/probe are in place before the deferred client entry boots the
|
|
2731
|
+
// sync engine. `devVerdict` was computed once above (reused here + in the
|
|
2732
|
+
// x-pylon-dev header). The log rides the runtime's inherited stderr, so an
|
|
2733
|
+
// agent running `pylon dev` sees the verdict without any extra call.
|
|
2734
|
+
if (devVerdict) {
|
|
2735
|
+
const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
|
|
2736
|
+
sendChunk(
|
|
2737
|
+
buildDevHudChunk({
|
|
2738
|
+
route: msg.url,
|
|
2739
|
+
component: msg.component,
|
|
2740
|
+
renderMode,
|
|
2741
|
+
renderMs,
|
|
2742
|
+
cache: devVerdict,
|
|
2743
|
+
}),
|
|
2744
|
+
);
|
|
2745
|
+
// The structured dev-log line is emitted host-side (Rust tracing) from the
|
|
2746
|
+
// x-pylon-dev header, so it rides the same log stream as every other
|
|
2747
|
+
// [pylon] line — no duplicate console.error here.
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2478
2750
|
send({ type: "render_done", call_id: msg.call_id });
|
|
2479
2751
|
} catch (err: any) {
|
|
2480
2752
|
// A page/layout called response.redirect()/response.notFound(), or
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
computeCacheVerdict,
|
|
23
23
|
computeRevalidateSecs,
|
|
24
24
|
computeWantsStream,
|
|
25
|
+
describeCacheVerdict,
|
|
25
26
|
diffCommittedResponse,
|
|
26
27
|
isDevMode,
|
|
27
28
|
} from "./ssr-runtime";
|
|
@@ -222,6 +223,61 @@ describe("computeBucketVerdict (PPR Phase 0 — session-presence shared cache)",
|
|
|
222
223
|
});
|
|
223
224
|
});
|
|
224
225
|
|
|
226
|
+
describe("describeCacheVerdict (dev HUD — why isn't my page cached?)", () => {
|
|
227
|
+
const base = {
|
|
228
|
+
bucketOptIn: false,
|
|
229
|
+
cacheable: false,
|
|
230
|
+
bucketable: false,
|
|
231
|
+
revalidateSecs: null as number | null,
|
|
232
|
+
authTouched: false,
|
|
233
|
+
dynamicTouched: false,
|
|
234
|
+
sessionTouched: false,
|
|
235
|
+
cookieCount: 0,
|
|
236
|
+
strictPolicies: false,
|
|
237
|
+
wantsStream: false,
|
|
238
|
+
forceDynamic: false,
|
|
239
|
+
status: 200,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
test("cacheable / bucketed verdicts carry the TTL", () => {
|
|
243
|
+
expect(describeCacheVerdict({ ...base, cacheable: true, revalidateSecs: 60 })).toEqual({
|
|
244
|
+
verdict: "cacheable",
|
|
245
|
+
secs: 60,
|
|
246
|
+
reason: "anonymous shared cache",
|
|
247
|
+
});
|
|
248
|
+
const b = describeCacheVerdict({
|
|
249
|
+
...base,
|
|
250
|
+
bucketOptIn: true,
|
|
251
|
+
bucketable: true,
|
|
252
|
+
revalidateSecs: 30,
|
|
253
|
+
});
|
|
254
|
+
expect(b.verdict).toBe("bucketed");
|
|
255
|
+
expect(b.secs).toBe(30);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("dynamic verdict reports the SINGLE actionable reason", () => {
|
|
259
|
+
const why = (over: any) =>
|
|
260
|
+
describeCacheVerdict({ ...base, ...over }).reason;
|
|
261
|
+
// Not opted in at all.
|
|
262
|
+
expect(why({})).toContain("not opted in");
|
|
263
|
+
// Opted in (revalidate set) but each veto wins in priority order.
|
|
264
|
+
const opted = { revalidateSecs: 60 };
|
|
265
|
+
expect(why({ ...opted, status: 404 })).toContain("status 404");
|
|
266
|
+
expect(why({ ...opted, forceDynamic: true })).toContain("force-dynamic");
|
|
267
|
+
expect(why({ ...opted, wantsStream: true })).toContain("streaming");
|
|
268
|
+
expect(why({ ...opted, cookieCount: 1 })).toContain("set a cookie");
|
|
269
|
+
expect(why({ ...opted, strictPolicies: true })).toContain("STRICT");
|
|
270
|
+
expect(why({ ...opted, authTouched: true })).toContain("props.auth");
|
|
271
|
+
expect(why({ ...opted, dynamicTouched: true })).toContain("headers/cookies");
|
|
272
|
+
// Reading session on a non-bucket page → the actionable hint to opt into buckets.
|
|
273
|
+
expect(why({ ...opted, sessionTouched: true })).toContain("auth-bucketed");
|
|
274
|
+
// …but on a bucket-opted page that didn't bucket for another reason, no hint.
|
|
275
|
+
expect(why({ bucketOptIn: true, revalidateSecs: 60, sessionTouched: true })).not.toContain(
|
|
276
|
+
"auth-bucketed",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
225
281
|
describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
|
|
226
282
|
const snap = (over: any = {}) => ({
|
|
227
283
|
status: 200,
|