Generic environment runner for Node.js. Ported from the nitro env runner concept into a standalone package.
Note: Keep
AGENTS.mdupdated with project status and structure.
Note: Keep
README.mdusage section updated when adding/changing public API, CLI flags, or runner behavior.
src/
├── common/
│ ├── base-runner.ts # BaseEnvRunner abstract class
│ └── worker-utils.ts # AppEntry interface, resolveEntry(), parseServerAddress()
├── runners/
│ ├── node-worker/
│ │ ├── runner.ts # NodeWorkerEnvRunner
│ │ └── worker.ts # Built-in srvx worker (parentPort)
│ ├── node-process/
│ │ ├── runner.ts # NodeProcessEnvRunner
│ │ └── worker.ts # Built-in srvx worker (process.send)
│ ├── bun-process/
│ │ ├── runner.ts # BunProcessEnvRunner
│ │ └── worker.ts # Built-in srvx worker (Bun/Node.js)
│ ├── deno-process/
│ │ ├── runner.ts # DenoProcessEnvRunner
│ │ └── worker.ts # Built-in srvx worker (Deno)
│ ├── self/
│ │ └── runner.ts # SelfEnvRunner (in-process, no worker)
│ └── miniflare/
│ └── runner.ts # MiniflareEnvRunner (Cloudflare Workers via miniflare)
├── types.ts # Core interfaces
├── index.ts # Public API exports
├── loader.ts # Dynamic runner loader
├── manager.ts # RunnerManager for hot-reload
├── server.ts # EnvServer (high-level API with watch mode)
└── cli.ts # CLI entry point
src/vite.ts— Vite Environment API helpers:createViteHotChannel()(host-side HotChannel from runner RPC hooks) andcreateViteTransport()(worker-side ModuleRunner transport)src/types.ts— Core interfaces:EnvRunner,WorkerAddress,WorkerHooks,RunnerRPCHooks,RPCOptionssrc/common/base-runner.ts—BaseEnvRunnerabstract class +EnvRunnerData: shared logic for all runners (fetch proxy with exponential backoff, upgrade, message dispatch, socket cleanup)src/common/worker-utils.ts— Shared utilities for built-in workers:AppEntryinterface (with optionalwebsocket,upgrade, andipchooks),AppEntryIPC/AppEntryIPCContexttypes,resolveEntry()to dynamically import user entry,parseServerAddress()to extract host/port from srvx server,reloadEntryModule()for cache-busted re-import with IPC teardown/re-initsrc/runners/node-worker/runner.ts—NodeWorkerEnvRunnerextendsBaseEnvRunner: spawns Node.js Worker threads, data viaworkerDatasrc/runners/node-worker/worker.ts— Built-in srvx worker: readsdata.entryfromworkerData, starts srvx server, reports address viaparentPortsrc/runners/node-process/runner.ts—NodeProcessEnvRunnerextendsBaseEnvRunner: spawns a child process viafork(), supports customexecArgvsrc/runners/node-process/worker.ts— Built-in srvx worker: readsdata.entryfromENV_RUNNER_DATA, starts srvx server, reports address viaprocess.send()src/runners/bun-process/runner.ts—BunProcessEnvRunnerextendsBaseEnvRunner: usesBun.spawn()with IPC when under Bun, falls back to Node.jsfork()otherwisesrc/runners/bun-process/worker.ts— Built-in srvx worker: same as node-process worker (works on both Bun and Node.js)src/runners/deno-process/runner.ts—DenoProcessEnvRunnerextendsBaseEnvRunner: spawns adeno run --allow-allchild process with IPC via Node.jsspawn(). Data passed viaENV_RUNNER_DATAenv var (JSON). Supports customexecArgvsrc/runners/deno-process/worker.ts— Built-in srvx worker: same as node-process worker (works on Deno via Node.js compat)src/runners/self/runner.ts—SelfEnvRunnerextendsBaseEnvRunner: runs entry code in the same process using an in-memory channel registry onprocess.__envRunnerssrc/runners/miniflare/runner.ts—MiniflareEnvRunnerextendsBaseEnvRunner: runs entry in Cloudflare Workers runtime via miniflare. Overridesfetch()to usemf.dispatchFetch(). Uses in-memoryscript(no temp files),unsafeModuleFallbackServicefor module resolution, andunsafeEvalBindingfor hot-reload viareloadModule(). Requiresminiflarepeer dependencysrc/loader.ts—loadRunner(name, opts): dynamic loader that imports a runner by name (node-worker|node-process|bun-process|deno-process|self|miniflare) and returns anEnvRunnerinstancesrc/manager.ts—RunnerManager: proxy manager for hot-reload, message queueing, and listener forwarding across runner swapssrc/server.ts—EnvServerextendsRunnerManager: high-level API combining runner loading, watch mode (fs.watchwith 100ms debounce), and auto-reload on file changes. SupportswatchandwatchPathsoptionssrc/cli.ts— CLI entry point:env-runner <entry> [--runner] [--port] [--host] [-w/--watch]src/index.ts— Public API: re-exports types,BaseEnvRunner, concrete runners,SelfEnvRunner,RunnerManager,EnvServer, andloadRunner
BaseEnvRunner implements the shared EnvRunner lifecycle:
- Runner takes an optional
entryscript path (defaults to co-locatedworker.ts/.mjs) and spawns it (Worker thread, child process, or in-process) - Entry posts
{ address: { host, port } }or{ address: { socketPath } }when ready fetch()proxies HTTP requests to the address viahttpxy(retries with exponential backoff: 100ms → 1.6s, up to 5 attempts)upgrade()proxies WebSocket upgradessendMessage()/onMessage()/offMessage()for bidirectional messagingwaitForReady(timeout?)returns a promise that resolves when the runner becomes ready (address received)rpc(name, data?, opts?)sends a request-response message over IPC (auto-generates ID, handles timeout, error propagation)reloadModule(timeout?)re-imports the entry module without restarting the worker/process (cache-bustedimport(), IPC teardown/re-init)close()immediately terminates the worker/process and cleans up sockets
Subclasses implement abstract methods: sendMessage(), _hasRuntime(), _closeRuntime(), _runtimeType(), and runtime init.
Uses worker_threads.Worker. Entry communicates via parentPort.postMessage() / parentPort.on('message'). Data passed via workerData.
Uses child_process.fork(). Entry communicates via process.send() / process.on('message'). Data passed via ENV_RUNNER_DATA env var (JSON). Supports custom execArgv (e.g. --inspect).
Dual-runtime: uses Bun.spawn() with IPC callback when running under Bun, falls back to Node.js child_process.fork() otherwise. Data passed via ENV_RUNNER_DATA env var (JSON). Supports custom execArgv.
Spawns a Deno child process via Node.js child_process.spawn() with deno run --allow-all --node-modules-dir=auto and an IPC channel (stdio: ["pipe", "pipe", "pipe", "ipc"]). Data passed via ENV_RUNNER_DATA env var (JSON). Supports custom execArgv. Uses the same worker as node-process (Deno's Node.js compatibility layer handles process.send()/process.on("message")).
Runs entry code in the same process (no IPC, no forking). Uses an in-memory channel registry stored on process.__envRunners (Map). Entry modules retrieve their channel via query string: import(entry + '?__envRunnerId=<id>'). Communication uses queueMicrotask() to avoid synchronous re-entrancy. Exposes SelfRunnerChannel interface with data, send(), and onMessage().
Runs entry in the Cloudflare Workers runtime via miniflare. No worker file or HTTP proxy needed — overrides fetch() to call mf.dispatchFetch() directly. Accepts miniflareOptions for full Miniflare configuration (bindings, KV, D1, Durable Objects, etc.). Requires miniflare as a peer dependency.
Entry loading: Entry script path passed via data.entry. The runner generates an in-memory wrapper module (passed as script to Miniflare, no temp files) that imports the user entry and adds IPC glue. scriptPath is set to the entry's directory so workerd resolves relative imports correctly.
Module resolution: Uses unsafeModuleFallbackService + unsafeUseModuleFallbackService to resolve imports that workerd can't find on its own (e.g. imports from node_modules, parent directories, or cache-busted reload imports). The fallback reads files from disk relative to the entry directory. Supports cache-busting query strings (?t=<version>) for hot-reload.
Module transform pipeline: Optional transformRequest callback enables integration with Vite's (or any) transform pipeline. When provided, unsafeModuleFallbackService calls it with the resolved file path before falling back to raw disk reads. Returns { code: string } or null. This enables TS/JSX/etc. compilation on-the-fly without pre-bundling. When transformRequest is set, the wrapper skips static export * re-exports (uses dynamicOnly mode) to avoid miniflare's ModuleLocator pre-walking the import tree, and adds modulesRules for .ts/.tsx/.jsx/.mts extensions.
IPC: Full bidirectional IPC (ipc.onOpen, ipc.onMessage, ipc.onClose) via a persistent WebSocket pair. During init, dispatchFetch with upgrade: "websocket" establishes a WebSocketPair — the runner keeps the client end, the worker wrapper keeps the server end. All messaging (user messages, reload commands, shutdown) flows over this single persistent connection as JSON. No per-message dispatchFetch overhead.
Hot-reload: reloadModule() sends { type: "reload", version } over the WebSocket. The worker wrapper uses unsafeEvalBinding (__ENV_RUNNER_UNSAFE_EVAL__) to create a dynamic import() with a cache-busting query string. The module fallback service serves the fresh file from disk. Old entry's ipc.onClose() is called before swapping, new entry's ipc.onOpen() is called after. Worker sends { event: "module-reloaded" } back over the WebSocket when done.
Proxy manager wrapping a runner with hot-reload support:
reload(runner)— Swaps active runner, closes old one, preserves listeners- Message queueing —
sendMessage()queues when runner not ready, auto-flushes on ready - Listener forwarding —
onMessage()/offMessage()persist across runner swaps - Hook wrapping — Detects unexpected runner exits, forwards
onReady()/onClose()multi-listener hooks (Set-based, mirrorsonMessage/offMessagepattern) onClose(listener)/offClose(listener)— Multi-listener close eventsonReady(listener)/offReady(listener)— Multi-listener ready events- Returns 503 from
fetch()/upgrade()when no runner is active
High-level API extending RunnerManager with runner loading and file watching:
start()— Loads runner vialoadRunner()and optionally starts file watchersclose()— Stops watchers and closes the runnerwatch: true— Watches the entry file usingfs.watch()with 100ms debounce; on change, creates a new runner and callsreload()watchPaths— Additional directories/files to watch (supportsrecursive: true)onReload(listener)/offReload(listener)— Multi-listener reload events (Set-based)
Pre-built worker scripts co-located with their runners (src/runners/<name>/worker.ts) that let users provide a simple export default { fetch } entry module instead of manually implementing the IPC/server boilerplate. Each worker uses srvx to start a standard HTTP server.
export default {
fetch(request: Request): Response | Promise<Response> {
return new Response("Hello!");
},
websocket?: Partial<Hooks>, // Optional crossws WebSocket hooks (recommended)
upgrade?: (context: { node: { req: IncomingMessage, socket: Socket, head: Buffer } }) => void, // Optional raw WebSocket upgrade handler (Node.js only)
middleware?: [], // Optional srvx middleware
plugins?: [], // Optional srvx plugins
ipc?: {
onOpen?: (ctx: { sendMessage: (message: unknown) => void }) => void,
onMessage?: (message: unknown) => void,
onClose?: () => void,
},
};The websocket property uses crossws hooks for cross-platform WebSocket support. Each built-in worker adds the crossws srvx plugin when websocket is defined. Node.js workers use crossws/server/node, while bun/deno workers use crossws/server (auto-selects runtime). The upgrade property is a lower-level alternative for raw Node.js socket access.
The ipc property enables bidirectional messaging between the entry and the runner:
onOpen— Called when the IPC channel is established (before ready signal), receives a{ sendMessage }context for sending messages back to the runneronMessage— Called when the runner sends a user message (internal messages like ping/pong and shutdown are filtered out)onClose— Called when the runner is shutting down
Each IPC-based runner defaults to its co-located built-in worker, so entry is optional:
import { NodeProcessEnvRunner } from "env-runner";
// Uses default built-in worker automatically
const runner = new NodeProcessEnvRunner({
name: "my-app",
data: { entry: "./my-server.ts" },
});
// Or explicitly pass a custom entry
const runner2 = new NodeProcessEnvRunner({
name: "my-app",
entry: "/path/to/custom-worker.ts",
data: { entry: "./my-server.ts" },
});- Worker receives
data.entrypath (viaworkerDataorENV_RUNNER_DATA) - Dynamically imports the user's entry module (
resolveEntry()) - Starts a srvx server with
port: 0on127.0.0.1, adding crossws srvx plugin ifentry.websocketis defined - Wires
entry.upgrade()to the underlying Node.js HTTP server'supgradeevent (if defined) - Calls
entry.ipc.onOpen()with{ sendMessage }if IPC hooks are defined - Reports
{ address: { host, port } }via IPC - Forwards user messages to
entry.ipc.onMessage()(filters out internal ping/pong and shutdown) - Calls
entry.ipc.onClose()on shutdown before closing the server
Worker (entry) |
Runner |
|---|---|
env-runner/runners/node-worker/worker (default) |
NodeWorkerEnvRunner |
env-runner/runners/node-process/worker (default) |
NodeProcessEnvRunner |
env-runner/runners/bun-process/worker (default) |
BunProcessEnvRunner |
env-runner/runners/deno-process/worker (default) |
DenoProcessEnvRunner |
| (no worker) | SelfEnvRunner |
| (in-memory wrapper module) | MiniflareEnvRunner |
env-runner(.) — Types + all runners +RunnerManager+AppEntryenv-runner/runners/node-worker(./runners/node-worker) — Direct import ofNodeWorkerEnvRunnerenv-runner/runners/node-worker/worker(./runners/node-worker/worker) — Built-in srvx worker for Worker threadsenv-runner/runners/node-process(./runners/node-process) — Direct import ofNodeProcessEnvRunnerenv-runner/runners/node-process/worker(./runners/node-process/worker) — Built-in srvx worker for Node.js child processenv-runner/runners/bun-process(./runners/bun-process) — Direct import ofBunProcessEnvRunnerenv-runner/runners/bun-process/worker(./runners/bun-process/worker) — Built-in srvx worker for Bun/Node.js processenv-runner/runners/deno-process(./runners/deno-process) — Direct import ofDenoProcessEnvRunnerenv-runner/runners/deno-process/worker(./runners/deno-process/worker) — Built-in srvx worker for Deno processenv-runner/runners/self(./runners/self) — Direct import ofSelfEnvRunnerenv-runner/runners/miniflare(./runners/miniflare) — Direct import ofMiniflareEnvRunnerenv-runner/vite(./vite) — Vite Environment API helpers (createViteHotChannel,createViteTransport)
- Tests use vitest:
pnpm vitest run test/runners.test.ts— Parameterized test suite for all IPC-based runner implementations (NodeWorker, NodeProcess, BunProcess, DenoProcess). Runners requiring specific runtimes (bun, deno) are auto-skipped when the runtime is not availabletest/manager.test.ts— Tests forRunnerManagerlifecycle, hot-reload, message queueing, hook forwardingtest/miniflare.test.ts— Tests forMiniflareEnvRunner: Durable Object exports, IPC alongside custom exports, hot-reload viareloadModule(), IPC re-initialization after reloadtest/vite.test.ts— Tests for Vite helpers:createViteHotChannelmessage namespacing/filtering/on/off,createViteTransportconnect/send filtering- Test app fixture in
test/fixtures/app.mjs— Minimalexport default { fetch }entry for worker tests - Test app fixture in
test/fixtures/app-rpc.mjs— Entry with RPC handler forrpc()method tests - Test fixture in
test/fixtures/worker-do.mjs— Worker with Durable Object export + IPC for miniflare tests - Test fixture in
test/fixtures/app-upgrade.mjs— Entry with WebSocket upgrade handler for upgrade tests - Test fixture in
test/fixtures/app-websocket.mjs— Entry with crossws WebSocket hooks for websocket tests - Tests cover: lifecycle, fetch (GET/POST), WebSocket upgrade, crossws websocket, messaging, hooks, graceful close, inspect output, manager hot-reload, message queueing, miniflare hot-reload, waitForReady, vite helpers
pnpm build— Build with obuildpnpm dev— Vitest watch modepnpm test— Lint + typecheck + vitest with coveragepnpm typecheck— tsgo type checkingpnpm fmt— Format (automd + oxlint fix + oxfmt)pnpm lint— Lint check (oxlint + oxfmt check)pnpm release— Test + build + changelog + publish + git push
crossws— Cross-platform WebSocket hooks (used by built-in workers forwebsocketentry key)httpxy— HTTP/WebSocket proxysrvx— Universal server framework (used by built-in workers)miniflare— Cloudflare Workers simulator (optional peer dependency, required forMiniflareEnvRunner)
See also:
.agents/MINIFLARE.md— Miniflare internals,unsafeEvalBinding,unsafeModuleFallbackService, service bindings patterns See also:.agents/PLAN.vite-compat.md— Planned improvements for Vite Environment API compatibility (waitForReady, RPC, transport helpers)
- Co-located runner + worker — Each runner directory contains both
runner.tsandworker.ts(exceptself/which has no worker). Runners default to their co-located worker viaimport.meta.resolve("env-runner/runners/<name>/worker")whenentryis omitted - Message-driven readiness — Workers/processes post
{ address }to signal ready state - Immediate shutdown —
close()immediately terminates the worker/process (no graceful shutdown handshake) - Data passing: Worker threads use
workerData, processes useENV_RUNNER_DATAenv var (JSON), self runner uses in-memory channel, miniflare runner uses in-memoryscriptwithunsafeModuleFallbackServicefor module resolution - Socket cleanup —
_closeSocket()avoids deleting Windows named pipes and abstract sockets - Custom inspect —
[Symbol.for('nodejs.util.inspect.custom')]()shows pending/ready/closed status - Adding a new runner — Create
src/runners/<name>/runner.tsextendingBaseEnvRunner, optionally addworker.ts, add export path inpackage.json, add toloadersmap insrc/loader.ts, re-export fromsrc/index.ts