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();
import.meta
import.meta.url; // "file:///app/server.js" import.meta.resolve("./schema.json"); // "file:///app/schema.json" import.meta.resolve("runtime:process"); // "runtime:process"
resolve is pure URL resolution against the current module: no I/O, and no check that the target exists — resolving a path and importing it are separate questions.
Bare and #private specifiers resolve as well, through the module loader:
import { file } from "runtime:fs"; // Locate a file shipped inside a dependency, wherever it was installed. const schema = import.meta.resolve("my-orm/migrations/001.sql"); const sql = await file(new URL(schema)).text();
That reads package.json files (see Resolving packages), so it needs the same permission an import does (--allow-imports) and obeys the same root jail and import policy — a run that may not import a package may not locate it either. A URL it returns is always one import() accepts.
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 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:system, and runtime:wasi.
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.