From c2553feac2aa57a40e5aeec9f58e44763457c55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 10 Oct 2026 11:31:07 +0200 Subject: [PATCH] server: workerd run, serve of Wrangler configs, and workerd check Standalone workerd needed a Cap'n Proto config for anything, which is the most-cited complaint from people trying to self-host it. This adds a front end that synthesizes the config in-process: - `workerd run app.js` serves a single JavaScript, TypeScript or Python file with the newest compatibility date this release supports. Files beside it are available to relative imports. `--watch` reloads on change; `--port`, `--compat-date`, `--compat-flag` and `--persist` adjust it. - `workerd serve wrangler.jsonc` (and `check`) reads a Wrangler config: main, compatibility date and flags, vars, durable_objects with migrations (SQLite classes), services and dev.port. Durable Objects are persisted under .workerd-state beside the config, since the in-memory storage has no SQLite. Sections that need Cloudflare services are reported and skipped. - `--print-config` prints the synthesized config as a schema file with `embed`s of the project files, so a project can graduate to a hand written config. - `workerd check ` starts every service without binding a port, reports every config error rather than exiting at the first, and exits 1 if any. Worker compile errors (syntax errors) are reported too. - Config errors about an unknown service now add a 'Did you mean' hint. The CLI side is Rust (cli/project.rs); the C++ side adds Server::check() and a check mode to the driver. --- README.md | 36 + samples/wrangler-project/.gitignore | 1 + samples/wrangler-project/README.md | 28 + samples/wrangler-project/src/index.js | 26 + samples/wrangler-project/wrangler.jsonc | 14 + src/workerd/server/AGENTS.md | 4 +- src/workerd/server/BUILD.bazel | 1 + src/workerd/server/cli-main.c++ | 45 +- src/workerd/server/cli-main.h | 4 + src/workerd/server/cli/args-test.rs | 41 + src/workerd/server/cli/args.rs | 123 ++- src/workerd/server/cli/bridge.rs | 8 + src/workerd/server/cli/lib.rs | 204 +++- src/workerd/server/cli/project-test.rs | 161 ++++ src/workerd/server/cli/project.rs | 1126 +++++++++++++++++++++++ src/workerd/server/server.c++ | 96 +- src/workerd/server/server.h | 8 + 17 files changed, 1918 insertions(+), 8 deletions(-) create mode 100644 samples/wrangler-project/.gitignore create mode 100644 samples/wrangler-project/README.md create mode 100644 samples/wrangler-project/src/index.js create mode 100644 samples/wrangler-project/wrangler.jsonc create mode 100644 src/workerd/server/cli/project-test.rs create mode 100644 src/workerd/server/cli/project.rs 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 27d7758f7b3..b525174c459 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -2,14 +2,14 @@ ## OVERVIEW -Binary + orchestration layer. `:workerd` is a Rust binary: the `:workerd-cli` crate (`cli/`) parses the command line (clap), produces the encoded config (schema files via `config-compiler.c++`), handles `--watch` and `compile`, and runs each serving subcommand (`serve`, `compile`, `test`, `fuzzilli`, `pyodide-lock`, `make-pyodide-baseline-snapshot`) through a `run_*` function in cli-main.c++. `Server` (server.c++, ~6K lines) is the god object: parses `workerd.capnp` config, constructs all service types as nested inner classes, wires sockets/bindings/actors, runs the event loop. +Binary + orchestration layer. `:workerd` is a Rust binary: the `:workerd-cli` crate (`cli/`) parses the command line (clap), produces the encoded config (schema files via `config-compiler.c++`), handles `--watch` and `compile`, synthesizes a config from a source file or a Wrangler config (`project.rs`: `run`, `serve wrangler.jsonc`, `check`), and runs each serving subcommand (`serve`, `run`, `check`, `compile`, `test`, `fuzzilli`, `pyodide-lock`, `make-pyodide-baseline-snapshot`) through a `run_*` function in cli-main.c++. `Server` (server.c++, ~6K lines) is the god object: parses `workerd.capnp` config, constructs all service types as nested inner classes, wires sockets/bindings/actors, runs the event loop. ## KEY FILES | File | Role | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workerd.rs` | Crate root of the `:workerd` binary; calls `workerd_cli::main()` | -| `cli/` | `:workerd-cli` crate: `main()` (`lib.rs`), clap definitions (`args.rs`), config sources and the synthesized Python config (`config.rs`), `--watch` (`watch.rs`), executable discovery, compiled-binary format and re-exec (`process.rs`), `--socket-fd` (`socket_fd.rs`), the cxx bridge (`bridge.rs`) | +| `cli/` | `:workerd-cli` crate: `main()` (`lib.rs`), clap definitions (`args.rs`), config sources and the synthesized Python config (`config.rs`), configs synthesized from a source file or a Wrangler config, and printed back as a schema file (`project.rs`), `--watch` (`watch.rs`), executable discovery, compiled-binary format and re-exec (`process.rs`), `--socket-fd` (`socket_fd.rs`), the cxx bridge (`bridge.rs`) | | `cli-main.h/c++` | C++ side of the CLI, called through `cli/bridge.rs`: V8 and `Server` setup, option forwarding, SIGTERM drain; enables kj-rs-io's `loopback:` addresses for `workerd test` | | `config-compiler.h/c++` | Compiles a schema-file config (`capnp::SchemaParser` over `schema-file.h`) into an encoded message for `cli/lib.rs`; reports parse errors as data and registers the files it reads for `--watch` | | `schema-file.h/c++` | The `capnp::SchemaFile` implementations: config files on disk with workerd's import resolution, and the schemas built into the binary | diff --git a/src/workerd/server/BUILD.bazel b/src/workerd/server/BUILD.bazel index 93f513daafb..2a560414e74 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -95,6 +95,7 @@ wd_rust_crate( "//src/rust/cxx/kj-rs-io", "@crates_vendor//:capnp", "@crates_vendor//:clap", + "@crates_vendor//:serde_json", "@crates_vendor//:tokio", ] + select({ "@platforms//os:windows": ["@crates_vendor//:windows-sys"], diff --git a/src/workerd/server/cli-main.c++ b/src/workerd/server/cli-main.c++ index d9687a2180e..475dc8a0e52 100644 --- a/src/workerd/server/cli-main.c++ +++ b/src/workerd/server/cli-main.c++ @@ -114,7 +114,11 @@ class CliMain { entropySource, Worker::LoggingOptions(Worker::ConsoleMode::STDOUT), [&](kj::String error) { - if (!process->is_watching()) { + if (checkMode) { + // `workerd check` collects every error and reports the count at the end. + configErrors.add(kj::str(error)); + context.error(error); + } else if (!process->is_watching()) { // TODO(someday): Don't just fail on the first error, keep going in order to report // additional errors. The tricky part is we don't currently have any signal of when // the server has completely finished loading, and also we probably don't want to @@ -215,6 +219,29 @@ class CliMain { }); } + void enableCheckMode() { + checkMode = true; + } + + // `workerd check`: start every service without binding sockets, then report how many config + // errors the Server's error callback saw. Exits 1 if there were any. + void check() { + serveImpl([&](jsg::V8System& v8System) { + return server->check(v8System, config).then([this]() { + auto errorCount = configErrors.size(); + if (errorCount > 0) { + context.exitError( + kj::str(errorCount, " config error", errorCount == 1 ? "" : "s", " found.")); + } + auto serviceCount = config.getServices().size(); + auto socketCount = config.getSockets().size(); + auto summary = kj::str("Config OK: ", serviceCount, " service", serviceCount == 1 ? "" : "s", + ", ", socketCount, " socket", socketCount == 1 ? "" : "s", ".\n"); + fputs(summary.cStr(), stderr); + }); + }); + } + void test(const TestOptions& options) { if (!options.no_verbose) { // Always turn on info logging when running tests so that uncaught exceptions are displayed. @@ -287,6 +314,10 @@ class CliMain { // Set by the Server's error callback under --watch, where errors don't exit. bool hadErrors = false; + // `workerd check`: errors are collected rather than exiting on the first. + bool checkMode = false; + kj::Vector configErrors; + kj::Maybe> openWritableDirectory(kj::StringPtr pathStr) { return fs->getRoot().tryOpenSubdir(fs->getCurrentPath().eval(pathStr), kj::WriteMode::MODIFY); } @@ -412,4 +443,16 @@ int32_t run_test(const CommonOptions& common, }); } +int32_t run_check(const CommonOptions& common, + ::rust::Vec config, + const ServeOrTestOptions& serveOrTest, + ::rust::Box process) { + return run(common, kj::mv(process), [&](CliMain& main) { + main.enableCheckMode(); + main.applyServeOrTestOptions(serveOrTest); + main.loadConfig(kj::mv(config)); + main.check(); + }); +} + } // namespace workerd::server::cli diff --git a/src/workerd/server/cli-main.h b/src/workerd/server/cli-main.h index 9d696db6a55..6244828d9d3 100644 --- a/src/workerd/server/cli-main.h +++ b/src/workerd/server/cli-main.h @@ -35,5 +35,9 @@ int32_t run_test(const CommonOptions& common, const ServeOrTestOptions& serveOrTest, const TestOptions& test, ::rust::Box process); +int32_t run_check(const CommonOptions& common, + ::rust::Vec config, + const ServeOrTestOptions& serveOrTest, + ::rust::Box process); } // namespace workerd::server::cli 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 c3a377a6bcd..ca65c6c21d7 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -52,7 +52,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)] @@ -61,6 +63,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, @@ -68,6 +95,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.", @@ -135,6 +185,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: @@ -186,6 +251,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/bridge.rs b/src/workerd/server/cli/bridge.rs index 3f5d632ce89..4211d17c458 100644 --- a/src/workerd/server/cli/bridge.rs +++ b/src/workerd/server/cli/bridge.rs @@ -123,6 +123,14 @@ pub mod ffi { test: &TestOptions, process: Box, ) -> Result; + /// `workerd check`: starts every service in `config` without binding any socket, reports + /// every config error, and exits 1 if there were any. + fn run_check( + common: &CommonOptions, + config: Vec, + serve_or_test: &ServeOrTestOptions, + process: Box, + ) -> Result; } extern "Rust" { diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 9f2e93e5143..82b475a0862 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -13,6 +13,7 @@ mod args; mod bridge; mod config; mod process; +mod project; mod socket_fd; mod watch; @@ -29,6 +30,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; @@ -98,6 +100,10 @@ fn run_compiled( ) } +#[expect( + clippy::too_many_lines, + reason = "one match arm per subcommand, each a straight line from options to the C++ driver" +)] fn run_command( program_name: &str, args: args::Main, @@ -114,14 +120,28 @@ fn run_command( 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 (serve, inherited_sockets) = serve_options::(serve); let process = Box::new(Process::new(executable, watcher, inherited_sockets)); - let config = load_config::(config, const_name.const_name, &process); + 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) + }; ffi::run_serve( &common, config.into_words(), @@ -130,6 +150,66 @@ fn run_command( 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 (serve, inherited_sockets) = serve_options::(serve); + let process = Box::new(Process::new(executable, watcher, inherited_sockets)); + 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); + }; + ffi::run_serve( + &common, + config.into_words(), + &serve_or_test.into(), + &serve, + process, + ) + } + args::Command::Check { + config, + const_name, + project, + mut serve_or_test, + } => { + check_serve_or_test::(&serve_or_test); + let watcher = watcher::(&serve_or_test, executable.as_ref()); + let process = Box::new(Process::new(executable, watcher, 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) + }; + ffi::run_check(&common, config.into_words(), &serve_or_test.into(), process) + } args::Command::Compile { config, const_name, @@ -189,6 +269,124 @@ fn run_command( } } +/// 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( @@ -449,6 +647,8 @@ impl args::Main { const fn command_name(&self) -> &'static str { match self.command { args::Command::Serve { .. } => "serve", + args::Command::Run { .. } => "run", + args::Command::Check { .. } => "check", args::Command::Compile { .. } => "compile", args::Command::Fuzzilli { .. } => "fuzzilli", args::Command::Test { .. } => "test", 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.c++ b/src/workerd/server/server.c++ index 1381a55ee38..b863a4acab0 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -6489,12 +6489,61 @@ void Server::taskFailed(kj::Exception&& exception) { fatalFulfiller->reject(kj::mv(exception)); } +namespace { + +size_t editDistance(kj::StringPtr a, kj::StringPtr b) { + // Levenshtein distance, case-insensitive. + auto lower = [](char c) -> char { return (c >= 'A' && c <= 'Z') ? c - 'A' + 'a' : c; }; + auto prev = kj::heapArray(b.size() + 1); + auto cur = kj::heapArray(b.size() + 1); + for (size_t j = 0; j <= b.size(); j++) prev[j] = j; + for (size_t i = 1; i <= a.size(); i++) { + cur[0] = i; + for (size_t j = 1; j <= b.size(); j++) { + size_t cost = lower(a[i - 1]) == lower(b[j - 1]) ? 0 : 1; + cur[j] = kj::min(kj::min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost); + } + for (size_t j = 0; j <= b.size(); j++) prev[j] = cur[j]; + } + return prev[b.size()]; +} + +// A hint for an error about a name that does not exist: ` Did you mean "x"?` naming the closest +// candidate, or empty when nothing is close enough to be worth suggesting. +kj::String didYouMean(kj::StringPtr name, kj::ArrayPtr candidates) { + kj::Maybe best; + size_t bestDistance = kj::maxValue; + size_t threshold = kj::max(size_t(2), name.size() / 3); + for (auto candidate: candidates) { + if (candidate == name) continue; + size_t distance = editDistance(name, candidate); + if (distance <= threshold && distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + KJ_IF_SOME(b, best) { + return kj::str(" Did you mean \"", b, "\"?"); + } + return kj::String(); +} + +} // namespace + +kj::Vector Server::serviceNames() { + kj::Vector names(services.size()); + for (auto& entry: services) { + names.add(entry.key); + } + return names; +} + kj::Rc Server::lookupService( config::ServiceDesignator::Reader designator, kj::String errorContext) { kj::StringPtr targetName = designator.getName(); Service* service = KJ_UNWRAP_OR(services.find(targetName), { reportConfigError(kj::str(errorContext, " refers to a service \"", targetName, - "\", but no such service is defined.")); + "\", but no such service is defined.", didYouMean(targetName, serviceNames()))); return invalidConfigServiceSingleton.addRef(); }).get(); @@ -6553,7 +6602,7 @@ kj::Rc Server::lookupActorClass( kj::StringPtr targetName = designator.getName(); Service* service = KJ_UNWRAP_OR(services.find(targetName), { reportConfigError(kj::str(errorContext, " refers to a service \"", targetName, - "\", but no such service is defined.")); + "\", but no such service is defined.", didYouMean(targetName, serviceNames()))); return invalidConfigActorClassSingleton.addRef(); }).get(); @@ -7497,6 +7546,49 @@ kj::Promise Server::run( } } +kj::Promise Server::check(jsg::V8System& v8System, config::Config::Reader config) { + TRACE_EVENT("workerd", "Server.check"); + + if (config.hasLogging()) { + auto logging = config.getLogging(); + loggingOptions.structuredLogging = StructuredLogging(logging.getStructuredLogging()); + if (logging.hasStdoutPrefix()) { + loggingOptions.stdoutPrefix = kj::ConstString(kj::str(logging.getStdoutPrefix())); + } + if (logging.hasStderrPrefix()) { + loggingOptions.stderrPrefix = kj::ConstString(kj::str(logging.getStderrPrefix())); + } + } else { + loggingOptions.structuredLogging = StructuredLogging(config.getStructuredLogging()); + } + + kj::HttpHeaderTable::Builder headerTableBuilder; + globalContext = kj::heap(*this, v8System, headerTableBuilder); + invalidConfigServiceSingleton = kj::rc(); + invalidConfigActorClassSingleton = kj::rc(); + + auto [fatalPromise, fatalFulfiller] = kj::newPromiseAndFulfiller(); + this->fatalFulfiller = kj::mv(fatalFulfiller); + + auto forkedDrainWhen = kj::Promise(kj::NEVER_DONE).fork(); + + // No bindSockets(): nothing is bound, so a check never conflicts with a running server. + co_await startServices(v8System, config, headerTableBuilder, forkedDrainWhen); + + // listenOnSockets() would resolve each socket's service; do the same without listening. + for (auto sock: config.getSockets()) { + kj::StringPtr name = sock.getName(); + if (!sock.hasAddress() && socketOverrides.find(name) == kj::none) { + reportConfigError(kj::str("Socket \"", name, + "\" has no address in the config, so must be specified on the " + "command line with `--socket-addr`.")); + } + lookupService(sock.getService(), kj::str("Socket \"", name, "\"")); + } + + auto ownHeaderTable = headerTableBuilder.build(); +} + // Configure and start the inspector socket, returning the port the socket started on. uint startInspector( kj::StringPtr inspectorAddress, Server::InspectorServiceIsolateRegistrar& registrar) { diff --git a/src/workerd/server/server.h b/src/workerd/server/server.h index ce3f82fa451..b30c2c70bf8 100644 --- a/src/workerd/server/server.h +++ b/src/workerd/server/server.h @@ -113,6 +113,11 @@ class Server final: private kj::TaskSet::ErrorHandler, private ChannelTokenHandl kj::StringPtr servicePattern = "*"_kj, kj::StringPtr entrypointPattern = "*"_kj); + // Starts every service in the config, compiling every Worker, without binding or listening on + // any socket, so that every config error is reported through the error callback. The sockets' + // service references are checked too. Backs `workerd check`. + kj::Promise check(jsg::V8System& v8System, config::Config::Reader conf); + struct Durable { kj::String uniqueKey; bool isEvictable; @@ -253,6 +258,9 @@ class Server final: private kj::TaskSet::ErrorHandler, private ChannelTokenHandl // request in flight. kj::Promise handleDrain(kj::Promise drainWhen); + // The names of every service defined so far, for "did you mean" hints in config errors. + kj::Vector serviceNames(); + kj::Own makeTlsContext(config::TlsOptions::Reader conf); kj::Promise> makeTlsNetworkAddress(config::TlsOptions::Reader conf, kj::StringPtr addrStr,