env-runner
v0.3.3
Published
Generic environment runner for JavaScript runtimes.
Readme
env-runner
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.tsFlags:
| 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 | |
--runner miniflare needs miniflare installed in your project — the runner imports it optionally when no module is passed.
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", // optional, defaults to "node-worker"
entry: "./app.ts",
watch: true,
watchPaths: ["./src"],
// Runner-specific constructor options, e.g. `{ miniflare }` for `runner: "miniflare"`
runnerOptions: {},
});
envServer.onReady((_runner, address) => {
console.log(`Worker ready on ${address?.host}:${address?.port}`);
});
envServer.onReload(() => {
console.log("Reloaded!");
});
// Optional — the server auto-starts on first fetch()
await envServer.start();
// Restart with a fresh runner created from the server options
await envServer.reload();
// Use with any HTTP server
const server = serve({
fetch: (request) => envServer.fetch(request),
// Proxy WebSocket upgrades to the worker (see "WebSocket proxying" below)
plugins: [await envServer.wsSrvxPlugin()],
});WebSocket proxying
To proxy WebSocket upgrades to the worker, attach the plugin returned by
wsSrvxPlugin() (available on both RunnerManager and EnvServer) to your
srvx server:
const server = serve({
fetch: (request) => envServer.fetch(request),
plugins: [await envServer.wsSrvxPlugin()],
});The plugin picks the proxy strategy by host runtime:
- Node — proxies the raw upgrade socket to the worker (transparent passthrough; subprotocol/extension negotiation stays end-to-end).
- Bun/Deno — those runtimes serve natively and expose no Node upgrade
socket, so the client WebSocket is terminated with crossws
and bridged to the worker over a standard
WebSocketclient.
It reads the active runner lazily, so it keeps working across hot-reloads, and
waits for the worker to become ready before proxying. Your entry module should
expose WebSocket hooks via the websocket field (see Workers).
Manager (RunnerManager)
Proxy manager for hot-reload with message queueing and listener forwarding:
import { RunnerManager } from "env-runner";
import { NodeProcessEnvRunner } from "env-runner/runners/node-process";
await using 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));
// manager.close() is awaited automatically at the end of the scope (`await using`)All runners, RunnerManager, and EnvServer implement AsyncDisposable, so they can be auto-closed with explicit resource management (await using) — or closed manually with await runner.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";Runtime dependencies
env-runner declares no peer dependencies. Runners that build on external packages take them as explicit options instead, and every such option accepts the same three shapes:
| Value | Meaning |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| the imported module (import * as miniflare from "miniflare") | the version you installed is the version that runs |
| a specifier — "miniflare", import.meta.resolve("miniflare"), or a URL | resolved from the current working directory, so bare specifiers hit your node_modules, not env-runner's |
| false | opt out of the package entirely |
Omitting the option falls back to an optional dynamic import of the package; only then does the runner error (miniflare) or degrade (minimal wrangler reader, Netlify shim, queue no-op).
| Runner | Package | Option | Without it |
| ----------- | ------------------ | ---------------------------------------- | ------------------------------------- |
| miniflare | miniflare | miniflare | throws — the runner cannot run |
| miniflare | wrangler | wranglerModule | built-in minimal JSON reader |
| vercel | @vercel/queue | sdk (on registerVercelQueueConsumer) | warn-once no-op |
| netlify | @netlify/runtime | netlifyRuntime | lightweight globalThis.Netlify shim |
netlifyRuntime is the one exception to the table above: the runtime has to start inside the worker thread, where a live module instance cannot be handed over, so it accepts a specifier (or false) only.
The shared resolver is exported as resolveRuntimeDep() (type RuntimeDep<T>) if you build runners of your own.
All runners implement the EnvRunner interface:
await using 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)
// Relative URLs are resolved against a placeholder origin
const response = await runner.fetch("/api");
// Proxy a raw WebSocket upgrade to the worker (Node host only — low-level;
// prefer `manager.wsSrvxPlugin()` for cross-runtime proxying)
runner.upgrade?.({ node: { req, socket, head } });
// Wait for runner to be ready (rejects as soon as it closes, with the close
// reason as `error.cause`)
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 (the entry is
// re-read under its own URL; modules it imports stay cached)
await runner.reloadModule();
// Add, replace or remove (`null`) virtual modules in one round trip, then reload
await runner.updateVirtualModules({ "#routes": `export default []`, "#old": null });
await runner.reloadModule();
// Invalidate a virtual module (re-runs a factory source), then reload
await runner.invalidateModule("#config.json");
await runner.reloadModule();
// Graceful shutdown happens automatically at the end of the scope
// (`await using`) — or call `await runner.close()` explicitlyAvailable runners:
| Runner | Isolation | IPC mechanism |
| ---------------------- | ------------------------------- | ---------------------------------- |
| NodeWorkerEnvRunner | Worker thread | workerData / parentPort |
| NodeProcessEnvRunner | Child process (fork) | process.send IPC channel |
| 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 |
Virtual Modules
The Node.js runners (NodeWorkerEnvRunner, NodeProcessEnvRunner, and the runners built on top of them), BunProcessEnvRunner, DenoProcessEnvRunner (Deno >= 2.8), and MiniflareEnvRunner can serve virtual modules from an in-memory specifier => source map passed via data.virtual (SelfEnvRunner cannot, and closes with an error when it is set). The entry (and its dependencies) can then import them as if they were real files:
import { NodeWorkerEnvRunner } from "env-runner/runners/node-worker";
await using runner = new NodeWorkerEnvRunner({
name: "my-app",
data: {
entry: "./app.ts",
virtual: {
"#config": `export const apiBase = "https://api.example.com";`,
"#banner": `export default "Hello from a virtual module!";`,
},
},
});// app.ts
import banner from "#banner";
import { apiBase } from "#config";
export default {
fetch: () => new Response(`${banner} (${apiBase})`),
};The entry itself can be virtual — set data.entry to one of the data.virtual keys (exactly as written in the map) to run an entry whose source lives in memory (it may import other virtual modules too):
await using runner = new NodeWorkerEnvRunner({
name: "my-app",
data: {
entry: "#entry",
virtual: {
"#entry": `import { body } from "#dep";
export default { fetch: () => new Response(body) };`,
"#dep": `export const body = "Hello from a virtual entry!";`,
},
},
});Keys can also be file paths: absolute paths (including Windows C:\...) or file:// URLs. Such a module behaves like a file at that path, whether or not its directory exists on disk:
- it is matched by resolved URL, so any relative or absolute import that resolves to it is served from the map, whether the importer is a virtual module or a real file. A key equal to a real file's path therefore overrides that file for every importer.
- it runs under its real
file:URL, soimport.meta.url,import.meta.dirnameandimport.meta.filenamepoint at the key. - its own imports (relative files, bare packages) resolve from the key's directory.
- as
data.entry, it may be written either way (/app/x.mjsfor afile:///app/x.mjskey, or the reverse). It is still run from the map on load and acrossreloadModule(), even when a real file exists there.
import { join, resolve } from "node:path";
const dir = resolve("src/generated"); // doesn't need to exist
await using runner = new NodeWorkerEnvRunner({
name: "my-app",
data: {
entry: join(dir, "entry.mjs"),
virtual: {
[join(dir, "entry.mjs")]: `import { body } from "./body.mjs";
export default { fetch: () => new Response(body) };`,
[join(dir, "body.mjs")]: `export const body = "Hello from " + import.meta.filename;`,
// Overrides the real `src/config.mjs` for all of its importers
[resolve("src/config.mjs")]: `export default { mode: "virtual" };`,
},
},
});Other keys (#name, bare names) only match an import specifier equal to the key, and relative imports inside them resolve from the working directory. Having no file, they get a runtime-specific id, shown by import.meta.url and stack traces: virtual:#config on Node.js and Deno, file:///%23config on Bun (file:///env-runner-virtual:%23util.mjs for keys with an extension), while on miniflare import.meta.url is undefined and stack traces show the key itself. A ?query appended to an import is ignored for matching (#config.json?raw matches #config.json), but gives a separate module instance. Path keys are fully supported on the Node.js, Bun and Deno runners, with these exceptions:
- On Bun (
BunProcessEnvRunner, and the Node.js runners when the host runtime is Bun), runtime plugins only see a specifier whose last.is followed by a letter, typically a file extension. A key without an extension (#config,/app/entry) therefore only matches an import spelled exactly like the key: no appended?query, and no relative orfile://import of an extensionless path key. MiniflareEnvRunnersupports relative imports and overrides, and treatsfile://keys and imports like their path, butimport.meta.urlis undefined.
On both, a virtual data.entry must be written exactly like its key.
Two path keys naming the same file (/app/x.mjs and file:///app/x.mjs) can't both be served, so the runner logs a warning naming both. Keep a single key per file.
Keys match every importer in the worker, dependencies included, like an import map entry. A bare key such as react replaces that package everywhere (useful to alias or stub it), and a #name key also replaces a dependency's own #name subpath import. There is no warning for this, since overriding a package is often the point, so give your own modules distinctive names (#app/config rather than #utils).
Each source may also be a factory returning a source (or a promise of one) — useful for lazily computed or asynchronously loaded sources:
await using runner = new NodeWorkerEnvRunner({
name: "my-app",
data: {
entry: "./app.ts",
virtual: {
"#config": () => `export const apiBase = ${JSON.stringify(getApiBase())};`,
"#schema": async () => `export default ${await loadSchemaJson()};`,
},
},
});Factories are invoked once on the host (before the worker is spawned), so the worker always receives resolved sources — functions can't cross the workerData/JSON boundary, and Node's synchronous load hook can't await. For the same reason, all factories are resolved eagerly at startup (in parallel), not lazily on first import — so keep them cheap, or use plain sources for modules that don't need computation. Maps without factories skip this step entirely.
To refresh a single virtual module without restarting the worker, call invalidateModule(specifier): a factory-valued source is re-run on the host and the module is invalidated in the worker so its next import evaluates fresh. Virtual modules that import the invalidated one (directly or transitively) are invalidated along with it, so the fresh module is picked up even through intermediate virtual importers. On Node.js, Bun and Deno this follows the imports that were actually resolved, and on miniflare the import specifiers of the virtual sources, including relative ones between path keys. On Node.js and Deno it also covers real files on the way from the entry, such as a ./lib.mjs importing #config: they are re-evaluated on the next reload too, while files that don't depend on the module stay cached. CommonJS files are never re-evaluated. On Bun and miniflare, a real file other than the entry keeps the old module. Already-imported modules keep their instances, so pair it with reloadModule() to re-import the entry graph:
await runner.invalidateModule("#config"); // re-runs the factory, busts the module
await runner.reloadModule(); // re-imports the entry, picking up the fresh moduleWhen fetching through RunnerManager or EnvServer, the reload is automatic: invalidateModule() marks the manager dirty and the next fetch() reloads the entry once before serving (concurrent fetches share the reload), so no explicit reloadModule() call is needed.
To change the map itself while the runner is running, call updateVirtualModules(changes). A source (string or factory) adds or replaces a key, and null removes it. All changes of one call are applied together in a single round trip to the worker:
await runner.updateVirtualModules({
"#routes": `export default ["/", "/about"]`, // add or replace
[resolve("src/generated/api.mjs")]: () => generateApi(), // factories run on the host
"#legacy": null, // remove
});
await runner.reloadModule(); // or let RunnerManager/EnvServer reload on the next fetchChanged and removed keys are invalidated like invalidateModule() does, together with the modules importing them, so the next reloadModule() sees the new map:
- An added key resolves from then on, path keys included. An importer that failed to import it, or that loaded the real file it now overrides, picks it up once it is re-evaluated: the reloaded entry, virtual importers, and on Node.js and Deno also real files between them. A runner started without
data.virtualregisters its virtual modules on the first update, and real files it loaded before aren't tracked as importers. - A removed key falls through to normal resolution: the real file it overrode, or a "not found" error. On Bun, a removed key without a file extension (see the Bun notes above) fails to load instead of falling through, and on miniflare an unresolvable bare specifier gets an empty module, as usual there.
Calls are applied one at a time in call order (a later call never loses to a slower factory of an earlier one), and reloadModule() waits for pending ones. A call made before the runner is ready waits for it. invalidateModule(specifier) is the same as updating the key with its current source. The runner keeps its own copy of the map, never changing your data.virtual. RunnerManager.updateVirtualModules() marks the manager dirty like invalidateModule(), and EnvServer also keeps the changes for the runners it creates later (reload(), watch mode). Changes made before the server started are only recorded, and the first runner starts with them.
Each module has a format. By default it follows the key's extension, like Node.js does for files: .cjs is CommonJS, .ts/.mts TypeScript, .cts CommonJS TypeScript, .json JSON, .jsx/.tsx JSX and .wasm WebAssembly. Any other string is an ES module, and any other Uint8Array raw bytes. To set the format explicitly, for example on a key without an extension, pass { source, format }:
import { readFileSync } from "node:fs";
await using runner = new NodeWorkerEnvRunner({
name: "my-app",
data: {
entry: "#entry.ts",
virtual: {
"#entry.ts": `
import { getGreeting } from "#util.ts";
import config from "#config";
import legacy from "#legacy.cjs";
import logo from "#logo";
import add from "#add.wasm";
const { exports } = await WebAssembly.instantiate(add);
const handler: () => Response = () =>
new Response(\`\${getGreeting(config.name)} \${legacy.answer} \${logo.length} \${exports.add(1, 2)}\`);
export default { fetch: handler };
`,
"#util.ts": `export function getGreeting(name: string): string {
return \`Hello, \${name}!\`;
}`,
"#config": { source: JSON.stringify({ name: "virtual" }), format: "json" },
"#legacy.cjs": `module.exports = { answer: 42 };`,
"#logo": readFileSync("logo.png"), // a Uint8Array: `bytes`
"#add.wasm": readFileSync("add.wasm"),
},
},
});| Format | Default for | Source | Importing it gives |
| --------------------- | -------------------------- | ------------ | ------------------------------------------------- |
| module | other string sources | string | the ES module |
| commonjs | .cjs | string | module.exports as default export, named exports |
| module-typescript | .ts, .mts | string | the ES module, types stripped |
| commonjs-typescript | .cts | string | like commonjs, types stripped |
| json | .json | string | the parsed value as default export |
| jsx, tsx | .jsx, .tsx | string | the ES module (Bun only) |
| text | — | string | the string as default export |
| bytes | other Uint8Array sources | Uint8Array | a Uint8Array as default export |
| wasm | .wasm | Uint8Array | a compiled WebAssembly.Module as default export |
The code formats are named like Node's load formats, and text and bytes like the with { type } import attributes proposed for them (import them without attributes, though). An unknown format, or a source that doesn't fit its format (a Uint8Array for json, a string for wasm, bytes that aren't valid WebAssembly), closes the runner at startup with an error naming the key, or rejects updateVirtualModules() before anything changes.
- TypeScript is type-stripped by Node's native type stripping (Node.js >= 22.18 / 23.6 — erasable syntax only) and by Bun's
tsloader. On Deno, custom load hooks bypass its native type stripping, so sources are pre-stripped withmodule.stripTypeScriptTypes(Deno >= 2.8.2); on older Deno without it, virtual TypeScript sources throw at registration — pass pre-transpiled JavaScript instead. On miniflare, sources are likewise pre-stripped withmodule.stripTypeScriptTypeson the host (workerd does not parse TypeScript). - JSON sources expose the parsed value as the default export on all runtimes. The
with { type: "json" }import attribute is optional on Node.js and Bun; on Deno and miniflare it must be omitted (static imports carrying an import attribute bypassregisterHooksresolution on Deno, and workerd rejects import attributes outright). - CommonJS is loaded natively by Node.js and workerd (miniflare). Bun and Deno only parse in-memory sources as ES modules, so there the source runs inside an ES module wrapper, as strict-mode code. Named exports are detected like Node.js does (cjs-module-lexer), but re-exports (
module.exports = require("./other.cjs")) only add named exports on Node.js.require()resolves packages, real files and other virtual modules, with these exceptions:- On Deno,
require()only reaches real files and packages, not virtual modules (Deno reads required files from disk), and requiring an ES module crashes Deno 2.9 while module hooks are registered (a Deno bug). - On miniflare, a virtual module that is
require()d keeps its first instance when it changes (invalidateModule(), updates): onlyimportspecifiers are rewritten. Import it instead, or restart the runner. - On Node.js, after a module re-exported by CommonJS (
module.exports = require("./dep.cjs")) changes, Node reads it as an empty, circular module (this happens with real files too after deleting them fromrequire.cache). Assign it first (const dep = require("./dep.cjs"); module.exports = dep;).
- On Deno,
- Text and bytes can't be imported from memory natively on every runtime, so they are served as ES modules where needed.
bytesgives each module instance its ownUint8Array. Bytes survive every transport, including the process runners' JSON IPC (as base64) andupdateVirtualModules(). - WebAssembly default-exports a compiled
WebAssembly.Moduleon every runtime, so instantiate it yourself withWebAssembly.instantiate(module, imports). This follows workerd, which compiles.wasmmodules ahead of time and disallows compiling Wasm at runtime. Node.js and Deno's own.wasmimports instantiate the module instead (Wasm ESM integration), and Bun's give a file path, so those aren't used. - JSX is only supported on Bun, by its
jsx/tsxloaders (configure the JSX runtime withtsconfig.jsonor pragma comments like/** @jsxImportSource preact */). Node.js, Deno and miniflare can't load JSX from memory and fail with an error naming the key: pre-transpile it to JavaScript, and pass{ source, format: "module" }to keep a.jsx/.tsxkey, or compile it with plugins.
Virtual modules are registered inside the worker, before the entry is imported. On Node.js (>= 22.15 / 23.5) and Deno (>= 2.8) this uses ESM customization hooks (module.registerHooks); on Bun (which does not implement registerHooks) it uses a Bun.plugin() runtime plugin instead, also for the Node.js runners when the host runtime is Bun. Each source is served in its format, and virtual specifiers (including a virtual entry) resolve across reloadModule(). On runtimes supporting neither mechanism, a warning is logged and registration is skipped. When the worker shuts down gracefully the registration is unregistered again (the registerHooks registration is deregistered; on Bun, which has no plugin-removal API, the registration is detached so fresh loads and reloads stop resolving, and an overridden real file loads from disk again).
On MiniflareEnvRunner there is no in-worker registration: the runner's module fallback service serves virtual specifiers to workerd directly (taking precedence over disk files and the transformRequest pipeline, so a virtual key overrides a real file with the same path). Named exports (Durable Objects / WorkerEntrypoints) also work with virtual entries. One limitation on miniflare v4: a real entry with auto-detected named exports can't import virtual modules, because miniflare's module locator reads its imports from disk at startup. Use a virtual entry, a separate exports module or miniflare v5 instead.
Plugins (plugins)
Pass the plugins runner option to resolve, load and transform the entry, its imports and virtual modules with plugins that run on the host. While resolving an import or loading a module, the worker sends it to the runner, the runner runs the plugins' resolveId, load and transform handlers and sends the result back, and the worker uses that result. Plugins are plain objects in the host process, so handlers can be async, close over host state and share work with the rest of your tooling. It works on every runner except SelfEnvRunner, which closes with an error (an empty list, or plugins without these hooks, count as no plugins). loadRunner() and EnvServer take the same plugins option (EnvServer passes it to every runner it creates).
The runner
pluginsoption is unrelated to the srvx serverpluginsof your app entry: those stay on the entry's default export.
TypeScript enums, namespaces and JSX need a compiler. A plugin with oxc-transform (npm i -D oxc-transform) covers them:
import { transformSync } from "oxc-transform";
import { NodeProcessEnvRunner } from "env-runner/runners/node-process";
const oxc = {
name: "oxc",
transform: {
filter: { moduleType: ["ts", "tsx", "jsx"], id: { exclude: "**/generated/**" } },
handler(code, id, { moduleType }) {
const result = transformSync(id, code, { sourcemap: true, lang: moduleType });
// `errors` also holds warnings: only fail on errors.
const errors = result.errors.filter((error) => error.severity === "Error");
if (errors.length > 0) this.error(errors.map((error) => error.message).join("\n"));
return { code: result.code, map: result.map, moduleType: "js" };
},
},
};
const version = {
name: "version",
transform: {
order: "pre", // "pre" | "post" (default: unordered)
filter: { id: "src/**", moduleType: ["ts", "tsx"], code: "__VERSION__" },
async handler(code) {
// Any async work on the host (`readVersion()` is your own).
return code.replaceAll("__VERSION__", JSON.stringify(await readVersion()));
},
},
};
const runner = new NodeProcessEnvRunner({
name: "my-app",
plugins: [oxc, version],
data: { entry: "./src/server.tsx" },
});transform can also be a plain function. Handlers get (code, id, { moduleType }) and return a string, { code, map, moduleType }, or nothing to keep the code (moduleSideEffects and meta in a result are ignored). Nested arrays in plugins are flattened and falsy entries skipped, so a plugin can be added conditionally (isDev && plugin).
In every handler, this has:
this.warn(message)andthis.info(message)log, andthis.error(message)throws, all prefixed with the plugin name and module id.messagecan also be a log object,{ message, loc?, pos?, frame? }. A position (a second argument, else the log'sloc{ line, column }orposoffset) is appended to the id as:line:column, and aframegoes below the message.this.debug()is ignored.this.resolve(source, importer?, { skipSelf?, isEntry?, attributes? })resolves an import on the host as the runner would. It runs theresolveIdhooks, then Node.js ESM resolution fromimporter(or the working directory) with the runner's export conditions, then thefallbackhooks (below). It returns{ id, external }, with builtins (node:fs) as external, ornullwhen nothing resolves it; like Node.js, it tries no extensions or directory indexes. Called from aresolveIdhandler, it skips that plugin's own hook (skipSelf: falsekeeps it), also when another plugin asks again for the same source and importer.this.addWatchFile()is accepted and ignored (this.getWatchFiles()is empty), andthis.meta.watchModeisfalse.
Errors a handler throws are reported the same way, with the plugin name and module id, and a loc/pos and frame of the error: [env-runner] plugin "version" failed on "/app/src/index.ts:3:10": .... Positions are in the code the hook got (after earlier hooks changed it). Other hooks (buildStart, renderChunk, ...) are ignored, and handlers find no other context methods (this.emitFile, this.parse, ...): calling one fails with a TypeError.
Resolving and loading modules: resolveId and load hooks serve modules that aren't files, redirect imports, or load files the runtime can't:
const virtual = {
name: "virtual",
resolveId: {
filter: { id: /^virtual:/ },
handler(source, importer) {
// `\0` marks an id that isn't a file.
if (source === "virtual:routes") return "\0virtual:routes";
},
},
load: {
filter: { id: /^\0virtual:/ },
async handler(id) {
if (id === "\0virtual:routes") return `export default ${JSON.stringify(await scanRoutes())};`;
},
},
};
const alias = {
name: "alias",
resolveId: {
filter: { id: "@/**" },
handler: (source) => resolve("src", source.slice(2)), // an absolute path loads that file
},
};
const yaml = {
name: "yaml",
transform: {
filter: { id: /\.ya?ml$/ },
handler: (code) => `export default ${JSON.stringify(parseYAML(code))};`,
},
};
// `import svg from "./logo.svg?raw"`: the file's text as the default export.
const raw = {
name: "raw",
load: {
filter: [{ kind: "include", expr: { kind: "query", key: "raw", pattern: true } }],
handler(id) {
const code = readFileSync(id.slice(0, id.indexOf("?")), "utf8");
return { code: `export default ${JSON.stringify(code)};`, moduleType: "js" };
},
},
};resolveId(source, importer, { isEntry, attributes })runs for the imports its filter matches, with the specifier as written, query included (file:URLs as paths).importeris the importing module's id (a path with its query, or an id aresolveIdhook returned), andundefinedfor the entry. The first handler returning a result wins. Returning nothing leaves the import to the next plugin, then to the runtime.A resolved absolute path (a query is kept) loads that file, through
loadhooks and then from disk. A path that is a key ofdata.virtualloads that virtual module, also without a file on disk. Any other id, like\0virtual:routes, must be returned by aloadhook, and relative imports inside such a module resolve from the working directory.fallback: truemakes aresolveIdhook run only for imports the runtime fails to resolve, after the runtime tried. The worker tries first, so imports it resolves take no round trip, unlike otherresolveIdhooks, which run before the runtime for every import their filter matches. Use it for imports the runtime can't resolve itself, like extensionless relative imports (./utils), which Bun resolves itself:const extensionless = { name: "extensionless", resolveId: { fallback: true, filter: { id: /^\.\.?\// }, handler: (source, importer) => [".ts", ".mts", "/index.ts"] .map((ext) => join(dirname(importer), source + ext)) .find((path) => existsSync(path)), }, };
false or { id, external: true } leaves the import (or the returned id) to the runtime. Only Node.js and Deno import a different non-path id returned with external; Bun and miniflare keep the original specifier, so return an unchanged id or a path for portable externals.
load(id)returns the module's code, or{ code, map, moduleType }. The first handler returning code wins, andtransformhooks run on the result. Without amoduleType, it is the id's type (jsfor other extensions). When noloadhook returns code, the file is read from disk (without the id's query).Queries: ids keep the import's query (
/app/logo.svg?raw), inload,transformand filters, and the query is part of the module's identity:./dep.tsand./dep.ts?raware separate modules on every runner. The module type comes from the path (svgthere). Query params env-runner adds itself (reload cache-busting, virtual module versions, Bun and miniflare markers) are removed from the ids plugins see, and the import's own params are kept as written.resolveIdandloadfilters take onlyid(or filter expressions withoutcodeandmoduleType). ForresolveId,idmatches the specifier with its query, and globs aren't resolved from the working directory (virtual:*,@/**).Error messages name the hook:
[env-runner] plugin "virtual" failed to load "\0virtual:routes": .....wasm: aloadhook (anidfilter like/\.wasm$/names the type) returns an ES module wrapper around the file. On Node.js, Deno and Bun it can inline the bytes and compile them. workerd can't compile bytes at runtime, so on miniflare the wrapper imports the file as a compiled module (with a query the hook's filter leaves out, served by the runner as a WebAssembly module) and instantiates it:const wasm = (workerd = false) => ({ name: "wasm", load: { filter: { id: /\.wasm$/ }, handler(id) { const bytes = readFileSync(id); const names = WebAssembly.Module.exports(new WebAssembly.Module(bytes)).map((e) => e.name); return [ workerd ? `import module from ${JSON.stringify(`${id}?module`)};` : `const module = new WebAssembly.Module(Uint8Array.from(atob("${bytes.toString("base64")}"), (c) => c.charCodeAt(0)));`, "const { exports } = new WebAssembly.Instance(module);", ...names.map((name) => `export const ${name} = exports.${name};`), ].join("\n"); }, }, });
Which modules are sent:
- Candidates are files outside
/node_modules/, modules aresolveIdhook resolved, and virtual modules with a code format. - Files under
/node_modules/only go to hooks with a matchingidinclude that namesnode_modules(like**/node_modules/my-pkg/**, or an include filter expression with such anid), or to every matching hook when aresolveIdhook returned their path. - The worker gets each
loadandtransformhook'sidandmoduleTypefilters (or its filter expressions). It only sends a candidate that one of them may match. A hook without these filters matches every script. The code isn't known in the worker, socodefilters are checked on the host. Anything else loads without a round trip, so give every plugin amoduleTypefilter, and anidfilter where you can. - Scripts (module types
js,jsx,ts,tsx) go to every hook whose filter matches. Other files only go to hooks whose filter names them:- an
idinclude that matches (like/\.ya?ml$/, orsrc/**), or, in filter expressions, an include that matches through anid(or a presentqueryparam), not only throughcodeornot; - for
.json,.nodeand.wasm, which runtimes load themselves, amoduleTypefilter listing the type (["json"], or amoduleTypeexpression in the matching include) or aloadhook'sidinclude.
- an
- Only imports that some
resolveIdfilter matches are sent, so giveresolveIdhooks anidfilter: without one, every import goes to the runner. Imports matching onlyfallbackhooks' filters are sent only when the runtime fails to resolve them. - On the host, each handler runs only when its whole filter matches the current code.
Filters: all given properties must match, and empty ones ("", null, []) are ignored. Ids are matched with their path /-separated and with their query string, by globs and RegExps alike: **/*.svg and /\.svg$/ don't match /app/logo.svg?raw, while /\.svg(\?.*)?$/ and **/*.svg{?*,} match it with or without a query. A query filter expression (below) matches the params.
id: RegExps are tested, strings are globs:*(within a path segment),?,**(any number of segments, sosrc/**matches belowsrc/),[abc],[a-z],[!abc],{a,b}(also nested) and\escapes. Globs are case-sensitive, have no extglobs, and*/**also match dot files and directories (**/*.tsincludes.nitro/). Globs not starting with**and not absolute are resolved from the working directory when the runner is created (src/**,*.ts); characters like[or{in that directory match literally. On Windows, an absolute glob written with\separators only (C:\app\*.ts) treats them as separators, so use/to escape characters there.code: strings are substrings, RegExps are tested.moduleType: a non-empty list, or{ include }. It starts as the module's language:js,jsx,ts,tsxorjsonfrom the extension (or a virtual module's format), and the extension itself for other files (vue,yaml).- Values can be arrays or
{ include, exclude }, and exclude wins. RegExpg/yflags are ignored (every test matches anywhere in the value). - Invalid values (a pattern that is neither a string nor a RegExp, an empty
moduleTypelist) throw aTypeErrorwhen the runner is created.
filter can also be a list of filter expressions, objects with a kind:
const filter = [
{ kind: "exclude", expr: { kind: "id", pattern: "**/vendor/**" } },
{
kind: "include",
expr: {
kind: "and",
args: [
{ kind: "moduleType", pattern: "ts" },
{ kind: "not", expr: { kind: "code", pattern: /@generated/ } },
],
},
},
];- The list holds
includeandexcludeentries, and the first one whoseexprmatches decides. If none matches, the module matches only when there are noincludeentries. exprcombinesand/or(args) andnot(expr) overid,codeandmoduleType(pattern), matched like the properties above.query(key,pattern) matches the id's query, parsed withURLSearchParams(decoded, without a#fragment):pattern: truematches whenkeyis present (?raw,?raw=0),falsewhen it's absent, a string equals its value (""for?raw), and a RegExp tests it (""when absent).importerIdis rejected.- The worker sends a module unless its expressions can't match whatever the code is.
Rules:
- Order:
prehandlers, then unordered ones, thenpostones. Within each group, plugins are sorted by theirenforce("pre"plugins first,"post"last), thenpluginsorder is kept, so list a compiling plugin before plugins that expect JavaScript. - Output: a compiling handler returns
moduleType: "js", and later handlers see it. If no handler changed a module, it loads as if unmatched. Output still typed astsis left to the runtime's type stripping, like an untransformed file (on miniflare, the host strips it), so a code-only plugin doesn't need a compiler before it. Output still typed asjsx/tsxfails to load. - Other file types: a handler that turns another file type (
yaml) into code without returning amoduleTypemakes itjs. JSON stays JSON while it parses as JSON, and is served as a JSON module: withwith { type: "json" }(orrequire()) where the runtime expects one, else as an ES module with the value as default export and its top-level keys as named exports. - Source maps are not composed: the first returned
mapis appended inline, and a second one drops both (with a warning, once per pair of plugins). Code-only results keep the current map, so keep such changes line-preserving. - Errors from a handler fail the import of that module in the worker, as the error message (with its position and frame). At startup, the runner closes with it as the cause (
waitForReady()rejects, andRunnerManager.onClose()listeners get it). OnreloadModule(), the reload rejects with it and the previous entry keeps serving. On miniflare, a named import of the failing module can fail to link first, so the error is also logged on the host. - Order with virtual modules and other hooks: virtual modules come first. An import of a
data.virtualkey never reachesresolveIdhooks (virtual modules are transformed on the host before they are sent), and a path aresolveIdhook returns that is a virtual key loads that virtual module. On Node.js and Deno, amodule.registerHooks()call in the entry registers hooks that run before env-runner's, for imports after it (hooks registered later run first; theirnextResolve()/nextLoad()reach env-runner's). On Bun, env-runner's callbacks run before those of aBun.plugin()in the entry, which only get what env-runner's leave. On miniflare, the order is virtual modules,resolveIdhooks, resolution on the host,fallbackhooks,transformRequest, thenloadandtransformhooks. - Cost: each module sent is one blocking round trip. Measured on an app of 400 small modules (Node.js 24, Bun 1.4, Deno 2.9, one desktop machine): about 50 µs per module with
NodeWorkerEnvRunner, and 150 to 330 µs per module with the process runners, which also pay a fixed 10 to 30 ms at startup. AresolveIdhook costs about the same per import it matches. A plugin whose filters match no module still runs the load hook for every module: about 20 µs per module withNodeWorkerEnvRunner, 50 µs on Bun and 115 µs on Deno. Nothing is cached on the host:reloadModule()sends only the entry again, since the runtime keeps its imports cached. - Don't wait on the same runner in a handler (
runner.fetch(),rpc(), ...): its worker is blocked until the handler returns. A transform taking over 10 seconds logs a warning in the worker.
How it works:
- Transport: module loader hooks are synchronous, so the worker blocks until the runner replies.
NodeWorkerEnvRunner(and the Vercel and Netlify runners) pass the worker aMessagePort. The process runners listen on a local socket (a unix socket in a private temporary directory, or a named pipe on Windows), which a helper thread in the worker connects to. CI runs theNodeProcessEnvRunnerplugin tests on Windows, over the named pipe; Bun and Deno plugins on Windows are untested. If the runner goes away, a pending load throws instead of hanging. - Node.js and Deno: a
module.registerHooksload hook. The output is ESM or CommonJS: by the package"type"when Node.js reports it, else.mts/.cts, else CommonJS only for output with CommonJS markers (require(),module.exports) and no ESM syntax. Deno evaluates hook output as plain ESM, so it gets TypeScript stripped and CommonJS wrapped as an ES module. Deno readsrequire()d files from disk, untransformed, and untouched CommonJS files load natively (CommonJS.ts/.jsthere needs--unstable-detect-cjsin the runner'sexecArgv). With that flag, Deno skips load hooks for.tsfiles in packages without"type": "module", so plugins don't see them: add"type": "module"to thatpackage.json, or use.mts. - Node.js and Deno resolve imports with a
module.registerHooksresolve hook, which sees every import.fallbackhooks get an import when the default resolution throws, or (Deno) resolves to a path without a file. - Bun: a
Bun.plugin()onLoadwith one filter RegExp built from the plugins'moduleTypefilters (implied extensions) andidfilters (globs match either path separator; on Windows,idRegExps are left to the per-module check), not from filter expressions. Bun evaluates plugin output as ESM and can't decline a load, so CommonJS is wrapped as an ES module, also in files the RegExp covers but no plugin matches..cjs/.ctsnever reach it. Other file types are sent while their import is resolved, so a file no plugin changes still loads with Bun's own loader (.txt,.toml, a path for unknown extensions). Virtual modules are registered first, so a virtual key overriding a file wins, like on Node.js. - Bun imports go through
onResolve, which Bun only calls for some specifiers. Bare specifiers without an extension (~icons/home,@/utils) never reach it, while@/utils.tsdoes. Ascheme:restspecifier only reaches the callbacks of its scheme, so the runner adds one for each scheme itsresolveIdfilters start with (/^virtual:/,"virtual:*").onResolveruns before Bun resolves, so forfallbackhooks the runner asksBun.resolveSync()first. - Miniflare: no worker round trip. Imports workerd can't resolve itself, and disk modules, go through the plugins in the module fallback service. Modules that
transformRequestreturns code for are served as is. A plugin error is logged on the host and thrown from the failing module. A persistent instance runs the plugins of the runner currently using it. AresolveIdresult that is a virtual path key is redirected to that key, and.wasmfiles no plugin loads are served as WebAssembly modules. - Virtual modules are transformed on the host before they are sent, also on
updateVirtualModules()/invalidateModule()(a source that fails to transform rejects the update and changes nothing). This makes JSX work on every runner (e.g.#entry.tsx, or{ source, format: "tsx" }). The output stays in its format's module system. reloadModule()sends the entry again; already-imported modules stay cached.
Miniflare Runner
Run your app in the Cloudflare Workers runtime using miniflare.
env-runner declares no peer dependencies — install miniflare (v4 or v5) yourself and pass it to the runner (see Runtime dependencies):
npm install miniflareimport * as miniflare from "miniflare";
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
await using runner = new MiniflareEnvRunner({
miniflare,
name: "my-worker",
data: { entry: "./worker.ts" },
miniflareOptions: {
compatibilityDate: "2024-01-01",
kvNamespaces: ["MY_KV"],
},
});
const response = await runner.fetch("http://localhost/api");
// Request inputs also preserve methods, headers, streaming bodies and cancellation.
// An optional RequestInit overrides the corresponding Request properties.Passing miniflare explicitly is preferred — the version you install is then the version that runs. A specifier works too (miniflare: "miniflare"). If you omit it, the runner imports miniflare itself and only fails (with an actionable error) when the package isn't installed either. The miniflareOptions object is passed directly to the Miniflare constructor — you can configure bindings, KV, D1, Durable Objects, and any other Miniflare option.
The entry uses the same AppEntry format as the other runners. Requests are handled like srvx's Cloudflare adapter (srvx/cloudflare): the entry's plugins, middleware and error handler are applied, and the request carries request.runtime ({ name: "cloudflare", cloudflare: { env, context } }), request.ip (from cf-connecting-ip) and request.waitUntil(). For Workers-style entries, fetch still receives (request, env, ctx). env-runner's internal bindings are never exposed on env. Listener-level srvx options (maxRequestBodySize, trustProxy, node/bun/deno, ...) do not apply to miniflare.
When you don't set a compatibility date, it defaults to the date supported by the installed workerd binary rather than today's date — the binary always lags the calendar slightly, and pinning a future date makes workerd refuse to start. Set the runner's compatibilityDate option to pin one, or to "latest" to use the installed workerd's supported date explicitly (no need to import miniflare for supportedCompatibilityDate). Precedence: miniflareOptions.compatibilityDate > compatibilityDate > the wrangler config's compatibility_date > the supported date. Whatever the source, a date newer than the installed workerd supports falls back to the supported date with a warning (like wrangler dev).
The runner enables the nodejs_compat compatibility flag by default. Set no_nodejs_compat (in the wrangler config's compatibility_flags or in miniflareOptions.compatibilityFlags) to opt out; the generated wrapper then avoids Node.js built-ins. If the two sources disagree, miniflareOptions wins.
Wrangler Config
Set the wrangler option to load a Cloudflare Wrangler config (wrangler.json / wrangler.jsonc / wrangler.toml) into the Miniflare options — compatibility date/flags and bindings (vars, KV, R2, D1, Durable Objects, queues):
import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
await using runner = new MiniflareEnvRunner({
miniflare,
name: "my-worker",
data: { entry: "./worker.ts" },
wrangler: true, // auto-discover wrangler.{json,jsonc,toml} (see below)
// wrangler: "./config/wrangler.toml", // or an explicit path
// wranglerEnv: "production", // select a `[env.production]` block
// compatibilityDate: "latest", // override the config's compatibility_date
});Auto-discovery searches parent directories: when the entry file is inside the current working directory, it walks up from the entry's directory to the filesystem root (so a config at a monorepo root is found for an entry in apps/web/src/); when the entry lives elsewhere (e.g. a framework entry hoisted under node_modules/.pnpm), only the entry's own directory is checked before walking up from the cwd, so the cwd's config is never shadowed by one above the entry. The nearest directory wins; within one directory wrangler.json is preferred over wrangler.jsonc, then wrangler.toml (unlike wrangler, which looks for each filename all the way up before trying the next). A config found in a parent directory is logged once.
wranglerEnv selects a named Wrangler environment (--env). When omitted, it defaults to the CLOUDFLARE_ENV environment variable, so CLOUDFLARE_ENV=production selects the production env without passing the option.
You can also pass an inline config object (raw wrangler.json shape) instead of (or in addition to) a file — handy for programmatic setups:
await using runner = new MiniflareEnvRunner({
miniflare,
name: "my-worker",
data: { entry: "./worker.ts" },
wrangler: {
compatibility_date: "2024-09-01",
compatibility_flags: ["nodejs_compat"],
vars: { GREETING: "hello" },
kv_namespaces: [{ binding: "MY_KV", id: "..." }],
},
});When an inline config is passed, a wrangler.{json,jsonc,toml} file is still auto-discovered (as above) and loaded, and the inline config is merged on top of it — inline values win per key, binding records (e.g. vars) merge, and compatibilityFlags are unioned. This lets you keep a committed wrangler file and override a few fields programmatically. If the inline config doesn't define the selected wranglerEnv, its top level is used as-is (the file's env still applies), and a config that fails to load only warns without discarding the other one.
Set wranglerConfigPath to load a specific config file instead of auto-discovering one — with wrangler: true or an inline config (which still merges on top), and without changing the working directory:
await using runner = new MiniflareEnvRunner({
miniflare,
name: "my-worker",
data: { entry: "./.nitro/dev/index.mjs" },
wrangler: { vars: { GREETING: "hello" } },
wranglerConfigPath: "./wrangler.jsonc", // relative to cwd
});A missing wranglerConfigPath file warns (an inline config is still applied). When wrangler is itself a string path, that path wins and wranglerConfigPath is ignored.
The runner hosts a single fetch-only worker, so config entries it can't run are dropped: assets, services, queues.consumers, workflows, tail_consumers/streaming_tail_consumers, and Durable Object bindings to another script (script_name). Durable Object bindings to local classes (exported by your entry or an exports module) are kept — including bindings whose script_name is the worker's own name (the inline config's name when set, else the file's; with wranglerEnv suffixed -<env> unless the env section sets a name, e.g. my-worker-staging), which are local in wrangler dev too — and merged with auto-detected exports. Pass any of the dropped options via miniflareOptions to opt back in.
Whenever wrangler is enabled (true, a path, or an inline config), local state (KV, D1, R2, Durable Objects, ...) persists under <dir>/.wrangler/state/v3 — the same place wrangler dev uses, so both share data. <dir> is the directory of the loaded config file, else of the requested config path (wrangler string or wranglerConfigPath, even if the file is missing), else the current working directory (e.g. inline-only configs, or wrangler: true with no file found). Set miniflareOptions.defaultPersistRoot (or any *Persist option, e.g. kvPersist: false; on miniflare v5, resourcePersistencePath) to opt out.
Pass the wrangler package as wranglerModule — the imported module or a specifier — for full fidelity: TOML, config validation, and every binding type.
import * as miniflare from "miniflare";
import * as wrangler from "wrangler";
await using runner = new MiniflareEnvRunner({
miniflare,
wranglerModule: wrangler,
name: "my-worker",
data: { entry: "./worker.ts" },
wrangler: true,
});With the wrangler package, wrangler's own config warnings (e.g. unexpected/misspelled keys, or a wranglerEnv the config doesn't define) are printed for a config file — once per file version and env, so hot reloads don't repeat them. Inline configs are validated without printing wrangler's warnings (load errors still warn). As in wrangler dev, unexpected keys also trigger wrangler's npm update check (cached for a day), which may print a "newer version of Wrangler available" hint.
Set wranglerEnvFiles to load local dev vars/secrets from custom .env files, like getPlatformProxy({ envFiles }):
await using runner = new MiniflareEnvRunner({
miniflare,
wranglerModule: wrangler,
name: "my-worker",
data: { entry: "./worker.ts" },
wrangler: true,
wranglerEnvFiles: [".env", ".env.development"], // relative to the config file's dir
});Paths resolve against the loaded config file's directory (else the current working directory) and later files override earlier ones. When set (non-empty), .dev.vars is not read; when unset, wrangler's defaults apply (.dev.vars[.<env>], else .env*); an empty array reads .dev.vars but no .env* files. Both the wrangler package and the built-in minimal reader honor it.
Without wranglerModule, wrangler is imported optionally; if that fails too, a built-in minimal reader handles JSON/JSONC files and inline objects (TOML files are skipped with a warning). It follows wrangler's semantics for env selection (bindings and vars are not inherited into a named env), local ids (preview_id / preview_bucket_name / preview_database_id first, so state is shared with wrangler dev), SQLite-backed Durable Objects (migrations[].new_sqlite_classes) and dev vars (.dev.vars[.<env>], .env*, secrets.required), but only maps common bindings (vars, KV, R2, D1, Durable Objects, queue producers); other bindings (e.g. hyperdrive, ai, ratelimits) are ignored with a warning. Pass wranglerModule: false to always use the minimal reader. Values you pass in miniflareOptions always take precedence over config-derived ones — binding records (e.g. bindings) merge per key, and compatibilityFlags are merged.
Config options a single dev worker can't run — services, assets, queues.consumers, workflows, tail_consumers/streaming_tail_consumers, and durable_objects bindings with a script_name naming another worker — are ignored with one warning listing them (e.g. services (MY_SERVICE)); pass the equivalent Miniflare options via miniflareOptions to opt in.
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";
await using runner = new MiniflareEnvRunner({
miniflare,
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
unsafeModuleFallbackServicecalls it with the resolved file path before falling back to raw disk reads - Module rules for
.ts,.tsx,.jsx, and.mtsare added automatically - The wrapper never statically re-exports the entry (
export *), to avoid miniflare's ModuleLocator pre-walking its 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:
await using runner = new MiniflareEnvRunner({
miniflare,
name: "my-worker",
data: { entry: "./worker.ts" },
// Explicit exports (merged with auto-detected ones)
exports: { Counter: { type: "DurableObject" } },
});Auto-wired bindings are merged with Durable Object bindings from miniflareOptions and a wrangler config: exports whose class is already bound (or whose binding name is taken) are skipped. Set exports: false to disable auto-detection entirely.
Exports Module
To load named exports from a separate module, set exports to its absolute path or a data.virtual key (a relative path resolves from the entry's directory, not the working directory). The wrapper re-exports it with export *, so re-exports and exported aliases work.
In this mode nothing is auto-detected or auto-wired: configure the bindings with wrangler or miniflareOptions. The entry's own export class declarations are not re-exported either, so re-export them from the exports module if they are bound.
const runner = new MiniflareEnvRunner({
name: "app",
miniflare,
data: {
entry: "/path/to/server.mjs",
virtual: {
"#server-exports": 'export { Counter } from "/path/to/counter.mjs";',
},
},
exports: "#server-exports",
miniflareOptions: { durableObjects: { COUNTER: "Counter" } },
});In both modes, named exports are registered when the worker starts. Recreate the runner when their implementation or export list changes; reloadModule() only reloads the request entry.
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({
miniflare,
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({
miniflare,
name: "my-worker",
data: { entry: "./worker.ts" },
persistent: true,
});
// Fully destroy: runner.dispose() or MiniflareEnvRunner.disposeAll()An instance is only reused by runners with the same virtual module sources. Once `inval
