diff --git a/AGENTS.md b/AGENTS.md index 9f7da3e52b3..a39638b983b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,8 @@ Be aware that workerd uses tcmalloc for memory allocation in the typical case. W ### Configuration System -- Uses **Cap'n Proto** for configuration files (`.capnp` format) +- Uses **Cap'n Proto** for configuration files (`.capnp` format); a `wrangler.json(c)` is also + accepted and translated (`src/workerd/server/cli/wrangler.rs`) - Main schema: `src/workerd/server/workerd.capnp` - Sample configurations in `samples/` directory - Configuration uses capability-based security model diff --git a/README.md b/README.md index 55830553b6c..8204b119d7b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,12 @@ bazel build --config=thin-lto //src/workerd/server:workerd `workerd` is configured using a config file written in Cap'n Proto text format. +For simple projects, `workerd serve wrangler.jsonc` also accepts a Wrangler +config. `main` is bundled with esbuild, as Wrangler does. The supported fields +are `name`, `main`, `compatibility_date`, `compatibility_flags`, `vars`, +`durable_objects`, `migrations`, `no_bundle` and `$schema`; any other field +is an error. + A simple "Hello World!" config file might look like: ```capnp diff --git a/samples/BUILD.bazel b/samples/BUILD.bazel index 398ae3476e8..5957675b905 100644 --- a/samples/BUILD.bazel +++ b/samples/BUILD.bazel @@ -1,4 +1,5 @@ exports_files([ "helloworld/config.capnp", "helloworld/worker.js", + "wrangler/wrangler.jsonc", ]) diff --git a/samples/wrangler/.gitignore b/samples/wrangler/.gitignore new file mode 100644 index 00000000000..b75a0fa9c31 --- /dev/null +++ b/samples/wrangler/.gitignore @@ -0,0 +1 @@ +.wrangler/ diff --git a/samples/wrangler/README.md b/samples/wrangler/README.md new file mode 100644 index 00000000000..df6b92b4da2 --- /dev/null +++ b/samples/wrangler/README.md @@ -0,0 +1,32 @@ +# Wrangler Config Example + +workerd can run a project configured with a `wrangler.jsonc` instead of a +`config.capnp`. This one is a TypeScript Worker with a SQLite-backed Durable +Object that counts visits per path. + +As Wrangler does, workerd bundles `main` with esbuild, so esbuild must be +installed: in the project (`npm i -D esbuild`), on `PATH`, or named by +`WORKERD_ESBUILD`. + +To run the example on http://localhost:8787 + +```sh +$ ./workerd serve wrangler.jsonc +``` + +To run using bazel + +```sh +$ bazel run //src/workerd/server:workerd -- serve ~/cloudflare/workerd/samples/wrangler/wrangler.jsonc +``` + +To test: + +```sh +$ curl http://localhost:8787/alice +Hello, alice! (visit 1) +$ curl http://localhost:8787/alice +Hello, alice! (visit 2) +``` + +Counts are stored under `.wrangler/state/workerd/` and survive a restart. diff --git a/samples/wrangler/src/index.ts b/samples/wrangler/src/index.ts new file mode 100644 index 00000000000..5bd867c7a4f --- /dev/null +++ b/samples/wrangler/src/index.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + GREETING: string; + COUNTER: DurableObjectNamespace; +} + +// One counter per name, stored in the object's SQLite database. +export class Counter extends DurableObject { + increment(): number { + const sql = this.ctx.storage.sql; + sql.exec('CREATE TABLE IF NOT EXISTS n (v INTEGER)'); + const v = (sql.exec('SELECT v FROM n').toArray()[0]?.v as number) ?? 0; + sql.exec('DELETE FROM n'); + sql.exec('INSERT INTO n VALUES (?)', v + 1); + return v + 1; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const name = new URL(request.url).pathname.slice(1) || 'world'; + const n = await env.COUNTER.getByName(name).increment(); + return new Response(`${env.GREETING}, ${name}! (visit ${n})\n`); + }, +} satisfies ExportedHandler; diff --git a/samples/wrangler/wrangler.jsonc b/samples/wrangler/wrangler.jsonc new file mode 100644 index 00000000000..b49b4bd3413 --- /dev/null +++ b/samples/wrangler/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + // workerd reads this file directly: `workerd serve wrangler.jsonc`. + "name": "counter", + "main": "src/index.ts", + "compatibility_date": "2026-10-01", + "vars": { "GREETING": "Hello" }, + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index 7e48f711da5..d9ca34728d7 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -57,6 +57,7 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` | `process.rs` | Executable discovery, the compiled-binary format, `Process` (the files to watch, re-exec) | | `socket_fd.rs` | `--socket-fd` / `--control-fd` descriptors, made owned; the platform halves are `socket_fd/{unix,windows}.rs` | | `watch.rs` | `--watch`: the file watcher | +| `wrangler.rs` | A `wrangler.json(c)` config: the supported fields translated into a `Config`, `main` bundled with esbuild (or scanned under `no_bundle`) | | `bridge.rs` | The cxx bridge (`workerd::server::cli`) to `config-compiler.h` and the build facts in `factory/bootstrap.h`; header `` | ### `server/` (`workerd-server`) diff --git a/src/workerd/server/BUILD.bazel b/src/workerd/server/BUILD.bazel index 9f555dac3dc..a736657db34 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -86,12 +86,15 @@ wd_rust_crate( cxx_bridge_hdrs = ["config-compiler.h"], cxx_bridge_src = "cli/bridge.rs", link_deps = [":config-compiler"], + test_compile_data = ["//samples:wrangler/wrangler.jsonc"], deps = [ ":workerd_capnp_rust", "//src/rust/cxx/kj-rs-io", "//src/workerd/server/server:workerd-server", "@crates_vendor//:capnp", "@crates_vendor//:clap", + "@crates_vendor//:serde", + "@crates_vendor//:serde_json", "@crates_vendor//:socket2", "@crates_vendor//:tokio", ] + select({ diff --git a/src/workerd/server/cli/args.rs b/src/workerd/server/cli/args.rs index 766fa92a7d5..90bbc701e15 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -216,6 +216,8 @@ pub struct ConfigFileArgs { #[arg(short, long)] pub binary: bool, + /// A Cap'n Proto config (`.capnp`), or a `wrangler.json` / `wrangler.jsonc`, whose `main` is + /// bundled with esbuild as `wrangler` does. #[arg(value_name = "config-file")] pub config_file: String, } diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 37a6eeb416c..f815e08f82b 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -16,6 +16,7 @@ mod config; mod process; mod socket_fd; mod watch; +mod wrangler; use std::fmt::Display; use std::io::IsTerminal; @@ -350,6 +351,16 @@ fn load_config( if config_file == "-" { usage_error::("-: Reading config from stdin is only allowed with --binary."); } + if wrangler::is_wrangler_config(&config_file) { + if let Some(name) = const_name { + usage_error::(format!("{name}: A wrangler config has no named constants.")); + } + if !std::path::Path::new(&config_file).is_file() { + usage_error::(format!("{config_file}: No such file.")); + } + return wrangler::load(&config_file, process) + .unwrap_or_else(|error| config_errors(&[format!("{config_file}: {error}")], process)); + } let compiled = ffi::compile_config( &config_file, @@ -363,7 +374,13 @@ fn load_config( .unwrap_or_else(|error| usage_error::(format!("{config_file}: {error}"))); } - for error in &compiled.errors { + config_errors(&compiled.errors, process) +} + +/// Prints errors in the config. Without `--watch` they end the process; with it the process waits +/// for the files to change and reloads. +fn config_errors(errors: &[impl Display], process: &Process) -> ! { + for error in errors { eprintln!("{error}"); } let Some(watcher) = process.watcher() else { diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs new file mode 100644 index 00000000000..1757907a433 --- /dev/null +++ b/src/workerd/server/cli/wrangler-test.rs @@ -0,0 +1,366 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use workerd_capnp::service; +use workerd_capnp::worker; + +use super::*; + +/// A fresh directory in the test's temp directory holding `files`. +fn project(files: &[(&str, &str)]) -> PathBuf { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let tmp = std::env::var_os("TEST_TMPDIR").map_or_else(std::env::temp_dir, PathBuf::from); + let dir = tmp.join(format!( + "workerd-wrangler-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + for (name, contents) in files { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + dir +} + +fn load_project(files: &[(&str, &str)]) -> Result { + let dir = project(files); + load( + dir.join("wrangler.jsonc").to_str().unwrap(), + &Process::new(None, None, Vec::new()), + ) +} + +fn read(config: Config) -> capnp::message::Reader { + let bytes: Vec = config + .into_words() + .iter() + .flat_map(|w| w.to_ne_bytes()) + .collect(); + capnp::serialize::read_message(bytes.as_slice(), capnp::message::ReaderOptions::new()).unwrap() +} + +#[test] +fn jsonc_comments_and_trailing_commas() { + let src = r#"{ + // a comment + "url": "https://example.com//x", /* block */ + "quote": "a \" // not a comment", + "list": [1, 2,], + }"#; + let v: Value = serde_json::from_str(&strip_jsonc(src)).unwrap(); + assert_eq!(v["url"], "https://example.com//x"); + assert_eq!(v["quote"], "a \" // not a comment"); + assert_eq!(v["list"], serde_json::json!([1, 2])); +} + +#[test] +fn hello() { + let message = read( + load_project(&[ + ( + "wrangler.jsonc", + r#"{ "name": "hello", "main": "index.js", "compatibility_date": "2026-01-01", "no_bundle": true, + "vars": { "GREETING": "hi", "N": 3 } }"#, + ), + ( + "index.js", + "export default { fetch() { return new Response('hi') } }", + ), + ("lib/util.js", "export const x = 1"), + ("README.md", "not a module"), + ]) + .unwrap(), + ); + let config = message.get_root::().unwrap(); + let services = config.get_services().unwrap(); + assert_eq!(services.len(), 1); + let service::Which::Worker(worker) = services.get(0).which().unwrap() else { + panic!("expected a worker service") + }; + let worker = worker.unwrap(); + let worker::Which::Modules(modules) = worker.which().unwrap() else { + panic!("expected a modules worker") + }; + let names: Vec<_> = modules + .unwrap() + .iter() + .map(|m| m.get_name().unwrap().to_string().unwrap()) + .collect(); + assert_eq!(names, ["index.js", "lib/util.js"]); + let bindings = worker.get_bindings().unwrap(); + assert_eq!(bindings.get(0).get_name().unwrap(), "GREETING"); + assert_eq!(bindings.get(1).get_name().unwrap(), "N"); + let socket = config.get_sockets().unwrap().get(0); + assert_eq!(socket.get_address().unwrap(), "localhost:8787"); + assert_eq!(socket.get_service().unwrap().get_name().unwrap(), "hello"); +} + +#[test] +fn durable_objects() { + let message = read( + load_project(&[ + ( + "wrangler.jsonc", + r#"{ + "name": "counter", + "main": "index.js", + "compatibility_date": "2026-01-01", + "no_bundle": true, + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["Old"] }, + { "tag": "v2", "renamed_classes": [{ "from": "Old", "to": "Counter" }] }, + ], + }"#, + ), + ("index.js", "export class Counter {}"), + ]) + .unwrap(), + ); + let config = message.get_root::().unwrap(); + let services = config.get_services().unwrap(); + assert_eq!(services.len(), 2); + let service::Which::Worker(worker) = services.get(0).which().unwrap() else { + panic!("expected a worker service") + }; + let worker = worker.unwrap(); + let binding = worker.get_bindings().unwrap().get(0); + assert_eq!(binding.get_name().unwrap(), "COUNTER"); + let worker::binding::Which::DurableObjectNamespace(target) = binding.which().unwrap() else { + panic!("expected a durable object binding") + }; + assert_eq!(target.unwrap().get_class_name().unwrap(), "Counter"); + let ns = worker.get_durable_object_namespaces().unwrap().get(0); + assert_eq!(ns.get_class_name().unwrap(), "Counter"); + // Renamed, the class keeps the storage it was created with. + let worker::durable_object_namespace::Which::UniqueKey(key) = ns.which().unwrap() else { + panic!("expected a unique key") + }; + assert_eq!(key.unwrap(), "counter-Old"); + assert!(ns.get_enable_sql()); + assert_eq!(services.get(1).get_name().unwrap(), "counter-do-storage"); +} + +#[test] +fn migrations_rename_and_delete() { + let w: Wrangler = serde_json::from_str( + r#"{ "main": "index.js", "compatibility_date": "2026-01-01", + "durable_objects": { "bindings": [{ "name": "B", "class_name": "Bound" }] }, + "migrations": [ + { "tag": "v1", "new_classes": ["A"], "new_sqlite_classes": ["S", "Gone"] }, + { "tag": "v2", "renamed_classes": [{ "from": "A", "to": "A2" }], + "deleted_classes": ["Gone"] } + ] }"#, + ) + .unwrap(); + assert_eq!( + do_classes(&w).unwrap(), + [ + ("A2".to_owned(), "A".to_owned(), false), + ("S".to_owned(), "S".to_owned(), true), + ("Bound".to_owned(), "Bound".to_owned(), false) + ] + ); +} + +#[test] +fn migrations_follow_wranglers_order_and_checks() { + let classes = |migrations: &str| { + let w: Wrangler = serde_json::from_str(&format!( + r#"{{ "main": "index.js", "compatibility_date": "2026-01-01", + "migrations": {migrations} }}"# + )) + .unwrap(); + do_classes(&w) + }; + // Deletes apply before new classes, so a class can be re-created in one migration. + let recreated = r#"[{ "tag": "v1", "new_classes": ["A"] }, + { "tag": "v2", "deleted_classes": ["A"], "new_sqlite_classes": ["A"] }]"#; + assert_eq!( + classes(recreated).unwrap(), + [("A".to_owned(), "A~1".to_owned(), true)] + ); + // A key is never reused, so a re-created or renamed-away name starts empty. + let reused = r#"[{ "tag": "v1", "new_classes": ["A"] }, + { "tag": "v2", "renamed_classes": [{ "from": "A", "to": "B" }] }, + { "tag": "v3", "new_classes": ["A"] }]"#; + assert_eq!( + classes(reused).unwrap(), + [ + ("B".to_owned(), "A".to_owned(), false), + ("A".to_owned(), "A~1".to_owned(), false) + ] + ); + let error = classes( + r#"[{ "tag": "v1", "new_classes": ["A"] }, + { "tag": "v2", "deleted_classes": ["A"], + "renamed_classes": [{ "from": "A", "to": "B" }] }]"#, + ) + .unwrap_err(); + assert!(error.contains("renamed class A does not exist"), "{error}"); + let error = classes(r#"[{ "tag": "v1", "deleted_classes": ["A"] }]"#).unwrap_err(); + assert!(error.contains("deleted class A does not exist"), "{error}"); + let error = classes(r#"[{ "tag": "v1", "new_classes": ["A"], "new_sqlite_classes": ["A"] }]"#) + .unwrap_err(); + assert!(error.contains("new class A already exists"), "{error}"); + let error = classes( + r#"[{ "tag": "v1", "new_classes": ["A", "B"] }, + { "tag": "v2", "renamed_classes": [{ "from": "A", "to": "B" }] }]"#, + ) + .unwrap_err(); + assert!( + error.contains("renamed-to class B already exists"), + "{error}" + ); +} + +#[test] +fn sample_parses() { + let src = include_str!("../../../../samples/wrangler/wrangler.jsonc"); + let w: Wrangler = serde_json::from_str(&strip_jsonc(src)).unwrap(); + assert_eq!( + do_classes(&w).unwrap(), + [("Counter".to_owned(), "Counter".to_owned(), true)] + ); +} + +#[test] +fn unsupported_field_is_an_error() { + let error = load_project(&[ + ( + "wrangler.jsonc", + r#"{ "main": "index.js", "compatibility_date": "2026-01-01", + "kv_namespaces": [{ "binding": "KV", "id": "x" }] }"#, + ), + ("index.js", ""), + ]) + .err() + .unwrap(); + assert!(error.contains("kv_namespaces"), "{error}"); +} + +#[test] +fn unbundled_typescript_main_is_an_error() { + let error = load_project(&[ + ( + "wrangler.jsonc", + r#"{ "main": "src/index.ts", "compatibility_date": "2026-01-01", "no_bundle": true }"#, + ), + ("src/index.ts", ""), + ]) + .err() + .unwrap(); + assert!(error.contains("no_bundle"), "{error}"); +} + +/// Held by every test that spawns a process. Writing the stub esbuild while another thread +/// forks would leave the fork holding it open for writing, and running it would fail (ETXTBSY). +static SPAWN: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn missing_esbuild_is_explained() { + let _spawn = SPAWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = project(&[("index.ts", "export default {}")]); + let error = bundle(Path::new("/nonexistent/esbuild"), &dir, "index.ts", &|_| { + Ok(()) + }) + .err() + .unwrap(); + assert!(error.contains("esbuild not found"), "{error}"); + assert!(error.contains("no_bundle"), "{error}"); +} + +/// A stand-in for esbuild: a shell script running `body` with `$out` and `$meta` set from +/// `--outdir=` and `--metafile=`, and its arguments in `args.txt`. +#[cfg(unix)] +fn stub_esbuild(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join("esbuild.sh"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > {args}\nfor a; do case $a in \ + --outdir=*) out=${{a#*=}};; --metafile=*) meta=${{a#*=}};; esac; done\n{body}\n", + args = dir.join("args.txt").display() + ); + fs::write(&path, script).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +#[cfg(unix)] +#[test] +fn bundle_reads_esbuild_output_and_watches_inputs() { + let _spawn = SPAWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = project(&[ + ("index.ts", ""), + ("lib.ts", ""), + ("node_modules/x/index.js", ""), + ]); + let esbuild = stub_esbuild( + &dir, + r#"echo 'export default {}' > "$out/index.js"; echo wasm > "$out/a.wasm" +echo '{"inputs":{"index.ts":{},"lib.ts":{},"node_modules/x/index.js":{}}}' > "$meta""#, + ); + let watched = std::cell::RefCell::new(Vec::new()); + let sources = bundle(&esbuild, &dir, "index.ts", &|p| { + watched.borrow_mut().push(p.to_owned()); + Ok(()) + }) + .unwrap(); + let names: Vec<_> = sources.iter().map(|(n, _, _)| n.as_str()).collect(); + assert_eq!(names, ["index.js", "a.wasm"]); + assert!(watched.borrow().contains(&dir.join("index.ts"))); + // Only the metafile names `lib.ts`. + assert!(watched.borrow().contains(&dir.join("lib.ts"))); + assert!( + !watched + .borrow() + .iter() + .any(|p| p.starts_with(dir.join("node_modules"))) + ); + let args = fs::read_to_string(dir.join("args.txt")).unwrap(); + for arg in [ + "index.ts", + "--bundle", + "--external:node:*", + "--external:fs/*", + ] { + assert!(args.lines().any(|l| l == arg), "{arg} not in {args}"); + } + assert!(args.contains("--banner:js=var require"), "{args}"); + assert!(!dir.join(".wrangler/tmp").read_dir().unwrap().any(|_| true)); +} + +#[cfg(unix)] +#[test] +fn failed_bundle_watches_sources_and_missing_main() { + let _spawn = SPAWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = project(&[("lib.ts", ""), ("README.md", "")]); + let esbuild = stub_esbuild(&dir, "echo 'syntax error' >&2; exit 1"); + let watched = std::cell::RefCell::new(Vec::new()); + let error = bundle(&esbuild, &dir, "index.ts", &|p| { + watched.borrow_mut().push(p.to_owned()); + Ok(()) + }) + .err() + .unwrap(); + assert!(error.contains("syntax error"), "{error}"); + watched.borrow_mut().sort(); + assert_eq!( + *watched.borrow(), + [dir.join("index.ts"), dir.join("lib.ts")] + ); +} diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs new file mode 100644 index 00000000000..c0b6ac54769 --- /dev/null +++ b/src/workerd/server/cli/wrangler.rs @@ -0,0 +1,535 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! A `wrangler.json` / `wrangler.jsonc` config, translated into a workerd `Config`. +//! +//! As wrangler does, `main` is bundled with esbuild (`WORKERD_ESBUILD`, else the project's +//! `node_modules/.bin/esbuild`, else `esbuild` on `PATH`), with the same platform, conditions and +//! externals; every input esbuild read outside `node_modules` is watched. Under `no_bundle: true`, +//! `main` must be an ES module and the modules it imports are found by scanning its directory with +//! wrangler's default module rules. Supported fields: `name`, `main`, `compatibility_date`, +//! `compatibility_flags`, `vars`, `durable_objects`, `migrations`, `no_bundle` and `$schema`. Any +//! other field is an error rather than silently ignored. +//! +//! Durable Objects are stored on disk under `.wrangler/state/workerd/` next to the config, one +//! directory per class, named `-` by its storage key (see [`do_classes`]). The server +//! listens on `localhost:8787`, as `wrangler dev` does; `--socket-addr http=` overrides it. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::Value; +use workerd_capnp::config; + +use crate::config::Config; +use crate::config::words_from_bytes; +use crate::process::Process; + +const DEFAULT_ADDRESS: &str = "localhost:8787"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Wrangler { + #[serde(rename = "$schema")] + _schema: Option, + name: Option, + main: String, + compatibility_date: String, + #[serde(default)] + compatibility_flags: Vec, + #[serde(default)] + vars: BTreeMap, + durable_objects: Option, + #[serde(default)] + migrations: Vec, + #[serde(default)] + no_bundle: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DurableObjects { + bindings: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DurableObjectBinding { + name: String, + class_name: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Migration { + #[serde(rename = "tag")] + _tag: Option, + #[serde(default)] + new_classes: Vec, + #[serde(default)] + new_sqlite_classes: Vec, + #[serde(default)] + renamed_classes: Vec, + #[serde(default)] + deleted_classes: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Rename { + from: String, + to: String, +} + +enum Kind { + EsModule, + CommonJs, + Wasm, + Text, + Data, +} + +/// wrangler's default module rules, by extension. +fn kind_of(path: &Path) -> Option { + match path.extension()?.to_str()? { + "js" | "mjs" => Some(Kind::EsModule), + "cjs" => Some(Kind::CommonJs), + "wasm" => Some(Kind::Wasm), + "txt" | "html" | "sql" => Some(Kind::Text), + "bin" => Some(Kind::Data), + _ => None, + } +} + +pub fn is_wrangler_config(path: &str) -> bool { + let ext = Path::new(path).extension().unwrap_or_default(); + ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("jsonc") +} + +pub fn load(path: &str, process: &Process) -> Result { + let watch = |p: &Path| { + process + .watch_file(p.as_os_str().as_encoded_bytes()) + .map_err(|e| e.description().to_owned()) + }; + let file = fs::canonicalize(path).map_err(|e| e.to_string())?; + watch(&file)?; + let source = fs::read_to_string(&file).map_err(|e| e.to_string())?; + let wrangler: Wrangler = + serde_json::from_str(&strip_jsonc(&source)).map_err(|e| e.to_string())?; + let root = file.parent().unwrap_or(&file); + + let sources = if wrangler.no_bundle { + unbundled(root, &wrangler.main, &watch)? + } else { + bundle(&esbuild(root), root, &wrangler.main, &watch)? + }; + + let classes = do_classes(&wrangler)?; + let name = wrangler.name.clone().unwrap_or_else(|| "main".to_owned()); + let storage = root.join(".wrangler/state/workerd"); + if !classes.is_empty() { + fs::create_dir_all(&storage).map_err(|e| format!("{}: {e}", storage.display()))?; + } + + let mut message = capnp::message::Builder::new_default(); + let mut conf = message.init_root::(); + let mut services = conf + .reborrow() + .init_services(if classes.is_empty() { 1 } else { 2 }); + + let mut service = services.reborrow().get(0); + service.set_name(&name); + let mut worker = service.init_worker(); + worker.set_compatibility_date(&wrangler.compatibility_date); + worker + .set_compatibility_flags(&wrangler.compatibility_flags[..]) + .map_err(|e| e.to_string())?; + + let mut ms = worker.reborrow().init_modules(sources.len() as u32); + for (i, (module_name, kind, bytes)) in sources.iter().enumerate() { + let mut m = ms.reborrow().get(i as u32); + m.set_name(module_name); + let text = || std::str::from_utf8(bytes).map_err(|_| format!("{module_name}: not UTF-8")); + match kind { + Kind::EsModule => m.set_es_module(text()?), + Kind::CommonJs => m.set_common_js_module(text()?), + Kind::Text => m.set_text(text()?), + Kind::Wasm => m.set_wasm(bytes), + Kind::Data => m.set_data(bytes), + } + } + + let do_bindings = wrangler + .durable_objects + .as_ref() + .map_or(&[][..], |d| &d.bindings); + let mut bs = worker + .reborrow() + .init_bindings((wrangler.vars.len() + do_bindings.len()) as u32); + for (i, (k, v)) in wrangler.vars.iter().enumerate() { + let mut b = bs.reborrow().get(i as u32); + b.set_name(k); + match v { + Value::String(s) => b.set_text(s), + other => b.set_json(other.to_string()), + } + } + for (i, d) in do_bindings.iter().enumerate() { + let mut b = bs.reborrow().get((wrangler.vars.len() + i) as u32); + b.set_name(&d.name); + b.init_durable_object_namespace() + .set_class_name(&d.class_name); + } + + if !classes.is_empty() { + let mut ns = worker + .reborrow() + .init_durable_object_namespaces(classes.len() as u32); + for (i, (class, key, sql)) in classes.iter().enumerate() { + let mut n = ns.reborrow().get(i as u32); + n.set_class_name(class); + n.set_unique_key(format!("{name}-{key}")); + n.set_enable_sql(*sql); + } + // Named after the worker, so it cannot collide with it. + let storage_service = format!("{name}-do-storage"); + worker + .init_durable_object_storage() + .set_local_disk(&storage_service); + let mut disk_service = services.get(1); + disk_service.set_name(&storage_service); + let mut disk = disk_service.init_disk(); + disk.set_path(storage.to_string_lossy()); + disk.set_writable(true); + } + + let mut socket = conf.init_sockets(1).get(0); + socket.set_name("http"); + socket.set_address(DEFAULT_ADDRESS); + socket.reborrow().init_http(); + socket.init_service().set_name(&name); + + Config::from_words(&words_from_bytes( + &capnp::serialize::write_message_to_words(&message), + )) + .map_err(|e| e.to_string()) +} + +type Source = (String, Kind, Vec); +type Watch<'a> = &'a dyn Fn(&Path) -> Result<(), String>; + +/// `main` and the modules beside it, as files (`no_bundle`). +fn unbundled(root: &Path, main: &str, watch: Watch) -> Result, String> { + let main_path = root.join(main); + if !matches!(kind_of(&main_path), Some(Kind::EsModule)) { + return Err(format!( + "main: {main}: must be a .js or .mjs ES module under \"no_bundle\"" + )); + } + let base = main_path.parent().unwrap_or(root); + let others = files(base).into_iter().filter(|p| *p != main_path); + std::iter::once(main_path.clone()) + .chain(others) + .filter_map(|m| Some((kind_of(&m)?, m))) + .map(|(kind, m)| { + watch(&m)?; + let name = m.strip_prefix(base).unwrap_or(&m).to_string_lossy(); + let bytes = fs::read(&m).map_err(|e| format!("{}: {e}", m.display()))?; + Ok((name.replace('\\', "/"), kind, bytes)) + }) + .collect() +} + +/// The esbuild to run: `WORKERD_ESBUILD`, else the nearest `node_modules/.bin/esbuild` at or +/// above the project, else `esbuild` on `PATH`. +fn esbuild(root: &Path) -> PathBuf { + if let Some(path) = std::env::var_os("WORKERD_ESBUILD") { + return path.into(); + } + root.ancestors() + .map(|dir| dir.join("node_modules/.bin/esbuild")) + .find(|path| path.is_file()) + .unwrap_or_else(|| "esbuild".into()) +} + +/// Node's builtin modules, which workerd provides under `nodejs_compat`. esbuild leaves them, +/// their subpaths and their `node:` spellings external, as wrangler does. +#[rustfmt::skip] +const NODE_BUILTINS: &[&str] = &[ + "assert", "async_hooks", "buffer", "child_process", "cluster", "console", "constants", + "crypto", "dgram", "diagnostics_channel", "dns", "domain", "events", "fs", "http", "http2", + "https", "inspector", "module", "net", "os", "path", "perf_hooks", "process", "punycode", + "querystring", "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", + "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", +]; + +/// esbuild leaves a CJS dependency's `require()` of an external builtin behind its +/// `__require` helper, which throws in a module Worker. wrangler rewrites those calls with a +/// plugin the esbuild CLI cannot run, so the bundle gets a `require` that resolves builtins. +const REQUIRE_BRIDGE: &str = r#"var require = (id) => { + const builtin = process.getBuiltinModule(String(id)); + if (builtin === undefined) { + const error = new Error(`Cannot find module '${id}'`); + error.code = "MODULE_NOT_FOUND"; + throw error; + } + return builtin; +};"#; + +/// Bundles `main` with esbuild into an ES module plus the files its `copy` loader emits +/// (`.wasm`), watching every input esbuild read outside `node_modules`. +fn bundle(esbuild: &Path, root: &Path, main: &str, watch: Watch) -> Result, String> { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let out = root.join(format!( + ".wrangler/tmp/bundle-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&out); + fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?; + let result = run_esbuild(esbuild, root, main, &out, watch); + let _ = fs::remove_dir_all(&out); + result +} + +fn run_esbuild( + esbuild: &Path, + root: &Path, + main: &str, + out: &Path, + watch: Watch, +) -> Result, String> { + // Watched up front: if `main` is missing, creating it reloads. + watch(&root.join(main))?; + let metafile = out.join("meta.json"); + let output = std::process::Command::new(esbuild) + .current_dir(root) + .arg(main) + .args([ + "--bundle", + "--format=esm", + "--platform=browser", + "--target=es2024", + "--conditions=workerd,worker,browser", + "--entry-names=index", + "--log-level=warning", + "--external:node:*", + "--external:cloudflare:*", + "--external:workerd:*", + "--loader:.wasm=copy", + "--loader:.txt=text", + "--loader:.html=text", + "--loader:.sql=text", + "--loader:.bin=binary", + ]) + .args( + NODE_BUILTINS + .iter() + .flat_map(|m| [format!("--external:{m}"), format!("--external:{m}/*")]), + ) + .arg(format!("--banner:js={REQUIRE_BRIDGE}")) + .arg(format!("--outdir={}", out.display())) + .arg(format!("--metafile={}", metafile.display())) + .output() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => format!( + "esbuild not found ({}). wrangler configs are bundled with esbuild: install it \ + (`npm i -D esbuild`), set WORKERD_ESBUILD to its path, or set \"no_bundle\": true.", + esbuild.display() + ), + _ => format!("{}: {e}", esbuild.display()), + })?; + if !output.status.success() { + // No metafile names the inputs, so watch the project's sources: fixing the error reloads. + const SOURCES: &[&str] = &["js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx", "json"]; + for p in files(root) { + if p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| SOURCES.contains(&e)) + { + watch(&p)?; + } + } + return Err(format!( + "esbuild failed:\n{}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + + let meta: Value = serde_json::from_slice(&fs::read(&metafile).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + for input in meta["inputs"] + .as_object() + .into_iter() + .flat_map(|m| m.keys()) + { + let path = root.join(input); + if !input.contains("node_modules/") && path.is_file() { + watch(&path)?; + } + } + + let read = |p: &Path| fs::read(p).map_err(|e| format!("{}: {e}", p.display())); + let mut sources = vec![( + "index.js".to_owned(), + Kind::EsModule, + read(&out.join("index.js"))?, + )]; + let mut emitted: Vec = fs::read_dir(out) + .map_err(|e| e.to_string())? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| matches!(kind_of(p), Some(Kind::Wasm))) + .collect(); + emitted.sort(); + for p in emitted { + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + sources.push((name, Kind::Wasm, read(&p)?)); + } + Ok(sources) +} + +/// Every file under `dir`, outside `node_modules` and dot-directories, in a stable order. +fn files(dir: &Path) -> Vec { + let mut entries: Vec<_> = fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + !name.starts_with('.') && name != "node_modules" + }) + // `file_type` does not follow symlinks, so a symlink cycle cannot recurse. + .map(|e| (e.path(), e.file_type().is_ok_and(|t| t.is_dir()))) + .collect(); + entries.sort(); + entries + .into_iter() + .flat_map(|(p, dir)| if dir { files(&p) } else { vec![p] }) + .collect() +} + +/// The Durable Object classes, each with its storage key and whether it is SQLite-backed, from +/// `migrations` applied in order, plus any bound class the migrations never mention (not +/// SQLite-backed, as in wrangler). A class keeps its key across renames, and a key is never +/// reused: a class created under a name an earlier class was keyed by (since deleted or renamed) +/// gets `~` and starts empty, as in production. +fn do_classes(w: &Wrangler) -> Result, String> { + let mut classes: Vec<(String, String, bool)> = Vec::new(); + let mut used: Vec = Vec::new(); + let mut key = |c: &str| { + let k = std::iter::once(c.to_owned()) + .chain((1..=used.len()).map(|n| format!("{c}~{n}"))) + .find(|k| !used.contains(k)) + .unwrap_or_default(); + used.push(k.clone()); + k + }; + for m in &w.migrations { + // In wrangler's order: deletes, then renames, then new classes. + let find = |classes: &[(String, String, bool)], c: &str, what: &str| { + let i = classes.iter().position(|(n, _, _)| n == c); + i.ok_or_else(|| format!("migrations: {what} class {c} does not exist")) + }; + for c in &m.deleted_classes { + classes.remove(find(&classes, c, "deleted")?); + } + for r in &m.renamed_classes { + let i = find(&classes, &r.from, "renamed")?; + if find(&classes, &r.to, "").is_ok() { + return Err(format!( + "migrations: renamed-to class {} already exists", + r.to + )); + } + classes[i].0.clone_from(&r.to); + } + let new = m.new_classes.iter().map(|c| (c, false)); + for (c, sql) in new.chain(m.new_sqlite_classes.iter().map(|c| (c, true))) { + if find(&classes, c, "new").is_ok() { + return Err(format!("migrations: new class {c} already exists")); + } + classes.push((c.clone(), key(c), sql)); + } + } + for b in w.durable_objects.iter().flat_map(|d| &d.bindings) { + if !classes.iter().any(|(c, _, _)| *c == b.class_name) { + classes.push((b.class_name.clone(), key(&b.class_name), false)); + } + } + Ok(classes) +} + +/// Removes `//` and `/* */` comments and trailing commas outside strings, so `serde_json` can +/// parse JSONC. A comment becomes a space; a comma is held until the next token shows whether it +/// trails. +fn strip_jsonc(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut comma = false; + let mut chars = src.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '/' if chars.peek() == Some(&'/') => { + while chars.next_if(|&c| c != '\n').is_some() {} + out.push(' '); + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut prev = '\0'; + for c in chars.by_ref() { + if prev == '*' && c == '/' { + break; + } + prev = c; + } + out.push(' '); + } + c if c.is_whitespace() => out.push(c), + ',' => { + if comma { + out.push(','); + } + comma = true; + } + _ => { + if comma && !matches!(c, '}' | ']') { + out.push(','); + } + comma = false; + out.push(c); + if c == '"' { + while let Some(c) = chars.next() { + out.push(c); + match c { + '\\' => out.extend(chars.next()), + '"' => break, + _ => {} + } + } + } + } + } + } + if comma { + out.push(','); + } + out +} + +#[cfg(test)] +#[path = "wrangler-test.rs"] +mod tests;