// weigh-in + cookies: everything a widget does before anyone touches the page. // bun run --cwd tools/widget-lab weigh-in (same run writes both files: weigh-in-2026-09.json, cookies-2026-09.json) // Each run: fresh Chrome profile (empty cache, no cookies), 1280x800, no throttling (bytes don't depend on it), load the // page, touch nothing for WINDOW ms, then read every request, websocket frame, cookie and storage key. // On our test page every request but the page itself is the widget's. On a vendor's homepage (widgets that refuse our // domain) only requests to the widget's own hosts count, and a widget's cookies/storage are the names that show up with // the widget and never in the same page loaded with the widget's urls blocked. import { chrome, env, median, RUNS, save } from "./lab"; import { serve, url } from "./serve"; import { WIDGETS, type Widget } from "./widgets"; const WINDOW = Number(process.env.WINDOW ?? 25_000); type Req = { host: string; path: string; type: string; status: number; transferred: number; body: number; t: number; failed?: string }; type Run = { at: string; requests: Req[]; ws: { host: string; t: number; framesIn: number; bytesIn: number; framesOut: number; bytesOut: number }[]; cookies: { name: string; domain: string; days: number | null; httpOnly: boolean; secure: boolean; sameSite: string; partitioned: boolean }[]; storage: { origin: string; kind: "localStorage" | "sessionStorage" | "indexedDB"; key: string; bytes: number }[]; launcherVisible: boolean; console: string[]; }; const site = (host: string) => host.split(".").slice(-2).join("."); // every host here is on a plain two-label site /** keep: which requests count, by "host/path" */ async function once(target: string, keep: (hostPath: string, frameUrl: string) => boolean, w?: Widget, block?: RegExp): Promise { const { ctx, page, close } = await chrome(); const requests: Req[] = []; const ws: Run["ws"] = []; const logs: string[] = []; const t0 = Date.now(); const hp = (u: string) => { const x = new URL(u); // the path as recorded: ids minted for this visitor (Intercom's conversation ids) are cut to their length const path = x.pathname.replace(/(?<=\/)\d{9,}(?=\/|$)/g, (id) => ``); return { host: x.hostname, path, both: x.hostname + x.pathname }; }; const frameOf = (r: import("playwright-core").Request) => { try { return r.frame().url(); } catch { return ""; // a service worker's request has no frame } }; if (block) await ctx.route((u) => block.test(u.hostname + u.pathname), (r) => r.abort()); const pending: Promise[] = []; ctx.on("requestfinished", (r) => { const { host, path, both } = hp(r.url()); if (!keep(both, frameOf(r))) return; const t = Date.now() - t0; pending.push((async () => { const [sizes, res] = await Promise.all([r.sizes(), r.response()]); const body = await res?.body().then((b) => b.length, () => 0); requests.push({ host, path, type: r.resourceType(), status: res?.status() ?? 0, transferred: sizes.responseHeadersSize + sizes.responseBodySize, body: body ?? 0, t }); })().catch(() => {})); }); ctx.on("requestfailed", (r) => { const { host, path, both } = hp(r.url()); if (keep(both, frameOf(r))) requests.push({ host, path, type: r.resourceType(), status: 0, transferred: 0, body: 0, t: Date.now() - t0, failed: r.failure()?.errorText }); }); page.on("websocket", (s) => { const { host, both } = hp(s.url()); if (!keep(both, "")) return; const e = { host, t: Date.now() - t0, framesIn: 0, bytesIn: 0, framesOut: 0, bytesOut: 0 }; ws.push(e); s.on("framereceived", (f) => void (e.framesIn++, (e.bytesIn += Buffer.byteLength(f.payload)))); s.on("framesent", (f) => void (e.framesOut++, (e.bytesOut += Buffer.byteLength(f.payload)))); }); page.on("console", (m) => void (/allowed|not allowed|invalid|frame-ancestors|blocked/i.test(m.text()) && logs.push(m.text().slice(0, 200)))); await page.goto(target, { waitUntil: "load", timeout: 60_000 }).catch((e) => logs.push(`goto: ${String(e).slice(0, 120)}`)); await page.waitForTimeout(WINDOW - (Date.now() - t0)); const launcherVisible = !!w && (await Promise.any(page.frames().map((f) => f.locator(w.launcher).first().isVisible().then((v) => v || Promise.reject()))).catch(() => false)); await Promise.allSettled(pending); const now = Date.now() / 1000; const cookies = (await ctx.cookies()).map((c) => ({ name: c.name, domain: c.domain, days: c.expires > 0 ? Math.round((c.expires - now) / 86400) : null, // null: a session cookie httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, partitioned: !!(c as { partitionKey?: unknown }).partitionKey, })); const storage: Run["storage"] = []; for (const f of page.frames()) { const got = await f .evaluate(async () => { const out: { kind: "localStorage" | "sessionStorage" | "indexedDB"; key: string; bytes: number }[] = []; for (const kind of ["localStorage", "sessionStorage"] as const) { try { const s = window[kind]; for (let i = 0; i < s.length; i++) out.push({ kind, key: s.key(i)!, bytes: s.key(i)!.length + (s.getItem(s.key(i)!) ?? "").length }); } catch {} } try { for (const db of await indexedDB.databases()) out.push({ kind: "indexedDB", key: db.name ?? "", bytes: 0 }); } catch {} return { origin: location.origin, out }; }) .catch(() => null); if (got?.origin === "null") continue; // about:blank and srcdoc frames share their parent's storage for (const s of got?.out ?? []) if (!storage.some((x) => x.origin === got!.origin && x.kind === s.kind && x.key === s.key)) storage.push({ origin: got!.origin, ...s }); } await close(); return { at: new Date(t0).toISOString(), requests, ws, cookies, storage, launcherVisible, console: [...new Set(logs)] }; } function summary(runs: Run[]) { const sum = (r: Run, f: (q: Req) => number, pick = (_: Req) => true) => r.requests.filter(pick).reduce((a, q) => a + f(q), 0); const types = [...new Set(runs.flatMap((r) => r.requests.map((q) => q.type)))]; const hosts = [...new Set(runs.flatMap((r) => [...r.requests.map((q) => q.host), ...r.ws.map((s) => s.host)]))].sort(); return { requests: median(runs.map((r) => r.requests.length)), websockets: median(runs.map((r) => r.ws.length)), transferredKB: median(runs.map((r) => sum(r, (q) => q.transferred) / 1024)), uncompressedKB: median(runs.map((r) => sum(r, (q) => q.body) / 1024)), wsKB: median(runs.map((r) => r.ws.reduce((a, s) => a + s.bytesIn + s.bytesOut, 0) / 1024)), byType: Object.fromEntries(types.map((t) => [t, { requests: median(runs.map((r) => r.requests.filter((q) => q.type === t).length)), transferredKB: median(runs.map((r) => sum(r, (q) => q.transferred, (q) => q.type === t) / 1024)), uncompressedKB: median(runs.map((r) => sum(r, (q) => q.body, (q) => q.type === t) / 1024)), }])), byHost: Object.fromEntries(hosts.map((h) => [h, { requests: median(runs.map((r) => r.requests.filter((q) => q.host === h).length)), transferredKB: median(runs.map((r) => sum(r, (q) => q.transferred, (q) => q.host === h) / 1024)), }])), sites: [...new Set(hosts.map(site))], launcherVisibleRuns: runs.filter((r) => r.launcherVisible).length, }; } /** names (cookie name@domain, key@origin) seen in at least half the runs with the widget and in none without it */ function widgetOnly(withW: T[][], without: T[][], key: (x: T) => string) { const count = new Map(); for (const r of withW) for (const k of new Set(r.map(key))) count.set(k, (count.get(k) ?? 0) + 1); const off = new Set(without.flatMap((r) => r.map(key))); return [...count].filter(([k, n]) => n >= withW.length / 2 && !off.has(k)).map(([k]) => k).sort(); } const server = serve(); const results: Record = {}; const started = new Date().toISOString(); const only = process.env.ONLY?.split(","); // the page alone, then each widget on it for (const w of [undefined, ...WIDGETS]) { const id = w?.id ?? "blank"; if (only && !only.includes(id)) continue; const runs: Run[] = []; for (let i = 0; i < RUNS; i++) { runs.push(await once(url(w?.id), (h) => !h.startsWith("localhost/"), w)); console.log(id, "test page", i + 1, runs.at(-1)!.requests.length, "requests"); } results[`${id}@test-page`] = { widget: w?.name ?? null, where: "test page", url: url(w?.id), summary: summary(runs), runs }; } // widgets that refuse our domain: on the vendor's own homepage, with and without the widget's hosts blocked for (const w of WIDGETS.filter((w) => !w.boots && w.home)) { if (only && !only.includes(w.id)) continue; const on: Run[] = []; const off: Run[] = []; let attempts = 0; for (let i = 0; on.length < RUNS && i < RUNS * 3; i++) { attempts++; const r = await once(w.home!, (h, f) => w.widgetUrls.test(h) || !!w.widgetFrame?.test(f), w); // a run counts once the widget booted: its launcher showed if (r.launcherVisible) on.push(r); console.log(w.id, "home", i + 1, r.launcherVisible ? "booted" : "did not boot", r.requests.length, "requests"); if (i < RUNS) off.push(await once(w.home!, () => true, undefined, w.widgetUrls)); await Bun.sleep(Number(process.env.HOME_PAUSE ?? 0)); // be gentle: some vendors stop booting their widget for a visitor who comes back every 30 s } const cookieKey = (c: Run["cookies"][number]) => `${c.name}@${c.domain}`; const storageKey = (s: Run["storage"][number]) => `${s.kind}:${s.key}@${s.origin}`; results[`${w.id}@home`] = { widget: w.name, where: "vendor homepage", url: w.home, attempts, summary: summary(on), widgetCookies: widgetOnly(on.map((r) => r.cookies), off.map((r) => r.cookies), cookieKey), widgetStorage: widgetOnly(on.map((r) => r.storage), off.map((r) => r.storage), storageKey), runs: on, runsWithWidgetBlocked: off.map((r) => ({ at: r.at, cookies: r.cookies.map(cookieKey), storage: r.storage.map(storageKey) })), }; } server.stop(true); const meta = { test: "before any interaction", started, finished: new Date().toISOString(), runs: RUNS, windowMs: WINDOW, env: await env(), spookatBuild: (await (await fetch("https://cdn.spookat.com/s/site_live_0000000000000000.js")).text()).match(/panel-\w+\.js/)?.[0], method: [ "Fresh Chrome profile per run (new temp user-data-dir): empty cache, no cookies, no storage, default settings. Headed Chrome, 1280x800, no throttling.", `The page loads, nothing is clicked, scrolled or typed; after ${WINDOW / 1000} s we read every finished request (Playwright: transferred = response headers + encoded body, uncompressed = decoded body), every websocket (frames and payload bytes), every cookie in the profile and localStorage, sessionStorage and IndexedDB in every frame.`, "Test page: http://localhost:5190/ from tools/widget-lab/serve.ts, the vendor's documented snippet at the end of , a widget id the vendor runs on its own homepage. Every request but the page itself counts as the widget's.", "Crisp, Intercom, LiveChat and Chatwoot refuse to boot on a domain their owner didn't allow; their test-page numbers are what loads before the refusal. For those we also load the vendor's own homepage and count only requests to the widget's hosts; a homepage run counts once the launcher showed. Cookies and storage there are the names seen in at least half the runs with the widget and in none of the runs with the widget's urls blocked.", "HOME_PAUSE (ms) waits between homepage visits: some vendors stop booting their widget for a visitor who comes back every half minute.", "Values are medians over the runs. Cookie and storage values are never read into these files: cookies keep name, domain, lifetime and flags, storage keeps key and size. Ids a vendor minted for our visit (Intercom's conversation ids in request paths) are replaced with ; widget ids and snippets the vendors publish on their own sites stay.", ], }; const pick = (keep: (k: string, v: unknown) => boolean) => Object.fromEntries(Object.entries(results).map(([k, v]) => [k, Object.fromEntries(Object.entries(v as object).filter(([kk, vv]) => keep(kk, vv)))])); save("weigh-in", { ...meta, results: pick((k) => k !== "widgetCookies" && k !== "widgetStorage" && k !== "runsWithWidgetBlocked") }); save("cookies", { ...meta, results: Object.fromEntries(Object.entries(results).map(([k, v]) => { const r = v as { widget: string; where: string; url: string; runs: Run[]; summary: ReturnType; widgetCookies?: string[]; widgetStorage?: string[]; runsWithWidgetBlocked?: unknown }; return [k, { widget: r.widget, where: r.where, url: r.url, thirdPartySites: r.summary.sites, widgetCookies: r.widgetCookies, widgetStorage: r.widgetStorage, runs: r.runs.map((x) => ({ at: x.at, cookies: x.cookies, storage: x.storage, console: x.console })), runsWithWidgetBlocked: r.runsWithWidgetBlocked, }]; })), });