Module system
esrun loads standard ES Modules. Static imports, dynamic import(), top-level await, and import.meta all behave as specified.
JSON module imports (import data from "./x.json" with { type: "json" }) are fully supported.
CommonJS (require / module.exports), JSX, and TypeScript. esrun runs JavaScript ES Modules — transpile anything else ahead of time. See Scope & non-goals.
Loading code from remote web addresses (https://) is permanently unsupported. This strictly enforces the secure runtime model and prevents arbitrary remote code execution over the network.
Static imports
// Relative and bare specifiers both work. import { greet } from "./greet.js"; import greeter from "greeter"; // from node_modules (ESM) export const message = greet("world");
Dynamic import
// Dynamic import() is fully supported, including top-level await. const { default: plugin } = await import("./plugins/auth.js"); await plugin.init();
A module is its URL
A module is identified by its URL, query and fragment included, as the HTML standard's module map is keyed and as Node documents. The same URL imported twice, statically or dynamically, is one module; a different query is a different module, evaluated afresh from what is on disk:
const a = await import("./config.js?v=1"); const b = await import("./config.js?v=1"); // a === b: one module const c = await import("./config.js?v=2"); // a new one, read from disk now
That is how a dev server re-reads a file that changed: it imports it under a new query. Every version stays in memory for the life of the run, as it does everywhere else, so it is a tool for development rather than a cache to cycle in production. import.meta.url carries the query.
import.meta
import.meta.url and the standard one-argument import.meta.resolve, and nothing else at run time. What each resolves, the permission a bare specifier needs, and how this compares with Node, Deno and Bun are in import.meta across runtimes.
Resolving packages
Bare specifiers resolve from node_modules, honoring the package exports map: conditions, subpath patterns such as "./*", array fallbacks, and null targets that withdraw a subpath. Symlinks are resolved to their real path, so pnpm's nested store works as-is. A CommonJS-only package is rejected with a clear error.
Conditions
The conditions esrun asserts are import and default — the standard ones, and only those. There is no node, no browser, and no esrun-specific key, so a package reaches this runtime through default rather than through a private name. Keys are matched in the order the package author wrote them, nesting as deep as the manifest goes:
{ "exports": { ".": { "node": "./node.js", // skipped — not asserted "import": "./esm.mjs", // ← matched "default": "./fallback.mjs" } } }
A require-only package is CommonJS, and says so rather than reporting a missing subpath.
Private imports and self-reference
A #specifier resolves through the nearest package.json's imports map, and a package that declares exports can import itself by its own name:
{ "name": "my-app", "exports": { "./util": "./src/util.js" }, "imports": { "#config": "./src/config.js", "#feat/*": "./src/feat/*.js", "#dep": "lodash-es" } }
import config from "#config"; // a path inside this package import { one } from "#feat/one"; // a subpath pattern import { chunk } from "#dep"; // another package, by private name import { util } from "my-app/util"; // self-reference through exports
A target may not step outside the package it belongs to: .., . and node_modules path segments are refused, after any * substitution, so a pattern capture cannot be used to walk out.
Module resolution is confined to a project root. A specifier that escapes the root is refused, even via symlink — the sandbox is the default, not an option.
The project root
One directory answers two questions: how far up the node_modules walk goes, and what the filesystem jail is anchored to. It is the working directory, exactly — no walk, no marker file, and no flag that moves it.
The working directory rather than the entry file, because an entry is a path someone typed, and a root derived from one moves when the argument moves. The cwd itself rather than a project detected around it, because not walking is what makes the boundary safe.
cd <proj> && esrun node_modules/@acme/cli/src/cli.js # root: <proj>, so the # CLI's hoisted deps resolve cd /app && esrun dist/server.js # root: /app, with or # without a package.json
An entry the working directory does not contain is refused before the program starts. Run from a workspace and the workspace is the root; run from inside one of its packages and that package is the root — it never widens to reach what is above it.
A filesystem root (/) and your home directory are refused outright: the jail would be the whole machine, or every credential you own. These are the deployments that forgot WORKDIR / WorkingDirectory=, and the cron job that starts in $HOME — so esrun stops and names the fix rather than running unconfined.
The runtime: scheme
Built-in host APIs are not globals. They are imported under the runtime: scheme, which makes every host dependency explicit and statically visible in the source.
// Host functionality is imported under the runtime: scheme. import { env, args } from "runtime:process"; console.log(env.HOME, args);
Each built-in module is backed by host ops that carry the capability check — the security boundary is the op, not the JavaScript. Shipped modules include runtime:process, runtime:fs, runtime:net, runtime:http, runtime:websocket, runtime:serialization, runtime:hashing, runtime:images, runtime:system, runtime:wasi, and runtime:workers.
runtime:context is the one exception to the gate: it carries values across await and has no capability on any export, because there is no side effect in it to gate.
runtime:wasi
WASI preview 1 for wasm32-wasip1 binaries — args, env, clocks, random, stdio, exit, filesystem.
import { WASI } from "runtime:wasi"; const wasi = new WASI({ args: ["prog"], env: { LOG: "debug" }, preopens: { "/sandbox": "./data" }, }); const { instance } = await WebAssembly.instantiate(bytes, wasi.getImportObject()); const status = wasi.start(instance);
Args and env come only from the constructor — the host environment is never inherited.
A file access passes three checks:
| Check | Enforced by |
|---|---|
| Mapped by a preopen, no climbing out | runtime:wasi |
FileRead / FileWrite granted | the host op |
| Inside the root jail | the provider |
Syscall coverage and caveats: WebAssembly & WASI.