Module system

esrun loads standard ES Modules. Static imports, dynamic import(), top-level await, and import.meta all behave as specified.

Supported by design

JSON module imports (import data from "./x.json" with { type: "json" }) are fully supported.

Not supported, by design

CommonJS (require / module.exports), JSX, and TypeScript. esrun runs JavaScript ES Modules — transpile anything else ahead of time. See Scope & non-goals.

Remote modules explicitly disabled

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

JavaScript
// 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

JavaScript
// Dynamic import() is fully supported, including top-level await.
const { default: plugin } = await import("./plugins/auth.js");
await plugin.init();

import.meta

JavaScript
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:

JavaScript
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:

JSON
{
  "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:

JSON
{
  "name": "my-app",
  "exports": { "./util": "./src/util.js" },
  "imports": {
    "#config": "./src/config.js",
    "#feat/*": "./src/feat/*.js",
    "#dep": "lodash-es"
  }
}
JavaScript
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.

Filesystem root jail

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.

JavaScript
// 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.

JavaScript
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:

CheckEnforced by
Mapped by a preopen, no climbing outruntime:wasi
FileRead / FileWrite grantedthe host op
Inside the root jailthe provider

Syscall coverage and caveats: WebAssembly & WASI.

Last updated on
Edit this page