---
title: "Day three: the build has to run inside a Worker"
date: 2026-08-03
summary: >-
  A new requirement arrived in the middle of the day's largest ticket: an
  Ursprung app must build in Node, Bun and inside a Cloudflare Worker as equals,
  on one code path. It reversed a day-two recommendation and now outranks the
  dependency budget. The build got its shape anyway — four stages over
  (module, realm) pairs, an explicit route table, and capnweb delegated for the
  wire but not for resumability.
---

# Day three: the build has to run inside a Worker

Day two produced a map and refused to write framework code. Day three kept that
posture and spent the whole day inside it: four tickets resolved, five new ones
opened, two research surveys totalling about 3,100 lines, one throwaway spike,
and roughly 6,000 lines added. Forty-five of those lines were outside the
planning directory, and they were a shell script.

The ticket count moved from nineteen live tickets with six resolved to
twenty-four with ten resolved. But the number that matters is one requirement
that arrived in the middle of the day's largest ticket and rearranged the map
around it.

## The requirement that arrived mid-ticket

Ticket 8 — build pipeline architecture, the spec's central chapter — was claimed
in the early afternoon. Partway through, a new requirement landed: **an Ursprung
app must be buildable in Node, Bun, and inside a Cloudflare Worker, as equals,
on one code path.** The shape to copy is `@cloudflare/worker-bundler`. The only
permitted difference between hosts is how source reaches the build — Node and
Bun are handed entry points and read from disk; workerd is handed file names
_and_ contents, because there is no disk.

That reverses ticket 1's closing recommendation from day two, which was that any
"build in the Worker" ambition should be ruled out now. It is not a finding that
contradicts an axiom, which is the shape day two kept encountering. It is a _new
axiom_, and the map says so explicitly: charting never asked the question, so
nothing was wrong, the map was incomplete.

It outranks the dependency budget, and the cost was written down before it was
known whether it would be paid: if no single toolchain runs on all three hosts,
this axiom buys two toolchains that must agree on what an app's code means —
which is exactly the outcome the dependency budget exists to prevent. oxc is a
native addon, and its wasm build targets `wasm32-wasip1-threads`, a flavour
expecting threads and a filesystem that Workers do not have.

The response was the interesting part. The requirement was **not resolved by
argument**. It became ticket 24, a survey, and the four already-closed tickets
that rest on oxc's specific API — 1, 5, 6 and 7 — were left closed pending the
answer rather than reopened on a guess.

**workerd is now the constraining host.** Every build-time dependency must run
inside it.

## The pipeline itself, which does not depend on that answer

Ticket 8's own architecture was designed to be independent of which library
parses and prints, and it survived the day intact.

**Four stages, not seven.** `read routes → walk → emit → manifest`. The sketch
in the question had three stages that do not exist — `split`, `link`, and a
separate `analyse` — and two that are not sequential. "Resolve" and "parse" are
one step iterated to a fixed point, because a module's imports are unknown until
it is parsed and unresolvable until its importer is.

**The emit unit is a `(module, realm)` pair, and so is the walk unit.** This is
the answer to the two-graph problem, and it falls out of ticket 5's boundary
rather than being added to it. A universal module reached through client code
has its `.server.ts` import retargeted to a generated stub, while the _same_
module in the server graph keeps the real import: one input file, two output
texts. So the traversal runs over the product graph, with one queue, and
membership becomes **structural** — a pair exists if and only if that module is
in that realm. Nothing to tag, nothing to propagate.

Three properties fall out rather than being built: no phase barrier, because
client entries are discovered mid-traversal and enqueued in both contexts
immediately; the violation chain is carried on the queue item rather than
reconstructed in a post-pass; and the edge rules are written once, in one place,
which matters because ticket 7 found two traps in them the same day.

Two alternatives were worked through and both are worse statements of the same
thing. That is the ticket's method throughout — the rejected option is priced,
not dismissed.

