Skip to content

Speculative Site Build

enumeratio edited this page Nov 14, 2026 · 1 revision

Site build: which route, and where the time goes

Speculative. A proposal, not adopted.

Two questions that are usually asked together: should the gate build the site from the packages' built output instead of their source, and why does the build take as long as it does. The numbers are read from the CI logs of green runs on main; nothing here was measured on a build box. What still needs measuring is listed at the end.

1. The two routes

web/.vitepress/config.mts reads every package's exports and aliases each @notatio/* specifier to its source file (srcAliases). SITE_FROM_PACKAGES=1 turns the aliases off, and the site resolves each package as an installed one, through its exports, to dist/.

From source (the gate, vitepress dev) From packages (SITE_FROM_PACKAGES=1)
Who runs it ci.yml's site job on every push and pull request tools/tarball-check, nightly, on packed tarballs installed into a scratch application
What it needs The dist job's output all the same: generated data, and the packages that are already resolved from dist Every package built, packed and installed
What it catches A site that does not build from the tree A package that does not work as published: an exports target missing from the tarball, a file read at run time that was not packed, an undeclared dependency, a TypeScript export
What it cannot catch Any of the above A site that reaches into the tree, unless the check looks for it (it does)
Risk None from a stale dist for the aliased packages A stale dist ships old code, where the build is not the cached one

The nightly route is failing, and nobody is told. In each of the last seven nightly runs the tarball check reports static: fail, census: fail and site: fail (build failed, 3 findings). The site step ends within seconds, so it fails while the configuration loads. The workflow is advisory and concludes success, and unlike the other advisory sweeps it files no rolling issue. So the from-packages build has not produced a site for at least a week, and what it would catch is caught by no one.

Two of the site's files reach into packages/ by relative path, which an install does not have: config.mts (the directory it scans for aliases) and vendor.ts (the components package, twice). They are likely the three findings; the report artifact says for certain.

2. Where the time goes

The site job's build step took 156 to 224 seconds on the last six green runs of main. One of them, phase by phase, from the log's timestamps:

Phase Seconds What happens
Before VitePress prints its banner 106 The configuration loads and the dynamic routes resolve: the records are read and assembled, and the route loaders (reference/symbol/[name].paths.ts and its siblings) run. This is where an engine is declared in full and markup is prerendered; the log has 2,869 "Compilation fallback" lines and the KaTeX warnings here.
Building the client and server bundles 44 Vite 8 bundles the theme, the packages and the worker.
Rendering pages 57 Every page is rendered to HTML; the output is about 16,600 files.
Total 208

The earlier measurements no longer describe the build. Bundling was 344 to 419 seconds when the site was last profiled; it is 44 now, since the bundler changed and compute-engine, KaTeX and MathLive are served as vendor files instead of being bundled. The time has moved to the phase before bundling, which neither route touches: it is the same work whether packages resolve to src/ or to dist/.

So the question in the issue has a short answer on time: the route changes at most part of a 44-second phase, and the one measurement on record found no difference at all.

For scale, the site build is not the slow job. The dist job's setup step took 17 minutes, 2.5 hours and 2.5 hours on the three runs of those six that missed the build cache, and about a minute on the others.

3. Options

  1. Leave the gate on source and repair the nightly. The gate keeps building what the tree says. The tarball check gets a rolling issue like the other advisory sweeps, and its three failing steps are fixed, so the published shape is checked every night and someone hears when it breaks.
  2. Switch the gate to SITE_FROM_PACKAGES=1. The site job already depends on dist, and the build cache makes a stale dist unlikely in CI. It would catch a broken exports map on the pull request instead of the next morning. It would not catch what packing catches, a local vitepress build could still ship a stale dist, and vitepress dev must stay on source for hot reload, so the gate would no longer build what a developer sees.
  3. Add the cheap half of the tarball check to the gate. pack and static take a few seconds: every package is packed and read as a consumer would read it. That catches the publish-shape errors at the pull request without changing how the site is built. The site-from-tarballs build stays nightly.
  4. Cut the first phase. The route loaders' output depends only on the records and the packages, both of which the build cache already hashes. Their result can be a cached build output, restored when nothing it reads has changed, as the packages' own outputs are.

Lean: 1 and 3 now, 4 after it is measured, not 2. Switching the route buys no time and trades a build that cannot be stale for one that can. What the from-packages route is for is checking the published shape, and the tarball check does that more fully, once it runs green and reports. The time is in the first phase, and that is where a change would pay.

4. Plan for a builder

In this order; each step stands alone.

  1. The tarball check reports. tarball.yml files and updates a rolling issue labelled nightly-fixup when a step fails, and closes it when all pass, as the other nightly sweeps do.
  2. The site step builds. web/.vitepress/config.mts and web/.vitepress/vendor.ts stop reaching into packages/ when SITE_FROM_PACKAGES=1: the components directory is found by resolving the installed package, and the alias scan is already skipped. Then whatever fails next, found with node tools/tarball-check/run.ts --only site --probe-sources.
  3. static and census pass, each as its own fix, from the report.
  4. The gate runs pack and static. A step in ci.yml's check job: node tools/tarball-check/run.ts --only static. Only once step 3 has it green.
  5. The build says where its time went. The route loaders and the record assembly print their own durations under an environment variable, and the site job's three phases go into the perf history the nightly keeps (tools/perf), so a slower build shows as drift.
  6. Cache the first phase, if step 5 shows it is the loaders and not the record parse: the loaders' output written as a build output of web, keyed as tools/ci/dist-cache.ts keys a package.

The issue can close once steps 1 to 4 land, with this page as its answer.

5. To measure on a build box

  • The first phase, split: importing the configuration, reading and assembling the records, each route loader. A CPU profile of vitepress build up to the banner is enough.
  • SITE_FROM_PACKAGES=1 pnpm --filter @notatio/web run build in the workspace with every package built: whether it builds today, its three phases beside the source route's, and whether the output differs (page count, total size).
  • node tools/tarball-check/run.ts --only site, and again with --probe-sources: the three findings and what fails after them.
  • Rendering: the number of HTML pages, and the phase's time against VitePress's buildConcurrency.
  • Run to run: the same commit built three times, to know how much of the 156 to 224 second spread is the runner.

Home

Project

Working

Design: every design page, by subject

Clone this wiki locally