Skip to content

[wrangler] Add wrangler compile for self-hosting Workers on standalone workerd#14294

Draft
threepointone wants to merge 5 commits into
mainfrom
wrangler-compile-alpha
Draft

[wrangler] Add wrangler compile for self-hosting Workers on standalone workerd#14294
threepointone wants to merge 5 commits into
mainfrom
wrangler-compile-alpha

Conversation

@threepointone

Copy link
Copy Markdown
Contributor

Fixes #[insert GH or internal issue link(s)].

Note

Draft for discussion. This is an alpha, internal-only feature — see "Release posture" below. The full design lives in RFC-wrangler-compile.md, included in this PR for review.

What & why

workerd (the runtime behind Cloudflare Workers) has always been runnable outside of Cloudflare, but almost nobody does it because you have to hand-author a Cap'n Proto config and wire up features (static assets, etc.) yourself.

This PR adds wrangler compile: it takes a Worker + its config and emits a self-contained, Node-free bundle that runs anywhere workerd runs via workerd serve — AWS, Hetzner, Railway, Render, Fly, bare metal, a container, etc. Static assets (with _headers/_redirects/SPA handling) are wired up out of the box.

The key insight: Miniflare already assembles a real Workerd.Config and its simulators are pure workerd Workers. The Node process is only needed for dev-only scaffolding. So most of the work here is stripping Miniflare's output down to a portable, production-shaped config and emitting it as a bundle.

wrangler compile                 # -> ./dist-standalone (config + assets + Dockerfile + README)
wrangler compile --serve         # run the exact emitted artifact locally under workerd
wrangler compile --format binary # single self-contained config.bin

You can also opt in via "standalone": true in wrangler.json (or cloudflare({ standalone: true }) in the Vite plugin). When set, wrangler dev warns about bindings that work locally but aren't supported by standalone workerd, and wrangler deploy errors (since the Worker targets a self-hosted runtime, not Cloudflare).

What this could enable

  • Self-hosting Workers on any provider — run the same Worker you'd deploy to Cloudflare on your own infrastructure, in a container, behind your own CDN.
  • Durable Objects on your own infra (future) — gated on cloudflare/workerd#6780 (cluster mode for Durable Objects), which gives the unique Cloudflare stateful primitive a real horizontally-scalable production story. That's the unlock for going public.
  • Desktop / embedded apps (future) — because the output is a single runtime + a portable config, a compiled Worker could ship inside a desktop app or other embedded contexts down the line.

Scope of this PR (alpha)

  • Stateless Workers + static assets, end-to-end, verified under bare workerd serve (both text and binary config formats).
  • ✅ Shared core: emit/transform lives in miniflare; wrangler compile and the Vite plugin both call one unstable_compileStandalone() path.
  • ✅ Output: version-pinned Dockerfile, $PORT-aware entrypoint.sh, README.md, and a capability COMPILE_REPORT.md.
  • ⛔️ Not yet: stateful bindings (KV/R2/D1/Queues/Durable Objects), remote-backed bindings (AI/Browser/Vectorize), cross-bundle service bindings. These are punted until each has a credible production story (see the RFC).

Release posture

Internal/alpha only; public release is gated on cloudflare/workerd#6780 landing. While alpha, nothing is stable — output layout, config shape, and CLI flags may all change. The command is status: experimental and unsupported bindings are a hard error (bypass with --force).

How it's wired

  • packages/miniflare/src/standalone/ — text + binary emitters, the production transform (reachability pruning, outbound repoint, read-only assets disk, extension pruning), and emitStandaloneBundle(). New Miniflare.prototype.unstable_getConfig() seam.
  • packages/workers-utils/src/config/"standalone" config field + standalone-support.ts binding matrix.
  • packages/wrangler/src/compile/ — the command + unstable_compileStandalone(); deploy guard; dev warning.
  • packages/vite-plugin-cloudflare/standalone build mode (thin adapter onto the shared core).

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation

A picture of a cute animal (not mandatory, but encouraged)

🦫

Made with Cursor

threepointone and others added 5 commits June 14, 2026 21:48
Add a reusable, tested `standalone/` module that turns a Miniflare-assembled
`Workerd.Config` into a self-contained bundle runnable under bare `workerd serve`:

- `emitConfigText()` — faithful text Cap'n Proto emitter (embeds modules/blobs,
  fails loud on unsupported shapes).
- `toStandaloneConfig()` — production transform: reachability-prune dev-only
  services, repoint `globalOutbound` to `internet`, single `http` socket,
  relativize `disk` paths, prune unused runtime extensions, emit assets disk
  read-only.
- `emitStandaloneBundle()` — writes the config (`text` or `binary`), embeds,
  and copies `disk` contents.
- `Miniflare.prototype.unstable_getConfig()` — read the fully-assembled config
  without re-deriving the service graph.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Add a `"standalone": boolean` config field (validated + normalized) declaring
  a Worker targets a self-hosted workerd runtime rather than Cloudflare.
- Add `standalone-support.ts`, a single source of truth classifying each binding
  type as supported (stateless/pure-workerd) or unsupported (stateful/platform)
  for the standalone MVP. Exported via `getStandaloneSupport`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add an experimental `wrangler compile` command that builds a Worker into a
self-contained `workerd` bundle (config, embedded modules, on-disk static
assets, a version-pinned Dockerfile, an entrypoint, a README, and a capability
report) runnable on any server outside of Cloudflare.

