Public: anyone can read this; the crew can change it.

A first-party beacon reports each page's real load, LCP, INP, CLS and TTFB to the forge, keeping no IP #2733

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2711-a-first-party-beacon-reports-each-page-s → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/2711-a-first-party-beacon-reports-each-page-s was merged into main as d5460cd.

Review

Sign in to review this change.
11 files changed +422 −3
README.md +4
@@ -228,6 +228,10 @@ form that mints one and shows its secret once; others see no section.
228228 `owner/name`. `n` mints (the child's name, then its https address) and shows
229229 the secret once on its own screen; `x` revokes the one under the cursor.
230230
231 ## Page-view beacon
232
233 Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's.
234
231235 ## Live in the terminal
232236
233237 `ssh` into the forge and you get the whole thing — repositories, code, issues,
src/core/migration-pins.json +2 −1
@@ -119,5 +119,6 @@
119119 "20261018110000-limb-replan": "23d6a14e5ef934c4d8f1509377ac8a15dc6a0097313127558b4c5a655a24581e",
120120 "20261019120000-drill-phases": "574071a8576f982fe8483af90b472a03793ffa03e3b391c786d5c5144e3bb82e",
121121 "20261019130000-run-model-prompt": "ce2e94f439649fc042c0a951f1af4a740b91f674dc94521e865ec517f68994f5",
122 "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011"
122 "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011",
123 "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf"
123124 }
src/core/migrations/20261019150000-page-vitals.ts added +25
@@ -0,0 +1,25 @@
1 // A first-party beacon reports each page view's real load, LCP, INP, CLS and
2 // TTFB (#2711). No IP and no user agent string is kept: `visitor` is a hash
3 // under a salt that rotates daily, and yesterday's salt is deleted, so a
4 // visitor cannot be followed from one day to the next. A day holds at most a
5 // fixed number of rows (`DAY_MAX`), so strangers cannot grow the table. A metric the browser
6 // could not measure is NULL.
7 export default `
8 CREATE TABLE page_vital (
9 id INTEGER PRIMARY KEY,
10 day TEXT NOT NULL,
11 at INTEGER NOT NULL,
12 route TEXT NOT NULL,
13 visitor TEXT NOT NULL,
14 ttfb REAL,
15 load REAL,
16 lcp REAL,
17 inp REAL,
18 cls REAL NOT NULL
19 );
20 CREATE INDEX page_vital_day ON page_vital (day);
21 CREATE TABLE vital_salt (
22 day TEXT PRIMARY KEY,
23 salt TEXT NOT NULL
24 );
25 `;
src/http/measure.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * POST /_measure: the page-view beacon's door. Same-origin only (a beacon
3 * from a page always carries Origin; anything else is refused — that is CSRF
4 * protection only, any script can set the header), opted-out
5 * visitors stored nothing, a rate per client and per forge, a body of a few
6 * hundred bytes at most, every field validated (`web/measure.ts`), and the
7 * route is one the forge serves. What protects the database is the bound on
8 * what is stored (`DAY_MAX` rows a day, `recordVital`), not the checks here. The answer is always an empty 204 or a
9 * short refusal; nothing is read back.
10 */
11
12 import { RateLimiter } from "../core/ratelimit.ts";
13 import type { App } from "../app.ts";
14 import { optedOut, parseReport, recordVital, REPORT_MAX } from "../web/measure.ts";
15 import type { Ctx } from "./context.ts";
16 import { readBounded } from "./limits.ts";
17 import { clientAddress, clientBucket } from "./peer.ts";
18
outdated

Medium (DoS/storage). Anyone who sets an Origin header (curl can) may write 1200 rows a minute from many addresses. That is about 1.7M rows a day, kept for 90 days, so on the order of 10^8 rows on the owner's disk. One address alone writes about 43k rows a day. Fix: lower the forge cap to what one person's browsing produces (around 120/min). Better, bound the table too: skip the insert once a day's row count passes a ceiling, e.g. SELECT COUNT(*) FROM page_vital WHERE day = ? against 50k. That way a flood cannot outgrow the disk.

