From db76734c7d9e6100f0ce89b6bed1574053cf5111 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:21:02 -0500 Subject: [PATCH] copy(garage): cut the staging tics declauding 0.13.0 flags A pass over 19 garage pages for the tics in oaustegard's declauding register: grading adverbs (genuinely, honestly, plainly, structurally), announced content ("worth saying plainly", "one thing to notice"), significance designations ("the part that", "the real question"), and "earns its keep" and "the whole idea" reused across pages. Six pages are generator output, so their edits land in pipelines/garage/specs and the HTML is regenerated. --- pipelines/garage/specs/hidden-flags.json | 4 +- pipelines/garage/specs/octane.json | 2 +- pipelines/garage/specs/pqc.json | 2 +- pipelines/garage/specs/resample.json | 2 +- pipelines/garage/specs/typed-config.json | 2 +- pipelines/garage/specs/useragent.json | 2 +- src/pages/garage/blueprint.html | 2 +- src/pages/garage/chunks.html | 4 +- src/pages/garage/cloudflare.html | 6 +-- src/pages/garage/compression.html | 2 +- src/pages/garage/encoding.html | 6 +-- src/pages/garage/hidden-flags.html | 10 ++-- src/pages/garage/horizon.html | 59 ++++++++++++------------ src/pages/garage/index.html | 2 +- src/pages/garage/octane.html | 8 ++-- src/pages/garage/pqc.html | 4 +- src/pages/garage/pretext.html | 6 +-- src/pages/garage/resample.html | 4 +- src/pages/garage/scroll.html | 2 +- src/pages/garage/tooltips.html | 2 +- src/pages/garage/typed-config.html | 6 +-- src/pages/garage/useragent.html | 4 +- src/pages/garage/vt-check.html | 2 +- src/pages/garage/wire.html | 12 ++--- src/pages/garage/workers.html | 2 +- 25 files changed, 78 insertions(+), 79 deletions(-) diff --git a/pipelines/garage/specs/hidden-flags.json b/pipelines/garage/specs/hidden-flags.json index ec0d2f0c3..26dbdff94 100644 --- a/pipelines/garage/specs/hidden-flags.json +++ b/pipelines/garage/specs/hidden-flags.json @@ -4,7 +4,7 @@ "description": "Nineteen hidden flags in one CLI, and the control that tells an option being accepted apart from an option doing anything.", "status": "shipped", "added": "2026-08-24", - "bodyHtml": "

Flags the docs don't list

\n

Wrangler 4.125.0 carries nineteen flags marked hidden, so they appear in no help output and no documentation page. Reading them is easy. The hard part is that an option a tool silently ignores and an option it honours produce the same exit code, the same output, and the same success. This page is about the control that separates those two, why it usually costs nothing, and four times the difference decided something real on this site.

\n\n

The inventory

\n

Every flag below was pulled out of the shipped bundle by matching option definitions that carry hidden: true, then read back with its own description. Filter by what it is for.

\n
\n
\n \n \n
\n
\n

Marked rows are the ones that touch this site. The rest are here so the list is the whole list: a survey that reports only the interesting rows cannot tell you how many rows it skipped.

\n
\n\n\n

Two questions, and only one of them is free

\n

When you find an undocumented option, there are two separate things to establish, and reading the docs answers neither.

\n

Is it accepted? Does the tool recognise the key at all, or is it dropping an unknown name on the floor? Does it work? Does accepting the key change the behaviour the flag claims to change.

\n

The first question is usually free, and that is the part people skip. A tool with strict argument parsing answers it through its exit code alone, with no work performed and nothing deployed:

\n
$ wrangler deploy --x-bogus-flag ; echo $?\n1\n$ wrangler deploy --x-provision=false --dry-run ; echo $?\n0
\n

Two commands, no account touched, and the answer is decisive: wrangler validates flag names, so a flag it accepts is a flag it knows. Run that before trusting any option a help page omits.

\n

The second question costs a real run, and its answer is worth much less than it looks like when the failure mode is silence.

\n\n

When silence is the failure mode

\n

Four cases from this site, each of which cost time in proportion to how late the control was run.

\n\n

A payload schema that refuses unknown keys

\n

Cloudflare's Browser Run binding takes a JSON payload. A launch post described selecting a rendering engine with one extra key; the Quick Actions reference did not list it. Sending it answered:

