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"); +}