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();

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:

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

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

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