**Facts, not trees.** Per file the build keeps source text plus a facts record —
imports and exports with spans and `isType`, which exports are function
declarations, realm from the path — and drops the AST at the end of the walk.
Nothing is re-parsed; emit edits from recorded spans. The rule this imposes is
stated rather than left to be discovered: every edit must declare what it needs
from the AST up front, so adding a transform means extending the extraction
pass. What it buys is that the facts record is plain serializable data, which is
what a cache or a cross-core hand-off would need, and which an AST from a native
addon can never be.

**No incremental cache in v0**, and the reason is not simplicity —
fingerprinting defeats most of what a cache would buy. A file's name contains a
hash of its contents, which include the fingerprinted names of its imports, so
editing one leaf renames every file above it. The door is left open: the facts
record is exactly what a cache would be keyed on, and the dev-loop ticket can
reopen this with a measurement rather than a guess.

**One core**, using the synchronous parser oxc's own documentation recommends.
Real parallelism does exist at emit — files at the same depth are independent —
but it is fenced by the bottom-up ordering that fingerprints demand, so cores
idle at the narrow parts of the graph. Under the Worker requirement this stops
being deferred and becomes moot: workerd has no threads.

Because emit is bottom-up, **import cycles in the client graph are refused**,
with the loop printed. And **source maps and minification are both in v0**,
against the recommendation on the ticket, which turned out to matter more than
it looked.

The omissions list is a feature, and it closes questions rather than deferring
them: no CSS handling, no asset pipeline, no lowering, no polyfill injection, no
CommonJS in any position, no tree-shaking, no import map, no build-time value
injection into client source, no build-time evaluation of app code.

## The survey, and the gamble paying off

Ticket 24 was claimed at the end of the day and resolved half an hour later on
2,270 lines of research across three independent investigations, with almost
every claim measured inside a real workerd isolate rather than read.

**oxc runs inside workerd.** All four packages — parser, transform, minify,
resolver — executed in a real isolate and produced output byte-identical to the
native Node binding. The dependency budget's central entry survives the new
axiom, the esbuild refusal stands, and tickets 1, 5, 6 and 7 stayed closed on
their merits. Ticket 1's finding that the build cannot run in workerd is
refuted.

Four conditions come with it, none optional:

- **Sync APIs only.** Every `async` oxc entry point routes through
  `wasi.thread-spawn` → `Worker`, and `Worker` is `undefined` in workerd. The
  build is single-threaded anyway, so this costs nothing — but it applies to the
  Node and Bun hosts too, because one code path means one code path.
- **Forked wasm loaders.** The published `browser` entries fetch their wasm
  relative to `import.meta.url`, which is `undefined` in workerd, and runtime
  compilation is refused outright, so the binary must arrive as a `wasm` module
  type in the bundle. Forty lines of loader per package, handing emnapi a
  precompiled `WebAssembly.Module`. Mechanical, but a fork Ursprung owns.
- **Paid plan only**, and not for size — all four gzip to 3,043 KiB, about 1%
  under the free 3 MiB ceiling. For CPU: the free tier allows 10 ms per request
  and a single module transform costs several times that. That went in Out of
  scope as arithmetic, so it is not rediscovered later.
- **Memory never shrinks.** Each module needs a shared `WebAssembly.Memory` of
  at least 980 pages, and twenty-one parses of a 28 KB file grew the parser from
  61.25 to 88.3 MiB against a 128 MB ceiling.

**esbuild was priced on its merits and refused on evidence rather than on the
prior preference.** It wins on one point that matters: it chains source maps
correctly through TS → JSX → minify, which oxc does not. Against it: it exceeds
the free-plan limit where oxc fits, its `transform()` returns no module record
at all so a lexer would be needed beside it, custom AST manipulation is
permanently out of scope upstream, and it does not deliver one code path either.

**Ticket 8's performance fear was unfounded.** It had committed to a full
non-incremental single-threaded rebuild whose content hashes form a sequential
chain that cannot be split. Measured inside workerd: 201 modules transform in 95
ms, and the sha256 chain costs 8 ms at 200 modules and 35 ms at 2,000 — roughly
300× margin against a paid 30 s budget. No Worker Loader, Durable Object or
Queue splitting is needed, which is fortunate, because the hash chain is
precisely the workload that could not have survived being split.

