From 3307e3af6cc255e82339937efd8681de1a9c5648 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 15 Sep 2026 15:57:00 +0200 Subject: [PATCH 1/3] docs(changelog): link the 0.6.0 comparison to the plain v0.5.0 tag 0.6.0 is the first release tagged with its component, so release-please compared it against duxt@v0.5.0, a tag that never existed: 0.5.0 was cut as v0.5.0. Later releases compare two component tags and are unaffected. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6dcd3c4..dc21f227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [0.6.0](https://github.com/kirchDev/duxt/compare/duxt@v0.5.0...duxt@v0.6.0) (2026-09-15) +## [0.6.0](https://github.com/kirchDev/duxt/compare/v0.5.0...duxt@v0.6.0) (2026-09-15) ### Features From 8b52a6d27a3cd4fd7d9bc76fa528929b5764494d Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 15 Sep 2026 16:27:14 +0200 Subject: [PATCH 2/3] fix(www): make the prerender benchmark compare like with like Run 34791359819 retained concurrency 8 while 4 and 12 were each about 25% faster, for two reasons that were not about concurrency. Every candidate was refused for fewer OG images than the best build, but the shortfall sat in repetition 1 at every setting, the baseline included; completeness is now judged against the fullest build of the same repetition. And each setting ran on its own matrix leg, so a slower machine read as a slower setting; every setting is now built on one runner, interleaved, with the order rotating. --- .github/workflows/prerender-bench.yml | 167 +++++++++++-------------- apps/www/scripts/prerender-bench.ts | 23 +++- apps/www/tests/prerender-bench.test.ts | 47 +++++++ 3 files changed, 141 insertions(+), 96 deletions(-) diff --git a/.github/workflows/prerender-bench.yml b/.github/workflows/prerender-bench.yml index 9f42e812..2d99cb51 100644 --- a/.github/workflows/prerender-bench.yml +++ b/.github/workflows/prerender-bench.yml @@ -16,9 +16,16 @@ # was named as one in advance. # # MANUAL, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT. One build of this -# site prerenders 1,205 routes and takes minutes; eighteen of them is around an -# hour of runner time. On any automatic trigger that is not a measurement, it is -# a bill. +# site prerenders over 5,000 routes and takes minutes; eighteen of them on one +# runner is around three hours. On any automatic trigger that is not a +# measurement, it is a bill. +# +# ONE RUNNER, NOT A MATRIX. The first run (34791359819) gave every setting a +# matrix leg of its own, and 8 came out 25% slower than both 4 and 12 — which +# no contention model predicts and a slower machine explains. Equal core counts +# do not make equal machines, so every setting is built on the same runner, +# interleaved, and the order rotates each repetition so no setting always +# inherits the coldest Vite and Nitro caches. # # IT CANNOT BE RUN UNTIL THIS FILE IS ON `main`, and that is GitHub's rule # rather than this repository's: a `workflow_dispatch` workflow is only @@ -56,44 +63,13 @@ permissions: contents: read jobs: - plan: - name: Plan - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.plan.outputs.matrix }} - steps: - # The comma-separated input, as the JSON array a matrix takes. Done here - # rather than in the matrix expression because `fromJSON` cannot split a - # string, and a matrix that silently became one job named "4,8,12" would - # run a sixth of the benchmark and report it as all of it. - - name: Expand the settings to compare - id: plan - env: - SETTINGS: ${{ inputs.concurrency }} - run: | - matrix=$(node -e ' - const values = process.env.SETTINGS.split(",") - .map((value) => Number(value.trim())) - .filter((value) => Number.isInteger(value) && value > 0); - if (values.length === 0) throw new Error("No concurrency to compare."); - process.stdout.write(JSON.stringify(values)); - ') - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - measure: - name: Prerender at ${{ matrix.concurrency }} - needs: plan + name: Prerender every setting runs-on: ubuntu-latest - # Six full builds of the site, plus the install and the content parse in - # front of them. - timeout-minutes: 120 - strategy: - # ONE SETTING'S FAILURE IS EVIDENCE, NOT A REASON TO STOP. A concurrency - # that cannot finish a build is exactly what this is looking for, and the - # verdict below refuses a candidate whose builds did not all succeed. - fail-fast: false - matrix: - concurrency: ${{ fromJSON(needs.plan.outputs.matrix) }} + # Six full builds per setting, all on this one runner, plus the install and + # the content parse in front of them. Under the six-hour ceiling a hosted + # runner allows, with room for a slow day. + timeout-minutes: 300 steps: - uses: actions/checkout@v6 with: @@ -128,26 +104,29 @@ jobs: path: ${{ steps.sources.outputs.paths }} key: ${{ steps.sources.outputs.key }} - # THE MEASUREMENT. Three cold/warm pairs, in that order, because a warm - # build is defined by #44 as one restoring the cache the immediately - # preceding equivalent build produced — so the pairing is local to this - # job and no Actions cache is involved at all. That is the more controlled - # arrangement as well as the simpler one: a restored cache from another - # commit would make "warm" mean a different thing per run. + # THE MEASUREMENT. Per repetition, every setting gets a cold/warm pair, in + # that order, because a warm build is defined by #44 as one restoring the + # cache the immediately preceding equivalent build produced — so the + # pairing is local to this job and no Actions cache is involved at all. # # `.output` is removed before every build so the OG image count is a fact # about THIS build. `.cache/og-image` is removed before a cold one and # left alone before a warm one; that single line is the whole cold/warm # distinction. # - # THE FIRST COLD BUILD IN A JOB IS THE SLOWEST AND IS LEFT THAT WAY. It - # pays for an empty Vite and Nitro cache that the five after it inherit. - # Three repetitions and a MEDIAN are what answer that: an outlier at - # either end never sits in the middle of three. Reporting it would be - # honest; removing it would be tuning the measurement to the answer. - - name: Build six times at concurrency ${{ matrix.concurrency }} + # THE ORDER ROTATES. The first build on a runner pays for an empty Vite + # and Nitro cache that every build after it inherits. With a fixed order + # the same setting would pay it every time; rotated, each setting leads + # one repetition, and three repetitions plus a MEDIAN keep that outlier + # out of the middle. Reporting it is honest; removing it would be tuning + # the measurement to the answer. + # + # ONE SETTING'S FAILED BUILD IS EVIDENCE, NOT A REASON TO STOP — the loop + # records `ok=false` and carries on, and the verdict refuses a candidate + # whose builds did not all succeed. + - name: Build every setting, interleaved env: - SETTING: ${{ matrix.concurrency }} + SETTINGS: ${{ inputs.concurrency }} REPETITIONS: ${{ inputs.repetitions }} run: | # `pipefail` for the reason deploy.yml states: the build is teed into @@ -156,67 +135,67 @@ jobs: # the one field the verdict refuses candidates on. set -o pipefail + # The comma-separated input, validated. A list that silently lost a + # value would run part of the benchmark and report it as all of it. + read -r -a settings <<< "$(node -e ' + const values = process.env.SETTINGS.split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isInteger(value) && value > 0); + if (values.length === 0) throw new Error("No concurrency to compare."); + process.stdout.write(values.join(" ")); + ')" + count=${#settings[@]} + for run in $(seq 1 "$REPETITIONS"); do - for state in cold warm; do - rm -rf apps/www/.output - if [ "$state" = cold ]; then rm -rf apps/www/.cache/og-image; fi + for offset in $(seq 0 $((count - 1))); do + setting=${settings[$(((run - 1 + offset) % count))]} + + for state in cold warm; do + rm -rf apps/www/.output + if [ "$state" = cold ]; then rm -rf apps/www/.cache/og-image; fi - started=$(date +%s%3N) - # `/usr/bin/time` for #44's "resource use where available": `%M` - # is the peak resident set across the whole process tree, which is - # the number that says whether a faster setting bought its time - # with memory. Its own output goes to a file so the build's stays - # on the pipe and reaches `tee` unchanged. - if /usr/bin/time -f '%M' -o rss.txt \ - env DUXT_PRERENDER_CONCURRENCY="$SETTING" pnpm build:www 2>&1 \ - | tee build.log; then ok=true; else ok=false; fi - elapsed=$(($(date +%s%3N) - started)) + started=$(date +%s%3N) + # `/usr/bin/time` for #44's "resource use where available": `%M` + # is the peak resident set across the whole process tree, which + # says whether a faster setting bought its time with memory. Its + # own output goes to a file so the build's stays on the pipe. + if /usr/bin/time -f '%M' -o rss.txt \ + env DUXT_PRERENDER_CONCURRENCY="$setting" pnpm build:www 2>&1 \ + | tee build.log; then ok=true; else ok=false; fi + elapsed=$(($(date +%s%3N) - started)) - node ./packages/duxt/bin/duxt-og-cache.mjs --root apps/www --report --github \ - --since "$started" --log build.log > og.txt + node ./packages/duxt/bin/duxt-og-cache.mjs --root apps/www --report --github \ + --since "$started" --log build.log > og.txt - node apps/www/scripts/prerender-bench.ts --measure \ - --concurrency "$SETTING" --state "$state" --run "$run" \ - --log build.log --og og.txt --build-ms "$elapsed" --ok "$ok" \ - --max-rss-kb "$(tail -n 1 rss.txt)" --cores "$(nproc)" \ - >> "runs-$SETTING.jsonl" + node apps/www/scripts/prerender-bench.ts --measure \ + --concurrency "$setting" --state "$state" --run "$run" \ + --log build.log --og og.txt --build-ms "$elapsed" --ok "$ok" \ + --max-rss-kb "$(tail -n 1 rss.txt)" --cores "$(nproc)" \ + >> runs.jsonl - echo "recorded concurrency $SETTING, $state run $run (ok=$ok)" + echo "recorded concurrency $setting, $state run $run (ok=$ok)" + done done done - # THIS LEG'S OWN NUMBERS, while the others are still building. Reading the - # setting as its own baseline gives a one-row table and no comparison, - # which is exactly what a single leg has to say — the verdict job is where - # the three are put against each other. - - name: Summarise concurrency ${{ matrix.concurrency }} - if: always() && hashFiles(format('runs-{0}.jsonl', matrix.concurrency)) != '' - env: - SETTING: ${{ matrix.concurrency }} - run: | - node apps/www/scripts/prerender-bench.ts --verdict \ - --results "runs-$SETTING.jsonl" --baseline "$SETTING" \ - >> "$GITHUB_STEP_SUMMARY" - - # ALWAYS, AND THAT IS THE WHOLE REASON IT IS GUARDED RATHER THAN PLAIN. An - # hour of builds lives in this one file; a step that failed after four of + # ALWAYS, AND THAT IS THE WHOLE REASON IT IS GUARDED RATHER THAN PLAIN. + # Hours of builds live in this one file; a step that failed after four of # them still has four measurements worth keeping, and without `always()` # they go in the bin with the runner. - uses: actions/upload-artifact@v7 - if: always() && hashFiles(format('runs-{0}.jsonl', matrix.concurrency)) != '' + if: always() && hashFiles('runs.jsonl') != '' with: - name: prerender-bench-${{ matrix.concurrency }} - path: runs-${{ matrix.concurrency }}.jsonl + name: prerender-bench-measured + path: runs.jsonl retention-days: 7 if-no-files-found: error verdict: name: Verdict needs: measure - # EVEN WHEN A SETTING FAILED. A matrix leg that could not finish is a + # EVEN WHEN THE MEASUREMENT FAILED PART-WAY. The builds it did record are a # finding, and the rule refuses a candidate whose builds did not all - # succeed — so the table is still worth printing, and the leg that survived - # is still the baseline it has to be compared against. A matrix that never + # succeed — so the table is still worth printing. A measurement that never # started is the one case with nothing to say. if: always() && needs.measure.result != 'skipped' runs-on: ubuntu-latest diff --git a/apps/www/scripts/prerender-bench.ts b/apps/www/scripts/prerender-bench.ts index c35c0508..dd685972 100644 --- a/apps/www/scripts/prerender-bench.ts +++ b/apps/www/scripts/prerender-bench.ts @@ -370,6 +370,23 @@ export function prerenderBenchVerdict( .filter((count): count is number => count !== undefined); const expectedImages = images.length ? Math.max(...images) : undefined; + // COMPLETE IS JUDGED PER REPETITION. The first benchmark wrote 1,315 images + // in repetition 1 and 1,399 in the two after it, at every setting alike, so + // the count followed the repetition rather than the concurrency. Held to the + // overall maximum, every candidate was refused for a shortfall the baseline + // shared. The fullest build of the same repetition, across all settings, is + // what a complete build of THAT repetition looks like. + const expectedByRepetition = new Map(); + for (const run of input.runs) { + if (run.images === undefined) continue; + expectedByRepetition.set( + run.run, + Math.max(expectedByRepetition.get(run.run) ?? 0, run.images) + ); + } + const expectedFor = (run: PrerenderRun) => + expectedByRepetition.get(run.run) ?? expectedImages; + const at = (concurrency: number) => input.runs.filter((run) => run.concurrency === concurrency); @@ -443,13 +460,15 @@ export function prerenderBenchVerdict( ); } else { const short = runs.filter( - (run) => run.images === undefined || run.images < expectedImages + (run) => run.images === undefined || run.images < expectedFor(run)! ); if (short.length > 0) { + const expected = [...new Set(short.map(expectedFor))].join(', '); refusals.push( `${short.length} build${short.length === 1 ? '' : 's'} produced ` + - `fewer than the ${expectedImages} OG images the best build did.` + `fewer OG images than the fullest build of the same repetition ` + + `(${expected}).` ); } } diff --git a/apps/www/tests/prerender-bench.test.ts b/apps/www/tests/prerender-bench.test.ts index 65054514..603ea7d3 100644 --- a/apps/www/tests/prerender-bench.test.ts +++ b/apps/www/tests/prerender-bench.test.ts @@ -293,6 +293,33 @@ describe('the rule that would replace concurrency 8', () => { ).toEqual([expect.stringContaining('281')]); }); + /** + * COMPLETE MEANS COMPLETE FOR THAT REPETITION, NOT FOR THE BEST BUILD EVER. + * The first benchmark (run 34791359819) produced 1,315 images in repetition 1 + * and 1,399 in repetitions 2 and 3 — at 4, at 8 and at 12 alike. The count + * moved with the repetition, not with the setting, and a rule measuring every + * build against the overall maximum refused both candidates for a shortfall + * the baseline shared, then kept the baseline 25% slower than either. + */ + it('does not refuse a candidate for a shortfall every setting shared', () => { + const shortFirst = (runs: PrerenderRun[]) => + runs.map((build) => + build.run === 1 ? { ...build, images: 270 } : build + ); + + const verdict = prerenderBenchVerdict({ + runs: [ + ...shortFirst(sixRuns(8)), + ...shortFirst(sixRuns(12, { prerenderMs: 100_000 })) + ] + }); + + expect( + verdict.candidates.find((c) => c.concurrency === 12)?.refusals + ).toEqual([]); + expect(verdict.chosen).toBe(12); + }); + /** * NOT ZERO ERRORS — NO NEW ONES. This site prerenders 42 links to routes * nothing serves, deliberately, and a rule demanding a clean crawl would @@ -514,4 +541,24 @@ describe('the benchmark workflow', () => { expect(commands).not.toMatch(/wrangler|deploy:www|publish:cf/); }); + + /** + * ONE RUNNER FOR EVERY SETTING. The first benchmark gave each concurrency a + * matrix leg of its own, and 8 came out slower than both 4 and 12 — a result + * no contention model predicts and a slower machine explains. Same core count + * is not same machine, so the only comparison that holds is the one where all + * settings share a runner. + */ + it('measures every setting on the same runner', () => { + const jobs = workflow.jobs as Record< + string, + { strategy?: unknown; steps?: { run?: string }[] } + >; + const building = Object.values(jobs).filter((job) => + (job.steps ?? []).some((step) => step.run?.includes('pnpm build:www')) + ); + + expect(building).toHaveLength(1); + expect(building[0]!.strategy).toBeUndefined(); + }); }); From a6d4b220d45693a816bcb07b5842e15c3d6f3a5b Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 15 Sep 2026 16:27:15 +0200 Subject: [PATCH 3/3] docs(claude): record what the first prerender benchmark did and did not settle --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2bc516f1..e49a835c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,7 +187,7 @@ Three Workers facts follow, and all three are guarded by `const cloudflare = NIT - **`nodejs_compat` is not optional.** Content's Nitro half, the MCP SDK and Nitro's own runtime all reach for node builtins; without the flag the Worker fails at the first import. - **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `apps/www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. - **The site's origin has to be stated.** `i18n.baseUrl` in `apps/www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". -- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. +- **The OG renders time out under the crawl, and the fix is now measured rather than assumed.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has been measured, and it is zero:** 18 full builds in benchmark run 34791359819 — 4, 8 and 12, three cold and three warm each, over 5,256 routes on 4-core runners — timed out on no render at all. The deploy still counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — because caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm, every setting on **one** runner, interleaved, with the order rotating per repetition — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site on one runner is about three hours; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **The concurrency question is still open, and the first run is why.** It retained 8 while 4 and 12 were each about 25% faster on the warm median, and neither half of that conclusion held. The rule refused both candidates for "fewer OG images than the best build" — 1,315 against 1,399 — but the shortfall sat in repetition 1 at **every** setting, the baseline included, so it measured the repetition and not the concurrency; completeness is now judged against the fullest build of the same repetition. And each setting ran on a matrix leg of its own, so 8 being slower than both 4 and 12 is at least as well explained by a slower machine; the workflow now builds every setting on one runner. Until it is run again, `8` stays because nothing has shown a better number, not because anything showed 8 is best. It only runs from the **default** branch: GitHub makes a `workflow_dispatch` workflow triggerable once it exists there, so a change to it measures nothing until it has been promoted. The `ref` it runs against stays free, so from `main` it can still measure any branch. - **The route rule alone prerenders nothing.** `routeRules` says a page *may* be prerendered; it seeds no crawl. Left at that, the build rendered the 17 Content SQL dumps and not one page — a build that looks fine and ships a fully dynamic site. `nitro.prerender.crawlLinks` with `routes: ['/']` is what actually walks the sidebar. **It walks pages and nothing else.** Nitro queues a discovered link only when its extension is `""` or `.json`, so the `.md` twin beside every page, the `llms.txt` every page's head link points at, and `rss.xml` are skipped however prominently they are linked — which is why all three are Worker routes above, and it is a property of Nitro rather than of this config. The one thing that does get past it is `prerenderRoutes` in `DuxtHeader`, and it is there because a version segment like `v0.1.0` reads to the crawler as a file with extension `.0`. - **`failOnError` is off, and the crawler is now this repo's link checker.** Nuxt exits the build on the first prerender error, and crawling every link finds every dead one: `/demo/openapi/shipments` and its two operations are linked by the versioned demo section and served by nothing, 42 times across the locales. Those pages fall through to the Worker, which answers them as it would anyway. **The links are a real defect and want fixing where they are generated** — the build prints each one, so the list stays visible rather than going quiet. - **The origin has to be pinned twice, and the second one is not a duplicate.** `@nuxtjs/i18n` copies its `baseUrl` into `runtimeConfig.public.i18n` with `defu`, and something in the SEO chain seeds that key first, so the module option never reaches the runtime. The runtime then holds an empty string, falls back to the request's own origin, and every page rendered at build time is rendered against `localhost:3000` — nuxt-site-config pushes that over `site.url`, and the prerendered HTML ships ``. `runtimeConfig.public.i18n.baseUrl` set explicitly is what fixes it. A served site never shows this, because the fallback resolves to the real host; it took the first prerendered build to surface. diff --git a/CLAUDE.md b/CLAUDE.md index 2bc516f1..e49a835c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ Three Workers facts follow, and all three are guarded by `const cloudflare = NIT - **`nodejs_compat` is not optional.** Content's Nitro half, the MCP SDK and Nitro's own runtime all reach for node builtins; without the flag the Worker fails at the first import. - **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `apps/www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. - **The site's origin has to be stated.** `i18n.baseUrl` in `apps/www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". -- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. +- **The OG renders time out under the crawl, and the fix is now measured rather than assumed.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has been measured, and it is zero:** 18 full builds in benchmark run 34791359819 — 4, 8 and 12, three cold and three warm each, over 5,256 routes on 4-core runners — timed out on no render at all. The deploy still counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — because caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm, every setting on **one** runner, interleaved, with the order rotating per repetition — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site on one runner is about three hours; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **The concurrency question is still open, and the first run is why.** It retained 8 while 4 and 12 were each about 25% faster on the warm median, and neither half of that conclusion held. The rule refused both candidates for "fewer OG images than the best build" — 1,315 against 1,399 — but the shortfall sat in repetition 1 at **every** setting, the baseline included, so it measured the repetition and not the concurrency; completeness is now judged against the fullest build of the same repetition. And each setting ran on a matrix leg of its own, so 8 being slower than both 4 and 12 is at least as well explained by a slower machine; the workflow now builds every setting on one runner. Until it is run again, `8` stays because nothing has shown a better number, not because anything showed 8 is best. It only runs from the **default** branch: GitHub makes a `workflow_dispatch` workflow triggerable once it exists there, so a change to it measures nothing until it has been promoted. The `ref` it runs against stays free, so from `main` it can still measure any branch. - **The route rule alone prerenders nothing.** `routeRules` says a page *may* be prerendered; it seeds no crawl. Left at that, the build rendered the 17 Content SQL dumps and not one page — a build that looks fine and ships a fully dynamic site. `nitro.prerender.crawlLinks` with `routes: ['/']` is what actually walks the sidebar. **It walks pages and nothing else.** Nitro queues a discovered link only when its extension is `""` or `.json`, so the `.md` twin beside every page, the `llms.txt` every page's head link points at, and `rss.xml` are skipped however prominently they are linked — which is why all three are Worker routes above, and it is a property of Nitro rather than of this config. The one thing that does get past it is `prerenderRoutes` in `DuxtHeader`, and it is there because a version segment like `v0.1.0` reads to the crawler as a file with extension `.0`. - **`failOnError` is off, and the crawler is now this repo's link checker.** Nuxt exits the build on the first prerender error, and crawling every link finds every dead one: `/demo/openapi/shipments` and its two operations are linked by the versioned demo section and served by nothing, 42 times across the locales. Those pages fall through to the Worker, which answers them as it would anyway. **The links are a real defect and want fixing where they are generated** — the build prints each one, so the list stays visible rather than going quiet. - **The origin has to be pinned twice, and the second one is not a duplicate.** `@nuxtjs/i18n` copies its `baseUrl` into `runtimeConfig.public.i18n` with `defu`, and something in the SEO chain seeds that key first, so the module option never reaches the runtime. The runtime then holds an empty string, falls back to the request's own origin, and every page rendered at build time is rendered against `localhost:3000` — nuxt-site-config pushes that over `site.url`, and the prerendered HTML ships ``. `runtimeConfig.public.i18n.baseUrl` set explicitly is what fixes it. A served site never shows this, because the fallback resolves to the real host; it took the first prerendered build to surface.