@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.299",
3
+ "version": "0.3.301",
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",
@@ -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), so userland (page setHeader /
2314
- // route-handler headers via `extra`) can't forge either. Mutually
2315
- // exclusive (bucketOptIn splits the two verdicts).
2316
- bucketable
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
- : undefined,
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,