Skip to content

Add workerd run: one Worker from a file, --eval or stdin - #8084

Open
bartlomieju wants to merge 3 commits into
mainfrom
biwanczuk/workerd-run
Open

bartlomieju wants to merge 3 commits into
mainfrom
biwanczuk/workerd-run

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

workerd run serves or tests one Worker without a full config file. The code is a JavaScript or TypeScript file, or source given on the command line:

workerd run worker.ts
workerd run worker.ts --port 9000 --watch
workerd run worker.ts --compat-date 2026-01-01 --compat-flag nodejs_compat
workerd run worker.ts --autogate v8-fast-api
workerd run worker.ts --config template.capnp
workerd run --port 0 --eval "export default { fetch: () => new Response('hi') }"
workerd run --test --compat-flag nodejs_compat - <<'EOF'
export default { test() { if (1 + 1 !== 2) throw new Error('math is broken!'); } }
EOF

Code

  • A file is bundled with esbuild exactly as serve wrangler.jsonc bundles main. esbuild comes from WORKERD_ESBUILD, else the nearest node_modules/.bin/esbuild, else esbuild on PATH. TypeScript, relative imports and npm packages work, and --watch reloads when any input to the bundle changes.
  • Source comes from --eval <source> (-e), or from stdin when <file> is -, so a here-document works. It goes to esbuild's stdin as TypeScript. Its imports resolve from the working directory, and the bundle is written to the system temp directory, so nothing is left in the working directory. Reading stdin under --watch is a usage error, because a reload cannot read it again.
  • Under run, -e means --eval; --external-addr keeps its long form there, and -e keeps its meaning in every other command.

Serving and testing

  • By default the Worker gets the newest compatibility date this build supports and listens on localhost:8787, as a wrangler config does. If 8787 is unavailable, it listens on the next free port, trying up to 10; a port given with --port, --socket-addr or a template is used as it is, and fails if taken.
  • run prints Listening on <url> to stdout for each HTTP(S) socket once it listens. With --port 0 or a moved default port, that line gives the port in use.
  • --test runs the Worker's tests instead of serving it, as workerd test does, and exits with their result. It adds no socket and conflicts with --port.

Configuration

  • --compat-date, --compat-flag (repeatable) and --autogate (repeatable) set the Worker's options. --autogate takes the gate name with or without the workerd-autogate- prefix; an unknown name is a usage error listing the valid ones.

  • --config <template> provides bindings, Durable Objects and other services. The template is a Cap'n Proto config, compiled as for serve, with exactly one Worker that has no modules; the code becomes that Worker's modules.

    using Workerd = import "/workerd/workerd.capnp";
    const config :Workerd.Config = (
      services = [(name = "main", worker = (
        compatibilityFlags = ["nodejs_compat"],
        bindings = [(name = "GREETING", text = "hi")],
      ))],
    );

    --compat-date replaces the template's date. --compat-flag and --autogate add to the template's lists. A template without sockets gets one on localhost:<port>; one with sockets keeps them, --socket-addr overrides them, and --port is an error.

  • The rest of serve's options apply.

Errors

  • A file that isn't JS/TS or doesn't exist, an unknown autogate, or a template that isn't a Cap'n Proto file is a usage error.
  • A failed bundle, or a template without exactly one Worker that has no modules, is a config error, so --watch waits for a fix.

Implementation

  • cli/run.rs builds the config: it bundles the code and fills it into the template, or into a lone Worker named main.
  • cli/wrangler.rs exposes its bundling and module-writing helpers, and bundle() takes source on stdin as well as a file. For source, esbuild runs with PWD set to its working directory, so the source's path in esbuild's messages stays relative.
  • Config can be read back as a message, which is how the template is filled.
  • The server's Control prints the Listening on lines when RunOptions::announce is set. Only run sets it.
  • RunOptions::port_attempts lets run's default socket move to the next free port when its own can't be bound. The server tries the ports itself as it binds, so no other process can take a port between a check and the bind.
  • autogate_names() in factory/bootstrap.{h,c++} and cli/bridge.rs lets --autogate be checked.

serve wrangler.jsonc behaves as before, except that the esbuild-not-found message no longer assumes a wrangler config.

Out of scope

  • Python entry modules, which esbuild can't bundle.
  • --print-config.
  • On Windows, node_modules/.bin/esbuild is a shell script and npm's Windows launcher is esbuild.cmd, neither of which run can launch, so WORKERD_ESBUILD must point at esbuild.exe unless one is on PATH. The lookup is shared with serve wrangler.jsonc and can be fixed separately.

`workerd run <file>` 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.
@codecov-commenter

