#!/usr/bin/env node // Measure what the editor actually costs to navigate, against a RUNNING // instance. Read-only: it issues GETs and times them, nothing else. That is // deliberate — it can be pointed at the live :3001 instance without booting a // second editor against the real corpus (which would arm the job runners and // resume the multi-week digest sweep via instrumentation.ts). // // node scripts/measure-nav.mjs [--base http://localhost:3001] [--runs 3] // // It reports three different costs per route, because they are three different // things and conflating them is what misdirected this whole investigation once // already: // // document a full page load (what you get from the URL bar / a reload) // nav a SIBLING CLIENT NAVIGATION (what a sidebar click actually costs) // prefetch the shell Next fetches ahead of time, with its byte count // // ⚠️ STATE-TREE ENCODING TRAPS. Both of these were hit while writing this: // // * The router state tree is a 4-element form: // [segment, parallelRoutes, url, refresh] // Sending a 5th element returns a bare HTTP 500 that reads like a real // server error rather than a malformed request. // * "refetch" is the FOURTH element, not the fifth. // * OMITTING the Next-Router-State-Tree header entirely makes the server // render from the ROOT — so you measure a whole document and think you have // measured a sidebar click. That mistake produced a "4.5s sidebar click" // figure that sent the first pass of this work after the wrong cause. const args = process.argv.slice(2); const argOf = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; }; const BASE = argOf("base", "http://localhost:3001").replace(/\/$/, ""); const RUNS = Number(argOf("runs", "3")); // Routes worth timing, with the segment name used to build a state tree. THE // THIRTEEN SINGLE-SEGMENT PAGES IN THE SIDEBAR (editor/app/lib/nav.ts), and // only those: stateTree() below encodes ONE segment, so /sites//charts // and /operations/ cannot be listed — each would need the nested // [segment, {children: [param, …]}] form, a different tree per route. A // RETIRED ROUTE ANSWERS 307 (editor/next.config.ts redirects); with // redirect: "manual" that is a millisecond stub, not a page timing, and the // >= 400 check below let five of them pass silently for a while. A 3xx is // flagged now. const ROUTES = [ ["/", "__PAGE__"], ["/channels", "channels"], ["/tags", "tags"], ["/review", "review"], ["/operations", "operations"], ["/sites", "sites"], ["/jobs", "jobs"], ["/workers", "workers"], ["/cleanup", "cleanup"], ["/saved-videos", "saved-videos"], ["/storage", "storage"], ["/settings", "settings"], ["/changelog", "changelog"], ]; // The polled endpoints, by the /api/view/ paths the pages actually call // (editor/app/api/view/[name]). The six pre-phase-3 paths (/api/pulse, // /api/widget/{cleanable,actionable,sync}, /api/jobs/active, /api/workers) are // retired — they answer only through editor/next.config.ts's rewrites, so // timing them measured rewrite + view, a hop no page pays. const APIS = [ "/api/view/pulse", "/api/view/cleanable", "/api/view/widgetActionable", "/api/view/widgetSync", "/api/view/activeJobs", "/api/view/workers", ]; // The 4-element form. A 5th element is a 500; `true` in slot 4 means "refetch", // which is what router.refresh() sends. function stateTree(segment, refetch) { const page = segment === "__PAGE__" ? ["", { children: ["__PAGE__", {}] }, null, refetch ? "refetch" : null] : ["", { children: [segment, { children: ["__PAGE__", {}] }] }, null, refetch ? "refetch" : null]; return JSON.stringify(page); } async function timeGet(url, headers = {}) { const t0 = performance.now(); let bytes = 0; let status = 0; try { const res = await fetch(url, { headers, redirect: "manual" }); status = res.status; const buf = await res.arrayBuffer(); bytes = buf.byteLength; } catch (e) { return { ms: performance.now() - t0, bytes: 0, status: 0, error: String(e) }; } return { ms: performance.now() - t0, bytes, status }; } // Best-of-N. The median would hide a first-hit compile in dev; the minimum is // the cleanest signal of what the work actually costs once warm — and warming // is exactly what a real user's second visit does. async function best(fn) { let out = null; for (let i = 0; i < RUNS; i++) { const r = await fn(); if (!out || r.ms < out.ms) out = r; } return out; } const fmt = (n) => `${n.toFixed(0)}`.padStart(7); const kb = (n) => `${(n / 1024).toFixed(1)}kb`.padStart(9); console.log(`base=${BASE} runs=${RUNS}\n`); console.log( "route".padEnd(16) + "document".padStart(9) + "nav".padStart(9) + "refresh".padStart(9) + "prefetch".padStart(10) + " prefetch size", ); console.log("-".repeat(72)); for (const [route, segment] of ROUTES) { const doc = await best(() => timeGet(`${BASE}${route}`)); const nav = await best(() => timeGet(`${BASE}${route}?_rsc=measure`, { RSC: "1", "Next-Router-State-Tree": stateTree(segment, false), }), ); const refresh = await best(() => timeGet(`${BASE}${route}?_rsc=measure`, { RSC: "1", "Next-Router-State-Tree": stateTree(segment, true), }), ); const pre = await best(() => timeGet(`${BASE}${route}?_rsc=measure`, { RSC: "1", "Next-Router-Prefetch": "1", "Next-Router-State-Tree": stateTree(segment, false), }), ); const bad = [doc, nav, refresh, pre].find((r) => r.status >= 400 || r.status === 0); const moved = [doc, nav, refresh, pre].find((r) => r.status >= 300 && r.status < 400); console.log( route.padEnd(16) + fmt(doc.ms) + " " + fmt(nav.ms) + " " + fmt(refresh.ms) + " " + fmt(pre.ms) + " " + kb(pre.bytes) + (bad ? ` ⚠️ status ${bad.status}${bad.error ? ` ${bad.error}` : ""}` : moved ? ` ⚠️ ${moved.status} redirect — a stub, not a page timing` : ""), ); } console.log("\n" + "endpoint".padEnd(30) + "ms".padStart(8) + " size"); console.log("-".repeat(56)); for (const api of APIS) { const r = await best(() => timeGet(`${BASE}${api}`)); console.log( api.padEnd(30) + fmt(r.ms) + " " + kb(r.bytes) + (r.status >= 400 || r.status === 0 ? ` ⚠️ status ${r.status}` : ""), ); } // The idle pulse is the one that has to stay cheap forever — measure it the way // the client actually calls it, with a rev in hand. const first = await timeGet(`${BASE}/api/view/pulse`); if (first.status === 200) { const res = await fetch(`${BASE}/api/view/pulse`); const body = await res.json(); const idle = await best(() => timeGet(`${BASE}/api/view/pulse?rev=${encodeURIComponent(body.rev)}`), ); console.log( "\n" + "/api/view/pulse (idle, rev matched)".padEnd(36) + fmt(idle.ms) + "ms ← this one is polled forever; keep it tiny", ); }