- Reuses the `deploy --dry-run` bundling pipeline, drives Miniflare to assemble
  the resolved config, and emits the standalone bundle via the miniflare core.
- `--outdir`, `--force`, `--format text|binary`, and `--serve` (+ `--port`/`--ip`)
  to run the exact emitted artifact locally under the bundled workerd binary.
- `unstable_compileStandalone()` programmatic API (shared with the Vite plugin).
- `wrangler deploy` errors when `standalone` is set; `wrangler dev --standalone`
  warns about bindings unsupported by standalone workerd.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add `cloudflare({ standalone: true | { outDir?, force? } })`. When set, `vite
build` emits the same self-contained workerd bundle as `wrangler compile`,
reusing the shared `unstable_compileStandalone()` core (no duplicated assembly
or emit logic). A guarded post-build hook handles Vite 6 and 7/8 timing so
exactly one path fires per build.

Co-authored-by: Cursor <cursoragent@cursor.com>
Changeset (wrangler/miniflare/vite-plugin minor) and the design RFC for the
standalone self-hosted workerd bundles feature, included for discussion.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jun 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e344ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
wrangler Minor
miniflare Minor
@cloudflare/vite-plugin Minor
@cloudflare/vitest-pool-workers Patch
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm Bonk, and I've done a quick review of your PR.

This adds wrangler compile (plus a shared miniflare emit/transform core and a Vite standalone build mode) to emit a self-contained workerd serve bundle. The plumbing is well-organized and the alpha scoping is clear. A few things stand out, ranked by severity:

  1. Secrets are baked into the emitted bundle in plaintext, silently. secret_text is marked supported, and the compile path resolves bindings via unstable_getMiniflareWorkerOptions -> getBindings() -> getVarsForDev(), which loads .dev.vars/.env values as secret_text. Those get embedded verbatim into config.capnp / config.bin (and the generated Dockerfile does COPY . /app). The README/COMPILE_REPORT actively encourage committing/Dockerizing the output, with no warning that real secrets may now be sitting in plaintext in a checked-in artifact. At minimum this needs a prominent warning at compile time and in the README; ideally local-dev secrets shouldn't be auto-embedded without an explicit opt-in.
  2. pruneUnusedExtensions uses naive substring matching for liveness, which can mis-prune. See inline comment.
  3. serveStandaloneBundle swallows the termination signal in its exit code. See inline comment.

Details inline.

// Pure value/config bindings + static assets are baked directly into the
// generated `workerd` config and work without any Cloudflare backend.
plain_text: "supported",
secret_text: "supported",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secret_text: "supported" means secrets are treated as fully fine for standalone. But in the compile path these are resolved from .dev.vars/.env via getVarsForDev() and embedded verbatim into the emitted config.capnp/config.bin (which the generated Dockerfile then COPY . /apps, and the README tells users to commit). There is no warning anywhere that local secrets end up in plaintext in a shippable/committable artifact.

At minimum, please surface a clear warning at compile time and in the generated README when any secret_text binding is embedded. Consider whether local-dev secrets should be embedded at all without an explicit opt-in flag — silently baking .dev.vars into a distributable bundle is a footgun.

const live = collectWrappedModuleNames(keptServices);
const workerSources = collectWorkerSources(keptServices);
for (const name of moduleByName.keys()) {
if (workerSources.some((source) => source.includes(name))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Liveness here is decided by source.includes(name) — a raw substring scan over the bundled worker JS. This is fragile in two directions:

  • A module whose name is a substring of another live module's name (e.g. email vs email-extra) will be kept (or dropped) incorrectly.
  • Any string literal in user code that merely mentions a module name keeps that extension, even if it's never imported.

The same substring issue exists in the transitive closure below (source.includes(candidate)). Since over-pruning silently produces a broken bundle and under-pruning bloats it, consider matching against actual import specifiers (e.g. parse import statements / match quoted specifiers) rather than includes, or at least document this as a known heuristic limitation in the function's JSDoc.

let exited = false;
let exitError: Error | undefined;
const exitPromise = new Promise<void>((resolve) => {
child.on("exit", (code, signal) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --serve is interrupted, the forwarded SIGINT/SIGTERM causes the child to exit with signal set (not a code), so exitError stays undefined and runStandaloneCompile resolves normally — wrangler exits 0 even though it was terminated. For a long-running serve command this matters for shell/CI/container semantics (e.g. a supervisor expecting 130/143). Consider re-raising the signal (process.kill(process.pid, signal)) or setting a non-zero process.exitCode when the child was killed by a signal.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #14294. Summary of what I flagged:

  1. Secrets baked into the bundle in plaintext (highest severity)secret_text is marked supported, and the compile path resolves .dev.vars/.env secrets via getVarsForDev(), embedding them verbatim into config.capnp/config.bin. The generated Dockerfile COPY . /apps the whole thing and the README encourages committing the output, with no warning that real secrets are now in a checked-in artifact.

  2. pruneUnusedExtensions naive substring matching — liveness via source.includes(name) can mis-prune (substring collisions between module names, false positives from string literals), risking either broken bundles or bloat.

  3. serveStandaloneBundle signal handling — forwarded SIGINT/SIGTERM causes wrangler to exit 0 instead of propagating 130/143, which matters for supervisors/CI/containers.

github run

@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown
create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14294

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14294

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14294

miniflare

npm i https://pkg.pr.new/miniflare@14294

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14294

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14294

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14294

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14294

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14294

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14294

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14294

wrangler

npm i https://pkg.pr.new/wrangler@14294

commit: 3e344ce

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

2 participants