19 const PER_CLIENT = 30;
20 const PER_FORGE = 1200;
21
22 const limiter = (perMinute: number) =>
23 new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
24
25 /** One pair per forge, so a test's forge never throttles another's. */
26 const limits = new WeakMap<App, { client: RateLimiter; forge: RateLimiter }>();
27
28 const refuse = (status: number, headers?: Record<string, string>): Response =>
29 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
30
31 /** The route pattern a path belongs to, or undefined when none does. */
32 export type RouteOf = (path: string) => string | undefined;
33
34 export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Response> {
outdated

This is CSRF protection, not an abuse gate: any non-browser client can set Origin. The comment above should say so, and the storage bound in recordVital is what actually protects the database.

35 const { request, app } = ctx;
36 if (request.headers.get("origin") !== ctx.url.origin) return refuse(403);
37 if (optedOut(request)) return refuse(204);
38
39 let pair = limits.get(app);
40 if (pair === undefined) {
41 pair = { client: limiter(PER_CLIENT), forge: limiter(PER_FORGE) };
42 limits.set(app, pair);
43 }
44 for (const verdict of [
45 pair.client.take(clientBucket(request), "ro"),
46 pair.forge.take("forge", "ro"),
47 ]) {
48 if (!verdict.allowed)
49 return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) });
50 }
51
52 const bytes = await readBounded(request, REPORT_MAX);
53 if (bytes === "too_large") return refuse(413);
54 const report = parseReport(new TextDecoder().decode(bytes));
55 if (report === "malformed") return refuse(400);
56 const route = routeOf(report.path);
57 if (route === undefined) return refuse(400);
58
59 recordVital(
60 app.db,
61 report,
62 route,
63 clientAddress(request),
64 request.headers.get("user-agent") ?? "",
65 );
66 return refuse(204);
67 }
src/http/server.ts +12
@@ -22,6 +22,7 @@ import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2222 import { usagePage } from "../web/pages/usage.ts";
2323 import { resiliencePage } from "../web/pages/resilience.ts";
2424 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
25 import { measureSubmit } from "./measure.ts";
2526 import { boardsPage } from "../web/pages/boards.ts";
2627 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
2728 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -96,6 +97,7 @@ import { sha256 } from "../kernel/hash.ts";
9697 import {
9798 assetResponse,
9899 highlightAsset,
100 beaconAsset,
99101 shellAsset,
100102 stylesheetAsset,
101103 stylesheetRedirect,
@@ -640,6 +642,8 @@ export function buildRouter(): Router<Handler> {
640642 router.add("GET", "/assets/:name.js", async (ctx) => {
641643 const shell = shellAsset();
642644 if (ctx.params.name === shell.name) return assetResponse(ctx.request, shell);
645 if (ctx.params.name === beaconAsset().name)
646 return assetResponse(ctx.request, beaconAsset());
643647 if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(ctx.request);
644648 const asset = await highlightAsset();
645649 if (asset === null || ctx.params.name !== asset.name) return notFound();
@@ -648,6 +652,8 @@ export function buildRouter(): Router<Handler> {
648652 // The service worker: root scope needs a root path, and it must never be
649653 // cached by the browser's HTTP cache or an update would wait a day.
650654 router.add("GET", "/sw.js", serviceWorker);
655 // The page-view beacon's report (http/measure.ts): numbers only, no IP kept.
656 router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf));
651657 router.add("GET", "/", home);
652658 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
653659 router.add("GET", "/claim", claimFragmentPage);
@@ -1379,6 +1385,12 @@ export function buildApiRouter(): Router<Handler> {
13791385 return router;
13801386 }
13811387
1388 let pages: Router<Handler> | undefined;
1389 /** The route pattern a page's path belongs to, for the beacon: never the path itself. */
1390 function routeOf(path: string): string | undefined {
1391 return (pages ??= buildRouter()).match("GET", path)?.pattern;
1392 }
1393
13821394 let known: string[] | undefined;
13831395 /** Every pattern the studio's and the API's routers know, read once on first use. */
13841396 export function knownPatterns(): string[] {
src/web/asset.ts +13
@@ -18,6 +18,7 @@
1818
1919 import { sha256 } from "../kernel/hash.ts";
2020 import { createLog } from "../kernel/log.ts";
21 import { beaconJs } from "./beacon.ts";
2122 import { shellJs } from "./client.ts";
2223 import { css } from "./style.ts";
2324
@@ -148,3 +149,15 @@ export function shellAsset(): BuiltAsset {
148149 }
149150 return shell;
150151 }
152
153 let beacon: BuiltAsset | null = null;
154
155 /** The page-view beacon (`web/beacon.ts`), content-addressed and immutable. */
156 export function beaconAsset(): BuiltAsset {
157 if (beacon === null) {
158 const hash = sha256(beaconJs);
159 const name = `beacon-${hash.slice(0, 16)}`;
160 beacon = { path: `/assets/${name}.js`, name, code: beaconJs, etag: `"${hash}"` };
161 }
162 return beacon;
163 }
src/web/beacon.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * The page-view beacon, as static source: one classic script, under 2 KB,
3 * served content-addressed (`web/asset.ts`) and linked from every page. It
4 * watches LCP, layout shift (CLS) and interaction latency (INP, the worst
5 * interaction), and sends them once, with the route's path and the
6 * navigation's TTFB and load time, when the page is hidden or left. The path
7 * is read once, at start: the code browser moves with pushState, and the load
8 * being measured belongs to the page that was loaded. Nothing
9 * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the
10 * server checks both again. A metric it could not measure is 0 (or absent).
11 */
12 export const beaconJs = `!function(n,d,w){
13 if(n.doNotTrack=="1"||/(^|; )forge_measure=off/.test(d.cookie)||!n.sendBeacon||!w.PerformanceObserver)return;
14 var c=0,i=0,l=0,x=0,a=location.pathname;
15 function o(t,f,e){try{new PerformanceObserver(function(s){s.getEntries().forEach(f)}).observe(Object.assign({type:t,buffered:true},e))}catch(_){}}
16 o("largest-contentful-paint",function(e){l=e.startTime});
17 o("layout-shift",function(e){e.hadRecentInput||(c+=e.value)});
18 o("event",function(e){e.interactionId&&e.duration>i&&(i=e.duration)},{durationThreshold:40});
outdated

location.pathname is read when the page hides, but client.ts navigates with pushState, so metrics from the first load are filed under the last route. Read the path once, when the script starts.

19 function s(){if(x)return;x=1;var v=performance.getEntriesByType("navigation")[0]||{};
20 n.sendBeacon("/_measure",JSON.stringify({path:a,ttfb:v.responseStart||0,load:v.loadEventEnd||0,lcp:l,inp:i,cls:c}))}
21 d.addEventListener("visibilitychange",function(){d.visibilityState=="hidden"&&s()});
22 w.addEventListener("pagehide",s)
23 }(navigator,document,window)`;
src/web/layout.ts +2 −1
@@ -25,7 +25,7 @@ import {
2525 THEMES,
2626 type ThemeName,
2727 } from "../design/themes.ts";
28 import { shellAsset, stylesheetAsset } from "./asset.ts";
28 import { beaconAsset, shellAsset, stylesheetAsset } from "./asset.ts";
2929 import { linkHeader, pageLinks } from "./standards.ts";
3030 import {
3131 clearFlashCookie,
@@ -672,6 +672,7 @@ export function page(options: PageOptions, status = 200): Response {
672672 <body>
673673 ${shell}${iconSprite(shell.text)}
674674 <script nonce="${nonce}" src="${shellAsset().path}"></script>
675 <script nonce="${nonce}" src="${beaconAsset().path}" defer></script>
675676 ${options.scriptJs
676677 ? raw(`<script nonce="${nonce}">${options.scriptJs}</script>`)
677678 : null}
src/web/measure.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * The page-view beacon's receiver: validate a report, hash the visitor, store
3 * the numbers. Plain data in, plain data out. What is stored is the route
4 * pattern (never the path, which can name a repository), the five metrics,
5 * and a visitor hash; the address and the user agent string are read to make
6 * that hash and are never written anywhere.
7 *
8 * The salt rotates daily and the old salts are deleted, so a hash means "the
9 * same browser today" and cannot be joined across days. Rows are kept 90 days.
10 *
11 * Anyone can post here, so the table is bounded by what is stored, not only
12 * by the rate: a day keeps at most DAY_MAX rows and the rest are dropped.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { sha256 } from "../kernel/hash.ts";
17
18 /** A report is a few dozen bytes; this is already generous. */
19 export const REPORT_MAX = 512;
20 /** Times in milliseconds: ten minutes is longer than any real page view. */
21 export const TIME_MAX = 600_000;
22 export const CLS_MAX = 100;
23 /** The most rows one day keeps; past it a report is dropped (and still answered 204). */
24 export const DAY_MAX = 20_000;
25 const KEEP_MS = 90 * 24 * 60 * 60 * 1000;
26
27 export interface Report {
28 readonly path: string;
29 readonly ttfb: number | null;
30 readonly load: number | null;
31 readonly lcp: number | null;
32 readonly inp: number | null;
33 readonly cls: number;
34 }
35
36 const FIELDS = new Set(["path", "ttfb", "load", "lcp", "inp", "cls"]);
37
38 const bounded = (value: unknown, max: number): number | undefined =>
39 typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= max
40 ? value
41 : undefined;
42
43 /** A report from the body's text, or why not. Unknown keys are refused. */
44 export function parseReport(text: string): Report | "malformed" {
45 let raw: unknown;
46 try {
47 raw = JSON.parse(text);
48 } catch {
49 return "malformed";
50 }
51 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "malformed";
52 const body = raw as Record<string, unknown>;
53 if (Object.keys(body).some((key) => !FIELDS.has(key))) return "malformed";
54 const { path } = body;
55 if (typeof path !== "string" || path.length > 512 || !path.startsWith("/"))
56 return "malformed";
57 // eslint-disable-next-line no-control-regex
58 if (/[\u0000-\u001f\u007f]/.test(path)) return "malformed";
59 const cls = bounded(body["cls"], CLS_MAX);
60 if (cls === undefined) return "malformed";
61 const times: Record<string, number | null> = {};
62 for (const key of ["ttfb", "load", "lcp", "inp"] as const) {
63 const given = body[key];
64 if (given === undefined) {
65 times[key] = null;
66 continue;
67 }
68 const value = bounded(given, TIME_MAX);
69 if (value === undefined) return "malformed";
70 // 0 is how the beacon says "not measured".
71 times[key] = value === 0 ? null : value;
72 }
73 return {
74 path,
75 ttfb: times["ttfb"] ?? null,
76 load: times["load"] ?? null,
77 lcp: times["lcp"] ?? null,
78 inp: times["inp"] ?? null,
79 cls,
80 };
81 }
82
83 /** The visitor asked not to be measured: Do-Not-Track, or the opt-out cookie. */
84 export function optedOut(request: Request): boolean {
85 if (request.headers.get("dnt") === "1") return true;
86 const cookie = request.headers.get("cookie") ?? "";
87 return cookie.split(";").some((pair) => pair.trim() === "forge_measure=off");
88 }
89
90 const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
91
92 /** Today's salt, made on first use; every other day's is deleted. */
93 function saltFor(db: Database, at: number): string {
94 const day = dayOf(at);
95 const found = db
96 .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?")
97 .get(day);
98 if (found !== null) return found.salt;
99 const salt = new Bun.CryptoHasher("sha256")
outdated

Low. The 90-day prune runs only when the salt rotates, which is the first report of a UTC day. That works, but it hides the retention policy inside saltFor. A short prune(db, at) called from recordVital would make it read as its own step.

100 .update(crypto.getRandomValues(new Uint8Array(32)))
101 .digest("hex");
102 db.run("DELETE FROM vital_salt WHERE day <> ?", [day]);
103 db.run("INSERT OR IGNORE INTO vital_salt (day, salt) VALUES (?, ?)", [day, salt]);
104 db.run("DELETE FROM page_vital WHERE at < ?", [at - KEEP_MS]);
105 return (
106 db
107 .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?")
108 .get(day)?.salt ?? salt
109 );
110 }
111
112 /**
113 * Store one report; false when the day is full and it was dropped. `client`
114 * and `agent` go into the hash and nowhere else.
115 */
116 export function recordVital(
117 db: Database,
118 report: Report,
outdated

High: one unauthenticated row per report, about 1.7M/day at the forge cap, kept 90 days. Bound it here: aggregate into per-(day, route) histograms, or skip the insert once today's row count reaches a cap, still answering 204.

119 route: string,
120 client: string,
121 agent: string,
122 at: number = Date.now(),
123 dayMax: number = DAY_MAX,
124 ): boolean {
125 const held = db
126 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM page_vital WHERE day = ?")
127 .get(dayOf(at));
128 if ((held?.n ?? 0) >= dayMax) return false;
129 const visitor = sha256(`${saltFor(db, at)}\0${client}\0${agent}`).slice(0, 32);
130 db.run(
131 "INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
132 [dayOf(at), at, route, visitor, report.ttfb, report.load, report.lcp, report.inp, report.cls],
133 );
134 return true;
135 }
test/measure.test.ts added +138
@@ -0,0 +1,138 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { beaconJs } from "../src/web/beacon.ts";
8 import { beaconAsset } from "../src/web/asset.ts";
9 import { DAY_MAX, parseReport, recordVital } from "../src/web/measure.ts";
10
11 let dir: string;
12 let app: App;
13 let servers: Servers;
14 let base: string;
15
16 beforeAll(async () => {
17 dir = await mkdtemp(join(tmpdir(), "measure-"));
18 app = createApp({ dataDir: dir, port: 0 });
19 servers = await serve(app);
20 base = `http://127.0.0.1:${servers.port}`;
21 });
22 afterAll(async () => {
23 await servers.stop();
24 await rm(dir, { recursive: true, force: true });
25 });
26
27 const good = { path: "/", ttfb: 40, load: 300, lcp: 250, inp: 80, cls: 0.02 };
28 const send = (body: unknown, headers: Record<string, string> = {}) =>
29 fetch(`${base}/_measure`, {
30 method: "POST",
31 headers: { origin: base, ...headers },
32 body: typeof body === "string" ? body : JSON.stringify(body),
33 });
34 const rows = () => app.db.query("SELECT * FROM page_vital").all() as Record<string, unknown>[];
35 const count = () => rows().length;
36
37 describe("the page-view beacon", () => {
38 test("the script is under 2048 bytes, immutable, and linked from a page", async () => {
39 expect(new TextEncoder().encode(beaconJs).length).toBeLessThan(2048);
40 const asset = beaconAsset();
41 const script = await fetch(base + asset.path);
42 expect(script.status).toBe(200);
43 expect(script.headers.get("cache-control")).toContain("immutable");
44 expect(await script.text()).toBe(beaconJs);
45 const home = await fetch(base + "/");
46 expect(await home.text()).toContain(asset.path);
47 expect(home.headers.get("content-security-policy")).toContain("script-src 'nonce-");
48 expect(home.headers.get("content-security-policy")).not.toContain("unsafe");
49 });
50
51 test("the path is read once, at start, not when the page hides", () => {
52 expect(beaconJs.match(/location\.pathname/g)).toHaveLength(1);
53 expect(beaconJs).toContain("a=location.pathname");
54 });
55
56 test("a valid report is stored by route pattern, with no address or agent", async () => {
57 const before = count();
58 const res = await send({ ...good, path: "/someone/thing" }, { "user-agent": "Mozilla/5.0 Secret" });
59 expect(res.status).toBe(204);
60 expect(count()).toBe(before + 1);
61 const row = rows().at(-1)!;
62 expect(row["route"]).toBe("/:owner/:name");
63 expect(row["lcp"]).toBe(250);
64 expect(row["cls"]).toBe(0.02);
65 expect(String(row["visitor"])).toMatch(/^[0-9a-f]{32}$/);
66 const text = JSON.stringify(rows());
67 expect(text).not.toContain("Secret");
68 expect(text).not.toContain("127.0.0.1");
69 expect(text).not.toContain("someone");
70 });
71
72 test("a cross-origin, originless, oversized or malformed report is refused", async () => {
73 const before = count();
74 expect((await send(good, { origin: "https://evil.example" })).status).toBe(403);
75 const bare = await fetch(`${base}/_measure`, { method: "POST", body: JSON.stringify(good) });
76 expect(bare.status).toBe(403);
77 expect((await send("x".repeat(5000))).status).toBe(413);
78 expect((await send("{nope")).status).toBe(400);
79 expect((await send({ ...good, cls: "1" })).status).toBe(400);
80 expect((await send({ ...good, lcp: 1e12 })).status).toBe(400);
81 expect((await send({ ...good, lcp: -1 })).status).toBe(400);
82 expect((await send({ ...good, extra: 1 })).status).toBe(400);
83 expect((await send({ ...good, path: "https://evil.example/" })).status).toBe(400);
84 expect((await send({ ...good, path: "/a\nb" })).status).toBe(400);
85 expect((await send([1])).status).toBe(400);
86 expect((await send({ ...good, path: "/_nothing/at/all/here/x/y/z" })).status).toBe(400);
87 expect(count()).toBe(before);
88 });
89
90 test("Do-Not-Track and the opt-out cookie store nothing", async () => {
91 const before = count();
92 expect((await send(good, { dnt: "1" })).status).toBe(204);
93 expect((await send(good, { cookie: "a=b; forge_measure=off" })).status).toBe(204);
94 expect(count()).toBe(before);
95 });
96
97 test("a client past its rate is told to wait", async () => {
98 const statuses: number[] = [];
99 for (let i = 0; i < 40; i++) statuses.push((await send(good)).status);
100 expect(statuses).toContain(429);
101 });
102 });
103
104 describe("the report and the visitor hash", () => {
105 test("0 means not measured, and absent times are null", () => {
106 expect(parseReport('{"path":"/","cls":0,"lcp":0}')).toEqual({
107 path: "/", ttfb: null, load: null, lcp: null, inp: null, cls: 0,
108 });
109 });
110
111 test("the salt rotates daily: same browser, same day, same hash; next day, another", () => {
112 const report = parseReport(JSON.stringify(good));
113 if (report === "malformed") throw new Error("bad fixture");
114 const day = Date.UTC(2026, 0, 5, 10);
115 const visitor = () => (rows().at(-1) as { visitor: string }).visitor;
116 app.db.run("DELETE FROM page_vital");
117 recordVital(app.db, report, "/", "1.2.3.4", "ua", day);
118 const first = visitor();
119 recordVital(app.db, report, "/", "1.2.3.4", "ua", day + 1000);
120 expect(visitor()).toBe(first);
121 recordVital(app.db, report, "/", "1.2.3.4", "ua", day + 86_400_000);
122 expect(visitor()).not.toBe(first);
123 expect((app.db.query("SELECT COUNT(*) AS n FROM vital_salt").get() as { n: number }).n).toBe(1);
124 });
125
126 test("a full day drops reports instead of growing the table", () => {
127 const report = parseReport(JSON.stringify(good));
128 if (report === "malformed") throw new Error("bad fixture");
129 const at = Date.UTC(2026, 5, 1, 12);
130 app.db.run("DELETE FROM page_vital");
131 for (let i = 0; i < 5; i++) expect(recordVital(app.db, report, "/", "c", "u", at, 5)).toBe(true);
132 expect(recordVital(app.db, report, "/", "c", "u", at, 5)).toBe(false);
133 expect(count()).toBe(5);
134 // The next day has its own room.
135 expect(recordVital(app.db, report, "/", "c", "u", at + 86_400_000, 5)).toBe(true);
136 expect(DAY_MAX).toBeLessThanOrEqual(50_000);
137 });
138 });
test/web-browse.test.ts +1 −1
@@ -502,7 +502,7 @@ describe("browsing an origin repository on the web", () => {
502502 );
503503 expect(running).toContain("new EventSource(url)");
504504 expect(running).toMatch(/<script nonce="[^"]+">/);
505 expect(running).not.toMatch(/<script[^>]*src="(?!\/assets\/shell-)/);
505 expect(running).not.toMatch(/<script[^>]*src="(?!\/assets\/(?:shell|beacon)-)/);
506506
507507 const done = await settled(jobPath.split("/").pop()!);
508508 expect(done?.stage).toBe("done");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs