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).
///