Skip to content

Latest commit

 

History

History
474 lines (353 loc) · 14.5 KB

File metadata and controls

474 lines (353 loc) · 14.5 KB

env-runner

npm version npm downloads

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.

Usage

App Entry

Create a server entry module that exports a fetch handler:

// app.ts
export default {
  fetch(request: Request) {
    return new Response("Hello!");
  },
};

CLI

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

Server (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),
});

Manager (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();

Runners

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

Miniflare Runner

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.

Module Transform Pipeline

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:

  • The unsafeModuleFallbackService calls it with the resolved file path before falling back to raw disk reads
  • Module rules for .ts, .tsx, .jsx, and .mts are added automatically
  • Static export * re-exports are skipped in the wrapper to avoid miniflare's ModuleLocator pre-walking the import tree

The callback should return { code: string } for transformed modules, or null/undefined to fall back to the default raw file read.

Auto-detected Exports

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.

Error Capture

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.

Persistent Miniflare

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()

Vercel Runner

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" },
});

Netlify Runner

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" },
});

Vite Environment API

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");

RPC

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" }.

Dynamic Runner Loading

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" },
});

Workers

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:

  1. Imports your entry module
  2. Starts a srvx server on a random port
  3. Reports the address back to the runner via IPC
  4. Handles graceful shutdown

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" },
});

Development

local development
  • Clone this repository
  • Install latest LTS version of Node.js
  • Enable Corepack using corepack enable
  • Install dependencies using pnpm install
  • Run interactive tests using pnpm dev

License

Published under the MIT license 💛.