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

Shell
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
Where you run from is what you get

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.

Two directories esrun will not run in

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.

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:hashing, 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.

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