**The real workerd constraint is code loading, not compute.** No `eval`, no `new
Function`, and a runtime-constructed `data:` import throws — it only appears to
work when a bundler statically inlines it, a trap that produced one wrong
intermediate result during the research. And `node:crypto`'s `createHash` turns
out to be load-bearing: it is the only synchronous digest in workerd, and the
bottom-up hash chain is synchronous, so a build-time `node:*` builtin is now a
requirement rather than a preference.

The survey cost one new thing and spawned three tickets. **oxc chains source
maps nowhere** — no `inputSourceMap` option exists in any oxc option type, and a
map handed in is silently ignored. Since v0 committed to both maps and
minification, composition needs either a fifth build-time dependency or
hand-written VLQ code, which is a dependency-budget decision and therefore
ticket 25.

The other two are consequences rather than reversals: ticket 27 shapes how oxc's
wasm reaches a build Worker at all, and **ticket 26 is the one to watch**. Every
measurement behind the survey's headline was taken against local workerd. All
four bindings import a _shared_ memory, which needs `SharedArrayBuffer` —
exactly the capability an edge deployment gates for Spectre reasons. It is a
five-minute deploy that can invalidate the whole result, and if it fails the
toolchain question reopens with esbuild as the surviving candidate.

## Routes: an explicit table, and a refusal that was measured

Earlier in the day, ticket 7 settled route and entry-point discovery on the back
of a spike — sixteen assertions, all passing, over six route declarations an
agent would plausibly emit plus the same app expressed as a directory tree.

**v0 uses an explicit route table in a constrained subset, read with oxc and
never executed.** File-based routing is rejected, and so is build-time
evaluation.

The ticket's own premise did not survive the spike, which is the useful part. It
had assumed a directory walk yields every route and every client-reachable
module for free, while a table is a program that can only be known by running
it. Both halves are false. The constrained table reads in 0.3–2.7 ms including
the parse; the directory walk took 11–37 ms for the same five routes. And **a
directory walk does not yield client entry points** — a route names its page
module, and that page's client entries are whatever its descendants pull in, one
or more hops further on. Discovery of either kind answers the server half and
hands the client half to a graph walk it does not shorten.

The rule is one sentence: a route table is an array literal, exported as default
from the app's route module, whose every element is either a `route()` call with
a string-literal path and a page of the form `() => import("<specifier>")`, or a
spread of another module's exported route table.

Spreading another module's table **reads** — it looks like it needs a value and
does not, since the identifier is an import binding and the other module's array
is read by the same reader with a cycle guard. That removes the strongest
practical objection to the explicit table for about forty lines. What stays
refused is `.map()` and conditional spreads, for one precisely stated reason:
the set of routes, or a specifier inside it, would be a value the build has to
compute. Reading is fine; evaluating is not.

**The object-literal form is refused, and the refusal is a measurement rather
than a preference.** `Params<"/users/:id">` derives `{ id: string }` from the
path's literal type, which only survives at an inference site. Written as
`route("/users/:id", …)`, the matching page passes and a page reading
`params.slug` fails, naming the path it was checked against. Written as `{ path:
"/users/:id", … } satisfies Route[]`, `P` widens to `string`, `Params<string>`
is `{}`, and **both** pages fail identically. The object form does not lose some
typing; it stops distinguishing correct from incorrect. For an audience whose
primary feedback channel is types, that is worse than no types.

Nesting is out of v0 for the same reason — a nested child's path is a segment,
so the parent's parameters are invisible to it at the type level, and nesting
and typed parameters cannot both hold. Layouts as a _rendering_ concept are
untouched and stayed in the fog.

The diagnostic the spike emits is worth quoting as a candidate house style,
because the map keeps choosing this shape — a precise rule traded for an error
that names the fix:

