diff --git a/.changeset/standalone-compile.md b/.changeset/standalone-compile.md
new file mode 100644
index 0000000000..ec1f5b1a55
--- /dev/null
+++ b/.changeset/standalone-compile.md
@@ -0,0 +1,36 @@
+---
+"wrangler": minor
+"miniflare": minor
+"@cloudflare/vite-plugin": minor
+---
+
+Add an experimental `wrangler compile` command (and Vite `standalone` mode) for self-hosting Workers on standalone `workerd`
+
+`wrangler compile` builds your Worker into a self-contained `workerd` bundle (a `config.capnp`, embedded modules, on-disk static assets, a version-pinned `Dockerfile`, an entrypoint, and a `README.md` with run instructions) that you can run on any server outside of Cloudflare. Static assets are wired up out of the box. Pass `--serve` to run the emitted bundle locally with the bundled `workerd` binary — the exact artifact that ships. Pass `--format binary` to emit a single self-contained `config.bin` instead of the human-readable `config.capnp` plus embedded module files.
+
+You can also set `"standalone": true` in your configuration (or pass `--standalone` to `wrangler dev`) to opt in. When set, `wrangler dev` warns about bindings that work locally but are not yet supported by standalone `workerd`, and `wrangler deploy` errors (since the Worker targets a self-hosted runtime rather than Cloudflare).
+
+For Vite projects, pass `standalone: true` to the `cloudflare()` plugin and `vite build` will also emit the same standalone bundle:
+
+```ts
+// vite.config.ts
+import { cloudflare } from "@cloudflare/vite-plugin";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [cloudflare({ standalone: true })],
+});
+```
+
+```jsonc
+// wrangler.json
+{
+ "name": "my-worker",
+ "main": "src/index.js",
+ "compatibility_date": "2025-05-01",
+ "standalone": true,
+ "assets": { "directory": "./public", "binding": "ASSETS" },
+}
+```
+
+This is experimental and currently targets stateless Workers plus static assets; stateful bindings (KV, R2, D1, Durable Objects, etc.) are not yet supported.
diff --git a/RFC-wrangler-compile.md b/RFC-wrangler-compile.md
new file mode 100644
index 0000000000..07d48fb075
--- /dev/null
+++ b/RFC-wrangler-compile.md
@@ -0,0 +1,554 @@
+# RFC: `wrangler compile` — standalone self-hosted workerd bundles
+
+Status: **Phase 1 alpha IMPLEMENTED (internal, not public)** — stateless + static assets end-to-end; gated on cloudflare/workerd#6780 before any public release.
+Owner: TBD (Workers: Authoring and Testing)
+Scope of this doc: MVP = **stateless Worker + static assets**, output = **portable directory + Dockerfile**, exposed via a `wrangler compile` command (+ planned `@cloudflare/vite-plugin` `standalone` mode), built on **one shared core**.
+
+---
+
+## 0. Status at a glance
+
+> Single source of truth for progress. Detailed write-ups: §14 (spike), §15 (miniflare core), §16 (wrangler surface). Phasing/gating: §11.
+
+### ✅ Done — Phase 0 + Phase 1 core (landed & verified)
+
+| Area | What | Where |
+| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| Spike | Hand-stripped stateless+assets config served under bare `workerd` (worker + assets + content-types + 404, no Node) | §14 (throwaway artifacts) |
+| Miniflare core | `emitConfigText()` — faithful **text** Cap'n Proto emitter (embeds modules/blobs, fails loud on unsupported shapes) | `packages/miniflare/src/standalone/capnp-text.ts` |
+| Miniflare core | `toStandaloneConfig()` — reachability-pruned production transform (drop dev services, repoint `globalOutbound`→`internet`, single `http` socket, relativize `disk`) | `packages/miniflare/src/standalone/transform.ts` |
+| Miniflare core | `emitStandaloneBundle()` — writes `config.capnp` + embedded files + copies `disk` assets | `packages/miniflare/src/standalone/emit.ts` |
+| Miniflare seam | `Miniflare.prototype.unstable_getConfig()` — read the fully-assembled `Config` without re-deriving it | `packages/miniflare/src/index.ts` |
+| Config | `"standalone": boolean` field (validated + normalized) | `packages/workers-utils/src/config/{config,validation}.ts` |
+| Config | `standalone-support.ts` binding matrix (supported vs unsupported) — single source of truth | `packages/workers-utils/src/config/standalone-support.ts` |
+| CLI | `wrangler compile` command (`--outdir`, `--force`; validates → bundles via `deploy --dry-run` → Miniflare → `unstable_getConfig` → emit + Dockerfile + entrypoint + report) | `packages/wrangler/src/compile/index.ts` |
+| CLI | `wrangler deploy` **errors** when `standalone` set (allows `--dry-run`) | `packages/wrangler/src/deploy/index.ts` |
+| CLI | `wrangler dev` `--standalone` flag + non-fatal **warning** on unsupported bindings | `packages/wrangler/src/dev.ts`, `src/standalone/validate.ts` |
+| API | `unstable_compileStandalone()` programmatic entry point (shared by CLI + Vite) | `packages/wrangler/src/compile/index.ts`, exported via `src/cli.ts` |
+| Vite | `cloudflare({ standalone: true })` — `vite build` emits the same bundle via the shared core (Vite 6 + 7/8) | `packages/vite-plugin-cloudflare/src/plugins/standalone.ts` |
+| Tests (miniflare) | Unit + **e2e under real `workerd serve`** (incl. `fromEnvironment`) | `packages/miniflare/test/standalone.spec.ts` |
+| Tests (workers-utils) | `getStandaloneSupport` matrix + `standalone` config validation | `packages/workers-utils/tests/config/standalone-support.test.ts`, `…/validation/normalize-and-validate-config.test.ts` |
+| Tests (wrangler) | `compile` e2e (bundle/files; unsupported→error; `--force`) + deploy-guard / dev-warning helper unit tests | `packages/wrangler/e2e/compile.test.ts`, `packages/wrangler/src/__tests__/standalone.test.ts` |
+| Tests (vite) | `standalone` resolution unit test + programmatic `buildApp()` bundle-emit integration test | `packages/vite-plugin-cloudflare/src/__tests__/{resolve-plugin-config,standalone-build}.spec.ts` |
+| Release | Changeset (`wrangler` + `miniflare`, minor) | `.changeset/standalone-compile.md` |
+
+**End-to-end verified:** `wrangler compile` on a fixture (`fetch` + `vars` + assets) → `workerd serve` served `/api/*` (dynamic + `env.GREETING`) and `/` (static); `deploy` blocked, `deploy --dry-run` allowed; `dev` warned on a KV binding. **Vite `standalone` also verified**: `vite build` on a `cloudflare({ standalone: true })` fixture emitted a bundle that served the same `/api/*` + `/` correctly under bare `workerd`.
+
+### 🔶 Rough edges (acceptable for alpha, see §16)
+
+- [x] User-module name leaks the dry-run temp path — fixed: `modulesRoot` is now set to the deepest common dir of the module paths, so the entry emits as `index.js`.
+- [ ] Compile briefly starts `workerd` via Miniflare just to read the config — add an assemble-only path.
+- [x] Unused simulator extensions (ratelimit/workflows/email/analytics/dispatch) — fixed: extension modules not referenced by a kept worker (directly or transitively) are pruned in `toStandaloneConfig` (`pruneExtensions`, default on).
+- [x] Assets `disk` is emitted `writable` — fixed: `assets:*` disk services are emitted `writable = false`.
+
+### ⬜ Left to do — Phase 1 polish (before internal-alpha "done")
+
+- [x] **Tests across the stack:** `getStandaloneSupport` matrix + `standalone` config validation (workers-utils); deploy-guard + dev-warning helper unit tests (wrangler); `wrangler compile` e2e (supported → bundle/files; unsupported → error; `--force`; `--serve` runs the bundle under bare `workerd` and serves dynamic + static + 404); extension-pruning + read-only-disk unit tests (miniflare). _(Done.)_ Remaining: binary-format coverage once it lands.
+- [x] **Pruning/cleanup** of the rough edges above (module-path leak, unused extensions, read-only assets disk). Remaining: assemble-only path (avoid briefly starting `workerd`).
+- [x] **`--serve`** — run the emitted bundle locally with the bundled `workerd` binary (the exact production artifact). _(Done; §6 / §8.6.)_
+- [x] **`--format binary`** — emit a single self-contained `config.bin` (encoded Cap'n Proto, run with `workerd serve --binary`) instead of text + `src/` embeds. _(Done; §6 / §8.)_
+- [x] **README.md** in the emitted bundle (per-platform run instructions: local, Docker, PaaS `$PORT`). _(Done.)_ `COMPILE_REPORT.md` keeps the capability detail (services kept/stripped, pruned extensions, warnings).
+- [x] **`@cloudflare/vite-plugin` `standalone` mode** — thin adapter onto the same core via `unstable_compileStandalone()` (§7, §17). _(Done.)_
+- [x] **Workerd version pinning** in the Dockerfile/README/report — pinned to the exact `workerd` version this Wrangler build bundles. _(Done.)_
+- [ ] **Vite multi-worker / auxiliary Workers** — `standalone` currently compiles only the entry Worker (warns otherwise). _(Tied to cross-bundle service bindings, §8.5.)_
+- [ ] **Assemble-only path** (optimization) — `compile` briefly starts `workerd` via Miniflare just to read the assembled config.
+
+### ⬜ Left to do — gated / later phases
+
+- [ ] **Phase 2 — Durable Objects on cluster mode** (gated on a workerd release with #6780). The public-unlock. _(Not started.)_
+- [ ] **Stateful simulators** (KV/R2/D1/maybe Queues) — only with a real prod story; **external bindings** are the leading alternative (§11, O11). _(Punted.)_
+- [ ] **FU-1 — Remote-backed bindings** (AI/Browser/Vectorize/… via live Cloudflare) (§8.3). _(Deferred.)_
+- [ ] **Cross-bundle service bindings** (§8.5), **`--format executable`**, per-platform deploy recipes. _(Deferred.)_
+
+### ❓ Open decisions needing your call
+
+- **O11** — stateful prod story route: hardened simulators vs. external bring-your-own bindings (lean: external-first). See §11 / §13.
+
+> ## Release posture (read first)
+>
+> - **Public release is GATED on cloudflare/workerd#6780 landing.** #6780 (cluster mode for Durable Objects) is what gives the unique Cloudflare primitive — Durable Objects — a real production story. Until it lands, this is **alpha and internal only**.
+> - **We will not ship any feature publicly that lacks a great production story.** This explicitly includes the stateful simulators (KV, R2, D1, **and Queues — which we may not do at all**). They are _punted_, not scheduled, until a credible prod story exists (see §11 + the data caveats). #6780 fixes the worst state hazard but is **not sufficient** on its own for KV/R2/Queues (it doesn't address on-disk format stability, queue persistence, or backups).
+> - **Alpha = wiggle room.** While alpha, nothing is stable: the output layout, `config.capnp` shape, CLI flags, and runtime behavior may all change without notice. No backward-compatibility guarantees until we declare GA. GA criteria: #6780 landed + a great prod story for every shipped feature.
+
+---
+
+## 1. Motivation
+
+`workerd` has always been runnable in production to self-host Workers outside of Cloudflare (AWS, Hetzner, Railway, Render, Fly, bare metal, etc.). In practice almost nobody does, because:
+
+1. You must hand-author a Cap'n Proto (`.capnp`) config — there is no supported tool that turns a `wrangler.jsonc` + bundled Worker into a runnable workerd config.
+2. Features people expect "for free" on Cloudflare (static assets routing with `_headers`/`_redirects`/SPA handling, KV/R2/D1, Durable Objects) are not wired up by a bare `workerd serve`.
+
+Meanwhile, **Miniflare already solves the hard half of this**: it converts Worker options into a real `Workerd.Config` and runs it, and its KV/R2/D1/Queues/Cache "simulators" are themselves pure workerd Workers backed by SQLite — not Node code. The Node.js process is only needed for **dev-only scaffolding** (pretty errors, custom JS service bindings, magic proxy, inspector, live reload).
+
+`wrangler compile` makes the existing-but-hidden capability into a product: take a Worker, emit a self-contained directory that runs anywhere `workerd` runs, with a curated set of features wired up out of the box. PR cloudflare/workerd#6780 (cluster mode for Durable Objects) is the future unlock that makes the output horizontally scalable; this RFC keeps clustering out of the MVP but designs so it can slot in.
+
+## 2. Goals
+
+- `wrangler compile --outdir
` produces a **portable, Node-free** bundle that runs via `workerd serve config.capnp`.
+- MVP supports **stateless Workers + static assets** with production-equivalent asset behavior (`_headers`, `_redirects`, `html_handling`, `not_found_handling`, `run_worker_first`).
+- Output includes a **Dockerfile** (`FROM` a pinned `workerd` image) and a **capability report** explaining which bindings are wired, simulated, or unsupported.
+- Secrets map to **environment variables** (`fromEnvironment`), never baked into the bundle.
+- A `@cloudflare/vite-plugin` `standalone` mode produces the same bundle from `vite build`, reusing the same core.
+- Deterministic, inspectable output: emit **text** `.capnp` (human-readable, diffable, editable), not just binary.
+
+## 3. Non-goals (MVP)
+
+- **Full Workers _platform_ parity.** We target the _runtime_ (workerd), not the platform. Things provided by Cloudflare's edge — `request.cf`, smart placement, the cron scheduler, the queues/email pumps, the tail/observability dashboard, global replication — are out of scope and **documented loudly**. This is an accepted, explicit limitation, not a bug.
+- **Stateful simulators (KV/R2/D1/Queues) — punted, not scheduled.** They will not ship publicly without a great prod story (which #6780 alone does not provide). Queues may never ship. See §11 + data caveats. The architecture leaves room to add them, but there is no committed timeline.
+- **Durable Objects — gated on #6780.** DOs are the headline post-#6780 capability (they get a real distributed prod story), but they are not in the pre-#6780 alpha.
+- Cron triggers, Browser Rendering, Email, Dispatch, Secrets Store, Pipelines — out of scope (report as unsupported).
+- Custom programmatic `serviceBindings: () => {...}` JS callbacks — fundamentally Node-bound; unsupported in compiled output by design.
+- **Cross-bundle service bindings** (binding to a _separately-deployed_ Worker by name) — punted for now; see §8.5. The decision (compile multiple workers into one bundle vs. treat as external/remote vs. unsupported) is deferred.
+- **Python workers** (`python_workers`) — would require shipping the Pyodide runtime into the bundle; out of scope, reported unsupported.
+- Single-file `workerd compile` executable output — later `--format executable`.
+
+## 4. Background: what already exists (reuse map)
+
+| Need | Reuse from | Path |
+| ----------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| Read + normalize config | `readConfig` / `normalizeAndValidateConfig` | `packages/wrangler/src/config/index.ts`, `packages/workers-utils/src/config/validation.ts` |
+| Bundle Worker → modules | `buildWorker()` → `CfModule[]` | `packages/wrangler/src/deployment-bundle/maybe-build-worker.ts` |
+| Asset manifest (paths + hashes) | `buildAssetManifest()` | `packages/deploy-helpers/src/deploy/helpers/assets.ts` |
+| Asset router/asset config | `getAssetsOptions()` | `packages/wrangler/src/assets.ts` |
+| Config → Miniflare worker options | `unstable_getMiniflareWorkerOptions()` | `packages/wrangler/src/api/integrations/platform/index.ts` |
+| Build `Workerd.Config` from options | Miniflare plugin pipeline (`#assembleConfig`) | `packages/miniflare/src/index.ts` |
+| Serialize config (binary) | `serializeConfig()` | `packages/miniflare/src/runtime/config/index.ts` |
+| Config JS type definitions | `Config`, `Service`, `Worker`, `Socket`, ... | `packages/miniflare/src/runtime/config/workerd.ts` |
+| Assets workers (router/asset) | `@cloudflare/workers-shared` | `packages/workers-shared/{asset-worker,router-worker}` |
+| CLI command scaffolding | `createCommand` + `CommandRegistry` | `packages/wrangler/src/core/*`, e.g. `agent-memory/` |
+
+Key facts established during research:
+
+- Miniflare always runs workerd with `serve --binary --experimental ... -` (config on **stdin**, binary capnp) and depends on a Node **loopback** external service (`core:loopback`) plus an **entry worker** that does request routing + pretty-error conversion. These are the dev-only pieces to strip.
+- The simulator Workers (KV/R2/D1/Queues/Cache) persist to a `disk` service + SQLite and do **not** require the Node loopback at runtime.
+- workerd's `kvNamespace`/`r2Bucket`/`queue`/`analyticsEngine` bindings are **HTTP redirects to a named service** — workerd ships no storage backend; the simulators provide it.
+- `MINIFLARE_WORKERD_CONFIG_DEBUG=` dumps the assembled JS `Config` as JSON — the prototyping hook.
+- There is **no** existing text-`.capnp` exporter anywhere; Miniflare only emits binary on stdin.
+
+## 5. Architecture: the shared core
+
+Introduce a new module that produces a **production profile** of the workerd config — i.e. the same plugin-derived services/bindings, but assembled for "ship it" rather than "dev it."
+
+> **As-built note:** the original layout/names below were the proposal. What actually landed (see §15–§17 for detail):
+>
+> ```
+> packages/miniflare/src/standalone/
+> capnp-text.ts # emitConfigText(config, sink) — text .capnp serializer
+> transform.ts # toStandaloneConfig(config) — production-profile transform (reachability-pruned)
+> emit.ts # emitStandaloneBundle(config, outDir) — config.capnp + embeds + disk copies
+> index.ts # public exports
+>
+> packages/wrangler/src/compile/
+> index.ts # `wrangler compile` command + runStandaloneCompile() + unstable_compileStandalone()
+> packages/wrangler/src/standalone/
+> validate.ts # getStandaloneBindingIssues() / formatStandaloneBindingIssues()
+>
+> packages/vite-plugin-cloudflare/src/plugins/standalone.ts
+> # buildApp post-hook → wrangler.unstable_compileStandalone()
+> ```
+>
+> Differences from the proposal: there is **no separate `assembleStandaloneConfig`** — `wrangler compile` drives a real Miniflare instance and reads the assembled graph via `unstable_getConfig()`, then `toStandaloneConfig()` does the production transform. The Vite path hooks `buildApp` (not `closeBundle`/`writeBundle`).
+
+### 5.1 Production-profile config assembler
+
+Rather than fork `#assembleConfig`, factor out a reusable assembler that consumes the **same plugin `getServices()` / `getBindings()` output** but builds a leaner top-level `Config`:
+
+- **Omit**: `core:loopback` external service, the dev `entry` worker (pretty errors / live reload / magic proxy), inspector/debug-port sockets, dev-registry proxy, local explorer.
+- **Sockets**: emit a single `http` (or `https`) socket pointed **directly** at the user worker service — or, when assets are present, at the **assets router worker** which then falls through to the user worker (matching production precedence and `run_worker_first`).
+- **Paths**: rewrite all `disk` service paths to be **relative** to the bundle root (e.g. `./assets`, `./state/kv`), resolvable at boot via workerd `--directory-path =` overrides or a documented working directory.
+- **Secrets**: convert secret-class bindings to `fromEnvironment` so they read `getenv()` at startup.
+- **Determinism**: stable service ordering and names; no temp dirs.
+
+Practically this is a thin alternate top-level assembler that imports the plugin registry and calls each plugin's `getServices`/`getBindings` (the per-binding logic is reused verbatim), then stitches a production top-level instead of the dev one. The MVP only needs the `core` and `assets` plugins, so the first cut can be small and grow as Phase 2 adds storage plugins.
+
+### 5.2 Text capnp emitter
+
+Miniflare's `Config` JS type (`runtime/config/workerd.ts`) is a faithful mirror of `workerd.capnp`. `emitCapnpText.ts` walks that object and prints valid text capnp:
+
+```capnp
+using Workerd = import "/workerd/workerd.capnp";
+const config :Workerd.Config = (
+ services = [ ... ],
+ sockets = [ ( name = "http", address = "*:8080", http = (), service = "router" ) ],
+);
+```
+
+Modules are referenced via `embed "worker/index.js"` rather than inlined, so the `.capnp` stays readable and the JS lives as real files in the bundle. Binary blobs (asset manifest, wasm) are emitted as files and `embed`-ed. We keep `serializeConfig()` (binary) available behind `--format binary` for size-sensitive cases.
+
+> Open question O1: emit text capnp (readable, editable, what this RFC recommends) vs binary capnp via existing `serializeConfig` (smaller, opaque). Recommendation: text by default, binary opt-in.
+
+### 5.3 Static assets wiring
+
+Reuse Miniflare's assets plugin output (it already emits pure workers):
+
+- `assets:storage` → `disk` service at `./assets`
+- asset manifest (binary) → `data` binding (built via `buildAssetManifest`, but using the **content-hash-of-bytes** manifest, not the dev mtime shortcut)
+- `@cloudflare/workers-shared` `asset-worker` + `router-worker` as `esModule` services
+- `CONFIG` JSON from `getAssetsOptions()` (`html_handling`, `not_found_handling`, redirects, headers, `run_worker_first`)
+- Socket → router worker → (assets | user worker) per `routerConfig`
+
+This gives production-equivalent asset semantics with no bespoke MIME/SPA code.
+
+## 6. CLI surface
+
+> **Status:** the command has landed with a deliberately small surface. **Implemented today:** `--outdir` (default `./dist-standalone`), `--force`, `--format text|binary`, and `--serve` (+ `--port` / `--ip`). The remaining flags below are the **target** surface, not yet built.
+
+```
+# Implemented:
+wrangler compile [--outdir ] [--force] [--format text|binary]
+ [--serve [--port ] [--ip ]]
+
+# Target (planned, not yet built):
+wrangler compile [--assets ] [--compatibility-date ...] [--env ]
+ [--experimental] [--strict] [--dry-run]
+```
+
+- **`--outdir`** (implemented): output directory, default `./dist-standalone`.
+- **`--force`** (implemented): compile even when the Worker uses bindings not yet supported by standalone workerd (otherwise unsupported bindings are a hard error; see §16).
+- **`--format`** (implemented): `text` (default) emits a human-readable `config.capnp` plus `src/` module/blob embeds; `binary` emits a single self-contained `config.bin` (encoded Cap'n Proto with modules inlined) run via `workerd serve --binary`. The entrypoint/Dockerfile/README/report adapt automatically. (The earlier `dir|capnp|binary` sketch collapsed to `text|binary` — "dir" is just the always-present output directory.)
+- **`--serve`** (implemented): after compiling, run the produced bundle locally via the bundled `workerd` binary — exercises the **exact production artifact** on localhost. `--port` (default `8080`) and `--ip` (default `127.0.0.1`) control the bind address. Polls the socket for readiness, prints a `Serving …` line, and forwards `SIGINT`/`SIGTERM` to `workerd`. See §8.6.
+- _(planned)_ `--assets` overrides/forces the assets directory (otherwise read from config `assets.directory`).
+- _(planned)_ `--experimental` (default **off**): include workerd's `--experimental` flag in the generated entrypoint. Off by default for production safety; required to opt into experimental runtime features (ephemeral DO, memory cache, later `localDisk`/cluster). See §8.4.
+- _(planned)_ `--strict` (default off): escalate "no-op handler" and unsupported-binding warnings to hard errors (good for CI). Note: unsupported-binding handling already errors-by-default + `--force` to bypass, which is the inverse of the original `--strict` proposal; revisit whether `--strict` is still needed.
+- Reuses `createCommand` + `registry.define([{ command: "wrangler compile", definition }])` + `registerNamespace`, mirroring `agent-memory` and the hidden `build` command. Status `experimental`.
+- Handler receives normalized `config` from `createHandler` (default `provideConfig`).
+
+## 7. Vite plugin surface
+
+> **As-built:** `cloudflare({ standalone: true | { outDir?, force? } })` (landed — see §17). The original `{ outdir, format, port }` shape was trimmed to `{ outDir, force }` to match the CLI; `format`/`port` follow once the CLI gains them.
+
+`@cloudflare/vite-plugin` gains `standalone: true | { outDir?, force? }`. On `vite build` it already has the worker module graph + client assets; after the build is finalized (deploy config + per-Worker `wrangler.json` written) a `buildApp` post-hook reads the entry Worker's generated `wrangler.json` and calls the **same core** via `wrangler.unstable_compileStandalone()`. No duplicate logic — the Vite path is a thin adapter pointing the shared orchestration at the Vite-generated config. (Vite 6 vs 7/8 timing is handled by a guarded wrapper; exactly one path fires per build.)
+
+## 8. Output layout
+
+The proposed/target layout:
+
+```
+dist-standalone/
+ config.capnp # text capnp; entry point for `workerd serve`
+ worker/
+ index.js # bundled user worker (+ additional modules: wasm, etc.)
+ assets/ # static files, copied from assets.directory
+ assets-manifest.bin # binary asset manifest (embed-ed by config)
+ state/ # reserved for Phase 2 stateful bindings (empty in MVP)
+ Dockerfile
+ COMPILE_REPORT.md # capability report (wired / simulated / unsupported)
+ README.md # how to run locally + on each platform
+```
+
+> **As-built (current):** in the default `text` format the emitter writes embeds under `src/` and copies `disk` services under `disk//`, so a real bundle looks like:
+>
+> ```
+> dist-standalone/
+> config.capnp # text capnp (text format)
+> src/ # embedded modules + data blobs (ASSETS_MANIFEST, asset-worker.mjs, …)
+> index.js # user worker (emitted relative to the common module root, no temp-path leak)
+> disk/assets_storage/ # copied static assets (index.html, …)
+> Dockerfile # pins `workerd@` this bundle was built against
+> entrypoint.sh # $PORT-aware `workerd serve` wrapper
+> README.md # how to run: local / npx / Docker / PaaS $PORT
+> COMPILE_REPORT.md # capability detail (kept/stripped services, pruned extensions, warnings)
+> ```
+>
+> With `--format binary` the `config.capnp` + `src/` embeds collapse to a single self-contained `config.bin` (modules inlined); only `disk/` plus the runtime/doc files sit alongside it, and `entrypoint.sh`/`README`/`Dockerfile` use `workerd serve --binary config.bin`.
+>
+> Not yet emitted: a `state/` dir (Phase 2) and the tidier `worker/`+`assets/` naming. These are tracked in §0 / §16.
+
+### 8.1 Dockerfile + entrypoint (MVP)
+
+Platforms (Railway, Render, Fly, Heroku-style, some ECS setups) inject a dynamic `$PORT` at runtime. workerd does not read env into socket addresses, so we ship a tiny entrypoint wrapper rather than a static `--socket-addr`:
+
+```sh
+# entrypoint.sh
+exec workerd serve config.capnp \
+ --socket-addr=http=0.0.0.0:"${PORT:-8080}" \
+ ${WORKERD_EXTRA_ARGS:-}
+```
+
+```dockerfile
+FROM
+WORKDIR /worker
+COPY . .
+EXPOSE 8080
+ENTRYPOINT ["./entrypoint.sh"]
+```
+
+The socket in `config.capnp` is named `http` so the entrypoint's `--socket-addr=http=...` override binds it. `--experimental` is only added to `WORKERD_EXTRA_ARGS`/entrypoint when `--experimental` was passed at compile time.
+
+> Open question O2: which workerd distribution to base on (official published image vs `npm i workerd` binary copied into a slim base). **Verify whether an official `workerd` container image is published** (uncertain) — if not, we copy the pinned `workerd` npm binary into a Debian/distroless base. Pin to the same version the config was generated against to avoid schema/feature drift.
+
+### 8.2 Capability report
+
+Generated per compile run, classifying every binding found in config:
+
+- **Wired** (Tier 1): runs natively (routing, fetch, service bindings, assets, env secrets).
+- **Simulated** (Tier 2, Phase 2): shipped simulator + disk state (KV/R2/D1/Queues/Cache/DO).
+- **External** (Tier 3): needs you to provide infra (Hyperdrive → your DB, `service`/`external` targets).
+- **Remote-backed** (Tier 3b, _future_): proxied to live Cloudflare via remote bindings when `--account-id` + token are provided (Browser Rendering, AI, Vectorize, Images, Email, Dispatch, Secrets Store, Pipelines; optionally KV/R2/D1/Queues). Deferred follow-up — see §8.3 / §11.
+- **Unsupported** (Tier 4): anything not in the above and not remote-capable (e.g. cron triggers) — emit a clear warning and skip.
+
+In MVP, encountering Tier 2/3/4 bindings produces a warning (and, with `--strict`, an error).
+
+## 8.3 Remote-backed bindings (Cloudflare-as-a-service) — FOLLOW-UP (deferred)
+
+> Status: **deferred follow-up, not in MVP.** Captured here for design completeness; to be investigated and implemented in a later phase (see §11). The MVP ships nothing here — remote-only bindings are reported as unsupported until this lands.
+
+For products with no local backend (Browser Rendering, AI, Vectorize, Images, Email, Dispatch, Secrets Store, Pipelines) — and optionally for KV/R2/D1/Queues when the user wants the _real_ Cloudflare resource — the bundle can proxy to live Cloudflare services using the existing **remote bindings** (mixed-mode) machinery. This is opt-in via an account ID + API token at compile time.
+
+### How it reuses existing infrastructure
+
+workers-sdk already implements this for `wrangler dev`:
+
+- **Setup (Node, needs auth):** `startRemoteProxySession` (`packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts`) deploys a `ProxyServerWorker` to the user's account (auth via `getAuthHook` → `requireAuth` + `requireApiToken`), carrying the real bindings, and returns a `remoteProxyConnectionString` (a `workers.dev` URL).
+- **Runtime (pure workerd, no Node):** `packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts` proxies each `env.X` call to that URL — `fetch()` with `MF-Binding`/`MF-Header-*` headers, or a `capnweb` WebSocket RPC session for JSRPC-style bindings (`remote-bindings-utils.ts`).
+
+The client worker is the key enabler: it needs only `fetch` + WebSocket, both native to workerd. **Node is involved only at setup/deploy time, never at request time** — so it slots directly into a compiled bundle.
+
+### Compile-time flow
+
+1. With `--account-id ` + `CLOUDFLARE_API_TOKEN`, classify which bindings are remote (`pickRemoteBindings` + `getBindingLocalSupport`; some products report `DO-NOT-USE-...-never-have-a-local-simulator` and are remote-only).
+2. Deploy a **persistent, named** proxy worker (NOT the ephemeral dev preview) with those bindings + an auth secret.
+3. Bake the `remoteProxyConnectionString` + the remote-proxy-client worker into the bundle; pass the auth secret via `fromEnvironment`.
+4. At runtime under `workerd serve`, `env.AI` / `env.BROWSER` / `env.VECTORIZE` / etc. transparently proxy to live Cloudflare.
+
+This promotes Tier-4 "unsupported" bindings to **Tier 3b — remote-backed** (works out of the box when an account + token are supplied). Reflected in the capability report.
+
+### Deltas vs. the dev path (the actual new work)
+
+- **Lifecycle:** dev deploys an ephemeral _preview_ worker (`dev.remote: "minimal"`) with temporary edge-preview tokens; self-host needs a long-lived deployed worker (or reuse of a pinned one). Compile owns this deployment rather than calling `startRemoteProxySession` directly.
+- **Security:** the connection string invokes account bindings, so the persistent proxy MUST be authenticated — shared-secret header or Cloudflare Access service token (`CLOUDFLARE_ACCESS_CLIENT_ID`/`_SECRET` hooks already exist in `remote-bindings-utils.ts`). Generate the secret at compile time; never hardcode it.
+- **Cost/latency/egress:** every call round-trips to Cloudflare and is billed; surface this in the report. Inherent for AI/BR/Vectorize (no local equivalent).
+
+### Why a proxy worker at all (vs. direct REST)
+
+These products _do_ have public HTTP APIs, but in a Worker they're consumed as `env.X.method(...)`, not `fetch(api.cloudflare.com)`. The `env.X` object is a **wrapped binding** backed by a workerd-internal shim (e.g. `cloudflare-internal:ai-api`, see `packages/miniflare/src/plugins/ai/index.ts`) that wraps an inner `fetcher` and emits Cloudflare's **internal binding protocol** — _not_ the public REST shape. The proxy worker holds the real binding and is the uniform, binding-agnostic terminator of that internal protocol. That's why dev uses it: one mechanism, zero per-product code, faithful for everything.
+
+### Two tracks for compile
+
+- **Track A — direct REST (no proxy), REST-capable products:** AI, Vectorize, D1, KV, R2/S3, Images, Queues. Point the inner fetcher at `api.cloudflare.com` (or the S3 endpoint) + Bearer token, and ship a **REST-shaped shim per product** that maps `env.X.method()` → the public endpoint. Pros: no deployed proxy, lower latency. Cons: we own/maintain a REST adapter per product (drift risk).
+- **Track B — proxy worker (faithful), binding-protocol-only:** service bindings (`fetch`/JSRPC), Durable Object namespaces across the boundary, dispatch namespaces (`env.DISPATCHER.get().fetch()`), Browser Rendering full Puppeteer/CDP control, and anything using capnweb streaming. These have no public request/response REST equivalent, so the deployed proxy (real binding in its `env`) is the only faithful option — or mark unsupported.
+
+The `getBindingLocalSupport` table (`packages/workers-utils/src/config/binding-local-support.ts`) is the source of truth for which bindings are remote-only (`DO-NOT-USE-...-never-have-a-local-simulator`: ai, ai_search, media, vpc_service, vpc_network, websearch, agent_memory; `dispatch_namespace: remote`).
+
+> Open question O6: do we deploy the persistent proxy worker on the user's behalf during `compile` (needs write API token + cleanup story), or only emit instructions + config and let the user deploy it? Recommendation: support both — `--deploy-proxy` to do it, otherwise emit a ready-to-deploy proxy project + wiring.
+> Open question O7: how far to invest in Track A REST shims (which products, and do we reuse any existing adapters) vs. defaulting everything to Track B proxy. Recommendation: start Track B (universal, low-code), add Track A shims opportunistically for the highest-traffic REST products (AI, Vectorize) to cut latency/egress.
+
+## 8.4 Runtime & operational behavior
+
+- **`--experimental` is OFF by default.** Production bundles run without it for safety; opt in via the compile `--experimental` flag. Document which features require it.
+- **Dynamic port** via the entrypoint wrapper (`$PORT`, default 8080); see §8.1.
+- **Minimal `request.cf`.** Bare workerd populates `request.cf` with only `{ clientIp }` (verified in the spike) — not the full geo/colo/TLS object Cloudflare provides. So `request.cf` is _defined_ but `request.cf.country` etc. are `undefined`. **Documented loudly**; a future `--cf-blob ` static override is possible. Part of the accepted "runtime, not platform" stance (§3).
+- **No-op handlers.** A worker exporting `scheduled()` (cron), `queue()`, `email()`, or DO `alarm()` handlers will compile, but **nothing drives them** in bare workerd. Compile detects these and **warns by default; errors under `--strict`**. (Deliberate: warn is the default so a worker whose primary `fetch()` handler is fine still compiles, but CI can opt into failing.)
+- **Error visibility is reduced vs `wrangler dev`.** The Node loopback pretty-error layer is stripped, so unhandled exceptions surface as a plain 500 + a stack on stderr. MVP ships a **minimal in-worker last-resort error handler** and emits **structured logs to stdout**. The report sets expectations: no dashboard/tail; bring your own log aggregation.
+- **Static asset size:** **no 25 MiB per-asset cap.** Assets are served straight from disk (with a CDN expected in front for production), so Cloudflare's asset-size limit does not apply.
+- **Health checks:** a TCP check on the port suffices; consider a built-in `/cdn-cgi/healthz` endpoint (O8).
+- **SIGTERM / graceful shutdown (rumination, verify):** Cloudflare exposes no lifecycle hook to Worker _code_ (the platform drains), so we will **not** invent one (breaks parity). But the _process_ receives SIGTERM from `docker stop`/k8s rollouts/PaaS redeploys. The runtime should stop accepting connections, drain in-flight requests, and (Phase 2) flush DO/SQLite before exit. **Action: verify whether workerd already drains+flushes on SIGTERM or hard-exits** (empirical / ask workerd team). Low-risk for stateless+assets (LB retries); matters for Phase 2 state.
+
+## 8.5 Cross-bundle service bindings (punted)
+
+If the worker binds `env.OTHER` to a _separately-deployed_ Worker, self-host has nowhere to route it. The resolution — (a) compile multiple workers into one bundle and wire them locally via `service` bindings, (b) treat as external/remote (FU-1), or (c) mark unsupported — is **deferred**. MVP assumes a single user worker (+ optional assets). Multi-worker bundles and the "which worker is the entrypoint" question are noted but not designed yet. Assets-only projects (no `main`) _are_ in scope: socket → router → assets, no user worker.
+
+## 8.6 Local dev & parity
+
+- **The inner dev loop is unchanged.** Developers keep using `wrangler dev` / Vite dev. `wrangler compile` is a **release/packaging step**, not a dev mode — it does not replace `dev`.
+- **Test the artifact locally before shipping.** There is a real behavioral gap between `wrangler dev` (loopback, pretty errors, hot reload, inspector, mock `cf`) and the compiled bundle (none of those). To catch bugs that hide in that gap, `wrangler compile --serve` compiles and runs the **exact production artifact** locally via the bundled `workerd` binary on localhost. **Landed** (§6): launches `workerd serve config.capnp` from the bundle root, binds `--ip`/`--port`, waits for the socket to accept requests, and tears down cleanly on `SIGINT`/`SIGTERM`. Covered by `packages/wrangler/e2e/compile.test.ts` (fetches a dynamic route, a static asset, and a 404 against the running bundle).
+- **Dev↔compiled parity is the core promise and the core risk.** "What you `dev` is what you `compile`" only holds if we test it behaviorally (see §10). Known, documented divergences: no pretty errors, no `request.cf`, no inspector, reduced observability.
+
+## 9. Security & correctness notes
+
+- Never serialize secret values into `config.capnp`; use `fromEnvironment`. Document that `.dev.vars`/`.env` are dev-only.
+- `internet` network service inherits Miniflare's allow-list; for production self-host, default to `public` only (no `private`/SSRF surface) unless the user opts in.
+- Inbound TLS: support `--tls-key`/`--tls-cert` → `TlsOptions` keypair, but default to plain HTTP behind a platform LB (Railway/Render/Fly terminate TLS).
+- Pin workerd version into the bundle metadata; warn if the local `workerd serve` version differs from the one compiled against (capnp schema is wire-compatible but features/flags drift).
+
+## 10. Testing strategy
+
+**Landed:**
+
+- **Miniflare core (unit + e2e):** reachability/repoint/relativize transform, read-only assets disk, extension pruning (incl. transitive keep + `pruneExtensions: false`), emitted text + file layout, **and end-to-end tests that run the emitted bundle under the real `workerd serve` binary in both `text` and `binary` formats** and assert a `200` + the worker's JSON (incl. a `fromEnvironment` value). `packages/miniflare/test/standalone.spec.ts`.
+- **workers-utils (unit):** `getStandaloneSupport` supported/unsupported matrix + unknown-binding default; `standalone` config field validation (boolean accepted, non-boolean errors).
+- **wrangler `compile` (e2e):** fixture worker + `public/` dir → `wrangler compile` → assert `config.capnp` (vars baked), version-pinned `Dockerfile`, `entrypoint.sh`, `README.md`, `COMPILE_REPORT.md`, and the copied `disk/assets_storage/index.html`; unsupported binding (KV) → error; `--force` → compiles; **`--serve` runs the emitted bundle under the real `workerd` binary and serves a dynamic route, a static asset, and a 404**; **`--format binary --serve` does the same from a single `config.bin`** (served-behavior coverage). `packages/wrangler/e2e/compile.test.ts`.
+- **wrangler guards (unit):** deploy errors when `standalone` set / allows `--dry-run`; `getStandaloneBindingIssues`/`formatStandaloneBindingIssues` helpers. `packages/wrangler/src/__tests__/standalone.test.ts`.
+- **Vite (integration):** programmatic `createBuilder().buildApp()` with `cloudflare({ standalone: true })` emits the standalone bundle; disabled by default; plus `standalone` option resolution. `packages/vite-plugin-cloudflare/src/__tests__/{standalone-build,resolve-plugin-config}.spec.ts`.
+
+**Still to add (tied to unbuilt features):**
+
+- **Deeper asset-behavior coverage:** the `--serve` e2e now covers dynamic route + static asset + 404; still to add explicit `_headers`/`_redirects` and `run_worker_first` precedence assertions against the running bundle.
+- **Vite↔CLI behavioral parity:** run both bundles and assert equivalent responses (not byte-identical capnp).
+- **Dev↔compiled parity:** same fixture under `wrangler dev` vs the compiled artifact via `--serve` (now possible); codify known divergences (no `request.cf`, no pretty errors) as explicit expectations.
+- Conventions: `runInTempDir`, `mockConsoleMethods`, `expect` from test context. Changeset required (user-facing) — landed.
+
+## 11. Phasing
+
+All of Phase 0–1 is **alpha / internal**. Nothing goes public until #6780 lands AND every shipped feature has a great prod story (see Release posture).
+
+- **Phase 0 — spike: ✅ DONE (validated).** See §14. A real stateless+assets config was dumped via `MINIFLARE_WORKERD_CONFIG_DEBUG`, stripped of the dev scaffolding, emitted as text capnp, and served correctly under bare `workerd serve` (worker + assets + content-types + 404, no Node, no `--experimental`). The production-profile approach is confirmed feasible.
+- **Phase 1 — alpha (this RFC, internal): ✅ CORE IMPLEMENTED, polish remaining.** `wrangler compile` **and Vite `standalone` mode** for **stateless + static assets** → dir + Dockerfile + report; text capnp; shared `standalone` core in Miniflare; shared `unstable_compileStandalone()` orchestration; `standalone` config + `dev` warning + `deploy` guard. text **and binary** capnp; pinned-`workerd` Dockerfile + bundle `README.md`. Verified end-to-end under bare `workerd` via both entry points. Remaining: an assemble-only path (avoid briefly starting `workerd`) and Vite auxiliary Workers. See §0 / §15 / §16 / §17. Not public.
+- **Phase 2 — Durable Objects on cluster mode (the public-unlock):** gated on a workerd release including #6780. `--cluster` emits `ClusterConfig` + shared channel-token key + sample multi-node compose/k8s; horizontally scalable DOs on shared FS/NFS. This is the capability that justifies a public alpha/beta, because DOs are the unique primitive and #6780 gives them a real prod story.
+- **Conditional (prod-story-gated, no committed timeline) — stateful simulators:** KV/R2/D1/(maybe Queues)/Cache. Ship _only if_ we close the gaps #6780 leaves: on-disk format-stability commitment, backup/restore tooling, queue persistence, and a clear single-node-vs-clustered story. The likely-better alternative is `external` bindings (R2→S3/MinIO, D1→SQL gateway, KV→Redis) — see §11 posture. Queues may never ship.
+- **Later — polish:** `--format executable` (`workerd compile`), per-platform deploy recipes (ECS/Fly/Hetzner/Railway/Render), Hyperdrive → external DB, state seeding/migration tooling.
+
+### Follow-up items (deferred, post-MVP — investigate then implement)
+
+- **FU-1 — Remote-backed bindings (Cloudflare-as-a-service):** wire AI / Browser Rendering / Vectorize / Images / Dispatch / etc. (and optionally KV/R2/D1/Queues) to live Cloudflare when an account ID + API token are provided. Full design in §8.3. Requires: persistent (non-preview) proxy worker lifecycle, proxy auth (shared secret / Access service token), and a decision on Track A (direct REST shims) vs Track B (proxy worker) per product (O6/O7). Until done, remote-only bindings are reported unsupported.
+
+### Phase 2 caveat: the Miniflare simulators are NOT a managed database
+
+Before defaulting Phase 2 to the shipped simulators, weigh these concerns (they shape the recommended posture — see O11):
+
+1. **On-disk format is a Miniflare implementation detail, not a stable contract.** `migrateDatabase()` already exists for "legacy layout" → the format has changed before. Runtime upgrades can require migration or risk data loss. No committed backward-compat guarantee.
+2. **Multi-process corruption (the hard one).** The simulators _are_ Durable Objects (single-owner per object per instance). Multiple workerd processes over the same `state/` dir = uncoordinated writers on the same SQLite files → corruption. This is #6780's problem, but the KV/R2/D1 simulators aren't covered, so **any stateful binding pins you to a single workerd process**. Silent, sharp edge when scaling out.
+3. **Queues lose data on restart** (simulator uses `inMemory`, no disk persistence). Not production-grade; must be flagged.
+4. **Semantics match, guarantees don't.** Local KV = strong+unreplicated (vs eventual+global); R2 = no multipart-scale/lifecycle; D1 = no backups/time-travel/replicas; no Cloudflare quotas. Portability footgun both ways.
+5. **No backup/restore/observability** tooling; operator must back up `state/` (consistently — see #6).
+6. **Crash consistency / torn backups.** SQLite WAL protects committed writes, but file-blob store + naive live copy can capture a torn snapshot. Needs a documented quiesce/checkpoint backup procedure.
+7. **Throughput.** Built for dev fidelity, not load; single SQLite-backed DO per namespace bottlenecks under real traffic.
+
+**Recommended Phase 2 posture:** lead with **"bring your own infra via `external` bindings"** (R2→S3/MinIO, D1→your SQL gateway, KV→Redis) as the _recommended_ production path; offer the simulators as a **zero-config, single-node, you-own-backups** default with loud caveats (good for small/hobby/rebuildable-data apps, not a managed DB).
+
+### Prerequisites (before writing code) — mostly addressed during Phase 1
+
+1. ✅ **Phase 0 spike** — done (§14).
+2. ✅ **Loopback/entry-worker dependency audit** — done; the reachability transform drops `loopback`/`core:entry`/`strip-cf-connecting-ip`/`cache`/`email:disk`/`local-explorer`/`rpc-proxy` (§14, §15).
+3. ⬜ **Cross-team alignment with workerd/Miniflare owners** — still open: (a) dev simulators as a _production_ substrate; (b) `--experimental` in production; (c) workerd Docker image + version policy; (d) SIGTERM drain/flush behavior; (e) #6780 timeline. (O12 notes the teams work closely; formalize when needed.)
+4. ⬜ **Ownership of the shared core** (`packages/miniflare/src/standalone`) — code landed there; Miniflare maintainers to confirm the maintenance contract (§12).
+5. ✅ **Integration seam validated** — `deploy --dry-run` bundle + `unstable_getMiniflareWorkerOptions` + `unstable_getConfig()` feed the emitter (§16).
+6. ✅ **Blocking open questions** — O1 (text capnp), O2 (Docker base), O4 (naming = `compile`), `--experimental` default off, `$PORT` entrypoint — all resolved.
+7. ✅ **MVP binding matrix + warning UX** — `standalone-support.ts` + dev warning + compile error (§16); stability stance settled (O10).
+
+## 12. Risks
+
+- **Production profile drift:** factoring a second top-level assembler risks divergence from Miniflare's dev assembler. Mitigation: share all per-binding plugin logic; only the top-level stitching differs; snapshot tests.
+- **Loopback-coupled features:** any feature secretly relying on the Node loopback (some error paths, custom bindings) silently breaks. Mitigation: capability report + E2E that runs the real binary.
+- **Schema/version skew:** generated config vs installed workerd. Mitigation: pin + warn.
+- **Scope creep into Phase 2 infra:** keep MVP strictly stateless+assets; gate the rest behind explicit warnings.
+
+## 13. Open questions
+
+### Resolved (decisions taken)
+
+- **`--experimental`:** OFF by default, opt-in flag. ✓
+- **`request.cf` / platform parity:** not provided; documented loudly; "runtime, not platform" is an accepted stance. ✓
+- **No-op handlers:** warn by default, error under `--strict`. ✓
+- **Asset size cap:** none (disk-served, CDN in prod). ✓
+- **Cross-bundle service bindings:** punted (§8.5). ✓
+- **Python workers:** out of scope, noted. ✓
+- **O1 — text vs binary capnp default:** text by default, `--format binary` opt-in. ✓
+
+### Still open — recommendations I can make
+
+- **O2 — RESOLVED:** there is **no** official Cloudflare-published `workerd` Docker image (verified: workerd ships only as npm prebuilt binaries; the repo's `Dockerfile.release` is build-only; community images like `jacoblincool/workerd` / `Selflare` exist but aren't official). Decision: **build our own minimal image** by copying the pinned `workerd` npm binary into a slim/distroless base, pinned to the generated-against version. (Prior art worth reviewing: `Selflare` self-hosts Workers with KV/D1/R2/DO/Cache.)
+- **O3:** Vite `standalone` — bundle SSR + client into one config, or worker only? Recommend worker + client assets = one config.
+- **O4:** Naming — `wrangler compile` vs `wrangler build --standalone`. Recommend dedicated `compile` (`build` is already a hidden dry-run alias).
+- **O5:** `state/` location vs container writable volume (Phase 2). Recommend a `DATA_DIR` env + `--directory-path` override convention.
+- **O8:** Built-in `/cdn-cgi/healthz` endpoint, or document TCP check only? Recommend a lightweight built-in health endpoint.
+
+### Resolved by product direction
+
+- **O9 — MVP usefulness: RESOLVED.** Thin stateless+assets MVP is fine because it's **alpha/internal**; public value comes from Durable Objects post-#6780, not from KV in Phase 1. KV/R2/Queues are punted.
+- **O10 — Support/stability stance: RESOLVED.** Alpha, internal, no stability guarantees; public release gated on #6780 + great prod story per feature (see Release posture).
+
+### Need YOUR call — still open
+
+- **O11 — How to build the stateful prod story (when/if we do).** Two routes to a "great prod story" for KV/R2/D1: (a) **simulators** hardened with #6780 coordination + format-stability commitment + backups, or (b) **external bindings** (bring-your-own S3/Postgres/Redis) as the recommended production path. You've ruled out shipping them without a story; the remaining question is which route (or both). Lean: external-first.
+- **O12 — RESOLVED.** workerd and Miniflare teams work closely together; ownership of the shared `standalone` core and the "dev simulators as production substrate" decision can be made jointly when the time comes. Not a blocker.
+
+## 14. Phase 0 spike results (validated)
+
+A stateless Worker (`fetch` handler + `vars`) with static assets (`assets.binding`) was run under `wrangler dev` with `MINIFLARE_WORKERD_CONFIG_DEBUG`, the dumped config was transformed into a stripped standalone **text-capnp** bundle, and served under the bare `workerd` binary (v1.20260518.1) — no Node, no loopback, no `--experimental`.
+
+**Result: PASS.** `/api/*` → dynamic worker (with `env.GREETING`); `/` → `index.html`; `/style.css` → `200 text/css` (correct content-type via the real `asset-worker`); unknown path → `404` (real `not_found_handling`). Port was set via `--socket-addr=http=...`, validating the `$PORT` entrypoint approach.
+
+### Confirmed service graph (stateless + assets)
+
+KEEP (runs standalone): `core:user:` (user worker), `assets:router:` (workers-shared router), `assets:assets-service:` (workers-shared asset-worker), `assets:kv:` (disk-backed fake-KV), `assets:storage` (`disk` → assets dir), `internet` (network).
+
+DROP (dev-only): `loopback` (the **only** Node dependency), `core:entry` (dev entry/pretty-errors — the only service that binds `loopback`), `strip-cf-connecting-ip:*`, `cache:*`, `email:disk`, `core:local-explorer*`, `assets:rpc-proxy:*`.
+
+### Concrete transforms the production-profile assembler must do (learned from the spike)
+
+1. **Socket → router.** Replace the single `entry` socket (→ `core:entry`) with an `http` socket pointed directly at `assets:router:` (or directly at the user worker when no assets). Name it `http` so the entrypoint's `--socket-addr=http=...` override binds.
+2. **Repoint `globalOutbound`.** The user worker's `globalOutbound` points at the dev `strip-cf-connecting-ip:*` shim → repoint to `internet` (or omit to default).
+3. **Relativize `disk` paths.** `assets:storage.disk.path` (and Phase 2 state dirs) → bundle-relative (`./public`, `./state/...`).
+4. **Per-worker compat is preserved verbatim** from the plugin output (e.g. asset-worker = `2024-07-31` + `nodejs_compat`,`enable_ctx_exports`; router adds `no_nodejs_compat_v2`). Do not normalize.
+5. **Extensions:** `toStandaloneConfig` keeps only the extension modules referenced by a kept worker — directly (the module name appears in a worker's source or a `wrapped` binding's `moduleName`) or transitively (imported by another kept extension module). For a stateless+assets worker this collapses to just `miniflare:shared` (+ `miniflare:zod` when used); the unused simulator extensions (ratelimit/workflows/email/analytics/dispatch) are pruned.
+6. **`data`/`json` bindings** (e.g. `ASSETS_MANIFEST` binary, `ASSETS_REVERSE_MAP` json, `CONFIG`) carry over directly; emit binary `data` as `embed`-ed files in text mode.
+
+### Caveats found
+
+- **`request.cf` = `{ clientIp }` only** (not absent) — see §8.4.
+- The debug JSON encodes `data` (Uint8Array) as a numeric-keyed object; a real emitter using Miniflare's `serializeConfig` keeps it as bytes (no round-trip concern there) — relevant only if a tool consumes the debug JSON directly.
+
+### Spike artifacts
+
+Throwaway, outside the repos: `/Users/sunilpai/code/compile-spike/` (`app/` = the dev project, `make-standalone.mjs` = the strip+emit prototype, `out/` = the runnable standalone bundle).
+
+## 15. Phase 1 progress — standalone core landed (in `packages/miniflare`)
+
+The production-profile assembler + text emitter (generalizing the spike's `make-standalone.mjs`) now live in the Miniflare package as a reusable, tested module: `packages/miniflare/src/standalone/`.
+
+- **`capnp-text.ts` — `emitConfigText(config, sink)`**: a faithful text Cap'n Proto emitter for Miniflare's `Config` type. Inlines short scalars/strings/JSON (with a Cap'n-Proto-safe escaper that never relies on `\u`); externalizes module sources and binary blobs via an `EmbedSink` and references them with `embed`, keeping the config human-readable. Fails loud on unsupported shapes (Python modules, HTTPS sockets, crypto-key bindings) rather than emitting something wrong.
+- **`transform.ts` — `toStandaloneConfig(config, options)`**: a pure transform. Instead of the spike's hardcoded keep-set, it does **reachability pruning** from the entry service (auto-detected: assets router → else first user worker), keeping only reachable, non-dev services. It repoints `globalOutbound` off the dev `strip-cf-connecting-ip` shim to `internet`, drops `cacheApiOutbound`/`moduleFallback` that reference dev-only services, replaces all sockets with a single named `http` socket → entry, and relativizes `disk` paths (returning `diskCopies` for the emitter). Input is not mutated; returns `{ config, diskCopies, keptServices, droppedServices, entryService, warnings }`.
+- **`emit.ts` — `emitStandaloneBundle(config, outDir, options)`**: runs the transform and writes the config in the requested `format`. `"text"` (default) writes a human-readable `config.capnp` plus every embedded module/data file under `src/` (collision-safe); `"binary"` writes a single self-contained `config.bin` via `serializeConfig()` (modules inlined, no `src/`). Both copy each `disk` service's contents into the bundle. Returns `{ ..., configPath, files, format }`.
+- **Miniflare seam — `Miniflare.prototype.unstable_getConfig()`**: returns the most-recently-assembled `workerd` `Config` (captured in `#assembleAndUpdateConfig`). This is how `wrangler compile` will obtain the fully-resolved service graph **without** re-deriving it or round-tripping through the lossy debug JSON. Marked alpha/unstable.
+
+**Tests (`packages/miniflare/test/standalone.spec.ts`, all passing):** unit coverage of reachability/repoint/relativize, of the emitted text + file layout, **and an end-to-end test that runs the emitted bundle under the real `workerd serve` binary** (via the `workerd` npm dep) and asserts a `200` + the worker's JSON, including a value injected through a `fromEnvironment` binding.
+
+### New finding (matters for the Dockerfile/run model)
+
+`workerd` resolves **`disk` service paths relative to its working directory**, while `embed` paths are resolved **relative to the `.capnp` file**. So the bundle must be **run from its own root** (`cd bundle && workerd serve config.capnp`), or disk services must be remapped at launch with `--directory-path=NAME=PATH`. The container `WORKDIR` must therefore be the bundle root. (Confirmed by the e2e test, which initially failed with `Directory named "assets:storage" not found` until `cwd` was set to the bundle dir.) The `$PORT` override continues to work via `--socket-addr=http=127.0.0.1:$PORT`.
+
+## 16. Phase 1 progress — `wrangler compile` + `standalone` wiring landed (in `packages/wrangler`)
+
+The user-facing surface is now implemented and verified end-to-end against a fixture (stateless `fetch` + `vars` + static assets) running under the bare `workerd` binary.
+
+- **`standalone` config field** (`@cloudflare/workers-utils`): `"standalone": boolean` in `wrangler.json`, validated and normalized like other top-level flags. A shared `standalone-support.ts` classifies each binding type as `supported` (stateless/pure-workerd: vars, secrets, wasm, text/data/json, assets, service bindings, …) or `unsupported` (stateful/platform: KV, R2, D1, Queues, Durable Objects, AI, Browser, Vectorize, …). This is the single source of truth for the dev warning and the compile error.
+- **`wrangler compile`** (`packages/wrangler/src/compile/index.ts`, registered as a top-level command, `status: experimental`):
+ 1. Validates bindings via the shared `standalone-support` matrix; errors on unsupported bindings unless `--force`.
+ 2. Builds the worker by reusing the existing `deploy --dry-run --outfile` pipeline (no Cloudflare account needed), then parses the bundle's `FormData` into Miniflare `ModuleDefinition[]` (entry module first). This reuses `check startup`'s bundle→modules helpers (`parseFormDataFromFile`, `convertWorkerBundleToModules`, now exported).
+ 3. Derives bindings/assets/compat via `unstable_getMiniflareWorkerOptions(config, env)`, drives a quiet `new Miniflare({ workers: [...] })`, reads the resolved graph via `unstable_getConfig()`, and disposes.
+ 4. `emitStandaloneBundle(...)` writes the config (`text` `config.capnp` + embeds, or `binary` `config.bin`) + on-disk assets; then writes a `$PORT`-aware `entrypoint.sh`, a `Dockerfile` (`node:20-slim` + `npm install workerd@`, `WORKDIR /app`), a human-facing `README.md` (local/npx/Docker/PaaS run instructions), and a `COMPILE_REPORT.md` (entry service, kept/dropped services, pruned extensions, warnings).
+ - Default output dir `dist-standalone`; `--outdir` to override; `--force` to compile past unsupported bindings; `--format text|binary` to pick the config encoding; `--serve` (+ `--port`/`--ip`) to run the emitted bundle locally under the bundled `workerd` binary.
+- **`wrangler deploy` guard**: errors when `config.standalone` is set and it is **not** a `--dry-run` (dry-run is intentionally allowed because `compile` reuses it internally). Message points the user to `wrangler compile`.
+- **`wrangler dev` warning**: a hidden `--standalone` flag plus `config.standalone` trigger a best-effort, non-fatal warning listing bindings that work in local dev but aren't yet supported by `compile`. Multi-config (`-c a -c b`) dev is skipped for now.
+
+**Verified:** `wrangler compile` on the fixture produced a runnable bundle; `workerd serve config.capnp` served `/api/*` (dynamic worker + `env.GREETING`) and `/` (static `index.html`) correctly. `wrangler deploy` errored on `standalone`; `wrangler deploy --dry-run` succeeded. `wrangler dev` emitted the unsupported-binding warning for a KV binding. Changeset added (`wrangler`/`miniflare` minor).
+
+### Known rough edges (acceptable for alpha)
+
+- ~~The user worker's emitted module name reflects the dry-run bundle's absolute path~~ — fixed: `modulesRoot` is set to the deepest common module dir, so the entry emits as `index.js`.
+- Compile briefly starts `workerd` (via Miniflare) just to read the assembled config — the assemble-only optimization below still applies.
+- ~~Unused simulator extensions are still embedded~~ — fixed: `toStandaloneConfig` prunes extension modules nothing references; a stateless+assets bundle keeps only `miniflare:shared`.
+- ~~Assets `disk` is emitted `writable`~~ — fixed: `assets:*` disk services are now emitted read-only (`writable = false`).
+
+### Tests (landed)
+
+- `packages/wrangler/e2e/compile.test.ts` — compile a fixture → assert `config.capnp`/`Dockerfile`/`entrypoint.sh`/`COMPILE_REPORT.md` + copied assets; unsupported-binding error; `--force`; `--serve` runs the bundle under bare `workerd` and serves dynamic + static + 404.
+- `packages/miniflare/test/standalone.spec.ts` — `toStandaloneConfig`/`emitStandaloneBundle` (reachability, read-only assets disk, extension pruning incl. transitive + `pruneExtensions: false`) and a bare `workerd serve` smoke test.
+- `packages/wrangler/src/__tests__/standalone.test.ts` — deploy guard + dry-run + binding-issue helpers.
+- `packages/vite-plugin-cloudflare/src/__tests__/standalone-build.spec.ts` (programmatic `vite build` emits the bundle; disabled by default) + `resolve-plugin-config.spec.ts` standalone resolution.
+- `packages/workers-utils/tests/config/standalone-support.test.ts` + `standalone` validation cases.
+
+### Still to do for Phase 1
+
+- **`--format binary`** to emit `serializeConfig` output instead of text. _(`--serve` is done — see §6 / §8.6. Output-quality cleanups — module path, extension pruning, read-only assets disk — are done.)_
+- **Optimization (later):** assemble-only path so `compile` doesn't briefly start `workerd` just to read the config.
+- **Bundle `README.md`** + workerd version pinning in the Dockerfile/report.
+
+## 17. Phase 1 progress — Vite `standalone` mode landed (in `packages/vite-plugin-cloudflare`)
+
+`@cloudflare/vite-plugin`'s `cloudflare()` factory gains a `standalone?: boolean | { outDir?: string; force?: boolean }` option. When set, `vite build` emits the same standalone `workerd` bundle as `wrangler compile`, reusing **one implementation** (no duplicated assembly/emit logic).
+
+- **Shared orchestration.** Wrangler's compile pipeline was refactored into `runStandaloneCompile(config, …)` and a programmatic `unstable_compileStandalone({ configPath, outDir, force })`, exported from the `wrangler` package root (`src/cli.ts`). The CLI command and the Vite plugin both call it.
+- **How the Vite path works.** Vite's normal build already emits a deployable per-Worker `wrangler.json` (with `no_bundle: true`, `main`, `rules`, `assets.directory`) plus client assets, and a `.wrangler/deploy/config.json`. After the build is finalized, `emitStandaloneBuild()` reads the entry Worker's generated `wrangler.json` from the deploy config and hands it to `unstable_compileStandalone()`. Because the Worker is already bundled, the internal `deploy --dry-run` step simply collects the prebuilt modules — so assets, bindings, and compat flow through unchanged.
+- **Version-robust integration seam.** The emit must run _after_ the build is fully finalized (deploy config + `wrangler.json` written, `removeAssetsField` applied). It is wired in two guarded places mirroring the existing `removeAssetsField` split: `standalonePlugin`'s `buildApp` post hook (Vite 7/8) and the end of `createBuildApp` via a `wrapBuildAppWithStandalone` wrapper guarded by `!satisfiesMinimumViteVersion("7.0.0")` (Vite 6). Exactly one fires per build.
+- **`no named imports from "wrangler"`** rule respected — the plugin calls `wrangler.unstable_compileStandalone(...)` via the namespace import.
+
+**Verified:** a `cloudflare({ standalone: true })` fixture (worker `fetch` + `vars` + `public/` assets) built with `vite build` (Vite 8) emitted `dist-standalone/` with `config.capnp` (entry `assets:router:`), embedded modules, and `disk/assets_storage/index.html`; running it under bare `workerd serve` returned the dynamic `/api/*` JSON (with `env.GREETING`) and the static `/` HTML.
+
+### Vite-specific limitations (alpha)
+
+- **Entry Worker only.** Auxiliary Workers in the deploy config are not yet compiled into the bundle (a warning is logged). Tied to the cross-bundle service-binding question (§8.5).
+- Shares §16's remaining rough edge (compile briefly starts `workerd` to read the config); the module-path, extension-pruning, and read-only-disk cleanups apply to the Vite path too.
+- A custom `builder.buildApp` in user Vite config bypasses the Vite 6 wrapper (Vite 7+ post hook still fires).
diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts
index 225f975af9..4459a14990 100644
--- a/packages/miniflare/src/index.ts
+++ b/packages/miniflare/src/index.ts
@@ -980,6 +980,10 @@ export class Miniflare {
readonly #runtime?: Runtime;
readonly #removeExitHook?: () => void;
#runtimeEntryURL?: URL;
+ // The most recent `workerd` config assembled by `#assembleAndUpdateConfig()`.
+ // Exposed (alpha) via `unstable_getConfig()` so tooling (e.g. `wrangler compile`)
+ // can transform it into a standalone bundle without re-deriving the service graph.
+ #lastAssembledConfig?: Config;
publicUrl?: string;
#socketPorts?: SocketPorts;
#runtimeDispatcher?: Dispatcher;
@@ -2415,6 +2419,7 @@ export class Miniflare {
loopbackPort,
this.#devRegistry.isEnabled()
);
+ this.#lastAssembledConfig = config;
const configBuffer = serializeConfig(config);
// Get all socket names we expect to get ports for
@@ -2663,6 +2668,26 @@ export class Miniflare {
return new URL(this.#runtimeEntryURL.toString());
}
+ /**
+ * Returns the `workerd` {@link Config} most recently assembled for this
+ * instance, waiting for initialisation to complete first.
+ *
+ * This is an alpha API intended for tooling that needs the fully-resolved
+ * service graph (e.g. `wrangler compile`, which transforms it into a
+ * standalone bundle via {@link toStandaloneConfig}). The shape is not yet
+ * stable and may change without notice.
+ */
+ async unstable_getConfig(): Promise {
+ this.#checkDisposed();
+ await this.#initPromise;
+ await this.#runtimeMutex.drained();
+ assert(
+ this.#lastAssembledConfig !== undefined,
+ "Config has not been assembled yet"
+ );
+ return this.#lastAssembledConfig;
+ }
+
async #registerWorkers(): Promise {
if (!this.#devRegistry.isEnabled()) {
return;
@@ -3235,6 +3260,7 @@ export * from "./http";
export * from "./plugins";
export * from "./runtime";
export * from "./shared";
+export * from "./standalone";
export * from "./workers";
export * from "./merge";
export * from "./zod-format";
diff --git a/packages/miniflare/src/standalone/capnp-text.ts b/packages/miniflare/src/standalone/capnp-text.ts
new file mode 100644
index 0000000000..eee354bbad
--- /dev/null
+++ b/packages/miniflare/src/standalone/capnp-text.ts
@@ -0,0 +1,609 @@
+import { kVoid } from "../runtime/config";
+import type {
+ Config,
+ DiskDirectory,
+ ExternalServer,
+ Extension,
+ HttpOptions,
+ Network,
+ Service,
+ ServiceDesignator,
+ Socket,
+ Worker,
+ Worker_Binding,
+ Worker_DurableObjectNamespace,
+ Worker_DurableObjectStorage,
+ Worker_Module,
+} from "../runtime/config";
+
+/**
+ * Destination for content that is too large or unsafe to inline directly into
+ * the generated Cap'n Proto text (module sources, binary blobs). Implementations
+ * persist the content somewhere and return a path, relative to the `.capnp` file,
+ * suitable for use with Cap'n Proto's `embed` keyword.
+ */
+export interface EmbedSink {
+ embedText(hint: string, content: string): string;
+ embedBinary(hint: string, content: Uint8Array): string;
+}
+
+const INDENT = "\t";
+
+function pad(depth: number): string {
+ return INDENT.repeat(depth);
+}
+
+/**
+ * Renders a Cap'n Proto struct literal. `fields` entries that are `null` are
+ * omitted, so callers can conditionally include fields inline. Each value string
+ * must already be rendered at `depth + 1`.
+ */
+function struct(depth: number, fields: Array<[string, string] | null>): string {
+ const present = fields.filter(
+ (field): field is [string, string] => field !== null
+ );
+ if (present.length === 0) {
+ return "()";
+ }
+ const inner = pad(depth + 1);
+ const body = present
+ .map(([key, value]) => `${inner}${key} = ${value}`)
+ .join(",\n");
+ return `(\n${body}\n${pad(depth)})`;
+}
+
+function list(depth: number, items: string[]): string {
+ if (items.length === 0) {
+ return "[]";
+ }
+ const inner = pad(depth + 1);
+ return `[\n${items.map((item) => `${inner}${item}`).join(",\n")}\n${pad(depth)}]`;
+}
+
+/**
+ * Escapes a string into a Cap'n Proto text literal. Only the escapes the text
+ * grammar guarantees are emitted; all other code points pass through as UTF-8
+ * (Cap'n Proto source is UTF-8), so we never rely on `\u` support.
+ */
+export function capnpString(value: string): string {
+ let out = '"';
+ for (const char of value) {
+ const code = char.codePointAt(0) ?? 0;
+ if (char === '"') {
+ out += '\\"';
+ } else if (char === "\\") {
+ out += "\\\\";
+ } else if (char === "\n") {
+ out += "\\n";
+ } else if (char === "\r") {
+ out += "\\r";
+ } else if (char === "\t") {
+ out += "\\t";
+ } else if (code < 0x20 || code === 0x7f) {
+ out += `\\x${code.toString(16).padStart(2, "0")}`;
+ } else {
+ out += char;
+ }
+ }
+ return `${out}"`;
+}
+
+function emitDesignator(designator: ServiceDesignator, depth: number): string {
+ const hasName = designator.name !== undefined;
+ // `ServiceDesignator` accepts a bare string shorthand for the `name` field, so
+ // emit the compact form whenever no other fields are set (matches workerd docs).
+ if (
+ hasName &&
+ designator.entrypoint === undefined &&
+ designator.props === undefined
+ ) {
+ return capnpString(designator.name ?? "");
+ }
+ return struct(depth, [
+ hasName ? ["name", capnpString(designator.name ?? "")] : null,
+ designator.entrypoint !== undefined
+ ? ["entrypoint", capnpString(designator.entrypoint)]
+ : null,
+ designator.props !== undefined
+ ? [
+ "props",
+ struct(depth + 1, [["json", capnpString(designator.props.json)]]),
+ ]
+ : null,
+ ]);
+}
+
+function emitHttpOptions(options: HttpOptions, depth: number): string {
+ return struct(depth, [
+ options.style !== undefined ? ["style", String(options.style)] : null,
+ options.forwardedProtoHeader !== undefined
+ ? ["forwardedProtoHeader", capnpString(options.forwardedProtoHeader)]
+ : null,
+ options.cfBlobHeader !== undefined
+ ? ["cfBlobHeader", capnpString(options.cfBlobHeader)]
+ : null,
+ options.injectRequestHeaders !== undefined
+ ? [
+ "injectRequestHeaders",
+ list(
+ depth + 1,
+ options.injectRequestHeaders.map((header) =>
+ struct(depth + 2, [
+ header.name !== undefined
+ ? ["name", capnpString(header.name)]
+ : null,
+ header.value !== undefined
+ ? ["value", capnpString(header.value)]
+ : null,
+ ])
+ )
+ ),
+ ]
+ : null,
+ ]);
+}
+
+function emitModule(
+ module: Worker_Module,
+ depth: number,
+ sink: EmbedSink
+): string {
+ const name = module.name;
+ const fields: Array<[string, string] | null> = [["name", capnpString(name)]];
+ if ("esModule" in module && module.esModule !== undefined) {
+ fields.push([
+ "esModule",
+ `embed ${capnpString(sink.embedText(name, module.esModule))}`,
+ ]);
+ } else if (
+ "commonJsModule" in module &&
+ module.commonJsModule !== undefined
+ ) {
+ fields.push([
+ "commonJsModule",
+ `embed ${capnpString(sink.embedText(name, module.commonJsModule))}`,
+ ]);
+ } else if ("text" in module && module.text !== undefined) {
+ fields.push([
+ "text",
+ `embed ${capnpString(sink.embedText(name, module.text))}`,
+ ]);
+ } else if ("json" in module && module.json !== undefined) {
+ fields.push([
+ "json",
+ `embed ${capnpString(sink.embedText(name, module.json))}`,
+ ]);
+ } else if ("data" in module && module.data !== undefined) {
+ fields.push([
+ "data",
+ `embed ${capnpString(sink.embedBinary(name, module.data))}`,
+ ]);
+ } else if ("wasm" in module && module.wasm !== undefined) {
+ fields.push([
+ "wasm",
+ `embed ${capnpString(sink.embedBinary(name, module.wasm))}`,
+ ]);
+ } else {
+ throw new Error(
+ `Unsupported module type for "${name}" in standalone output (e.g. Python modules are not yet supported)`
+ );
+ }
+ return struct(depth, fields);
+}
+
+function emitBinding(
+ binding: Worker_Binding,
+ depth: number,
+ sink: EmbedSink
+): string {
+ const name = binding.name;
+ const head: [string, string] | null =
+ name !== undefined ? ["name", capnpString(name)] : null;
+ const designatorArms = [
+ "service",
+ "kvNamespace",
+ "r2Bucket",
+ "r2Admin",
+ "queue",
+ "analyticsEngine",
+ ] as const;
+ for (const arm of designatorArms) {
+ if (arm in binding) {
+ const value = (binding as Record)[arm];
+ return struct(depth, [head, [arm, emitDesignator(value, depth + 1)]]);
+ }
+ }
+ if ("text" in binding && binding.text !== undefined) {
+ return struct(depth, [head, ["text", capnpString(binding.text)]]);
+ }
+ if ("json" in binding && binding.json !== undefined) {
+ return struct(depth, [head, ["json", capnpString(binding.json)]]);
+ }
+ if ("data" in binding && binding.data !== undefined) {
+ return struct(depth, [
+ head,
+ [
+ "data",
+ `embed ${capnpString(sink.embedBinary(name ?? "data", binding.data))}`,
+ ],
+ ]);
+ }
+ if ("wasmModule" in binding && binding.wasmModule !== undefined) {
+ return struct(depth, [
+ head,
+ [
+ "wasmModule",
+ `embed ${capnpString(sink.embedBinary(name ?? "wasm", binding.wasmModule))}`,
+ ],
+ ]);
+ }
+ if ("fromEnvironment" in binding && binding.fromEnvironment !== undefined) {
+ return struct(depth, [
+ head,
+ ["fromEnvironment", capnpString(binding.fromEnvironment)],
+ ]);
+ }
+ if ("durableObjectNamespace" in binding && binding.durableObjectNamespace) {
+ const designator = binding.durableObjectNamespace;
+ return struct(depth, [
+ head,
+ [
+ "durableObjectNamespace",
+ struct(depth + 1, [
+ designator.className !== undefined
+ ? ["className", capnpString(designator.className)]
+ : null,
+ designator.serviceName !== undefined
+ ? ["serviceName", capnpString(designator.serviceName)]
+ : null,
+ ]),
+ ],
+ ]);
+ }
+ if ("wrapped" in binding && binding.wrapped !== undefined) {
+ const wrapped = binding.wrapped;
+ return struct(depth, [
+ head,
+ [
+ "wrapped",
+ struct(depth + 1, [
+ wrapped.moduleName !== undefined
+ ? ["moduleName", capnpString(wrapped.moduleName)]
+ : null,
+ wrapped.entrypoint !== undefined
+ ? ["entrypoint", capnpString(wrapped.entrypoint)]
+ : null,
+ wrapped.innerBindings !== undefined
+ ? [
+ "innerBindings",
+ list(
+ depth + 2,
+ wrapped.innerBindings.map((inner) =>
+ emitBinding(inner, depth + 3, sink)
+ )
+ ),
+ ]
+ : null,
+ ]),
+ ],
+ ]);
+ }
+ if ("unsafeEval" in binding && binding.unsafeEval === kVoid) {
+ return struct(depth, [head, ["unsafeEval", "void"]]);
+ }
+ throw new Error(
+ `Unsupported binding "${name ?? "(unnamed)"}" in standalone output: ${Object.keys(
+ binding
+ )
+ .filter((key) => key !== "name")
+ .join(", ")}`
+ );
+}
+
+function emitDurableObjectNamespace(
+ namespace: Worker_DurableObjectNamespace,
+ depth: number
+): string {
+ return struct(depth, [
+ namespace.className !== undefined
+ ? ["className", capnpString(namespace.className)]
+ : null,
+ "uniqueKey" in namespace && namespace.uniqueKey !== undefined
+ ? ["uniqueKey", capnpString(namespace.uniqueKey)]
+ : null,
+ "ephemeralLocal" in namespace && namespace.ephemeralLocal === kVoid
+ ? ["ephemeralLocal", "void"]
+ : null,
+ namespace.preventEviction !== undefined
+ ? ["preventEviction", String(namespace.preventEviction)]
+ : null,
+ namespace.enableSql !== undefined
+ ? ["enableSql", String(namespace.enableSql)]
+ : null,
+ ]);
+}
+
+function emitDurableObjectStorage(
+ storage: Worker_DurableObjectStorage,
+ depth: number
+): string {
+ if ("none" in storage && storage.none === kVoid) {
+ return struct(depth, [["none", "void"]]);
+ }
+ if ("inMemory" in storage && storage.inMemory === kVoid) {
+ return struct(depth, [["inMemory", "void"]]);
+ }
+ if ("localDisk" in storage && storage.localDisk !== undefined) {
+ return struct(depth, [["localDisk", capnpString(storage.localDisk)]]);
+ }
+ throw new Error("Unsupported durableObjectStorage in standalone output");
+}
+
+function emitWorker(worker: Worker, depth: number, sink: EmbedSink): string {
+ const fields: Array<[string, string] | null> = [];
+ if ("modules" in worker && worker.modules !== undefined) {
+ fields.push([
+ "modules",
+ list(
+ depth + 1,
+ worker.modules.map((module) => emitModule(module, depth + 2, sink))
+ ),
+ ]);
+ } else if (
+ "serviceWorkerScript" in worker &&
+ worker.serviceWorkerScript !== undefined
+ ) {
+ fields.push([
+ "serviceWorkerScript",
+ `embed ${capnpString(sink.embedText("service-worker.js", worker.serviceWorkerScript))}`,
+ ]);
+ } else if ("inherit" in worker && worker.inherit !== undefined) {
+ fields.push(["inherit", capnpString(worker.inherit)]);
+ }
+ if (worker.compatibilityDate !== undefined) {
+ fields.push(["compatibilityDate", capnpString(worker.compatibilityDate)]);
+ }
+ if (worker.compatibilityFlags !== undefined) {
+ fields.push([
+ "compatibilityFlags",
+ list(
+ depth + 1,
+ worker.compatibilityFlags.map((flag) => capnpString(flag))
+ ),
+ ]);
+ }
+ if (worker.bindings !== undefined) {
+ fields.push([
+ "bindings",
+ list(
+ depth + 1,
+ worker.bindings.map((binding) => emitBinding(binding, depth + 2, sink))
+ ),
+ ]);
+ }
+ if (worker.globalOutbound !== undefined) {
+ fields.push([
+ "globalOutbound",
+ emitDesignator(worker.globalOutbound, depth + 1),
+ ]);
+ }
+ if (worker.cacheApiOutbound !== undefined) {
+ fields.push([
+ "cacheApiOutbound",
+ emitDesignator(worker.cacheApiOutbound, depth + 1),
+ ]);
+ }
+ if (worker.durableObjectNamespaces !== undefined) {
+ fields.push([
+ "durableObjectNamespaces",
+ list(
+ depth + 1,
+ worker.durableObjectNamespaces.map((namespace) =>
+ emitDurableObjectNamespace(namespace, depth + 2)
+ )
+ ),
+ ]);
+ }
+ if (worker.durableObjectUniqueKeyModifier !== undefined) {
+ fields.push([
+ "durableObjectUniqueKeyModifier",
+ capnpString(worker.durableObjectUniqueKeyModifier),
+ ]);
+ }
+ if (worker.durableObjectStorage !== undefined) {
+ fields.push([
+ "durableObjectStorage",
+ emitDurableObjectStorage(worker.durableObjectStorage, depth + 1),
+ ]);
+ }
+ return struct(depth, fields);
+}
+
+function emitNetwork(network: Network, depth: number): string {
+ return struct(depth, [
+ network.allow !== undefined
+ ? [
+ "allow",
+ list(
+ depth + 1,
+ network.allow.map((entry) => capnpString(entry))
+ ),
+ ]
+ : null,
+ network.deny !== undefined
+ ? [
+ "deny",
+ list(
+ depth + 1,
+ network.deny.map((entry) => capnpString(entry))
+ ),
+ ]
+ : null,
+ ]);
+}
+
+function emitDisk(disk: DiskDirectory, depth: number): string {
+ return struct(depth, [
+ disk.path !== undefined ? ["path", capnpString(disk.path)] : null,
+ disk.writable !== undefined ? ["writable", String(disk.writable)] : null,
+ disk.allowDotfiles !== undefined
+ ? ["allowDotfiles", String(disk.allowDotfiles)]
+ : null,
+ ]);
+}
+
+function emitExternal(external: ExternalServer, depth: number): string {
+ const fields: Array<[string, string] | null> = [
+ external.address !== undefined
+ ? ["address", capnpString(external.address)]
+ : null,
+ ];
+ if ("http" in external && external.http !== undefined) {
+ fields.push(["http", emitHttpOptions(external.http, depth + 1)]);
+ } else {
+ throw new Error(
+ "Unsupported external service variant in standalone output (only plain http is supported)"
+ );
+ }
+ return struct(depth, fields);
+}
+
+function emitService(service: Service, depth: number, sink: EmbedSink): string {
+ const head: [string, string] | null =
+ service.name !== undefined ? ["name", capnpString(service.name)] : null;
+ if ("worker" in service && service.worker !== undefined) {
+ return struct(depth, [
+ head,
+ ["worker", emitWorker(service.worker, depth + 1, sink)],
+ ]);
+ }
+ if ("network" in service && service.network !== undefined) {
+ return struct(depth, [
+ head,
+ ["network", emitNetwork(service.network, depth + 1)],
+ ]);
+ }
+ if ("disk" in service && service.disk !== undefined) {
+ return struct(depth, [head, ["disk", emitDisk(service.disk, depth + 1)]]);
+ }
+ if ("external" in service && service.external !== undefined) {
+ return struct(depth, [
+ head,
+ ["external", emitExternal(service.external, depth + 1)],
+ ]);
+ }
+ throw new Error(
+ `Unsupported service "${service.name ?? "(unnamed)"}" in standalone output`
+ );
+}
+
+function emitSocket(socket: Socket, depth: number): string {
+ const fields: Array<[string, string] | null> = [
+ socket.name !== undefined ? ["name", capnpString(socket.name)] : null,
+ socket.address !== undefined
+ ? ["address", capnpString(socket.address)]
+ : null,
+ socket.service !== undefined
+ ? ["service", emitDesignator(socket.service, depth + 1)]
+ : null,
+ ];
+ if ("https" in socket && socket.https !== undefined) {
+ throw new Error("HTTPS sockets are not supported in standalone output");
+ }
+ // Default to a plain HTTP socket; `http = ()` is valid and common.
+ const http = "http" in socket ? socket.http : undefined;
+ fields.push([
+ "http",
+ http !== undefined ? emitHttpOptions(http, depth + 1) : "()",
+ ]);
+ return struct(depth, fields);
+}
+
+function emitExtension(
+ extension: Extension,
+ depth: number,
+ sink: EmbedSink
+): string {
+ return struct(depth, [
+ extension.modules !== undefined
+ ? [
+ "modules",
+ list(
+ depth + 1,
+ extension.modules.map((module) =>
+ struct(depth + 2, [
+ module.name !== undefined
+ ? ["name", capnpString(module.name)]
+ : null,
+ module.internal !== undefined
+ ? ["internal", String(module.internal)]
+ : null,
+ module.esModule !== undefined
+ ? [
+ "esModule",
+ `embed ${capnpString(sink.embedText(module.name ?? "extension.js", module.esModule))}`,
+ ]
+ : null,
+ ])
+ )
+ ),
+ ]
+ : null,
+ ]);
+}
+
+/**
+ * Renders a Miniflare-assembled {@link Config} as human-readable text Cap'n Proto
+ * suitable for `workerd serve`. Module sources and binary blobs are written via
+ * {@link EmbedSink} and referenced with `embed`, keeping the config readable.
+ */
+export function emitConfigText(config: Config, sink: EmbedSink): string {
+ const fields: Array<[string, string] | null> = [];
+ if (config.services !== undefined) {
+ fields.push([
+ "services",
+ list(
+ 1,
+ config.services.map((service) => emitService(service, 2, sink))
+ ),
+ ]);
+ }
+ if (config.sockets !== undefined) {
+ fields.push([
+ "sockets",
+ list(
+ 1,
+ config.sockets.map((socket) => emitSocket(socket, 2))
+ ),
+ ]);
+ }
+ if (config.extensions !== undefined && config.extensions.length > 0) {
+ fields.push([
+ "extensions",
+ list(
+ 1,
+ config.extensions.map((extension) => emitExtension(extension, 2, sink))
+ ),
+ ]);
+ }
+ if (config.v8Flags !== undefined && config.v8Flags.length > 0) {
+ fields.push([
+ "v8Flags",
+ list(
+ 1,
+ config.v8Flags.map((flag) => capnpString(flag))
+ ),
+ ]);
+ }
+ if (config.autogates !== undefined && config.autogates.length > 0) {
+ fields.push([
+ "autogates",
+ list(
+ 1,
+ config.autogates.map((gate) => capnpString(gate))
+ ),
+ ]);
+ }
+ const header = `using Workerd = import "/workerd/workerd.capnp";\n`;
+ return `${header}\nconst config :Workerd.Config = ${struct(0, fields)};\n`;
+}
diff --git a/packages/miniflare/src/standalone/emit.ts b/packages/miniflare/src/standalone/emit.ts
new file mode 100644
index 0000000000..a89e75f6f2
--- /dev/null
+++ b/packages/miniflare/src/standalone/emit.ts
@@ -0,0 +1,132 @@
+import { cpSync, mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { serializeConfig } from "../runtime/config";
+import { emitConfigText } from "./capnp-text";
+import { toStandaloneConfig } from "./transform";
+import type { Config } from "../runtime/config";
+import type { EmbedSink } from "./capnp-text";
+import type {
+ StandaloneTransformOptions,
+ StandaloneTransformResult,
+} from "./transform";
+
+/**
+ * How the `Workerd.Config` is written to disk:
+ * - `"text"` — human-readable Cap'n Proto with modules/blobs `embed`-ed as
+ * sibling files. Inspectable and diffable; the default.
+ * - `"binary"` — a single self-contained encoded Cap'n Proto message (run with
+ * `workerd serve --binary`). No sibling module files; modules are inlined.
+ */
+export type StandaloneConfigFormat = "text" | "binary";
+
+/** File name written for each {@link StandaloneConfigFormat}. */
+export const STANDALONE_CONFIG_FILENAME: Record<
+ StandaloneConfigFormat,
+ string
+> = {
+ text: "config.capnp",
+ binary: "config.bin",
+};
+
+export interface EmitStandaloneOptions extends StandaloneTransformOptions {
+ /**
+ * Directory (relative to the config) for embedded module and data files in
+ * `"text"` format. Defaults to `"src"`. Unused for `"binary"` format.
+ */
+ embedDir?: string;
+ /** Config serialization format. Defaults to `"text"`. */
+ format?: StandaloneConfigFormat;
+}
+
+export interface EmitStandaloneResult extends StandaloneTransformResult {
+ /** Absolute path to the written config file. */
+ configPath: string;
+ /** Bundle-relative paths of every file written or copied (excluding the config). */
+ files: string[];
+ /** The format the config was written in. */
+ format: StandaloneConfigFormat;
+}
+
+/** Strips path traversal and absolute prefixes so `hint` is safe to join under the bundle. */
+function safeRelative(hint: string): string {
+ const cleaned = hint
+ .replace(/\\/g, "/")
+ .split("/")
+ .filter((segment) => segment !== "" && segment !== "." && segment !== "..")
+ .join("/");
+ return cleaned === "" ? "module" : cleaned;
+}
+
+/**
+ * Transforms `config` into a standalone configuration (see {@link toStandaloneConfig})
+ * and writes the complete bundle to `outDir`: the config (`"text"` or `"binary"`,
+ * see {@link StandaloneConfigFormat}), any `"text"`-format embedded module/data
+ * files, and the contents of any `disk` services.
+ */
+export function emitStandaloneBundle(
+ config: Config,
+ outDir: string,
+ options: EmitStandaloneOptions = {}
+): EmitStandaloneResult {
+ const result = toStandaloneConfig(config, options);
+ const embedDir = options.embedDir ?? "src";
+ const format = options.format ?? "text";
+
+ mkdirSync(outDir, { recursive: true });
+
+ const used = new Set();
+ const files: string[] = [];
+
+ function reserve(hint: string): string {
+ const base = path.posix.join(embedDir, safeRelative(hint));
+ let candidate = base;
+ if (used.has(candidate)) {
+ const ext = path.posix.extname(base);
+ const stem = base.slice(0, base.length - ext.length);
+ let index = 1;
+ do {
+ candidate = `${stem}-${index}${ext}`;
+ index++;
+ } while (used.has(candidate));
+ }
+ used.add(candidate);
+ return candidate;
+ }
+
+ function writeRelative(relative: string, content: string | Uint8Array): void {
+ const destination = path.join(outDir, ...relative.split("/"));
+ mkdirSync(path.dirname(destination), { recursive: true });
+ writeFileSync(destination, content);
+ files.push(relative);
+ }
+
+ const configPath = path.join(outDir, STANDALONE_CONFIG_FILENAME[format]);
+ if (format === "binary") {
+ // Binary config inlines all modules/blobs, so there's nothing to embed —
+ // only the (read-only) `disk` contents need to live alongside it.
+ writeFileSync(configPath, serializeConfig(result.config));
+ } else {
+ const sink: EmbedSink = {
+ embedText(hint, content) {
+ const relative = reserve(hint);
+ writeRelative(relative, content);
+ return relative;
+ },
+ embedBinary(hint, content) {
+ const relative = reserve(hint);
+ writeRelative(relative, content);
+ return relative;
+ },
+ };
+ writeFileSync(configPath, emitConfigText(result.config, sink));
+ }
+
+ for (const copy of result.diskCopies) {
+ const destination = path.join(outDir, ...copy.to.split("/"));
+ mkdirSync(path.dirname(destination), { recursive: true });
+ cpSync(copy.from, destination, { recursive: true });
+ files.push(copy.to);
+ }
+
+ return { ...result, configPath, files, format };
+}
diff --git a/packages/miniflare/src/standalone/index.ts b/packages/miniflare/src/standalone/index.ts
new file mode 100644
index 0000000000..ba8378bda4
--- /dev/null
+++ b/packages/miniflare/src/standalone/index.ts
@@ -0,0 +1,14 @@
+export { capnpString, emitConfigText } from "./capnp-text";
+export type { EmbedSink } from "./capnp-text";
+export { toStandaloneConfig } from "./transform";
+export type {
+ StandaloneDiskCopy,
+ StandaloneTransformOptions,
+ StandaloneTransformResult,
+} from "./transform";
+export { emitStandaloneBundle, STANDALONE_CONFIG_FILENAME } from "./emit";
+export type {
+ EmitStandaloneOptions,
+ EmitStandaloneResult,
+ StandaloneConfigFormat,
+} from "./emit";
diff --git a/packages/miniflare/src/standalone/transform.ts b/packages/miniflare/src/standalone/transform.ts
new file mode 100644
index 0000000000..93388aef68
--- /dev/null
+++ b/packages/miniflare/src/standalone/transform.ts
@@ -0,0 +1,450 @@
+import {
+ LOCAL_EXPLORER_DISK,
+ SERVICE_ENTRY,
+ SERVICE_LOCAL_EXPLORER,
+} from "../plugins/core/constants";
+import { SERVICE_LOOPBACK } from "../plugins/shared/constants";
+import type {
+ Config,
+ Extension,
+ Extension_Module,
+ Service,
+ ServiceDesignator,
+ Socket,
+ Worker,
+ Worker_Binding,
+} from "../runtime/config";
+
+const ASSETS_ROUTER_PREFIX = "assets:router:";
+const USER_SERVICE_PREFIX = "core:user:";
+const ASSETS_SERVICE_PREFIX = "assets:";
+
+export interface StandaloneTransformOptions {
+ /** Name of the generated entry socket. Defaults to `"http"`. */
+ socketName?: string;
+ /** Listen address for the entry socket. Defaults to `"*:8080"`. */
+ address?: string;
+ /**
+ * Override which service the entry socket routes to. Defaults to the assets
+ * router (if present) or the first user worker.
+ */
+ entryServiceName?: string;
+ /** Bundle-relative directory under which `disk` service contents are copied. */
+ diskDir?: string;
+ /**
+ * Drop extension modules not referenced by any kept worker (or transitively
+ * by another referenced extension module). Defaults to `true`.
+ */
+ pruneExtensions?: boolean;
+}
+
+export interface StandaloneDiskCopy {
+ serviceName: string;
+ /** Absolute source path taken from the assembled config. */
+ from: string;
+ /** Bundle-relative destination path. */
+ to: string;
+}
+
+export interface StandaloneTransformResult {
+ config: Config;
+ diskCopies: StandaloneDiskCopy[];
+ keptServices: string[];
+ droppedServices: string[];
+ /** Names of extension modules pruned because nothing referenced them. */
+ droppedExtensionModules: string[];
+ entryService: string;
+ warnings: string[];
+}
+
+/**
+ * Services that only exist to support Miniflare's local development experience.
+ * They depend on the Node.js loopback server, the dev inspector, or local-only
+ * scaffolding and must never appear in a standalone `workerd serve` bundle.
+ */
+function isDevOnlyService(name: string): boolean {
+ return (
+ name === SERVICE_LOOPBACK ||
+ name === SERVICE_ENTRY ||
+ name === SERVICE_LOCAL_EXPLORER ||
+ name === LOCAL_EXPLORER_DISK ||
+ name.startsWith("core:local-explorer") ||
+ name.startsWith("strip-cf-connecting-ip:") ||
+ name === "cache" ||
+ name.startsWith("cache:") ||
+ name === "email" ||
+ name.startsWith("email:") ||
+ name.includes(":rpc-proxy") ||
+ name.includes("dev-registry")
+ );
+}
+
+function sanitize(name: string): string {
+ return name.replace(/[^a-zA-Z0-9._-]/g, "_");
+}
+
+function* bindingServiceRefs(binding: Worker_Binding): Iterable {
+ const designatorArms = [
+ "service",
+ "kvNamespace",
+ "r2Bucket",
+ "r2Admin",
+ "queue",
+ "analyticsEngine",
+ ] as const;
+ for (const arm of designatorArms) {
+ if (arm in binding) {
+ const designator = (
+ binding as Record
+ )[arm];
+ if (designator?.name !== undefined) {
+ yield designator.name;
+ }
+ }
+ }
+ if (
+ "hyperdrive" in binding &&
+ binding.hyperdrive?.designator?.name !== undefined
+ ) {
+ yield binding.hyperdrive.designator.name;
+ }
+ if (
+ "durableObjectNamespace" in binding &&
+ binding.durableObjectNamespace?.serviceName !== undefined
+ ) {
+ yield binding.durableObjectNamespace.serviceName;
+ }
+ if ("wrapped" in binding && binding.wrapped?.innerBindings !== undefined) {
+ for (const inner of binding.wrapped.innerBindings) {
+ yield* bindingServiceRefs(inner);
+ }
+ }
+}
+
+function* workerServiceRefs(worker: Worker): Iterable {
+ for (const binding of worker.bindings ?? []) {
+ yield* bindingServiceRefs(binding);
+ }
+ for (const tail of worker.tails ?? []) {
+ if (tail.name !== undefined) {
+ yield tail.name;
+ }
+ }
+ for (const tail of worker.streamingTails ?? []) {
+ if (tail.name !== undefined) {
+ yield tail.name;
+ }
+ }
+}
+
+function resolveEntryService(
+ services: Service[],
+ override: string | undefined
+): string {
+ if (override !== undefined) {
+ return override;
+ }
+ const router = services.find((service) =>
+ (service.name ?? "").startsWith(ASSETS_ROUTER_PREFIX)
+ );
+ if (router?.name !== undefined) {
+ return router.name;
+ }
+ const user = services.find((service) =>
+ (service.name ?? "").startsWith(USER_SERVICE_PREFIX)
+ );
+ if (user?.name !== undefined) {
+ return user.name;
+ }
+ throw new Error(
+ "Could not determine an entry service for the standalone bundle. " +
+ "Pass `entryServiceName` explicitly."
+ );
+}
+
+/** Collects every module source string from the workers in `services`. */
+function collectWorkerSources(services: Service[]): string[] {
+ const sources: string[] = [];
+ for (const service of services) {
+ if (!("worker" in service) || service.worker === undefined) {
+ continue;
+ }
+ const worker = service.worker;
+ if ("modules" in worker && worker.modules !== undefined) {
+ for (const module of worker.modules) {
+ for (const key of [
+ "esModule",
+ "commonJsModule",
+ "text",
+ "json",
+ ] as const) {
+ const value = (module as Record)[key];
+ if (typeof value === "string") {
+ sources.push(value);
+ }
+ }
+ }
+ }
+ if (
+ "serviceWorkerScript" in worker &&
+ typeof worker.serviceWorkerScript === "string"
+ ) {
+ sources.push(worker.serviceWorkerScript);
+ }
+ }
+ return sources;
+}
+
+/** Collects every `wrapped` binding module name referenced by `services`. */
+function collectWrappedModuleNames(services: Service[]): Set {
+ const names = new Set();
+ const visitBinding = (binding: Worker_Binding): void => {
+ if ("wrapped" in binding && binding.wrapped !== undefined) {
+ if (binding.wrapped.moduleName !== undefined) {
+ names.add(binding.wrapped.moduleName);
+ }
+ for (const inner of binding.wrapped.innerBindings ?? []) {
+ visitBinding(inner);
+ }
+ }
+ };
+ for (const service of services) {
+ if ("worker" in service && service.worker?.bindings !== undefined) {
+ for (const binding of service.worker.bindings) {
+ visitBinding(binding);
+ }
+ }
+ }
+ return names;
+}
+
+/**
+ * Drops extension modules that nothing references. Miniflare registers extension
+ * modules (rate-limit, workflows, email, analytics-engine, dispatch, …) for every
+ * enabled plugin, but a stateless+assets bundle only imports a few. An extension
+ * module is kept if a kept worker imports it (its name appears in a worker source
+ * or `wrapped` binding) or if a kept extension module imports it (transitive
+ * closure). The running `workerd serve` e2e guards against over-pruning.
+ */
+function pruneUnusedExtensions(
+ extensions: Extension[],
+ keptServices: Service[]
+): { extensions: Extension[]; droppedExtensionModules: string[] } {
+ const moduleByName = new Map();
+ for (const extension of extensions) {
+ for (const module of extension.modules ?? []) {
+ if (module.name !== undefined) {
+ moduleByName.set(module.name, module);
+ }
+ }
+ }
+
+ const live = collectWrappedModuleNames(keptServices);
+ const workerSources = collectWorkerSources(keptServices);
+ for (const name of moduleByName.keys()) {
+ if (workerSources.some((source) => source.includes(name))) {
+ live.add(name);
+ }
+ }
+
+ // Transitively keep extension modules imported by already-live ones.
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const name of [...live]) {
+ const source = moduleByName.get(name)?.esModule;
+ if (source === undefined) {
+ continue;
+ }
+ for (const candidate of moduleByName.keys()) {
+ if (!live.has(candidate) && source.includes(candidate)) {
+ live.add(candidate);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ const droppedExtensionModules: string[] = [];
+ const prunedExtensions: Extension[] = [];
+ for (const extension of extensions) {
+ const keptModules = (extension.modules ?? []).filter(
+ (module) => module.name !== undefined && live.has(module.name)
+ );
+ for (const module of extension.modules ?? []) {
+ if (module.name !== undefined && !live.has(module.name)) {
+ droppedExtensionModules.push(module.name);
+ }
+ }
+ if (keptModules.length > 0) {
+ prunedExtensions.push({ ...extension, modules: keptModules });
+ }
+ }
+ return { extensions: prunedExtensions, droppedExtensionModules };
+}
+
+/**
+ * Transforms a Miniflare-assembled {@link Config} into a self-contained,
+ * loopback-free configuration suitable for `workerd serve` outside of Miniflare:
+ *
+ * - Drops development-only services (the Node loopback, dev entry/router
+ * scaffolding, inspector explorer, cache/email simulators) by keeping only the
+ * services reachable from the entry worker.
+ * - Repoints `globalOutbound` away from the dev `strip-cf-connecting-ip` service
+ * to the `internet` network service.
+ * - Drops `cacheApiOutbound`/`moduleFallback` that reference dev-only services.
+ * - Replaces all sockets with a single HTTP socket pointing at the entry service.
+ * - Relativizes `disk` service paths so their contents can be copied into the
+ * bundle (reported via {@link StandaloneTransformResult.diskCopies}).
+ *
+ * The input config is not mutated.
+ */
+export function toStandaloneConfig(
+ config: Config,
+ options: StandaloneTransformOptions = {}
+): StandaloneTransformResult {
+ const services = config.services ?? [];
+ const byName = new Map();
+ for (const service of services) {
+ if (service.name !== undefined) {
+ byName.set(service.name, service);
+ }
+ }
+
+ const entryService = resolveEntryService(services, options.entryServiceName);
+ const warnings: string[] = [];
+ const keep = new Set();
+ const repointGlobalOutbound = new Set();
+ const dropCacheOutbound = new Set();
+
+ const visit = (name: string): void => {
+ if (keep.has(name)) {
+ return;
+ }
+ const service = byName.get(name);
+ if (service === undefined) {
+ warnings.push(`Service "${name}" is referenced but was not found.`);
+ return;
+ }
+ keep.add(name);
+ if (!("worker" in service) || service.worker === undefined) {
+ return;
+ }
+ const worker = service.worker;
+ for (const ref of workerServiceRefs(worker)) {
+ if (isDevOnlyService(ref)) {
+ warnings.push(
+ `Service "${name}" references development-only service "${ref}", which is not included in the standalone bundle.`
+ );
+ } else {
+ visit(ref);
+ }
+ }
+ if (worker.globalOutbound?.name !== undefined) {
+ if (isDevOnlyService(worker.globalOutbound.name)) {
+ repointGlobalOutbound.add(name);
+ } else {
+ visit(worker.globalOutbound.name);
+ }
+ }
+ if (worker.cacheApiOutbound?.name !== undefined) {
+ if (isDevOnlyService(worker.cacheApiOutbound.name)) {
+ dropCacheOutbound.add(name);
+ } else {
+ visit(worker.cacheApiOutbound.name);
+ }
+ }
+ };
+
+ visit(entryService);
+
+ // Keep the `internet` network service if anything needs to be repointed to it.
+ if (repointGlobalOutbound.size > 0 && byName.has("internet")) {
+ keep.add("internet");
+ }
+
+ const diskCopies: StandaloneDiskCopy[] = [];
+ const diskDir = options.diskDir ?? "disk";
+ const newServices: Service[] = [];
+ for (const service of services) {
+ const name = service.name ?? "";
+ if (!keep.has(name)) {
+ continue;
+ }
+ if ("worker" in service && service.worker !== undefined) {
+ const worker = { ...service.worker } as Worker & Record;
+ // `moduleFallback` points at the dev-only Node loopback server.
+ delete worker.moduleFallback;
+ if (dropCacheOutbound.has(name)) {
+ delete worker.cacheApiOutbound;
+ }
+ if (repointGlobalOutbound.has(name)) {
+ worker.globalOutbound = { name: "internet" };
+ }
+ newServices.push({ ...service, worker: worker as Worker });
+ continue;
+ }
+ if ("disk" in service && service.disk !== undefined) {
+ const from = service.disk.path;
+ if (from === undefined) {
+ warnings.push(`Disk service "${name}" has no path; skipping copy.`);
+ newServices.push(service);
+ continue;
+ }
+ const to = `${diskDir}/${sanitize(name)}`;
+ diskCopies.push({ serviceName: name, from, to });
+ // Static assets are served read-only in a standalone bundle; only
+ // Miniflare's dev simulator needs write access to the assets dir.
+ const disk = { ...service.disk, path: to };
+ if (name.startsWith(ASSETS_SERVICE_PREFIX)) {
+ disk.writable = false;
+ }
+ newServices.push({ ...service, disk });
+ continue;
+ }
+ newServices.push(service);
+ }
+
+ const socket: Socket = {
+ name: options.socketName ?? "http",
+ address: options.address ?? "*:8080",
+ service: { name: entryService },
+ http: {},
+ };
+
+ const droppedServices = services
+ .map((service) => service.name ?? "")
+ .filter((name) => !keep.has(name));
+
+ let droppedExtensionModules: string[] = [];
+ let extensions = config.extensions;
+ if (
+ (options.pruneExtensions ?? true) &&
+ extensions !== undefined &&
+ extensions.length > 0
+ ) {
+ const pruned = pruneUnusedExtensions(extensions, newServices);
+ extensions = pruned.extensions.length > 0 ? pruned.extensions : undefined;
+ droppedExtensionModules = pruned.droppedExtensionModules;
+ }
+
+ const standaloneConfig: Config = {
+ ...config,
+ services: newServices,
+ sockets: [socket],
+ ...(extensions !== undefined ? { extensions } : {}),
+ };
+ if (extensions === undefined) {
+ delete standaloneConfig.extensions;
+ }
+
+ return {
+ config: standaloneConfig,
+ diskCopies,
+ keptServices: [...keep],
+ droppedServices,
+ droppedExtensionModules,
+ entryService,
+ warnings,
+ };
+}
diff --git a/packages/miniflare/test/standalone.spec.ts b/packages/miniflare/test/standalone.spec.ts
new file mode 100644
index 0000000000..6cacc7ca9b
--- /dev/null
+++ b/packages/miniflare/test/standalone.spec.ts
@@ -0,0 +1,340 @@
+import { spawn } from "node:child_process";
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { removeDirSync } from "@cloudflare/workers-utils";
+import {
+ emitStandaloneBundle,
+ STANDALONE_CONFIG_FILENAME,
+ toStandaloneConfig,
+} from "miniflare";
+import { afterEach, test } from "vitest";
+import workerdPath from "workerd";
+import type { Config, StandaloneConfigFormat } from "miniflare";
+import type { ExpectStatic } from "vitest";
+
+const USER_SERVICE = "core:user:my-app";
+
+const tempDirs: string[] = [];
+function makeTempDir(): string {
+ const dir = mkdtempSync(path.join(tmpdir(), "mf-standalone-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(() => {
+ while (tempDirs.length > 0) {
+ const dir = tempDirs.pop();
+ if (dir !== undefined) {
+ removeDirSync(dir);
+ }
+ }
+});
+
+const WORKER_SOURCE = `export default {
+ async fetch(request, env) {
+ return Response.json({
+ ok: true,
+ greeting: env.GREETING,
+ secret: env.SECRET ?? null,
+ });
+ },
+};
+`;
+
+/**
+ * Builds a fixture resembling a Miniflare-assembled config for a stateless worker:
+ * the user worker plus the development-only scaffolding (loopback, dev entry,
+ * `strip-cf-connecting-ip`, cache) that the transform must strip.
+ */
+function makeFixtureConfig(assetsDir: string): Config {
+ return {
+ services: [
+ { name: "loopback", external: { address: "127.0.0.1:1234", http: {} } },
+ {
+ name: "core:entry",
+ worker: {
+ modules: [{ name: "entry.js", esModule: "export default {}" }],
+ compatibilityDate: "2024-11-01",
+ },
+ },
+ {
+ name: "strip-cf-connecting-ip:0",
+ worker: {
+ modules: [{ name: "strip.js", esModule: "export default {}" }],
+ compatibilityDate: "2024-11-01",
+ },
+ },
+ {
+ name: "cache:0",
+ worker: {
+ modules: [{ name: "cache.js", esModule: "export default {}" }],
+ compatibilityDate: "2024-11-01",
+ },
+ },
+ {
+ name: USER_SERVICE,
+ worker: {
+ modules: [{ name: "index.js", esModule: WORKER_SOURCE }],
+ compatibilityDate: "2024-11-01",
+ compatibilityFlags: ["nodejs_compat"],
+ bindings: [
+ { name: "GREETING", text: "hello from standalone" },
+ { name: "SECRET", fromEnvironment: "SECRET" },
+ { name: "ASSETS", service: { name: "assets:storage" } },
+ {
+ name: "RATE_LIMITER",
+ wrapped: { moduleName: "cloudflare-internal:ratelimit" },
+ },
+ ],
+ globalOutbound: { name: "strip-cf-connecting-ip:0" },
+ cacheApiOutbound: { name: "cache:0" },
+ },
+ },
+ {
+ // Miniflare assembles the assets disk as writable; the transform must
+ // flip it read-only for the production bundle.
+ name: "assets:storage",
+ disk: { path: assetsDir, writable: true, allowDotfiles: true },
+ },
+ { name: "internet", network: { allow: ["public", "private"], deny: [] } },
+ ],
+ extensions: [
+ {
+ modules: [
+ // Imported transitively by the kept rate-limit module below.
+ { name: "miniflare:shared", internal: true, esModule: "export {}" },
+ // Referenced by the user worker's `wrapped` binding (kept).
+ {
+ name: "cloudflare-internal:ratelimit",
+ internal: true,
+ esModule: `import "miniflare:shared";\nexport default function () {\n\treturn { limit() {} };\n};`,
+ },
+ // Nothing references these — they must be pruned.
+ {
+ name: "cloudflare-internal:workflows",
+ internal: true,
+ esModule: "export default {};",
+ },
+ {
+ name: "cloudflare-internal:email",
+ internal: true,
+ esModule: "export default {};",
+ },
+ ],
+ },
+ ],
+ sockets: [
+ {
+ name: "entry",
+ service: { name: "core:entry" },
+ http: {},
+ address: "127.0.0.1:0",
+ },
+ ],
+ };
+}
+
+test("toStandaloneConfig keeps only reachable, non-dev services", ({
+ expect,
+}) => {
+ const assetsDir = makeTempDir();
+ const result = toStandaloneConfig(makeFixtureConfig(assetsDir));
+
+ expect(result.entryService).toBe(USER_SERVICE);
+ expect(new Set(result.keptServices)).toEqual(
+ new Set([USER_SERVICE, "assets:storage", "internet"])
+ );
+ expect(result.droppedServices).toContain("loopback");
+ expect(result.droppedServices).toContain("core:entry");
+ expect(result.droppedServices).toContain("strip-cf-connecting-ip:0");
+ expect(result.droppedServices).toContain("cache:0");
+
+ // Single HTTP socket pointing at the entry worker.
+ expect(result.config.sockets).toHaveLength(1);
+ expect(result.config.sockets?.[0].service).toEqual({ name: USER_SERVICE });
+
+ const user = result.config.services?.find((s) => s.name === USER_SERVICE);
+ expect(user).toBeDefined();
+ if (user === undefined || !("worker" in user) || user.worker === undefined) {
+ throw new Error("expected user worker service");
+ }
+ // `globalOutbound` repointed away from the dev strip service.
+ expect(user.worker.globalOutbound).toEqual({ name: "internet" });
+ // `cacheApiOutbound` referencing the dev cache service is dropped.
+ expect(user.worker.cacheApiOutbound).toBeUndefined();
+
+ // Disk path relativized + copy recorded.
+ expect(result.diskCopies).toEqual([
+ {
+ serviceName: "assets:storage",
+ from: assetsDir,
+ to: "disk/assets_storage",
+ },
+ ]);
+ const disk = result.config.services?.find((s) => s.name === "assets:storage");
+ if (disk === undefined || !("disk" in disk) || disk.disk === undefined) {
+ throw new Error("expected disk service");
+ }
+ expect(disk.disk.path).toBe("disk/assets_storage");
+ // Static assets are read-only in the production bundle.
+ expect(disk.disk.writable).toBe(false);
+});
+
+test("toStandaloneConfig prunes extension modules nothing references", ({
+ expect,
+}) => {
+ const assetsDir = makeTempDir();
+ const result = toStandaloneConfig(makeFixtureConfig(assetsDir));
+
+ const keptModules = (result.config.extensions ?? [])
+ .flatMap((extension) => extension.modules ?? [])
+ .map((module) => module.name);
+
+ // Referenced by the user worker's `wrapped` binding...
+ expect(keptModules).toContain("cloudflare-internal:ratelimit");
+ // ...and its transitive import is kept too.
+ expect(keptModules).toContain("miniflare:shared");
+ // Unreferenced simulator extensions are pruned.
+ expect(keptModules).not.toContain("cloudflare-internal:workflows");
+ expect(keptModules).not.toContain("cloudflare-internal:email");
+ expect(result.droppedExtensionModules).toEqual(
+ expect.arrayContaining([
+ "cloudflare-internal:workflows",
+ "cloudflare-internal:email",
+ ])
+ );
+});
+
+test("pruneExtensions can be disabled", ({ expect }) => {
+ const assetsDir = makeTempDir();
+ const result = toStandaloneConfig(makeFixtureConfig(assetsDir), {
+ pruneExtensions: false,
+ });
+ const keptModules = (result.config.extensions ?? [])
+ .flatMap((extension) => extension.modules ?? [])
+ .map((module) => module.name);
+ expect(keptModules).toContain("cloudflare-internal:workflows");
+ expect(result.droppedExtensionModules).toEqual([]);
+});
+
+test("emitStandaloneBundle writes config, modules, and disk contents", ({
+ expect,
+}) => {
+ const assetsDir = makeTempDir();
+ writeFileSync(path.join(assetsDir, "hello.txt"), "hi");
+ const outDir = makeTempDir();
+
+ const result = emitStandaloneBundle(makeFixtureConfig(assetsDir), outDir);
+
+ const config = readFileSync(result.configPath, "utf8");
+ expect(config).toContain('using Workerd = import "/workerd/workerd.capnp"');
+ expect(config).toContain('name = "core:user:my-app"');
+ expect(config).toContain("esModule = embed");
+ expect(config).toContain('fromEnvironment = "SECRET"');
+ // Dropped services must not appear.
+ expect(config).not.toContain('name = "loopback"');
+ expect(config).not.toContain("strip-cf-connecting-ip");
+
+ // Module source embedded as a file and disk contents copied in.
+ expect(result.files).toContain("src/index.js");
+ expect(
+ readFileSync(path.join(outDir, "disk/assets_storage/hello.txt"), "utf8")
+ ).toBe("hi");
+});
+
+function getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.on("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (address === null || typeof address === "string") {
+ reject(new Error("could not determine port"));
+ return;
+ }
+ const { port } = address;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+async function waitForResponse(
+ url: string,
+ signal: AbortSignal
+): Promise {
+ while (!signal.aborted) {
+ try {
+ return await fetch(url);
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+ throw new Error(`timed out waiting for ${url}`);
+}
+
+async function assertBundleServes(
+ expect: ExpectStatic,
+ format: StandaloneConfigFormat
+): Promise {
+ const assetsDir = makeTempDir();
+ const outDir = makeTempDir();
+ const result = emitStandaloneBundle(makeFixtureConfig(assetsDir), outDir, {
+ format,
+ });
+ expect(result.format).toBe(format);
+ expect(path.basename(result.configPath)).toBe(
+ STANDALONE_CONFIG_FILENAME[format]
+ );
+ // Binary inlines modules, so there must be no embedded `src/` files.
+ expect(result.files.some((file) => file.startsWith("src/"))).toBe(
+ format === "text"
+ );
+
+ const port = await getFreePort();
+ const child = spawn(
+ workerdPath,
+ [
+ "serve",
+ ...(format === "binary" ? ["--binary"] : []),
+ STANDALONE_CONFIG_FILENAME[format],
+ `--socket-addr=http=127.0.0.1:${port}`,
+ ],
+ { cwd: outDir, env: { ...process.env, SECRET: "shhh" }, stdio: "pipe" }
+ );
+ let stderr = "";
+ child.stderr.on("data", (chunk) => (stderr += String(chunk)));
+
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+ const response = await waitForResponse(
+ `http://127.0.0.1:${port}/`,
+ controller.signal
+ );
+ clearTimeout(timeout);
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ ok: true,
+ greeting: "hello from standalone",
+ secret: "shhh",
+ });
+ } catch (error) {
+ throw new Error(`workerd failed: ${String(error)}\nstderr:\n${stderr}`);
+ } finally {
+ child.kill("SIGKILL");
+ }
+}
+
+test("emitted text bundle runs under bare workerd serve", async ({
+ expect,
+}) => {
+ await assertBundleServes(expect, "text");
+});
+
+test("emitted binary bundle runs under workerd serve --binary", async ({
+ expect,
+}) => {
+ await assertBundleServes(expect, "binary");
+});
diff --git a/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/index.ts b/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/index.ts
new file mode 100644
index 0000000000..41543fcfad
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/index.ts
@@ -0,0 +1,9 @@
+interface Env {
+ GREETING: string;
+}
+
+export default {
+ fetch(request, env) {
+ return new Response(env.GREETING);
+ },
+} satisfies ExportedHandler;
diff --git a/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/wrangler.jsonc b/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/wrangler.jsonc
new file mode 100644
index 0000000000..c503b5d35b
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/src/__tests__/fixtures-standalone/wrangler.jsonc
@@ -0,0 +1,8 @@
+{
+ "name": "standalone-worker",
+ "main": "./index.ts",
+ "compatibility_date": "2024-12-30",
+ "vars": {
+ "GREETING": "hello from standalone vite",
+ },
+}
diff --git a/packages/vite-plugin-cloudflare/src/__tests__/resolve-plugin-config.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/resolve-plugin-config.spec.ts
index 4d9ba69b1e..f69972c0db 100644
--- a/packages/vite-plugin-cloudflare/src/__tests__/resolve-plugin-config.spec.ts
+++ b/packages/vite-plugin-cloudflare/src/__tests__/resolve-plugin-config.spec.ts
@@ -977,6 +977,61 @@ describe("resolvePluginConfig - defaults fill in missing fields", () => {
});
});
+describe("resolvePluginConfig - standalone option", () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vite-plugin-test-"));
+ fs.mkdirSync(path.join(tempDir, "src"), { recursive: true });
+ fs.writeFileSync(
+ path.join(tempDir, "wrangler.jsonc"),
+ JSON.stringify({
+ name: "worker",
+ main: "./src/index.ts",
+ compatibility_date: "2024-01-01",
+ })
+ );
+ fs.writeFileSync(path.join(tempDir, "src/index.ts"), "export default {}");
+ });
+
+ afterEach(() => {
+ removeDirSync(tempDir);
+ });
+
+ const viteEnv = { mode: "production", command: "build" as const };
+
+ test("defaults to disabled", async ({ expect }) => {
+ const result = await resolvePluginConfig({}, { root: tempDir }, viteEnv);
+ expect(result.standalone).toBe(false);
+ });
+
+ test("standalone: true resolves to default output directory", async ({
+ expect,
+ }) => {
+ const result = await resolvePluginConfig(
+ { standalone: true },
+ { root: tempDir },
+ viteEnv
+ );
+ expect(result.standalone).toEqual({
+ outDir: "dist-standalone",
+ force: false,
+ });
+ });
+
+ test("standalone object overrides outDir and force", async ({ expect }) => {
+ const result = await resolvePluginConfig(
+ { standalone: { outDir: "out/standalone", force: true } },
+ { root: tempDir },
+ viteEnv
+ );
+ expect(result.standalone).toEqual({
+ outDir: "out/standalone",
+ force: true,
+ });
+ });
+});
+
describe("resolvePluginConfig - environment name validation", () => {
let tempDir: string;
diff --git a/packages/vite-plugin-cloudflare/src/__tests__/standalone-build.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/standalone-build.spec.ts
new file mode 100644
index 0000000000..3da24c4930
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/src/__tests__/standalone-build.spec.ts
@@ -0,0 +1,73 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import { removeDirSync } from "@cloudflare/workers-utils";
+import { createBuilder } from "vite";
+import { afterEach, describe, test } from "vitest";
+import { cloudflare } from "../index";
+
+const fixturesPath = fileURLToPath(
+ new URL("./fixtures-standalone", import.meta.url)
+);
+
+function read(relative: string): string {
+ return fs.readFileSync(path.join(fixturesPath, relative), "utf-8");
+}
+
+describe("standalone build", () => {
+ afterEach(() => {
+ for (const dir of ["dist", "dist-standalone", ".wrangler"]) {
+ removeDirSync(path.join(fixturesPath, dir));
+ }
+ });
+
+ test("emits a standalone workerd bundle when `standalone` is enabled", async ({
+ expect,
+ }) => {
+ const builder = await createBuilder({
+ root: fixturesPath,
+ logLevel: "silent",
+ plugins: [
+ cloudflare({
+ inspectorPort: false,
+ persistState: false,
+ standalone: true,
+ }),
+ ],
+ });
+
+ await builder.buildApp();
+
+ // The standalone plugin should have produced a self-contained workerd
+ // bundle alongside the normal Vite output.
+ const capnp = read("dist-standalone/config.capnp");
+ expect(capnp).toContain("Workerd.Config");
+ // The plain-text var is baked into the generated config.
+ expect(capnp).toContain("GREETING");
+ expect(capnp).toContain("hello from standalone vite");
+
+ expect(
+ fs.existsSync(path.join(fixturesPath, "dist-standalone/Dockerfile"))
+ ).toBe(true);
+ expect(read("dist-standalone/entrypoint.sh")).toContain(
+ "workerd serve config.capnp"
+ );
+ expect(read("dist-standalone/COMPILE_REPORT.md")).toContain(
+ "standalone-worker"
+ );
+ });
+
+ test("does not emit a standalone bundle by default", async ({ expect }) => {
+ const builder = await createBuilder({
+ root: fixturesPath,
+ logLevel: "silent",
+ plugins: [cloudflare({ inspectorPort: false, persistState: false })],
+ });
+
+ await builder.buildApp();
+
+ expect(fs.existsSync(path.join(fixturesPath, "dist-standalone"))).toBe(
+ false
+ );
+ });
+});
diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts
index 4ec597814e..1c4cfa121a 100644
--- a/packages/vite-plugin-cloudflare/src/index.ts
+++ b/packages/vite-plugin-cloudflare/src/index.ts
@@ -15,6 +15,7 @@ import { outputConfigPlugin } from "./plugins/output-config";
import { previewPlugin } from "./plugins/preview";
import { rscPlugin } from "./plugins/rsc";
import { shortcutsPlugin } from "./plugins/shortcuts";
+import { standalonePlugin } from "./plugins/standalone";
import { triggerHandlersPlugin } from "./plugins/trigger-handlers";
import { tunnelPlugin } from "./plugins/tunnel";
import {
@@ -111,5 +112,6 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
nodeJsAlsPlugin(ctx),
nodeJsCompatPlugin(ctx),
nodeJsCompatWarningsPlugin(ctx),
+ standalonePlugin(ctx),
];
}
diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts
index a4ce674f9a..0a8771bbcd 100644
--- a/packages/vite-plugin-cloudflare/src/plugin-config.ts
+++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts
@@ -144,6 +144,22 @@ type WorkerConfigCustomizer =
]
) => Partial | void);
+/**
+ * Configuration for compiling the built app into a standalone, self-hosted
+ * `workerd` bundle (the Vite equivalent of `wrangler compile`).
+ */
+export interface StandaloneConfig {
+ /** Output directory for the standalone bundle. Defaults to `dist-standalone`. */
+ outDir?: string;
+ /** Compile even when the Worker uses bindings unsupported by standalone workerd. */
+ force?: boolean;
+}
+
+export interface ResolvedStandaloneConfig {
+ outDir: string;
+ force: boolean;
+}
+
export interface PluginConfig extends EntryWorkerConfig {
auxiliaryWorkers?: AuxiliaryWorkerConfig[];
persistState?: PersistState;
@@ -151,6 +167,13 @@ export interface PluginConfig extends EntryWorkerConfig {
remoteBindings?: boolean;
tunnel?: boolean | TunnelConfig;
experimental?: Experimental;
+ /**
+ * When set, `vite build` also emits a standalone `workerd` bundle (config +
+ * assets + Dockerfile) for self-hosting outside Cloudflare. Experimental.
+ *
+ * @experimental
+ */
+ standalone?: boolean | StandaloneConfig;
}
export interface ResolvedAssetsOnlyConfig extends WorkerConfig {
@@ -177,6 +200,7 @@ interface BaseResolvedConfig {
};
remoteBindings: boolean;
tunnel: TunnelConfig;
+ standalone: ResolvedStandaloneConfig | false;
}
interface NonPreviewResolvedConfig extends BaseResolvedConfig {
@@ -354,6 +378,19 @@ function resolveWorkerConfig(
});
}
+function resolveStandaloneConfig(
+ standalone: boolean | StandaloneConfig | undefined
+): ResolvedStandaloneConfig | false {
+ if (!standalone) {
+ return false;
+ }
+ const config = standalone === true ? {} : standalone;
+ return {
+ outDir: config.outDir ?? "dist-standalone",
+ force: config.force ?? false,
+ };
+}
+
export async function resolvePluginConfig(
pluginConfig: PluginConfig,
userConfig: vite.UserConfig,
@@ -377,6 +414,7 @@ export async function resolvePluginConfig(
pluginConfig.experimental?.headersAndRedirectsDevModeSupport,
newConfig: resolvedNewConfig,
},
+ standalone: resolveStandaloneConfig(pluginConfig.standalone),
};
const root = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
const prefixedEnv = vite.loadEnv(viteEnv.mode, root, [
diff --git a/packages/vite-plugin-cloudflare/src/plugins/config.ts b/packages/vite-plugin-cloudflare/src/plugins/config.ts
index 104fd745ab..443ae12a43 100644
--- a/packages/vite-plugin-cloudflare/src/plugins/config.ts
+++ b/packages/vite-plugin-cloudflare/src/plugins/config.ts
@@ -12,13 +12,37 @@ import { assertIsNotPreview } from "../context";
import { writeDeployConfig } from "../deploy-config";
import { hasLocalDevVarsFileChanged } from "../dev-vars";
import { resolveDevOnly } from "../plugin-config";
-import { createPlugin, debuglog, getOutputDirectory } from "../utils";
+import {
+ createPlugin,
+ debuglog,
+ getOutputDirectory,
+ satisfiesMinimumViteVersion,
+} from "../utils";
import { validateWorkerEnvironmentOptions } from "../vite-config";
import { getWarningForWorkersConfigs } from "../workers-configs";
+import { emitStandaloneBuild } from "./standalone";
import type { PluginContext } from "../context";
import type { EnvironmentOptions, UserConfig } from "vite";
import type { Unstable_RawConfig } from "wrangler";
+/**
+ * Wraps the app builder so that, on Vite 6 (where the `buildApp` plugin hook is
+ * unavailable), the standalone bundle is emitted once the build completes. On
+ * Vite 7+ this is a no-op here and `standalonePlugin`'s `buildApp` post hook
+ * handles it instead.
+ */
+function wrapBuildAppWithStandalone(
+ buildApp: ReturnType,
+ ctx: PluginContext
+): ReturnType {
+ return async (builder) => {
+ await buildApp(builder);
+ if (!satisfiesMinimumViteVersion("7.0.0")) {
+ await emitStandaloneBuild(ctx);
+ }
+ };
+}
+
/**
* Plugin to handle configuration and config file watching
*/
@@ -75,7 +99,10 @@ export const configPlugin = createPlugin("config", (ctx) => {
builder: {
buildApp:
userConfig.builder?.buildApp ??
- createBuildApp(ctx.resolvedPluginConfig),
+ wrapBuildAppWithStandalone(
+ createBuildApp(ctx.resolvedPluginConfig),
+ ctx
+ ),
},
};
},
diff --git a/packages/vite-plugin-cloudflare/src/plugins/standalone.ts b/packages/vite-plugin-cloudflare/src/plugins/standalone.ts
new file mode 100644
index 0000000000..05fb7336e9
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/src/plugins/standalone.ts
@@ -0,0 +1,106 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import colors from "picocolors";
+import * as wrangler from "wrangler";
+import { createPlugin, satisfiesMinimumViteVersion } from "../utils";
+import type { PluginContext } from "../context";
+
+interface DeployConfig {
+ configPath: string;
+ auxiliaryWorkers?: Array<{ configPath: string }>;
+}
+
+/**
+ * Compiles the just-built application into a standalone, self-hosted `workerd`
+ * bundle (the Vite equivalent of `wrangler compile`). Reads the entry Worker's
+ * generated `wrangler.json` from the build output and delegates to Wrangler's
+ * shared compile implementation, so the two paths stay in sync.
+ *
+ * Must run after the build is fully finalized (deploy config + per-Worker
+ * `wrangler.json` written), so it is invoked from the `buildApp` post hook
+ * (Vite 7+) and from the end of `createBuildApp` (Vite 6) — guarded so it only
+ * runs once per build.
+ */
+export async function emitStandaloneBuild(ctx: PluginContext): Promise {
+ const resolvedPluginConfig = ctx.resolvedPluginConfig;
+
+ if (
+ resolvedPluginConfig.type === "preview" ||
+ resolvedPluginConfig.standalone === false
+ ) {
+ return;
+ }
+
+ const root = ctx.resolvedViteConfig.root;
+ const logger = ctx.resolvedViteConfig.logger;
+ const deployConfigPath = path.resolve(
+ root,
+ ".wrangler",
+ "deploy",
+ "config.json"
+ );
+
+ if (!fs.existsSync(deployConfigPath)) {
+ logger.warn(
+ colors.yellow(
+ "[cloudflare] `standalone` build skipped: no build output was found."
+ )
+ );
+ return;
+ }
+
+ const deployConfig = JSON.parse(
+ fs.readFileSync(deployConfigPath, "utf-8")
+ ) as DeployConfig;
+ const entryConfigPath = path.resolve(
+ path.dirname(deployConfigPath),
+ deployConfig.configPath
+ );
+
+ if (deployConfig.auxiliaryWorkers?.length) {
+ logger.warn(
+ colors.yellow(
+ "[cloudflare] `standalone` currently compiles only the entry Worker; auxiliary Workers are not yet included in the bundle."
+ )
+ );
+ }
+
+ const outDir = path.resolve(root, resolvedPluginConfig.standalone.outDir);
+
+ const result = await wrangler.unstable_compileStandalone({
+ configPath: entryConfigPath,
+ outDir,
+ force: resolvedPluginConfig.standalone.force,
+ log: false,
+ });
+
+ const relativeOutDir = path.relative(root, outDir) || ".";
+ logger.info(
+ `\n${colors.green("✦")} Standalone ${colors.bold("workerd")} bundle written to ${colors.dim(
+ relativeOutDir
+ )} (entry: ${result.entryService}).\n Run it with ${colors.dim(
+ `workerd serve config.capnp`
+ )} from that directory, or build the generated Dockerfile.`
+ );
+}
+
+/**
+ * Plugin that emits the standalone bundle via the `buildApp` post hook
+ * (Vite 7+). The Vite 6 path is handled by wrapping `builder.buildApp` in the
+ * config plugin.
+ */
+export const standalonePlugin = createPlugin("standalone", (ctx) => {
+ return {
+ buildApp: {
+ order: "post",
+ async handler() {
+ // In Vite 6 this hook does not run; `createBuildApp` invokes
+ // `emitStandaloneBuild` instead.
+ if (!satisfiesMinimumViteVersion("7.0.0")) {
+ return;
+ }
+ await emitStandaloneBuild(ctx);
+ },
+ },
+ };
+});
diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts
index b7f36973ff..42677230a4 100644
--- a/packages/workers-utils/src/config/config.ts
+++ b/packages/workers-utils/src/config/config.ts
@@ -181,6 +181,20 @@ export interface ConfigFields {
* @nonInheritable
*/
keep_vars?: boolean;
+
+ /**
+ * Declares that this Worker targets a self-hosted, standalone `workerd`
+ * runtime (e.g. via `wrangler compile`) rather than the Cloudflare platform.
+ *
+ * When set, `wrangler dev` validates the configuration against the features
+ * supported by standalone `workerd` (surfacing unsupported bindings as
+ * warnings), and `wrangler deploy` refuses to run (use `wrangler compile`).
+ *
+ * @experimental This field is experimental and its shape may change.
+ * @default false
+ * @nonInheritable
+ */
+ standalone?: boolean;
}
// Pages-specific configuration fields
@@ -364,6 +378,7 @@ export const defaultWranglerConfig: Config = {
text_blobs: undefined,
data_blobs: undefined,
keep_vars: undefined,
+ standalone: undefined,
alias: undefined,
/** INHERITABLE ENVIRONMENT FIELDS **/
diff --git a/packages/workers-utils/src/config/standalone-support.ts b/packages/workers-utils/src/config/standalone-support.ts
new file mode 100644
index 0000000000..5b4c5ddebf
--- /dev/null
+++ b/packages/workers-utils/src/config/standalone-support.ts
@@ -0,0 +1,84 @@
+import type { Binding } from "../types";
+
+/**
+ * Whether a binding type is usable by a Worker compiled for a standalone,
+ * self-hosted `workerd` runtime (e.g. via `wrangler compile`).
+ *
+ * The standalone target is currently an **alpha** focused on stateless Workers
+ * plus static assets. Stateful resources (KV, R2, D1, Queues), Durable Objects
+ * (pending clustered `workerd`), and Cloudflare platform services (AI, Browser
+ * Rendering, etc.) do not yet have a production-quality standalone story, so
+ * they are reported as `unsupported`.
+ *
+ * - `supported`: works in a standalone bundle today.
+ * - `unsupported`: no standalone story yet — surfaced as a warning by
+ * `wrangler dev` (the binding still works locally) and as an error by
+ * `wrangler compile`.
+ */
+export type StandaloneSupport = "supported" | "unsupported";
+
+const STANDALONE_SUPPORT: Record<
+ Exclude | "unsafe_hello_world",
+ StandaloneSupport
+> = {
+ // 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",
+ json: "supported",
+ wasm_module: "supported",
+ text_blob: "supported",
+ data_blob: "supported",
+ version_metadata: "supported",
+ inherit: "supported",
+ assets: "supported",
+
+ // Stateful resources — no standalone production story yet.
+ kv_namespace: "unsupported",
+ r2_bucket: "unsupported",
+ d1: "unsupported",
+ queue: "unsupported",
+
+ // Durable Objects need clustered `workerd` (cloudflare/workerd#6780).
+ durable_object_namespace: "unsupported",
+ workflow: "unsupported",
+
+ // Cross-Worker / service-graph bindings — deferred.
+ service: "unsupported",
+ dispatch_namespace: "unsupported",
+ fetcher: "unsupported",
+ worker_loader: "unsupported",
+
+ // Bindings that depend on a Cloudflare backend or platform service.
+ hyperdrive: "unsupported",
+ browser: "unsupported",
+ images: "unsupported",
+ stream: "unsupported",
+ send_email: "unsupported",
+ pipeline: "unsupported",
+ vectorize: "unsupported",
+ analytics_engine: "unsupported",
+ secrets_store_secret: "unsupported",
+ ratelimit: "unsupported",
+ mtls_certificate: "unsupported",
+ logfwdr: "unsupported",
+ unsafe_hello_world: "unsupported",
+ ai: "unsupported",
+ ai_search: "unsupported",
+ ai_search_namespace: "unsupported",
+ media: "unsupported",
+ artifacts: "unsupported",
+ flagship: "unsupported",
+ vpc_service: "unsupported",
+ vpc_network: "unsupported",
+ websearch: "unsupported",
+ agent_memory: "unsupported",
+};
+
+export function getStandaloneSupport(type: Binding["type"]): StandaloneSupport {
+ if (type in STANDALONE_SUPPORT) {
+ return STANDALONE_SUPPORT[type as keyof typeof STANDALONE_SUPPORT];
+ }
+ // Be conservative about binding types we don't explicitly know about.
+ return "unsupported";
+}
diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts
index 83969a0e56..0f97f1dbda 100644
--- a/packages/workers-utils/src/config/validation.ts
+++ b/packages/workers-utils/src/config/validation.ts
@@ -312,6 +312,14 @@ export function normalizeAndValidateConfig(
"boolean"
);
+ validateOptionalProperty(
+ diagnostics,
+ "",
+ "standalone",
+ rawConfig.standalone,
+ "boolean"
+ );
+
validateOptionalProperty(
diagnostics,
"",
@@ -499,6 +507,7 @@ export function normalizeAndValidateConfig(
legacy_env: !useServiceEnvironments,
send_metrics: rawConfig.send_metrics,
keep_vars: rawConfig.keep_vars,
+ standalone: rawConfig.standalone,
...activeEnv,
dev: normalizeAndValidateDev(diagnostics, rawConfig.dev ?? {}, args),
site: normalizeAndValidateSite(
diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts
index 31f21b6bd2..a298781f16 100644
--- a/packages/workers-utils/src/index.ts
+++ b/packages/workers-utils/src/index.ts
@@ -56,6 +56,10 @@ export {
type BindingLocalSupport,
getBindingLocalSupport,
} from "./config/binding-local-support";
+export {
+ type StandaloneSupport,
+ getStandaloneSupport,
+} from "./config/standalone-support";
export { validatePagesConfig } from "./config/validation-pages";
diff --git a/packages/workers-utils/tests/config/standalone-support.test.ts b/packages/workers-utils/tests/config/standalone-support.test.ts
new file mode 100644
index 0000000000..8343529047
--- /dev/null
+++ b/packages/workers-utils/tests/config/standalone-support.test.ts
@@ -0,0 +1,55 @@
+import { describe, it } from "vitest";
+import { getStandaloneSupport } from "../../src/config/standalone-support";
+import type { Binding } from "../../src/types";
+
+describe("getStandaloneSupport", () => {
+ it("reports pure value/config bindings and assets as supported", ({
+ expect,
+ }) => {
+ const supported: Binding["type"][] = [
+ "plain_text",
+ "secret_text",
+ "json",
+ "wasm_module",
+ "text_blob",
+ "data_blob",
+ "version_metadata",
+ "inherit",
+ "assets",
+ ];
+ for (const type of supported) {
+ expect(getStandaloneSupport(type), type).toBe("supported");
+ }
+ });
+
+ it("reports stateful and platform bindings as unsupported", ({ expect }) => {
+ const unsupported: Binding["type"][] = [
+ "kv_namespace",
+ "r2_bucket",
+ "d1",
+ "queue",
+ "durable_object_namespace",
+ "workflow",
+ "service",
+ "dispatch_namespace",
+ "hyperdrive",
+ "browser",
+ "ai",
+ "vectorize",
+ "send_email",
+ ];
+ for (const type of unsupported) {
+ expect(getStandaloneSupport(type), type).toBe("unsupported");
+ }
+ });
+
+ it("defaults to unsupported for unknown binding types", ({ expect }) => {
+ expect(getStandaloneSupport("totally_made_up" as Binding["type"])).toBe(
+ "unsupported"
+ );
+ // `unsafe_*` bindings are not individually enumerated.
+ expect(
+ getStandaloneSupport("unsafe_some_future_thing" as Binding["type"])
+ ).toBe("unsupported");
+ });
+});
diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts
index d28b2e426c..555400e0a0 100644
--- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts
+++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts
@@ -114,6 +114,7 @@ describe("normalizeAndValidateConfig()", () => {
minify: undefined,
first_party_worker: undefined,
keep_vars: undefined,
+ standalone: undefined,
logpush: undefined,
upload_source_maps: undefined,
placement: undefined,
@@ -213,6 +214,33 @@ describe("normalizeAndValidateConfig()", () => {
`);
});
+ it("should accept a boolean `standalone` field", ({ expect }) => {
+ const { config, diagnostics } = normalizeAndValidateConfig(
+ { standalone: true } as unknown as RawConfig,
+ undefined,
+ undefined,
+ { env: undefined }
+ );
+
+ expect(config.standalone).toBe(true);
+ expect(diagnostics.hasErrors()).toBe(false);
+ expect(diagnostics.hasWarnings()).toBe(false);
+ });
+
+ it("should error when `standalone` is not a boolean", ({ expect }) => {
+ const { diagnostics } = normalizeAndValidateConfig(
+ { standalone: "yes" } as unknown as RawConfig,
+ undefined,
+ undefined,
+ { env: undefined }
+ );
+
+ expect(diagnostics.hasErrors()).toBe(true);
+ expect(diagnostics.renderErrors()).toContain(
+ 'Expected "standalone" to be of type boolean but got "yes".'
+ );
+ });
+
it("should warn on and remove unexpected top level fields", ({
expect,
}) => {
diff --git a/packages/wrangler/e2e/compile.test.ts b/packages/wrangler/e2e/compile.test.ts
new file mode 100644
index 0000000000..ff242f6edc
--- /dev/null
+++ b/packages/wrangler/e2e/compile.test.ts
@@ -0,0 +1,277 @@
+import { readFileSync } from "node:fs";
+import { createServer } from "node:net";
+import path from "node:path";
+import { fetch } from "undici";
+import { describe, it } from "vitest";
+import { dedent } from "../src/utils/dedent";
+import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test";
+
+const TIMEOUT = 90_000;
+
+function read(helper: WranglerE2ETestHelper, file: string): string {
+ return readFileSync(path.join(helper.tmpPath, file), "utf8");
+}
+
+function getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.on("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (address === null || typeof address === "string") {
+ reject(new Error("could not determine port"));
+ return;
+ }
+ const { port } = address;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+describe("compile", { timeout: TIMEOUT }, () => {
+ it("compiles a Worker with static assets into a standalone bundle", async ({
+ expect,
+ }) => {
+ const helper = new WranglerE2ETestHelper();
+ await helper.seed({
+ "wrangler.json": JSON.stringify({
+ name: "compile-test",
+ main: "src/index.ts",
+ compatibility_date: "2024-01-01",
+ standalone: true,
+ vars: { GREETING: "hello" },
+ assets: { directory: "./public", binding: "ASSETS" },
+ }),
+ "src/index.ts": dedent`
+ export default {
+ fetch(request, env) {
+ return new Response(env.GREETING);
+ },
+ };
+ `,
+ "public/index.html": "standalone
",
+ "package.json": dedent`
+ {
+ "name": "compile-test",
+ "version": "0.0.0",
+ "private": true
+ }
+ `,
+ });
+
+ const output = await helper.run(`wrangler compile`);
+ expect(output.stdout).toContain("standalone");
+
+ // Core runtime artifacts are emitted to the default output directory.
+ const capnp = read(helper, "dist-standalone/config.capnp");
+ expect(capnp).toContain("Workerd.Config");
+ // The user's plain-text var is baked into the generated config.
+ expect(capnp).toContain("GREETING");
+ expect(capnp).toContain("hello");
+
+ const dockerfile = read(helper, "dist-standalone/Dockerfile");
+ // The runtime is pinned to the exact workerd version we generated against.
+ expect(dockerfile).toMatch(/npm install workerd@\d[\w.-]+ --no-save/);
+ expect(dockerfile).toContain('ENTRYPOINT ["sh", "/app/entrypoint.sh"]');
+
+ const entrypoint = read(helper, "dist-standalone/entrypoint.sh");
+ expect(entrypoint).toContain("workerd serve config.capnp");
+
+ const report = read(helper, "dist-standalone/COMPILE_REPORT.md");
+ expect(report).toContain("wrangler compile` report");
+
+ // The human-facing README has copy-pasteable run instructions and lists
+ // the text-format `src/` embeds.
+ const readme = read(helper, "dist-standalone/README.md");
+ expect(readme).toContain("standalone `workerd` bundle");
+ expect(readme).toContain("workerd serve config.capnp");
+ expect(readme).toContain("`src/`");
+
+ // The static asset is copied into the bundle (as a workerd `disk`
+ // service) so workerd can serve it.
+ expect(
+ read(helper, "dist-standalone/disk/assets_storage/index.html")
+ ).toContain("standalone");
+ });
+
+ it("errors when the Worker uses bindings unsupported by standalone workerd", async ({
+ expect,
+ }) => {
+ const helper = new WranglerE2ETestHelper();
+ await helper.seed({
+ "wrangler.json": JSON.stringify({
+ name: "compile-unsupported",
+ main: "src/index.ts",
+ compatibility_date: "2024-01-01",
+ standalone: true,
+ kv_namespaces: [{ binding: "MY_KV", id: "abc123" }],
+ }),
+ "src/index.ts": dedent`
+ export default {
+ fetch() {
+ return new Response("ok");
+ },
+ };
+ `,
+ "package.json": dedent`
+ {
+ "name": "compile-unsupported",
+ "version": "0.0.0",
+ "private": true
+ }
+ `,
+ });
+
+ const result = await helper.run(`wrangler compile`);
+ expect(result.status).not.toBe(0);
+ const combined = `${result.stdout}\n${result.stderr}`;
+ expect(combined).toContain("not yet supported by standalone workerd");
+ expect(combined).toContain("MY_KV (kv_namespace)");
+ });
+
+ it("compiles an unsupported-binding Worker when --force is passed", async ({
+ expect,
+ }) => {
+ const helper = new WranglerE2ETestHelper();
+ await helper.seed({
+ "wrangler.json": JSON.stringify({
+ name: "compile-forced",
+ main: "src/index.ts",
+ compatibility_date: "2024-01-01",
+ standalone: true,
+ kv_namespaces: [{ binding: "MY_KV", id: "abc123" }],
+ }),
+ "src/index.ts": dedent`
+ export default {
+ fetch() {
+ return new Response("ok");
+ },
+ };
+ `,
+ "package.json": dedent`
+ {
+ "name": "compile-forced",
+ "version": "0.0.0",
+ "private": true
+ }
+ `,
+ });
+
+ await helper.run(`wrangler compile --force`);
+ const capnp = read(helper, "dist-standalone/config.capnp");
+ expect(capnp).toContain("Workerd.Config");
+ });
+
+ it("runs the compiled bundle with --serve (the exact production artifact)", async ({
+ expect,
+ }) => {
+ const helper = new WranglerE2ETestHelper();
+ await helper.seed({
+ "wrangler.json": JSON.stringify({
+ name: "compile-serve",
+ main: "src/index.ts",
+ compatibility_date: "2024-01-01",
+ standalone: true,
+ vars: { GREETING: "hello from serve" },
+ assets: { directory: "./public", binding: "ASSETS" },
+ }),
+ "src/index.ts": dedent`
+ export default {
+ fetch(request, env) {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith("/api")) {
+ return new Response(env.GREETING);
+ }
+ return env.ASSETS.fetch(request);
+ },
+ };
+ `,
+ "public/index.html": "served standalone
",
+ "package.json": dedent`
+ {
+ "name": "compile-serve",
+ "version": "0.0.0",
+ "private": true
+ }
+ `,
+ });
+
+ const port = await getFreePort();
+ const server = helper.runLongLived(
+ `wrangler compile --serve --ip 127.0.0.1 --port ${port}`
+ );
+ await server.readUntil(/Serving ".*" on http:\/\//, 60_000);
+
+ const base = `http://127.0.0.1:${port}`;
+
+ // Dynamic route hits the user Worker (with the env var baked in).
+ const api = await fetch(`${base}/api/hello`);
+ expect(api.status).toBe(200);
+ expect(await api.text()).toBe("hello from serve");
+
+ // Static asset served from the bundled `disk` service.
+ const root = await fetch(`${base}/`);
+ expect(root.status).toBe(200);
+ expect(await root.text()).toContain("served standalone");
+
+ // Unknown asset path hits the real `not_found_handling` (404).
+ const missing = await fetch(`${base}/does-not-exist.html`);
+ expect(missing.status).toBe(404);
+ });
+
+ it("runs a --format binary bundle with --serve", async ({ expect }) => {
+ const helper = new WranglerE2ETestHelper();
+ await helper.seed({
+ "wrangler.json": JSON.stringify({
+ name: "compile-binary",
+ main: "src/index.ts",
+ compatibility_date: "2024-01-01",
+ standalone: true,
+ vars: { GREETING: "hello from binary" },
+ assets: { directory: "./public", binding: "ASSETS" },
+ }),
+ "src/index.ts": dedent`
+ export default {
+ fetch(request, env) {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith("/api")) {
+ return new Response(env.GREETING);
+ }
+ return env.ASSETS.fetch(request);
+ },
+ };
+ `,
+ "public/index.html": "served binary
",
+ "package.json": dedent`
+ {
+ "name": "compile-binary",
+ "version": "0.0.0",
+ "private": true
+ }
+ `,
+ });
+
+ const port = await getFreePort();
+ const server = helper.runLongLived(
+ `wrangler compile --format binary --serve --ip 127.0.0.1 --port ${port}`
+ );
+ await server.readUntil(/Serving ".*" on http:\/\//, 60_000);
+
+ const base = `http://127.0.0.1:${port}`;
+
+ // The dynamic route and static asset both work from the single binary config.
+ const api = await fetch(`${base}/api/hello`);
+ expect(api.status).toBe(200);
+ expect(await api.text()).toBe("hello from binary");
+
+ const root = await fetch(`${base}/`);
+ expect(root.status).toBe(200);
+ expect(await root.text()).toContain("served binary");
+
+ // Binary bundles emit a single self-contained config.bin (no text capnp).
+ const configBin = read(helper, "dist-standalone/config.bin");
+ expect(configBin.length).toBeGreaterThan(0);
+ const entrypoint = read(helper, "dist-standalone/entrypoint.sh");
+ expect(entrypoint).toContain("workerd serve --binary config.bin");
+ });
+});
diff --git a/packages/wrangler/src/__tests__/standalone.test.ts b/packages/wrangler/src/__tests__/standalone.test.ts
new file mode 100644
index 0000000000..11f54e3d4f
--- /dev/null
+++ b/packages/wrangler/src/__tests__/standalone.test.ts
@@ -0,0 +1,104 @@
+import fs from "node:fs";
+import {
+ runInTempDir,
+ writeWranglerConfig,
+} from "@cloudflare/workers-utils/test-helpers";
+import { describe, it, vi } from "vitest";
+import {
+ formatStandaloneBindingIssues,
+ getStandaloneBindingIssues,
+} from "../standalone/validate";
+import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
+import { mockConsoleMethods } from "./helpers/mock-console";
+import { useMockIsTTY } from "./helpers/mock-istty";
+import { runWrangler } from "./helpers/run-wrangler";
+import { writeWorkerSource } from "./helpers/write-worker-source";
+import type { Config } from "@cloudflare/workers-utils";
+
+// `wrangler deploy` runs autoconfig before the standalone guard; mock the inner
+// pieces so the command doesn't touch the network/filesystem heuristics.
+vi.mock("../autoconfig/run");
+vi.mock("../autoconfig/frameworks/utils/packages");
+
+describe("standalone binding validation", () => {
+ it("reports no issues for supported bindings", ({ expect }) => {
+ const issues = getStandaloneBindingIssues({
+ vars: { GREETING: "hello" },
+ assets: { directory: "./public", binding: "ASSETS" },
+ } as unknown as Config);
+
+ expect(issues).toEqual([]);
+ });
+
+ it("flags stateful and platform bindings as unsupported", ({ expect }) => {
+ const issues = getStandaloneBindingIssues({
+ kv_namespaces: [{ binding: "MY_KV", id: "abc" }],
+ r2_buckets: [{ binding: "MY_R2", bucket_name: "bucket" }],
+ ai: { binding: "AI" },
+ vars: { GREETING: "hello" },
+ } as unknown as Config);
+
+ expect(issues).toEqual(
+ expect.arrayContaining([
+ { name: "MY_KV", type: "kv_namespace" },
+ { name: "MY_R2", type: "r2_bucket" },
+ { name: "AI", type: "ai" },
+ ])
+ );
+ // The plain `var` should not be reported.
+ expect(issues).not.toContainEqual({ name: "GREETING", type: "plain_text" });
+ });
+
+ it("formats issues as a readable bullet list", ({ expect }) => {
+ expect(
+ formatStandaloneBindingIssues([
+ { name: "MY_KV", type: "kv_namespace" },
+ { name: "AI", type: "ai" },
+ ])
+ ).toMatchInlineSnapshot(`
+ " - MY_KV (kv_namespace)
+ - AI (ai)"
+ `);
+ });
+});
+
+describe("deploy standalone guard", () => {
+ runInTempDir();
+ mockAccountId();
+ mockApiToken();
+ const { setIsTTY } = useMockIsTTY();
+ const std = mockConsoleMethods();
+
+ it("errors when deploying a Worker configured as standalone", async ({
+ expect,
+ }) => {
+ setIsTTY(false);
+ writeWranglerConfig({
+ main: "index.js",
+ standalone: true,
+ });
+ writeWorkerSource();
+
+ await expect(
+ runWrangler("deploy")
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `[Error: This Worker has \`standalone\` set, so it targets a self-hosted workerd runtime rather than Cloudflare. Use \`wrangler compile\` to build a standalone bundle, or remove \`standalone\` from your configuration to deploy to Cloudflare.]`
+ );
+ });
+
+ it("allows a dry-run of a standalone Worker (reused by compile)", async ({
+ expect,
+ }) => {
+ setIsTTY(false);
+ writeWranglerConfig({
+ main: "index.js",
+ standalone: true,
+ });
+ writeWorkerSource();
+
+ await runWrangler("deploy --dry-run --outdir dist");
+
+ expect(fs.existsSync("dist/index.js")).toBe(true);
+ expect(std.err).toBe("");
+ });
+});
diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts
index f9842e0ae0..8c1fcf0047 100644
--- a/packages/wrangler/src/api/dev.ts
+++ b/packages/wrangler/src/api/dev.ts
@@ -161,6 +161,7 @@ export async function unstable_dev(
$0: "",
remote: !local,
local: undefined,
+ standalone: undefined,
d1Databases,
disableDevRegistry,
testScheduled: testScheduled ?? false,
diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts
index 011ad58c85..3f836222d8 100644
--- a/packages/wrangler/src/api/index.ts
+++ b/packages/wrangler/src/api/index.ts
@@ -90,6 +90,10 @@ export type {
Unstable_MiniflareWorkerOptions,
} from "./integrations";
+// Exports from ./compile
+export { unstable_compileStandalone } from "../compile";
+export type { CompileStandaloneOptions } from "../compile";
+
// Exports from ./remoteBindings
export {
startRemoteProxySession,
diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts
index 62a7ac6044..2f8422b979 100644
--- a/packages/wrangler/src/check/commands.ts
+++ b/packages/wrangler/src/check/commands.ts
@@ -173,7 +173,7 @@ function getModuleType(entry: FormDataEntryValue) {
}
}
-async function convertWorkerBundleToModules(
+export async function convertWorkerBundleToModules(
workerBundle: FormData
): Promise {
return await Promise.all(
@@ -193,7 +193,7 @@ async function convertWorkerBundleToModules(
);
}
-async function parseFormDataFromFile(file: string): Promise {
+export async function parseFormDataFromFile(file: string): Promise {
const bundle = await readFile(file);
const firstLine = bundle.findIndex((v) => v === 10);
const boundary = Uint8Array.prototype.slice
diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts
index 2f5f25afe2..a2e1d24378 100644
--- a/packages/wrangler/src/cli.ts
+++ b/packages/wrangler/src/cli.ts
@@ -14,6 +14,7 @@ import {
maybeStartOrUpdateRemoteProxySession,
startRemoteProxySession,
startWorker,
+ unstable_compileStandalone,
unstable_dev,
createTestHarness,
experimental_generateTypes,
@@ -28,6 +29,7 @@ import {
import { main } from "./index";
import type {
Binding,
+ CompileStandaloneOptions,
GetPlatformProxyOptions,
PlatformProxy,
RemoteProxySession,
@@ -80,6 +82,7 @@ export {
unstable_getWorkerNameFromProject,
getPlatformProxy,
unstable_getMiniflareWorkerOptions,
+ unstable_compileStandalone,
};
export type {
@@ -97,6 +100,7 @@ export type {
TestHarnessOptions,
WorkerHandle,
TestHarness,
+ CompileStandaloneOptions,
};
export { printBindings as unstable_printBindings } from "./utils/print-bindings";
diff --git a/packages/wrangler/src/compile/index.ts b/packages/wrangler/src/compile/index.ts
new file mode 100644
index 0000000000..7b894f1ec3
--- /dev/null
+++ b/packages/wrangler/src/compile/index.ts
@@ -0,0 +1,618 @@
+import { spawn } from "node:child_process";
+import { writeFileSync } from "node:fs";
+import path from "node:path";
+import { setTimeout as sleep } from "node:timers/promises";
+import { getWranglerTmpDir, UserError } from "@cloudflare/workers-utils";
+import {
+ emitStandaloneBundle,
+ Log,
+ LogLevel,
+ Miniflare,
+ STANDALONE_CONFIG_FILENAME,
+} from "miniflare";
+import { fetch } from "undici";
+import workerdPath from "workerd";
+import { version as workerdVersion } from "workerd/package.json";
+import { createCLIParser } from "..";
+import { unstable_getMiniflareWorkerOptions } from "../api/integrations";
+import {
+ convertWorkerBundleToModules,
+ parseFormDataFromFile,
+} from "../check/commands";
+import { readConfig } from "../config";
+import { createCommand } from "../core/create-command";
+import { logger } from "../logger";
+import {
+ formatStandaloneBindingIssues,
+ getStandaloneBindingIssues,
+} from "../standalone/validate";
+import type { Config } from "@cloudflare/workers-utils";
+import type {
+ EmitStandaloneResult,
+ ModuleDefinition,
+ StandaloneConfigFormat,
+} from "miniflare";
+
+const DEFAULT_OUTDIR = "dist-standalone";
+const DEFAULT_SERVE_PORT = 8080;
+const DEFAULT_SERVE_IP = "127.0.0.1";
+const DEFAULT_FORMAT: StandaloneConfigFormat = "text";
+
+/**
+ * Builds the user Worker into a deployable bundle (reusing the `deploy
+ * --dry-run` pipeline, which requires no Cloudflare account) and returns its
+ * modules in Miniflare's format, with the entrypoint module first.
+ */
+async function buildWorkerModules(
+ config: Config,
+ env: string | undefined
+): Promise {
+ const tmpDir = getWranglerTmpDir(
+ config.configPath ? path.dirname(config.configPath) : undefined,
+ "compile"
+ );
+ const bundlePath = path.join(tmpDir.path, "worker.bundle");
+
+ const previousLevel = logger.loggerLevel;
+ if (logger.loggerLevel !== "debug") {
+ logger.loggerLevel = "error";
+ }
+ try {
+ const { wrangler } = createCLIParser([
+ "deploy",
+ "--dry-run",
+ `--outfile=${bundlePath}`,
+ ...(config.configPath ? ["--config", config.configPath] : []),
+ ...(env ? ["--env", env] : []),
+ ]);
+ await wrangler.parse();
+ } finally {
+ logger.loggerLevel = previousLevel;
+ }
+
+ const bundle = await parseFormDataFromFile(bundlePath);
+ const metadata = JSON.parse(bundle.get("metadata") as string);
+ if (!("main_module" in metadata)) {
+ throw new UserError(
+ "`wrangler compile` only supports module-format Workers. Refer to https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/ for migration guidance.",
+ { telemetryMessage: "compile service worker format unsupported" }
+ );
+ }
+
+ const modules = await convertWorkerBundleToModules(bundle);
+ // Miniflare treats the first module as the entrypoint.
+ modules.sort((a, b) =>
+ a.path === metadata.main_module
+ ? -1
+ : b.path === metadata.main_module
+ ? 1
+ : 0
+ );
+ return modules;
+}
+
+/**
+ * Computes the deepest directory shared by every module path. Used as Miniflare's
+ * `modulesRoot` so emitted module names stay relative (e.g. `index.js`) instead of
+ * leaking the dry-run temp directory the bundle was produced in.
+ */
+function commonModulesRoot(modules: ModuleDefinition[]): string {
+ const dirs = modules.map((module) =>
+ path.posix.dirname(module.path.replace(/\\/g, "/"))
+ );
+ if (dirs.length === 0) {
+ return "/";
+ }
+ let segments = dirs[0].split("/");
+ for (const dir of dirs.slice(1)) {
+ const other = dir.split("/");
+ let i = 0;
+ while (
+ i < segments.length &&
+ i < other.length &&
+ segments[i] === other[i]
+ ) {
+ i++;
+ }
+ segments = segments.slice(0, i);
+ }
+ const root = segments.join("/");
+ return root === "" ? "/" : root;
+}
+
+/**
+ * Assembles the resolved `workerd` config for `config` by driving Miniflare with
+ * the bundled worker modules and the project's bindings/assets, then disposing.
+ */
+async function assembleWorkerdConfig(
+ config: Config,
+ env: string | undefined,
+ modules: ModuleDefinition[]
+) {
+ const { workerOptions, externalWorkers } = unstable_getMiniflareWorkerOptions(
+ config,
+ env
+ );
+
+ const mf = new Miniflare({
+ log: new Log(LogLevel.WARN),
+ workers: [
+ {
+ ...workerOptions,
+ name: config.name ?? "worker",
+ compatibilityDate:
+ workerOptions.compatibilityDate ?? config.compatibility_date,
+ modulesRoot: commonModulesRoot(modules),
+ modules,
+ },
+ ...externalWorkers,
+ ],
+ });
+ try {
+ await mf.ready;
+ return await mf.unstable_getConfig();
+ } finally {
+ await mf.dispose();
+ }
+}
+
+interface RuntimeFileOptions {
+ configFile: string;
+ binary: boolean;
+}
+
+/** `workerd serve` flags shared by the entrypoint, Dockerfile, and docs. */
+function serveArgs(options: RuntimeFileOptions): string {
+ return `${options.binary ? "--binary " : ""}${options.configFile}`;
+}
+
+function writeRuntimeFiles(
+ outDir: string,
+ workerName: string,
+ options: RuntimeFileOptions
+): void {
+ const args = serveArgs(options);
+ const entrypoint = `#!/bin/sh
+# Entrypoint for the standalone workerd bundle generated by \`wrangler compile\`.
+# The listen port defaults to 8080 and can be overridden with the PORT env var
+# (common on platforms like Railway, Render, and Fly.io).
+set -e
+exec ./node_modules/.bin/workerd serve ${args} \\
+ --socket-addr=http=0.0.0.0:"\${PORT:-8080}" "$@"
+`;
+ writeFileSync(path.join(outDir, "entrypoint.sh"), entrypoint, {
+ mode: 0o755,
+ });
+
+ // Pin the runtime to the workerd version this bundle was generated against.
+ // While standalone is in alpha the config shape tracks the runtime, so an
+ // unpinned install risks a runtime/config mismatch.
+ const workerdSpec = workerdVersion ? `workerd@${workerdVersion}` : "workerd";
+ const dockerfile = `# Dockerfile generated by \`wrangler compile\` for "${workerName}".
+# Builds a self-contained image that serves the Worker on bare workerd.
+FROM node:20-slim
+WORKDIR /app
+COPY . /app
+# Installs the workerd runtime binary for the image platform, pinned to the
+# version this bundle was generated against. Change it deliberately.
+RUN npm install ${workerdSpec} --no-save
+ENV PORT=8080
+EXPOSE 8080
+ENTRYPOINT ["sh", "/app/entrypoint.sh"]
+`;
+ writeFileSync(path.join(outDir, "Dockerfile"), dockerfile);
+}
+
+/**
+ * Writes a human-facing `README.md` to the bundle with copy-pasteable run
+ * instructions for the common deploy targets. The machine-oriented capability
+ * detail (services kept/stripped, warnings) lives in `COMPILE_REPORT.md`.
+ */
+function writeReadme(
+ outDir: string,
+ workerName: string,
+ options: RuntimeFileOptions
+): void {
+ const args = serveArgs(options);
+ const workerdSpec = workerdVersion ? `workerd@${workerdVersion}` : "workerd";
+ const readme = `# ${workerName} — standalone \`workerd\` bundle
+
+This directory is a self-contained [\`workerd\`](https://github.com/cloudflare/workerd)
+bundle produced by \`wrangler compile\`. It runs anywhere \`workerd\` runs — no
+Cloudflare account or platform required.
+
+> Generated against \`${workerdSpec}\`. Install that exact version for a faithful runtime.
+
+## Run locally
+
+\`\`\`sh
+# With the workerd binary on your PATH:
+workerd serve ${args} --socket-addr=http=0.0.0.0:8080
+
+# ...or with npx (no global install):
+npx ${workerdSpec} serve ${args} --socket-addr=http=0.0.0.0:8080
+\`\`\`
+
+Then open .
+
+## Run with Docker
+
+\`\`\`sh
+docker build -t ${workerName} .
+docker run -p 8080:8080 ${workerName}
+\`\`\`
+
+## Deploy to a PaaS (Railway, Render, Fly.io, …)
+
+These platforms inject a \`PORT\` environment variable. \`entrypoint.sh\` honours it
+(defaulting to 8080), so point your platform at the Dockerfile and it will bind
+the right port automatically. For a generic host, run \`sh entrypoint.sh\`.
+
+## What's in here
+
+| Path | Purpose |
+| --- | --- |
+| \`${options.configFile}\` | The \`workerd\` config (${options.binary ? "encoded binary message" : "human-readable Cap'n Proto"}). |
+${options.binary ? "" : "| `src/` | Embedded Worker modules and data blobs. |\n"}| \`disk/\` | Static assets served read-only by \`workerd\`. |
+| \`Dockerfile\` | Builds a self-contained image. |
+| \`entrypoint.sh\` | \`$PORT\`-aware launch script. |
+| \`COMPILE_REPORT.md\` | Which bindings/services are wired, simulated, or stripped. |
+`;
+ writeFileSync(
+ path.join(outDir, "README.md"),
+ readme.replace(/\n\n+/g, "\n\n")
+ );
+}
+
+function writeReport(
+ outDir: string,
+ result: EmitStandaloneResult,
+ workerName: string,
+ options: RuntimeFileOptions
+): void {
+ const lines: string[] = [
+ `# \`wrangler compile\` report — ${workerName}`,
+ "",
+ "This bundle runs on a standalone, self-hosted `workerd` runtime (no Cloudflare platform).",
+ `Generated against \`workerd@${workerdVersion ?? "(unknown)"}\` in \`${result.format}\` format. See README.md to run it.`,
+ "",
+ "## Run it",
+ "",
+ "```sh",
+ "# Locally (requires the `workerd` binary on your PATH):",
+ `cd ${path.basename(outDir)} && workerd serve ${serveArgs(options)} --socket-addr=http=0.0.0.0:8080`,
+ "",
+ "# Or build the container:",
+ `docker build -t ${workerName} ${path.basename(outDir)} && docker run -p 8080:8080 ${workerName}`,
+ "```",
+ "",
+ "## Entry service",
+ "",
+ `- \`${result.entryService}\``,
+ "",
+ "## Services included",
+ "",
+ ...result.keptServices.map((service) => `- \`${service}\``),
+ ];
+ if (result.droppedServices.length > 0) {
+ lines.push(
+ "",
+ "## Development-only services stripped",
+ "",
+ ...result.droppedServices.map((service) => `- \`${service}\``)
+ );
+ }
+ if (result.droppedExtensionModules.length > 0) {
+ lines.push(
+ "",
+ "## Unused runtime extensions pruned",
+ "",
+ ...result.droppedExtensionModules.map((module) => `- \`${module}\``)
+ );
+ }
+ if (result.warnings.length > 0) {
+ lines.push(
+ "",
+ "## Warnings",
+ "",
+ ...result.warnings.map((warning) => `- ${warning}`)
+ );
+ }
+ writeFileSync(
+ path.join(outDir, "COMPILE_REPORT.md"),
+ lines.join("\n") + "\n"
+ );
+}
+
+/**
+ * Runs the freshly-emitted bundle with the bundled `workerd` binary — the exact
+ * artifact that ships, not a dev-mode approximation. Resolves only when the
+ * server exits (Ctrl-C / SIGTERM / `workerd` crash), so callers should treat it
+ * as long-running.
+ *
+ * `workerd` resolves `disk` service paths relative to its working directory, so
+ * the process is launched with `cwd` set to the bundle root.
+ */
+async function serveStandaloneBundle(
+ outDir: string,
+ workerName: string,
+ port: number,
+ ip: string,
+ runtimeOptions: RuntimeFileOptions
+): Promise {
+ const binaryPath = process.env.MINIFLARE_WORKERD_PATH ?? workerdPath;
+ const child = spawn(
+ binaryPath,
+ [
+ "serve",
+ ...(runtimeOptions.binary ? ["--binary"] : []),
+ runtimeOptions.configFile,
+ `--socket-addr=http=${ip}:${port}`,
+ ],
+ { cwd: outDir, stdio: "inherit" }
+ );
+
+ let exited = false;
+ let exitError: Error | undefined;
+ const exitPromise = new Promise((resolve) => {
+ child.on("exit", (code, signal) => {
+ exited = true;
+ if (code !== 0 && code !== null && signal === null) {
+ exitError = new Error(`workerd exited with code ${code}`);
+ }
+ resolve();
+ });
+ child.on("error", (error) => {
+ exited = true;
+ exitError = error;
+ resolve();
+ });
+ });
+
+ // Forward termination signals so Ctrl-C / container stop tears down workerd.
+ const forward = (signal: NodeJS.Signals) => () => {
+ if (!child.killed) {
+ child.kill(signal);
+ }
+ };
+ const onSigint = forward("SIGINT");
+ const onSigterm = forward("SIGTERM");
+ process.once("SIGINT", onSigint);
+ process.once("SIGTERM", onSigterm);
+
+ try {
+ // `workerd serve` emits no structured ready signal here, so poll the
+ // socket until it accepts a request (or the process dies).
+ const url = `http://${ip}:${port}/`;
+ const deadline = Date.now() + 10_000;
+ let ready = false;
+ while (!exited && Date.now() < deadline) {
+ try {
+ await fetch(url);
+ ready = true;
+ break;
+ } catch {
+ await sleep(100);
+ }
+ }
+
+ if (exited) {
+ throw exitError ?? new Error("workerd exited before it started serving");
+ }
+ if (!ready) {
+ throw new Error(
+ `Timed out waiting for the standalone bundle to start serving on ${url}`
+ );
+ }
+
+ logger.log(
+ [
+ `✨ Serving "${workerName}" on http://localhost:${port} (Ctrl-C to stop)`,
+ "This is the exact bundle that will run under `workerd serve` in production.",
+ ].join("\n")
+ );
+
+ await exitPromise;
+ if (exitError) {
+ throw exitError;
+ }
+ } finally {
+ process.off("SIGINT", onSigint);
+ process.off("SIGTERM", onSigterm);
+ if (!exited && !child.killed) {
+ child.kill("SIGTERM");
+ }
+ }
+}
+
+export const compileCommand = createCommand({
+ metadata: {
+ description:
+ "🧱 Compile your Worker into a standalone workerd bundle for self-hosting",
+ owner: "Workers: Authoring and Testing",
+ status: "experimental",
+ },
+ args: {
+ outdir: {
+ describe: "Directory to write the standalone bundle to",
+ type: "string",
+ default: DEFAULT_OUTDIR,
+ },
+ force: {
+ describe:
+ "Compile even if the Worker uses bindings not yet supported by standalone workerd",
+ type: "boolean",
+ default: false,
+ },
+ format: {
+ describe:
+ "Config output format: 'text' (human-readable config.capnp + embedded modules) or 'binary' (a single self-contained config.bin)",
+ type: "string",
+ choices: ["text", "binary"] as const,
+ default: DEFAULT_FORMAT,
+ },
+ serve: {
+ describe:
+ "After compiling, run the produced bundle locally with the bundled workerd binary (the exact production artifact)",
+ type: "boolean",
+ default: false,
+ },
+ port: {
+ describe: "Port to serve on when using --serve",
+ type: "number",
+ default: DEFAULT_SERVE_PORT,
+ },
+ ip: {
+ describe: "IP address to bind to when using --serve",
+ type: "string",
+ default: DEFAULT_SERVE_IP,
+ },
+ },
+ behaviour: {
+ printBanner: true,
+ },
+ async handler(args, { config }) {
+ await runStandaloneCompile(config, {
+ env: config.targetEnvironment,
+ outDir: args.outdir,
+ force: args.force,
+ format: args.format,
+ log: true,
+ serve: args.serve,
+ port: args.port,
+ ip: args.ip,
+ });
+ },
+});
+
+export interface RunStandaloneCompileOptions {
+ /** The active environment name (for config resolution / bundling). */
+ env?: string;
+ /** Output directory for the bundle (resolved against cwd). */
+ outDir: string;
+ /** Compile even when unsupported bindings are present. */
+ force?: boolean;
+ /** Config output format (default `"text"`). */
+ format?: StandaloneConfigFormat;
+ /** Whether to log progress/success (off for programmatic callers). */
+ log?: boolean;
+ /** After emitting, run the bundle with the bundled `workerd` binary. */
+ serve?: boolean;
+ /** Port for `--serve` (default 8080). */
+ port?: number;
+ /** Bind address for `--serve` (default 127.0.0.1). */
+ ip?: string;
+}
+
+/**
+ * Shared orchestration behind `wrangler compile` and the programmatic
+ * {@link unstable_compileStandalone} API: validate bindings, bundle the Worker,
+ * assemble the resolved `workerd` config via Miniflare, and emit the standalone
+ * bundle plus its Dockerfile/entrypoint/report.
+ */
+export async function runStandaloneCompile(
+ config: Config,
+ options: RunStandaloneCompileOptions
+): Promise {
+ const workerName = config.name ?? "worker";
+ const outDir = path.resolve(options.outDir);
+ const log = options.log ?? true;
+
+ const issues = getStandaloneBindingIssues(config);
+ if (issues.length > 0) {
+ const message = `The following bindings are not yet supported by standalone workerd:\n${formatStandaloneBindingIssues(
+ issues
+ )}`;
+ if (!options.force) {
+ throw new UserError(
+ `${message}\n\nThese have no standalone production story yet. Re-run with --force to compile anyway (they will be omitted or may not work).`,
+ { telemetryMessage: "compile unsupported bindings" }
+ );
+ }
+ logger.warn(message);
+ }
+
+ if (log) {
+ logger.log(`Compiling "${workerName}" for standalone workerd...`);
+ }
+
+ const format = options.format ?? DEFAULT_FORMAT;
+ const modules = await buildWorkerModules(config, options.env);
+ const workerdConfig = await assembleWorkerdConfig(
+ config,
+ options.env,
+ modules
+ );
+ const result = emitStandaloneBundle(workerdConfig, outDir, { format });
+
+ for (const warning of result.warnings) {
+ logger.warn(warning);
+ }
+
+ const runtimeOptions: RuntimeFileOptions = {
+ configFile: STANDALONE_CONFIG_FILENAME[result.format],
+ binary: result.format === "binary",
+ };
+ writeRuntimeFiles(outDir, workerName, runtimeOptions);
+ writeReport(outDir, result, workerName, runtimeOptions);
+ writeReadme(outDir, workerName, runtimeOptions);
+
+ if (log) {
+ logger.log(
+ [
+ `✨ Compiled standalone bundle to ${path.relative(process.cwd(), outDir) || "."}`,
+ "",
+ "Run it with:",
+ ` cd ${path.relative(process.cwd(), outDir) || "."} && workerd serve ${serveArgs(runtimeOptions)}`,
+ "or build the generated Dockerfile. See README.md for details.",
+ ].join("\n")
+ );
+ }
+
+ if (options.serve) {
+ await serveStandaloneBundle(
+ outDir,
+ workerName,
+ options.port ?? DEFAULT_SERVE_PORT,
+ options.ip ?? DEFAULT_SERVE_IP,
+ runtimeOptions
+ );
+ }
+
+ return result;
+}
+
+export interface CompileStandaloneOptions {
+ /** Path to the Wrangler config to compile (e.g. a generated `wrangler.json`). */
+ configPath?: string;
+ /** The Cloudflare environment to resolve the config against. */
+ env?: string;
+ /** Output directory for the standalone bundle. */
+ outDir: string;
+ /** Compile even when unsupported bindings are present. */
+ force?: boolean;
+ /** Config output format (default `"text"`). */
+ format?: StandaloneConfigFormat;
+ /** Whether to log progress/success. Defaults to `true`. */
+ log?: boolean;
+}
+
+/**
+ * Programmatic entry point for producing a standalone `workerd` bundle from a
+ * Wrangler config. Used by `@cloudflare/vite-plugin`'s `standalone` mode so the
+ * Vite build and `wrangler compile` share one implementation.
+ *
+ * @experimental
+ */
+export async function unstable_compileStandalone(
+ options: CompileStandaloneOptions
+): Promise {
+ const config = readConfig({ config: options.configPath, env: options.env });
+ return runStandaloneCompile(config, {
+ env: config.targetEnvironment,
+ outDir: options.outDir,
+ force: options.force,
+ format: options.format,
+ log: options.log ?? true,
+ });
+}
diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts
index c707ef22cc..c17d9219c8 100644
--- a/packages/wrangler/src/deploy/index.ts
+++ b/packages/wrangler/src/deploy/index.ts
@@ -1,4 +1,5 @@
import { deploy } from "@cloudflare/deploy-helpers";
+import { UserError } from "@cloudflare/workers-utils";
import { analyseBundle } from "../check/commands";
import { buildContainer } from "../containers/build";
import { getNormalizedContainerOptions } from "../containers/config";
@@ -121,6 +122,16 @@ export const deployCommand = createCommand({
}
config = autoConfigResult.config;
+ // `standalone` declares the Worker targets a self-hosted workerd runtime,
+ // not the Cloudflare platform — so deploying it is a mistake. (Dry runs are
+ // allowed: `wrangler compile` reuses the dry-run bundling pipeline.)
+ if (config.standalone && !args.dryRun) {
+ throw new UserError(
+ "This Worker has `standalone` set, so it targets a self-hosted workerd runtime rather than Cloudflare. Use `wrangler compile` to build a standalone bundle, or remove `standalone` from your configuration to deploy to Cloudflare.",
+ { telemetryMessage: "deploy standalone worker" }
+ );
+ }
+
// Interatively handle missing/incorrect --assets, --script, --name, --compatibility-date
args = await promptForMissingDeployConfig(args, config);
diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts
index ca3fe490de..abc906eedd 100644
--- a/packages/wrangler/src/dev.ts
+++ b/packages/wrangler/src/dev.ts
@@ -9,12 +9,17 @@ import {
import { getHostFromRoute } from "@cloudflare/workers-utils";
import { isWebContainer } from "@webcontainer/env";
import { getAssetsOptions } from "./assets";
+import { readConfig } from "./config";
import { createCommand } from "./core/create-command";
import { validateRoutes } from "./deployment-bundle/resolve-config-args";
import { getVarsForDev } from "./dev/dev-vars";
import { startDev } from "./dev/start-dev";
import { experimentalNewConfigArg } from "./experimental-config/cli-flag";
import { logger } from "./logger";
+import {
+ formatStandaloneBindingIssues,
+ getStandaloneBindingIssues,
+} from "./standalone/validate";
import type { StartDevWorkerInput, Trigger } from "./api";
import type { EnablePagesAssetsServiceBindingOptions } from "./miniflare-cli/types";
import type {
@@ -73,6 +78,12 @@ export const dev = createCommand({
type: "boolean",
default: true,
},
+ standalone: {
+ describe:
+ "Validate this Worker against the features supported by standalone workerd (see `wrangler compile`)",
+ type: "boolean",
+ hidden: true,
+ },
assets: {
describe: "Static assets to be served. Replaces Workers Sites.",
type: "string",
@@ -304,6 +315,7 @@ export const dev = createCommand({
}
},
async handler(args) {
+ maybeWarnStandaloneBindings(args);
const devInstance = await startDev(args);
assert(devInstance.devEnv !== undefined);
await events.once(devInstance.devEnv, "teardown");
@@ -313,6 +325,42 @@ export const dev = createCommand({
},
});
+/**
+ * When a project targets standalone workerd (`standalone` in config or
+ * `--standalone`), warn about bindings that work in local dev but aren't yet
+ * supported by `wrangler compile`, so the standalone limitations surface in the
+ * inner loop. Best-effort and never fatal to `wrangler dev`.
+ */
+function maybeWarnStandaloneBindings(args: {
+ config?: string | string[];
+ env?: string;
+ standalone?: boolean;
+}): void {
+ if (Array.isArray(args.config)) {
+ // Multi-worker dev is out of scope for the standalone check for now.
+ return;
+ }
+ let config: Config;
+ try {
+ // `args` carries the full handler args at runtime; readConfig reads what it needs.
+ config = readConfig(args as Parameters[0]);
+ } catch {
+ return;
+ }
+ if (!(args.standalone ?? config.standalone)) {
+ return;
+ }
+ const issues = getStandaloneBindingIssues(config);
+ if (issues.length === 0) {
+ return;
+ }
+ logger.warn(
+ `This Worker targets standalone workerd, but the following bindings work in local dev only and aren't supported by \`wrangler compile\` yet:\n${formatStandaloneBindingIssues(
+ issues
+ )}`
+ );
+}
+
export type AdditionalDevProps = {
/**
* Default vars that can be overridden by config vars.
diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts
index 6eadcb12d3..9ff4b1cb72 100644
--- a/packages/wrangler/src/index.ts
+++ b/packages/wrangler/src/index.ts
@@ -87,6 +87,7 @@ import {
cloudchamberSshListCommand,
cloudchamberSshNamespace,
} from "./cloudchamber";
+import { compileCommand } from "./compile";
import { completionsCommand } from "./complete";
import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env";
import {
@@ -829,6 +830,14 @@ export function createCLIParser(argv: string[]) {
]);
registry.registerNamespace("deploy");
+ registry.define([
+ {
+ command: "wrangler compile",
+ definition: compileCommand,
+ },
+ ]);
+ registry.registerNamespace("compile");
+
registry.define([
{ command: "wrangler preview", definition: previewCommand },
{ command: "wrangler preview delete", definition: previewDeleteCommand },
diff --git a/packages/wrangler/src/standalone/validate.ts b/packages/wrangler/src/standalone/validate.ts
new file mode 100644
index 0000000000..d26aa89948
--- /dev/null
+++ b/packages/wrangler/src/standalone/validate.ts
@@ -0,0 +1,32 @@
+import { getStandaloneSupport } from "@cloudflare/workers-utils";
+import { convertConfigBindingsToStartWorkerBindings } from "../api/startDevWorker";
+import type { Config } from "@cloudflare/workers-utils";
+
+export interface StandaloneBindingIssue {
+ name: string;
+ type: string;
+}
+
+/**
+ * Returns the bindings configured for `config` that are not yet supported by a
+ * standalone, self-hosted `workerd` runtime. Shared by `wrangler compile`
+ * (which errors) and `wrangler dev` (which warns) so both apply the same rules.
+ */
+export function getStandaloneBindingIssues(
+ config: Config
+): StandaloneBindingIssue[] {
+ const bindings = convertConfigBindingsToStartWorkerBindings(config) ?? {};
+ const issues: StandaloneBindingIssue[] = [];
+ for (const [name, binding] of Object.entries(bindings)) {
+ if (getStandaloneSupport(binding.type) === "unsupported") {
+ issues.push({ name, type: binding.type });
+ }
+ }
+ return issues;
+}
+
+export function formatStandaloneBindingIssues(
+ issues: StandaloneBindingIssue[]
+): string {
+ return issues.map((issue) => ` - ${issue.name} (${issue.type})`).join("\n");
+}