From 1565d7649645702e92fa30f042108ee590b206dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 29 Oct 2026 11:29:21 +0100 Subject: [PATCH 1/2] Add workerd run `workerd run ` serves one JavaScript or TypeScript file as a Worker with no config file. It is served as a wrangler config whose only field is `main`: the file is bundled with esbuild, found the same way (WORKERD_ESBUILD, else the nearest node_modules/.bin/esbuild, else PATH), so TypeScript, relative imports and npm packages work, and --watch reloads on any input to the bundle. The Worker gets the newest compatibility date this build supports and listens on localhost:8787, as a wrangler config does. --port, --compat-date and --compat-flag change them, and the rest of `serve`'s options apply. wrangler.rs's load() is split into parsing and build(), which load_file() reuses with a synthesized config and the socket address as a parameter. The esbuild-not-found message no longer assumes a wrangler config. --- README.md | 5 ++ src/workerd/server/AGENTS.md | 2 +- src/workerd/server/cli/args-test.rs | 36 +++++++++++ src/workerd/server/cli/args.rs | 52 +++++++++++++++ src/workerd/server/cli/lib.rs | 59 +++++++++++++++++ src/workerd/server/cli/wrangler-test.rs | 64 +++++++++++++++++++ src/workerd/server/cli/wrangler.rs | 85 +++++++++++++++++++++---- 7 files changed, 290 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8204b119d7b..3528c4bdb96 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ are `name`, `main`, `compatibility_date`, `compatibility_flags`, `vars`, `durable_objects`, `migrations`, `no_bundle` and `$schema`; any other field is an error. +A Worker that needs no configuration at all can be served from its file: +`workerd run worker.ts` bundles it with esbuild and serves it on +`localhost:8787` with the newest compatibility date the build supports. See +`workerd run --help` for its options. + A simple "Hello World!" config file might look like: ```capnp diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index 16a31a8ea43..d21788e9d8c 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -58,7 +58,7 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` | `process.rs` | Executable discovery, the compiled-binary format, `Process` (the files to watch, re-exec) | | `socket_fd.rs` | `--socket-fd` / `--control-fd` descriptors, made owned; the platform halves are `socket_fd/{unix,windows}.rs` | | `watch.rs` | `--watch`: the file watcher | -| `wrangler.rs` | A `wrangler.json(c)` config: the supported fields translated into a `Config`, `main` bundled with esbuild (or scanned under `no_bundle`) | +| `wrangler.rs` | A `wrangler.json(c)` config: the supported fields translated into a `Config`, `main` bundled with esbuild (or scanned under `no_bundle`); `workerd run `, served as a wrangler config with only `main` | | `bridge.rs` | The cxx bridge (`workerd::server::cli`) to `config-compiler.h` and the build facts in `factory/bootstrap.h`; header `` | ### `server/` (`workerd-server`) diff --git a/src/workerd/server/cli/args-test.rs b/src/workerd/server/cli/args-test.rs index 4787dcaa452..4ae28fc4f26 100644 --- a/src/workerd/server/cli/args-test.rs +++ b/src/workerd/server/cli/args-test.rs @@ -42,6 +42,42 @@ fn wd_test_invocation() { ); } +#[test] +fn run_invocation() { + let Command::Run { + file, + run, + serve_or_test, + serve, + } = parse(&[ + "run", + "app.ts", + "--port=9000", + "--compat-date=2026-01-01", + "--compat-flag=nodejs_compat", + "--compat-flag=experimental", + "--watch", + "--socket-addr=http=*:0", + ]) + .command + else { + panic!("expected run") + }; + assert_eq!(file, "app.ts"); + assert_eq!(run.port, 9000); + assert_eq!(run.compat_date.as_deref(), Some("2026-01-01")); + assert_eq!(run.compat_flags, ["nodejs_compat", "experimental"]); + assert!(serve_or_test.watch); + assert_eq!(serve.socket_addr_overrides.len(), 1); + + let Command::Run { run, .. } = parse(&["run", "app.js"]).command else { + panic!("expected run") + }; + assert_eq!(run.port, 8787); + assert_eq!(run.compat_date, None); + assert!(run.compat_flags.is_empty()); +} + #[test] fn serve_options() { let Command::Serve { diff --git a/src/workerd/server/cli/args.rs b/src/workerd/server/cli/args.rs index 2a809003f84..554a1873dc8 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -77,6 +77,26 @@ pub enum Command { serve: ServeArgs, }, + /// serve one JavaScript or TypeScript file as a Worker, with no config file + #[command( + long_about = "Serves one JavaScript or TypeScript file as a Worker, with no config file.", + after_long_help = RUN_HELP, + )] + Run { + /// The Worker's entry module. + #[arg(value_name = "file")] + file: String, + + #[command(flatten)] + run: RunArgs, + + #[command(flatten)] + serve_or_test: ServeOrTestArgs, + + #[command(flatten)] + serve: ServeArgs, + }, + /// create a self-contained binary #[command( long_about = "Builds a self-contained binary from a config.", @@ -156,6 +176,23 @@ pub enum Command { }, } +const RUN_HELP: &str = "\ +Serves on http://localhost:8787 (see --port) as a Worker that has no bindings and the \ +newest compatibility date this build supports (see --compat-date). Example: + export default { + async fetch(request, env, ctx) { + return new Response('Hello World'); + } + } + is bundled with esbuild, as `wrangler` bundles a Worker's `main`: from WORKERD_ESBUILD, \ +else the nearest node_modules/.bin/esbuild, else `esbuild` on PATH. So TypeScript, and the npm \ +packages imports, work. With --watch, the server reloads when any file in the bundle \ +changes. + +For bindings, Durable Objects and other configuration, serve a wrangler.jsonc or a Cap'n Proto \ +config with `serve` instead. +"; + 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: @@ -228,6 +265,21 @@ pub struct ConfigFileArgs { pub config_file: String, } +#[derive(Args, Debug)] +pub struct RunArgs { + /// Listen on localhost:. + #[arg(long, value_name = "port", default_value_t = crate::wrangler::DEFAULT_PORT)] + pub port: u16, + + /// The Worker's compatibility date. The default is the newest this build supports. + #[arg(long, value_name = "date")] + pub compat_date: Option, + + /// A compatibility flag for the Worker. May be repeated. + #[arg(long = "compat-flag", value_name = "flag")] + pub compat_flags: Vec, +} + #[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 67d262307ea..118a20d49db 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -129,6 +129,12 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv watching(process), ) } + args::Command::Run { + file, + run, + serve_or_test, + serve, + } => run_file(verbose, &file, run, &serve_or_test, serve, executable), args::Command::Compile { config, const_name, @@ -377,6 +383,59 @@ fn load_config( config_errors(&compiled.errors, process) } +/// `workerd run `: serves one file as a Worker (`wrangler::load_file`). +fn run_file( + verbose: bool, + file: &str, + run: args::RunArgs, + serve_or_test: &args::ServeOrTestArgs, + serve: args::ServeArgs, + executable: Option, +) -> workerd_server::Result { + type T = args::Main; + + 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_file::(file, run, &process)?; + entry::serve( + verbose, + config.into_words(), + serve_or_test.into(), + options, + watching(process), + ) +} + +/// The config of `workerd run `. A file that does not exist or is not JavaScript or +/// TypeScript is a usage error; a failed bundle is a config error, which `--watch` waits out. +fn load_file( + file: &str, + run: args::RunArgs, + process: &Process, +) -> workerd_server::Result { + if !wrangler::is_runnable(file) { + usage_error::(format!( + "{file}: Expected a JavaScript or TypeScript file (.{}).", + wrangler::RUN_EXTENSIONS.join(", .") + )); + } + if !std::path::Path::new(file).is_file() { + usage_error::(format!("{file}: No such file.")); + } + let options = wrangler::RunOptions { + port: run.port, + compatibility_date: match run.compat_date { + Some(date) => date, + None => server::newest_compat_date()?, + }, + compatibility_flags: run.compat_flags, + }; + Ok(wrangler::load_file(file, options, process) + .unwrap_or_else(|error| config_errors(&[format!("{file}: {error}")], process))) +} + /// Prints errors in the config. Without `--watch` they end the process; with it the process waits /// for the files to change and reloads. fn config_errors(errors: &[impl Display], process: &Process) -> ! { diff --git a/src/workerd/server/cli/wrangler-test.rs b/src/workerd/server/cli/wrangler-test.rs index 1757907a433..5beb40f22b3 100644 --- a/src/workerd/server/cli/wrangler-test.rs +++ b/src/workerd/server/cli/wrangler-test.rs @@ -342,6 +342,70 @@ echo '{"inputs":{"index.ts":{},"lib.ts":{},"node_modules/x/index.js":{}}}' > "$m assert!(!dir.join(".wrangler/tmp").read_dir().unwrap().any(|_| true)); } +#[test] +fn runnable_files_are_javascript_or_typescript() { + for file in ["app.js", "app.mjs", "src/app.ts", "APP.TSX"] { + assert!(is_runnable(file), "{file}"); + } + for file in ["app.py", "wrangler.jsonc", "app", "README.md"] { + assert!(!is_runnable(file), "{file}"); + } +} + +#[cfg(unix)] +#[test] +fn run_serves_the_bundle_of_one_file() { + use std::os::unix::fs::PermissionsExt; + + let _spawn = SPAWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = project(&[("app.ts", "export default {}")]); + // Found as the project's own esbuild, as `npm i -D esbuild` installs it. + let esbuild = stub_esbuild( + &dir, + r#"echo 'export default {}' > "$out/index.js" +echo '{"inputs":{"app.ts":{}}}' > "$meta""#, + ); + let bin = dir.join("node_modules/.bin"); + fs::create_dir_all(&bin).unwrap(); + fs::rename(&esbuild, bin.join("esbuild")).unwrap(); + fs::set_permissions(bin.join("esbuild"), fs::Permissions::from_mode(0o755)).unwrap(); + + let options = RunOptions { + port: 9000, + compatibility_date: "2026-01-01".to_owned(), + compatibility_flags: vec!["nodejs_compat".to_owned()], + }; + let process = Process::new(None, None, Vec::new()); + let message = read(load_file(dir.join("app.ts").to_str().unwrap(), options, &process).unwrap()); + + let config = message.get_root::().unwrap(); + let services = config.get_services().unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services.get(0).get_name().unwrap(), "main"); + let service::Which::Worker(worker) = services.get(0).which().unwrap() else { + panic!("expected a worker service") + }; + let worker = worker.unwrap(); + assert_eq!(worker.get_compatibility_date().unwrap(), "2026-01-01"); + let flags = worker.get_compatibility_flags().unwrap(); + assert_eq!(flags.len(), 1); + assert_eq!(flags.get(0).unwrap(), "nodejs_compat"); + let worker::Which::Modules(modules) = worker.which().unwrap() else { + panic!("expected a modules worker") + }; + let modules = modules.unwrap(); + assert_eq!(modules.len(), 1); + assert_eq!(modules.get(0).get_name().unwrap(), "index.js"); + assert_eq!(worker.get_bindings().unwrap().len(), 0); + let socket = config.get_sockets().unwrap().get(0); + assert_eq!(socket.get_address().unwrap(), "localhost:9000"); + assert_eq!(socket.get_service().unwrap().get_name().unwrap(), "main"); + let args = fs::read_to_string(dir.join("args.txt")).unwrap(); + assert!(args.lines().any(|l| l == "app.ts"), "{args}"); +} + #[cfg(unix)] #[test] fn failed_bundle_watches_sources_and_missing_main() { diff --git a/src/workerd/server/cli/wrangler.rs b/src/workerd/server/cli/wrangler.rs index c0b6ac54769..18adb458f4e 100644 --- a/src/workerd/server/cli/wrangler.rs +++ b/src/workerd/server/cli/wrangler.rs @@ -15,6 +15,10 @@ //! Durable Objects are stored on disk under `.wrangler/state/workerd/` next to the config, one //! directory per class, named `-` by its storage key (see [`do_classes`]). The server //! listens on `localhost:8787`, as `wrangler dev` does; `--socket-addr http=` overrides it. +//! +//! `workerd run ` ([`load_file`]) is served as a wrangler config with `` as its +//! `main` and nothing else: bundled with esbuild, its bundle in `.wrangler/tmp` next to it while +//! it is read. use std::collections::BTreeMap; use std::fs; @@ -29,7 +33,8 @@ use crate::config::Config; use crate::config::words_from_bytes; use crate::process::Process; -const DEFAULT_ADDRESS: &str = "localhost:8787"; +/// The port the server listens on unless told otherwise, as `wrangler dev` does. +pub const DEFAULT_PORT: u16 = 8787; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -110,26 +115,81 @@ pub fn is_wrangler_config(path: &str) -> bool { ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("jsonc") } +/// The `Config` of the wrangler config at `path`, registering every file it depends on with +/// `process`'s watcher. pub fn load(path: &str, process: &Process) -> Result { - let watch = |p: &Path| { - process - .watch_file(p.as_os_str().as_encoded_bytes()) - .map_err(|e| e.description().to_owned()) - }; + let watch = watcher(process); let file = fs::canonicalize(path).map_err(|e| e.to_string())?; watch(&file)?; let source = fs::read_to_string(&file).map_err(|e| e.to_string())?; let wrangler: Wrangler = serde_json::from_str(&strip_jsonc(&source)).map_err(|e| e.to_string())?; let root = file.parent().unwrap_or(&file); + build( + &wrangler, + root, + &format!("localhost:{DEFAULT_PORT}"), + &watch, + ) +} + +/// What `workerd run` takes besides the file: the command line's choices. +pub struct RunOptions { + pub port: u16, + pub compatibility_date: String, + pub compatibility_flags: Vec, +} + +/// The extensions of the entry modules `workerd run` accepts: the JavaScript and TypeScript +/// files esbuild bundles. +pub const RUN_EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx"]; + +/// Whether `workerd run` accepts `path` as an entry module, by its extension. +pub fn is_runnable(path: &str) -> bool { + let ext = Path::new(path).extension().unwrap_or_default(); + RUN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) +} + +/// The `Config` of `workerd run `: a wrangler config with `file` as its `main` and nothing +/// else, so `file` is bundled with esbuild, and listening on `localhost:`. +pub fn load_file(file: &str, options: RunOptions, process: &Process) -> Result { + let path = fs::canonicalize(file).map_err(|e| e.to_string())?; + let (Some(root), Some(main)) = (path.parent(), path.file_name()) else { + return Err("not a file.".to_owned()); + }; + let wrangler = Wrangler { + _schema: None, + name: None, + main: main.to_string_lossy().into_owned(), + compatibility_date: options.compatibility_date, + compatibility_flags: options.compatibility_flags, + vars: BTreeMap::new(), + durable_objects: None, + migrations: Vec::new(), + no_bundle: false, + }; + let address = format!("localhost:{}", options.port); + build(&wrangler, root, &address, &watcher(process)) +} + +/// Registers a file with `process`'s watcher, if it has one. +fn watcher(process: &Process) -> impl Fn(&Path) -> Result<(), String> { + |p: &Path| { + process + .watch_file(p.as_os_str().as_encoded_bytes()) + .map_err(|e| e.description().to_owned()) + } +} +/// The `Config` of `wrangler`, whose paths are relative to `root`, serving on `address`. +fn build(wrangler: &Wrangler, root: &Path, address: &str, watch: Watch) -> Result { let sources = if wrangler.no_bundle { - unbundled(root, &wrangler.main, &watch)? + unbundled(root, &wrangler.main, watch)? } else { - bundle(&esbuild(root), root, &wrangler.main, &watch)? + bundle(&esbuild(root), root, &wrangler.main, watch)? }; - let classes = do_classes(&wrangler)?; + let classes = do_classes(wrangler)?; let name = wrangler.name.clone().unwrap_or_else(|| "main".to_owned()); let storage = root.join(".wrangler/state/workerd"); if !classes.is_empty() { @@ -210,7 +270,7 @@ pub fn load(path: &str, process: &Process) -> Result { let mut socket = conf.init_sockets(1).get(0); socket.set_name("http"); - socket.set_address(DEFAULT_ADDRESS); + socket.set_address(address); socket.reborrow().init_http(); socket.init_service().set_name(&name); @@ -341,8 +401,9 @@ fn run_esbuild( .output() .map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => format!( - "esbuild not found ({}). wrangler configs are bundled with esbuild: install it \ - (`npm i -D esbuild`), set WORKERD_ESBUILD to its path, or set \"no_bundle\": true.", + "esbuild not found ({}). Workers are bundled with esbuild: install it \ + (`npm i -D esbuild`) or set WORKERD_ESBUILD to its path. A wrangler config can \ + set \"no_bundle\": true instead.", esbuild.display() ), _ => format!("{}: {e}", esbuild.display()), From cd7b5a6a85c34f430782f423570000aea095ed50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Fri, 30 Oct 2026 13:32:50 +0100 Subject: [PATCH 2/2] workerd run: source, tests, templates, autogates, port fallback Source can be given instead of a file, with --eval (-e) or on stdin as `-`, so a here-document works. It is bundled with esbuild through its stdin as TypeScript, its imports resolved from the working directory and its bundle written under the system's temporary directory. Stdin under --watch is a usage error: a reload cannot read it again. Under `run`, -e is --eval; --external-addr keeps its long form. --test runs the Worker's tests instead of serving it and exits with their result. It adds no socket and conflicts with --port. --config