codecov-commenter commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 7.40741% with 400 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.33%. Comparing base (1dc3230) to head (cd7b5a6).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/server/cli/run.rs 0.00% 142 Missing ⚠️
src/workerd/server/cli/lib.rs 0.00% 111 Missing ⚠️
src/workerd/server/cli/wrangler.rs 0.00% 80 Missing ⚠️
src/workerd/server/server/listen/mod.rs 12.82% 34 Missing ⚠️
src/workerd/server/cli/config.rs 0.00% 12 Missing ⚠️
src/workerd/server/tests/run/greeting.ts 0.00% 8 Missing ⚠️
src/workerd/server/factory/bootstrap.c++ 0.00% 7 Missing ⚠️
src/workerd/server/server/run.rs 84.37% 5 Missing ⚠️
src/workerd/server/cli/bridge.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8084      +/-   ##
==========================================
- Coverage   68.46%   68.33%   -0.14%     
==========================================
  Files         722      724       +2     
  Lines      186735   187122     +387     
  Branches    29364    29365       +1     
==========================================
+ Hits       127843   127863      +20     
- Misses      41726    42092     +366     
- Partials    17166    17167       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell

jasnell commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

We would need --autogate= as an option also. It might also be good to point at a template config file that provides the details... workerd run foo.ts --config=template.capnp ... the idea is that template.capnp is not itself a complete workerd config file used with workerd serve template.capnp but is enough to provide compat flags, bindings, etc.

It might also be nice to allow the run {...} arg to be a here document... could make automated testing with an AI agent a lot easier, workerd run "export default { ... }" ...

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 <template> takes a Cap'n Proto config with exactly one Worker
that has no modules, which the code becomes. --compat-date replaces its
date; --compat-flag and --autogate add to its lists. A template without
sockets gets one on localhost:<port>; one with sockets keeps them, and
--port is then an error.

--autogate <name> enables an autogate, with or without the
workerd-autogate- prefix; an unknown name is a usage error listing this
build's, from a new autogate_names() in the bootstrap's bridge.

`run` prints `Listening on <url>` to stdout for each HTTP(S) socket,
through the server's Control when RunOptions::announce is set, so
--port 0 is usable from a script. When no port is given and 8787 cannot
be bound, the server tries the next ports, 10 in all
(RunOptions::port_attempts); the search happens as it binds, so no
other process can take a port between a check and the bind.

The run logic moves out of wrangler.rs into run.rs. wrangler.rs exposes
its bundling and module-writing helpers, and bundle() takes source as
well as a file; `serve wrangler.jsonc` is unchanged. For source, esbuild
gets PWD set to its working directory, so the source's path in its
messages does not climb to `/` when the inherited PWD is a symlinked
spelling of that directory.

Tests: unit tests for the arguments, filling a template, bundling
source and the port search; //src/workerd/server/tests/run:run-test
runs the binary with the repository's native esbuild.
@bartlomieju bartlomieju changed the title server: add workerd run Add workerd run: one Worker from a file, --eval or stdin Oct 30, 2026
@bartlomieju
bartlomieju marked this pull request as ready for review October 30, 2026 12:51
@bartlomieju
bartlomieju requested review from a team as code owners October 30, 2026 12:51

@clanky-the-clanker clanky-the-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Approved with comments — The design and tests are solid. There is one WARNING: --eval/stdin bundles go to a predictable directory in the shared temp dir, and another local user could take that directory over. Two smaller non-blocking notes.

Findings: 1 warning · 1 info · 1 suggestion (1 is an inline comment on the diff)

Not opened by the correctness review: 1 changed file it was meant to read
  • src/workerd/server/factory/bootstrap.h

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #8084.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## WARNING (1)

### [Security] Another local user can take over the bundle directory for --eval/stdin source: its name is predictable (`workerd-bundle-<pid>-<n>` in the shared temp dir) and `create_dir_all` reuses an existing directory, so a pre-created writable directory lets them replace the bundled code.
- **File:** src/workerd/server/cli/wrangler.rs:333

## INFO (1)

### [Documentation] The README's `workerd run` section could note that on Windows, WORKERD_ESBUILD must point at esbuild.exe unless an esbuild executable is on PATH.
- **File:** README.md:118

## SUGGESTION (1)

### [Code Quality] If esbuild exits before reading the source, the stdin write fails with BrokenPipe and returns early, hiding esbuild's stderr. Ignore BrokenPipe and still call wait_with_output().
- **File:** src/workerd/server/cli/wrangler.rs:408

🔒 Security: ⚠️ 1 warning

The bundle directory for --eval/stdin source has a predictable name in the shared system temp dir.

Recommendation: 📝 Minor Comments

  • [WARNING] Another local user can take over the bundle directory for --eval/stdin source: its name is predictable (workerd-bundle-<pid>-<n> in the shared temp dir) and create_dir_all reuses an existing directory, so a pre-created writable directory lets them replace the bundled code. (src/workerd/server/cli/wrangler.rs:333)
