Getting Started / Building From Source
The Steps
bun run build:local runs these in order. Each is skipped when what it produces is already on disk,
so a re-run after a failure resumes rather than restarting. Together they produce every artifact a
release payload carries, which tests/node/build-from-source.spec.ts asserts against the payload's
own manifest rather than against a list.
| # | step | produces | needs |
|---|---|---|---|
| 1 | interpreter |
.interp/php8.5.wasm, .interp/php8.5-worker.mjs |
network |
| 2 | frame |
.interp/php8.5.wasm.br |
- |
| 3 | siblings |
.siblings/{drupflare,rom,stream-http} |
git |
| 4 | driver |
assets/driver.json |
step 3 |
| 5 | tree |
drupal-src/ |
composer, tar |
| 6 | mount |
the driver modules, inside drupal-src/ |
bun, step 5 |
| 7 | site |
drupal-src/sites/default/settings.php |
php, step 6 |
| 8 | patch |
the wasm-runtime patches, in place | node, step 7 |
| 9 | bootstrap |
a first assets/drupal/core.json, core.bin.gz |
node, step 5 |
| 10 | twig |
assets/drupal/twig-bake.json, core.list.json |
php, steps 8 and 9 |
| 11 | core |
the same two, repacked from the list | node, step 10 |
| 12 | pack |
assets/drupal-pf/core.pf.json, core.pf.bin |
step 11 |
| 13 | static |
assets/core/ |
step 5 |
| 14 | agg |
assets/agg/ |
bun, step 5 |
| 15 | container |
the packed cache_container row, rekeyed |
a free port, step 12 |
| 16 | sql |
assets/drupal-sql/ |
node, site.sqlite |
| 17 | prefill |
assets/prefill.json |
a free port, 1-16 |
1-2, The Interpreter
The shipping seam src/runtime/php-binary-raw.ts imports two files, and both have to exist before the
bundle builds: the glue and the interpreter itself.
interpreter restores the binary and its glue from the public CDN, verified by sha256 against
cdn-manifest.json, needing no credential. bun install already did this as a postinstall, so on a
normal clone the step is a no-op. When the CDN cannot be reached the step falls back to phasm's
workflow artifacts over gh: a different host on a different domain, so it is a second source and
never a retry.
frame compresses the binary to .interp/php8.5.wasm.br, and the shipping bundle does not need
it. The Worker size limit is 64 MiB uncompressed, with no compressed limit. bun run release:check
prints the current uncompressed figure. The interpreter travels as a raw CompiledWasm import, which
took startup from 106 ms to 5 ms on a deployed free worker.
The step stays because three configs under experiments/wrangler/ still name the brotli seam, and it
is the rollback path if the raw import has to be reverted. It is brotli at quality 11 and window 22,
packed and inflated by node:zlib, so producer and consumer are the same implementation. The frame
is cached on mtime; pass --force to repack.
The build has no decoder step and needs no Docker.
3-4, The Driver Pack
Composer never runs on the edge, so a require in a manifest ships nothing. The Durable Object
mounts assets/driver.json into its in-memory filesystem, and that packed copy is what executes.
The two Drupal modules and the stream wrapper live in sibling repositories, which are the source of
truth. siblings resolves each one in this order and clones only what is missing:
DRUPFLARE_SRC/ROM_SRC/STREAM_HTTP_SRC, when set../drupflare,../rom,../stream-http(the developer layout).siblings/<name>(this build's own clone)
An explicit environment setting outranks an inference from the layout, and the developer layout outranks a private clone, so a checkout you are editing is never shadowed by a clone of master.
5-8, The Drupal Tree
tree downloads the pinned core tarball from ftp.drupal.org and completes it with the four
contributed modules the tarball does not carry. It is ~180 MB. A tree already at the requested
version is left alone, because re-extracting it rewrites every mtime, and mtime decides behaviour
here: Drupal's MTimeProtectedFastFileStorage hashes it into the compiled-Twig directory name.
site installs a Drupal site into sites/build, then points sites/default/settings.php at the
database it created. Nothing downstream reads that database as content; it exists so bake-twig.php
has a kernel to boot. A release tarball ships default.settings.php and no settings.php at all, so
without this step there is no site to boot.
This step's database is a throwaway; the shipping one is built by the same script. Both come from
scripts/drupal/install-site-db.php, and it refuses to write assets/drupal/site.sqlite without
--allow-shipping-pack, so the normal loop is build elsewhere and compare with
node scripts/diff-site-db.ts. Site Database is the recipe and lists every value the shipped
database disagrees with a stock install about.
patch rewrites the tree for the wasm runtime, and is idempotent. Drupal 11 uses new \Fiber() in
five places; PHP builds Fibers on ucontext, emscripten provides none, and the first Fiber aborts the
runtime. The patch swaps the class for a synchronous stand-in with the same surface. It also pins the
twig PHP-code bin to plain FileStorage, without which no precompiled Twig cache is reachable at
all: the default storage hashes the containing directory's mtime into the filename, and a mounted
MEMFS directory's mtime is mount time.
9-16, The Assets
bootstrap exists because the packers and the bake read each other's output. bake-twig.php builds
core.list.json as the previous core.json, minus the compiled-Twig paths, plus the ones it just
compiled, so on a checkout with no pack there is no list to build, and PACK_INDEX=1 has no file
set. The bootstrap breaks that cycle by globbing the tree under FULL=1 instead. It is skipped
entirely on a hydrated tree, which already has a core.json.
This is the one place where a source-built tree and a shipped one differ in content. The shipping pack's file list came from a traced run that a checkout cannot repeat, so the bootstrap globs every non-test file instead, and the bake derives its list from that. The difference is small, because the profiled set is completed by rules that already reach most of the tree.
Measured on Drupal 11.4.5, a clean clone against the shipping artifacts. The shipped core version is
SHIPPED_CORE_VERSION in src/ops/shipped-lock.ts:
| artifact | shipped | source-built | delta |
|---|---|---|---|
| pack entries | 11,444 | 11,446 | +2 |
core.pf.bin |
11,992,672 | 12,457,108 | +464,436 (+3.9%) |
core.bin.gz |
8,126,017 | 8,588,601 | +462,584 (+5.7%) |
The Worker bundle is unaffected; the packs are Workers assets and carry no bundle bytes.
twig bakes the precompiled Twig cache and writes two records: twig-bake.json, which the gate
reads, and core.list.json, the file list both packers then read.
core repacks the tree from that list. It runs under PACK_INDEX=1, which takes core.list.json
verbatim with no re-globbing, so a repack measures the tree and never the completion rules. It
rewrites the two files bootstrap wrote, and it is skipped only when they are NEWER than the list;
a presence check would skip the repack and ship a pack missing every template the bake just compiled.
pack rewrites that blob with every file compressed independently, which is what lets a file be
materialised when PHP opens it instead of inflating 11,447 files at boot. It then scrubs the pack:
core.pf.bin carries settings.php with a real hash_salt, Workers assets are served publicly, and
a packed-but-unscrubbed pack is the state release-payload.ts refuses to publish.
static copies the browser-fetchable half of the tree (css, js, fonts and images under
core/) into assets/core/, where the Workers Assets layer serves it at the /core/** URLs
Drupal already emits. Every pack skips those extensions because PHP never opens them, and nothing
serves a file out of the PHP MEMFS over HTTP, so this is the only thing that answers them. It copies
where the other steps pack: Workers Assets content-hashes and compresses what it serves, and a hit
never reaches the Worker. 4,028 files on the current tree, with
core/profiles/demo_umami, tests/, node_modules/ and .pcss.css sources left out.
sql chunks the committed site.sqlite into the JSON the Durable Object replays in JavaScript. It
reads a tracked file, so it is the one step that works on a clone with nothing else built.
It also drops the bake's own history on the way through: dropBakeHistory() empties watchdog and
removes state:install_time. Without that, a new site inherits the bake's log dates and an install
time that makes the Cron requirement read as overdue. This is a build step rather than a hand edit,
because bun install restores the tracked database from the CDN and would revert a hand edit.
14, The Aggregates
agg reads the *.libraries.yml definitions out of drupal-src and emits immutable per-library CSS
and JS aggregates into assets/agg/, plus a file-to-library index. The output is not committed. The
serving side is off unless ASSET_AGGREGATES=1, and with no manifest present it changes nothing.
It belongs in the numbered sequence, because wrangler.jsonc sets ASSET_AGGREGATES: "1". A
tree that builds the pack without it runs the lever with nothing to substitute, and the lever
degrades quietly by design, so nothing reports it. The symptom is /agg/manifest.json answering 404.
The output has to be published as well as built. assets/.assetsignore denies by default, so it
needs !/agg/; without that line the aggregates upload nowhere and a page with the lever on has no
CSS. The aggregates count against the Workers Assets store, not the 64 MiB bundle.