\n
{\"code\":\"unrecognized_keys\",\"keys\":[\"browser\"]}
\n

An invented engine name returned the byte-identical error, which is the useful half of the result. The schema is closed, so it refuses the option rather than failing on the value, and a closed schema is a gift: it answers \"is this accepted\" for free, every time, with no render performed and no browser minutes spent.

\n

The same feature then produced the opposite trap through the REST door, where the parameter is merely ignored. A 200 came back either way, so the code labelled the render with the engine it had asked for. On a page whose entire premise is showing what a machine actually saw, that label was a claim nobody had checked. It reads kitesurf-requested now.

\n\n

A compression option three runtimes disagree about

\n

Dictionary compression takes a dictionary option. Node 26 honours it. Workers ignore it for zstd and say nothing. Bun ignored it through 1.3.14 and fixed it in oven-sh/bun#34427. Every one of those failures is silent, because a frame compressed without a dictionary still decodes perfectly with one. Nothing throws. The only symptom is a byte count that never got smaller.

\n

The control is four lines and it needs no deploy: compress one target three times, with no dictionary, with the right one, and with a deliberately 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.

\n
\n \n \n \n \n
engineno dictionaryright dictionaryverdict
node 2670 B18 Bhonours it
workerdsame number three timesignores it
bun through 1.3.14same number three timesignored it
\n

Sending the option and reading the exit code would have passed on all three.

\n\n

A key that is accepted and still unmeasured

\n

The Browser Run payload takes a waitForTimeout. Whether the binding accepts the key was answered for free, using the closed schema above: send it alongside a deliberately invalid URL, and a bogus key comes back as unrecognized_keys while an accepted one leaves only the URL error. Three keys passed that test individually and together.

\n

Whether the capture actually waits out the timeout is a different question, it needs a real render, and it is still open. Both halves are written down separately for that reason. An entry claiming the key works, on evidence that only shows it is accepted, is the exact error this page is about.

\n\n

A path that routes either way

\n

The engine selector above only functions on one of two URL spellings. Both route. Probed unauthenticated against a real account, each answers 10000 Authentication error rather than 7003 Could not route to. So the wrong path costs the feature, silently: no error, no retry, no log line. One reference page documents the selector on the newer spelling while older pages show the other, so reading either one alone hands you a path that looks right.

\n\n

A flag that proves the limit is somewhere else

\n

Among the nineteen is --max-versions, on the command that shifts production traffic between Worker versions. This site's release script walks a version to 10%, then 50%, then 100%, and it can only ever name two versions in a step, which shapes the whole procedure: a ramp starting from a three-way split has to drop one of them. The obvious hope is that a hidden flag raises the ceiling. The definition answers it without a single request:

\n
\"max-versions\": {\n  hidden: true,\n  // experimental, not supported long-term\n  describe: \"Maximum allowed versions to select\",\n  type: \"number\",\n  default: 2\n  // (when server-side limitation is lifted, we can update this\n  //  default or just remove the option entirely)\n}
\n

The comment is the finding. The cap lives on the server and this flag is the client's mirror of it, so raising the number locally buys an error instead of a wider split. That is a real answer, arrived at by reading rather than by deploying, and it retires a question that had been open on the strength of a guess.

\n\n

A flag that looks like the fix and is not one

\n

This site has a documented failure where a gradual deployment splits asset requests: a document served by one version asks for a content-hashed asset only the other version built, and gets a 404. So --old-asset-ttl, described as \"expire old assets in given seconds rather than immediate deletion\", reads like the exact repair.

\n

It is threaded into one place in the entire codebase, and that place is the sync routine for the legacy Workers Sites layer, the retired KV-namespace mechanism that predates the modern assets binding this site uses. The flag would apply to a system that is not running here.

\n

Fifteen minutes of reading, and the answer arrives before any config changes. Had it been adopted on the strength of its description, it would have shipped, changed nothing, and become the fix everyone believed was already in place.

\n\n

Pick a situation, get the control

\n

The shape of the doubt decides the experiment. Choose what you are looking at:

\n
\n
\n
\n
\n\n\n

The rule this leaves

\n

On a closed schema, \"is this accepted\" and \"does this work\" are two questions, and the first one is free. Ask it first, always, because it costs an exit code and it eliminates the most common way an undocumented option wastes a week: being quietly dropped while everything around it reports success.

\n

Then be careful about what a success proves. A 200 from an endpoint that ignores unknown parameters is indistinguishable from a 200 from an endpoint that honoured yours. When you cannot tell those apart, the honest move is to label the result with what you asked for rather than with what you hope happened, and to write the unmeasured half down beside the measured one.

\n

And prefer reading the definition to running the experiment when the definition is in your node_modules. Two of the four cases here were settled by a source comment and a call site, in less time than a deploy takes, and neither answer could have been reached by watching the tool succeed.

\n\n

What would change this

\n

A tool that accepts unknown flags silently would break the free half outright, and the check for that is the bogus-flag control itself: an exit code of 0 on an invented name means every conclusion on this page has to be re-earned with real runs. A hidden flag becoming documented would move its row from this list to an ordinary reference, which is the outcome to hope for. And a measurement showing the capture does honour waitForTimeout would close the one case here that is deliberately still open.

", + "bodyHtml": "

Flags the docs don't list

\n

Wrangler 4.125.0 carries nineteen flags marked hidden, so they appear in no help output and no documentation page. Reading them is easy. The hard part is that an option a tool silently ignores and an option it honours produce the same exit code, the same output, and the same success. This page is about the control that separates those two, why it usually costs nothing, and four times the difference decided something real on this site.

\n\n

The inventory

\n

Every flag below was pulled out of the shipped bundle by matching option definitions that carry hidden: true, then read back with its own description. Filter by what it is for.

\n
\n
\n \n \n
\n
\n

Marked rows are the ones that touch this site. The rest are here so the list is the whole list: a survey that reports only the interesting rows cannot tell you how many rows it skipped.

\n
\n\n\n

Two questions, and only one of them is free

\n

When you find an undocumented option, there are two separate things to establish, and reading the docs answers neither.

\n

Is it accepted? Does the tool recognise the key at all, or is it dropping an unknown name on the floor? Does it work? Does accepting the key change the behaviour the flag claims to change.

\n

The first question is usually free, and people skip it. A tool with strict argument parsing answers it through its exit code alone, with no work performed and nothing deployed:

\n
$ wrangler deploy --x-bogus-flag ; echo $?\n1\n$ wrangler deploy --x-provision=false --dry-run ; echo $?\n0
\n

Two commands, no account touched, and the answer is decisive: wrangler validates flag names, so a flag it accepts is a flag it knows. Run that before trusting any option a help page omits.

\n

The second question costs a real run, and its answer is worth much less than it looks like when the failure mode is silence.

\n\n

When silence is the failure mode

\n

Four cases from this site, each of which cost time in proportion to how late the control was run.

\n\n

A payload schema that refuses unknown keys

\n

Cloudflare's Browser Run binding takes a JSON payload. A launch post described selecting a rendering engine with one extra key; the Quick Actions reference did not list it. Sending it answered:

\n
{\"code\":\"unrecognized_keys\",\"keys\":[\"browser\"]}
\n

An invented engine name returned the byte-identical error. The schema is closed, so it refuses the option rather than failing on the value, and a closed schema is a gift: it answers \"is this accepted\" for free, every time, with no render performed and no browser minutes spent.

\n

The same feature then produced the opposite trap through the REST door, where the parameter is merely ignored. A 200 came back either way, so the code labelled the render with the engine it had asked for. On a page whose premise is showing what a machine actually saw, that label was a claim the code never checked. It reads kitesurf-requested now.

\n\n

A compression option three runtimes disagree about

\n

Dictionary compression takes a dictionary option. Node 26 honours it. Workers ignore it for zstd and say nothing. Bun ignored it through 1.3.14 and fixed it in oven-sh/bun#34427. Every one of those failures is silent, because a frame compressed without a dictionary still decodes perfectly with one. Nothing throws. The only symptom is a byte count that never got smaller.

\n

The control is four lines and it needs no deploy: compress one target three times, with no dictionary, with the right one, and with a deliberately 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.

\n
\n \n \n \n \n
engineno dictionaryright dictionaryverdict
node 2670 B18 Bhonours it
workerdsame number three timesignores it
bun through 1.3.14same number three timesignored it
\n

Sending the option and reading the exit code would have passed on all three.

\n\n

A key that is accepted and still unmeasured

\n

The Browser Run payload takes a waitForTimeout. Whether the binding accepts the key was answered for free, using the closed schema above: send it alongside a deliberately invalid URL, and a bogus key comes back as unrecognized_keys while an accepted one leaves only the URL error. Three keys passed that test individually and together.

\n

Whether the capture actually waits out the timeout is a different question, it needs a real render, and it is still open. Both halves are written down separately for that reason. An entry claiming the key works, on evidence that only shows it is accepted, is the exact error this page is about.

\n\n

A path that routes either way

\n

The engine selector above only functions on one of two URL spellings. Both route. Probed unauthenticated against a real account, each answers 10000 Authentication error rather than 7003 Could not route to. So the wrong path costs the feature, silently: no error, no retry, no log line. One reference page documents the selector on the newer spelling while older pages show the other, so reading either one alone hands you a path that looks right.

\n\n

A flag that proves the limit is somewhere else

\n

Among the nineteen is --max-versions, on the command that shifts production traffic between Worker versions. This site's release script walks a version to 10%, then 50%, then 100%, and it can only ever name two versions in a step, which shapes the whole procedure: a ramp starting from a three-way split has to drop one of them. The obvious hope is that a hidden flag raises the ceiling. The definition answers it without a single request:

\n
\"max-versions\": {\n  hidden: true,\n  // experimental, not supported long-term\n  describe: \"Maximum allowed versions to select\",\n  type: \"number\",\n  default: 2\n  // (when server-side limitation is lifted, we can update this\n  //  default or just remove the option entirely)\n}
\n

The comment is the finding. The cap lives on the server and this flag is the client's mirror of it, so raising the number locally buys an error instead of a wider split. That is a real answer, arrived at by reading rather than by deploying, and it retires a question that had been open on the strength of a guess.

\n\n

A flag that looks like the fix and is not one

\n

This site has a documented failure where a gradual deployment splits asset requests: a document served by one version asks for a content-hashed asset only the other version built, and gets a 404. So --old-asset-ttl, described as \"expire old assets in given seconds rather than immediate deletion\", reads like the exact repair.

\n

It is threaded into one place in the entire codebase, and that place is the sync routine for the legacy Workers Sites layer, the retired KV-namespace mechanism that predates the modern assets binding this site uses. The flag would apply to a system that is not running here.

\n

Fifteen minutes of reading, and the answer arrives before any config changes. Had it been adopted on the strength of its description, it would have shipped, changed nothing, and become the fix everyone believed was already in place.

\n\n

Pick a situation, get the control

\n

The shape of the doubt decides the experiment. Choose what you are looking at:

\n
\n
\n
\n
\n\n\n

The rule this leaves

\n

On a closed schema, \"is this accepted\" and \"does this work\" are two questions, and the first one is free. Ask it first, always, because it costs an exit code and it eliminates the most common way an undocumented option wastes a week: being quietly dropped while everything around it reports success.

\n

Then be careful about what a success proves. A 200 from an endpoint that ignores unknown parameters is indistinguishable from a 200 from an endpoint that honoured yours. When you cannot tell those apart, the honest move is to label the result with what you asked for rather than with what you hope happened, and to write the unmeasured half down beside the measured one.

\n

And prefer reading the definition to running the experiment when the definition is in your node_modules. Two of the four cases here were settled by a source comment and a call site, in less time than a deploy takes, and neither answer could have been reached by watching the tool succeed.

\n\n

What would change this

\n

A tool that accepts unknown flags silently would break the free half, and the check for that is the bogus-flag control itself: an exit code of 0 on an invented name means every conclusion on this page has to be re-earned with real runs. A hidden flag becoming documented would move its row from this list to an ordinary reference, which is the outcome to hope for. And a measurement showing the capture does honour waitForTimeout would close the one case here that is deliberately still open.

", "pageCss": ".hf-code{background:var(--row-alt);border:1px solid #c8d2e2;border-left:3px solid var(--luna-44);padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:var(--text-xs);line-height:1.5}\n.hf-code code{white-space:pre}\n.hf-demo{border:1px solid var(--slate-73);background:var(--row-alt);margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.hf-bar{background:var(--surface-desktop);border-bottom:1px solid #c8d2e2;padding:5px 8px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}\n.hf-filters{display:inline-flex;gap:4px;flex-wrap:wrap}\n.hf-filters button{font:inherit;font-size:var(--text-2xs);padding:2px 8px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,var(--surface-desktop));border-radius:3px;cursor:pointer;color:var(--slate-38)}\n.hf-filters button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.hf-count{font-size:var(--text-micro);color:var(--slate-52)}\n.hf-scroll{overflow-x:auto;background:#fff}\n.hf-table{border-collapse:collapse;font-size:var(--text-micro);width:100%;min-width:560px}\n.hf-table th{text-align:left;background:var(--surface-desktop);border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:var(--slate-38);position:sticky;top:0}\n.hf-table td{border:1px solid var(--slate-90);padding:4px 7px;vertical-align:top;line-height:1.45}\n.hf-table tbody tr:nth-child(odd) td{background:var(--paper-cool)}\n.hf-table code{font-size:var(--text-2xs);font-family:var(--font-mono)}\n.hf-here td{background:#fdf6e0 !important;box-shadow:inset 3px 0 0 #e0a72c}\n.hf-tag{display:inline-block;font-size:var(--text-3xs);padding:0 5px;border-radius:8px;background:var(--slate-92);color:#3d4d68;white-space:nowrap}\n.hf-static{margin:0 0 12px}\n.hf-ok{color:#1c6b32;font-weight:bold}\n.hf-no{color:#a3261a;font-weight:bold}\n.hf-case{padding:10px 12px;background:#fff;font-size:var(--text-xs);line-height:1.55}\n.hf-case h4{font-family:var(--font-caption);color:var(--luna-44);font-size:var(--text-body);margin:0 0 5px}\n.hf-case pre{background:var(--row-alt);border:1px solid #c8d2e2;padding:7px 9px;margin:0 0 8px;overflow-x:auto;font-size:var(--text-micro)}\n.hf-case pre code{white-space:pre;font-family:var(--font-mono)}\n.hf-case table{border-collapse:collapse;font-size:var(--text-micro);width:100%;margin:0 0 6px}\n.hf-case th{text-align:left;background:var(--surface-desktop);border:1px solid #c8d2e2;padding:3px 7px;color:var(--slate-38)}\n.hf-case td{border:1px solid var(--slate-90);padding:3px 7px;vertical-align:top}\n.hf-cost{font-size:var(--text-micro);color:var(--slate-52);margin:0}\n@media(max-width:620px){.hf-table{min-width:460px}}", "editorial": { "reader": "Someone who has found an undocumented flag or config key and is deciding whether to trust it, having read a description that says what it does and nothing about how it fails.", @@ -58,7 +58,7 @@ ] }, { - "q": "The dictionary control runs three times: no option, the right value, a wrong value. Why does the wrong value earn its place?", + "q": "The dictionary control runs three times: no option, the right value, a wrong value. Why run the wrong value at all?", "options": [ { "t": "It ties the smaller output to the value you passed rather than to some other cause.", diff --git a/pipelines/garage/specs/octane.json b/pipelines/garage/specs/octane.json index f7257be5f..ae780c265 100644 --- a/pipelines/garage/specs/octane.json +++ b/pipelines/garage/specs/octane.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-07-31", "favicon": "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Cpath%20d='M5%2023%20A11%2011%200%200%201%2027%2023'%20stroke='%23166aee'%20stroke-width='2.8'%20fill='none'%20stroke-linecap='round'/%3E%3Cpath%20d='M16%2023%20L10%2015'%20stroke='%230855dd'%20stroke-width='2.8'%20fill='none'%20stroke-linecap='round'/%3E%3Ccircle%20cx='16'%20cy='23'%20r='2.6'%20fill='%23003bda'/%3E%3C/svg%3E", - "bodyHtml": "

Octane, priced against no framework

\n

A benchmark told me Nextane ships 60.9% less client JavaScript than Next.js 16. That is a real number, honestly measured, and I checked it against the deployed demo. What the table cannot tell you is where zero sits, because every row in it is a framework. This site makes a useful zero: it ships no framework at all, so putting it on the same axis turns a ranking into a distance.

\n\n

The benchmark, and what it leaves out

\n

Nextane rebuilds the Next.js Pages Router on Octane, a compiled React replacement descended from Inferno that drops the virtual DOM and has its compiler infer reactivity instead of asking you for dependency arrays. Its README publishes this, measured on one small hydrated getServerSideProps page:

\n\n \n \n \n \n
RuntimeClient JavaScript (gzip)Against Next.js
Next.js 16.2.12110.0 KiBbaseline
Vinext 1.0.0-beta.477.8 KiB29.2% less
Nextane + Octane 0.1.2143.0 KiB60.9% less
\n

The README is unusually candid about its own standing, calling the project a fun experiment rather than a serious framework and pointing you at Vinext for real work. That candour is why the number is worth trusting, and worth extending.

\n

Every row here is a framework, so the table ranks. Ranking is a weaker thing than measuring, because a measurement needs an origin and this one has none.

\n\n

Re-measuring it on the live demo

\n

The demo is deployed, so the claim is checkable without building anything. Here is what it puts on the wire, brotli as served and gzip so it lines up with the README's axis:

\n\n \n \n \n \n \n \n
AssetRawgzipbrotli
document2,6521,2471,130
styles.css3,4061,3641,224
index-903Z56aL.js8,1933,3362,974
router-bwM-JHXE.js125,15240,04835,159
JavaScript total133,34543,38438,133
\n

43,384 bytes gzip against a claimed 43.0 KiB. The benchmark holds, which is worth saying plainly, because a page that only re-measures claims it can knock down is not measuring.

\n

One thing to notice before moving on. The document is 2,652 bytes and the JavaScript is 133,345. The page's actual content is 2% of what the page costs.

\n\n

Where the baseline lands

\n

Predict before you reveal. The three framework rows are drawn below. This site's homepage and its heaviest page are hidden. Guess where they fall relative to the 43.0 KiB row that won the benchmark.

\n
\n
\n Client JavaScript per page load, gzip\n \n
\n
    \n

    \n
    \n

    The homepage loads one script, nav.js, at 14.9 KiB gzip. That script builds the wallpaper, the draggable desktop icons, the taskbar, the clock, the Start orb, and the Run palette that filters 158 photos plus every page on the site. The heaviest page here is /lens at 36.6 KiB, and it still lands under the framework row that won.

    \n

    Now the complication I would rather state than bury. Every script this site serves, summed across all 43 pages, is 49.0 KiB gzip, which is more than Nextane's 43.0. A visitor never downloads that sum, and a Nextane visitor pays 43.0 on their first page, so per page load the gap is real and roughly 3x. As a whole-site total the comparison reverses. Both numbers are true, they answer different questions, and only the per-page one describes what a browser does.

    \n\n

    The chunk is the framework

    \n

    The obvious objection is that most of that 125 KB is Next.js compatibility shim, which a plain Octane app would never load. I counted identifiers in the shipped chunk rather than arguing about it:

    \n\n \n \n \n \n \n \n
    Identifier in router-bwM-JHXE.jsOccurrences
    effect48
    octane34
    Suspense9
    prefetch5
    router1
    \n

    The chunk is named for the router and contains almost none of one. What fills it is the reactive runtime, Suspense, and the scheduler. So 43.0 KiB sits close to Octane's own floor in this build, and stripping the Next.js surface off the top would not recover much of it.

    \n

    That reading rests on one alpha build. Octane calls itself alpha software and has beta in view, so a tuned release may tree-shake considerably further. Treat the number as provisional.

    \n\n

    What the comparison actually measures

    \n

    Here is where I argue against my own table. A framework competes with the version of your application you would otherwise hand-write and then maintain for years, which is a different opponent from a hand-written document. Octane's 43 KiB buys a component model, hydration, Suspense, and a compiler that tracks what your code reads so you stop maintaining dependency arrays. This site buys none of that because it needs none of it. It serves documents, and its interactivity is a desktop shell bolted on top of them.

    \n

    So the honest reading is narrower than \"frameworks are heavy.\" A framework's floor is set by its runtime, and this site's floor is set by how much behaviour it actually has. Those are different quantities. A benchmark containing only frameworks quietly makes them look like one quantity, and that is the thing the extra rows are for.

    \n\n

    Where Octane would win here

    \n

    nav.js is the one module on this site with enough state to make the question live. It handles window dragging and resizing, taskbar state, a command palette filtering 158 photos alongside every page and six profiles, and icon positions that deliberately reset each visit. All of it is imperative DOM code, and all of it is 14.9 KiB.

    \n

    The crossover is a maintenance threshold rather than a byte one. Octane's runtime alone is roughly three times what nav.js weighs today, so a rewrite loses on size until that module grows several times over. If it ever does, the calculation changes.

    \n

    The idea underneath is worth taking even while the package is not. Octane compiles the reactivity bookkeeping away so the browser receives direct DOM updates with no virtual DOM in between. Hand-written imperative DOM code is that output. This site already stands where the compiler is trying to arrive, having paid in author effort rather than in runtime bytes, and that is the whole reason the framework has nothing to sell it.

    \n\n

    The objection I think is right

    \n

    The strongest reading of this page is that it stacks the deck. I picked a document site as the zero, then reported that an application framework costs more than zero, which was never in doubt. Nobody building a dashboard can adopt this site's answer, because the answer is \"have less behaviour.\"

    \n

    I think that is fair, and it is why the middle sections exist rather than just the chart. What the extra rows buy is a scale to read Octane against. A reader who has only ever seen framework-versus-framework tables can rank three options and still have no idea whether the winner is close to the metal or a long way from it. Now there is a distance on the axis, and 43.0 KiB reads as a genuine achievement against React and a large number against nothing at all. Both readings survive, and holding them together is the point.

    \n

    What would change my mind: a tuned, non-alpha Octane build landing anywhere near nav.js's 14.9 KiB. That would mean the runtime cost measured here is a property of an early build rather than of the architecture, and the crossover argument above would collapse with it. The beta is where that shows up.

    ", + "bodyHtml": "

    Octane, priced against no framework

    \n

    A benchmark told me Nextane ships 60.9% less client JavaScript than Next.js 16. That is a real number, and I checked it against the deployed demo. What the table cannot tell you is where zero sits, because every row in it is a framework. This site makes a useful zero: it ships no framework at all, so putting it on the same axis turns a ranking into a distance.

    \n\n

    The benchmark, and what it leaves out

    \n

    Nextane rebuilds the Next.js Pages Router on Octane, a compiled React replacement descended from Inferno that drops the virtual DOM and has its compiler infer reactivity instead of asking you for dependency arrays. Its README publishes this, measured on one small hydrated getServerSideProps page:

    \n\n \n \n \n \n
    RuntimeClient JavaScript (gzip)Against Next.js
    Next.js 16.2.12110.0 KiBbaseline
    Vinext 1.0.0-beta.477.8 KiB29.2% less
    Nextane + Octane 0.1.2143.0 KiB60.9% less
    \n

    The README is unusually candid about its own standing, calling the project a fun experiment rather than a serious framework and pointing you at Vinext for real work. That candour is why the number is worth trusting, and worth extending.

    \n

    Every row here is a framework, so the table ranks. Ranking is a weaker thing than measuring, because a measurement needs an origin and this one has none.

    \n\n

    Re-measuring it on the live demo

    \n

    The demo is deployed, so the claim is checkable without building anything. Here is what it puts on the wire, brotli as served and gzip so it lines up with the README's axis:

    \n\n \n \n \n \n \n \n
    AssetRawgzipbrotli
    document2,6521,2471,130
    styles.css3,4061,3641,224
    index-903Z56aL.js8,1933,3362,974
    router-bwM-JHXE.js125,15240,04835,159
    JavaScript total133,34543,38438,133
    \n

    43,384 bytes gzip against a claimed 43.0 KiB. The benchmark holds. I say so because a page that only re-measures claims it can knock down isn't measuring.

    \n

    The document is 2,652 bytes and the JavaScript is 133,345, so the page's actual content is 2% of what the page costs.

    \n\n

    Where the baseline lands

    \n

    Predict before you reveal. The three framework rows are drawn below. This site's homepage and its heaviest page are hidden. Guess where they fall relative to the 43.0 KiB row that won the benchmark.

    \n
    \n
    \n Client JavaScript per page load, gzip\n \n
    \n
      \n

      \n
      \n

      The homepage loads one script, nav.js, at 14.9 KiB gzip. That script builds the wallpaper, the draggable desktop icons, the taskbar, the clock, the Start orb, and the Run palette that filters 158 photos plus every page on the site. The heaviest page here is /lens at 36.6 KiB, and it still lands under the framework row that won.

      \n

      Now the complication I would rather state than bury. Every script this site serves, summed across all 43 pages, is 49.0 KiB gzip, which is more than Nextane's 43.0. A visitor never downloads that sum, and a Nextane visitor pays 43.0 on their first page, so per page load the gap is real and roughly 3x. As a whole-site total the comparison reverses. Both numbers are true, they answer different questions, and only the per-page one describes what a browser does.

      \n\n

      The chunk is the framework

      \n

      The obvious objection is that most of that 125 KB is Next.js compatibility shim, which a plain Octane app would never load. I counted identifiers in the shipped chunk rather than arguing about it:

      \n\n \n \n \n \n \n \n
      Identifier in router-bwM-JHXE.jsOccurrences
      effect48
      octane34
      Suspense9
      prefetch5
      router1
      \n

      The chunk is named for the router and contains almost none of one. What fills it is the reactive runtime, Suspense, and the scheduler. So 43.0 KiB sits close to Octane's own floor in this build, and stripping the Next.js surface off the top would not recover much of it.

      \n

      That reading rests on one alpha build. Octane calls itself alpha software and has beta in view, so a tuned release may tree-shake considerably further. Treat the number as provisional.

      \n\n

      What the comparison actually measures

      \n

      Here is where I argue against my own table. A framework competes with the version of your application you would otherwise hand-write and then maintain for years, which is a different opponent from a hand-written document. Octane's 43 KiB buys a component model, hydration, Suspense, and a compiler that tracks what your code reads so you stop maintaining dependency arrays. This site buys none of that because it needs none of it. It serves documents, and its interactivity is a desktop shell bolted on top of them.

      \n

      So the honest reading is narrower than \"frameworks are heavy.\" A framework's floor is set by its runtime, and this site's floor is set by how much behaviour it actually has. Those are different quantities. A benchmark containing only frameworks makes them look like one quantity, and the extra rows are there to break that.

      \n\n

      Where Octane would win here

      \n

      nav.js is the one module on this site with enough state to make the question live. It handles window dragging and resizing, taskbar state, a command palette filtering 158 photos alongside every page and six profiles, and icon positions that deliberately reset each visit. All of it is imperative DOM code, and all of it is 14.9 KiB.

      \n

      The crossover is a maintenance threshold rather than a byte one. Octane's runtime alone is roughly three times what nav.js weighs today, so a rewrite loses on size until that module grows several times over. If it ever does, the calculation changes.

      \n

      The idea underneath is worth taking even while the package is not. Octane compiles the reactivity bookkeeping away so the browser receives direct DOM updates with no virtual DOM in between. Hand-written imperative DOM code is that output. This site already stands where the compiler is trying to arrive, having paid in author effort rather than in runtime bytes, and that is the whole reason the framework has nothing to sell it.

      \n\n

      The objection I think is right

      \n

      The strongest reading of this page is that it stacks the deck. I picked a document site as the zero, then reported that an application framework costs more than zero, which was never in doubt. Nobody building a dashboard can adopt this site's answer, because the answer is \"have less behaviour.\"

      \n

      I think that is fair, and it is why the middle sections exist rather than just the chart. What the extra rows buy is a scale to read Octane against. A reader who has only ever seen framework-versus-framework tables can rank three options and still have no idea whether the winner is close to the metal or a long way from it. Now there is a distance on the axis, and 43.0 KiB reads as a genuine achievement against React and a large number against nothing at all. Both readings survive, and holding them together is the point.

      \n

      What would change my mind: a tuned, non-alpha Octane build landing anywhere near nav.js's 14.9 KiB. That would mean the runtime cost measured here is a property of an early build rather than of the architecture, and the crossover argument above would collapse with it. The beta is where that shows up.

      ", "pageCss": ".oct-table{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:var(--text-sm)}\n.oct-table th{text-align:left;background:var(--surface-desktop);border:1px solid #c3ced0;padding:4px 7px;font-family:var(--font-caption)}\n.oct-table td{border:1px solid var(--slate-90);padding:4px 7px;vertical-align:top}\n@media(max-width:340px){.oct-table th,.oct-table td{padding-inline:5px}}\n.oct-table td:not(:first-child){text-align:right;font-family:var(--font-mono)}\n.oct-table tr.mine td{background:#fffbe8}\n.oct-demo{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.oct-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,var(--surface-desktop),var(--slate-92));border-bottom:1px solid var(--slate-81);padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.oct-bars{list-style:none;margin:0;padding:10px 12px 4px}\n.oct-bars li{display:grid;grid-template-columns:11em 1fr auto;align-items:center;gap:8px;margin:0 0 7px;font-size:var(--text-xs)}\n.oct-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.oct-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.oct-bars li.mine .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.oct-bars li.hidden .t{background:repeating-linear-gradient(135deg,var(--surface-desktop),var(--surface-desktop) 5px,var(--slate-88) 5px,var(--slate-88) 10px);border-style:dashed;border-color:var(--slate-73);width:14%}\n.oct-bars .v{font-family:var(--font-mono);font-size:var(--text-micro);color:#4a5870;white-space:nowrap}\n.oct-read{margin:0;padding:2px 12px 10px;font-size:var(--text-micro);color:var(--slate-52)}\n.oct-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.oct-bars li{grid-template-columns:8.5em 1fr;row-gap:2px}.oct-bars .v{grid-column:2}}", "pageJs": "(function(){\n // Framework rows are the Nextane README's published benchmark (one small\n // hydrated getServerSideProps page, gzip). The two site rows are measured\n // from this repo's own build output at the same compression, 2026-07-31.\n var ROWS = [\n { n: \"Next.js 16.2.12\", kib: 110.0 },\n { n: \"Vinext 1.0.0-b4\", kib: 77.8 },\n { n: \"Nextane + Octane\", kib: 43.0 },\n { n: \"aadhar.sh /lens\", kib: 36.6, mine: true, hide: true },\n { n: \"aadhar.sh home\", kib: 14.9, mine: true, hide: true }\n ];\n var BEFORE = \"Three frameworks, ranked. The two hidden rows are this site's heaviest page and its homepage. Where do they land?\";\n var AFTER = \"The homepage is 14.9 KiB against the winning framework's 43.0, so roughly a third. The heaviest page here still sits under that row. Read it as a scale rather than a verdict: 43.0 KiB is a real win against React and a large number against no framework.\";\n\n var host = document.getElementById(\"oct-demo\");\n if (!host) return;\n var list = host.querySelector(\".oct-bars\");\n var read = host.querySelector(\".oct-read\");\n var button = host.querySelector(\".oct-reveal\");\n var max = 0, i;\n for (i = 0; i < ROWS.length; i++) if (ROWS[i].kib > max) max = ROWS[i].kib;\n var revealed = false;\n\n function draw() {\n list.textContent = \"\";\n for (var j = 0; j < ROWS.length; j++) {\n var row = ROWS[j];\n var covered = row.hide && !revealed;\n var li = document.createElement(\"li\");\n li.className = (row.mine ? \"mine\" : \"\") + (covered ? \" hidden\" : \"\");\n var n = document.createElement(\"span\");\n n.className = \"n\";\n n.textContent = row.n;\n var t = document.createElement(\"span\");\n t.className = \"t\";\n if (!covered) t.style.width = (row.kib / max) * 100 + \"%\";\n var v = document.createElement(\"span\");\n v.className = \"v\";\n v.textContent = covered ? \"?\" : row.kib.toFixed(1) + \" KiB\";\n li.appendChild(n);\n li.appendChild(t);\n li.appendChild(v);\n list.appendChild(li);\n }\n read.textContent = revealed ? AFTER : BEFORE;\n }\n\n button.addEventListener(\"click\", function(){\n revealed = true;\n button.disabled = true;\n button.textContent = \"revealed\";\n draw();\n });\n draw();\n})();", "editorial": { diff --git a/pipelines/garage/specs/pqc.json b/pipelines/garage/specs/pqc.json index 0330000b0..f473df01e 100644 --- a/pipelines/garage/specs/pqc.json +++ b/pipelines/garage/specs/pqc.json @@ -4,7 +4,7 @@ "description": "This site's TLS is already post-quantum and its signatures aren't. What ML-DSA-44 and hash-based signatures cost on its own crawler's RFC 9421 requests.", "status": "shipped", "added": "2026-07-27", - "bodyHtml": "

      Post-quantum signatures, priced

      \n

      Half of this site's TLS handshake is already post-quantum and I did nothing to make that happen. The other half, authentication, moves on a completely different schedule, because signatures live in certificates and registries that a personal site does not control. So I went looking for the signature I do control, and measured what it would cost to migrate it.

      \n\n

      What is already done, and what it covers

      \n

      Ask the live site what it negotiated:

      \n
      $ openssl s_client -connect aadhar.sh:443 -groups X25519MLKEM768 -tls1_3\n\nNegotiated TLS1.3 group: X25519MLKEM768\nPeer signature type:     ecdsa_secp256r1_sha256\nCertificate:             ecdsa-with-SHA256, P-256
      \n

      The key agreement is post-quantum. The certificate and the handshake signature are classical, and they will stay that way for a while: no public CA issues ML-DSA certificates into the WebPKI. Cloudflare's own position is that ML-DSA chains add roughly 14KB per handshake, which is why they are pushing Merkle Tree Certificates instead.

      \n

      That split matters more than it looks. Key agreement is where harvest now, decrypt later bites: an attacker recording today's ciphertext can decrypt it whenever a quantum computer arrives, so the migration had to happen years early. A signature has no such property. Forging a 2026 signature in 2035 proves nothing about 2026, because by then the request is long over and nobody is still trusting it. The urgency argument for key agreement simply does not transfer.

      \n\n

      Three tiers of signature, and only one is mine

      \n\n \n \n \n \n \n
      SurfaceAlgorithmWhose problem
      TLS to visitorsECDSA P-256The CA and browser ecosystem
      DNSSEC on the zoneECDSA P-256 SHA-256The registry, plus DNS response sizes
      Booking links, cover URLsHMAC-SHA256Already fine. Symmetric, so Grover halves it to 128-bit
      AadharshBot request signingEd25519Mine. About 30 lines in one file
      \n

      AadharshBot signs every outbound request per RFC 9421 using the Web Bot Auth profile, so receiving sites can check that a request really came from here. That signature is the only asymmetric one this site owns end to end. The whole post-quantum question collapses to it.

      \n\n

      What the candidates actually cost

      \n

      When I first measured this in July, Cloudflare's runtime had no post-quantum algorithm in WebCrypto. I checked the shipping binary rather than the docs: workerd 1.20260722 contained the strings Ed25519, X25519, and ECDSA, and no ML-DSA at any parameter size. So the blue bars are pure JavaScript, via @noble/post-quantum, signing the site's real signature base on an M3 Max under Node 26. The amber bar arrived in October, and the last section explains it.

      \n
      \n
      \n Cost of one signature\n \n \n \n \n
      \n
        \n

        \n \n
        \n

        Flip the toggle before you read on and predict where each bar lands. The point of the exercise is that the ordering changes. SLH-DSA-128s has the smallest public key of anything here at 32 bytes, and it takes 2.65 seconds to produce one signature. Its sibling 128f signs in 132ms and spends 22KB of header doing it. No row is small on both axes, and that is the state of post-quantum signatures, whichever library computes them.

        \n\n

        How much of that 8.58ms is the algorithm?

        \n

        Less than it looks, and the panel above will prove it on your own hardware if it can. Chrome ships ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc, implementing the same WICG proposal whose JWK shape this site already publishes. If that flag is on, the panel offers a button that generates an ML-DSA-44 key in your browser, signs this page's signature base 200 times natively, and drops the result in beside the JavaScript number. The amber workerd bar answers the same question for Cloudflare's runtime.

        \n

        This matters for how you read the whole table. The 8.58ms is a ceiling set by running a lattice scheme in a JIT, and the byte counts underneath it are properties of FIPS 204 that no implementation will improve. Sizes are the permanent cost. Speed is the temporary one, and it goes away on its own when the runtimes catch up.

        \n\n

        Why the hash-based options lost

        \n

        I wanted SLH-DSA to work. It rests on hash functions alone, so it survives even if lattices turn out to be broken, and its 32-byte public key is charming. It fails here twice over.

        \n

        The header is the harder failure. A 128f signature is 22,784 bytes once base64'd into a structured-fields value, and most servers cap total request headers around 8KB, so the crawl would start collecting 431s instead of pages. The latency is the other one: 2.65 seconds of single-threaded work per signature is not something to put in front of an HTTP request, and a Worker bills CPU time.

        \n

        Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a Worker isolate is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints.

        \n\n

        Shipping it without a codepoint

        \n

        Here is the part that made this cheap. The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

        \n

        But Signature-Input and Signature are structured-fields Dictionaries. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:

        \n
        Signature-Input: sig1=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o\";alg=\"ed25519\";tag=\"web-bot-auth\",\n                 sig2=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"rn-mldsa-2026-07-27\";alg=\"ml-dsa-44\";tag=\"web-bot-auth\"
        \n

        A verifier that only knows Ed25519 reads sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says plainly that you should verify sig1 and ignore sig2.

        \n

        The public key is published under the same JWKS as an AKP key per RFC 9964, which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:

        \n
        \n
        Live, from this site's key directory
        \n
        reading /.well-known/http-message-signatures-directory…
        \n
        \n\n

        What it costs in production

        \n

        About 8.6ms of CPU and 3,228 bytes of request header per signed fetch, plus 11.96KB gzip of lattice code in the Worker bundle, which is the price of carrying an implementation the runtime should be providing. Zero bytes reach a browser, because this is a server-to-server signature and no page loads it. The /around crawl signs roughly 21 origins per refresh, so a full cycle gains around 180ms of CPU on a cached background path.

        \n

        Two of those three costs are temporary. When workerd gains native ML-DSA, the bundle cost goes to zero and the signing cost drops to whatever the native bar in the panel above showed you. The 3,228 bytes of header stay forever. That asymmetry is the argument for migrating on the size budget now and letting the speed sort itself out.

        \n

        One implementation detail worth stealing: ML-DSA-44 keygen expands a 32-byte seed into a 2,560-byte secret key and costs about 1.4ms. Worker isolates outlive a request, so the expanded key is derived once and cached in module scope, keyed by the raw secret so a rotation inside a live isolate re-derives instead of signing with a retired key.

        \n\n

        The objection I think is right

        \n

        The strongest reading of this page is that it is theatre. Nothing verifies sig2. Signatures have no harvest-now-decrypt-later exposure, so there is no clock forcing the migration. By that argument the honest move was to write the analysis and ship nothing.

        \n

        I mostly agree, and it is why sig2 is second rather than first, why a missing key degrades to Ed25519 alone instead of failing the request, and why /bot tells verifiers to ignore it. What I get in exchange is a migration already paid for and measured, on a surface where being wrong costs a crawl rather than a login. When the registry does land a codepoint, the remaining work is renaming a string.

        \n

        What would change my mind: a receiving server rejecting requests because the extra 3.2KB pushes headers past its limit, or a verifier that chokes on a dictionary label it does not know. Either result would mean a second label is not free after all, and sig2 would come straight back out. The crawl logs are where that would show up first.

        \n\n

        What actually happened: retired after 19 days

        \n

        Neither of those things went wrong. No server rejected the headers, and no verifier choked on the extra label. The prediction above was accurate, but it was aimed in the wrong direction, because it priced the cost to other people and treated the cost to this site as a settled line item: 8.58ms, listed once, in a table.

        \n

        The number that mattered was 8.58ms multiplied by fan-out, against a per-invocation budget. This site runs on the Workers free plan, which allows 10ms of CPU per request. So one signature spent most of a request's entire budget, and every surface that signs more than once had already blown it:

        \n\n \n \n \n \n
        SurfaceSignatures per requestML-DSA CPU
        A single crawl of one origin1~8.5ms of a 10ms budget
        The /rn playlist scrape21, one per track~180ms
        /lens discovery28 probes~240ms
        \n

        Both of those were dark and neither said so. The playlist scrape catches a failed track embed per track and degrades that one track to no cover, so 21 signatures timing out rendered as a tracklist that had quietly stopped showing album art. On /lens it was louder but no better attributed: sampling production logs on 2026-08-15, 31 of 51 requests died exceededCpu, nearly all of them /lens/fetch and /lens/tools.

        \n

        Removing sig2 took the scrape from 379ms of CPU to 95ms, measured on the same 21-track playlist. The key is out of the JWKS too, because publishing a key nothing signs with is the dangling-pointer problem this site refuses elsewhere.

        \n

        The honest reading is that the objection above was right for a reason it did not name. Shipping the migration early was cheap to build and the build was never the risk. What it cost was a recurring tax on every outbound request, on the one axis a free-tier Worker has least of, and the page that measured the tax in isolation is the same page that missed it in aggregate. A per-request cost is only a number once you know how many requests, and how much budget.

        \n

        None of this changes the sizing argument, which is the part worth keeping. The 3,228 bytes are still permanent, the speed is still temporary, and when workerd ships native ML-DSA the CPU objection evaporates and sig2 can come back with no code to write beyond deleting this section. Until then the migration is measured, documented, and turned off.

        \n\n

        Then the runtime caught up

        \n

        That last paragraph made a prediction, and on 2026-10-01 I got to check it. Cloudflare has put ML-KEM and ML-DSA into WebCrypto on Workers. They run on BoringSSL, and each Worker opts in with the webcrypto_modern_algorithms compatibility flag (the announcement). This site already pins a workerd build from 2026-09-26 that carries both, so I measured it on the same M3 Max that drew the JavaScript bars.

        \n\n \n \n \n \n
        Same laptop, same signature baseJavaScriptworkerd, native
        Sign once with ML-DSA-448.58ms0.12ms
        Expand the 32-byte seed into a signing key~1.4ms0.03ms
        Signature in the header3,228 bytes3,228 bytes
        \n

        Signing got 72 times faster, and the header didn't lose a single byte. That's the asymmetry this page bet on in July. Natively, one ML-DSA-44 signature costs about as much as 10 Ed25519 signatures (11.3µs each on the same rig), where the JavaScript version cost as much as 170.

        \n

        Here is the table that retired sig2, repriced:

        \n\n \n \n \n \n
        SurfaceSignatures per requestJavaScriptNative, on the laptop
        A single crawl of one origin1~8.5ms~0.12ms
        The /rn playlist scrape21~180ms~2.5ms
        /lens discovery28~240ms~3.4ms
        \n

        On the laptop, one signature takes about 1% of a 10ms request budget, and the worst fan-out on the site would sign everything it needs in about a third of a single budget. Hold that projection loosely; production priced it at 3 times that.

        \n\n

        How I checked it

        \n

        A 72x speedup deserves a control, so I ran three.

        \n

        Without the flag, the same runtime refuses outright: Unrecognized key generation algorithm \"ML-DSA-44\" requested. With it, a key shaped like the one still sitting in this site's secrets (a bare 32-byte seed in an AKP JWK, with no public half) imports and signs. And Node 26's WebCrypto, which runs on OpenSSL instead of BoringSSL, verifies workerd's signature and rejects it the moment one byte of the signed base changes.

        \n

        A Worker's clock only advances across I/O, so a Worker can't time its own crypto. The timings are wall-clock from outside instead: 2,000 signatures inside one request, minus an empty request so the dispatch cost cancels, median of 9 runs. The browser button above times yours the same way.

        \n\n

        What it leaves open

        \n

        The objection I think is right survives intact. Nothing on the internet verifies sig2, the IANA registry still lists six classical algorithms, and every signed request would still carry 3.2KB more header for a signature with no harvest-now clock behind it.

        \n

        The retirement section also promised sig2 could return \"with no code to write.\" That was wrong. The library left with the label, so bringing it back means a compatibility flag on the whole Worker plus a signing path through crypto.subtle. It's a small change, and a real one.

        \n

        What production said

        \n

        Those were laptop numbers, and production bills CPU on different hardware, under a clamp that tightens with load. That is exactly where this page went wrong the first time. So before sig2 came back, I deployed it at 0% beside the live version and pinned six /lens scans to it, each against a fresh origin so no cache could skip the signing.

        \n\n \n \n \n \n
        Version/lens scan CPU, median of 6
        Live, sig1 only22ms
        sig2 on every request32ms
        sig2 on primary fetches only22ms (live read 20ms in the same run)
        \n

        Signing everything cost 10ms a scan, about 0.33ms a signature, which is 3 times the laptop figure. August's lesson applied a second time: the per-signature price was right, but the price that mattered was that number on production's CPU, multiplied by fan-out.

        \n

        So sig2 now rides on the requests that are this crawler's identity (the page being scanned, /around, the playlist) and skips the 28 discovery probes and the robots.txt reads that only look around. That costs about 2ms a scan, inside the noise between two runs of the live version.

        \n

        The flag carries its own risk, since Cloudflare gates it \"while the specification is still moving.\" If the API breaks, this site loses the second label and nothing else: any ML-DSA failure drops sig2, logs once, and sig1 ships alone.

        \n

        In July this page priced three costs: CPU, bundle size, and header bytes. Two belonged to the runtime, and the runtime has now paid them. The 3,228 bytes belong to FIPS 204, so they're the only number left to decide on. That's the rule I'd hand anyone timing a post-quantum migration: decide on the sizes, because the speeds come down on someone else's schedule.

        ", + "bodyHtml": "

        Post-quantum signatures, priced

        \n

        Half of this site's TLS handshake is already post-quantum and I did nothing to make that happen. The other half, authentication, moves on a completely different schedule, because signatures live in certificates and registries that a personal site does not control. So I went looking for the signature I do control, and measured what it would cost to migrate it.

        \n\n

        What is already done, and what it covers

        \n

        Ask the live site what it negotiated:

        \n
        $ openssl s_client -connect aadhar.sh:443 -groups X25519MLKEM768 -tls1_3\n\nNegotiated TLS1.3 group: X25519MLKEM768\nPeer signature type:     ecdsa_secp256r1_sha256\nCertificate:             ecdsa-with-SHA256, P-256
        \n

        The key agreement is post-quantum. The certificate and the handshake signature are classical, and they will stay that way for a while: no public CA issues ML-DSA certificates into the WebPKI. Cloudflare's own position is that ML-DSA chains add roughly 14KB per handshake, which is why they are pushing Merkle Tree Certificates instead.

        \n

        That split matters more than it looks. Key agreement is where harvest now, decrypt later bites: an attacker recording today's ciphertext can decrypt it whenever a quantum computer arrives, so the migration had to happen years early. A signature has no such property. Forging a 2026 signature in 2035 proves nothing about 2026, because by then the request is long over and nobody is still trusting it. The urgency argument for key agreement simply does not transfer.

        \n\n

        Three tiers of signature, and only one is mine

        \n\n \n \n \n \n \n
        SurfaceAlgorithmWhose problem
        TLS to visitorsECDSA P-256The CA and browser ecosystem
        DNSSEC on the zoneECDSA P-256 SHA-256The registry, plus DNS response sizes
        Booking links, cover URLsHMAC-SHA256Already fine. Symmetric, so Grover halves it to 128-bit
        AadharshBot request signingEd25519Mine. About 30 lines in one file
        \n

        AadharshBot signs every outbound request per RFC 9421 using the Web Bot Auth profile, so receiving sites can check that a request really came from here. That signature is the only asymmetric one this site owns end to end. The whole post-quantum question collapses to it.

        \n\n

        What the candidates actually cost

        \n

        When I first measured this in July, Cloudflare's runtime had no post-quantum algorithm in WebCrypto. I checked the shipping binary rather than the docs: workerd 1.20260722 contained the strings Ed25519, X25519, and ECDSA, and no ML-DSA at any parameter size. So the blue bars are pure JavaScript, via @noble/post-quantum, signing the site's real signature base on an M3 Max under Node 26. The amber bar arrived in October, and the last section explains it.

        \n
        \n
        \n Cost of one signature\n \n \n \n \n
        \n
          \n

          \n \n
          \n

          Flip the toggle before you read on and predict where each bar lands. The point of the exercise is that the ordering changes. SLH-DSA-128s has the smallest public key of anything here at 32 bytes, and it takes 2.65 seconds to produce one signature. Its sibling 128f signs in 132ms and spends 22KB of header doing it. No row is small on both axes, and that is the state of post-quantum signatures, whichever library computes them.

          \n\n

          How much of that 8.58ms is the algorithm?

          \n

          Less than it looks, and the panel above will prove it on your own hardware if it can. Chrome ships ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc, implementing the same WICG proposal whose JWK shape this site already publishes. If that flag is on, the panel offers a button that generates an ML-DSA-44 key in your browser, signs this page's signature base 200 times natively, and drops the result in beside the JavaScript number. The amber workerd bar answers the same question for Cloudflare's runtime.

          \n

          This matters for how you read the whole table. The 8.58ms is a ceiling set by running a lattice scheme in a JIT, and the byte counts underneath it are properties of FIPS 204 that no implementation will improve. Sizes are the permanent cost. Speed is the temporary one, and it goes away on its own when the runtimes catch up.

          \n\n

          Why the hash-based options lost

          \n

          I wanted SLH-DSA to work. It rests on hash functions alone, so it survives even if lattices turn out to be broken, and its 32-byte public key is charming. It fails here twice over.

          \n

          The header is the harder failure. A 128f signature is 22,784 bytes once base64'd into a structured-fields value, and most servers cap total request headers around 8KB, so the crawl would start collecting 431s instead of pages. The latency is the other one: 2.65 seconds of single-threaded work per signature is not something to put in front of an HTTP request, and a Worker bills CPU time.

          \n

          Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a Worker isolate is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints.

          \n\n

          Shipping it without a codepoint

          \n

          The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

          \n

          But Signature-Input and Signature are structured-fields Dictionaries. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:

          \n
          Signature-Input: sig1=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o\";alg=\"ed25519\";tag=\"web-bot-auth\",\n                 sig2=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"rn-mldsa-2026-07-27\";alg=\"ml-dsa-44\";tag=\"web-bot-auth\"
          \n

          A verifier that only knows Ed25519 reads sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says that you should verify sig1 and ignore sig2.

          \n

          The public key is published under the same JWKS as an AKP key per RFC 9964, which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:

          \n
          \n
          Live, from this site's key directory
          \n
          reading /.well-known/http-message-signatures-directory…
          \n
          \n\n

          What it costs in production

          \n

          About 8.6ms of CPU and 3,228 bytes of request header per signed fetch, plus 11.96KB gzip of lattice code in the Worker bundle, which is the price of carrying an implementation the runtime should be providing. Zero bytes reach a browser, because this is a server-to-server signature and no page loads it. The /around crawl signs roughly 21 origins per refresh, so a full cycle gains around 180ms of CPU on a cached background path.

          \n

          Two of those three costs are temporary. When workerd gains native ML-DSA, the bundle cost goes to zero and the signing cost drops to whatever the native bar in the panel above showed you. The 3,228 bytes of header stay forever. That asymmetry is the argument for migrating on the size budget now and letting the speed sort itself out.

          \n

          One implementation detail worth stealing: ML-DSA-44 keygen expands a 32-byte seed into a 2,560-byte secret key and costs about 1.4ms. Worker isolates outlive a request, so the expanded key is derived once and cached in module scope, keyed by the raw secret so a rotation inside a live isolate re-derives instead of signing with a retired key.

          \n\n

          The objection I think is right

          \n

          The strongest reading of this page is that it is theatre. Nothing verifies sig2. Signatures have no harvest-now-decrypt-later exposure, so there is no clock forcing the migration. By that argument the honest move was to write the analysis and ship nothing.

          \n

          I mostly agree, and it is why sig2 is second rather than first, why a missing key degrades to Ed25519 alone instead of failing the request, and why /bot tells verifiers to ignore it. What I get in exchange is a migration already paid for and measured, on a surface where being wrong costs a crawl rather than a login. When the registry does land a codepoint, the remaining work is renaming a string.

          \n

          What would change my mind: a receiving server rejecting requests because the extra 3.2KB pushes headers past its limit, or a verifier that chokes on a dictionary label it does not know. Either result would mean a second label is not free after all, and sig2 would come straight back out. The crawl logs are where that would show up first.

          \n\n

          What actually happened: retired after 19 days

          \n

          Neither of those things went wrong. No server rejected the headers, and no verifier choked on the extra label. The prediction above was accurate, but it was aimed in the wrong direction, because it priced the cost to other people and treated the cost to this site as a settled line item: 8.58ms, listed once, in a table.

          \n

          The number that mattered was 8.58ms multiplied by fan-out, against a per-invocation budget. This site runs on the Workers free plan, which allows 10ms of CPU per request. So one signature spent most of a request's entire budget, and every surface that signs more than once had already blown it:

          \n\n \n \n \n \n
          SurfaceSignatures per requestML-DSA CPU
          A single crawl of one origin1~8.5ms of a 10ms budget
          The /rn playlist scrape21, one per track~180ms
          /lens discovery28 probes~240ms
          \n

          Both of those were dark and neither said so. The playlist scrape catches a failed track embed per track and degrades that one track to no cover, so 21 signatures timing out rendered as a tracklist that had quietly stopped showing album art. On /lens it was louder but no better attributed: sampling production logs on 2026-08-15, 31 of 51 requests died exceededCpu, nearly all of them /lens/fetch and /lens/tools.

          \n

          Removing sig2 took the scrape from 379ms of CPU to 95ms, measured on the same 21-track playlist. The key is out of the JWKS too, because publishing a key nothing signs with is the dangling-pointer problem this site refuses elsewhere.

          \n

          The honest reading is that the objection above was right for a reason it did not name. Shipping the migration early was cheap to build and the build was never the risk. What it cost was a recurring tax on every outbound request, on the one axis a free-tier Worker has least of, and the page that measured the tax in isolation is the same page that missed it in aggregate. A per-request cost is only a number once you know how many requests, and how much budget.

          \n

          None of this changes the sizing argument, which is the part worth keeping. The 3,228 bytes are still permanent, the speed is still temporary, and when workerd ships native ML-DSA the CPU objection evaporates and sig2 can come back with no code to write beyond deleting this section. Until then the migration is measured, documented, and turned off.

          \n\n

          Then the runtime caught up

          \n

          That last paragraph made a prediction, and on 2026-10-01 I got to check it. Cloudflare has put ML-KEM and ML-DSA into WebCrypto on Workers. They run on BoringSSL, and each Worker opts in with the webcrypto_modern_algorithms compatibility flag (the announcement). This site already pins a workerd build from 2026-09-26 that carries both, so I measured it on the same M3 Max that drew the JavaScript bars.

          \n\n \n \n \n \n
          Same laptop, same signature baseJavaScriptworkerd, native
          Sign once with ML-DSA-448.58ms0.12ms
          Expand the 32-byte seed into a signing key~1.4ms0.03ms
          Signature in the header3,228 bytes3,228 bytes
          \n

          Signing got 72 times faster, and the header didn't lose a single byte. That's the asymmetry this page bet on in July. Natively, one ML-DSA-44 signature costs about as much as 10 Ed25519 signatures (11.3µs each on the same rig), where the JavaScript version cost as much as 170.

          \n

          Here is the table that retired sig2, repriced:

          \n\n \n \n \n \n
          SurfaceSignatures per requestJavaScriptNative, on the laptop
          A single crawl of one origin1~8.5ms~0.12ms
          The /rn playlist scrape21~180ms~2.5ms
          /lens discovery28~240ms~3.4ms
          \n

          On the laptop, one signature takes about 1% of a 10ms request budget, and the worst fan-out on the site would sign everything it needs in about a third of a single budget. Hold that projection loosely; production priced it at 3 times that.

          \n\n

          How I checked it

          \n

          A 72x speedup deserves a control, so I ran three.

          \n

          Without the flag, the same runtime refuses outright: Unrecognized key generation algorithm \"ML-DSA-44\" requested. With it, a key shaped like the one still sitting in this site's secrets (a bare 32-byte seed in an AKP JWK, with no public half) imports and signs. And Node 26's WebCrypto, which runs on OpenSSL instead of BoringSSL, verifies workerd's signature and rejects it the moment one byte of the signed base changes.

          \n

          A Worker's clock only advances across I/O, so a Worker can't time its own crypto. The timings are wall-clock from outside instead: 2,000 signatures inside one request, minus an empty request so the dispatch cost cancels, median of 9 runs. The browser button above times yours the same way.

          \n\n

          What it leaves open

          \n

          The objection I think is right survives intact. Nothing on the internet verifies sig2, the IANA registry still lists six classical algorithms, and every signed request would still carry 3.2KB more header for a signature with no harvest-now clock behind it.

          \n

          The retirement section also promised sig2 could return \"with no code to write.\" That was wrong. The library left with the label, so bringing it back means a compatibility flag on the whole Worker plus a signing path through crypto.subtle. It's a small change, and a real one.

          \n

          What production said

          \n

          Those were laptop numbers, and production bills CPU on different hardware, under a clamp that tightens with load. That is exactly where this page went wrong the first time. So before sig2 came back, I deployed it at 0% beside the live version and pinned six /lens scans to it, each against a fresh origin so no cache could skip the signing.

          \n\n \n \n \n \n
          Version/lens scan CPU, median of 6
          Live, sig1 only22ms
          sig2 on every request32ms
          sig2 on primary fetches only22ms (live read 20ms in the same run)
          \n

          Signing everything cost 10ms a scan, about 0.33ms a signature, which is 3 times the laptop figure. August's lesson applied a second time: the per-signature price was right, but the price that mattered was that number on production's CPU, multiplied by fan-out.

          \n

          So sig2 now rides on the requests that are this crawler's identity (the page being scanned, /around, the playlist) and skips the 28 discovery probes and the robots.txt reads that only look around. That costs about 2ms a scan, inside the noise between two runs of the live version.

          \n

          The flag carries its own risk, since Cloudflare gates it \"while the specification is still moving.\" If the API breaks, this site loses the second label and nothing else: any ML-DSA failure drops sig2, logs once, and sig1 ships alone.

          \n

          In July this page priced three costs: CPU, bundle size, and header bytes. Two belonged to the runtime, and the runtime has now paid them. The 3,228 bytes belong to FIPS 204, so they're the only number left to decide on. That's the rule I'd hand anyone timing a post-quantum migration: decide on the sizes, because the speeds come down on someone else's schedule.

          ", "pageCss": ".pqc-tiers{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:var(--text-sm)}\n.pqc-tiers th{text-align:left;background:var(--surface-desktop);border:1px solid var(--rule);padding:4px 7px;font-family:var(--font-caption)}\n.pqc-tiers td{border:1px solid var(--slate-90);padding:4px 7px;vertical-align:top}\n.pqc-tiers tr.mine td{background:#fffbe8}\n.pqc-demo,.pqc-live{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.pqc-demo-head,.pqc-live-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,var(--surface-desktop),var(--slate-92));border-bottom:1px solid var(--slate-81);padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.pqc-toggle button{font:inherit;font-size:var(--text-micro);font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,var(--surface-desktop));border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.pqc-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.pqc-bars{list-style:none;margin:0;padding:10px 12px 4px;display:grid;grid-template-columns:max-content 1fr auto;gap:7px 8px;font-size:var(--text-xs)}\n.pqc-bars li{display:grid;grid-column:1/-1;grid-template-columns:subgrid;align-items:center}\n.pqc-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap}\n.pqc-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.pqc-bars li.now .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.pqc-bars li.native .t{background:linear-gradient(180deg,#e6b45c,#c8880f);border-color:#9a6a08}\n.pqc-native{border-top:1px solid #ccd6e4;padding:8px 12px;display:flex;align-items:center;gap:9px;flex-wrap:wrap}\n.pqc-native-note{font-size:var(--text-micro);color:var(--slate-52);flex:1;min-width:14em}\n.pqc-bars .v{font-family:var(--font-mono);font-size:var(--text-micro);color:#4a5870;white-space:nowrap}\n.pqc-read{margin:0;padding:2px 12px 10px;font-size:var(--text-micro);color:var(--slate-52)}\n.pqc-live-body{padding:8px 10px;font-family:var(--font-mono);font-size:var(--text-micro);color:#33455f;word-break:break-all;line-height:1.6}\n.pqc-live-body b{font-family:var(--font-ui)}\n.pqc-retired{border-left:3px solid var(--blue-40);background:var(--mist-96);padding:8px 10px;margin:0 0 14px}\n.pqc-retired p{margin:0}\n.pqc-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.pqc-bars{grid-template-columns:max-content 1fr}.pqc-bars li{row-gap:2px}.pqc-bars .v{grid-column:2}}", "pageJs": "(function(){\n // Measured with @noble/post-quantum 0.6.1 on an M3 Max under Node 26.\n // sign is the median of 5 runs of 300 signatures over this site's real\n // signature base; ML-DSA is rejection-sampled, so it varies by design.\n var ROWS = [\n { n: \"Ed25519\", bytes: 88, sign: 0.05, now: true },\n { n: \"ML-DSA-44\", bytes: 3228, sign: 8.58 },\n // workerd 2026-09-26 behind webcrypto_modern_algorithms, same M3 Max,\n // 2026-10-01: median of 9 runs of 2,000 signatures, wall-clock per request\n // differenced against an empty one (a Worker's clock is frozen inside it).\n { n: \"ML-DSA-44 (workerd)\", bytes: 3228, sign: 0.12, native: true },\n { n: \"SLH-DSA-128s\", bytes: 10476, sign: 2650 },\n { n: \"SLH-DSA-128f\", bytes: 22784, sign: 132.34 }\n ];\n var NOTE = {\n bytes: \"Base64'd signature as it sits in the Signature header. Most servers cap total request headers near 8KB, which rules out both hash-based rows.\",\n sign: \"Log scale. The blue bars are single-threaded JavaScript; the amber one is the same ML-DSA-44 native in Cloudflare's runtime, 72x faster on the same M3 Max at the same 3,228 bytes.\"\n };\n var host = document.getElementById(\"pqc-demo\");\n if (!host) return;\n var list = host.querySelector(\".pqc-bars\"), read = host.querySelector(\".pqc-read\");\n var metric = \"bytes\";\n\n // Grouped by hand: the first Intl call in a renderer process loads ICU data,\n // about 10 ms of the first page of a visit (gotcha 92). Integers only.\n function commas(n) { return String(n).replace(/\\B(?=(\\d{3})+$)/g, \",\"); }\n\n function fmt(v) {\n if (metric === \"bytes\") return commas(v) + \" B\";\n // a native ML-DSA result can land well under 0.01ms, where two decimals\n // would round a real measurement down to \"0.00\".\n var s = v < 1 ? v.toPrecision(2) : v < 100 ? v.toFixed(1) : commas(Math.round(v));\n return s + \" ms\";\n }\n // bytes read honestly on a linear scale; sign times span microseconds to\n // seconds, where linear would collapse every fast row into nothing.\n function width(v, max) {\n if (metric === \"bytes\") return (v / max) * 100;\n // floor at 1us so a sub-microsecond result cannot produce a negative width\n var lo = Math.log10(Math.max(v, 0.001) * 1000);\n return Math.max(2, (lo / Math.log10(max * 1000)) * 100);\n }\n function render() {\n var max = ROWS.reduce(function (m, r) { return Math.max(m, r[metric]); }, 0);\n list.innerHTML = ROWS.map(function (r) {\n return '
        1. ' + r.n +\n '' + fmt(r[metric]) + \"
        2. \";\n }).join(\"\");\n read.textContent = NOTE[metric];\n }\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (b) {\n b.addEventListener(\"click\", function () {\n metric = b.getAttribute(\"data-metric\");\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (o) {\n o.classList.toggle(\"on\", o === b);\n });\n render();\n });\n });\n render();\n\n // Chrome puts ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc. If the\n // reader has it on, let their hardware answer \"how much of 8.58ms was the JIT?\"\n // rather than asserting a speedup this page never measured.\n var nativeBox = host.querySelector(\".pqc-native\");\n var BASE = new TextEncoder().encode(\n '\"@authority\": example.com\\n\"signature-agent\": \"https://aadhar.sh/\"\\n' +\n '\"@signature-params\": (\"@authority\" \"signature-agent\");created=1785186311;' +\n 'keyid=\"rn-mldsa-2026-07-27\";alg=\"ml-dsa-44\";tag=\"web-bot-auth\"');\n\n function show(msg) { nativeBox.hidden = false; nativeBox.innerHTML = msg; }\n\n crypto.subtle.generateKey({ name: \"ML-DSA-44\" }, true, [\"sign\", \"verify\"]).then(function (key) {\n show('' +\n 'Your browser has native ML-DSA. 200 signatures, on your hardware.');\n nativeBox.querySelector(\".pqc-run\").addEventListener(\"click\", function (e) {\n e.target.disabled = true;\n e.target.textContent = \"signing\\u2026\";\n // yield first so the button repaints before the main thread is busy\n setTimeout(function () {\n var alg = { name: \"ML-DSA-44\" }, warm = [];\n for (var i = 0; i < 20; i++) warm.push(crypto.subtle.sign(alg, key.privateKey, BASE));\n Promise.all(warm).then(function () {\n var t0 = performance.now(), runs = [];\n for (var j = 0; j < 200; j++) runs.push(crypto.subtle.sign(alg, key.privateKey, BASE));\n return Promise.all(runs).then(function (sigs) {\n var ms = (performance.now() - t0) / 200;\n ROWS.push({\n n: \"ML-DSA-44 (browser)\",\n bytes: Math.ceil(sigs[0].byteLength / 3) * 4,\n sign: ms, native: true,\n });\n metric = \"sign\";\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (o) {\n o.classList.toggle(\"on\", o.getAttribute(\"data-metric\") === \"sign\");\n });\n render();\n show('' + (ms < 0.1 ? ms.toPrecision(2) : ms.toFixed(2)) +\n \"ms per signature natively, versus 8.58ms in JavaScript: about \" +\n Math.round(8.58 / ms) + \"x. Same \" + commas(sigs[0].byteLength) +\n \" bytes on the wire, because that part is FIPS 204 and no implementation gets to improve it.\");\n });\n }).catch(function () { show('The native run failed partway.'); });\n }, 30);\n });\n }).catch(function () {\n show('Your browser has no native ML-DSA. Chrome hides it behind ' +\n 'chrome://flags/#webcrypto-pqc; turn it on and reload to benchmark the native ' +\n 'implementation against the JavaScript number above.');\n });\n\n // Read the site's own key directory so the page cannot drift from the key\n // the bot actually signs with.\n var live = document.querySelector(\"#pqc-live .pqc-live-body\");\n if (!live) return;\n fetch(\"/.well-known/http-message-signatures-directory\").then(function (r) {\n return r.json();\n }).then(function (dir) {\n var akp = (dir.keys || []).filter(function (k) { return k.kty === \"AKP\"; })[0];\n var ed = (dir.keys || []).filter(function (k) { return k.crv === \"Ed25519\"; })[0];\n if (!akp) {\n live.innerHTML = (ed ? \"\" + ed.kid + \" · kty \" + ed.crv + \" · 32 bytes, and the only key this bot signs with.
          \" : \"\") +\n \"No AKP key: the ML-DSA label was retired on 2026-08-15. See below.\";\n return;\n }\n var raw = atob(akp.pub.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n live.innerHTML =\n \"\" + akp.kid + \" · kty \" + akp.kty + \" · alg \" + akp.alg +\n \" · \" + commas(raw.length) + \" bytes of public key\" +\n (ed ? \"
          \" + ed.kid + \" · kty \" + ed.crv + \" · 32 bytes, and the one your verifier should use\" : \"\") +\n \"
          \" + akp.pub.slice(0, 96) + \"… (\" +\n commas(akp.pub.length) + \" base64url chars)\";\n }).catch(function () {\n live.textContent = \"Could not read the key directory from here.\";\n });\n})();", "editorial": { diff --git a/pipelines/garage/specs/resample.json b/pipelines/garage/specs/resample.json index 6d4080695..c5e919469 100644 --- a/pipelines/garage/specs/resample.json +++ b/pipelines/garage/specs/resample.json @@ -4,7 +4,7 @@ "description": "A one-pixel checkerboard should shrink to sRGB 188, and every resizer says 128, your browser's canvas included. Why, plus five defects measurement missed.", "status": "shipped", "added": "2026-08-26", - "bodyHtml": "

          Half the light

          \n

          A one pixel black and white checkerboard is exactly half black and half white, so any honest downscale has to average it. The right answer is sRGB 188, because the average is over light. Every resizer I measured returns about 128: sips, ffmpeg, the Rust image crate, fast_image_resize, Bun.Image, and the canvas in whatever browser is rendering this sentence. Their kernels agree with each other to seven decimal places. All of them average in the wrong space, which Eric Brasseur wrote up in 2007 and the defaults have outlived.

          \n\n

          Your browser is doing it right now

          \n

          This runs in your machine, on a checkerboard built a moment ago. The left square is what drawImage produces when it shrinks that checkerboard 16 times. The right square is the same reduction with the samples converted to light first.

          \n
          enable JavaScript to run the measurement
          \n

          The two grey squares should be visibly different, and the numbers under them say by how much. Your browser is behaving normally: the left square is what almost every image pipeline shipped this decade does.

          \n\n

          Why 128 is the wrong grey

          \n

          sRGB encodes perceived brightness rather than light. The code 128 sits near the middle of what your eye does with the range, and it carries about 21% of the light of code 255. So averaging the codes of black and white gives a grey with a fifth of the light of the pair it replaced, and the picture darkens everywhere fine detail used to be.

          \n

          Textures are where this lands hardest, because texture is exactly where neighbouring pixels differ most. A photograph reduced this way loses contrast in foliage, fabric, hair and grain, and gains it nowhere.

          \n\n

          The resampler, with every operational concern taken back out

          \n

          Here is the whole idea at an integer reduction factor. It is the load-bearing version, and the shipping one is a few hundred lines around this shape.

          \n
          function reduce(src, w, factor) {\n  const out = [], n = factor * factor;\n  for (let y = 0; y < w / factor; y++)\n    for (let x = 0; x < w / factor; x++) {\n      let sum = 0;\n      for (let dy = 0; dy < factor; dy++)\n        for (let dx = 0; dx < factor; dx++)\n          sum += toLinear(src[(y * factor + dy) * w + x * factor + dx]);\n      out.push(toSrgb(sum / n));\n    }\n  return out;\n}
          \n

          Convert to light, average, convert back. Everything the real implementation adds is a separate concern wearing this function's clothes, and each one earns its place for a reason worth naming.

          \n

          Arbitrary ratios need weights. An integer factor lets every source sample count once. A 3.86 times reduction does not, so each output sample carries a list of taps and weights instead of a rectangle.

          \n

          The filter has to widen with the reduction. On a downscale the filter must low pass at the output Nyquist, so its support in source pixels grows by the reduction factor. A kernel evaluated at fixed width is sampling rather than filtering, and it aliases. This is why naive Lanczos3 downscales shimmer.

          \n

          Weights are renormalised per output sample. At the frame edge the support window is clipped, and the weights that survive have to sum to one again or the border darkens into a vignette nobody can explain.

          \n

          The two axes run separately. A 2D filter of this family factors into two 1D passes, which turns work per output pixel from support squared into support: about 13 taps instead of 44 at the reduction this site uses.

          \n

          The forward transfer is a table. It takes an 8 bit value, so it has exactly 256 answers and does not need a powf 119 million times. That one change cut the resample core from 42.9 ms to 22.9 ms with byte identical output.

          \n\n

          Six wrong answers, and the control that caught each one

          \n

          The gamma defect is the easy part of this story. The hard part is that it survived years of measurement, and so did five other things, because in each case the instrument could not see the defect it was pointed at.

          \n
          \n

          The pattern that connects them: a measurement that compares a thing against itself always agrees. Scoring an encode against the reference it came from cannot see a bad reference. Resizing to the size you already are cannot test a kernel that short circuits equal dimensions. Reading a metric you invented cannot tell you the metric is insensitive.

          \n

          What worked every time was cheaper than it sounds. Build the control that would fail if the thing were broken, then break the thing on purpose and watch the control go red. The regression test for the rotation fix was written that way, and so was the probe column that finally caught sips leaving noise on a flat field.

          \n\n

          What the correct version costs

          \n

          Correct pixels compress worse, because the gamma incorrect average was quietly destroying high frequency energy that a correct one keeps. Regenerating this site's 660 thumbnail files moved them 15164 KB to 15392 KB, which is 1.5%.

          \n

          Against a ground truth downscale, delivered quality on real photographs moved from 57.2 to 84.8 as a mean over 10 frames. On the Instagram export the same change let three Leica frames beat their old versions by about 4 ssimulacra2 points while landing 43% smaller, because the search could stop reaching for quality it was spending on aliasing.

          \n

          Speed is a fair question, and the first answer this page gave was wrong. It said the whole gamma correct job here beat fast_image_resize's own gamma correct path, 70 ms against 60.3. The 70 was this kernel on Box and the 60.3 was theirs on Lanczos3, which is a comparison between two filters wearing the costume of a comparison between two libraries. Measured apples to apples on 2026-09-02, one process per cell and best of five, the same 5952 by 3968 frame to 900 by 600, whole 8 bit job against their sRGB mapper into 16 bit linear and back: Box 43.9 ms against 37.5, Lanczos3 123.6 against 63.0. Theirs is ahead by 1.2 times and 2 times. The kernels themselves are at parity when both are fed linear f32, 23.1 against 19.6 on Box and 99.6 against 102.0 on Lanczos3, agreeing to within f32 rounding. What wins them the whole job is that a 16 bit lane is half an f32 lane in SIMD, and that this transfer step is scalar. Their gamma wrong default, at 8.4 and 28.6 ms, is faster still. So the honest sentence is the one the old paragraph ended on: the gap is the price of the transfer function, and the price is theirs to skip and mine to pay.

          \n\n

          Offered upstream

          \n

          Two of these were somebody else's to fix, and the two went differently. image-rs/image#3101 proposed an opt in linear light resize, with measurements aimed at the objection that converting to a 16 bit linear space is too expensive to do transparently. It was closed the same day, with one reply pointing at the project's policy that issue comments must be human written. That is a process answer, and it is theirs to give. The kernel and its property tests ship on their own instead, as halflight, a crate and an npm package whose conformance suite is where the corrected speed numbers above come from. oven-sh/bun#40510 reported the same defect in Bun.Image with a self contained repro, where the argument was different: a runtime whose image API shipped this year has no back compatible golden images anchoring it to the wrong default. It was reproduced upstream, and the fix in flight adds an opt in linear colourspace option and leaves the default alone. Changing the default was the one outcome the argument could not reach.

          \n\n

          What would change my mind

          \n

          The byte cost is real and it is the honest objection. If a wider corpus showed the 1.5% growing into double digits at some other reduction ratio or subject matter, the right answer would be to spend the difference on the quality knob instead and take a smaller correctness win. The measurement that would settle it is a matched bytes comparison across a few hundred frames from several cameras, and this site has 165 frames from two.

          \n

          The other open edge is the transfer curve itself. This pipeline assumes sRGB except where a file declares Gray Gamma 2.2, which covers its two cameras and would be wrong for anything shot in Display P3 or a log profile. Reading the curve from the ICC profile rather than from a filename pattern is the fix, and it is not written yet.

          ", + "bodyHtml": "

          Half the light

          \n

          A one pixel black and white checkerboard is exactly half black and half white, so any honest downscale has to average it. The right answer is sRGB 188, because the average is over light. Every resizer I measured returns about 128: sips, ffmpeg, the Rust image crate, fast_image_resize, Bun.Image, and the canvas in whatever browser is rendering this sentence. Their kernels agree with each other to seven decimal places. All of them average in the wrong space, which Eric Brasseur wrote up in 2007 and the defaults have outlived.

          \n\n

          Your browser is doing it right now

          \n

          This runs in your machine, on a checkerboard built a moment ago. The left square is what drawImage produces when it shrinks that checkerboard 16 times. The right square is the same reduction with the samples converted to light first.

          \n
          enable JavaScript to run the measurement
          \n

          The two grey squares should be visibly different, and the numbers under them say by how much. Your browser is behaving normally: the left square is what almost every image pipeline shipped this decade does.

          \n\n

          Why 128 is the wrong grey

          \n

          sRGB encodes perceived brightness rather than light. The code 128 sits near the middle of what your eye does with the range, and it carries about 21% of the light of code 255. So averaging the codes of black and white gives a grey with a fifth of the light of the pair it replaced, and the picture darkens everywhere fine detail used to be.

          \n

          Textures are where this lands hardest, because texture is exactly where neighbouring pixels differ most. A photograph reduced this way loses contrast in foliage, fabric, hair and grain, and gains it nowhere.

          \n\n

          The resampler, with every operational concern taken back out

          \n

          Here is the resampler at an integer reduction factor. This version carries the meaning, and the shipping one is a few hundred lines around this shape.

          \n
          function reduce(src, w, factor) {\n  const out = [], n = factor * factor;\n  for (let y = 0; y < w / factor; y++)\n    for (let x = 0; x < w / factor; x++) {\n      let sum = 0;\n      for (let dy = 0; dy < factor; dy++)\n        for (let dx = 0; dx < factor; dx++)\n          sum += toLinear(src[(y * factor + dy) * w + x * factor + dx]);\n      out.push(toSrgb(sum / n));\n    }\n  return out;\n}
          \n

          Convert to light, average, convert back. Everything the real implementation adds is a separate concern wearing this function's clothes, and each one is below with the reason it's there.

          \n

          Arbitrary ratios need weights. An integer factor lets every source sample count once. A 3.86 times reduction does not, so each output sample carries a list of taps and weights instead of a rectangle.

          \n

          The filter has to widen with the reduction. On a downscale the filter must low pass at the output Nyquist, so its support in source pixels grows by the reduction factor. A kernel evaluated at fixed width is sampling rather than filtering, and it aliases. This is why naive Lanczos3 downscales shimmer.

          \n

          Weights are renormalised per output sample. At the frame edge the support window is clipped, and the weights that survive have to sum to one again or the border darkens into a vignette nobody can explain.

          \n

          The two axes run separately. A 2D filter of this family factors into two 1D passes, which turns work per output pixel from support squared into support: about 13 taps instead of 44 at the reduction this site uses.

          \n

          The forward transfer is a table. It takes an 8 bit value, so it has exactly 256 answers and does not need a powf 119 million times. That one change cut the resample core from 42.9 ms to 22.9 ms with byte identical output.

          \n\n

          Six wrong answers, and the control that caught each one

          \n

          The gamma defect is the easy part of this story. The hard part is that it survived years of measurement, and so did five other things, because in each case the instrument could not see the defect it was pointed at.

          \n
          \n

          The pattern that connects them: a measurement that compares a thing against itself always agrees. Scoring an encode against the reference it came from cannot see a bad reference. Resizing to the size you already are cannot test a kernel that short circuits equal dimensions. Reading a metric you invented cannot tell you the metric is insensitive.

          \n

          What worked every time was cheaper than it sounds. Build the control that would fail if the thing were broken, then break the thing on purpose and watch the control go red. The regression test for the rotation fix was written that way, and so was the probe column that finally caught sips leaving noise on a flat field.

          \n\n

          What the correct version costs

          \n

          Correct pixels compress worse, because the gamma incorrect average was quietly destroying high frequency energy that a correct one keeps. Regenerating this site's 660 thumbnail files moved them 15164 KB to 15392 KB, which is 1.5%.

          \n

          Against a ground truth downscale, delivered quality on real photographs moved from 57.2 to 84.8 as a mean over 10 frames. On the Instagram export the same change let three Leica frames beat their old versions by about 4 ssimulacra2 points while landing 43% smaller, because the search could stop reaching for quality it was spending on aliasing.

          \n

          Speed is a fair question, and the first answer this page gave was wrong. It said the whole gamma correct job here beat fast_image_resize's own gamma correct path, 70 ms against 60.3. The 70 was this kernel on Box and the 60.3 was theirs on Lanczos3, which is a comparison between two filters wearing the costume of a comparison between two libraries. Measured apples to apples on 2026-09-02, one process per cell and best of five, the same 5952 by 3968 frame to 900 by 600, whole 8 bit job against their sRGB mapper into 16 bit linear and back: Box 43.9 ms against 37.5, Lanczos3 123.6 against 63.0. Theirs is ahead by 1.2 times and 2 times. The kernels themselves are at parity when both are fed linear f32, 23.1 against 19.6 on Box and 99.6 against 102.0 on Lanczos3, agreeing to within f32 rounding. What wins them the whole job is that a 16 bit lane is half an f32 lane in SIMD, and that this transfer step is scalar. Their gamma wrong default, at 8.4 and 28.6 ms, is faster still. So the honest sentence is the one the old paragraph ended on: the gap is the price of the transfer function, and the price is theirs to skip and mine to pay.

          \n\n

          Offered upstream

          \n

          Two of these were somebody else's to fix, and the two went differently. image-rs/image#3101 proposed an opt in linear light resize, with measurements aimed at the objection that converting to a 16 bit linear space is too expensive to do transparently. It was closed the same day, with one reply pointing at the project's policy that issue comments must be human written. That is a process answer, and it is theirs to give. The kernel and its property tests ship on their own instead, as halflight, a crate and an npm package whose conformance suite is where the corrected speed numbers above come from. oven-sh/bun#40510 reported the same defect in Bun.Image with a self contained repro, where the argument was different: a runtime whose image API shipped this year has no back compatible golden images anchoring it to the wrong default. It was reproduced upstream, and the fix in flight adds an opt in linear colourspace option and leaves the default alone. Changing the default was the one outcome the argument could not reach.

          \n\n

          What would change my mind

          \n

          The byte cost is real and it is the honest objection. If a wider corpus showed the 1.5% growing into double digits at some other reduction ratio or subject matter, the right answer would be to spend the difference on the quality knob instead and take a smaller correctness win. The measurement that would settle it is a matched bytes comparison across a few hundred frames from several cameras, and this site has 165 frames from two.

          \n

          The other open edge is the transfer curve itself. This pipeline assumes sRGB except where a file declares Gray Gamma 2.2, which covers its two cameras and would be wrong for anything shot in Display P3 or a log profile. Reading the curve from the ICC profile rather than from a filename pattern is the fix, and it is not written yet.

          ", "pageCss": ".hl-demo{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;padding:12px;margin:10px 0;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}\n.hl-demo .cell{text-align:center;font-size:var(--text-micro)}\n.hl-demo .sw{width:104px;height:104px;border:1px solid #7a8ba3;display:block;margin:0 0 5px}\n.hl-demo .n{font-family:var(--font-mono);font-size:var(--text-lede);font-weight:700}\n.hl-demo .lbl{color:var(--slate-40)}\n.hl-code{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:var(--text-micro);margin:8px 0}\n.hl-table{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:2px 0}\n.hl-table .row{display:grid;grid-template-columns:11em 10em 1fr;gap:10px;align-items:baseline;padding:4px 10px;font-size:var(--text-micro);border-bottom:1px solid var(--surface-desktop)}\n.hl-table .row:last-child{border-bottom:0}\n.hl-table .row.head{font-weight:700;background:var(--surface-desktop)}\n@media(max-width:560px){.hl-table .row{grid-template-columns:1fr}}", "pageJs": "(function(){\n // The checkerboard is BUILT here rather than shipped as an image, so the\n // measurement is of the reader's own browser and cannot be a picture of\n // someone else's result.\n var el = document.getElementById(\"hl-demo\");\n if (el) {\n var N = 256, F = 16, S = N / F;\n var lin = function(c){ var s = c / 255; return s <= 0.04044993 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };\n var enc = function(l){ var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055; return Math.round(s * 255); };\n var a = document.createElement(\"canvas\"); a.width = a.height = N;\n var ax = a.getContext(\"2d\", { willReadFrequently: true });\n var im = ax.createImageData(N, N);\n for (var y = 0; y < N; y++) for (var x = 0; x < N; x++) {\n var v = ((x + y) & 1) ? 255 : 0, i = (y * N + x) * 4;\n im.data[i] = im.data[i + 1] = im.data[i + 2] = v; im.data[i + 3] = 255;\n }\n ax.putImageData(im, 0, 0);\n var b = document.createElement(\"canvas\"); b.width = b.height = S;\n var bx = b.getContext(\"2d\", { willReadFrequently: true });\n bx.imageSmoothingEnabled = true; bx.imageSmoothingQuality = \"high\";\n bx.drawImage(a, 0, 0, S, S);\n var bd = bx.getImageData(0, 0, S, S).data, cs = 0, cn = 0;\n for (var p = 0; p < bd.length; p += 4) { cs += bd[p]; cn++; }\n var canvasMean = cs / cn;\n var ls = 0;\n for (var oy = 0; oy < S; oy++) for (var ox = 0; ox < S; ox++) {\n var sum = 0;\n for (var dy = 0; dy < F; dy++) for (var dx = 0; dx < F; dx++)\n sum += lin(im.data[(((oy * F + dy) * N) + (ox * F + dx)) * 4]);\n ls += enc(sum / (F * F));\n }\n var linMean = ls / (S * S);\n var cell = function(v, label) {\n var g = Math.round(v);\n return '
          ' +\n '' + v.toFixed(2) + '
          ' + label + '
          ';\n };\n el.innerHTML = cell(canvasMean, \"canvas drawImage\") + cell(linMean, \"averaged over light\") +\n '
          The correct answer is 187.5. ' +\n 'Your browser reports ' + canvasMean.toFixed(2) + ', which is the average of the codes rather than of the light.
          ';\n }\n\n // The six rows are the page's argument, so they live in one place and render\n // rather than being restated in prose where the copies could drift.\n var t = document.getElementById(\"hl-table\");\n if (!t) return;\n var rows = [\n [\"the shipped resize\", \"score against its own reference\", \"a defective reference scores itself perfectly\"],\n [\"the quality target\", \"the same ssimulacra2 number\", \"a clean reference is easier to match, so 70 bought a worse file\"],\n [\"dropping a resize step\", \"mean linear luminance\", \"insensitive here; ssimulacra2 against ground truth said +27.5\"],\n [\"jpegtran rotation\", \"the exit code\", \"off the block grid it degrades and still exits 0\"],\n [\"a claim about image-rs\", \"resize to the current size\", \"both entry points short circuit, so the kernel never ran\"],\n [\"a container contract test\", \"the first 12 files on disk\", \"readdir order differs by filesystem, so CI and the laptop sampled different files\"]\n ];\n var html = '
          what looked finethe instrumentwhat it could not see
          ';\n for (var i = 0; i < rows.length; i++)\n html += '
          ' + rows[i][0] + '' + rows[i][1] + '' + rows[i][2] + '
          ';\n t.innerHTML = html;\n})();", "editorial": { diff --git a/pipelines/garage/specs/typed-config.json b/pipelines/garage/specs/typed-config.json index 464bc9f7f..6ed195e3e 100644 --- a/pipelines/garage/specs/typed-config.json +++ b/pipelines/garage/specs/typed-config.json @@ -4,7 +4,7 @@ "description": "Wrangler's TypeScript config, re-measured on wrangler 4.147.0 and cf: a byte-identical upload and a Durable Object type that now threads across Workers.", "status": "watching", "added": "2026-08-24", - "bodyHtml": "

          The config that types itself

          \n

          A Worker config is data, so every binding name in it is a string that nothing checks. Wrangler can load a config that is a TypeScript program instead, behind a flag it still hides, and Cloudflare's new cf CLI loads the same file with no flag at all. Both infer the Worker's Env from it. This page measured that twice, in August and again on 2026-10-05. The upload still comes out byte-identical. The Durable Object type still degrades for a Worker that binds its own class, and now carries the class through when the binding names a second Worker. And one August claim was wrong: wrangler types already wrote a plain-text variable's value into the type.

          \n\n

          Two runs, two toolchains

          \n

          The first run, on 2026-08-24, used wrangler 4.125.0 with @cloudflare/config 0.7.0. The second, on 2026-10-05, used wrangler 4.147.0 (the pkg.pr.new build at f025bbf this site pins), @cloudflare/config 0.23.0, cf 1.0.0-beta.12, TypeScript 7.0.2 and Node 26.10.0. Config 0.23.0 is what cf installs today, so it is what a reader gets. Every row was cross-checked on 0.20.0, this site's own pin, and came back the same except where a row says otherwise.

          \n

          Where the two runs disagree, the page says so in place and keeps the August result beside the October one.

          \n\n

          The thing a config cannot do

          \n

          Here is the whole problem in one line. In August this site's Worker declared 21 bindings across two config files, and its request handler typed them like this:

          \n
          @typedef {(request: Request, env: any, ctx: any, url: URL) => Response} RouteHandler
          \n

          any. Every KV namespace, every D1 database, the R2 bucket, the Durable Object, all of it. Rename a binding in wrangler.jsonc and nothing in the tree goes red; the failure arrives at runtime as a property read on undefined, in production, on whichever route touched it first.

          \n

          That is a property of the format. JSON cannot export a type. The usual repair is wrangler types, which reads the config and writes a .d.ts, and it works: it produces the full interface, Durable Object generics and all. On 4.125.0 and on 4.147.0 alike, it also prints this when it finishes.

          \n
          Remember to rerun 'wrangler types' after you change your wrangler.jsonc file.
          \n

          A generated file plus a reminder is a snapshot plus a promise. The snapshot is correct on the day it is taken and silently wrong afterwards, which is the same shape as every committed artifact this site has replaced with a build step.

          \n\n

          What the flag is, and the door beside it

          \n

          Wrangler loads a cloudflare.config.ts, and an optional wrangler.config.ts beside it, behind --x-new-config. On 4.147.0 the flag is still marked hidden: true, and it appears in the help of none of build, deploy, dev, types or versions upload. The package still describes itself as \"not yet stable enough for external use\".

          \n

          Two things moved around the flag since August. Cloudflare now documents the file as an open beta, in a page last updated 2026-09-30, where in August a search found no documentation at all. And the documented door is cf, which imports the helpers from cf/config and, for a JavaScript Worker, hands the build to wrangler. On 2026-10-02 a wrangler maintainer answered workers-sdk#16002 with \"we don't intend to support cloudflare.config.ts in more Wrangler commands\" and recommended cf. So the flag keeps working where it works today, and new commands for this format arrive in cf.

          \n

          The two filenames are the reverse of the guess. cloudflare.config.ts holds the Worker: name, compatibility date, entrypoint, triggers, bindings, exports, observability, placement, limits. wrangler.config.ts holds the tooling: the build command, the assets directory, dev settings, tsconfig, source maps. Put a Worker field in the tooling file and the error names the mistake and the fix, on 4.147.0 as on 4.125.0:

          \n
          compatibilityDate is not a supported field in wrangler.config.ts. Move it to cloudflare.config.ts.
          \n\n

          Build a config and watch three things move

          \n

          Toggle bindings on the left. The middle pane is the config you would write, in the shape that builds today. The right pane switches between what the platform receives and what your env becomes. Every row below was measured through a real Worker on 2026-10-05: the resolved JSON is what wrangler build --x-new-config --x-cf-build-output wrote, and each type is what the compiler reported for that key of the generated interface.

          \n
          \n
          \n
          \n
          bindings
          \n
          \n
          exports
          \n
          \n
          \n
          \n
          cloudflare.config.tswhat you write
          \n
          \n
          \n
          \n
          \n \n \n \n \n
          \n
          \n
          \n
          \n

          The resolved pane is the Build Output Specification wrangler writes to .cloudflare/output/v0/workers/default/worker.config.json, which is the readable answer to \"what did my config actually become\". In August that file was config.json and opened with \"type\": \"worker\"; today it drops that key and ends with a manifest. The Env pane is what tsc reported for each key of the generated interface.

          \n
          \n

          The two front doors agree. cf build (1.0.0-beta.12) printed \"Delegating to Wrangler\" and wrote the same four files wrangler build did, sha256 for sha256. The snippet imports from @cloudflare/config/public, as this site's own cloudflare.config.ts does. The documentation's cf/config re-exports that module in one line, so either import works, and the resolved output is identical.

          \n

          One trap sits between the build and the types. The generated .cloudflare/types/index.d.ts imports cf/config. Install wrangler and @cloudflare/config without cf, keep skipLibCheck on as most templates do, and nothing complains: Env comes back empty, and every binding read is TS2339 on a value typed any. Only with skipLibCheck off does TS2307 name the missing module. Add cf as a dev dependency, as the docs say, or map cf/config in paths, which is what this site does.

          \n\n

          A variable arrives in the type as its own value, in both formats

          \n

          Thirteen binding kinds went through the compiler. Ten land where you would guess, and three carry more than a bare type.

          \n
          \n

          bindings.text(\"default\") gives env.AI_GATEWAY the type \"default\", so the variable's value is in the type and a comparison against a string that was never configured is a compile error. bindings.json({ dcz: true, tier: 2 }) comes back as { dcz: true; tier: number }, where the boolean narrows to a literal and the number widens, which is ordinary TypeScript inference showing through. And bindings.secret() types the secret as string, so a secret you forgot to set is a name the compiler already knows about.

          \n

          This section used to say a JSON config structurally cannot do this. That was wrong, and it was already wrong on 4.125.0. Run against an equivalent wrangler.jsonc, wrangler types writes AI_GATEWAY: \"default\" on both 4.125.0 and 4.147.0. It types the required secret as string too, and it narrows the JSON variable further than inference does, to {\"dcz\":true,\"tier\":2}. What the TypeScript config changes is when the type is computed. wrangler types takes a snapshot that the reminder above asks you to retake. The inferred Env reads the config every time the compiler runs, so a renamed binding is a type error with no regeneration step.

          \n

          One row is new since August. bindings.analyticsSQL(), added in config 0.22.0, resolves to {\"type\":\"analytics\"} and types as AnalyticsSQLBinding. Wrangler 4.147.0 builds it. cf 1.0.0-beta.5, which carries config 0.20.0, lets wrangler write the build output and then refuses that output on its own schema check, with \"Invalid discriminator value\", and exits 1. Beta.12 accepts it.

          \n\n

          The row that went backwards, and the way around it

          \n

          The Durable Object type is where the new format lost in August, and the October run found a way around it that works for some Workers and leaves the rest where they were. Against a JSON config, wrangler types threads the class through, on 4.147.0 as on 4.125.0:

          \n
          COUNTER: DurableObjectNamespace<import(\"./src/index\").Counter>;\nBOOKING: Workflow<Parameters<import(\"./src/index\").Booking['run']>[0]['payload']>;
          \n

          Under inference, a binding that names its Worker by string still comes back as DurableObjectNamespace<undefined>, so a stub method call fails with Property 'increment' does not exist on type 'DurableObjectStub<undefined>'. That holds with the entrypoint given as a path string and with it given as a module through the { type: \"cf-worker\" } import attribute, the same two forms August tried.

          \n

          What changed is the binding helper. Its worker option (spelled workerName in August) now takes either a name or a Worker definition. Bind a class that a second Worker exports, pass that Worker's definition, and give that definition a module entrypoint:

          \n
          import * as counterModule from \"./src/index.ts\" with { type: \"cf-worker\" };\n\nexport const counterWorker = defineWorker({\n  name: \"probe-counter\",\n  entrypoint: counterModule,\n  exports: { Counter: exports.durableObject({ storage: \"sqlite\" }) },\n});\n\n// in the binding Worker's env:\nCOUNTER: bindings.durableObject({ worker: counterWorker, exportName: \"Counter\" }),
          \n

          Now env.COUNTER types as DurableObjectNamespace<Counter>, and await stub.increment(1) is a number the compiler checks. A Workflow bound the same way types as Workflow<{ slot: string }>. The resolved JSON is the same as the string form, \"worker\": \"probe-counter\", so the definition buys a type and changes nothing the platform receives.

          \n

          Two controls say which half does the work. The same definition with a path-string entrypoint gives DurableObjectNamespace<DurableObjectBranded>, which still has no methods, so the module import is load-bearing. And a Worker that binds its own class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to never. The build also never type-checks the config. A Workflow binding whose exportName the definition did not declare was a compile error, and wrangler build still exited 0.

          \n

          So the scoreboard moved. Cross-Worker Durable Objects and Workflows now type as precisely as wrangler types types them, and stay current without a regeneration step. A Worker that binds its own Durable Object, the common single-Worker case, still types worse than the snapshot does.

          \n\n

          Migrations become a state on an export

          \n

          The cumulative migration list is gone. A class declares what it is on its own export, and the storage engine sits beside it:

          \n
          \n \n \n \n \n
          wrangler.tomlcloudflare.config.ts
          [[migrations]] tag = \"v1\"
          new_sqlite_classes = [\"Counter\"]
          Counter: exports.durableObject({ storage: \"sqlite\" })
          deleted_classes = [\"Old\"]Old: exports.durableObject({ state: \"deleted\" })
          renamed_classes = [{ from, to }]Old: exports.durableObject({ state: \"renamed\", renamedTo: \"New\" })
          \n

          This is a different upload path. Wrangler 4.147.0 still decides between them in one function, resolveDoLifecyclePayload: when a config declares Durable Object exports it sends those exports and sends no migrations at all, and it computes a migration only for configs that declare none.

          \n

          In August that left one question a dry run structurally cannot answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin b168333) against a Counter created under tag = \"v1\", the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers wrangler deploy. It says nothing about versions upload, which refuses Durable Object lifecycle changes on its own path.

          \n\n

          One thing it still will not do, and one it now does

          \n

          The dependency instrumentation key still has no home. The old config set [dependencies_instrumentation] enabled = true, and config 0.23.0 has no field for it. The escape hatch offers no route around it, because unsafe carries metadata and capnp schemas alone. This cost nothing, for a reason only readable in wrangler's source: 4.147.0 still tests the key as enabled !== false, so an absent block behaves exactly like the explicit true. A dry run reports on none of this, which is why the answer came from reading the upload path.

          \n

          The types file now comes from a build. wrangler types --x-new-config still fails, with \"Unknown arguments: x-new-config, xNewConfig\". In August the generated file came only from wrangler dev, so the inferred types existed on a machine somebody had run the dev server on. On 4.147.0, wrangler build --x-new-config --x-cf-build-output writes it too, and so do cf build and cf workers types, which wrote the same 622,861 bytes. That is the build step this page asked for in August.

          \n\n

          The bun trap, and why it is a trap

          \n

          The loader refuses bun, in a message that names itself, and on 4.147.0 a second warning follows it:

          \n
          cloudflare.config.ts loading is not supported on Bun. Please use Node.js v22.18.0 or higher.\nWrangler does not support the Bun runtime. Please try this command again using Node.js via `npm` or `pnpm`.
          \n

          On a repository whose whole toolchain is bun, that reads as a blocker for about a minute. It is a claim about the process that ends up running wrangler, whichever package manager launched it. Re-run on bun 1.4.3-canary.1:

          \n
          \n \n \n \n \n \n
          invocationresult
          bun x --no-install wrangler … --x-new-configloads, builds
          bun run <script>, resolving the same shimloads
          bun run --bun <script>refused
          bun ./node_modules/wrangler/bin/wrangler.js …refused
          \n

          The first two resolve node_modules/.bin/wrangler, whose #!/usr/bin/env node shebang hands the process to node before any config is read. The last two put bun in front of the loader, one by overriding the shebang and one by running wrangler's entry file directly. So \"does this tool support bun\" turned out to be a question about a shebang, and the answer changed with the invocation while the tool stayed the same.

          \n\n

          What this cost, and what it did not

          \n

          The conversion ran first on this site's smallest deployed Worker, the one behind /garage/cf/*, chosen because it is a demo that a person deploys by hand. In August the upload came out at 8.83 KiB, 3.07 KiB gzip, the same as before to the byte, with the same four bindings. Re-measured on 4.147.0 against the old wrangler.toml with its flags and minify setting brought level, both configs produce the same index.js, sha256 for sha256, at 5.94 KiB, 2.63 KiB gzip. The source maps differ only in their output-relative paths.

          \n

          In August the main Worker still typed env as any, because converting it meant folding two config files of 24,662 and 10,987 bytes into one program and putting a hidden flag on the path that publishes production. It moved on 2026-09-28, with an upload byte-identical to the JSON's. Its handlers take env: Env now, from a hand-written interface that a check diffs against cloudflare.config.ts in both directions. That choice is about optional secrets, which the interface marks with ? so every read has to face their absence, and about the Durable Object: its Counter lives in a second Worker that still has a wrangler.jsonc, so there is no definition to import yet.

          \n\n

          What would change the verdict

          \n

          Two of August's three triggers have fired. The API accepted the export-based Durable Object form on a real deploy, so the migration table is a translation. Documentation arrived, though the flag stayed hidden and the documented door became cf.

          \n

          The third half-fired: the class type now threads through a binding to another Worker. What would finish it is a self-bound Durable Object that types as its class, which would remove the last column where wrangler types wins. The other result worth watching is the format leaving open beta, since several shapes on this page moved between the two runs: the import path, the default export, the Durable Object binding key and the build output file.

          ", + "bodyHtml": "

          The config that types itself

          \n

          A Worker config is data, so every binding name in it is a string that nothing checks. Wrangler can load a config that is a TypeScript program instead, behind a flag it still hides, and Cloudflare's new cf CLI loads the same file with no flag at all. Both infer the Worker's Env from it. This page measured that twice, in August and again on 2026-10-05. The upload still comes out byte-identical. The Durable Object type still degrades for a Worker that binds its own class, and now carries the class through when the binding names a second Worker. And one August claim was wrong: wrangler types already wrote a plain-text variable's value into the type.

          \n\n

          Two runs, two toolchains

          \n

          The first run, on 2026-08-24, used wrangler 4.125.0 with @cloudflare/config 0.7.0. The second, on 2026-10-05, used wrangler 4.147.0 (the pkg.pr.new build at f025bbf this site pins), @cloudflare/config 0.23.0, cf 1.0.0-beta.12, TypeScript 7.0.2 and Node 26.10.0. Config 0.23.0 is what cf installs today, so it is what a reader gets. Every row was cross-checked on 0.20.0, this site's own pin, and came back the same except where a row says otherwise.

          \n

          Where the two runs disagree, the page says so in place and keeps the August result beside the October one.

          \n\n

          The thing a config cannot do

          \n

          The problem fits in one line. In August this site's Worker declared 21 bindings across two config files, and its request handler typed them like this:

          \n
          @typedef {(request: Request, env: any, ctx: any, url: URL) => Response} RouteHandler
          \n

          any. Every KV namespace, every D1 database, the R2 bucket, the Durable Object, all of it. Rename a binding in wrangler.jsonc and nothing in the tree goes red; the failure arrives at runtime as a property read on undefined, in production, on whichever route touched it first.

          \n

          That is a property of the format. JSON cannot export a type. The usual repair is wrangler types, which reads the config and writes a .d.ts, and it works: it produces the full interface, Durable Object generics and all. On 4.125.0 and on 4.147.0 alike, it also prints this when it finishes.

          \n
          Remember to rerun 'wrangler types' after you change your wrangler.jsonc file.
          \n

          A generated file plus a reminder is a snapshot plus a promise. The snapshot is correct on the day it is taken and silently wrong afterwards, which is the same shape as every committed artifact this site has replaced with a build step.

          \n\n

          What the flag is, and the door beside it

          \n

          Wrangler loads a cloudflare.config.ts, and an optional wrangler.config.ts beside it, behind --x-new-config. On 4.147.0 the flag is still marked hidden: true, and it appears in the help of none of build, deploy, dev, types or versions upload. The package still describes itself as \"not yet stable enough for external use\".

          \n

          Two things moved around the flag since August. Cloudflare now documents the file as an open beta, in a page last updated 2026-09-30, where in August a search found no documentation at all. And the documented door is cf, which imports the helpers from cf/config and, for a JavaScript Worker, hands the build to wrangler. On 2026-10-02 a wrangler maintainer answered workers-sdk#16002 with \"we don't intend to support cloudflare.config.ts in more Wrangler commands\" and recommended cf. So the flag keeps working where it works today, and new commands for this format arrive in cf.

          \n

          The two filenames are the reverse of the guess. cloudflare.config.ts holds the Worker: name, compatibility date, entrypoint, triggers, bindings, exports, observability, placement, limits. wrangler.config.ts holds the tooling: the build command, the assets directory, dev settings, tsconfig, source maps. Put a Worker field in the tooling file and the error names the mistake and the fix, on 4.147.0 as on 4.125.0:

          \n
          compatibilityDate is not a supported field in wrangler.config.ts. Move it to cloudflare.config.ts.
          \n\n

          Build a config and watch three things move

          \n

          Toggle bindings on the left. The middle pane is the config you would write, in the shape that builds today. The right pane switches between what the platform receives and what your env becomes. Every row below was measured through a real Worker on 2026-10-05: the resolved JSON is what wrangler build --x-new-config --x-cf-build-output wrote, and each type is what the compiler reported for that key of the generated interface.

          \n
          \n
          \n
          \n
          bindings
          \n
          \n
          exports
          \n
          \n
          \n
          \n
          cloudflare.config.tswhat you write
          \n
          \n
          \n
          \n
          \n \n \n \n \n
          \n
          \n
          \n
          \n

          The resolved pane is the Build Output Specification wrangler writes to .cloudflare/output/v0/workers/default/worker.config.json, which is the readable answer to \"what did my config actually become\". In August that file was config.json and opened with \"type\": \"worker\"; today it drops that key and ends with a manifest. The Env pane is what tsc reported for each key of the generated interface.

          \n
          \n

          The two front doors agree. cf build (1.0.0-beta.12) printed \"Delegating to Wrangler\" and wrote the same four files wrangler build did, sha256 for sha256. The snippet imports from @cloudflare/config/public, as this site's own cloudflare.config.ts does. The documentation's cf/config re-exports that module in one line, so either import works, and the resolved output is identical.

          \n

          One trap sits between the build and the types. The generated .cloudflare/types/index.d.ts imports cf/config. Install wrangler and @cloudflare/config without cf, keep skipLibCheck on as most templates do, and nothing complains: Env comes back empty, and every binding read is TS2339 on a value typed any. Only with skipLibCheck off does TS2307 name the missing module. Add cf as a dev dependency, as the docs say, or map cf/config in paths, which is what this site does.

          \n\n

          A variable arrives in the type as its own value, in both formats

          \n

          Thirteen binding kinds went through the compiler. Ten land where you would guess, and three carry more than a bare type.

          \n
          \n

          bindings.text(\"default\") gives env.AI_GATEWAY the type \"default\", so the variable's value is in the type and a comparison against a string that was never configured is a compile error. bindings.json({ dcz: true, tier: 2 }) comes back as { dcz: true; tier: number }, where the boolean narrows to a literal and the number widens, which is ordinary TypeScript inference showing through. And bindings.secret() types the secret as string, so a secret you forgot to set is a name the compiler already knows about.

          \n

          This section used to say a JSON config structurally cannot do this. That was wrong, and it was already wrong on 4.125.0. Run against an equivalent wrangler.jsonc, wrangler types writes AI_GATEWAY: \"default\" on both 4.125.0 and 4.147.0. It types the required secret as string too, and it narrows the JSON variable further than inference does, to {\"dcz\":true,\"tier\":2}. What the TypeScript config changes is when the type is computed. wrangler types takes a snapshot that the reminder above asks you to retake. The inferred Env reads the config every time the compiler runs, so a renamed binding is a type error with no regeneration step.

          \n

          One row is new since August. bindings.analyticsSQL(), added in config 0.22.0, resolves to {\"type\":\"analytics\"} and types as AnalyticsSQLBinding. Wrangler 4.147.0 builds it. cf 1.0.0-beta.5, which carries config 0.20.0, lets wrangler write the build output and then refuses that output on its own schema check, with \"Invalid discriminator value\", and exits 1. Beta.12 accepts it.

          \n\n

          The row that went backwards, and the way around it

          \n

          The Durable Object type is where the new format lost in August, and the October run found a way around it that works for some Workers and leaves the rest where they were. Against a JSON config, wrangler types threads the class through, on 4.147.0 as on 4.125.0:

          \n
          COUNTER: DurableObjectNamespace<import(\"./src/index\").Counter>;\nBOOKING: Workflow<Parameters<import(\"./src/index\").Booking['run']>[0]['payload']>;
          \n

          Under inference, a binding that names its Worker by string still comes back as DurableObjectNamespace<undefined>, so a stub method call fails with Property 'increment' does not exist on type 'DurableObjectStub<undefined>'. That holds with the entrypoint given as a path string and with it given as a module through the { type: \"cf-worker\" } import attribute, the same two forms August tried.

          \n

          What changed is the binding helper. Its worker option (spelled workerName in August) now takes either a name or a Worker definition. Bind a class that a second Worker exports, pass that Worker's definition, and give that definition a module entrypoint:

          \n
          import * as counterModule from \"./src/index.ts\" with { type: \"cf-worker\" };\n\nexport const counterWorker = defineWorker({\n  name: \"probe-counter\",\n  entrypoint: counterModule,\n  exports: { Counter: exports.durableObject({ storage: \"sqlite\" }) },\n});\n\n// in the binding Worker's env:\nCOUNTER: bindings.durableObject({ worker: counterWorker, exportName: \"Counter\" }),
          \n

          Now env.COUNTER types as DurableObjectNamespace<Counter>, and await stub.increment(1) is a number the compiler checks. A Workflow bound the same way types as Workflow<{ slot: string }>. The resolved JSON is the same as the string form, \"worker\": \"probe-counter\", so the definition buys a type and changes nothing the platform receives.

          \n

          Two controls say which half does the work. The same definition with a path-string entrypoint gives DurableObjectNamespace<DurableObjectBranded>, which still has no methods, so the module import is required. And a Worker that binds its own class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to never. The build also never type-checks the config. A Workflow binding whose exportName the definition did not declare was a compile error, and wrangler build still exited 0.

          \n

          So the scoreboard moved. Cross-Worker Durable Objects and Workflows now type as precisely as wrangler types types them, and stay current without a regeneration step. A Worker that binds its own Durable Object, the common single-Worker case, still types worse than the snapshot does.

          \n\n

          Migrations become a state on an export

          \n

          The cumulative migration list is gone. A class declares what it is on its own export, and the storage engine sits beside it:

          \n
          \n \n \n \n \n
          wrangler.tomlcloudflare.config.ts
          [[migrations]] tag = \"v1\"
          new_sqlite_classes = [\"Counter\"]
          Counter: exports.durableObject({ storage: \"sqlite\" })
          deleted_classes = [\"Old\"]Old: exports.durableObject({ state: \"deleted\" })
          renamed_classes = [{ from, to }]Old: exports.durableObject({ state: \"renamed\", renamedTo: \"New\" })
          \n

          This is a different upload path. Wrangler 4.147.0 still decides between them in one function, resolveDoLifecyclePayload: when a config declares Durable Object exports it sends those exports and sends no migrations at all, and it computes a migration only for configs that declare none.

          \n

          In August that left one question a dry run can't answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin b168333) against a Counter created under tag = \"v1\", the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers wrangler deploy. It says nothing about versions upload, which refuses Durable Object lifecycle changes on its own path.

          \n\n

          One thing it still will not do, and one it now does

          \n

          The dependency instrumentation key still has no home. The old config set [dependencies_instrumentation] enabled = true, and config 0.23.0 has no field for it. The escape hatch offers no route around it, because unsafe carries metadata and capnp schemas alone. This cost nothing, for a reason only readable in wrangler's source: 4.147.0 still tests the key as enabled !== false, so an absent block behaves exactly like the explicit true. A dry run reports on none of this, which is why the answer came from reading the upload path.

          \n

          The types file now comes from a build. wrangler types --x-new-config still fails, with \"Unknown arguments: x-new-config, xNewConfig\". In August the generated file came only from wrangler dev, so the inferred types existed on a machine somebody had run the dev server on. On 4.147.0, wrangler build --x-new-config --x-cf-build-output writes it too, and so do cf build and cf workers types, which wrote the same 622,861 bytes. That is the build step this page asked for in August.

          \n\n

          The bun trap, and why it is a trap

          \n

          The loader refuses bun, in a message that names itself, and on 4.147.0 a second warning follows it:

          \n
          cloudflare.config.ts loading is not supported on Bun. Please use Node.js v22.18.0 or higher.\nWrangler does not support the Bun runtime. Please try this command again using Node.js via `npm` or `pnpm`.
          \n

          On a repository whose whole toolchain is bun, that reads as a blocker for about a minute. It is a claim about the process that ends up running wrangler, whichever package manager launched it. Re-run on bun 1.4.3-canary.1:

          \n
          \n \n \n \n \n \n
          invocationresult
          bun x --no-install wrangler … --x-new-configloads, builds
          bun run <script>, resolving the same shimloads
          bun run --bun <script>refused
          bun ./node_modules/wrangler/bin/wrangler.js …refused
          \n

          The first two resolve node_modules/.bin/wrangler, whose #!/usr/bin/env node shebang hands the process to node before any config is read. The last two put bun in front of the loader, one by overriding the shebang and one by running wrangler's entry file directly. So \"does this tool support bun\" turned out to be a question about a shebang, and the answer changed with the invocation while the tool stayed the same.

          \n\n

          What this cost, and what it did not

          \n

          The conversion ran first on this site's smallest deployed Worker, the one behind /garage/cf/*, chosen because it is a demo that a person deploys by hand. In August the upload came out at 8.83 KiB, 3.07 KiB gzip, the same as before to the byte, with the same four bindings. Re-measured on 4.147.0 against the old wrangler.toml with its flags and minify setting brought level, both configs produce the same index.js, sha256 for sha256, at 5.94 KiB, 2.63 KiB gzip. The source maps differ only in their output-relative paths.

          \n

          In August the main Worker still typed env as any, because converting it meant folding two config files of 24,662 and 10,987 bytes into one program and putting a hidden flag on the path that publishes production. It moved on 2026-09-28, with an upload byte-identical to the JSON's. Its handlers take env: Env now, from a hand-written interface that a check diffs against cloudflare.config.ts in both directions. That choice is about optional secrets, which the interface marks with ? so every read has to face their absence, and about the Durable Object: its Counter lives in a second Worker that still has a wrangler.jsonc, so there is no definition to import yet.

          \n\n

          What would change the verdict

          \n

          Two of August's three triggers have fired. The API accepted the export-based Durable Object form on a real deploy, so the migration table is a translation. Documentation arrived, though the flag stayed hidden and the documented door became cf.

          \n

          The third half-fired: the class type now threads through a binding to another Worker. What would finish it is a self-bound Durable Object that types as its class, which would remove the last column where wrangler types wins. The other result worth watching is the format leaving open beta, since several shapes on this page moved between the two runs: the import path, the default export, the Durable Object binding key and the build output file.

          ", "pageCss": ".tc-code{background:var(--row-alt);border:1px solid #c8d2e2;border-left:3px solid var(--luna-44);padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:var(--text-xs);line-height:1.5}\n.tc-code code{white-space:pre}\n.tc-demo{border:1px solid var(--slate-73);background:var(--surface-desktop);margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.tc-cols{display:grid;grid-template-columns:minmax(150px,1fr) minmax(0,1.5fr) minmax(0,1.5fr);gap:1px;background:var(--slate-73)}\n.tc-pick{background:var(--row-alt);padding:0 0 8px}\n.tc-pick-head{font-family:var(--font-caption);font-size:var(--text-micro);text-transform:uppercase;letter-spacing:.06em;color:var(--slate-45);background:var(--surface-desktop);border-bottom:1px solid #c8d2e2;padding:5px 8px}\n.tc-pick-head-2{margin-top:8px;border-top:1px solid #c8d2e2}\n.tc-row{display:flex;align-items:flex-start;gap:6px;padding:4px 8px;font-size:var(--text-micro);line-height:1.35;cursor:pointer}\n.tc-row:hover{background:#e8eefa}\n.tc-row input{margin:2px 0 0;flex:0 0 auto}\n.tc-row b{font-family:var(--font-mono);font-weight:normal;color:#123;display:block}\n.tc-row span{color:#6b7688;display:block;font-size:var(--text-2xs)}\n.tc-pane{background:#fff;min-width:0;display:flex;flex-direction:column}\n.tc-pane-head{background:var(--surface-desktop);border-bottom:1px solid #c8d2e2;padding:4px 8px;font-size:var(--text-micro);color:var(--slate-45);display:flex;justify-content:space-between;align-items:center;gap:6px;min-height:26px}\n.tc-dim{color:#8b95a6}\n.tc-tabs{display:inline-flex;gap:4px}\n.tc-tabs button{font:inherit;font-size:var(--text-2xs);padding:1px 7px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,var(--surface-desktop));border-radius:3px;cursor:pointer;color:var(--slate-38)}\n.tc-tabs button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.tc-src{margin:0;padding:8px 10px;font-size:var(--text-micro);line-height:1.45;overflow:auto;max-height:390px;flex:1}\n.tc-src code{white-space:pre;font-family:var(--font-mono)}\n.tc-legend{overflow-wrap:break-word;font-size:var(--text-micro);color:var(--slate-52);margin:0;padding:7px 10px;border-top:1px solid #c8d2e2;background:var(--row-alt)}\n.tc-scroll{overflow-x:auto;margin:0 0 12px}\n.tc-table{border-collapse:collapse;font-size:var(--text-micro);width:100%;min-width:520px}\n.tc-table th{text-align:left;background:var(--surface-desktop);border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:var(--slate-38)}\n.tc-table td{border:1px solid var(--slate-90);padding:4px 7px;vertical-align:top}\n.tc-table td code{font-size:var(--text-2xs)}\n.tc-table tbody tr:nth-child(odd) td{background:var(--paper-cool)}\n.tc-ok{color:#1c6b32;font-weight:bold}\n.tc-no{color:#a3261a;font-weight:bold}\n.tc-mig td,.tc-bun td{line-height:1.5}\n@media(max-width:760px){.tc-cols{grid-template-columns:1fr}.tc-src{max-height:250px}}.content p{overflow-wrap:break-word}", "pageJs": "// /garage/typed-config — the config explorer.\n//\n// Every row below was measured on 2026-10-05 against a real Worker on wrangler\n// 4.147.0 (pkg.pr.new f025bbf) with @cloudflare/config 0.23.0 and cf\n// 1.0.0-beta.12, cross-checked on config 0.20.0: `resolved` is what\n// `wrangler build --x-new-config --x-cf-build-output` wrote to\n// .cloudflare/output/v0/workers/default/worker.config.json for that binding\n// (`cf build` wrote the same bytes), and `env` is what tsc 7.0.2 reported for\n// that key of the generated interface. ASSETS is the one row where tsc printed\n// the expansion of a type alias; the config's type map names it `Fetcher`.\n// The first run, on 2026-08-24, used wrangler 4.125.0 with config 0.7.0.\n// Nothing here is derived at runtime, so the page states measurements rather\n// than guesses.\n(function () {\n var BINDINGS = [\n { key: \"RN_KV\", note: \"KV namespace\", call: 'bindings.kv({ id: \"3cb8a107c58e47dc9244e75b33401f36\" })',\n resolved: { type: \"kv\", id: \"3cb8a107c58e47dc9244e75b33401f36\" }, env: \"KVNamespace\", on: true },\n { key: \"RESTORE_DB\", note: \"D1 database\", call: 'bindings.d1({ id: \"88c8daf1-3a36-4f8e-a2ad-dba8a74e1b9f\", name: \"aadhar-restore\" })',\n resolved: { type: \"d1\", name: \"aadhar-restore\", id: \"88c8daf1-3a36-4f8e-a2ad-dba8a74e1b9f\" }, env: \"D1Database\", on: true },\n { key: \"PHOTOS_R2\", note: \"R2 bucket\", call: 'bindings.r2({ name: \"aadhar-photos\" })',\n resolved: { type: \"r2\", name: \"aadhar-photos\" }, env: \"R2Bucket\", on: true },\n { key: \"AI\", note: \"Workers AI\", call: \"bindings.ai()\", resolved: { type: \"ai\" }, env: \"Ai\", on: false },\n { key: \"BROWSER\", note: \"Browser Run\", call: \"bindings.browser()\", resolved: { type: \"browser\" }, env: \"BrowserRun\", on: false },\n { key: \"BOT_LEDGER\", note: \"Analytics Engine, the write side\", call: 'bindings.analyticsEngineDataset({ name: \"aadhar_bot_ledger\" })',\n resolved: { type: \"analytics-engine-dataset\", name: \"aadhar_bot_ledger\" }, env: \"AnalyticsEngineDataset\", on: false },\n { key: \"LEDGER_SQL\", note: \"Analytics SQL, the read side; new in config 0.22.0\", call: \"bindings.analyticsSQL()\",\n resolved: { type: \"analytics\" }, env: \"AnalyticsSQLBinding\", on: false },\n { key: \"CF_VERSION_METADATA\", note: \"version metadata\", call: \"bindings.versionMetadata()\",\n resolved: { type: \"version-metadata\" }, env: \"WorkerVersionMetadata\", on: false },\n { key: \"ASSETS\", note: \"static assets; the directory lives in wrangler.config.ts\", call: \"bindings.assets()\",\n resolved: { type: \"assets\" }, env: \"Fetcher\", on: false },\n { key: \"COUNTER\", note: \"Durable Object stub, its Worker named by string\", call: 'bindings.durableObject({ worker: \"probe\", exportName: \"Counter\" })',\n resolved: { type: \"durable-object\", worker: \"probe\", exportName: \"Counter\" }, env: \"DurableObjectNamespace\", on: true },\n { key: \"SIGNING_SECRET\", note: \"secret; the value never enters the config\", call: \"bindings.secret()\",\n resolved: { type: \"secret\" }, env: \"string\", on: true },\n { key: \"AI_GATEWAY\", note: \"plain text; the value reaches the type\", call: 'bindings.text(\"default\")',\n resolved: { type: \"text\", value: \"default\" }, env: '\"default\"', on: true },\n { key: \"FEATURES\", note: \"JSON; booleans narrow, numbers widen\", call: \"bindings.json({ dcz: true, tier: 2 })\",\n resolved: { type: \"json\", value: { dcz: true, tier: 2 } }, env: \"{ dcz: true; tier: number; }\", on: true },\n ];\n\n var EXPORTS = [\n { key: \"Counter\", note: \"created, SQLite storage\", call: 'exports.durableObject({ storage: \"sqlite\" })',\n resolved: { type: \"durable-object\", storage: \"sqlite\" }, on: true },\n { key: \"Legacy\", note: \"deleted; this destroys its stored data\", call: 'exports.durableObject({ state: \"deleted\" })',\n resolved: { type: \"durable-object\", state: \"deleted\" }, on: false },\n ];\n\n var rows = document.getElementById(\"tc-rows\");\n var rowsX = document.getElementById(\"tc-rows-x\");\n var srcEl = document.getElementById(\"tc-src\");\n var outEl = document.getElementById(\"tc-out\");\n var table = document.getElementById(\"tc-table\");\n if (!rows || !srcEl || !outEl) return;\n var mode = \"resolved\";\n\n function pick(list, host) {\n list.forEach(function (item) {\n var label = document.createElement(\"label\");\n label.className = \"tc-row\";\n var box = document.createElement(\"input\");\n box.type = \"checkbox\";\n box.checked = item.on;\n box.addEventListener(\"change\", function () { item.on = box.checked; render(); });\n var text = document.createElement(\"span\");\n var name = document.createElement(\"b\");\n name.textContent = item.key;\n var note = document.createElement(\"span\");\n note.textContent = item.note;\n text.appendChild(name);\n text.appendChild(note);\n label.appendChild(box);\n label.appendChild(text);\n host.appendChild(label);\n });\n }\n\n function chosen(list) { return list.filter(function (i) { return i.on; }); }\n\n // The shape that builds on wrangler 4.147.0 and cf 1.0.0-beta.12, written the\n // way this site's cloudflare.config.ts is: helpers from @cloudflare/config/public\n // (workers-sdk#15914 moved them out of wrangler/experimental-config; cf/config\n // re-exports the same module), and one default export, defineConfig({ worker })\n // (workers-sdk#15713 retired the defineWorker default export this page emitted\n // in August).\n function source() {\n var env = chosen(BINDINGS);\n var ex = chosen(EXPORTS);\n var helpers = [\"bindings\", \"defineConfig\", \"defineWorker\"];\n if (ex.length) helpers.push(\"exports\");\n helpers.push(\"triggers\");\n var lines = [];\n lines.push(\"import { \" + helpers.join(\", \") + ' } from \"@cloudflare/config/public\";');\n lines.push(\"\");\n lines.push(\"const worker = defineWorker({\");\n lines.push(' name: \"probe\",');\n lines.push(' compatibilityDate: \"2026-08-15\",');\n lines.push(' entrypoint: \"./src/index.ts\",');\n lines.push(' triggers: [triggers.fetch({ pattern: \"aadhar.sh/probe/*\", zone: \"aadhar.sh\" })],');\n if (env.length) {\n lines.push(\" env: {\");\n env.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.call + \",\"); });\n lines.push(\" },\");\n }\n if (ex.length) {\n lines.push(\" exports: {\");\n ex.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.call + \",\"); });\n lines.push(\" },\");\n }\n lines.push(\"});\");\n lines.push(\"\");\n lines.push(\"export default defineConfig({ worker });\");\n return lines.join(\"\\n\");\n }\n\n function resolved() {\n var out = {\n name: \"probe\",\n compatibilityDate: \"2026-08-15\",\n triggers: [{ type: \"fetch\", pattern: \"aadhar.sh/probe/*\", zone: \"aadhar.sh\" }],\n };\n var env = chosen(BINDINGS);\n var ex = chosen(EXPORTS);\n if (env.length) {\n out.env = {};\n env.forEach(function (b) { out.env[b.key] = b.resolved; });\n }\n if (ex.length) {\n out.exports = {};\n ex.forEach(function (b) { out.exports[b.key] = b.resolved; });\n }\n out.manifest = { type: \"complete\", mainModule: \"index.js\", modules: { \"index.js\": { type: \"esm\" } } };\n return JSON.stringify(out, null, 2);\n }\n\n function envType() {\n var env = chosen(BINDINGS);\n if (!env.length) return \"interface Env {}\\n\\n// No bindings selected, so env carries nothing\\n// and every property read is a compile error.\";\n var lines = [\"interface Env {\"];\n env.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.env + \";\"); });\n lines.push(\"}\");\n return lines.join(\"\\n\");\n }\n\n function render() {\n srcEl.textContent = source();\n outEl.textContent = mode === \"resolved\" ? resolved() : envType();\n }\n\n var tabs = document.querySelectorAll(\".tc-tabs button\");\n Array.prototype.forEach.call(tabs, function (btn) {\n btn.addEventListener(\"click\", function () {\n mode = btn.getAttribute(\"data-t\");\n Array.prototype.forEach.call(tabs, function (b) { b.classList.toggle(\"on\", b === btn); });\n render();\n });\n });\n\n if (table) {\n var head = document.createElement(\"tr\");\n [\"binding\", \"written as\", \"reaches the platform as\", \"env type\"].forEach(function (h) {\n var th = document.createElement(\"th\");\n th.scope = \"col\";\n th.textContent = h;\n head.appendChild(th);\n });\n table.createTHead().appendChild(head);\n var tableRows = table.createTBody();\n BINDINGS.forEach(function (b) {\n var tr = document.createElement(\"tr\");\n [b.key, b.call, JSON.stringify(b.resolved), b.env].forEach(function (cell, i) {\n var td = document.createElement(\"td\");\n var code = document.createElement(\"code\");\n code.textContent = cell;\n td.appendChild(code);\n if (i === 3) td.className = \"tc-envcell\";\n tr.appendChild(td);\n });\n tableRows.appendChild(tr);\n });\n }\n\n pick(BINDINGS, rows);\n if (rowsX) pick(EXPORTS, rowsX);\n render();\n})();", "editorial": { diff --git a/pipelines/garage/specs/useragent.json b/pipelines/garage/specs/useragent.json index 1d2c40a00..58a681312 100644 --- a/pipelines/garage/specs/useragent.json +++ b/pipelines/garage/specs/useragent.json @@ -4,7 +4,7 @@ "description": "16 identities, 11 sites: two invented user-agents got past walls 4 real crawler tokens hit, and Reddit gave a preview bot 1,640 words to a browser's 1.", "status": "shipped", "added": "2026-08-21", - "bodyHtml": "

          What a crawler name is worth

          \n

          A user-agent string costs nothing to type and nothing verifies it. So this asked sixteen identities to fetch the same eleven pages, three times each, and recorded what came back. Sites answer a name in four different ways, three of which any name absent from a list walks straight through. One site catches the lie. And on one of them the crawler is handed 1,640 words where a browser gets one.

          \n\n

          The claim under test

          \n

          The prompt for this was an observation going around: that spoofing is worth doing again, and that inventing a plausible AI-company user-agent gets you served like a first-class citizen. Two strings were named specifically, OpenAI File Downloader and XaiImageApiFetch/1.0. Neither is a documented crawler token. Both are in the identity list below for exactly that reason, and both turn out to work.

          \n\n

          What the survey sends

          \n

          Sixteen identities, grouped by what they claim. Three are controls, and they carry the weight of the whole table.

          \n
          \n\n

          The row labelled ua-survey (ours) is the honest one. It claims nothing it cannot back and points at this page. Read it against googlebot further down, because the gap between those two rows is the argument in miniature.

          \n\n

          The control is what makes the table readable

          \n

          A crawler row returning 403 has two explanations that look identical from one sample. The origin refuses that name, or the origin refuses this instrument and would refuse anything wearing any name. Chrome, curl, and our own string answer the second question, so a refusal below is about the name only when a control got through.

          \n

          That check disqualifies four of the eleven targets, which is the point of running it. On medium.com and quora.com every identity gets 403 including Chrome, so nothing on those hosts is evidence about user-agent policy. Reporting them as sites that block AI crawlers would be reporting our own exclusion as their policy.

          \n\n
          \n
          \n What each identity was served\n \n \n \n \n \n \n \n
          \n
          \n\n

          ✓ served a readable document · × refused, with the status beside it · ◐ a 200 carrying under 50 words, so the request was allowed and the content is still absent · hover any cell for its status and word count, or switch to with counts. Counts are the median of three trials with scripts and styles stripped.

          \n
          \n\n

          Answer one: a list of names, and it fails open

          \n

          The New York Times refuses five identities and serves everybody else. GPTBot, ClaudeBot, Claude-User and PerplexityBot each get 403 and the same 135-word error page, identically across all three trials, and Amazonbot gets a 403 carrying a different 21-word body, which suggests two rules rather than one list. Chrome gets the paper. So does curl.

          \n

          So do both invented strings. OpenAI File Downloader was served 1,255 words, the same count Chrome received, and XaiImageApiFetch/1.0 was served 1,438. The claim that started this checks out precisely: two made-up names cleared a wall that four real ones could not, and one of them sounds more like OpenAI than GPTBot does.

          \n

          A denylist has to enumerate the thing it stops, so every identity it has never heard of sits on the allowed side by default. The population it actually filters is the crawlers polite enough to announce themselves with a documented token. Declaring yourself is what gets you blocked.

          \n\n

          Answer two: a price

          \n

          The Atlantic does something more interesting than refusing. Applebot, ClaudeBot, Claude-User and PerplexityBot each receive 402 Payment Required and a seven-word body reading {\"message\":\"Please contact the site owner for access.\"}, while the other twelve identities receive the full 1,344-word homepage. 402 is the status code HTTP reserved in 1997 and left undefined for twenty-odd years, and it is now carrying pay-per-crawl.

          \n

          Read what it is aimed at, though. GPTBot pays nothing and gets the homepage, and so do both fabricated names. The bill is addressed to a string, so this is the denylist above with a different status code, and it inherits the same hole. Charging for access is a real change in posture, but it is orthogonal to being able to tell who is asking.

          \n\n

          Answer three: reputation, and it fails closed

          \n

          LinkedIn inverts the denylist. An earlier run against a public profile, taken before this survey had made many requests, put three clean bands on the same URL: about 11,800 words to Chrome, about 10,400 to ClaudeBot, and about 7,900 to Claude-User, with Contact Info present for the browser and absent for every crawler. Both invented strings got 999, LinkedIn's house refusal code.

          \n

          An unknown name is refused instead of admitted, which stops the trick that works on the Times. It also refuses a real, well-behaved, newly launched crawler on its first request and every request after, because the refusal keys on being unknown, however well the crawler behaves.

          \n
          \n

          The survey broke this row by running it. By the final pass LinkedIn was answering 999 to fifteen of sixteen identities including Chrome, so the control gate marks the target unmeasurable and the bands above are no longer reproducible from this address. The numbers are real and were taken before the block. An instrument that changes its subject by measuring it has to say so, and a survey that keeps sampling a defended origin is training that origin to refuse it. The same thing happened to stackoverflow.com partway through, which slid from 403 into 429.

          \n
          \n\n

          Answer four: verification, and it catches the lie

          \n

          Wikimedia is the only site in the sample doing something a string cannot defeat. It served the full 8,243-word article to Chrome, to curl, to our own invented survey string, to GPTBot, to Claude-User, to PerplexityBot, and to both fabricated names. It refused six: googlebot, bingbot, Applebot, DuckDuckBot, facebookexternalhit and ClaudeBot, each with a 9 or 10 word body reading Unauthorized request. Please contact bot-traffic@wikimedia.org to request unblocking.

          \n

          Every one of those six publishes the addresses its crawler uses, or a reverse DNS procedure, or both. Claiming to be nobody is fine. A nonsense token invented on the spot is fine. Claiming a checkable identity from an address that cannot back it is refused. Three AI crawlers read the article in full during the same run, so what Wikimedia turns away is impersonation. The honest versions of those six are unaffected, because their addresses match what they say.

          \n

          One caveat kept because it spoils the tidy version: GPTBot passes, and OpenAI publishes crawler addresses too. So the check is applied unevenly, and the honest claim is narrower than the pattern invites. What held across every trial is the contrast between claiming nothing and claiming Googlebot.

          \n\n

          The other direction, where the crawler gets more

          \n

          Reddit is the strongest result in the survey and it points the opposite way from a paywall. A browser asking for /r/programming is served one word. facebookexternalhit, the Facebook link-preview fetcher, is served 1,640. Same URL, same address, same three trials.

          \n

          The mechanism is mundane once you see it, and it is worth stating because the exciting reading is wrong. Reddit renders for humans in the browser and pre-renders for clients that cannot run JavaScript, because a link preview has to work without a JS engine. So the crawler is handed server-rendered HTML while the browser is handed an empty frame and a script tag. Nobody decided to be generous to bots.

          \n

          That still leaves a real and widely useful trick, and it is a better description of what spoofing buys than the paywall story. Claiming to be a link-preview bot is a way to get server-rendered HTML out of a client-rendered application. What it wins is a rendering path, which is also why it works on sites with no AI policy whatsoever.

          \n\n

          The sites with no opinion at all

          \n

          Three targets served all sixteen identities the same document to the word: GitHub at 1,135 words, Hacker News at 747, and this site at 314. That is the baseline the other rows should be read against, and it is a reminder that most of the web still answers a request without interrogating it.

          \n\n

          What makes a name checkable

          \n

          Reverse DNS is the old answer and it works. Resolve the connecting address, confirm the name belongs to the crawler's operator, resolve it forward again to confirm it points back. Wikimedia's six refusals are that check failing, and it is why a fabricated token sailed past while googlebot did not.

          \n

          It is also available to about a dozen companies, because it requires owning address space and publishing a procedure that site operators bother to implement. Everybody else gets a string, which is where this page started.

          \n

          Web Bot Auth moves the check off the address. The crawler signs each request with an Ed25519 key under RFC 9421 HTTP Message Signatures and publishes the public half as a JWKS document. Three headers carry it: Signature-Input naming the covered components with tag=\"web-bot-auth\", a keyid thumbprint and created and expires stamps; Signature carrying the signature; and Signature-Agent naming the directory to fetch the key from. The directory lives at /.well-known/http-message-signatures-directory.

          \n

          The property that matters is the one this survey keeps demonstrating in the negative. Anyone can type GPTBot. Producing a valid signature over a request needs the private key, so the claim stops depending on which address it arrives from, and a crawler owning no network blocks can still hold an identity somebody is able to check.

          \n\n

          The program that already runs this, and the part still waiting

          \n

          All of that already ships. Cloudflare's Verified Bots program accepts exactly three proofs of identity, and they map onto this page's answers one for one: a Web Bot Auth signature, a published IP list paired with a stable user-agent, or reverse DNS validation. Verification carries behavioural terms too, since a verified bot has to respect robots.txt, hold a reasonable request rate, and avoid working around a site owner's stated preferences. Breaking any of them removes the service from the global allowlist. The signature route is the one that widens the field, because the other two proofs are facts about the address a request comes from and a signature is not.

          \n

          Two labels sit close together here and are worth keeping apart. A valid Web Bot Auth signature classifies traffic as a signed agent, and Verified Bot status is a further step judging behaviour rather than cryptography. Cloudflare's own announcement uses itself as the example: Browser Run (Browser Rendering when the post ran) sends signed Web Bot Auth headers and does not qualify as a verified bot, while Radar's URL Scanner does. Signing proves who is asking. Verification judges whether that identity has earned an allowlist entry.

          \n

          So the fix for everything above is deployed, at one of the largest reverse proxies on the web, and a site gets it by turning it on rather than by building it. Whether anything in this sample looks for it is a separate question, and the survey carries one small piece of evidence.

          \n
          \n\n

          That row sends the pointer and signs nothing. It sets Signature-Agent to a directory that genuinely exists and omits Signature and Signature-Input entirely, so an origin actually verifying has everything it needs to reject the request. Read it against the plain ua-survey (ours) row in the matrix, which is the same user-agent carrying no header at all.

          \n

          Worth stating the limit of that probe. It shows whether an origin reacts to an unsigned Web Bot Auth attempt, and it cannot show whether an origin would honour a correctly signed one, because signing needs AadharshBot's private key and that key is a Worker secret this survey cannot reach. A run from inside the Worker would answer the second question and is the obvious next experiment.

          \n

          One more data point arrived by accident while this page was being written. Cloudflare's own Radar bot directory answered 403 to the unverified fetcher used to read it, and served the same page to an ordinary browser seconds later. The registry of verified bots sits behind the wall that verification exists to open.

          \n\n

          What this site serves

          \n

          The last row of the matrix is aadhar.sh, which answered all sixteen identities with the same 314-word document. That is a deliberate position: the pages here publish a Markdown twin, an MCP endpoint and a machine catalog precisely so a crawler has a cheaper option than scraping the HTML. Serving one document to everyone is easy when there is nothing you are trying to keep from them.

          \n

          It also means this page cannot claim to have tested its own defense, because it has none to test.

          \n

          The crawler is a different story, and the honest version implicates us. AadharshBot signs its outbound requests under RFC 9421 and publishes an Ed25519 key at the exact well-known path Cloudflare's documentation specifies, served as application/jwk-set+json. That clears the technical bar and stops there, because the bot has never been submitted to the Verified Bots program. So the signature it has been sending all along buys it nothing at any origin that checks a registry rather than a signature, and this page argues for a thing its own crawler has half done.

          \n\n
          \n

          What would falsify this. The denylist reading dies if the Times refuses OpenAI File Downloader on a later run, which would mean the list is maintained faster than a survey can exploit it. The verification reading dies if Wikimedia turns out to refuse googlebot from every address including Google's, which would make it an ordinary denylist that happens to name six crawlers. The rendering explanation for Reddit dies if facebookexternalhit is served more than a browser on a server-rendered page, where there is no JavaScript gap to explain it. The adoption reading dies if any origin treats the unsigned Signature-Agent row differently from the bare row beside it. All four are one re-run of bun run ua:survey away, and the survey prints its own control rows so a future reader can tell a changed policy from a changed instrument.

          \n
          ", + "bodyHtml": "

          What a crawler name is worth

          \n

          A user-agent string costs nothing to type and nothing verifies it. So this asked sixteen identities to fetch the same eleven pages, three times each, and recorded what came back. Sites answer a name in four different ways, three of which any name absent from a list walks straight through. One site catches the lie. And on one of them the crawler is handed 1,640 words where a browser gets one.

          \n\n

          The claim under test

          \n

          The prompt for this was an observation going around: that spoofing is worth doing again, and that inventing a plausible AI-company user-agent gets you served like a first-class citizen. Two strings were named specifically, OpenAI File Downloader and XaiImageApiFetch/1.0. Neither is a documented crawler token. Both are in the identity list below for exactly that reason, and both turn out to work.

          \n\n

          What the survey sends

          \n

          Sixteen identities, grouped by what they claim. Three are controls, and they carry the weight of the whole table.

          \n
          \n\n

          The row labelled ua-survey (ours) is the honest one. It claims nothing it cannot back and points at this page. Read it against googlebot further down, because the gap between those two rows is the argument in miniature.

          \n\n

          The control is what makes the table readable

          \n

          A crawler row returning 403 has two explanations that look identical from one sample. The origin refuses that name, or the origin refuses this instrument and would refuse anything wearing any name. Chrome, curl, and our own string answer the second question, so a refusal below is about the name only when a control got through.

          \n

          That check disqualifies four of the eleven targets, which is the point of running it. On medium.com and quora.com every identity gets 403 including Chrome, so nothing on those hosts is evidence about user-agent policy. Reporting them as sites that block AI crawlers would be reporting our own exclusion as their policy.

          \n\n
          \n
          \n What each identity was served\n \n \n \n \n \n \n \n
          \n
          \n\n

          ✓ served a readable document · × refused, with the status beside it · ◐ a 200 carrying under 50 words, so the request was allowed and the content is still absent · hover any cell for its status and word count, or switch to with counts. Counts are the median of three trials with scripts and styles stripped.

          \n
          \n\n

          Answer one: a list of names, and it fails open

          \n

          The New York Times refuses five identities and serves everybody else. GPTBot, ClaudeBot, Claude-User and PerplexityBot each get 403 and the same 135-word error page, identically across all three trials, and Amazonbot gets a 403 carrying a different 21-word body, which suggests two rules rather than one list. Chrome gets the paper. So does curl.

          \n

          So do both invented strings. OpenAI File Downloader was served 1,255 words, the same count Chrome received, and XaiImageApiFetch/1.0 was served 1,438. The claim that started this checks out precisely: two made-up names cleared a wall that four real ones could not, and one of them sounds more like OpenAI than GPTBot does.

          \n

          A denylist has to enumerate the thing it stops, so every identity it has never heard of sits on the allowed side by default. The population it actually filters is the crawlers polite enough to announce themselves with a documented token. Declaring yourself is what gets you blocked.

          \n\n

          Answer two: a price

          \n

          The Atlantic does something more interesting than refusing. Applebot, ClaudeBot, Claude-User and PerplexityBot each receive 402 Payment Required and a seven-word body reading {\"message\":\"Please contact the site owner for access.\"}, while the other twelve identities receive the full 1,344-word homepage. 402 is the status code HTTP reserved in 1997 and left undefined for twenty-odd years, and it is now carrying pay-per-crawl.

          \n

          Read what it is aimed at, though. GPTBot pays nothing and gets the homepage, and so do both fabricated names. The bill is addressed to a string, so this is the denylist above with a different status code, and it inherits the same hole. Charging for access is a real change in posture, but it is orthogonal to being able to tell who is asking.

          \n\n

          Answer three: reputation, and it fails closed

          \n

          LinkedIn inverts the denylist. An earlier run against a public profile, taken before this survey had made many requests, put three clean bands on the same URL: about 11,800 words to Chrome, about 10,400 to ClaudeBot, and about 7,900 to Claude-User, with Contact Info present for the browser and absent for every crawler. Both invented strings got 999, LinkedIn's house refusal code.

          \n

          An unknown name is refused instead of admitted, which stops the trick that works on the Times. It also refuses a real, well-behaved, newly launched crawler on its first request and every request after, because the refusal keys on being unknown, however well the crawler behaves.

          \n
          \n

          The survey broke this row by running it. By the final pass LinkedIn was answering 999 to fifteen of sixteen identities including Chrome, so the control gate marks the target unmeasurable and the bands above are no longer reproducible from this address. The numbers are real and were taken before the block. An instrument that changes its subject by measuring it has to say so, and a survey that keeps sampling a defended origin is training that origin to refuse it. The same thing happened to stackoverflow.com partway through, which slid from 403 into 429.

          \n
          \n\n

          Answer four: verification, and it catches the lie

          \n

          Wikimedia is the only site in the sample doing something a string cannot defeat. It served the full 8,243-word article to Chrome, to curl, to our own invented survey string, to GPTBot, to Claude-User, to PerplexityBot, and to both fabricated names. It refused six: googlebot, bingbot, Applebot, DuckDuckBot, facebookexternalhit and ClaudeBot, each with a 9 or 10 word body reading Unauthorized request. Please contact bot-traffic@wikimedia.org to request unblocking.

          \n

          Every one of those six publishes the addresses its crawler uses, or a reverse DNS procedure, or both. Claiming to be nobody is fine. A nonsense token invented on the spot is fine. Claiming a checkable identity from an address that cannot back it is refused. Three AI crawlers read the article in full during the same run, so what Wikimedia turns away is impersonation. The honest versions of those six are unaffected, because their addresses match what they say.

          \n

          One caveat kept because it spoils the tidy version: GPTBot passes, and OpenAI publishes crawler addresses too. So the check is applied unevenly, and the honest claim is narrower than the pattern invites. What held across every trial is the contrast between claiming nothing and claiming Googlebot.

          \n\n

          The other direction, where the crawler gets more

          \n

          Reddit is the strongest result in the survey and it points the opposite way from a paywall. A browser asking for /r/programming is served one word. facebookexternalhit, the Facebook link-preview fetcher, is served 1,640. Same URL, same address, same three trials.

          \n

          The mechanism is mundane once you see it, and it is worth stating because the exciting reading is wrong. Reddit renders for humans in the browser and pre-renders for clients that cannot run JavaScript, because a link preview has to work without a JS engine. So the crawler is handed server-rendered HTML while the browser is handed an empty frame and a script tag. Nobody decided to be generous to bots.

          \n

          That still leaves a real and widely useful trick, and it is a better description of what spoofing buys than the paywall story. Claiming to be a link-preview bot is a way to get server-rendered HTML out of a client-rendered application. What it wins is a rendering path, which is also why it works on sites with no AI policy whatsoever.

          \n\n

          The sites with no opinion at all

          \n

          Three targets served all sixteen identities the same document to the word: GitHub at 1,135 words, Hacker News at 747, and this site at 314. That is the baseline the other rows should be read against, and it is a reminder that most of the web still answers a request without interrogating it.

          \n\n

          What makes a name checkable

          \n

          Reverse DNS is the old answer and it works. Resolve the connecting address, confirm the name belongs to the crawler's operator, resolve it forward again to confirm it points back. Wikimedia's six refusals are that check failing, and it is why a fabricated token sailed past while googlebot did not.

          \n

          It is also available to about a dozen companies, because it requires owning address space and publishing a procedure that site operators bother to implement. Everybody else gets a string, which is where this page started.

          \n

          Web Bot Auth moves the check off the address. The crawler signs each request with an Ed25519 key under RFC 9421 HTTP Message Signatures and publishes the public half as a JWKS document. Three headers carry it: Signature-Input naming the covered components with tag=\"web-bot-auth\", a keyid thumbprint and created and expires stamps; Signature carrying the signature; and Signature-Agent naming the directory to fetch the key from. The directory lives at /.well-known/http-message-signatures-directory.

          \n

          The property that matters is the one this survey keeps demonstrating in the negative. Anyone can type GPTBot. Producing a valid signature over a request needs the private key, so the claim stops depending on which address it arrives from, and a crawler owning no network blocks can still hold an identity somebody is able to check.

          \n\n

          The program that already runs this, and the part still waiting

          \n

          All of that already ships. Cloudflare's Verified Bots program accepts exactly three proofs of identity, and they map onto this page's answers one for one: a Web Bot Auth signature, a published IP list paired with a stable user-agent, or reverse DNS validation. Verification carries behavioural terms too, since a verified bot has to respect robots.txt, hold a reasonable request rate, and avoid working around a site owner's stated preferences. Breaking any of them removes the service from the global allowlist. The signature route is the one that widens the field, because the other two proofs are facts about the address a request comes from and a signature is not.

          \n

          Two labels sit close together here and are worth keeping apart. A valid Web Bot Auth signature classifies traffic as a signed agent, and Verified Bot status is a further step judging behaviour rather than cryptography. Cloudflare's own announcement uses itself as the example: Browser Run (Browser Rendering when the post ran) sends signed Web Bot Auth headers and does not qualify as a verified bot, while Radar's URL Scanner does. Signing proves who is asking. Verification judges whether that identity has earned an allowlist entry.

          \n

          So the fix for everything above is deployed, at one of the largest reverse proxies on the web, and a site gets it by turning it on rather than by building it. Whether anything in this sample looks for it is a separate question, and the survey carries one small piece of evidence.

          \n
          \n\n

          That row sends the pointer and signs nothing. It sets Signature-Agent to a real directory and omits Signature and Signature-Input entirely, so an origin actually verifying has everything it needs to reject the request. Read it against the plain ua-survey (ours) row in the matrix, which is the same user-agent carrying no header at all.

          \n

          That probe shows whether an origin reacts to an unsigned Web Bot Auth attempt, and it cannot show whether an origin would honour a correctly signed one, because signing needs AadharshBot's private key and that key is a Worker secret this survey cannot reach. A run from inside the Worker would answer the second question and is the obvious next experiment.

          \n

          One more data point arrived by accident while this page was being written. Cloudflare's own Radar bot directory answered 403 to the unverified fetcher used to read it, and served the same page to an ordinary browser seconds later. The registry of verified bots sits behind the wall that verification exists to open.

          \n\n

          What this site serves

          \n

          The last row of the matrix is aadhar.sh, which answered all sixteen identities with the same 314-word document. That is a deliberate position: the pages here publish a Markdown twin, an MCP endpoint and a machine catalog precisely so a crawler has a cheaper option than scraping the HTML. Serving one document to everyone is easy when there is nothing you are trying to keep from them.

          \n

          It also means this page cannot claim to have tested its own defense, because it has none to test.

          \n

          The crawler is a different story, and the honest version implicates us. AadharshBot signs its outbound requests under RFC 9421 and publishes an Ed25519 key at the exact well-known path Cloudflare's documentation specifies, served as application/jwk-set+json. That clears the technical bar and stops there, because the bot has never been submitted to the Verified Bots program. So the signature it has been sending all along buys it nothing at any origin that checks a registry rather than a signature, and this page argues for a thing its own crawler has half done.

          \n\n
          \n

          What would falsify this. The denylist reading dies if the Times refuses OpenAI File Downloader on a later run, which would mean the list is maintained faster than a survey can exploit it. The verification reading dies if Wikimedia turns out to refuse googlebot from every address including Google's, which would make it an ordinary denylist that happens to name six crawlers. The rendering explanation for Reddit dies if facebookexternalhit is served more than a browser on a server-rendered page, where there is no JavaScript gap to explain it. The adoption reading dies if any origin treats the unsigned Signature-Agent row differently from the bare row beside it. All four are one re-run of bun run ua:survey away, and the survey prints its own control rows so a future reader can tell a changed policy from a changed instrument.

          \n
          ", "pageCss": ".ua-ids{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:0 0 10px;padding:2px 0}\n.ua-ids .row{display:grid;grid-template-columns:10em 6.5em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:var(--text-xs);border-bottom:1px solid var(--surface-desktop)}\n.ua-ids .row:last-child{border-bottom:0}\n.ua-ids .row.ctl{background:var(--mist-96)}\n.ua-ids b{font-family:var(--font-mono);color:#20406e}\n.ua-ids .who{color:#69758a;font-size:var(--text-2xs)}\n.ua-ids .ua{font-family:var(--font-mono);font-size:var(--text-2xs);color:#4a5870;word-break:break-all}\n.ua-ids .tag{display:inline-block;margin-left:5px;padding:0 4px;border-radius:2px;font-size:var(--text-4xs);text-transform:uppercase;letter-spacing:.04em;color:#31527d;background:#cddcf2}\n.ua-note{font-size:var(--text-xs);color:var(--slate-52)}\n.ua-demo{border:1px solid var(--slate-73);background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.ua-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,var(--surface-desktop),var(--slate-92));border-bottom:1px solid var(--slate-81);padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.ua-toggle button{font:inherit;font-size:var(--text-micro);font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,var(--surface-desktop));border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.ua-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.ua-scroll{overflow-x:auto}\n.ua-matrix{border-collapse:collapse;font-size:var(--text-2xs);width:100%}\n.ua-matrix th{background:var(--surface-desktop);border:1px solid var(--rule);padding:3px 5px;text-align:left;font-family:var(--font-caption);font-weight:normal;white-space:nowrap}\n.ua-matrix th.ctl{background:#d9e5f6}\n.ua-matrix td{border:1px solid var(--slate-90);padding:3px 5px;white-space:nowrap;font-family:var(--font-mono)}\n.ua-matrix td.site{font-family:var(--font-ui);max-width:15em;white-space:normal}\n.ua-matrix td.site b{color:#20406e}\n.ua-matrix td.site span{display:block;color:#69758a;font-size:var(--text-2xs)}\n.ua-matrix tr.unmeasurable td{background:#f2f2f2;color:var(--slate-55)}\n.ua-matrix tr.unmeasurable td.site b{color:#69758a}\n.ua-matrix .ok{color:#1d6b1d}\n.ua-matrix .no{color:#a32b13}\n.ua-matrix .sh{color:#9a6a08}\n.ua-matrix td.ctl{background:var(--row-alt)}\n.ua-matrix tr.unmeasurable td.ctl{background:var(--grey-95)}\n.ua-verdict{font-family:var(--font-ui);font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.04em}\n.ua-legend{margin:0;padding:6px 10px 8px;font-size:var(--text-2xs);color:var(--slate-52);border-top:1px solid #ccd6e4}\n.ua-quote{font-family:var(--font-mono);font-size:var(--text-micro);background:#fffbe8;border:1px solid #e5d9a8;padding:1px 5px}\n.ua-selfharm{border-left:3px solid #a32b13;background:#fdf2ef;padding:8px 10px;margin:0 0 14px}\n.ua-selfharm p{margin:0}\n.ua-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n.ua-falsify p{margin:0}\n@media(max-width:620px){.ua-ids .row{grid-template-columns:1fr;row-gap:1px}}\n.ua-wba{border:1px solid var(--slate-73);background:var(--paper-cool);box-shadow:inset 1px 1px 0 #fff;margin:0 0 12px;padding:2px 0}\n.ua-wba .r{display:grid;grid-template-columns:11em 1fr 1fr;gap:8px;align-items:center;padding:4px 10px;font-size:var(--text-xs);border-bottom:1px solid var(--surface-desktop)}\n.ua-wba .r:last-child{border-bottom:0}\n.ua-wba .r.h{background:var(--surface-desktop);font-family:var(--font-caption);color:#20406e;font-size:var(--text-micro)}\n.ua-wba .t{font-family:var(--font-mono);color:#20406e}\n.ua-wba .v{font-family:var(--font-mono)}\n.ua-wba .same{color:var(--slate-52)}\n.ua-wba .diff{color:#a32b13;font-weight:bold}\n.ua-wba .verdict{padding:6px 10px;font-size:var(--text-micro);color:#33455f;border-top:1px solid #ccd6e4;background:var(--mist-96)}\n@media(max-width:620px){.ua-wba .r{grid-template-columns:1fr}}\n\n.ua-sep{display:inline-block;width:1px;height:13px;background:var(--slate-81);margin:0 3px;vertical-align:-2px}\n.ua-matrix.compact{width:auto;min-width:100%}\n.ua-scroll{padding-right:2.2em}\n.ua-matrix.compact td.c{text-align:center;width:1.75em;padding:3px 1px;font-size:var(--text-body);line-height:1.15;font-family:var(--font-ui)}\n.ua-matrix.compact td.c .s{display:block;font-family:var(--font-mono);font-size:var(--text-4xs);line-height:1;color:inherit;opacity:.72}\n.ua-matrix.compact .ok{color:#1d6b1d}\n.ua-matrix.compact .no{color:#a32b13}\n.ua-matrix.compact .sh{color:#9a6a08}\n/* The header turns on its side, which is what gives 16 identity columns the room\n to fit a 760px window. A horizontal header needs ~7em a column and there is not\n 7em x 16 anywhere on this page. */\n.ua-matrix.compact th.rot{height:7em;vertical-align:bottom;padding:0 0 4px;width:1.75em;position:relative}\n.ua-matrix.compact th.rot>span{display:block;transform-origin:left bottom;transform:translateX(1.35em) rotate(-60deg);white-space:nowrap;font-size:var(--text-2xs);text-align:left;width:0}\n.ua-matrix.compact th.hsite{vertical-align:bottom}\n.ua-matrix.compact th.ctl>span{font-weight:bold;color:#20406e}\n.ua-matrix.compact td.site{max-width:11em}\n.ua-matrix.compact td,.ua-matrix.compact th{padding-left:2px;padding-right:2px}\n@media(max-width:620px){\n .ua-matrix.compact th.rot{height:6.4em}\n .ua-matrix.compact th.rot>span{font-size:var(--text-4xs)}\n .ua-matrix.compact td.c{width:1.85em;font-size:var(--text-sm)}\n}\n", "editorial": { "reader": "Someone who runs a site and is deciding whether a user-agent denylist is worth maintaining, or who has read that spoofing a crawler token is free and wants to know what it actually buys.", diff --git a/src/pages/garage/blueprint.html b/src/pages/garage/blueprint.html index dd066f98d..97061c14a 100644 --- a/src/pages/garage/blueprint.html +++ b/src/pages/garage/blueprint.html @@ -288,7 +288,7 @@

          What I would not touchthe soul

          - The verdict, honestly. Half of this blueprint is already the changelog: + The verdict. Half of this blueprint is already the changelog: the Workers migration, the in-house counter DO, granular routing, the build step, the edge-cache kit all landed incrementally, each gated by a 42-route oracle. The site never went down for any of it. That is the actual argument against a big-bang rewrite: diff --git a/src/pages/garage/chunks.html b/src/pages/garage/chunks.html index 92df1d866..e3d0fb3fc 100644 --- a/src/pages/garage/chunks.html +++ b/src/pages/garage/chunks.html @@ -127,7 +127,7 @@

          FastCDC content-defined: min 16 · avg 40 · max 120 bytes2 · FastCDC, briefly

          - Here is the whole idea, with every operational concern taken back out: + Here is FastCDC with every operational concern taken back out:

          function cut(bytes, avg) {
             var mask = avg - 1, hash = 0, cuts = [];
          @@ -157,7 +157,7 @@ 

          2 · FastCDC, briefly

          3 · Big files

          - Content-addressing earns its keep on large blobs. Drop any file (it + Content-addressing pays off on large blobs. Drop any file (it never leaves your machine) and your browser FastCDC-chunks it locally (avg 8 KB), then SHA-256's each chunk into a content ID. The blocks run proportional to size so you can see the variable-length cuts; the diff --git a/src/pages/garage/cloudflare.html b/src/pages/garage/cloudflare.html index 91493be82..68fd3dcd5 100644 --- a/src/pages/garage/cloudflare.html +++ b/src/pages/garage/cloudflare.html @@ -53,13 +53,13 @@

          Four free Cloudflare features, live

          - The free Workers plan quietly bundles things that used to cost money or + The free Workers plan bundles things that used to cost money or a separate service. I wired four of them to live demos here (Durable Objects, Workers AI, Workers Logs, and custom trace spans), behind a tiny worker (cf-garage) routed at /garage/cf/*. The buttons hit real endpoints; nothing is faked. A fifth, Browser Rendering, I bound and deployed, then parked on the free - tier's daily cap. Noted honestly below. + tier's daily cap. The note below says why.

          @@ -124,7 +124,7 @@

          The honest scorecard

          request shows its own spans in the trace waterfall.

          - The fifth, parked honestly: Browser Run. Headless Chromium in a Worker + The fifth, parked: Browser Run. Headless Chromium in a Worker via env.BROWSER.quickAction("screenshot", options), the basis for on-the-fly og:image cards. I bind it and the worker deploys; no enable toggle exists (the binding diff --git a/src/pages/garage/compression.html b/src/pages/garage/compression.html index fa7c310b9..3f148b0f0 100644 --- a/src/pages/garage/compression.html +++ b/src/pages/garage/compression.html @@ -180,7 +180,7 @@

          Deltas, and why they ship as dcz

          because decode scales with the CPU while 44 bytes stays 44 bytes.

          - Worth saying plainly: on the old numbers this was a coin flip, and on these it's lopsided. The + On the old numbers this was a coin flip, and on these it's lopsided. The size table now favours brotli by more than it ever did, and that table is the wrong axis to decide on.

          diff --git a/src/pages/garage/encoding.html b/src/pages/garage/encoding.html index de40ae437..ad00a7655 100644 --- a/src/pages/garage/encoding.html +++ b/src/pages/garage/encoding.html @@ -335,14 +335,14 @@

          Chrome paints nothing until every channel has started On a thumbnail that barely matters. The JPG tile is the fallback most browsers never fetch, and it is small: a median 48.7 KB, with the gate at a median 19.3 KB. A 10-packet initial congestion window carries about 14 KB, so 116 of the 255 - reach their gate in the opening flight and the rest one round trip later. The scan search still earns its place + reach their gate in the opening flight and the rest one round trip later. The scan search is still worth running at this size, purely for the bytes it saves.

          It matters at the full-size archive. /images/full/ is the one image surface here with no AVIF tier and no <picture> around it: the worker streams a median 21.6 MB progressive JPEG from R2 (since October 2026 as its lossless JPEG XL copy, about 8% smaller), and that single file is the whole payload. That is also the awkward part of the format story, because AVIF - structurally cannot do this. Its layered mode caps at four stages, paints early only in + can't do this. Its layered mode caps at four stages, paints early only in Chromium, and is staged rather than incremental the way a JPEG scan script is. On the tiles it costs 6 to 10% more bytes (measured below), so nothing here emits it. So the format that wins every byte comparison on this page @@ -406,7 +406,7 @@

          What the archive tier actually costs

          Re-encoding at 4:4:4 instead of 4:2:2 scores 88.28 against 88.15, so the chroma round-trip is not the limit and 4:2:2 keeps its ~13% byte saving for free. Flattening the reference to 8-bit first scores 88.08 against 88.15, so the 10-bit→8-bit step is not the limit either: JPEG is - 8-bit, and a HIF's extra depth earns its keep in the edit.

          + 8-bit, and a HIF's extra depth pays off in the edit.

          That generation also costs far less than it looks once an image is resized for anywhere else. Comparing a direct downscale of the HIF against a downscale of the archive JPEG, the same pair that reads 88.15 at full resolution reads 94.62 at 1080px: averaging ~50 diff --git a/src/pages/garage/hidden-flags.html b/src/pages/garage/hidden-flags.html index 95869de70..e0ce89261 100644 --- a/src/pages/garage/hidden-flags.html +++ b/src/pages/garage/hidden-flags.html @@ -216,7 +216,7 @@

          The inventory

          Two questions, and only one of them is free

          When you find an undocumented option, there are two separate things to establish, and reading the docs answers neither.

          Is it accepted? Does the tool recognise the key at all, or is it dropping an unknown name on the floor? Does it work? Does accepting the key change the behaviour the flag claims to change.

          -

          The first question is usually free, and that is the part people skip. A tool with strict argument parsing answers it through its exit code alone, with no work performed and nothing deployed:

          +

          The first question is usually free, and people skip it. A tool with strict argument parsing answers it through its exit code alone, with no work performed and nothing deployed:

          $ wrangler deploy --x-bogus-flag ; echo $?
           1
           $ wrangler deploy --x-provision=false --dry-run ; echo $?
          @@ -230,8 +230,8 @@ 

          When silence is the failure mode

          A payload schema that refuses unknown keys

          Cloudflare's Browser Run binding takes a JSON payload. A launch post described selecting a rendering engine with one extra key; the Quick Actions reference did not list it. Sending it answered:

          {"code":"unrecognized_keys","keys":["browser"]}
          -

          An invented engine name returned the byte-identical error, which is the useful half of the result. The schema is closed, so it refuses the option rather than failing on the value, and a closed schema is a gift: it answers "is this accepted" for free, every time, with no render performed and no browser minutes spent.

          -

          The same feature then produced the opposite trap through the REST door, where the parameter is merely ignored. A 200 came back either way, so the code labelled the render with the engine it had asked for. On a page whose entire premise is showing what a machine actually saw, that label was a claim nobody had checked. It reads kitesurf-requested now.

          +

          An invented engine name returned the byte-identical error. The schema is closed, so it refuses the option rather than failing on the value, and a closed schema is a gift: it answers "is this accepted" for free, every time, with no render performed and no browser minutes spent.

          +

          The same feature then produced the opposite trap through the REST door, where the parameter is merely ignored. A 200 came back either way, so the code labelled the render with the engine it had asked for. On a page whose premise is showing what a machine actually saw, that label was a claim the code never checked. It reads kitesurf-requested now.

          A compression option three runtimes disagree about

          Dictionary compression takes a dictionary option. Node 26 honours it. Workers ignore it for zstd and say nothing. Bun ignored it through 1.3.14 and fixed it in oven-sh/bun#34427. Every one of those failures is silent, because a frame compressed without a dictionary still decodes perfectly with one. Nothing throws. The only symptom is a byte count that never got smaller.

          @@ -394,7 +394,7 @@

          The rule this leaves

          And prefer reading the definition to running the experiment when the definition is in your node_modules. Two of the four cases here were settled by a source comment and a call site, in less time than a deploy takes, and neither answer could have been reached by watching the tool succeed.

          What would change this

          -

          A tool that accepts unknown flags silently would break the free half outright, and the check for that is the bogus-flag control itself: an exit code of 0 on an invented name means every conclusion on this page has to be re-earned with real runs. A hidden flag becoming documented would move its row from this list to an ordinary reference, which is the outcome to hope for. And a measurement showing the capture does honour waitForTimeout would close the one case here that is deliberately still open.

          +

          A tool that accepts unknown flags silently would break the free half, and the check for that is the bogus-flag control itself: an exit code of 0 on an invented name means every conclusion on this page has to be re-earned with real runs. A hidden flag becoming documented would move its row from this list to an ordinary reference, which is the outcome to hope for. And a measurement showing the capture does honour waitForTimeout would close the one case here that is deliberately still open.

          garageshipped2026-08-24
          @@ -442,7 +442,7 @@

          What would change this

          ] }, { - "q": "The dictionary control runs three times: no option, the right value, a wrong value. Why does the wrong value earn its place?", + "q": "The dictionary control runs three times: no option, the right value, a wrong value. Why run the wrong value at all?", "options": [ { "t": "It ties the smaller output to the value you passed rather than to some other cause.", diff --git a/src/pages/garage/horizon.html b/src/pages/garage/horizon.html index 3c3f16eb3..a9019efbb 100644 --- a/src/pages/garage/horizon.html +++ b/src/pages/garage/horizon.html @@ -1248,7 +1248,7 @@

          Horizon

          quietly change status underneath me. The 07-26 sweep moved four cards (InstallEvent.addRoutes(), compression dictionaries, appearance: base-select, Temporal) and demoted one of them off - "shipped on the site" entirely, which is the more interesting half. Dates + "shipped on the site" entirely. Dates here are when I checked, never when I guessed.

          @@ -1324,8 +1324,8 @@

          Horizon

          differently in the prerelease. Its first two findings moved the margin-trim and JPEG XL cards on 2026-09-17: both flip on in Chrome 155, three weeks before stable, and a third moved the CSS - random() card on 2026-09-30, the day Chrome 156 reached beta. That catches the direction a sweep - structurally cannot, a feature that ships between sweeps, and it catches it + random() card on 2026-09-30, the day Chrome 156 reached beta. That catches what a sweep + can't, a feature that ships between sweeps, and it catches it before the release rather than after. It reads only what a probe can measure, so a card whose status lives in a standards position or a flag description still rots at the old rate. @@ -1337,7 +1337,7 @@

          Horizon

          chromestatus tags shipping_year:2026 turned up six that already ship in two or three engines, which is this page's own bar for graduating, sitting here uncovered. Reading a frontier from Chrome's - release notes structurally cannot see them. It also caught a wrong claim on + release notes can't see them. It also caught a wrong claim on the document-patching card, corrected in place. Version numbers in the new batch come from MDN's browser-compat-data v8.0.8 rather than chromestatus, whose Safari and @@ -1735,7 +1735,7 @@

          more than the animation: startViewTransition defers its callback, so every caller had to move focus and measurement inside the callback, and the ones that forgot ran a frame early against a - still-hidden element. The API is genuinely good, and the demo above is + still-hidden element. The API is good, and the demo above is real; this site just ran out of places where the trade paid.

          @@ -3487,14 +3487,14 @@

          border-shape replaces the rectangle: it sets the geometry of the border box itself, so the border, the border image, the focus ring, and the shadow all follow an arbitrary shape(), polygon(), or - circle(). That is the part clip-path never did. - Clipping cuts the box and takes the border with it, leaving you to fake a stroke; + circle(). clip-path never did that: + clipping cuts the box and takes the border with it, leaving you to fake a stroke; border-shape keeps a real border and clips only the inside.

          - On the site: honestly, close to nothing, and that is worth saying out loud. + On the site: close to nothing. Luna is rectilinear on purpose. A window that isn't a rectangle reads as a - skin, and the whole point of the resto-mod is that the chrome stays + skin, and the resto-mod depends on the chrome staying period-correct. Where it could matter is the focus ring: the site's bevels are stacked box-shadows, and an outline around a bevelled control currently traces the box rather than the bevel. Filed next to @@ -3574,7 +3574,7 @@

          pseudo-elements Firefox never took. So Firefox 155 limits the subset to a short list of domains in the layout.css.fake-webkit-scrollbar.enabled-domains pref, and everywhere else the selector is unknown again. The standard - scrollbar-width and scrollbar-color remain the actual answer. + scrollbar-width and scrollbar-color are still how to style a Firefox scrollbar.

          On the site: nav.js paints a custom XP scrollbar through the @@ -3761,8 +3761,8 @@

          On the site: tried, then removed. It shipped here for most of 2026 and every route opted in inline, with a hand-tuned XP minimize/restore - instead of a cross-fade. It came out on 2026-07-30 for a reason worth stating - plainly, because it is the same reason twice. The Speculation-Rules prerender + instead of a cross-fade. It came out on 2026-07-30, and the reason showed up + twice. The Speculation-Rules prerender two cards down already loads the next page on hover-intent, so by click time the document is rendered and activation is free. The transition then spent ~200ms animating over a navigation that had nothing left to hide. Two features @@ -3971,9 +3971,9 @@

          now two engines rather than one and a plan.

          - On the site: marginal, honestly. The homepage is ~22 KB brotli and - nav.js is small, so the delta savings stay tiny in absolute terms. It'd earn - its keep if those grew. Cloudflare shipped its edge half as + On the site: marginal. The homepage is ~22 KB brotli and + nav.js is small, so the delta savings stay tiny in absolute terms. It'd + matter if those grew. Cloudflare shipped its edge half as Shared Dictionaries, still passthrough open beta on all plans (Apr 30 2026): my origin owns the dictionary lifecycle and the edge just forwards the headers and dcb/dcz @@ -4196,9 +4196,9 @@

          them arriving fast while three straggle. That distinction is invisible to LCP and would be one containertiming attribute on <section class="photos">. The same applies to the now-playing - list, which is the other server-rendered chunk. Genuinely useful, genuinely - single-engine, and measurement rather than user-facing behaviour, so it costs - nothing to wait. + list, which is the other server-rendered chunk. It's useful, it's still + single-engine, and it measures rather than changing what a visitor sees, so + waiting costs nothing.

          No container entry type in this browser's PerformanceObserver.

          @@ -4404,8 +4404,8 @@

          luck. The one place it would matter is /lens, which fetches arbitrary URLs and renders what comes back; that path builds text nodes and escapes on the way out rather than parsing hostile markup, so it - sidesteps the question instead of answering it. Where this genuinely - changes the calculus is DOMPurify: a ~20KB dependency whose whole job + sidesteps the question instead of answering it. Where it does change the + calculus is DOMPurify: a ~20KB dependency whose whole job the platform now does, which is the kind of subtraction this site optimises for. Two engines, so by the rule at the top it has graduated.

          @@ -4435,7 +4435,7 @@

          On the site: nothing here plays media, so this is a card about the sweep - rather than about a plan. It earns its place because it is the clearest + rather than about a plan. It stays because it is the clearest example of the failure mode the note above describes. It shipped in WebKit four years ago, shipped in Gecko this year, is an Interop priority, and still never crossed my desk, because features that arrive @@ -4534,7 +4534,7 @@

          These two most likely look identical to you, which is the honest result of a working demo: on Latin text at this size auto already skips every descender that crosses the rule, - so all has nothing left to do. The keyword earns its keep + so all has nothing left to do. The keyword helps where the heuristic under-skips, which is scripts with deep or frequent descenders rather than Verdana running English.

          @@ -4839,7 +4839,7 @@

          On the site: this lands in the middle of a problem already written up in this repo. The Workers Traces work found that a span cannot measure CPU, because the runtime's clock advances across I/O and never during - synchronous execution, so home.grid.render honestly reports + synchronous execution, so home.grid.render reports 0 ms after doing real work. That is the server-side version of exactly this ambiguity, and presentationTime is the client-side fix for it: the homepage's LCP is a photo tile, and @@ -5109,7 +5109,7 @@

          Sweep note: one preview build, six cards

          Temporal, CloseWatcher, popover=hint, if(), @function, and the attr() function below. Technology Preview notes aren't release notes, so a sweep that reads - "New in Safari N" posts structurally cannot see any of it, and a + "New in Safari N" posts can't see any of it, and a card can sit on "Safari no" for a year after WebKit started answering. Versions here are MDN browser-compat-data v8.0.10 (2026-08-06), cross-checked against the STP 249 release notes. @@ -5160,9 +5160,8 @@

          The text cursor has been a thin blinking bar in every browser since there were browsers, and caret-shape is one property that makes it a filled - block or an underscore instead. It is a small thing that is unusually load-bearing - for one specific look, because on a terminal the block caret is how you know - it is a terminal. Click into the field below (Chrome 144+ to + block or an underscore instead. It's a small property that one look depends + on: on a terminal, the block caret is how you know it's a terminal. Click into the field below (Chrome 144+ to see it):

          @@ -5253,8 +5252,8 @@

          component, per-page override), and every place a media query needs to back one layer off currently re-declares the value underneath it by hand. That is the exact duplication this keyword deletes. Cross-engine, cheap, and the only reason - it isn't already in luna.css is that nobody knew it had - finished. + it isn't already in luna.css is that I hadn't noticed it + had finished.

          No revert-rule here; the keyword is dropped as invalid and the declaration above it wins.

          @@ -5387,7 +5386,7 @@

          On the site: nav.js's Run palette scrolls a matched result into view and then wants to focus it, and today that is a setTimeout with a number picked by feel. A promise turns a guess into an await. One engine, so - the guess stays, but this is the kind of card that graduates quietly the moment a + the guess stays, but this card can graduate with no code change the moment a second engine lands, because the fallback and the feature are the same two lines.

          No promise returned here, so the demo reports what it got back instead.

          diff --git a/src/pages/garage/index.html b/src/pages/garage/index.html index 50b44d1c6..c84621c13 100644 --- a/src/pages/garage/index.html +++ b/src/pages/garage/index.html @@ -653,7 +653,7 @@

          Garage

          photo), Workers Logs (structured observability) and custom trace spans: one live demo each, behind a tiny worker at /garage/cf/*. The - free Workers plan quietly bundles them; all four run live on the page. A fifth, + free Workers plan bundles them; all four run live on the page. A fifth, Browser Run, is wired but parked on the free tier's 10-min/day cap.
          diff --git a/src/pages/garage/octane.html b/src/pages/garage/octane.html index 4ba67f6a2..0ed8caf2f 100644 --- a/src/pages/garage/octane.html +++ b/src/pages/garage/octane.html @@ -55,7 +55,7 @@

          Octane, priced against no framework

          -

          A benchmark told me Nextane ships 60.9% less client JavaScript than Next.js 16. That is a real number, honestly measured, and I checked it against the deployed demo. What the table cannot tell you is where zero sits, because every row in it is a framework. This site makes a useful zero: it ships no framework at all, so putting it on the same axis turns a ranking into a distance.

          +

          A benchmark told me Nextane ships 60.9% less client JavaScript than Next.js 16. That is a real number, and I checked it against the deployed demo. What the table cannot tell you is where zero sits, because every row in it is a framework. This site makes a useful zero: it ships no framework at all, so putting it on the same axis turns a ranking into a distance.

          The benchmark, and what it leaves out

          Nextane rebuilds the Next.js Pages Router on Octane, a compiled React replacement descended from Inferno that drops the virtual DOM and has its compiler infer reactivity instead of asking you for dependency arrays. Its README publishes this, measured on one small hydrated getServerSideProps page:

          @@ -78,8 +78,8 @@

          Re-measuring it on the live demo

          router-bwM-JHXE.js125,15240,04835,159 JavaScript total133,34543,38438,133 -

          43,384 bytes gzip against a claimed 43.0 KiB. The benchmark holds, which is worth saying plainly, because a page that only re-measures claims it can knock down is not measuring.

          -

          One thing to notice before moving on. The document is 2,652 bytes and the JavaScript is 133,345. The page's actual content is 2% of what the page costs.

          +

          43,384 bytes gzip against a claimed 43.0 KiB. The benchmark holds. I say so because a page that only re-measures claims it can knock down isn't measuring.

          +

          The document is 2,652 bytes and the JavaScript is 133,345, so the page's actual content is 2% of what the page costs.

          Where the baseline lands

          Predict before you reveal. The three framework rows are drawn below. This site's homepage and its heaviest page are hidden. Guess where they fall relative to the 43.0 KiB row that won the benchmark.

          @@ -109,7 +109,7 @@

          The chunk is the framework

          What the comparison actually measures

          Here is where I argue against my own table. A framework competes with the version of your application you would otherwise hand-write and then maintain for years, which is a different opponent from a hand-written document. Octane's 43 KiB buys a component model, hydration, Suspense, and a compiler that tracks what your code reads so you stop maintaining dependency arrays. This site buys none of that because it needs none of it. It serves documents, and its interactivity is a desktop shell bolted on top of them.

          -

          So the honest reading is narrower than "frameworks are heavy." A framework's floor is set by its runtime, and this site's floor is set by how much behaviour it actually has. Those are different quantities. A benchmark containing only frameworks quietly makes them look like one quantity, and that is the thing the extra rows are for.

          +

          So the honest reading is narrower than "frameworks are heavy." A framework's floor is set by its runtime, and this site's floor is set by how much behaviour it actually has. Those are different quantities. A benchmark containing only frameworks makes them look like one quantity, and the extra rows are there to break that.

          Where Octane would win here

          nav.js is the one module on this site with enough state to make the question live. It handles window dragging and resizing, taskbar state, a command palette filtering 158 photos alongside every page and six profiles, and icon positions that deliberately reset each visit. All of it is imperative DOM code, and all of it is 14.9 KiB.

          diff --git a/src/pages/garage/pqc.html b/src/pages/garage/pqc.html index 98fa5d6c4..2ae0e5571 100644 --- a/src/pages/garage/pqc.html +++ b/src/pages/garage/pqc.html @@ -108,13 +108,13 @@

          Why the hash-based options lost

          Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a Worker isolate is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints.

          Shipping it without a codepoint

          -

          Here is the part that made this cheap. The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

          +

          The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

          But Signature-Input and Signature are structured-fields Dictionaries. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:

          Signature-Input: sig1=("@authority" "signature-agent");created=1785186311;
                              keyid="091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o";alg="ed25519";tag="web-bot-auth",
                            sig2=("@authority" "signature-agent");created=1785186311;
                              keyid="rn-mldsa-2026-07-27";alg="ml-dsa-44";tag="web-bot-auth"
          -

          A verifier that only knows Ed25519 reads sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says plainly that you should verify sig1 and ignore sig2.

          +

          A verifier that only knows Ed25519 reads sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says that you should verify sig1 and ignore sig2.

          The public key is published under the same JWKS as an AKP key per RFC 9964, which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:

          Live, from this site's key directory
          diff --git a/src/pages/garage/pretext.html b/src/pages/garage/pretext.html index ddf96f4ec..27f2a559c 100644 --- a/src/pages/garage/pretext.html +++ b/src/pages/garage/pretext.html @@ -208,10 +208,10 @@

          5 · The payoff: re-layout cost

          6 · Where it'd actually help aadhar.sh

            -
          • Honestly: not much yet. The site's text is tiny: +
          • Not much yet. The site's text is tiny: a dozen track rows, a 3×4 photo grid, one tooltip at a time. A handful of one-off measurements is exactly the case where the DOM is - fine. pretext earns its keep at volume, which this homepage + fine. pretext pays off at volume, which this homepage doesn't have.
          • The real candidate is serendipity. If the event / attendee lists ever get virtualized (only render what's @@ -255,7 +255,7 @@

            7 · Tradeoffs (why it stays "watching")

          - Verdict: a genuinely clever inversion. Treat the + Verdict: a clever inversion. Treat the canvas as the measurement oracle and keep re-layout in pure arithmetic. But its win scales with how often you measure, and aadhar.sh measures almost nothing. Filed under watching: the moment a virtualized list shows diff --git a/src/pages/garage/resample.html b/src/pages/garage/resample.html index a63552491..614d04d85 100644 --- a/src/pages/garage/resample.html +++ b/src/pages/garage/resample.html @@ -59,7 +59,7 @@

          Why 128 is the wrong grey

          Textures are where this lands hardest, because texture is exactly where neighbouring pixels differ most. A photograph reduced this way loses contrast in foliage, fabric, hair and grain, and gains it nowhere.

          The resampler, with every operational concern taken back out

          -

          Here is the whole idea at an integer reduction factor. It is the load-bearing version, and the shipping one is a few hundred lines around this shape.

          +

          Here is the resampler at an integer reduction factor. This version carries the meaning, and the shipping one is a few hundred lines around this shape.

          function reduce(src, w, factor) {
             const out = [], n = factor * factor;
             for (let y = 0; y < w / factor; y++)
          @@ -72,7 +72,7 @@ 

          The resampler, with every operational concern taken back out

          } return out; }
          -

          Convert to light, average, convert back. Everything the real implementation adds is a separate concern wearing this function's clothes, and each one earns its place for a reason worth naming.

          +

          Convert to light, average, convert back. Everything the real implementation adds is a separate concern wearing this function's clothes, and each one is below with the reason it's there.

          Arbitrary ratios need weights. An integer factor lets every source sample count once. A 3.86 times reduction does not, so each output sample carries a list of taps and weights instead of a rectangle.

          The filter has to widen with the reduction. On a downscale the filter must low pass at the output Nyquist, so its support in source pixels grows by the reduction factor. A kernel evaluated at fixed width is sampling rather than filtering, and it aliases. This is why naive Lanczos3 downscales shimmer.

          Weights are renormalised per output sample. At the frame edge the support window is clipped, and the weights that survive have to sum to one again or the border darkens into a vignette nobody can explain.

          diff --git a/src/pages/garage/scroll.html b/src/pages/garage/scroll.html index 453591e82..b473a59bd 100644 --- a/src/pages/garage/scroll.html +++ b/src/pages/garage/scroll.html @@ -314,7 +314,7 @@

          3 · The Luna scrollbar (scroll inside the b

          Scroll this box to drive it. WebKit/Blink get the full gradient thumb + buttons; Firefox gets the two-tone scrollbar-color (thumb + track), no buttons.

          On the homepage the photo wall and tracklist would scroll inside windows that actually look like windows, right down to the chrome.

          Keep scrolling… the thumb is the gradient, the track is the lighter channel beside it.

          -

          That's the whole trick. No images, no JS, just a styled native scrollbar.

          +

          It takes no images and no JS, just a styled native scrollbar.

          ::-webkit-scrollbar-thumb { background: linear-gradient(90deg, …); } diff --git a/src/pages/garage/tooltips.html b/src/pages/garage/tooltips.html index 781d0c924..2e911910a 100644 --- a/src/pages/garage/tooltips.html +++ b/src/pages/garage/tooltips.html @@ -621,7 +621,7 @@

          Tooltip Experiments

          Popover API for show/hide, @starting-style for the entry fade, and two custom-property writes per move for cursor tracking. No rAF, no layout reads.

          - The shipping version is roughly 100 lines. Most of the difference is the markup each variant renders, and the rest is four guards that have nothing to do with tooltips. An activeSlot check, so moving across a slot's children does not re-render it. A :popover-open test before each show and hide, so the platform call only fires on a real state change. A relatedTarget containment test, so moving to another element inside the grid is not read as leaving it. And the clamp() wrapped around each translate, which is viewport edge handling, a separate job from placement. Every one earns its keep and none of them is the idea. + The shipping version is roughly 100 lines. Most of the difference is the markup each variant renders, and the rest is four guards that have nothing to do with tooltips. An activeSlot check, so moving across a slot's children does not re-render it. A :popover-open test before each show and hide, so the platform call only fires on a real state change. A relatedTarget containment test, so moving to another element inside the grid is not read as leaving it. And the clamp() wrapped around each translate, which is viewport edge handling, a separate job from placement. Each one does a real job, and none of them is the idea.

          ← back to garage diff --git a/src/pages/garage/typed-config.html b/src/pages/garage/typed-config.html index 2cf7f803b..509016f59 100644 --- a/src/pages/garage/typed-config.html +++ b/src/pages/garage/typed-config.html @@ -74,7 +74,7 @@

          Two runs, two toolchains

          Where the two runs disagree, the page says so in place and keeps the August result beside the October one.

          The thing a config cannot do

          -

          Here is the whole problem in one line. In August this site's Worker declared 21 bindings across two config files, and its request handler typed them like this:

          +

          The problem fits in one line. In August this site's Worker declared 21 bindings across two config files, and its request handler typed them like this:

          @typedef {(request: Request, env: any, ctx: any, url: URL) => Response} RouteHandler

          any. Every KV namespace, every D1 database, the R2 bucket, the Durable Object, all of it. Rename a binding in wrangler.jsonc and nothing in the tree goes red; the failure arrives at runtime as a property read on undefined, in production, on whichever route touched it first.

          That is a property of the format. JSON cannot export a type. The usual repair is wrangler types, which reads the config and writes a .d.ts, and it works: it produces the full interface, Durable Object generics and all. On 4.125.0 and on 4.147.0 alike, it also prints this when it finishes.

          @@ -140,7 +140,7 @@

          The row that went backwards, and the way around it

          // in the binding Worker's env: COUNTER: bindings.durableObject({ worker: counterWorker, exportName: "Counter" }),

          Now env.COUNTER types as DurableObjectNamespace<Counter>, and await stub.increment(1) is a number the compiler checks. A Workflow bound the same way types as Workflow<{ slot: string }>. The resolved JSON is the same as the string form, "worker": "probe-counter", so the definition buys a type and changes nothing the platform receives.

          -

          Two controls say which half does the work. The same definition with a path-string entrypoint gives DurableObjectNamespace<DurableObjectBranded>, which still has no methods, so the module import is load-bearing. And a Worker that binds its own class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to never. The build also never type-checks the config. A Workflow binding whose exportName the definition did not declare was a compile error, and wrangler build still exited 0.

          +

          Two controls say which half does the work. The same definition with a path-string entrypoint gives DurableObjectNamespace<DurableObjectBranded>, which still has no methods, so the module import is required. And a Worker that binds its own class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to never. The build also never type-checks the config. A Workflow binding whose exportName the definition did not declare was a compile error, and wrangler build still exited 0.

          So the scoreboard moved. Cross-Worker Durable Objects and Workflows now type as precisely as wrangler types types them, and stay current without a regeneration step. A Worker that binds its own Durable Object, the common single-Worker case, still types worse than the snapshot does.

          Migrations become a state on an export

          @@ -152,7 +152,7 @@

          Migrations become a state on an export

          renamed_classes = [{ from, to }]Old: exports.durableObject({ state: "renamed", renamedTo: "New" })

          This is a different upload path. Wrangler 4.147.0 still decides between them in one function, resolveDoLifecyclePayload: when a config declares Durable Object exports it sends those exports and sends no migrations at all, and it computes a migration only for configs that declare none.

          -

          In August that left one question a dry run structurally cannot answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin b168333) against a Counter created under tag = "v1", the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers wrangler deploy. It says nothing about versions upload, which refuses Durable Object lifecycle changes on its own path.

          +

          In August that left one question a dry run can't answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin b168333) against a Counter created under tag = "v1", the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers wrangler deploy. It says nothing about versions upload, which refuses Durable Object lifecycle changes on its own path.

          One thing it still will not do, and one it now does

          The dependency instrumentation key still has no home. The old config set [dependencies_instrumentation] enabled = true, and config 0.23.0 has no field for it. The escape hatch offers no route around it, because unsafe carries metadata and capnp schemas alone. This cost nothing, for a reason only readable in wrangler's source: 4.147.0 still tests the key as enabled !== false, so an absent block behaves exactly like the explicit true. A dry run reports on none of this, which is why the answer came from reading the upload path.

          diff --git a/src/pages/garage/useragent.html b/src/pages/garage/useragent.html index cb7de52a2..00d06dab4 100644 --- a/src/pages/garage/useragent.html +++ b/src/pages/garage/useragent.html @@ -302,8 +302,8 @@

          The program that already runs this, and the part still waiting

          } })(); -

          That row sends the pointer and signs nothing. It sets Signature-Agent to a directory that genuinely exists and omits Signature and Signature-Input entirely, so an origin actually verifying has everything it needs to reject the request. Read it against the plain ua-survey (ours) row in the matrix, which is the same user-agent carrying no header at all.

          -

          Worth stating the limit of that probe. It shows whether an origin reacts to an unsigned Web Bot Auth attempt, and it cannot show whether an origin would honour a correctly signed one, because signing needs AadharshBot's private key and that key is a Worker secret this survey cannot reach. A run from inside the Worker would answer the second question and is the obvious next experiment.

          +

          That row sends the pointer and signs nothing. It sets Signature-Agent to a real directory and omits Signature and Signature-Input entirely, so an origin actually verifying has everything it needs to reject the request. Read it against the plain ua-survey (ours) row in the matrix, which is the same user-agent carrying no header at all.

          +

          That probe shows whether an origin reacts to an unsigned Web Bot Auth attempt, and it cannot show whether an origin would honour a correctly signed one, because signing needs AadharshBot's private key and that key is a Worker secret this survey cannot reach. A run from inside the Worker would answer the second question and is the obvious next experiment.

          One more data point arrived by accident while this page was being written. Cloudflare's own Radar bot directory answered 403 to the unverified fetcher used to read it, and served the same page to an ordinary browser seconds later. The registry of verified bots sits behind the wall that verification exists to open.

          What this site serves

          diff --git a/src/pages/garage/vt-check.html b/src/pages/garage/vt-check.html index 3b5f81e1b..d4af0ee74 100644 --- a/src/pages/garage/vt-check.html +++ b/src/pages/garage/vt-check.html @@ -36,7 +36,7 @@

          View Transition diagnostic (page A)

          @view-transition site-wide on 2026-07-30: every internal page is prerendered on hover, so the document is already rendered at click time and the transition only added ~200ms over a navigation with nothing left to wait for. - This diagnostic pair keeps its own opt-in because it tests the browser on its own, which is the whole point of a mule. + This diagnostic pair keeps its own opt-in because it tests the browser on its own, which is what a mule is for. Navigate to page B → diff --git a/src/pages/garage/wire.html b/src/pages/garage/wire.html index 54b0a8c27..f666a0374 100644 --- a/src/pages/garage/wire.html +++ b/src/pages/garage/wire.html @@ -92,8 +92,8 @@

          Bytes on the wire

          This site was no-build on principle: every page inline, every byte committed - the way it ships. This is the story of the first build step that earned its - place anyway and the brotli rabbit hole that led there. It also covers the + the way it ships. This page covers the first build step it took on anyway, + and the brotli rabbit hole that led there. It also covers the Oxc and Lightning CSS knobs that survived contact with the real site, and the cache layer that finally @@ -189,7 +189,7 @@

          Keeping View Source honest

          Two details I did not expect to enjoy this much. The build has tripwires: it fails the deploy if CSS emits a warning, loses a required route marker, or breaks the stylesheet's known-good rules, so - "the transform broke something silently" is structurally impossible. And + the transform can't break any of those three silently. And Cloudflare's asset storage is content-addressed, so on the first minified deploy the six .src.js twins uploaded as "already uploaded": their bytes were identical to the shells the previous deploy had @@ -263,7 +263,7 @@

          What about the CSS?

          transition:filter 60ms ease-out} -

          The gap is the whole story. JS shrinks 44% because a minifier can rename +

          JS shrinks 44% because a minifier can rename things: every local variable collapses to one letter, information genuinely leaves the file. CSS shrinks 15% because nothing in it is renamable: selectors and properties are the page's public API, so all a CSS minifier can remove is @@ -271,7 +271,7 @@

          What about the CSS?

          0% and 100% gradient stops). And whitespace is exactly the thing brotli already compresses to almost nothing.

          -

          So the real question is "does the WIRE get smaller after brotli?" This site's CSS lives in four habitats, and I measured all +

          So what I needed to know was whether the WIRE gets smaller after brotli. This site's CSS lives in four habitats, and I measured all four:

          @@ -442,7 +442,7 @@

          The fifth road: stop rendering per request

          The shape lives in one module, lib/island.ts. The placeholder comes from the same renderer as the fragment, so the swap moves nothing. The fragment is preloaded from <head> with - crossorigin, which is load-bearing even same-origin: without it + crossorigin, which is required even same-origin: without it the preload can't satisfy the script's fetch and the fragment downloads twice. The loader refuses any response missing an x-island marker, since an edge 5xx is HTML too. A failed fetch leaves the placeholder and says so. diff --git a/src/pages/garage/workers.html b/src/pages/garage/workers.html index b1e59ecf1..c59c530d2 100644 --- a/src/pages/garage/workers.html +++ b/src/pages/garage/workers.html @@ -106,7 +106,7 @@

          The real payoff: configuration as code

          What it opened up

          Given the constraint I care about most here (this site is frameworkless and - buildless, and staying that way), the interesting question was what the platform can do once the config is code. Four things I have already used, + buildless, and staying that way), I wanted to know what the platform can do once the config is code. Four things I have already used, and a few more now in reach.