```
error: this route table cannot be read without running it
  app/routes.ts:6:16
  │ export default sections.map((name) => ({
  the route table is a CallExpression, not an array literal.
  A route table is an array literal whose entries are `route()` calls with a
  string-literal path and a page of the form `() => import("<specifier>")`.
  fix: write the table as `[ … ]` with one literal entry per route.
```

Three parts always: where, what specifically was found, and the fix — with the
violated rule restated in full every time, on the argument that an agent reads
the error rather than the docs.

Two mechanical findings outlived the spike and went straight into ticket 8.
`satisfies` is not transparent to a shape match — `export default [ … ]
satisfies Route[]` parses as a `TSSatisfiesExpression` wrapping the array, and
the reader's first draft refused the canonical table because of it, so every
span-directed transform on the map has to unwrap it and its four relatives. And
`dynamicImports[].moduleRequest` carries no `.value`, only a span, so every
`import()` specifier must be re-read from source and checked for being a
literal; the module record cannot tell you whether a dynamic import is
analysable.

## capnweb: the delegation splits

The day opened with the capnweb survey — 830 lines against the tagged source of
`capnweb@0.10.0`, with the README scored as claims rather than facts. Two of its
claims did not survive.

**The delegation written at charting time was too broad.** capnweb covers the
wire and does not cover resumability. It passes the module-resolution gate
generously: the published dist is a single flat ES module with zero imports and
no dependencies, so it costs the client graph exactly one hashed module. But its
serializer refuses functions, `RpcTarget`, `RpcStub`, streams, `Map`, `Set`,
class instances and cycles, and has **no object identity** — `{a: shared, b:
shared}` revives with `r.a !== r.b`, because the protocol serializes strictly as
trees. Those are precisely the three things the resumability ticket named as
hard. So ticket 9 designs its own format, and Ursprung ships two serializers
deliberately.

The axiom on the map was amended in place rather than reopened as a ticket,
which is the difference between an over-broad delegation and a contradicted
finding.

Costs recorded: 14.4 kB minified and gzipped rather than the advertised 10 kB,
shipped unminified at 93 KB with no `browser` condition and no tree-shaking to
relieve it, `cloudflare:` needing a special case in the resolver, and RPC input
validation being unavailable because it lives in a separate package requiring a
decorator and a bundler plugin. Pin it exactly: the wire protocol has no version
field, no handshake and no negotiation, and it already broke in a minor bump.

Three findings went into the error-message patch, all of them specimens from a
dependency rather than from Ursprung's own code: a client callback passed into
an HTTP batch **hangs forever with no error**, a custom `Error` subclass **loses
its class name** across the wire, and `e.stack` is **fabricated locally** while
looking real. A framework whose audience reads errors literally has to decide
what it does with a dependency's bad diagnostics, not only its own.

## Forty-five lines outside the planning directory

The day's only non-planning change was a `SessionStart` hook, and it exists
because of a small failure earlier the same morning: unformatted files reached
CI despite a pre-commit hook that checks formatting.

The chain is worth recording. Remote containers are provisioned without a full
dependency install, so devDependencies are missing and husky never runs its
`prepare` script. Without `prepare` there is no `.husky/_` and no
`core.hooksPath`, which leaves the pre-commit hook inert — git never consults
it. The hook runs `bun install --frozen-lockfile`, which restores both the
toolchain and the git hooks without touching the lockfile, and then **asserts
that husky actually registered** rather than assuming it, because a silent
failure there is exactly the bug it exists to prevent. It no-ops outside remote
sessions.

## Where this leaves things

Twenty-four tickets, ten resolved. The build now has a shape all the way down:
four stages over a product graph of `(module, realm)` pairs, facts kept and ASTs
dropped, no cache and one core, bottom-up emit with fingerprinted names, an
explicit route table read and never executed, and three build hosts on one code
path with a file source as the seam.

The largest question is no longer architectural but empirical, and it is five
minutes of work: whether production Cloudflare gives a Worker a
`SharedArrayBuffer`. If it does, the dependency budget held under the hardest
constraint anyone has put on it. If it does not, the day's headline inverts and
the toolchain question reopens with the option that was priced and refused.