View full review
  • src/workerd/server/cli/wrangler.rs:326-336: For Input::Source, the bundle is written to temp_dir()/workerd-bundle-<pid>-<counter>.
    • The name can be guessed, and the counter starts at 0.
    • The directory is prepared with remove_dir_all followed by create_dir_all, and create_dir_all accepts a directory that already exists.
  • On a host where several users share /tmp, another local user can create that directory ahead of time and make it world-writable. No race is needed. The sticky bit stops the victim's remove_dir_all from deleting it.
    • esbuild then writes index.js into a directory the attacker controls.
    • The attacker can replace that file before workerd reads it back, so their code runs as the victim's Worker.
  • Fix: create a directory with a random name, atomically and owner-only. For example, use fs::create_dir with mode 0700 and a random suffix, retrying on AlreadyExists, or a tempfile::TempDir-style helper. Do not call remove_dir_all beforehand.
  • The rest of the change keeps the trust model of serve wrangler.jsonc:
    • run only bundles files the invoking user names.
    • The default socket binds localhost.
    • The port fallback only moves forward from the configured port.

📋 Code Quality: 💬 1 suggestion

One minor error-reporting issue in the esbuild stdin path.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] If esbuild exits before reading the source, the stdin write fails with BrokenPipe and returns early, hiding esbuild's stderr. Ignore BrokenPipe and still call wait_with_output(). (src/workerd/server/cli/wrangler.rs:408)
View full review
  • src/workerd/server/cli/wrangler.rs:405-411: The Input::Source path writes the source to esbuild's stdin with stdin.write_all(...)?, inside the and_then closure.
    • If esbuild exits before it reads stdin, the write fails with BrokenPipe and wait_with_output() is never called. esbuild might exit early because it rejects an option, or because WORKERD_ESBUILD points at the wrong binary.
    • The user then sees <esbuild>: Broken pipe instead of esbuild's own stderr.
  • Fix: ignore a BrokenPipe error from the write, then still call wait_with_output(), so the real exit status and stderr are reported.

📝 Documentation: 💬 1 info

The README could mention the Windows esbuild limitation.

Recommendation: 💬 Optional Nits

  • [INFO] The README's workerd run section could note that on Windows, WORKERD_ESBUILD must point at esbuild.exe unless an esbuild executable is on PATH. (README.md:118)
View full review
  • README.md: The new workerd run section does not mention the Windows limitation described in the pull request.
    • run cannot launch node_modules/.bin/esbuild or esbuild.cmd.
    • So WORKERD_ESBUILD must point at esbuild.exe, unless an esbuild executable is on PATH.
  • A one-line note would save Windows users some confusion.

✅ All other checks passed

📖 AGENTS.md: ✅ LGTM

The src/workerd/server/AGENTS.md updates match the change.


⚡ Performance: ✅ LGTM

The port fallback is capped, bundling runs once per config load, and the rest of the change is one-off CLI work.


Reviewed commit: cd7b5a6a

);
let out = match input {
Input::File(_) => root.join(".wrangler/tmp").join(dir),
Input::Source { .. } => std::env::temp_dir().join(format!("workerd-{dir}")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING · Security

Another local user can take over the bundle directory for --eval/stdin source: its name is predictable (workerd-bundle-<pid>-<n> in the shared temp dir) and create_dir_all reuses an existing directory, so a pre-created writable directory lets them replace the bundled code.

For source given with --eval/stdin, the output directory is temp_dir()/workerd-bundle-<pid>-<counter>, and that name can be guessed. On a multi-user host, another user can create it ahead of time with mode 0777. Because of the sticky bit, remove_dir_all cannot delete it, and create_dir_all then accepts it. esbuild writes index.js there, and the other user can replace it before workerd reads it, so their code runs as this Worker. Create the directory with a random name, atomically (fs::create_dir, mode 0700, retry on AlreadyExists), instead of remove-then-create_dir_all.

@jasnell

jasnell commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Given that workerd serve, workerd test, and workerd bench are already things... I wonder if the new workerd run is actually needed... we could instead use new arguments off the existing subcommands.

workerd serve --run worker.ts
workerd serve --run worker.ts --port 9000 --watch
workerd serve --run run worker.ts --compat-date 2026-01-01 --compat-flag nodejs_compat
workerd serve --run worker.ts --autogate v8-fast-api
workerd serve --run worker.ts --config template.capnp
workerd serve --eval "export default { fetch: () => new Response('hi') }" --port 0
workerd test --run worker-with-tests.ts
workerd test --eval "-" --compat-flag nodejs_compat
workerd bench --run worker-with-bench.ts
workerd bench --eval "..."

@bartlomieju

Copy link
Copy Markdown
Collaborator Author

@jasnell sure, we can do that. I'll wait for some more feedback from the team before iterating further

@ry ry left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should add this yet.

  • workerd test already exists.
  • Most applications need bindings, and bindings have to come from a config file. Without one, workerd run index.js has no well-defined behavior.
  • We should be wary of adding subcommands, especially ones without well-defined behavior.
  • Probably the config file should be the entrypoint.
  • I do want to support workerd config.json as an alias for workerd serve config.json
  • The most obvious direction for workerd run is as an alias for serve. But let's hold off on run, since we may want it for something else later.

@dom96

dom96 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

I think this is great.

I do want to support workerd config.json as an alias for workerd serve config.json

Maybe it would make sense to support workerd index.ts/workerd serve index.ts in that case too then?

Python entry modules, which esbuild can't bundle.

Shouldn't need to bundle anything for Python. Would love to see .py files just work (TM) with this command.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants