From 2679f7f5f9e1c6bd827fb730b488062ce975299e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:40:13 -0700 Subject: [PATCH] fix(selfhost): ship @cloudflare/worker-bundler in the docker runtime The dynamic Worker bundler change made plugin-apps resolve @cloudflare/worker-bundler at runtime via import.meta.resolve, but package-runtime.ts never copied the package into the self-host image, so serve.js crashed on startup with a module-not-found error. Copy the package alongside the other runtime externals and pre-bundle dist/index.bundled.js at build time (the npm package does not ship it and the distroless runtime has no esbuild for the fallback), mirroring what apps/cli/src/build.ts does for the packed CLI. The CI smoke job only built the image, which is exactly why this slipped through: boot the container and require a healthy /api/health as well. --- .changeset/selfhost-worker-bundler-runtime.md | 5 +++ .github/workflows/ci.yml | 31 +++++++++++++++++++ apps/host-selfhost/scripts/package-runtime.ts | 31 ++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .changeset/selfhost-worker-bundler-runtime.md diff --git a/.changeset/selfhost-worker-bundler-runtime.md b/.changeset/selfhost-worker-bundler-runtime.md new file mode 100644 index 000000000..ed256c449 --- /dev/null +++ b/.changeset/selfhost-worker-bundler-runtime.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Ship @cloudflare/worker-bundler in the self-host Docker runtime so the server starts; it was resolved at runtime since the dynamic Worker bundler change but never copied into the image. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7acb75073..0f71e000f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -414,4 +414,35 @@ jobs: context: . file: apps/host-selfhost/Dockerfile push: false + load: true tags: executor-selfhost:ci + + # Building alone doesn't catch startup crashes (e.g. a runtime-resolved + # package missing from the packaged runtime), so boot the container and + # require a healthy /api/health before calling the image good. + - name: Boot image and check health + shell: bash + run: | + set -euo pipefail + docker run -d --name selfhost-smoke -p 4788:4788 \ + -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \ + -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=smoke@example.com \ + -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -hex 16)" \ + -e EXECUTOR_WEB_BASE_URL=http://localhost:4788 \ + executor-selfhost:ci + for i in $(seq 1 30); do + if [ "$(docker inspect -f '{{.State.Running}}' selfhost-smoke)" != "true" ]; then + echo "container exited during startup" + docker logs selfhost-smoke + exit 1 + fi + if curl -fsS http://localhost:4788/api/health >/dev/null 2>&1; then + echo "healthy after ${i}s" + docker logs selfhost-smoke + exit 0 + fi + sleep 1 + done + echo "health check never passed" + docker logs selfhost-smoke + exit 1 diff --git a/apps/host-selfhost/scripts/package-runtime.ts b/apps/host-selfhost/scripts/package-runtime.ts index c6d28cc9c..a5004b89b 100644 --- a/apps/host-selfhost/scripts/package-runtime.ts +++ b/apps/host-selfhost/scripts/package-runtime.ts @@ -1,6 +1,7 @@ -import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; const root = process.cwd(); const out = join(root, ".selfhost-runtime"); @@ -36,6 +37,7 @@ const libsqlNativePackage = (): string => { }; const externalPackages = [ + "@cloudflare/worker-bundler", "quickjs-emscripten", "quickjs-emscripten-core", "@jitl/quickjs-ffi-types", @@ -64,12 +66,39 @@ const copyPackage = (name: string): void => { cpSync(packageDir(name), destination, { recursive: true, dereference: true }); }; +// The worker-bundler backend (packages/plugins/apps/src/pipeline/ +// worker-bundler-artifact.ts) loads `dist/index.bundled.js` from the resolved +// package, falling back to bundling `dist/index.js` with esbuild at runtime. +// The npm package doesn't ship the bundled entry and the runtime image has no +// esbuild, so produce it here, the same way apps/cli/src/build.ts does for the +// packed CLI. +const writeBundledWorkerBundler = async (): Promise => { + const distPath = join(packageDir("@cloudflare/worker-bundler"), "dist"); + const esbuildEntry = requireFromSelfHost.resolve("esbuild", { + paths: [join(root, "node_modules/.bun/node_modules")], + }); + const { build } = await import(pathToFileURL(esbuildEntry).href); + const result = await build({ + entryPoints: [join(distPath, "index.js")], + bundle: true, + format: "esm", + platform: "browser", + external: ["./esbuild.wasm"], + logLevel: "silent", + write: false, + }); + const source = result.outputFiles[0]?.text; + if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); + writeFileSync(join(out, "node_modules/@cloudflare/worker-bundler/dist/index.bundled.js"), source); +}; + rmSync(out, { recursive: true, force: true }); mkdirSync(serverOut, { recursive: true }); await Bun.$`bun build apps/host-selfhost/src/serve.ts --target=bun --format=esm --outdir=${serverOut} ${quickJsExternals.map((name) => `--external=${name}`)}`; for (const name of externalPackages) copyPackage(name); +await writeBundledWorkerBundler(); if (!existsSync(join(serverOut, "serve.js"))) { throw new Error(