Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2057,9 +2057,10 @@ npm run deploy
changes what the Worker sees and is not a cosmetic edit.

27. **A `github-advanced-security` failure is usually GitHub's own Copilot
Autofix falling over, not your diff.** Seen twice on 2026-08-08, on two
unrelated PRs (#289, #295), both times while the separate `CodeQL` check
reported *"No new alerts in code changed by this pull request."*
Autofix falling over, not your diff.** Seen four times across two days: on
2026-08-08 on #289 and #295, and again on 2026-08-10 on #305 and #307. Every
one of them ran while the separate `CodeQL` check reported *"No new alerts in
code changed by this pull request."*

The signature is specific enough to recognise on sight: the check has an
EMPTY output title, and its single annotation points at `.github:<line>` —
Expand Down Expand Up @@ -2091,6 +2092,62 @@ npm run deploy
summary, and its lone annotation at `.github:<line>`. Check the annotation
path: a finding points at code you wrote, the artifact points at a directory.

**A prose-only PR is the strongest control there is, and 2026-08-10 handed us
one.** #305 changed documentation, four code COMMENTS and one quiz string;
#307 changed a build script. Both failed this check identically, with
`title: null` and one annotation at `.github:211` and `.github:213`
respectively, while CodeQL passed on both. A diff carrying no executable
change cannot carry a security finding, so a check that reddens on it is
reporting on itself. When the API tell above leaves you unsure which variant
you have, ask whether the PR contains code at all. And confirm the stakes from
the ruleset rather than from the check list, since `validate` is the only
required context: this has now been red on four PRs while gating none of them.

28. **Bun runs this build byte-identically and about twice as fast, and it is
still not adopted.** `npm run bun:check` is the control, in the same idiom as
`kitesurf:check`: it probes the zstd dictionary option, diffs a full node
build against a full bun build file by file, and runs the contract suite
under `bun test`. Measured 2026-08-10, node v26.7.0 against bun
`1.4.0-canary.1+827475e21`:

| question | result |
|---|---|
| zstd honours `dictionary` | yes, 73 none / 24 good / 73 wrong |
| build output byte-identical | yes, 1975 files, 0 differing |
| wall clock | 14.4s node against 7.1s bun |
| contract suite under `bun test` | 206 pass, 0 fail |

**BYTE-IDENTICAL is the bar, and it is much higher than "the build
succeeds".** `/a/` and `/i/` are content-addressed, so a single differing
byte mints a new URL, orphans every committed `a-dict` snapshot naming the
old hash, and moves the CSP hashes the documents are served under. A build
that is 2x faster and one byte different is not a faster build.

Three things keep it unadopted, and only the first is about bun. The newest
STABLE bun is **1.3.14** (2026-05-13), which predates the dictionary fix
(oven-sh/bun#34427, merged 2026-07-18) and silently ignores
`zstdCompressSync`'s `dictionary`; pinning the build path to a canary trades a
correctness bug for an unreleased revision. wrangler, miniflare and workerd
are the deploy path AND the route oracle, and they are node-pinned. And the
win is seconds on a step CI already spends far longer on in dry-runs.

**The failure here would be LOUD, which is worth knowing before this reads
scarier than it is.** build.mjs already feature-detects the same collapse and
throws (search `expected a collapse`), so bun 1.3.14 kills the build rather
than shipping no-op deltas. That guard exists because the no-op shipped for a
full deploy once. The ENGINE is silent; this build is not.

**Bun's spec-strict `Response` found a real defect in our suite**, which is
the byproduct worth keeping even if bun is never adopted. `withSecurityHeaders`
rebuilds every response as `new Response(response.body, …)`, which per Fetch
LOCKS the body it was handed, and one contract test pushed the same four case
objects through it twice. Bun threw `Body object should not be disturbed or
locked`; node's undici allows it. The assertions were about headers, so the
leniency was never load-bearing, and the test now builds its cases fresh per
pass. **A suite that passes on one runtime and not another is reporting a
fact about the runtime**, so run the other one occasionally even when you have
no intention of switching.

---

## Source folder for new photos
Expand Down
13 changes: 10 additions & 3 deletions contract-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3367,20 +3367,27 @@ test("preview noindex reaches the responses the security wrapper otherwise skips
// The wrapper bails early on redirects and images, which is correct for CSP
// and wrong for robots: both are independently indexable, so a preview that
// marked only its HTML would still publish a duplicate photo corpus.
const cases = [
// Built fresh per pass, deliberately. `withSecurityHeaders` rebuilds every
// response as `new Response(response.body, …)`, which per Fetch LOCKS the
// body it was handed, so reusing one case object across both passes feeds the
// second one a disturbed stream. Node's undici allows that and bun 1.4 throws
// `Body object should not be disturbed or locked`, which is the spec-correct
// read. The assertions here are about headers, so the leniency was never load
// bearing; it just made the suite depend on which runtime ran it.
const makeCases = () => [
["a redirect", new Response(null, { status: 301, headers: { location: "https://aadhar.sh/photos" } })],
["an image", new Response("jpegbytes", { headers: { "content-type": "image/jpeg" } })],
["a document", new Response("<!doctype html><title>x</title>", { headers: { "content-type": "text/html; charset=utf-8" } })],
["a json feed", new Response("{}", { headers: { "content-type": "application/json" } })],
];
for (const [what, response] of cases) {
for (const [what, response] of makeCases()) {
const marked = withSecurityHeaders(response, "/photos", { noindex: true });
assert.equal(marked.headers.get("x-robots-tag"), "noindex, nofollow", `${what} must carry noindex on a preview`);
}

// ...and production is untouched. This is the regression that would matter
// most: a bug here deindexes the real site.
for (const [what, response] of cases) {
for (const [what, response] of makeCases()) {
const plain = withSecurityHeaders(response, "/photos");
assert.equal(plain.headers.get("x-robots-tag"), null, `${what} must NOT be noindexed off a preview`);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"pages:roll": "node scripts/roll-shell-dictionary.mjs --pages",
"dcz:check": "node scripts/check-dictionary-support.mjs",
"kitesurf:check": "node scripts/check-kitesurf.mjs",
"bun:check": "node scripts/check-bun.mjs",
"checkpoints:check": "node scripts/check-checkpoints.mjs",
"checkpoints:sync": "node scripts/check-checkpoints.mjs --sync",
"inp": "node scripts/inp-lab.mjs",
Expand Down
230 changes: 230 additions & 0 deletions scripts/check-bun.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env node
// npm run bun:check [-- --bun /path/to/bun]
//
// The control for "could this repo's build run on bun instead of node?".
//
// It is a SCRIPT and not a CI step for the same reason `kitesurf:check` is: the
// answer only changes when someone ships a new bun, and the run costs two full
// builds (~25s). Run it when a bun release lands, record the verdict, move on.
//
// Three questions, in the order that can disqualify bun soonest:
//
// 1. Does `node:zlib`'s zstd honour `dictionary`? FIRST because it disqualifies
// a runtime outright: build.mjs mints every dcz delta through
// `zstdCompressSync({ dictionary })`, and an engine that accepts the option
// and ignores it produces plain zstd that still decodes correctly against
// the dictionary, so the API itself reports nothing. workerd does exactly
// this (measured 2026-08-05, see /terminal) and bun did too through 1.3.14;
// oven-sh/bun#34427 fixed it for 1.4.
//
// What this check is NOT is the tripwire. build.mjs already feature-detects
// the same thing and THROWS (search `expected a collapse`), which is how bun
// 1.3.14 announces itself: the build dies rather than shipping no-op deltas.
// This runs the probe anyway, because a build that dies 40 seconds in with a
// message about `.node-version` is a poor way to learn that your bun is too
// old, and because the probe is the thing worth quoting in a note.
//
// 2. Is the build output BYTE-IDENTICAL to node's? This is the real bar, and
// it is higher than "the build succeeds". `/a/` and `/i/` assets are
// content-addressed, so one byte of difference anywhere mints a different
// URL, invalidates every committed shell dictionary that names the old
// hash, and changes the CSP hashes the documents are served under. A build
// that is 2x faster and 1 byte different is not a faster build.
//
// 3. Does the contract suite pass? Bun's test runner needs `.test` in the
// filename, so this stands up a symlink and takes it down again.
//
// The verdict this printed on 2026-08-10, node v26.7.0 vs bun 1.4.0-canary.1:
// all three green, 1975 files identical, 17.0s -> 8.3s. The blocker is release
// timing rather than behaviour, because 1.3.14 is the newest STABLE bun and it
// fails question 1.

import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = fileURLToPath(new URL("..", import.meta.url));
const BUILD = join(ROOT, ".build");
const SHADOW = join(ROOT, ".build.node-baseline");
const TEST_LINK = join(ROOT, "contract-tests.test.mjs");

const argv = process.argv.slice(2);
const flag = (name) => {
const i = argv.indexOf(name);
return i === -1 ? null : argv[i + 1];
};

function resolveBun() {
const explicit = flag("--bun") || process.env.BUN;
if (explicit) return explicit;
const found = spawnSync("command", ["-v", "bun"], { shell: true, encoding: "utf8" });
const path = found.stdout?.trim();
if (!path) {
console.error("no bun found. install one, or point at a build:\n npm run bun:check -- --bun /path/to/bun");
process.exit(2);
}
return path;
}

const bun = resolveBun();
const run = (cmd, args, opts = {}) =>
spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8", ...opts });

const version = run(bun, ["--revision"]).stdout?.trim() || run(bun, ["--version"]).stdout?.trim();
console.log(`bun: ${bun}\n ${version}`);
console.log(`node: ${process.version}\n`);

const results = [];
const record = (name, ok, detail) => {
results.push({ name, ok });
console.log(`${ok ? " ok " : " FAIL "} ${name}${detail ? ` — ${detail}` : ""}`);
};

// ---------------------------------------------------------------------------
// 1. the silent one: does zstd honour `dictionary`?
// ---------------------------------------------------------------------------
// Three compressions of one target: no dictionary, the right dictionary, a
// wrong one. An engine that honours the option prints a SMALLER number for the
// right dictionary alone. An engine that ignores it prints the same number
// three times, which is why the byte count is the only available signal.
const PROBE = `
import { zstdCompressSync } from "node:zlib";
const target = Buffer.from(("export const NAV_SHELL = {taskbar:1,start:1,clock:1};").repeat(400));
const n = (o) => zstdCompressSync(target, o).length;
console.log(JSON.stringify({
none: n({}),
good: n({ dictionary: target.subarray(0, 4096) }),
wrong: n({ dictionary: Buffer.alloc(4096, 0x78) }),
}));
`;
{
const out = run(bun, ["-e", PROBE]);
let parsed = null;
try { parsed = JSON.parse(out.stdout.trim()); } catch { /* left null on purpose */ }
if (!parsed) {
record("zstd honours `dictionary`", false, `probe did not run: ${(out.stderr || "").trim().split("\n")[0] || "no output"}`);
} else {
const honoured = parsed.good < parsed.none && parsed.wrong >= parsed.none;
record(
"zstd honours `dictionary`",
honoured,
`${parsed.none} none / ${parsed.good} good / ${parsed.wrong} wrong` +
(honoured ? "" : " <-- SILENT: every dcz delta would be plain zstd"),
);
}
}

// A failure here disqualifies the runtime, so stop rather than spend two builds
// proving it again. build.mjs's own tripwire would kill the node-vs-bun run
// partway through anyway, and a half-run comparison reads as a tooling fault.
if (results.some((r) => !r.ok)) {
console.log("\nbun:check: NOT viable on this build — every dcz delta would be plain zstd.");
console.log(" build.mjs feature-detects the same thing and throws, so the build would fail rather than ship no-ops.");
process.exit(1);
}

// ---------------------------------------------------------------------------
// 2. the real bar: byte-identical build output
// ---------------------------------------------------------------------------
function hashTree(dir) {
const files = new Map();
const walk = (abs) => {
for (const entry of readdirSync(abs, { withFileTypes: true })) {
const next = join(abs, entry.name);
if (entry.isDirectory()) walk(next);
else if (entry.isFile()) files.set(relative(dir, next), createHash("sha256").update(readFileSync(next)).digest("hex"));
}
};
walk(dir);
return files;
}

// THROWS rather than exits, on purpose: `process.exit()` skips `finally`, and the
// finally below is what puts `.build/` back. Caught that the first time this ran
// against a bun old enough to fail the build, which left the tree holding a
// half-written `.build/` beside an orphan baseline.
const timedBuild = (label, cmd, args) => {
const started = process.hrtime.bigint();
const out = run(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
const ms = Number(process.hrtime.bigint() - started) / 1e6;
if (out.status !== 0) {
const tail = (out.stderr || out.stdout || "").trim().split("\n").slice(-6).join("\n");
throw new Error(`${label} build failed (exit ${out.status}):\n${tail}`);
}
return ms;
};

if (existsSync(SHADOW)) rmSync(SHADOW, { recursive: true, force: true });
let restored = false;
try {
rmSync(BUILD, { recursive: true, force: true });
const nodeMs = timedBuild("node", process.execPath, ["build.mjs"]);
renameSync(BUILD, SHADOW);

const bunMs = timedBuild("bun", bun, ["build.mjs"]);

const a = hashTree(SHADOW);
const b = hashTree(BUILD);
const onlyNode = [...a.keys()].filter((k) => !b.has(k));
const onlyBun = [...b.keys()].filter((k) => !a.has(k));
const differing = [...a.keys()].filter((k) => b.has(k) && a.get(k) !== b.get(k));
const identical = onlyNode.length === 0 && onlyBun.length === 0 && differing.length === 0;

record(
"build output is byte-identical",
identical,
identical
? `${a.size} files, node ${(nodeMs / 1000).toFixed(1)}s vs bun ${(bunMs / 1000).toFixed(1)}s`
: `${differing.length} differing, ${onlyNode.length} node-only, ${onlyBun.length} bun-only`,
);
for (const f of [...differing, ...onlyNode, ...onlyBun].slice(0, 20)) console.log(` ${f}`);

// Leave `.build/` holding NODE's output. A half-checked tree staged by an
// unreleased runtime is not something a later `wrangler deploy` should find.
rmSync(BUILD, { recursive: true, force: true });
renameSync(SHADOW, BUILD);
restored = true;
} finally {
if (!restored && existsSync(SHADOW)) {
rmSync(BUILD, { recursive: true, force: true });
renameSync(SHADOW, BUILD);
}
}

// ---------------------------------------------------------------------------
// 3. the contract suite
// ---------------------------------------------------------------------------
// `bun test` filters on `.test`/`_test_`/`.spec` in the filename and the suite
// is `contract-tests.mjs`, so it needs a symlink. It lives beside the real file
// because the tests resolve fixtures off `import.meta.url`.
try {
if (existsSync(TEST_LINK)) unlinkSync(TEST_LINK);
symlinkSync("contract-tests.mjs", TEST_LINK);
const out = run(bun, ["test", "contract-tests.test.mjs"]);
const text = `${out.stdout}\n${out.stderr}`;
const pass = Number(text.match(/(\d+) pass/)?.[1] ?? 0);
const fail = Number(text.match(/(\d+) fail/)?.[1] ?? -1);
record("contract suite passes under bun", fail === 0 && pass > 0, `${pass} pass, ${fail} fail`);
if (fail > 0) {
for (const line of text.split("\n").filter((l) => l.includes("(fail)"))) console.log(` ${line.trim()}`);
}
} finally {
if (existsSync(TEST_LINK) && statSync(TEST_LINK, { throwIfNoEntry: false })) unlinkSync(TEST_LINK);
}

// ---------------------------------------------------------------------------
const failed = results.filter((r) => !r.ok);
console.log("");
if (failed.length) {
console.log(`bun:check: NOT viable — ${failed.map((r) => r.name).join("; ")}`);
process.exit(1);
}
console.log("bun:check: all green on this build. Note that VIABLE is not ADOPTED:");
console.log(" wrangler + miniflare + workerd is the deploy and route-oracle path and is node-pinned,");
console.log(" and a canary is not something the build path may depend on. See gotcha 28.");
try {
const stable = execFileSync("npm", ["view", "bun", "version"], { encoding: "utf8" }).trim();
console.log(` newest STABLE bun on npm: ${stable}`);
} catch { /* offline is fine; the verdict above does not depend on it */ }
Loading