diff --git a/README.md b/README.md index f3cfeb15a9e..db66c6cc153 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,42 @@ To serve your config, do: For more details about command-line usage, use `workerd --help`. +#### Running a file or a Wrangler project without a config + +A single source file can be served with no config file at all: + +``` +workerd run app.js # http://localhost:8080, newest compatibility date, nodejs_compat +workerd run app.ts # TypeScript, with type stripping (experimental) +workerd run app.js --watch # reload when the file or its imports change +``` + +The file is the Worker's entry module, and every `.js`, `.mjs`, `.cjs`, `.ts`, `.json`, `.wasm`, +`.txt` and `.html` file in the same directory tree is available to relative imports +(`node_modules` is skipped, so bundle dependencies first). + +A project with a `wrangler.json` / `wrangler.jsonc` can be served the same way: + +``` +workerd serve wrangler.jsonc +``` + +`main`, `compatibility_date`, `compatibility_flags`, `vars`, `durable_objects` (with +`migrations`), `services` and `dev.port` are honoured. Durable Objects are backed by SQLite and +persisted under `.workerd-state` next to the config (`--persist ` chooses another place). +Bindings that need Cloudflare services (KV, R2, D1, Queues, ...) are reported and skipped. + +`--print-config` prints the config `workerd` synthesized, as a schema file with `embed`s of your +project files. Save it and serve it with `workerd serve` when you outgrow what the shortcut +offers. + +To validate a config without serving it, which reports every problem rather than stopping at the +first and never binds a port, do: + +``` +workerd check my-config.capnp # or wrangler.jsonc, or app.js +``` + Prebuilt binaries are distributed via `npm`. Run `npx workerd ...` to use these. If you're running a prebuilt binary, you'll need to make sure your system has the right dependencies installed: * On Linux: diff --git a/samples/wrangler-project/.gitignore b/samples/wrangler-project/.gitignore new file mode 100644 index 00000000000..daa21bd9ee8 --- /dev/null +++ b/samples/wrangler-project/.gitignore @@ -0,0 +1 @@ +.workerd-state/ diff --git a/samples/wrangler-project/README.md b/samples/wrangler-project/README.md new file mode 100644 index 00000000000..8b453c8d08f --- /dev/null +++ b/samples/wrangler-project/README.md @@ -0,0 +1,28 @@ +# Serving a Wrangler project directly + +This is an ordinary Wrangler project: a `wrangler.jsonc` with a Durable Object class (SQLite +backed), plain `vars`, a service binding to itself, and a KV namespace. It needs no `workerd` +config file: + +``` +workerd serve samples/wrangler-project/wrangler.jsonc +curl http://localhost:8787/room-a +curl http://localhost:8787/room-a +curl http://localhost:8787/self +``` + +The counter survives restarts: Durable Object storage is persisted under +`samples/wrangler-project/.workerd-state`. The KV namespace has no standalone implementation, so +`workerd` reports it and leaves that binding out. + +To see (and keep) the config `workerd` synthesized: + +``` +workerd serve samples/wrangler-project/wrangler.jsonc --print-config > config.capnp +``` + +To validate the project without serving it, which reports every error at once: + +``` +workerd check samples/wrangler-project/wrangler.jsonc +``` diff --git a/samples/wrangler-project/src/index.js b/samples/wrangler-project/src/index.js new file mode 100644 index 00000000000..49f65407d1e --- /dev/null +++ b/samples/wrangler-project/src/index.js @@ -0,0 +1,26 @@ +import { DurableObject } from "cloudflare:workers"; + +export class Counter extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.sql = ctx.storage.sql; + this.sql.exec("CREATE TABLE IF NOT EXISTS hits (n INTEGER)"); + } + async increment() { + this.sql.exec("INSERT INTO hits VALUES (1)"); + return this.sql.exec("SELECT COUNT(*) AS n FROM hits").one().n; + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/self") { + const r = await env.SELF.fetch("http://x/"); + return new Response("via service binding: " + (await r.text())); + } + const id = env.COUNTER.idFromName(url.pathname); + const n = await env.COUNTER.get(id).increment(); + return new Response(`${env.GREETING} (max ${env.LIMITS.max}): ${url.pathname} hit ${n} times\n`); + } +}; diff --git a/samples/wrangler-project/wrangler.jsonc b/samples/wrangler-project/wrangler.jsonc new file mode 100644 index 00000000000..81f4222c543 --- /dev/null +++ b/samples/wrangler-project/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + // A Worker with a Durable Object, exactly as wrangler would deploy it. + "name": "counter", + "main": "src/index.js", + "compatibility_date": "2026-09-01", + "compatibility_flags": ["nodejs_compat"], + "vars": { "GREETING": "Counter service", "LIMITS": { "max": 100 } }, + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], + "kv_namespaces": [{ "binding": "CACHE", "id": "0000" }], + "services": [{ "binding": "SELF", "service": "counter" }], +} diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index d651c5108a8..4b99bac00e5 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -4,7 +4,7 @@ The `workerd` binary and its server. `:workerd` (`workerd.rs`, this package) is a Rust binary over three crates in this directory: -- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, and hands `serve` and `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) to the server crate's `entry` module. +- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, synthesizes a config from a source file or a Wrangler config (`project.rs`: `run`, `serve wrangler.jsonc`, `check`, `--print-config`), and hands `serve`, `check` and `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) to the server crate's `entry` module. - `server/` -- the `workerd-server` crate: the single-tenant server. It interprets the config, builds the service graph, binds the sockets, runs the listeners, actors and dynamic workers, drains on SIGTERM, and runs `workerd test`. `Error` is `cxx::KjError`; `bridge.rs` is the one module that allows `unsafe`. - `factory/` -- C++: `WorkerFactory` (`worker-factory*`), everything that needs the isolate, driven by the server through the cxx bridge in `server/bridge.rs`; and `bootstrap.{h,c++}`, the C++ half of a command. `with_process_context` sets up the process's logging (the JSON logger, when the config asks for structured logging) on the C++ stack around the Rust command; `bootstrap` sets up perfetto, autogates and V8 on the Rust-owned event loop and builds the `WorkerFactory`, which owns them, from the encoded config and the command-line options for `server/entry.rs`, which owns the loop (`kj_rs_tokio::Runtime`) and blocks on `server_serve` / `server_test`. diff --git a/src/workerd/server/BUILD.bazel b/src/workerd/server/BUILD.bazel index 5d120e01fd0..3c68cc7e73e 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -93,6 +93,7 @@ wd_rust_crate( "//src/workerd/server/server:workerd-server", "@crates_vendor//:capnp", "@crates_vendor//:clap", + "@crates_vendor//:serde_json", "@crates_vendor//:socket2", "@crates_vendor//:tokio", ] + select({ diff --git a/src/workerd/server/cli/args-test.rs b/src/workerd/server/cli/args-test.rs index 1d37864ee5b..f145e9e85e5 100644 --- a/src/workerd/server/cli/args-test.rs +++ b/src/workerd/server/cli/args-test.rs @@ -49,6 +49,7 @@ fn serve_options() { const_name, serve_or_test, serve, + .. } = parse(&[ "serve", "--binary", @@ -164,3 +165,43 @@ fn compiled_binary_has_no_config_arguments() { assert_eq!(main.serve.socket_addr_overrides.len(), 1); assert!(CompiledMain::try_parse_from(["workerd", "config.capnp"]).is_err()); } + +#[test] +fn run_invocation() { + let Command::Run { + file, + project, + serve_or_test, + .. + } = parse(&[ + "run", + "app.ts", + "--port=9000", + "--compat-flag=foo", + "--compat-flag=bar", + "--persist", + "--watch", + ]) + .command + else { + panic!("expected run") + }; + assert_eq!(file, "app.ts"); + assert_eq!(project.port, Some(9000)); + assert_eq!(project.compatibility_flags, ["foo", "bar"]); + assert_eq!(project.persist.as_deref(), Some(".workerd-state")); + assert!(!project.print_config); + assert!(serve_or_test.watch); +} + +#[test] +fn check_invocation() { + let Command::Check { + config, project, .. + } = parse(&["check", "wrangler.jsonc", "--print-config"]).command + else { + panic!("expected check") + }; + assert_eq!(config.config_file, "wrangler.jsonc"); + assert!(project.print_config); +} diff --git a/src/workerd/server/cli/args.rs b/src/workerd/server/cli/args.rs index 47e99db4064..f4091f338ad 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -53,7 +53,9 @@ pub enum Command { /// run the server #[command( long_about = "Serve requests based on a config.", - after_long_help = "Serves requests based on the configuration specified in ." + after_long_help = "Serves requests based on the configuration specified in : \ + a Cap'n Proto schema file, an encoded config (--binary), or a Wrangler config \ + (wrangler.json / wrangler.jsonc), from which a config is synthesized." )] Serve { #[command(flatten)] @@ -62,6 +64,31 @@ pub enum Command { #[command(flatten)] const_name: ConstNameArg, + #[command(flatten)] + project: ProjectArgs, + + #[command(flatten)] + serve_or_test: ServeOrTestArgs, + + #[command(flatten)] + serve: ServeArgs, + }, + + /// run a single source file as a Worker, no config needed + #[command( + long_about = "Runs one JavaScript, TypeScript or Python file as a Worker, with no config \ + file.", + after_long_help = RUN_HELP, + )] + Run { + /// The Worker's entry module: a .js, .mjs, .cjs, .ts or .py file. Files beside it are + /// available to relative imports. + #[arg(value_name = "file")] + file: String, + + #[command(flatten)] + project: ProjectArgs, + #[command(flatten)] serve_or_test: ServeOrTestArgs, @@ -69,6 +96,29 @@ pub enum Command { serve: ServeArgs, }, + /// check a config and report every problem, without serving + #[command( + long_about = "Loads a config and reports every problem with it, without serving.", + after_long_help = "Accepts the same config files as `serve`: a Cap'n Proto schema file, \ + an encoded config (--binary), a Wrangler config (wrangler.json / wrangler.jsonc) or a \ + single source file as for `run`. \ + Every service is constructed and every Worker compiled, so errors in a Worker's top \ + level are reported too. Exits with status 1 if any error was found." + )] + Check { + #[command(flatten)] + config: ConfigFileArgs, + + #[command(flatten)] + const_name: ConstNameArg, + + #[command(flatten)] + project: ProjectArgs, + + #[command(flatten)] + serve_or_test: ServeOrTestArgs, + }, + /// create a self-contained binary #[command( long_about = "Builds a self-contained binary from a config.", @@ -136,6 +186,21 @@ pub enum Command { }, } +const RUN_HELP: &str = "\ +Serves as a Worker on http://localhost:8080 (see --port). The file is the Worker's entry \ +module; every .js, .mjs, .cjs, .ts, .json, .wasm, .txt and .html file in the same directory tree \ +is available to relative imports (node_modules is skipped: bundle dependencies first). The Worker \ +gets the newest compatibility date this workerd release supports and nodejs_compat. Example: + export default { + async fetch(request, env) { + return new Response('Hello from workerd'); + } + } +A .ts entry module enables TypeScript type stripping (experimental). A .py entry module runs as a \ +Python Worker. --print-config prints the synthesized config as a schema file, which can be saved \ +next to the code and served with `workerd serve`. +"; + const TEST_HELP: &str = "\ Runs tests for services defined in . , if given, specifies exactly which \ tests to run. It has one of the following formats: @@ -187,6 +252,62 @@ pub struct ConfigFileArgs { pub config_file: String, } +/// Options for a config synthesized from a project: `run`, or `serve` / `check` of a Wrangler +/// config. Ignored for a schema-file config. +#[derive(Args, Debug, Default, Clone, PartialEq, Eq)] +pub struct ProjectArgs { + /// Listen on (default: 8080 for `run`; a Wrangler config's dev.port, or 8787). + #[arg(long, value_name = "port", help_heading = "Project options")] + pub port: Option, + + /// The Worker's compatibility date (default: the Wrangler config's, or this workerd release's + /// date). + #[arg( + long = "compat-date", + value_name = "date", + help_heading = "Project options" + )] + pub compatibility_date: Option, + + /// Add a compatibility flag. May be repeated. + #[arg( + long = "compat-flag", + value_name = "flag", + help_heading = "Project options" + )] + pub compatibility_flags: Vec, + + /// Persist Durable Object storage to (default: .workerd-state) instead of memory. + #[arg( + long, + value_name = "dir", + num_args = 0..=1, + default_missing_value = ".workerd-state", + help_heading = "Project options" + )] + pub persist: Option, + + /// The Worker's name (default: the Wrangler config's `name`, or the file's stem). + #[arg(long, value_name = "name", help_heading = "Project options")] + pub name: Option, + + /// Print the synthesized config as a Cap'n Proto schema file and exit, instead of running it. + #[arg(long, help_heading = "Project options")] + pub print_config: bool, +} + +impl From for crate::project::Overrides { + fn from(args: ProjectArgs) -> Self { + Self { + name: args.name, + port: args.port, + compatibility_date: args.compatibility_date, + compatibility_flags: args.compatibility_flags, + persist: args.persist.map(std::path::PathBuf::from), + } + } +} + #[derive(Args, Debug)] pub struct ConstNameArg { #[arg(value_name = "const-name")] diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 2bc5e08294a..733bfb1b86c 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -14,6 +14,7 @@ mod args; mod bridge; mod config; mod process; +mod project; mod socket_fd; mod watch; @@ -34,6 +35,7 @@ use crate::bridge::ffi; use crate::config::Config; use crate::process::Executable; use crate::process::Process; +use crate::project::Project; use crate::socket_fd::InheritedSocket; use crate::watch::Watcher; @@ -103,6 +105,10 @@ fn run_compiled( ) } +#[expect( + clippy::too_many_lines, + reason = "one match arm per subcommand, each a straight line from options to the server" +)] fn run_command(args: args::Main, executable: Option) -> workerd_server::Result { type T = args::Main; @@ -112,14 +118,60 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv args::Command::Serve { config, const_name, - serve_or_test, + project, + mut serve_or_test, + serve, + } => { + check_serve_or_test::(&serve_or_test); + let watcher = watcher::(&serve_or_test, executable.as_ref()); + let (options, inherited_sockets) = serve_options::(serve, &serve_or_test); + let process = Process::new(executable, watcher, inherited_sockets); + let config = if project::is_wrangler_config(&config.config_file) { + let Some(config) = load_project::( + &ProjectSource::Wrangler(config.config_file), + &project, + &mut serve_or_test, + &process, + true, + ) else { + return Ok(0); + }; + config + } else { + load_config::(config, const_name.const_name, &process) + }; + entry::serve( + verbose, + config.into_words(), + (&serve_or_test).into(), + options, + watching(process), + ) + } + args::Command::Run { + file, + project, + mut serve_or_test, serve, } => { check_serve_or_test::(&serve_or_test); let watcher = watcher::(&serve_or_test, executable.as_ref()); let (options, inherited_sockets) = serve_options::(serve, &serve_or_test); let process = Process::new(executable, watcher, inherited_sockets); - let config = load_config::(config, const_name.const_name, &process); + let source = if project::is_wrangler_config(&file) { + ProjectSource::Wrangler(file) + } else if project::is_source_file(&file) { + ProjectSource::File(file) + } else { + usage_error::(format!( + "{file}: expected a .js, .mjs, .cjs, .ts or .py file, or a Wrangler config." + )); + }; + let Some(config) = + load_project::(&source, &project, &mut serve_or_test, &process, true) + else { + return Ok(0); + }; entry::serve( verbose, config.into_words(), @@ -128,6 +180,38 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv watching(process), ) } + args::Command::Check { + config, + const_name, + project, + mut serve_or_test, + } => { + check_serve_or_test::(&serve_or_test); + let process = Process::new(executable, None, Vec::new()); + let source = if project::is_wrangler_config(&config.config_file) { + Some(ProjectSource::Wrangler(config.config_file.clone())) + } else if project::is_source_file(&config.config_file) { + Some(ProjectSource::File(config.config_file.clone())) + } else { + None + }; + let config = if let Some(source) = source { + let Some(config) = + load_project::(&source, &project, &mut serve_or_test, &process, false) + else { + return Ok(0); + }; + config + } else { + load_config::(config, const_name.const_name, &process) + }; + entry::check( + verbose, + config.into_words(), + (&serve_or_test).into(), + run_options(&serve_or_test), + ) + } args::Command::Compile { config, const_name, @@ -191,6 +275,124 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv } } +/// What a synthesized config is built from. +enum ProjectSource { + /// A single source file (`workerd run app.js`). + File(String), + /// A Wrangler config (`wrangler.json` / `wrangler.jsonc`). + Wrangler(String), +} + +/// The config synthesized from a project, after printing its warnings and registering its files +/// with the watcher. With `--print-config` the config is printed as a schema file instead and +/// `None` is returned. `announce` prints where the Worker will be served. +fn load_project( + source: &ProjectSource, + args: &args::ProjectArgs, + serve_or_test: &mut args::ServeOrTestArgs, + process: &Process, + announce: bool, +) -> Option { + let overrides = args.clone().into(); + let default_date = default_compatibility_date(); + let result = match source { + ProjectSource::File(file) => Project::from_source_file(file, &default_date, &overrides), + ProjectSource::Wrangler(file) => { + Project::from_wrangler_config(file, &default_date, &overrides) + } + }; + let project = result.unwrap_or_else(|error| usage_error::(error)); + for warning in &project.warnings { + eprintln!("warning: {warning}"); + } + if args.print_config { + print!("{}", project.to_capnp_text()); + return None; + } + if project.needs_experimental() && !serve_or_test.experimental { + eprintln!("note: TypeScript type stripping is experimental; enabling --experimental."); + serve_or_test.experimental = true; + } + for path in project.watched_files() { + if let Err(error) = process.watch_file(path_bytes(path)) { + usage_error::(format!( + "--watch: {}: {}", + path.display(), + error.description() + )); + } + } + if announce { + let what = match source { + ProjectSource::File(file) => file.clone(), + ProjectSource::Wrangler(_) => project.name.clone(), + }; + eprintln!( + "Serving {what} on http://localhost:{} ({} module{}, compatibility date {})", + project.port, + project.modules.len(), + if project.modules.len() == 1 { "" } else { "s" }, + project.compatibility_date + ); + if let Some(dir) = &project.persist { + eprintln!("Durable Object storage: {}", dir.display()); + } + } + Some( + project + .to_config() + .unwrap_or_else(|error| usage_error::(error)), + ) +} + +#[cfg(unix)] +fn path_bytes(path: &std::path::Path) -> &[u8] { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes() +} + +#[cfg(windows)] +fn path_bytes(path: &std::path::Path) -> &[u8] { + path.to_str().map_or(&[], str::as_bytes) +} + +/// The compatibility date a project gets when it does not name one: this release's date, which +/// is the newest date the binary supports. A development build whose version is not a date gets +/// today's date. +fn default_compatibility_date() -> String { + let version = release_version(); + let is_date = version.len() == 10 + && version.bytes().enumerate().all(|(i, b)| { + if i == 4 || i == 7 { + b == b'-' + } else { + b.is_ascii_digit() + } + }); + if is_date { + return version.to_owned(); + } + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let (y, m, d) = civil_from_days(i64::try_from(secs / 86_400).unwrap_or(0)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); + let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); + (if m <= 2 { y + 1 } else { y }, m, d) +} + /// `workerd compile`: the config, encoded, either on its own (`--config-only`) or appended to a /// copy of this executable. fn run_compile( diff --git a/src/workerd/server/cli/project-test.rs b/src/workerd/server/cli/project-test.rs new file mode 100644 index 00000000000..0b1b15bd865 --- /dev/null +++ b/src/workerd/server/cli/project-test.rs @@ -0,0 +1,161 @@ +use std::fs; + +use super::*; + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "workerd-project-test-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn jsonc_comments_and_trailing_commas_are_stripped() { + let text = r#"{ + // a line comment + "a": 1, /* block + comment */ + "b": ["x", "y",], + "c": "has // no comment inside", + }"#; + let value: Value = serde_json::from_str(&strip_jsonc(text)).unwrap(); + assert_eq!(value["a"], 1); + assert_eq!(value["b"], serde_json::json!(["x", "y"])); + assert_eq!(value["c"], "has // no comment inside"); +} + +#[test] +fn const_names_are_identifiers() { + assert_eq!(const_name("my-worker"), "myWorkerWorker"); + assert_eq!(const_name("app"), "appWorker"); + assert_eq!(const_name("1st"), "_1stWorker"); + assert_eq!(const_name("---"), "workerWorker"); +} + +#[test] +fn quoting_escapes_capnp_text() { + assert_eq!(quote("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); +} + +#[test] +fn source_file_project_collects_siblings_main_first() { + let dir = temp_dir("source"); + fs::write(dir.join("app.js"), "import './lib/util.js';").unwrap(); + fs::create_dir_all(dir.join("lib")).unwrap(); + fs::write(dir.join("lib/util.js"), "export const x = 1;").unwrap(); + fs::write(dir.join("data.json"), "{}").unwrap(); + fs::create_dir_all(dir.join("node_modules/pkg")).unwrap(); + fs::write(dir.join("node_modules/pkg/index.js"), "").unwrap(); + fs::write(dir.join("README"), "not a module").unwrap(); + + let project = Project::from_source_file( + dir.join("app.js").to_str().unwrap(), + "2026-01-01", + &Overrides::default(), + ) + .unwrap(); + let names: Vec<&str> = project.modules.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["app.js", "data.json", "lib/util.js"]); + assert_eq!(project.name, "app"); + assert_eq!(project.compatibility_date, "2026-01-01"); + assert_eq!(project.compatibility_flags, ["nodejs_compat"]); + assert_eq!(project.port, DEFAULT_RUN_PORT); + assert_eq!(project.persist, None); + + let config = project.to_config().unwrap(); + assert!(!config.into_words().is_empty()); + let text = project.to_capnp_text(); + assert!(text.contains("(name = \"app.js\", esModule = embed \"app.js\")")); + assert!(text.contains("address = \"*:8080\"")); +} + +#[test] +fn typescript_entry_adds_strip_types_flag() { + let dir = temp_dir("ts"); + fs::write(dir.join("app.ts"), "export default {};").unwrap(); + let project = Project::from_source_file( + dir.join("app.ts").to_str().unwrap(), + "2026-01-01", + &Overrides::default(), + ) + .unwrap(); + assert!(project.needs_experimental()); + assert!( + project + .compatibility_flags + .contains(&"typescript_strip_types".to_owned()) + ); +} + +#[test] +fn wrangler_config_maps_bindings_and_warns_about_the_rest() { + let dir = temp_dir("wrangler"); + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write(dir.join("src/index.js"), "export default {};").unwrap(); + fs::write( + dir.join("wrangler.jsonc"), + r#"{ + // the worker + "name": "demo", + "main": "src/index.js", + "compatibility_date": "2025-05-05", + "compatibility_flags": ["nodejs_compat"], + "vars": { "GREETING": "hi", "LIMITS": { "max": 3 } }, + "durable_objects": { "bindings": [ { "name": "COUNTER", "class_name": "Counter" } ] }, + "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Counter"] } ], + "services": [ { "binding": "SELF", "service": "demo" } ], + "kv_namespaces": [ { "binding": "KV", "id": "abc" } ], + "dev": { "port": 9999 }, + }"#, + ) + .unwrap(); + + let project = Project::from_wrangler_config( + dir.join("wrangler.jsonc").to_str().unwrap(), + "2026-01-01", + &Overrides { + port: Some(1234), + ..Overrides::default() + }, + ) + .unwrap(); + assert_eq!(project.name, "demo"); + assert_eq!(project.port, 1234); + assert_eq!(project.compatibility_date, "2025-05-05"); + assert_eq!(project.modules[0].name, "src/index.js"); + assert_eq!(project.vars.len(), 2); + assert_eq!(project.durable_objects[0].class_name, "Counter"); + assert!(project.sqlite_classes.contains("Counter")); + assert_eq!(project.services[0].service, "demo"); + assert_eq!(project.warnings.len(), 1); + assert!(project.warnings[0].contains("KV namespaces")); + assert!(project.warnings[0].contains("(KV)")); + + let text = project.to_capnp_text(); + assert!(text.contains("(name = \"GREETING\", text = \"hi\")")); + assert!(text.contains("(name = \"LIMITS\", json = \"{\\\"max\\\":3}\")")); + assert!(text.contains("(name = \"COUNTER\", durableObjectNamespace = \"Counter\")")); + assert!(text.contains("uniqueKey = \"demo-Counter\", enableSql = true")); + let root = fs::canonicalize(&dir).unwrap(); + assert_eq!(project.persist, Some(root.join(".workerd-state"))); + assert!(text.contains("disk = (path = \".workerd-state\", writable = true)")); + assert!(text.contains("durableObjectStorage = (localDisk = \"demo-storage\")")); + assert!(text.contains("address = \"*:1234\"")); + assert!(project.to_config().is_ok()); +} + +#[test] +fn wrangler_config_without_main_is_an_error() { + let dir = temp_dir("nomain"); + fs::write(dir.join("wrangler.json"), r#"{ "name": "x" }"#).unwrap(); + let error = Project::from_wrangler_config( + dir.join("wrangler.json").to_str().unwrap(), + "2026-01-01", + &Overrides::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("\"main\" is required")); +} diff --git a/src/workerd/server/cli/project.rs b/src/workerd/server/cli/project.rs new file mode 100644 index 00000000000..5d111cfa5ce --- /dev/null +++ b/src/workerd/server/cli/project.rs @@ -0,0 +1,1126 @@ +//! Configs synthesized from a project rather than written as a Cap'n Proto schema file: a single +//! source file (`workerd run app.js`) or a Wrangler config (`workerd serve wrangler.jsonc`). +//! +//! A [`Project`] is the small, runtime-independent description of a Worker: its entry module, the +//! files beside it, its compatibility settings and its bindings. [`Project::to_config`] turns it +//! into the encoded `Config` message the C++ driver runs, and [`Project::to_capnp_text`] prints +//! the same config as a schema file the user can keep (`--print-config`), with every module as an +//! `embed` of the original file so the printed config stays small and editable. + +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use serde_json::Value; +use workerd_capnp::config; + +use crate::config::Config; +use crate::config::words_from_bytes; + +/// Directories never scanned for modules. +const SKIPPED_DIRS: &[&str] = &[ + "node_modules", + ".git", + ".wrangler", + ".workerd", + ".workerd-state", +]; + +/// The port `workerd run` listens on without `--port`. +pub const DEFAULT_RUN_PORT: u16 = 8080; +/// The port a Wrangler project listens on without `dev.port` or `--port`: Wrangler's own default. +pub const DEFAULT_WRANGLER_PORT: u16 = 8787; + +/// A module found in the project directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Module { + /// The module's name in the Worker: its path relative to the project root, with `/`. + pub name: String, + /// The file it came from. + pub path: PathBuf, + pub kind: ModuleKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModuleKind { + EsModule, + /// TypeScript, run through the runtime's type stripping (`typescript_strip_types`). + TypeScript, + CommonJs, + Json, + Text, + Data, + Wasm, + Python, +} + +impl ModuleKind { + /// The kind a file gets from its extension, or `None` for files that are not modules. + fn from_extension(ext: &str) -> Option { + Some(match ext { + "js" | "mjs" => Self::EsModule, + "ts" | "mts" => Self::TypeScript, + "cjs" => Self::CommonJs, + "json" => Self::Json, + "txt" | "html" | "htm" | "css" | "md" | "svg" | "csv" | "xml" => Self::Text, + "wasm" => Self::Wasm, + "py" => Self::Python, + "bin" | "dat" | "png" | "jpg" | "jpeg" | "gif" | "ico" | "woff" | "woff2" => Self::Data, + _ => return None, + }) + } + + /// The field of `Worker.Module` this kind is stored in, as named in the schema file. + const fn capnp_field(self) -> &'static str { + match self { + Self::EsModule | Self::TypeScript => "esModule", + Self::CommonJs => "commonJsModule", + Self::Json => "json", + Self::Text => "text", + Self::Data => "data", + Self::Wasm => "wasm", + Self::Python => "pythonModule", + } + } + + const fn is_text(self) -> bool { + !matches!(self, Self::Data | Self::Wasm) + } +} + +/// A `vars` entry: a string becomes a `text` binding, anything else a `json` binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Var { + pub name: String, + pub value: Value, +} + +/// A `durable_objects.bindings` entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DurableObjectBinding { + pub name: String, + pub class_name: String, + /// Another Worker's name, when the class lives there (`script_name`). + pub script_name: Option, +} + +/// A `services` entry: a binding to another Worker (or this one) in the same config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceBinding { + pub name: String, + pub service: String, + pub entrypoint: Option, +} + +/// A Worker described by its project files. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + /// The Worker's name: Wrangler's `name`, or the entry file's stem. + pub name: String, + /// The directory module names are relative to. + pub root: PathBuf, + /// The entry module and the other files beside it; the entry module is first. + pub modules: Vec, + pub compatibility_date: String, + pub compatibility_flags: Vec, + pub vars: Vec, + pub durable_objects: Vec, + /// Durable Object classes with SQL-backed storage (`new_sqlite_classes` in `migrations`). + pub sqlite_classes: BTreeSet, + /// Durable Object classes declared in migrations without a binding; they still get a + /// namespace so the Worker's exports are checked. + pub extra_classes: BTreeSet, + pub services: Vec, + pub port: u16, + /// Where Durable Object storage goes: a directory to persist to, or memory. + pub persist: Option, + /// Config the standalone runtime cannot honour, reported to the user and skipped. + pub warnings: Vec, + /// The file this project was read from, when it was a Wrangler config. + pub config_file: Option, +} + +/// Command-line overrides applied on top of a project. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Overrides { + pub name: Option, + pub port: Option, + pub compatibility_date: Option, + pub compatibility_flags: Vec, + pub persist: Option, +} + +/// Why a project could not be read. +#[derive(Debug)] +pub struct Error(pub String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for Error { + fn from(error: std::io::Error) -> Self { + Self(error.to_string()) + } +} + +/// Whether `path` names a Wrangler config rather than a schema-file config, by extension. +pub fn is_wrangler_config(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + ["json", "jsonc", "toml"] + .iter() + .any(|known| ext.eq_ignore_ascii_case(known)) + }) +} + +/// Whether `path` names a source file `workerd run` can serve directly. +pub fn is_source_file(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ModuleKind::from_extension(&ext.to_ascii_lowercase()), + Some( + ModuleKind::EsModule + | ModuleKind::TypeScript + | ModuleKind::CommonJs + | ModuleKind::Python + ) + ) + }) +} + +impl Project { + /// A project for one source file: the file is the entry module and the files beside it are + /// the other modules, so relative imports work. + pub fn from_source_file( + file: &str, + default_compatibility_date: &str, + overrides: &Overrides, + ) -> Result { + let path = fs::canonicalize(file).map_err(|_| Error(format!("{file}: No such file.")))?; + if !path.is_file() { + return Err(Error(format!("{file}: not a file."))); + } + let root = path + .parent() + .ok_or_else(|| Error(format!("{file}: has no parent directory.")))? + .to_path_buf(); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("worker") + .to_owned(); + let modules = collect_modules(&root, &path)?; + let mut project = Self { + name: stem, + root, + modules, + compatibility_date: default_compatibility_date.to_owned(), + // nodejs_compat is the default from 2026-08-04; earlier dates need it spelled out. + compatibility_flags: if default_compatibility_date < "2026-08-04" { + vec!["nodejs_compat".to_owned()] + } else { + Vec::new() + }, + vars: Vec::new(), + durable_objects: Vec::new(), + sqlite_classes: BTreeSet::new(), + extra_classes: BTreeSet::new(), + services: Vec::new(), + port: DEFAULT_RUN_PORT, + persist: None, + warnings: Vec::new(), + config_file: None, + }; + project.apply(overrides); + Ok(project) + } + + /// A project from a Wrangler config (`wrangler.json` / `wrangler.jsonc`). + #[expect( + clippy::too_many_lines, + reason = "one straight read of each Wrangler section, in the order the docs list them" + )] + pub fn from_wrangler_config( + file: &str, + default_compatibility_date: &str, + overrides: &Overrides, + ) -> Result { + let config_path = + fs::canonicalize(file).map_err(|_| Error(format!("{file}: No such file.")))?; + if file.to_ascii_lowercase().ends_with(".toml") { + return Err(Error(format!( + "{file}: wrangler.toml is not supported yet; convert it to wrangler.jsonc \ + (https://developers.cloudflare.com/workers/wrangler/configuration/)." + ))); + } + let text = fs::read_to_string(&config_path)?; + let json: Value = serde_json::from_str(&strip_jsonc(&text)) + .map_err(|error| Error(format!("{file}: {error}")))?; + let Value::Object(top) = &json else { + return Err(Error(format!( + "{file}: expected a JSON object at the top level." + ))); + }; + let root = config_path + .parent() + .ok_or_else(|| Error(format!("{file}: has no parent directory.")))? + .to_path_buf(); + + let main = top + .get("main") + .and_then(Value::as_str) + .ok_or_else(|| Error(format!("{file}: \"main\" is required.")))?; + let main_path = root.join(main); + if !main_path.is_file() { + return Err(Error(format!( + "{file}: \"main\" points to {main}, which does not exist. If the project needs \ + bundling, run `wrangler deploy --dry-run --outdir dist` and point \"main\" at the \ + bundle." + ))); + } + let main_path = fs::canonicalize(&main_path)?; + + let name = top.get("name").and_then(Value::as_str).map_or_else( + || { + main_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("worker") + .to_owned() + }, + str::to_owned, + ); + + let mut warnings = Vec::new(); + + let compatibility_date = top + .get("compatibility_date") + .and_then(Value::as_str) + .map_or_else( + || { + warnings.push(format!( + "\"compatibility_date\" is not set; using {default_compatibility_date}." + )); + default_compatibility_date.to_owned() + }, + str::to_owned, + ); + let compatibility_flags: Vec = top + .get("compatibility_flags") + .and_then(Value::as_array) + .map(|flags| { + flags + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + + let vars: Vec = top + .get("vars") + .and_then(Value::as_object) + .map(|vars| { + vars.iter() + .map(|(name, value)| Var { + name: name.clone(), + value: value.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + let durable_objects: Vec = top + .get("durable_objects") + .and_then(|d| d.get("bindings")) + .and_then(Value::as_array) + .map(|bindings| { + bindings + .iter() + .filter_map(|b| { + Some(DurableObjectBinding { + name: b.get("name")?.as_str()?.to_owned(), + class_name: b.get("class_name")?.as_str()?.to_owned(), + script_name: b + .get("script_name") + .and_then(Value::as_str) + .map(str::to_owned), + }) + }) + .collect() + }) + .unwrap_or_default(); + + let mut sqlite_classes = BTreeSet::new(); + let mut extra_classes = BTreeSet::new(); + if let Some(migrations) = top.get("migrations").and_then(Value::as_array) { + for migration in migrations { + for class in string_list(migration.get("new_sqlite_classes")) { + sqlite_classes.insert(class.clone()); + extra_classes.insert(class); + } + for class in string_list(migration.get("new_classes")) { + extra_classes.insert(class); + } + for class in string_list(migration.get("deleted_classes")) { + extra_classes.remove(&class); + sqlite_classes.remove(&class); + } + } + } + for binding in &durable_objects { + if binding.script_name.is_none() { + extra_classes.remove(&binding.class_name); + } + } + + let services: Vec = top + .get("services") + .and_then(Value::as_array) + .map(|services| { + services + .iter() + .filter_map(|s| { + Some(ServiceBinding { + name: s.get("binding")?.as_str()?.to_owned(), + service: s.get("service")?.as_str()?.to_owned(), + entrypoint: s + .get("entrypoint") + .and_then(Value::as_str) + .map(str::to_owned), + }) + }) + .collect() + }) + .unwrap_or_default(); + + // Everything the standalone runtime has no local implementation of. + for (key, what) in [ + ("kv_namespaces", "KV namespaces"), + ("r2_buckets", "R2 buckets"), + ("d1_databases", "D1 databases"), + ("queues", "Queues"), + ("assets", "static assets"), + ("ai", "Workers AI"), + ("images", "Images"), + ("browser", "Browser Rendering"), + ("vectorize", "Vectorize"), + ("hyperdrive", "Hyperdrive"), + ("workflows", "Workflows"), + ("analytics_engine_datasets", "Analytics Engine"), + ("send_email", "Email"), + ("mtls_certificates", "mTLS certificates"), + ("dispatch_namespaces", "dispatch namespaces"), + ("pipelines", "Pipelines"), + ("secrets_store_secrets", "Secrets Store"), + ("triggers", "cron triggers"), + ("tail_consumers", "tail consumers"), + ] { + if let Some(value) = top.get(key) { + let names = binding_names(value); + let suffix = if names.is_empty() { + String::new() + } else { + format!(" ({})", names.join(", ")) + }; + warnings.push(format!( + "\"{key}\": {what} are not available in standalone workerd yet; skipped{suffix}." + )); + } + } + + let port = top + .get("dev") + .and_then(|dev| dev.get("port")) + .and_then(Value::as_u64) + .and_then(|port| u16::try_from(port).ok()) + .unwrap_or(DEFAULT_WRANGLER_PORT); + + let modules = collect_modules(&root, &main_path)?; + let mut project = Self { + name, + root, + modules, + compatibility_date, + compatibility_flags, + vars, + durable_objects, + sqlite_classes, + extra_classes, + services, + port, + persist: None, + warnings, + config_file: Some(config_path), + }; + project.apply(overrides); + Ok(project) + } + + fn apply(&mut self, overrides: &Overrides) { + if let Some(name) = &overrides.name { + self.name.clone_from(name); + } + if let Some(port) = overrides.port { + self.port = port; + } + if let Some(date) = &overrides.compatibility_date { + self.compatibility_date.clone_from(date); + } + for flag in &overrides.compatibility_flags { + if !self.compatibility_flags.contains(flag) { + self.compatibility_flags.push(flag.clone()); + } + } + if let Some(dir) = &overrides.persist { + self.persist = Some(dir.clone()); + } else if !self.own_classes().is_empty() { + // The runtime's in-memory Durable Object storage has no SQLite, so a project with + // Durable Objects is persisted beside its code, as Wrangler does under .wrangler. + self.persist = Some(self.root.join(".workerd-state")); + } + if self.uses_typescript() { + let flag = "typescript_strip_types".to_owned(); + if !self.compatibility_flags.contains(&flag) { + self.compatibility_flags.push(flag); + } + } + if self.uses_python() { + let flag = "python_workers".to_owned(); + if !self.compatibility_flags.contains(&flag) { + self.compatibility_flags.push(flag); + } + } + } + + /// Whether any module needs type stripping, which is an experimental feature. + pub fn uses_typescript(&self) -> bool { + self.modules + .iter() + .any(|m| m.kind == ModuleKind::TypeScript) + } + + pub fn uses_python(&self) -> bool { + self.modules + .first() + .is_some_and(|m| m.kind == ModuleKind::Python) + } + + /// Whether the config needs `--experimental` to be accepted by the runtime. + pub fn needs_experimental(&self) -> bool { + self.uses_typescript() + } + + /// The files the config depends on, for `--watch`. + pub fn watched_files(&self) -> impl Iterator { + self.config_file + .iter() + .map(PathBuf::as_path) + .chain(self.modules.iter().map(|m| m.path.as_path())) + } + + /// The name of the disk service that backs Durable Object storage under `--persist`. + fn storage_service_name(&self) -> String { + format!("{}-storage", self.name) + } + + /// Every Durable Object class this Worker implements, with its namespace. + fn own_classes(&self) -> Vec<(String, bool)> { + let mut classes: Vec = self + .durable_objects + .iter() + .filter(|b| b.script_name.is_none()) + .map(|b| b.class_name.clone()) + .collect(); + for class in &self.extra_classes { + if !classes.contains(class) { + classes.push(class.clone()); + } + } + classes + .into_iter() + .map(|class| { + let sql = self.sqlite_classes.contains(&class); + (class, sql) + }) + .collect() + } + + fn unique_key(&self, class: &str) -> String { + format!("{}-{class}", self.name) + } + + /// The config as an encoded message for the C++ driver, with every module's contents read + /// from disk. + #[expect( + clippy::too_many_lines, + reason = "fills one capnp message field by field; the builders borrow the message, so splitting it would only pass builders around" + )] + pub fn to_config(&self) -> Result { + let mut message = capnp::message::Builder::new_default(); + let mut root = message.init_root::(); + + let persist_dir = match &self.persist { + Some(dir) => { + fs::create_dir_all(dir)?; + Some(fs::canonicalize(dir)?) + } + None => None, + }; + + let service_count = 1 + usize::from(persist_dir.is_some()); + let mut services = root.reborrow().init_services(list_len(service_count)?); + { + let mut service = services.reborrow().get(0); + service.set_name(self.name.as_str()); + let mut worker = service.init_worker(); + worker.set_compatibility_date(self.compatibility_date.as_str()); + { + let mut flags = worker + .reborrow() + .init_compatibility_flags(list_len(self.compatibility_flags.len())?); + for (i, flag) in self.compatibility_flags.iter().enumerate() { + flags.set(list_index(i)?, flag.as_str()); + } + } + { + let mut modules = worker + .reborrow() + .init_modules(list_len(self.modules.len())?); + for (i, module) in self.modules.iter().enumerate() { + let mut m = modules.reborrow().get(list_index(i)?); + m.set_name(module.name.as_str()); + let bytes = fs::read(&module.path) + .map_err(|error| Error(format!("{}: {error}", module.path.display())))?; + if module.kind.is_text() { + let text = String::from_utf8(bytes).map_err(|_| { + Error(format!("{}: not valid UTF-8", module.path.display())) + })?; + match module.kind { + ModuleKind::EsModule | ModuleKind::TypeScript => { + m.set_es_module(text.as_str()); + } + ModuleKind::CommonJs => m.set_common_js_module(text.as_str()), + ModuleKind::Json => m.set_json(text.as_str()), + ModuleKind::Text => m.set_text(text.as_str()), + ModuleKind::Python => m.set_python_module(text.as_str()), + ModuleKind::Data | ModuleKind::Wasm => unreachable!(), + } + } else { + match module.kind { + ModuleKind::Wasm => m.set_wasm(bytes.as_slice()), + _ => m.set_data(bytes.as_slice()), + } + } + } + } + { + let binding_count = + self.vars.len() + self.durable_objects.len() + self.services.len(); + let mut bindings = worker.reborrow().init_bindings(list_len(binding_count)?); + let mut i = 0; + for var in &self.vars { + let mut b = bindings.reborrow().get(list_index(i)?); + b.set_name(var.name.as_str()); + match &var.value { + Value::String(s) => b.set_text(s.as_str()), + other => b.set_json(other.to_string().as_str()), + } + i += 1; + } + for d in &self.durable_objects { + let mut b = bindings.reborrow().get(list_index(i)?); + b.set_name(d.name.as_str()); + let mut ns = b.init_durable_object_namespace(); + ns.set_class_name(d.class_name.as_str()); + if let Some(script) = &d.script_name { + ns.set_service_name(script.as_str()); + } + i += 1; + } + for s in &self.services { + let mut b = bindings.reborrow().get(list_index(i)?); + b.set_name(s.name.as_str()); + let mut designator = b.init_service(); + designator.set_name(s.service.as_str()); + if let Some(entrypoint) = &s.entrypoint { + designator.set_entrypoint(entrypoint.as_str()); + } + i += 1; + } + } + let classes = self.own_classes(); + if !classes.is_empty() { + let mut namespaces = worker + .reborrow() + .init_durable_object_namespaces(list_len(classes.len())?); + for (i, (class, sql)) in classes.iter().enumerate() { + let mut ns = namespaces.reborrow().get(list_index(i)?); + ns.set_class_name(class.as_str()); + ns.set_unique_key(self.unique_key(class).as_str()); + ns.set_enable_sql(*sql); + } + let mut storage = worker.reborrow().init_durable_object_storage(); + if persist_dir.is_some() { + storage.set_local_disk(self.storage_service_name().as_str()); + } else { + storage.set_in_memory(()); + } + } + } + if let Some(dir) = &persist_dir { + let mut service = services.reborrow().get(1); + service.set_name(self.storage_service_name().as_str()); + let mut disk = service.init_disk(); + disk.set_path(dir.to_string_lossy().as_ref()); + disk.set_writable(true); + } + + let mut sockets = root.reborrow().init_sockets(1); + let mut socket = sockets.reborrow().get(0); + socket.set_name("http"); + socket.set_address(format!("*:{}", self.port).as_str()); + socket.reborrow().init_http(); + socket.init_service().set_name(self.name.as_str()); + + Config::from_words(&words_from_bytes( + &capnp::serialize::write_message_to_words(&message), + )) + .map_err(|error| Error(format!("synthesized config is malformed: {error}"))) + } + + /// The config as a schema file, with modules as `embed`s of the project files relative to + /// the project root. Written to the root, it runs as `workerd serve `. + #[expect( + clippy::too_many_lines, + reason = "prints one schema file top to bottom; the order of the output is the structure" + )] + pub fn to_capnp_text(&self) -> String { + let mut out = String::new(); + let worker_const = const_name(&self.name); + let _ = writeln!( + out, + "# Generated by `workerd` from {}.", + self.origin_description() + ); + let _ = writeln!( + out, + "# Save this next to your code and run it with `workerd serve `.\n" + ); + let _ = writeln!(out, "using Workerd = import \"/workerd/workerd.capnp\";\n"); + let _ = writeln!(out, "const config :Workerd.Config = ("); + let _ = writeln!(out, " services = ["); + let _ = writeln!( + out, + " (name = {}, worker = .{worker_const}),", + quote(&self.name) + ); + if let Some(dir) = &self.persist { + let _ = writeln!( + out, + " (name = {}, disk = (path = {}, writable = true)),", + quote(&self.storage_service_name()), + quote(&self.display_path(dir)) + ); + } + let _ = writeln!(out, " ],"); + let _ = writeln!( + out, + " sockets = [ (name = \"http\", address = \"*:{}\", http = (), service = {}) ],", + self.port, + quote(&self.name) + ); + let _ = writeln!(out, ");\n"); + + let _ = writeln!(out, "const {worker_const} :Workerd.Worker = ("); + let _ = writeln!( + out, + " compatibilityDate = {},", + quote(&self.compatibility_date) + ); + if !self.compatibility_flags.is_empty() { + let flags: Vec = self.compatibility_flags.iter().map(|f| quote(f)).collect(); + let _ = writeln!(out, " compatibilityFlags = [{}],", flags.join(", ")); + } + let _ = writeln!(out, " modules = ["); + for module in &self.modules { + let _ = writeln!( + out, + " (name = {}, {} = embed {}),", + quote(&module.name), + module.kind.capnp_field(), + quote(&module.name) + ); + } + let _ = writeln!(out, " ],"); + + let has_bindings = + !self.vars.is_empty() || !self.durable_objects.is_empty() || !self.services.is_empty(); + if has_bindings { + let _ = writeln!(out, " bindings = ["); + for var in &self.vars { + match &var.value { + Value::String(s) => { + let _ = writeln!( + out, + " (name = {}, text = {}),", + quote(&var.name), + quote(s) + ); + } + other => { + let _ = writeln!( + out, + " (name = {}, json = {}),", + quote(&var.name), + quote(&other.to_string()) + ); + } + } + } + for d in &self.durable_objects { + match &d.script_name { + Some(script) => { + let _ = writeln!( + out, + " (name = {}, durableObjectNamespace = (className = {}, serviceName = {})),", + quote(&d.name), + quote(&d.class_name), + quote(script) + ); + } + None => { + let _ = writeln!( + out, + " (name = {}, durableObjectNamespace = {}),", + quote(&d.name), + quote(&d.class_name) + ); + } + } + } + for s in &self.services { + match &s.entrypoint { + Some(entrypoint) => { + let _ = writeln!( + out, + " (name = {}, service = (name = {}, entrypoint = {})),", + quote(&s.name), + quote(&s.service), + quote(entrypoint) + ); + } + None => { + let _ = writeln!( + out, + " (name = {}, service = {}),", + quote(&s.name), + quote(&s.service) + ); + } + } + } + let _ = writeln!(out, " ],"); + } + + let classes = self.own_classes(); + if !classes.is_empty() { + let _ = writeln!(out, " durableObjectNamespaces = ["); + for (class, sql) in &classes { + let sql = if *sql { ", enableSql = true" } else { "" }; + let _ = writeln!( + out, + " (className = {}, uniqueKey = {}{sql}),", + quote(class), + quote(&self.unique_key(class)) + ); + } + let _ = writeln!(out, " ],"); + if self.persist.is_some() { + let _ = writeln!( + out, + " durableObjectStorage = (localDisk = {}),", + quote(&self.storage_service_name()) + ); + } else { + let _ = writeln!(out, " durableObjectStorage = (inMemory = void),"); + } + } + let _ = writeln!(out, ");"); + out + } + + /// `path` relative to the project root when it is inside it, for the printed config. + fn display_path(&self, path: &Path) -> String { + path.strip_prefix(&self.root).map_or_else( + |_| path.to_string_lossy().into_owned(), + |relative| relative.to_string_lossy().into_owned(), + ) + } + + fn origin_description(&self) -> String { + match &self.config_file { + Some(file) => file.file_name().map_or_else( + || file.display().to_string(), + |n| n.to_string_lossy().into_owned(), + ), + None => self + .modules + .first() + .map_or_else(|| "a project".to_owned(), |m| m.name.clone()), + } + } +} + +/// The modules under `root`: `main` first, then every other module file, sorted by name. +fn collect_modules(root: &Path, main: &Path) -> Result, Error> { + let main_name = module_name(root, main)?; + let main_kind = kind_of(main).ok_or_else(|| { + Error(format!( + "{}: not a file type workerd can run (expected .js, .mjs, .cjs, .ts or .py).", + main.display() + )) + })?; + let mut others = Vec::new(); + walk(root, root, &mut others)?; + others.retain(|m| m.path != main); + others.sort_by(|a, b| a.name.cmp(&b.name)); + let mut modules = Vec::with_capacity(others.len() + 1); + modules.push(Module { + name: main_name, + path: main.to_path_buf(), + kind: main_kind, + }); + modules.extend(others); + Ok(modules) +} + +fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), Error> { + let entries = + fs::read_dir(dir).map_err(|error| Error(format!("{}: {error}", dir.display())))?; + for entry in entries { + let entry = entry.map_err(|error| Error(format!("{}: {error}", dir.display())))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| Error(format!("{}: {error}", path.display())))?; + let skipped = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| SKIPPED_DIRS.contains(&n)); + if file_type.is_dir() { + if !skipped { + walk(root, &path, out)?; + } + } else if file_type.is_file() + && let Some(kind) = kind_of(&path) + { + out.push(Module { + name: module_name(root, &path)?, + path, + kind, + }); + } + } + Ok(()) +} + +fn kind_of(path: &Path) -> Option { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + ModuleKind::from_extension(&ext) +} + +/// `path` relative to `root`, with `/` separators, as the module's name in the Worker. +fn module_name(root: &Path, path: &Path) -> Result { + let relative = path.strip_prefix(root).map_err(|_| { + Error(format!( + "{}: not inside the project root {}", + path.display(), + root.display() + )) + })?; + Ok(relative + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/")) +} + +fn string_list(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +/// The binding names declared in a Wrangler section, for a warning about it. +fn binding_names(value: &Value) -> Vec { + match value { + Value::Array(items) => items + .iter() + .filter_map(|item| item.get("binding").and_then(Value::as_str)) + .map(str::to_owned) + .collect(), + Value::Object(object) => object + .get("binding") + .and_then(Value::as_str) + .map(str::to_owned) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +fn list_len(len: usize) -> Result { + u32::try_from(len).map_err(|_| Error("too many items for a config list".to_owned())) +} + +fn list_index(index: usize) -> Result { + list_len(index) +} + +/// A Cap'n Proto constant name for a Worker: a lowerCamelCase identifier. +fn const_name(name: &str) -> String { + let mut out = String::new(); + let mut upper_next = false; + for c in name.chars() { + if c.is_ascii_alphanumeric() { + if upper_next { + out.extend(c.to_uppercase()); + upper_next = false; + } else { + out.push(c); + } + } else { + upper_next = !out.is_empty(); + } + } + if out.is_empty() { + out.push_str("worker"); + } else if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out.push_str("Worker"); + out +} + +/// `s` as a Cap'n Proto text literal. +fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\x{:02x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// JSON with comments and trailing commas (what Wrangler accepts) reduced to strict JSON. Comments +/// are replaced by spaces so error positions stay meaningful. +pub fn strip_jsonc(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + let mut in_string = false; + while i < bytes.len() { + let b = bytes[i]; + if in_string { + out.push(b); + if b == b'\\' && i + 1 < bytes.len() { + out.push(bytes[i + 1]); + i += 2; + continue; + } + if b == b'"' { + in_string = false; + } + i += 1; + continue; + } + match b { + b'"' => { + in_string = true; + out.push(b); + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + if bytes[i] == b'\n' { + out.push(b'\n'); + } + i += 1; + } + i += 2; + } + b',' => { + // A trailing comma: skip it when the next token closes an object or array. + let mut j = i + 1; + while j < bytes.len() { + match bytes[j] { + b' ' | b'\t' | b'\r' | b'\n' => j += 1, + b'/' if bytes.get(j + 1) == Some(&b'/') => { + while j < bytes.len() && bytes[j] != b'\n' { + j += 1; + } + } + b'/' if bytes.get(j + 1) == Some(&b'*') => { + j += 2; + while j < bytes.len() + && !(bytes[j] == b'*' && bytes.get(j + 1) == Some(&b'/')) + { + j += 1; + } + j += 2; + } + _ => break, + } + } + if !matches!(bytes.get(j), Some(b'}' | b']')) { + out.push(b','); + } + i += 1; + } + _ => { + out.push(b); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_default() +} + +#[cfg(test)] +#[path = "project-test.rs"] +mod tests; diff --git a/src/workerd/server/server/config-test.rs b/src/workerd/server/server/config-test.rs index 68256665735..cc800c43f87 100644 --- a/src/workerd/server/server/config-test.rs +++ b/src/workerd/server/server/config-test.rs @@ -279,3 +279,18 @@ fn invalid_services_fail_requests_with_a_js_error() { fn dummy_metadata() -> KjOwn { ffi::new_request_metadata(kj_rs::KjMaybe::None, kj_rs::KjMaybe::None) } + +#[test] +fn did_you_mean_suggests_the_closest_name() { + let names = ["main", "internet", "storage"]; + assert_eq!( + did_you_mean("mian", names.iter().copied()), + " Did you mean \"main\"?" + ); + assert_eq!( + did_you_mean("MAIN", names.iter().copied()), + " Did you mean \"main\"?" + ); + assert_eq!(did_you_mean("database", names.iter().copied()), ""); + assert_eq!(did_you_mean("main", names.iter().copied()), ""); +} diff --git a/src/workerd/server/server/config.rs b/src/workerd/server/server/config.rs index 4aea4a5a24f..ff8f5ae2b71 100644 --- a/src/workerd/server/server/config.rs +++ b/src/workerd/server/server/config.rs @@ -876,6 +876,38 @@ impl<'a> WorkflowEngine<'a> { } } +/// Levenshtein distance, ignoring ASCII case. +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let b: Vec = b.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// A hint for an error about a name that does not exist: ` Did you mean "x"?` naming the +/// closest of `candidates`, or empty when nothing is close enough to be worth suggesting. +pub fn did_you_mean<'a>(name: &str, candidates: impl Iterator) -> String { + let threshold = (name.len() / 3).max(2); + let best = candidates + .filter(|candidate| *candidate != name) + .map(|candidate| (edit_distance(name, candidate), candidate)) + .filter(|(distance, _)| *distance <= threshold) + .min_by_key(|(distance, _)| *distance); + match best { + Some((_, candidate)) => format!(" Did you mean \"{candidate}\"?"), + None => String::new(), + } +} + /// What a `ServiceDesignator` resolves to, or the invalid-config stand-in after the error was /// reported. pub fn lookup_service( @@ -890,8 +922,9 @@ pub fn lookup_service( error_context: context, } = designator; let Some(service) = services.get(target) else { + let hint = did_you_mean(target, services.keys().map(String::as_str)); report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." + "{context} refers to a service \"{target}\", but no such service is defined.{hint}" )); return Rc::new(InvalidConfigChannel); }; @@ -949,8 +982,9 @@ pub fn lookup_actor_class( error_context: context, } = designator; let Some(service) = services.get(target) else { + let hint = did_you_mean(target, services.keys().map(String::as_str)); report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." + "{context} refers to a service \"{target}\", but no such service is defined.{hint}" )); return Rc::new(InvalidConfigActorClass); }; diff --git a/src/workerd/server/server/entry.rs b/src/workerd/server/server/entry.rs index 2fad2e1e232..63f7396ed09 100644 --- a/src/workerd/server/server/entry.rs +++ b/src/workerd/server/server/entry.rs @@ -19,6 +19,7 @@ //! set the process ends right there, without running the remaining destructors, like //! `kj::ProcessContext::exit()`; with it, the Runtime is torn down and `main` returns the status. +use std::cell::Cell; use std::convert::Infallible; use std::future::Future; use std::rc::Rc; @@ -56,6 +57,18 @@ pub fn serve( run(verbose, config, options, None, command) } +/// Runs `workerd check` (see the module doc): every config error is reported, and nothing is +/// served. The exit status is 1 if there was any. +pub fn check( + verbose: bool, + config: Vec, + options: ffi::ServeOrTestOptions, + run_options: RunOptions, +) -> Result { + let command = move |factory, structured| server_check(factory, run_options, structured); + run(verbose, config, options, None, command) +} + /// Runs `workerd test` (see the module doc); the exit status, if the process is to go on. /// `patterns` are the `` and `` globs. pub fn test( @@ -204,6 +217,48 @@ async fn server_serve( shutdown } +/// `workerd check`: builds `factory`'s config without listening, then reports how many errors +/// there were, or that the config is OK. +async fn server_check( + factory: KjOwn, + options: RunOptions, + structured: bool, +) -> Result<()> { + crate::log::install(); + let errors = Rc::new(Cell::new(0usize)); + let counted = Rc::clone(&errors); + let report = Reporter::new( + Box::new(move |message| { + counted.set(counted.get() + 1); + error(structured, &message); + }), + Box::new(move |message| report(structured, Severity::Warning, &message)), + true, + ); + let factory = Rc::new(Factory::new(factory)); + let result = crate::run::check(Rc::clone(&factory), options, report).await; + let shutdown = shutdown(factory).await; + let (services, sockets) = result?; + shutdown?; + let count = errors.get(); + if count > 0 { + let plural = if count == 1 { "" } else { "s" }; + error(structured, &format!("{count} config error{plural} found.")); + } else { + let plural = |n: usize| if n == 1 { "" } else { "s" }; + self::report( + structured, + Severity::Info, + &format!( + "Config OK: {services} service{}, {sockets} socket{}.", + plural(services), + plural(sockets) + ), + ); + } + Ok(()) +} + /// Tears the run down once the server is gone: ends the factory's background tasks, then drops /// the factory and, with it, the last of the isolates, V8 and the perfetto session. A factory /// still shared at this point is a leaked service. diff --git a/src/workerd/server/server/run.rs b/src/workerd/server/server/run.rs index ad809a49f66..4ce7bdedcd4 100644 --- a/src/workerd/server/server/run.rs +++ b/src/workerd/server/server/run.rs @@ -374,6 +374,54 @@ pub async fn run( Ok(ffi::factory_shutdown_containers(server.factory().raw()).await?) } +/// `workerd check`: builds the service graph without binding or listening on any socket. +/// +/// Every Worker is compiled, so every config error is reported, and a check never conflicts with +/// a running server. The sockets' service references are resolved too. Returns the number of +/// services and sockets in the config. +pub async fn check( + factory: Rc, + options: RunOptions, + report: Reporter, +) -> Result<(usize, usize)> { + let message = factory.config()?; + let config = message.get_root::().map_err(capnp_error)?; + let sockets = config.get_sockets().map_err(capnp_error)?; + for sock in sockets { + let name = text(sock.get_name())?; + if !sock.has_address() && !options.socket_addresses.contains_key(&name) { + report.error(format!( + "Socket \"{name}\" has no address in the config, so must be specified on the \ + command line with `--socket-addr`." + )); + } + } + let server = Server::start( + Rc::clone(&factory), + &options.overrides, + report, + HashMap::new(), + ) + .await?; + report_unmatched_overrides(&server, &options.overrides, config, false)?; + for sock in sockets { + let name = text(sock.get_name())?; + let mut errors = Vec::new(); + let designator = Designator::from_reader( + sock.get_service().map_err(capnp_error)?, + format!("Socket \"{name}\""), + &mut errors, + ) + .map_err(capnp_error)?; + for error in errors { + server.report().error(error); + } + let _channel = server.lookup(&designator); + } + let service_count = config.get_services().map_err(capnp_error)?.len() as usize; + Ok((service_count, sockets.len() as usize)) +} + /// `workerd test`: runs the `test()` handler of every entrypoint matching the patterns (globs /// as the `glob` crate reads them, matched against the whole name). ///