@pylonsync/functions 0.3.301 → 0.3.303

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/ssr-runtime.ts +225 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.301",
3
+ "version": "0.3.303",
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",
@@ -1159,13 +1159,14 @@ const DEV_LIVE_RELOAD_SNIPPET =
1159
1159
  "}catch(_){}})();</script>";
1160
1160
 
1161
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.
1162
+ * The dev HUD (a floating bottom-left overlay, dev-only): surfaces the framework
1163
+ * decisions a dev otherwise can't see — the cache verdict + WHY a page isn't
1164
+ * cached, render mode + timing, sync connection + offline-outbox depth, /api
1165
+ * activity + policy denials, and client errors. The cache / api / errors rows
1166
+ * expand a detail drawer (click). Written as a plain function and embedded via
1167
+ * `.toString()` (Bun strips the TS annotations), so it ships as self-contained
1168
+ * browser JS that closes over nothing. Browser globals go through `g` so it
1169
+ * typechecks without a DOM lib. Mirrors Next's dev indicator, tuned to Pylon.
1169
1170
  */
1170
1171
  function pylonDevHud() {
1171
1172
  const g: any = globalThis;
@@ -1181,6 +1182,7 @@ function pylonDevHud() {
1181
1182
  // Marker the sync engine checks before publishing its dev status probe.
1182
1183
  g.__PYLON_DEV__ = info;
1183
1184
 
1185
+ // Client errors.
1184
1186
  const errs: string[] = [];
1185
1187
  const onErr = (m: any) => {
1186
1188
  errs.push(String(m));
@@ -1191,7 +1193,68 @@ function pylonDevHud() {
1191
1193
  g.addEventListener("unhandledrejection", (e: any) => onErr((e && e.reason) || e));
1192
1194
  }
1193
1195
 
1194
- const C = { ok: "#3fb950", warn: "#d29922", bad: "#f85149", dim: "#6e7681" };
1196
+ // /api activity: wrap fetch ONCE to observe Pylon API calls (method, path,
1197
+ // status, ms, and a policy/error reason on non-2xx). Transparent — returns the
1198
+ // ORIGINAL promise untouched; the body is read from a clone only on errors.
1199
+ const api: any[] = [];
1200
+ if (g.fetch && !g.__pylonFetchWrapped) {
1201
+ g.__pylonFetchWrapped = true;
1202
+ const orig = g.fetch.bind(g);
1203
+ g.fetch = function (input: any, init: any) {
1204
+ let url = "";
1205
+ try {
1206
+ url = typeof input === "string" ? input : (input && input.url) || "";
1207
+ } catch (_e) {}
1208
+ const method = String(
1209
+ (init && init.method) || (input && input.method) || "GET",
1210
+ ).toUpperCase();
1211
+ const t0 = g.performance && g.performance.now ? g.performance.now() : Date.now();
1212
+ const p = orig(input, init);
1213
+ if (url.indexOf("/api/") !== -1 && p && p.then) {
1214
+ const path = url.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
1215
+ const done = (status: number, reason: string) => {
1216
+ const t1 = g.performance && g.performance.now ? g.performance.now() : Date.now();
1217
+ api.push({
1218
+ method,
1219
+ path,
1220
+ status,
1221
+ ms: Math.round((t1 - t0) * 10) / 10,
1222
+ reason,
1223
+ denied: status === 403,
1224
+ });
1225
+ if (api.length > 40) api.shift();
1226
+ };
1227
+ p.then(
1228
+ (res: any) => {
1229
+ if (!res.ok && res.clone) {
1230
+ res
1231
+ .clone()
1232
+ .json()
1233
+ .then((b: any) =>
1234
+ done(
1235
+ res.status,
1236
+ (b && b.error && (b.error.message || b.error.code)) || (b && b.message) || "",
1237
+ ),
1238
+ )
1239
+ .catch(() => done(res.status, ""));
1240
+ } else {
1241
+ done(res.status, "");
1242
+ }
1243
+ },
1244
+ () => done(0, "network error"),
1245
+ );
1246
+ }
1247
+ return p;
1248
+ };
1249
+ }
1250
+
1251
+ const C = {
1252
+ ok: "#3fb950",
1253
+ warn: "#d29922",
1254
+ bad: "#f85149",
1255
+ dim: "#6e7681",
1256
+ txt: "#e6edf3",
1257
+ };
1195
1258
  const make = (tag: string, css: string, text?: string) => {
1196
1259
  const n = d.createElement(tag);
1197
1260
  n.style.cssText = css;
@@ -1199,7 +1262,11 @@ function pylonDevHud() {
1199
1262
  return n;
1200
1263
  };
1201
1264
  const dot = (color: string) =>
1202
- make("span", "display:inline-block;width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:" + color);
1265
+ make(
1266
+ "span",
1267
+ "display:inline-block;width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:" +
1268
+ color,
1269
+ );
1203
1270
 
1204
1271
  const box = make(
1205
1272
  "div",
@@ -1207,18 +1274,111 @@ function pylonDevHud() {
1207
1274
  );
1208
1275
  const panel = make(
1209
1276
  "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)",
1277
+ "background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:9px 11px;margin-bottom:6px;min-width:260px;max-width:380px;box-shadow:0 8px 28px rgba(0,0,0,.45)",
1211
1278
  );
1212
1279
  const pill = make(
1213
1280
  "button",
1214
1281
  "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
1282
  );
1216
1283
 
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));
1284
+ // The expandable detail area below the rows.
1285
+ let activeDrawer: string | null = null;
1286
+ const drawer = make(
1287
+ "div",
1288
+ "display:none;margin-top:7px;padding-top:7px;border-top:1px solid #21262d;max-height:200px;overflow:auto",
1289
+ );
1290
+ const drawerLine = (label: string, value: string, color?: string) => {
1291
+ const r = make("div", "display:flex;gap:8px;margin:2px 0");
1292
+ r.appendChild(make("span", "color:#8b949e;flex:0 0 110px", label));
1293
+ r.appendChild(make("span", "color:" + (color || C.txt) + ";flex:1;word-break:break-word", value));
1294
+ drawer.appendChild(r);
1295
+ };
1296
+ const renderDrawer = () => {
1297
+ if (!activeDrawer) {
1298
+ drawer.style.display = "none";
1299
+ return;
1300
+ }
1301
+ drawer.style.display = "block";
1302
+ drawer.textContent = "";
1303
+ if (activeDrawer === "cache") {
1304
+ const f = (info.cache && info.cache.flags) || {};
1305
+ const optIn = f.bucketOptIn
1306
+ ? "auth-bucketed"
1307
+ : f.revalidateSecs != null
1308
+ ? "revalidate=" + f.revalidateSecs + "s"
1309
+ : "none";
1310
+ drawerLine("opt-in", optIn, optIn === "none" ? C.warn : C.ok);
1311
+ drawerLine("props.auth", f.authTouched ? "read ✗" : "untouched ✓", f.authTouched ? C.bad : C.ok);
1312
+ drawerLine(
1313
+ "headers/cookies",
1314
+ f.dynamicTouched ? "read ✗" : "untouched ✓",
1315
+ f.dynamicTouched ? C.bad : C.ok,
1316
+ );
1317
+ drawerLine("props.session", f.sessionTouched ? "read (bucket bit)" : "untouched", C.dim);
1318
+ drawerLine("set-cookie", String(f.cookieCount || 0), f.cookieCount ? C.bad : C.ok);
1319
+ drawerLine("strict policies", f.strictPolicies ? "on ✗" : "off ✓", f.strictPolicies ? C.bad : C.ok);
1320
+ drawerLine("streaming", f.wantsStream ? "yes" : "no", C.dim);
1321
+ drawerLine("status", String(f.status != null ? f.status : "—"), f.status === 200 ? C.ok : C.warn);
1322
+ } else if (activeDrawer === "api") {
1323
+ if (!api.length) {
1324
+ drawerLine("", "no /api calls yet", C.dim);
1325
+ } else {
1326
+ for (let i = api.length - 1; i >= 0; i--) {
1327
+ const a = api[i];
1328
+ const col =
1329
+ a.status === 0 || a.status >= 500 || a.denied
1330
+ ? C.bad
1331
+ : a.status >= 400
1332
+ ? C.warn
1333
+ : C.ok;
1334
+ drawer.appendChild(
1335
+ make(
1336
+ "div",
1337
+ "margin:2px 0;color:" + col + ";word-break:break-word",
1338
+ a.method +
1339
+ " " +
1340
+ a.path +
1341
+ " " +
1342
+ (a.status || "ERR") +
1343
+ " · " +
1344
+ a.ms +
1345
+ "ms" +
1346
+ (a.reason ? " — " + a.reason : ""),
1347
+ ),
1348
+ );
1349
+ }
1350
+ }
1351
+ } else if (activeDrawer === "errors") {
1352
+ if (!errs.length) {
1353
+ drawerLine("", "no errors", C.dim);
1354
+ } else {
1355
+ for (let i = errs.length - 1; i >= 0; i--) {
1356
+ drawer.appendChild(
1357
+ make("div", "margin:2px 0;color:" + C.bad + ";word-break:break-word", errs[i]),
1358
+ );
1359
+ }
1360
+ }
1361
+ }
1362
+ };
1363
+
1364
+ // A row; pass `key` to make it click-to-expand the matching drawer section.
1365
+ // Each row hover-explains itself via a native `title` (the terse labels are
1366
+ // cryptic otherwise). `key` makes the row click-to-expand its drawer.
1367
+ const rowEl = (label: string, tip: string, key?: string) => {
1368
+ const r = make(
1369
+ "div",
1370
+ "display:flex;align-items:flex-start;gap:8px;margin:3px 0" + (key ? ";cursor:pointer" : ""),
1371
+ );
1372
+ r.title = key ? tip + " (click to expand)" : tip;
1373
+ r.appendChild(make("span", "color:#8b949e;flex:0 0 60px", key ? label + " ▸" : label));
1220
1374
  const v = make("span", "color:#e6edf3;word-break:break-word;flex:1");
1221
1375
  r.appendChild(v);
1376
+ if (key) {
1377
+ r.onclick = () => {
1378
+ activeDrawer = activeDrawer === key ? null : key;
1379
+ renderDrawer();
1380
+ };
1381
+ }
1222
1382
  panel.appendChild(r);
1223
1383
  return v;
1224
1384
  };
@@ -1228,16 +1388,39 @@ function pylonDevHud() {
1228
1388
  cache.verdict === "dynamic"
1229
1389
  ? "dynamic"
1230
1390
  : cache.verdict + (cache.secs ? " · " + cache.secs + "s" : "");
1231
- rowEl("route").textContent = info.route || "—";
1232
- rowEl("page").textContent = info.component || "—";
1233
- const cacheV = rowEl("cache");
1391
+ rowEl("route", "The request path being server-rendered.").textContent = info.route || "—";
1392
+ rowEl("page", "The page component (app/…/page.tsx) that matched this route.").textContent =
1393
+ info.component || "—";
1394
+ const cacheV = rowEl(
1395
+ "cache",
1396
+ "SSR output-cache verdict — dynamic = re-rendered every request; cacheable = shared anonymous cache; bucketed = cached per signed-in/out shell. Shows the single reason it's dynamic.",
1397
+ "cache",
1398
+ );
1234
1399
  cacheV.textContent = cacheLabel + (cache.reason ? " · " + cache.reason : "");
1235
1400
  cacheV.style.color = cache.verdict === "dynamic" ? C.warn : C.ok;
1236
- rowEl("render").textContent =
1401
+ rowEl(
1402
+ "render",
1403
+ "How the page rendered (buffered vs streaming) and the server render time, end to end.",
1404
+ ).textContent =
1237
1405
  (info.renderMode || "ssr") + (info.renderMs != null ? " · " + info.renderMs + "ms" : "");
1238
- const syncV = rowEl("sync");
1239
- const errV = rowEl("errors");
1406
+ const syncV = rowEl(
1407
+ "sync",
1408
+ "Local-first sync engine — connection status · queued offline writes (outbox) · rows in the local replica.",
1409
+ );
1410
+ const apiV = rowEl(
1411
+ "api",
1412
+ "Recent /api/* calls this page made: status + timing. 403s show the policy reason.",
1413
+ "api",
1414
+ );
1415
+ const errV = rowEl(
1416
+ "errors",
1417
+ "Uncaught client errors + unhandled promise rejections captured on this page.",
1418
+ "errors",
1419
+ );
1420
+ panel.appendChild(drawer);
1240
1421
 
1422
+ pill.title =
1423
+ "Pylon dev HUD — cache verdict, render timing, sync + /api activity, errors. Click to expand.";
1241
1424
  pill.appendChild(dot(cache.verdict === "dynamic" ? C.warn : C.ok));
1242
1425
  pill.appendChild(make("span", "font-weight:600;color:#e6edf3", "pylon"));
1243
1426
  const pillSyncDot = dot(C.dim);
@@ -1277,8 +1460,14 @@ function pylonDevHud() {
1277
1460
  syncV.style.color = C.dim;
1278
1461
  pillSyncDot.style.background = online ? C.dim : C.bad;
1279
1462
  }
1280
- errV.textContent = errs.length === 0 ? "0" : errs.length + " · " + errs[errs.length - 1];
1463
+ const failed = api.filter((a) => a.denied || a.status === 0 || a.status >= 400).length;
1464
+ apiV.textContent =
1465
+ api.length === 0 ? "—" : api.length + " calls" + (failed ? " · " + failed + " failed" : "");
1466
+ apiV.style.color = failed ? C.bad : api.length ? C.ok : C.dim;
1467
+ errV.textContent = String(errs.length);
1281
1468
  errV.style.color = errs.length ? C.bad : C.dim;
1469
+ // Keep the live sections (api / errors) fresh while open.
1470
+ if (activeDrawer === "api" || activeDrawer === "errors") renderDrawer();
1282
1471
  };
1283
1472
 
1284
1473
  box.appendChild(panel);
@@ -2739,7 +2928,22 @@ export async function handleRenderRoute(
2739
2928
  component: msg.component,
2740
2929
  renderMode,
2741
2930
  renderMs,
2742
- cache: devVerdict,
2931
+ // verdict + reason + the raw gate flags, so the HUD's cache drawer can
2932
+ // show the full per-gate breakdown (which check vetoed caching).
2933
+ cache: {
2934
+ ...devVerdict,
2935
+ flags: {
2936
+ bucketOptIn,
2937
+ revalidateSecs,
2938
+ authTouched,
2939
+ dynamicTouched,
2940
+ sessionTouched,
2941
+ cookieCount: responseState.cookies.length,
2942
+ strictPolicies,
2943
+ wantsStream,
2944
+ status: responseState.status,
2945
+ },
2946
+ },
2743
2947
  }),
2744
2948
  );
2745
2949
  // The structured dev-log line is emitted host-side (Rust tracing) from the