
Security News
PolinRider: North Korea-Linked Supply Chain Campaign Expands Across Open Source Ecosystems
PolinRider expands across npm, Packagist, Go modules, and Chrome extensions, using hidden loaders to target developer environments.
env-runner
Advanced tools
Generic environment runner for JavaScript runtimes. Run your server apps across Node.js worker threads, child processes, Bun, Deno, Cloudflare Workers (via miniflare), Vercel, Netlify, or in-process — with hot-reload, WebSocket proxying, and bidirectional messaging.
Create a server entry module that exports a fetch handler:
// app.ts
export default {
fetch(request: Request) {
return new Response("Hello!");
},
};
The quickest way to run your app:
npx env-runner app.ts
Flags:
| Flag | Description | Default |
|---|---|---|
--runner <name> | Runner to use (node-worker, node-process, bun-process, deno-process, self, miniflare) | node-process |
--port <port> | Port to listen on | 3000 |
--host <host> | Host to bind to | localhost |
-w, --watch | Watch entry file for changes and auto-reload |
EnvServer)High-level API that combines runner loading, file watching, and auto-reload:
import { serve } from "srvx";
import { EnvServer } from "env-runner";
const envServer = new EnvServer({
runner: "node-process",
entry: "./app.ts",
watch: true,
watchPaths: ["./src"],
});
envServer.onReady((_runner, address) => {
console.log(`Worker ready on ${address?.host}:${address?.port}`);
});
envServer.onReload(() => {
console.log("Reloaded!");
});
await envServer.start();
// Use with any HTTP server
const server = serve({
fetch: (request) => envServer.fetch(request),
});
RunnerManager)Proxy manager for hot-reload with message queueing and listener forwarding:
import { RunnerManager, NodeProcessEnvRunner } from "env-runner";
const manager = new RunnerManager();
manager.onReady((_runner, address) => {
console.log("Ready:", address);
});
// Load initial runner
const runner = new NodeProcessEnvRunner({
name: "my-app",
data: { entry: "./app.ts" },
});
await manager.reload(runner);
// Proxy requests
const response = await manager.fetch("http://localhost/hello");
// Hot-reload with a new runner
const newRunner = new NodeProcessEnvRunner({
name: "my-app",
data: { entry: "./app.ts" },
});
await manager.reload(newRunner); // old runner is closed automatically
// Bidirectional messaging (queued until runner is ready)
manager.sendMessage({ type: "config", value: 42 });
manager.onMessage((msg) => console.log("From worker:", msg));
await manager.close();
Use runners directly for lower-level control:
import { NodeWorkerEnvRunner } from "env-runner/runners/node-worker";
import { NodeProcessEnvRunner } from "env-runner/runners/node-process";
import { BunProcessEnvRunner } from "env-runner/runners/bun-process";
import { DenoProcessEnvRunner } from "env-runner/runners/deno-process";
import { SelfEnvRunner } from "env-runner/runners/self";
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
import { VercelEnvRunner } from "env-runner/runners/vercel";
import { NetlifyEnvRunner } from "env-runner/runners/netlify";
All runners implement the EnvRunner interface:
const runner = new NodeProcessEnvRunner({
name: "my-app",
data: { entry: "./app.ts" },
hooks: {
onReady: (runner, address) => console.log("Listening on", address),
onClose: (runner, cause) => console.log("Closed", cause),
},
execArgv: ["--inspect"], // Node.js flags (process-based runners)
});
// Proxy HTTP requests (retries with exponential backoff)
const response = await runner.fetch("http://localhost/api");
// Proxy WebSocket upgrades
runner.upgrade?.({ node: { req, socket, head } });
// Wait for runner to be ready
await runner.waitForReady();
// Bidirectional messaging
runner.sendMessage({ type: "ping" });
runner.onMessage((msg) => console.log(msg));
// Request-response RPC
const result = await runner.rpc<string>("transformHTML", "<html>...</html>");
// Hot-reload entry module without restarting the worker
await runner.reloadModule();
// Graceful shutdown
await runner.close();
Available runners:
| Runner | Isolation | IPC mechanism |
|---|---|---|
NodeWorkerEnvRunner | Worker thread | workerData / parentPort |
NodeProcessEnvRunner | Child process (fork) | ENV_RUNNER_DATA / process.send |
BunProcessEnvRunner | Bun or Node.js process | Bun.spawn IPC or fork() |
DenoProcessEnvRunner | Deno process | deno run with IPC channel |
SelfEnvRunner | In-process | In-memory channel |
MiniflareEnvRunner | Cloudflare Workers (miniflare) | WebSocket pair via dispatchFetch |
VercelEnvRunner | Worker thread (Vercel context) | workerData / parentPort |
NetlifyEnvRunner | Worker thread (Netlify context) | workerData / parentPort |
Run your app in the Cloudflare Workers runtime using miniflare:
npm install miniflare
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
const runner = new MiniflareEnvRunner({
name: "my-worker",
data: { entry: "./worker.ts" },
miniflareOptions: {
compatibilityDate: "2024-01-01",
kvNamespaces: ["MY_KV"],
},
});
const response = await runner.fetch("http://localhost/api");
await runner.close();
The miniflareOptions object is passed directly to the Miniflare constructor — you can configure bindings, KV, D1, Durable Objects, and any other Miniflare option.
Pass a transformRequest callback to route module resolution through Vite's (or any) transform pipeline. This enables TS, JSX, and other non-JS formats to be compiled on-the-fly inside the Workers runtime without pre-bundling:
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
const runner = new MiniflareEnvRunner({
name: "my-worker",
data: { entry: "./worker.ts" },
// Route module resolution through Vite's transform pipeline
transformRequest: (id) => viteDevEnvironment.transformRequest(id),
});
When transformRequest is provided:
unsafeModuleFallbackService calls it with the resolved file path before falling back to raw disk reads.ts, .tsx, .jsx, and .mts are added automaticallyexport * re-exports are skipped in the wrapper to avoid miniflare's ModuleLocator pre-walking the import treeThe callback should return { code: string } for transformed modules, or null/undefined to fall back to the default raw file read.
MiniflareEnvRunner automatically scans the entry file for export class declarations and wires them as Durable Object bindings (binding name = class name). This means you don't need to manually configure miniflareOptions.durableObjects for simple cases:
// worker.ts
export class Counter {
/* ... Durable Object implementation ... */
}
export default {
async fetch(request, env) {
// env.Counter is auto-wired — no manual config needed
const id = env.Counter.idFromName("test");
const stub = env.Counter.get(id);
return stub.fetch(request);
},
};
To explicitly declare exports or override auto-detection:
const runner = new MiniflareEnvRunner({
name: "my-worker",
data: { entry: "./worker.ts" },
// Explicit exports (merged with auto-detected ones)
exports: { Counter: { type: "DurableObject" } },
});
Set exports: false to disable auto-detection entirely.
By default, the runner wraps the user's fetch handler in a try/catch that returns structured JSON error responses with preserved stack traces:
{
"error": "Cannot read properties of undefined",
"stack": "Error: Cannot read properties...\n at fetch (worker.ts:10:5)",
"name": "TypeError"
}
Error responses include Content-Type: application/json and X-Env-Runner-Error: 1 headers. Disable with captureErrors: false.
By default, close() disposes the Miniflare instance. With persistent: true, the Miniflare instance is cached and reused across runner swaps — only the IPC connection is re-established:
const runner1 = new MiniflareEnvRunner({
name: "my-worker",
data: { entry: "./worker.ts" },
persistent: true,
});
// Later, after close() + creating a new runner with the same config,
// the Miniflare instance is reused (faster startup)
await runner1.close();
const runner2 = new MiniflareEnvRunner({
name: "my-worker",
data: { entry: "./worker.ts" },
persistent: true,
});
// Fully destroy: runner.dispose() or MiniflareEnvRunner.disposeAll()
Simulates a Vercel deployment environment with automatic header injection (x-vercel-deployment-url, x-vercel-forwarded-for, forwarding headers) and global context.
import { VercelEnvRunner } from "env-runner/runners/vercel";
const runner = new VercelEnvRunner({
name: "my-app",
data: { entry: "./app.ts" },
});
Simulates a Netlify deployment environment with automatic header injection (x-nf-client-connection-ip, x-nf-account-id, x-nf-site-id, x-nf-deploy-id, x-nf-deploy-context, x-nf-geo, x-nf-request-id, forwarding headers) and globalThis.Netlify setup:
import { NetlifyEnvRunner } from "env-runner/runners/netlify";
const runner = new NetlifyEnvRunner({
name: "my-app",
data: { entry: "./app.ts" },
});
env-runner provides helpers for integrating with Vite's Environment API:
import { createViteHotChannel, createViteTransport } from "env-runner/vite";
Host side — create a Vite HotChannel from any runner's messaging hooks:
import { createViteHotChannel } from "env-runner/vite";
// Bridge env-runner IPC → Vite's DevEnvironment transport
const transport = createViteHotChannel(runner, "ssr");
const env = new DevEnvironment("ssr", config, { hot: true, transport });
Worker side — create a ModuleRunner transport:
import { createViteTransport } from "env-runner/vite";
const transport = createViteTransport(sendMessage, onMessage, "ssr");
const runner = new ModuleRunner({ transport, sourcemapInterceptor: "prepareStackTrace" });
Messages are namespaced by environment name, so multiple Vite environments can share a single runner's IPC channel.
Miniflare + Vite — combine MiniflareEnvRunner.transformRequest with Vite helpers for a full Cloudflare Workers dev environment with HMR and on-the-fly transforms:
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
import { createViteHotChannel } from "env-runner/vite";
const runner = new MiniflareEnvRunner({
name: "worker",
data: { entry: "./src/worker.ts" },
transformRequest: (id) => devEnvironment.transformRequest(id),
});
const hotChannel = createViteHotChannel(runner, "worker");
Send request-response messages over IPC with automatic ID generation, timeout, and error propagation:
// Host side
const html = await runner.rpc<string>("transformHTML", rawHtml, { timeout: 5000 });
// Worker side (in entry's ipc.onMessage)
onMessage(msg) {
if (msg?.__rpc === "transformHTML") {
const result = await transform(msg.data);
sendMessage({ __rpc_id: msg.__rpc_id, data: result });
}
}
Errors can be propagated back by sending { __rpc_id, error: "message" }.
You can also use loadRunner() to dynamically load a runner by name:
import { loadRunner } from "env-runner";
const runner = await loadRunner("node-worker", {
name: "my-app",
data: { entry: "./app.ts" },
});
Each IPC-based runner includes a built-in worker that handles the srvx server boilerplate. You just provide an entry module:
// app.ts
export default {
fetch(request: Request) {
return new Response("Hello!");
},
websocket: {
// Optional: crossws WebSocket hooks (recommended)
open(peer) {
peer.send("Welcome!");
},
message(peer, message) {
peer.send(`Echo: ${message.text()}`);
},
close(peer, details) {},
error(peer, error) {},
},
upgrade(context) {
// Optional: raw WebSocket upgrade handler (Node.js only)
// context.node gives { req, socket, head }
},
middleware: [], // Optional srvx middleware
plugins: [], // Optional srvx plugins
ipc: {
onOpen({ sendMessage }) {
// IPC channel is ready — send messages back to the runner
sendMessage({ type: "hello", from: "worker" });
},
onMessage(message) {
// Receive messages from the runner
console.log("Got message:", message);
},
onClose() {
// Runner is shutting down
},
},
};
The built-in worker automatically:
For advanced use cases, you can provide a custom worker entry:
const runner = new NodeProcessEnvRunner({
name: "my-app",
workerEntry: "/path/to/custom-worker.ts",
data: { entry: "./app.ts" },
});
Published under the MIT license 💛.
FAQs
Generic environment runner for JavaScript runtimes.
The npm package env-runner receives a total of 12,523,402 weekly downloads. As such, env-runner popularity was classified as popular.
We found that env-runner demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
PolinRider expands across npm, Packagist, Go modules, and Chrome extensions, using hidden loaders to target developer environments.

Security News
Open source attacks are accelerating as AI coding agents pull in dependencies faster, with less human review.

Research
/Security News
Malicious Chrome and Firefox extensions posed as free VPNs while stealing clipboard data through later extension updates.