Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/workerd/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`, 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 `<workerd/server/cli/bridge.rs.h>` |

### `server/` (`workerd-server`)
Expand Down
36 changes: 36 additions & 0 deletions src/workerd/server/cli/args-test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/workerd/server/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -156,6 +176,23 @@ pub enum Command {
},
}

const RUN_HELP: &str = "\
Serves <file> 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');
}
}
<file> 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 <file> 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 <config-file>. <filter>, if given, specifies exactly which \
tests to run. It has one of the following formats:
Expand Down Expand Up @@ -228,6 +265,21 @@ pub struct ConfigFileArgs {
pub config_file: String,
}

#[derive(Args, Debug)]
pub struct RunArgs {
/// Listen on localhost:<port>.
#[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<String>,

/// A compatibility flag for the Worker. May be repeated.
#[arg(long = "compat-flag", value_name = "flag")]
pub compat_flags: Vec<String>,
}

#[derive(Args, Debug)]
pub struct ConstNameArg {
#[arg(value_name = "const-name")]
Expand Down
59 changes: 59 additions & 0 deletions src/workerd/server/cli/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ fn run_command(args: args::Main, executable: Option<Executable>) -> 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,
Expand Down Expand Up @@ -377,6 +383,59 @@ fn load_config<T: CommandFactory>(
config_errors(&compiled.errors, process)
}

/// `workerd run <file>`: 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<Executable>,
) -> workerd_server::Result<i32> {
type T = args::Main;

check_serve_or_test::<T>(serve_or_test);
let watcher = watcher::<T>(serve_or_test, executable.as_ref());
let (options, inherited_sockets) = serve_options::<T>(serve, serve_or_test);
let process = Process::new(executable, watcher, inherited_sockets);
let config = load_file::<T>(file, run, &process)?;
entry::serve(
verbose,
config.into_words(),
serve_or_test.into(),
options,
watching(process),
)
}

/// The config of `workerd run <file>`. 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<T: CommandFactory>(
file: &str,
run: args::RunArgs,
process: &Process,
) -> workerd_server::Result<Config> {
if !wrangler::is_runnable(file) {
usage_error::<T>(format!(
"{file}: Expected a JavaScript or TypeScript file (.{}).",
wrangler::RUN_EXTENSIONS.join(", .")
));
}
if !std::path::Path::new(file).is_file() {
usage_error::<T>(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) -> ! {
Expand Down
64 changes: 64 additions & 0 deletions src/workerd/server/cli/wrangler-test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<config::Reader>().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() {
Expand Down
Loading
Loading