From 25ef73c206137e356df411c7cd1961cffc3f8fc2 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Tue, 27 Oct 2026 16:12:46 -0700 Subject: [PATCH 1/9] Accept wrangler.json(c) as a config `workerd serve wrangler.jsonc` translates a wrangler config into a Config: name, main, compatibility_date/flags, vars, durable_objects and migrations. main is bundled with esbuild the way wrangler does it (WORKERD_ESBUILD, else node_modules/.bin/esbuild, else PATH), or scanned as plain modules under "no_bundle". DO storage goes in .wrangler/state/workerd. --watch reloads on changes to the config or to any bundled input. Unsupported fields are an error. --- src/workerd/server/AGENTS.md | 1 + src/workerd/server/BUILD.bazel | 2 + src/workerd/server/cli/args.rs | 2 + src/workerd/server/cli/lib.rs | 16 +- src/workerd/server/cli/wrangler-test.rs | 194 +++++++++ src/workerd/server/cli/wrangler.rs | 497 ++++++++++++++++++++++++ 6 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 src/workerd/server/cli/wrangler-test.rs create mode 100644 src/workerd/server/cli/wrangler.rs diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index 6404c3fdbf7..b48ae79015b 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..8e9a9b46cc4 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -92,6 +92,8 @@ wd_rust_crate( "//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 86213e3e0a8..238c92c8d60 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -220,6 +220,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 d6e8714f5f7..37cb4cbb31f 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; @@ -354,6 +355,13 @@ 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.")); + } + return wrangler::load(&config_file, process) + .unwrap_or_else(|error| config_errors(&[format!("{config_file}: {error}")], process)); + } let compiled = ffi::compile_config( &config_file, @@ -367,7 +375,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..6d739ad2adc --- /dev/null +++ b/src/workerd/server/cli/wrangler-test.rs @@ -0,0 +1,194 @@ +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(), "*: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": ["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 ns = worker.get_durable_object_namespaces().unwrap().get(0); + assert_eq!(ns.get_class_name().unwrap(), "Counter"); + assert!(ns.get_enable_sql()); + assert_eq!(services.get(1).get_name().unwrap(), STORAGE_SERVICE); +} + +#[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(), false), + ("S".to_owned(), true), + ("Bound".to_owned(), false) + ] + ); +} + +#[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}"); +} + +#[test] +fn missing_esbuild_is_explained() { + 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}"); +} diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs new file mode 100644 index 00000000000..4c07130658d --- /dev/null +++ b/src/workerd/server/cli/wrangler.rs @@ -0,0 +1,497 @@ +//! 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 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` and `no_bundle`. 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 (`-`). The server listens on `*: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 STORAGE_SERVICE: &str = "wrangler-do-storage"; +const DEFAULT_ADDRESS: &str = "*: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, 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}-{class}")); + n.set_enable_sql(*sql); + } + 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 = std::env::temp_dir().join(format!( + "workerd-esbuild-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + 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> { + 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) + .map(|e| e.path()) + .filter(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + !name.starts_with('.') && name != "node_modules" + }) + .collect(); + entries.sort(); + entries + .into_iter() + .flat_map(|p| if p.is_dir() { files(&p) } else { vec![p] }) + .collect() +} + +/// The Durable Object classes and whether each is SQLite-backed, from `migrations` applied in +/// order, plus any bound class the migrations never mention (not SQLite-backed, as in wrangler). +fn do_classes(w: &Wrangler) -> Result, String> { + let mut classes: Vec<(String, bool)> = Vec::new(); + for m in &w.migrations { + classes.extend(m.new_classes.iter().map(|c| (c.clone(), false))); + classes.extend(m.new_sqlite_classes.iter().map(|c| (c.clone(), true))); + for r in &m.renamed_classes { + let entry = classes + .iter_mut() + .find(|(c, _)| *c == r.from) + .ok_or_else(|| format!("migrations: renamed class {} was never created", r.from))?; + entry.0.clone_from(&r.to); + } + classes.retain(|(c, _)| !m.deleted_classes.contains(c)); + } + 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(), 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; From a65a96caa0d67a5802a1ae6c9a396c1884511a39 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Tue, 27 Oct 2026 16:21:58 -0700 Subject: [PATCH 2/9] Address review: bundle dir, DO renames, bundling tests - Bundle into .wrangler/tmp in the project, not a predictable path in the shared temp dir. - Key a Durable Object's storage by the name it was created under, so renamed_classes keeps its data. - files() no longer follows symlinks, so a cycle cannot recurse. - Test bundling, success and failure, against a stub esbuild. - Mention wrangler configs in the README. --- README.md | 5 ++ src/workerd/server/cli/wrangler-test.rs | 70 +++++++++++++++++++++++-- src/workerd/server/cli/wrangler.rs | 47 ++++++++++------- 3 files changed, 100 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 55830553b6c..5ab853ace7b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ 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` and `no_bundle`; any other field is an error. + A simple "Hello World!" config file might look like: ```capnp diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index 6d739ad2adc..db91689d074 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -145,9 +145,9 @@ fn migrations_rename_and_delete() { assert_eq!( do_classes(&w).unwrap(), [ - ("A2".to_owned(), false), - ("S".to_owned(), true), - ("Bound".to_owned(), false) + ("A2".to_owned(), "A".to_owned(), false), + ("S".to_owned(), "S".to_owned(), true), + ("Bound".to_owned(), "Bound".to_owned(), false) ] ); } @@ -192,3 +192,67 @@ fn missing_esbuild_is_explained() { 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 dir = project(&[("index.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":{},"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_eq!(*watched.borrow(), [dir.join("index.ts")]); + 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() { + let dir = project(&[("index.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}"); + assert_eq!(*watched.borrow(), [dir.join("index.ts")]); +} diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index 4c07130658d..b80b57da615 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -2,7 +2,7 @@ //! //! 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 is watched. Under `no_bundle: true`, `main` must be an ES +//! 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` and `no_bundle`. Any other field is an error rather @@ -187,10 +187,10 @@ pub fn load(path: &str, process: &Process) -> Result { let mut ns = worker .reborrow() .init_durable_object_namespaces(classes.len() as u32); - for (i, (class, sql)) in classes.iter().enumerate() { + 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}-{class}")); + n.set_unique_key(format!("{name}-{key}")); n.set_enable_sql(*sql); } worker @@ -283,11 +283,13 @@ fn bundle(esbuild: &Path, root: &Path, main: &str, watch: Watch) -> Result Vec { .into_iter() .flatten() .filter_map(Result::ok) - .map(|e| e.path()) - .filter(|p| { - let name = p.file_name().unwrap_or_default().to_string_lossy(); + .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| if p.is_dir() { files(&p) } else { vec![p] }) + .flat_map(|(p, dir)| if dir { files(&p) } else { vec![p] }) .collect() } -/// The Durable Object classes and whether each is SQLite-backed, from `migrations` applied in -/// order, plus any bound class the migrations never mention (not SQLite-backed, as in wrangler). -fn do_classes(w: &Wrangler) -> Result, String> { - let mut classes: Vec<(String, bool)> = Vec::new(); +/// The Durable Object classes, each with the name it was created under (its storage key, kept +/// across renames) 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). +fn do_classes(w: &Wrangler) -> Result, String> { + let mut classes: Vec<(String, String, bool)> = Vec::new(); for m in &w.migrations { - classes.extend(m.new_classes.iter().map(|c| (c.clone(), false))); - classes.extend(m.new_sqlite_classes.iter().map(|c| (c.clone(), true))); + classes.extend(m.new_classes.iter().map(|c| (c.clone(), c.clone(), false))); + classes.extend( + m.new_sqlite_classes + .iter() + .map(|c| (c.clone(), c.clone(), true)), + ); for r in &m.renamed_classes { let entry = classes .iter_mut() - .find(|(c, _)| *c == r.from) + .find(|(c, _, _)| *c == r.from) .ok_or_else(|| format!("migrations: renamed class {} was never created", r.from))?; entry.0.clone_from(&r.to); } - classes.retain(|(c, _)| !m.deleted_classes.contains(c)); + classes.retain(|(c, _, _)| !m.deleted_classes.contains(c)); } 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(), false)); + if !classes.iter().any(|(c, _, _)| *c == b.class_name) { + classes.push((b.class_name.clone(), b.class_name.clone(), false)); } } Ok(classes) From 38fd14ac6a8c8609f60c0fb2555aa216f7b4b9aa Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Tue, 27 Oct 2026 19:39:38 -0700 Subject: [PATCH 3/9] Apply DO migrations in wrangler's order, with its checks Within a migration, apply deleted_classes, then renamed_classes, then new classes, as wrangler does, so a class can be deleted and re-created in one migration. Reject deleting or renaming a class that does not exist, and creating one that already does. --- src/workerd/server/cli/wrangler-test.rs | 24 ++++++++++++++++++++ src/workerd/server/cli/wrangler.rs | 29 +++++++++++++++---------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index db91689d074..c2f543c3293 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -152,6 +152,30 @@ fn migrations_rename_and_delete() { ); } +#[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".to_owned(), true)] + ); + 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}"); +} + #[test] fn unsupported_field_is_an_error() { let error = load_project(&[ diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index b80b57da615..ad3917f7dce 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -422,20 +422,25 @@ fn files(dir: &Path) -> Vec { fn do_classes(w: &Wrangler) -> Result, String> { let mut classes: Vec<(String, String, bool)> = Vec::new(); for m in &w.migrations { - classes.extend(m.new_classes.iter().map(|c| (c.clone(), c.clone(), false))); - classes.extend( - m.new_sqlite_classes - .iter() - .map(|c| (c.clone(), c.clone(), true)), - ); + // 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 entry = classes - .iter_mut() - .find(|(c, _, _)| *c == r.from) - .ok_or_else(|| format!("migrations: renamed class {} was never created", r.from))?; - entry.0.clone_from(&r.to); + let i = find(&classes, &r.from, "renamed")?; + 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(), c.clone(), sql)); } - classes.retain(|(c, _, _)| !m.deleted_classes.contains(c)); } for b in w.durable_objects.iter().flat_map(|d| &d.bindings) { if !classes.iter().any(|(c, _, _)| *c == b.class_name) { From b65c5d4cacf6d5ccc231661cbd572ba9e0be6575 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 06:20:35 -0700 Subject: [PATCH 4/9] Address review: DO storage keys, missing main, test coverage - Never reuse a Durable Object storage key: a class created under a name an earlier class was keyed by (deleted, or renamed away) gets ~ and starts empty, as in production. Previously a renamed class and a new class under its old name shared storage. - Name the DO disk service after the worker, so a worker named wrangler-do-storage no longer collides with it. - Watch main before bundling, so creating a missing main reloads. - Serialize the tests that spawn processes, so the stub esbuild cannot fail with ETXTBSY. - Test the DO binding, the uniqueKey a renamed class keeps, and that deletes apply before renames. - List $schema among the README's supported fields. --- README.md | 3 +- src/workerd/server/cli/wrangler-test.rs | 69 ++++++++++++++++++++++--- src/workerd/server/cli/wrangler.rs | 30 ++++++++--- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5ab853ace7b..8204b119d7b 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ bazel build --config=thin-lto //src/workerd/server:workerd 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` and `no_bundle`; any other field is an error. +`durable_objects`, `migrations`, `no_bundle` and `$schema`; any other field +is an error. A simple "Hello World!" config file might look like: diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index c2f543c3293..b5b61f5d6f3 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -110,7 +110,10 @@ fn durable_objects() { "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["Old"] }, + { "tag": "v2", "renamed_classes": [{ "from": "Old", "to": "Counter" }] }, + ], }"#, ), ("index.js", "export class Counter {}"), @@ -124,10 +127,21 @@ fn durable_objects() { 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(), STORAGE_SERVICE); + assert_eq!(services.get(1).get_name().unwrap(), "counter-do-storage"); } #[test] @@ -167,8 +181,26 @@ fn migrations_follow_wranglers_order_and_checks() { { "tag": "v2", "deleted_classes": ["A"], "new_sqlite_classes": ["A"] }]"#; assert_eq!( classes(recreated).unwrap(), - [("A".to_owned(), "A".to_owned(), true)] + [("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"] }]"#) @@ -205,8 +237,15 @@ fn unbundled_typescript_main_is_an_error() { 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(()) @@ -237,6 +276,9 @@ fn stub_esbuild(dir: &Path, body: &str) -> PathBuf { #[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", ""), ("node_modules/x/index.js", "")]); let esbuild = stub_esbuild( &dir, @@ -251,7 +293,13 @@ echo '{"inputs":{"index.ts":{},"node_modules/x/index.js":{}}}' > "$meta""#, .unwrap(); let names: Vec<_> = sources.iter().map(|(n, _, _)| n.as_str()).collect(); assert_eq!(names, ["index.js", "a.wasm"]); - assert_eq!(*watched.borrow(), [dir.join("index.ts")]); + assert!(watched.borrow().contains(&dir.join("index.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", @@ -267,8 +315,11 @@ echo '{"inputs":{"index.ts":{},"node_modules/x/index.js":{}}}' > "$meta""#, #[cfg(unix)] #[test] -fn failed_bundle_watches_sources() { - let dir = project(&[("index.ts", ""), ("README.md", "")]); +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| { @@ -278,5 +329,9 @@ fn failed_bundle_watches_sources() { .err() .unwrap(); assert!(error.contains("syntax error"), "{error}"); - assert_eq!(*watched.borrow(), [dir.join("index.ts")]); + 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 index ad3917f7dce..87ca3b96e70 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -25,7 +25,6 @@ use crate::config::Config; use crate::config::words_from_bytes; use crate::process::Process; -const STORAGE_SERVICE: &str = "wrangler-do-storage"; const DEFAULT_ADDRESS: &str = "*:8787"; #[derive(Deserialize)] @@ -193,11 +192,13 @@ pub fn load(path: &str, process: &Process) -> Result { 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); + .set_local_disk(&storage_service); let mut disk_service = services.get(1); - disk_service.set_name(STORAGE_SERVICE); + disk_service.set_name(&storage_service); let mut disk = disk_service.init_disk(); disk.set_path(storage.to_string_lossy()); disk.set_writable(true); @@ -302,6 +303,8 @@ fn run_esbuild( 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) @@ -416,11 +419,22 @@ fn files(dir: &Path) -> Vec { .collect() } -/// The Durable Object classes, each with the name it was created under (its storage key, kept -/// across renames) 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). +/// 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| { @@ -439,12 +453,12 @@ fn do_classes(w: &Wrangler) -> Result, String> { if find(&classes, c, "new").is_ok() { return Err(format!("migrations: new class {c} already exists")); } - classes.push((c.clone(), c.clone(), sql)); + 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(), b.class_name.clone(), false)); + classes.push((b.class_name.clone(), key(&b.class_name), false)); } } Ok(classes) From f6808fd84dd5132edb073e6c1507b4acabe91e09 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 06:26:39 -0700 Subject: [PATCH 5/9] Add the license header to the new Rust files --- src/workerd/server/cli/wrangler-test.rs | 4 ++++ src/workerd/server/cli/wrangler.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index b5b61f5d6f3..401d4068d31 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -1,3 +1,7 @@ +// 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; diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index 87ca3b96e70..3ef6cfcb2b3 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -1,3 +1,7 @@ +// 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 From 9dbcdd245b6124c5efb5f8da287eff2123043964 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 07:10:09 -0700 Subject: [PATCH 6/9] Address review: reject renames onto existing classes, docs - Reject renaming a Durable Object class onto a name that exists. - Test that bundling watches inputs only the metafile names. - Module doc: name storage directories by key and list $schema; the root AGENTS.md mentions wrangler configs. --- AGENTS.md | 3 ++- src/workerd/server/cli/wrangler-test.rs | 19 +++++++++++++++++-- src/workerd/server/cli/wrangler.rs | 18 ++++++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) 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/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index 401d4068d31..798585e3078 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -210,6 +210,15 @@ fn migrations_follow_wranglers_order_and_checks() { 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] @@ -283,11 +292,15 @@ 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", ""), ("node_modules/x/index.js", "")]); + 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":{},"node_modules/x/index.js":{}}}' > "$meta""#, +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| { @@ -298,6 +311,8 @@ echo '{"inputs":{"index.ts":{},"node_modules/x/index.js":{}}}' > "$meta""#, 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() diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index 3ef6cfcb2b3..b5fc8f0de0d 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -6,14 +6,14 @@ //! //! 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` and `no_bundle`. Any other field is an error rather -//! than silently ignored. +//! 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 (`-`). The server listens on `*:8787`, as `wrangler dev` +//! directory per class, named `-` by its storage key (see [`do_classes`]). The server listens on `*:8787`, as `wrangler dev` //! does; `--socket-addr http=` overrides it. use std::collections::BTreeMap; @@ -450,6 +450,12 @@ fn do_classes(w: &Wrangler) -> Result, String> { } 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)); From f8c22add340128381c13a0f9614d1676a8ce3252 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 13:06:29 -0700 Subject: [PATCH 7/9] Add a sample configured with wrangler.jsonc A TypeScript Worker with a SQLite-backed Durable Object, bundled with esbuild. A unit test parses its config, so it cannot drift from what the loader accepts. --- samples/BUILD.bazel | 1 + samples/wrangler/.gitignore | 1 + samples/wrangler/README.md | 32 +++++++++++++++++++++++++ samples/wrangler/src/index.ts | 30 +++++++++++++++++++++++ samples/wrangler/wrangler.jsonc | 11 +++++++++ src/workerd/server/BUILD.bazel | 1 + src/workerd/server/cli/wrangler-test.rs | 10 ++++++++ 7 files changed, 86 insertions(+) create mode 100644 samples/wrangler/.gitignore create mode 100644 samples/wrangler/README.md create mode 100644 samples/wrangler/src/index.ts create mode 100644 samples/wrangler/wrangler.jsonc 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/BUILD.bazel b/src/workerd/server/BUILD.bazel index 8e9a9b46cc4..a736657db34 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -86,6 +86,7 @@ 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", diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index 798585e3078..f850999df0a 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -221,6 +221,16 @@ fn migrations_follow_wranglers_order_and_checks() { ); } +#[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(&[ From ef05e63ed88ccbe4164e2492119fb9a5f1bebba2 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 14:20:53 -0700 Subject: [PATCH 8/9] Fail at once on a missing wrangler config A missing wrangler config was reported as a config error, so --watch waited for it to change but never watched it. Report it as a usage error, as for a missing .capnp config. --- src/workerd/server/cli/lib.rs | 3 +++ src/workerd/server/cli/wrangler.rs | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 7dc28755469..f815e08f82b 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -355,6 +355,9 @@ fn load_config( 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)); } diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index b5fc8f0de0d..a78282875cf 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -13,8 +13,8 @@ //! 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 `*:8787`, as `wrangler dev` -//! does; `--socket-addr http=` overrides it. +//! directory per class, named `-` by its storage key (see [`do_classes`]). The server +//! listens on `*:8787`, as `wrangler dev` does; `--socket-addr http=` overrides it. use std::collections::BTreeMap; use std::fs; From 976e5474cfc4235da35ce7c8315ca1351963358f Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Wed, 28 Oct 2026 14:51:38 -0700 Subject: [PATCH 9/9] Listen on localhost:8787 by default, as wrangler dev does A wrangler config's server listened on every interface (*:8787). wrangler dev listens on localhost; do the same, so a dev server is not reachable from the network unless --socket-addr says so. --- src/workerd/server/cli/wrangler-test.rs | 2 +- src/workerd/server/cli/wrangler.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index f850999df0a..1757907a433 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -96,7 +96,7 @@ fn hello() { 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(), "*:8787"); + assert_eq!(socket.get_address().unwrap(), "localhost:8787"); assert_eq!(socket.get_service().unwrap().get_name().unwrap(), "hello"); } diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index a78282875cf..c0b6ac54769 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -14,7 +14,7 @@ //! //! 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 `*:8787`, as `wrangler dev` does; `--socket-addr http=` overrides it. +//! listens on `localhost:8787`, as `wrangler dev` does; `--socket-addr http=` overrides it. use std::collections::BTreeMap; use std::fs; @@ -29,7 +29,7 @@ use crate::config::Config; use crate::config::words_from_bytes; use crate::process::Process; -const DEFAULT_ADDRESS: &str = "*:8787"; +const DEFAULT_ADDRESS: &str = "localhost:8787"; #[derive(Deserialize)] #[serde(deny_unknown_fields)]