Modules
Programs can import. Every specifier resolves against the modules map you pass:
await executor.execute({ code: `import { format } from './format.js'
async () => format(await store.listUsers())`, modules: { './format.js': 'export const format = (rows) => rows.map((row) => row.name).join(", ")', }, providers: [{ name: 'store', tools: ['listUsers'] }], dispatch,})// { result: 'ada, grace', logs: [] }Maps are per request. Two executions on one warm executor can declare entirely different graphs, and neither sees what the other loaded. Static imports at the top of the program and a dynamic import() inside it resolve the same way, against the same map:
await executor.execute({ code: `async () => { const m = await import('./late.js'); return m.value }`, modules: { './late.js': 'export const value = "loaded late"' },})// { result: 'loaded late', logs: [] }Resolving a key
Section titled “Resolving a key”Relative specifiers resolve against the module that imported them, the way a module system does. ./format.js imported from ./tools/render.js looks for ./tools/format.js, so a map with a directory shape works:
await executor.execute({ code: `import { render } from './tools/render.js'
async () => render()`, modules: { './tools/render.js': `import { format } from './format.js'export const render = () => format('x')`, './tools/format.js': `export const format = (value) => 'formatted ' + value`, },})// { result: 'formatted x', logs: [] }Normalization decides the key each of these looks for:
| Specifier | What it resolves to |
|---|---|
./a |
The key a. No extension is guessed, so a map with only ./a.js in it never matches. |
zod-lite, as in import { z } from 'zod-lite' |
A sibling key of the importer when the map declares one, and otherwise the key zod-lite. |
runtime:helpers, node:fs, https://esm.sh/lodash |
A sibling key or the exact key, the same two steps as a bare name, because nothing reads a URL scheme out of a specifier. A key declared under any of those names resolves; an undeclared one meets the not-found text below. |
Two spellings of one module are an error:
await executor.execute({ code: 'async () => 1', modules: { './a.js': 'export default 1', 'a.js': 'export default 2' },})// error: 'Module map keys "./a.js" and "a.js" both resolve to "a.js". Keep one key for each module.'Static imports of a key the map does not declare fail before the program runs. A dynamic import() rejects inside the program instead, where a try catches the same text and the run comes back with no error of its own. Both texts name what the request declared:
| Specifier | What the failure text reads |
|---|---|
node:fs, against the ./tools/ map above |
Module "node:fs" is not in the module map. Import one of the names it declares: tools/render.js, tools/format.js. |
./a.js, on a request that passed no modules |
Module "a.js" is not in the module map. This request declared no modules. |
Resolution ends there. The map is the whole filesystem a program has, and no second resolver goes looking on the network.
Kinds an entry may take
Section titled “Kinds an entry may take”String entries are JavaScript. An object entry names its kind:
| Field | What you pass | What the program imports |
|---|---|---|
js |
Source text | The module’s exports |
cjs |
CommonJS source text | module.exports, as the default export |
json |
A JavaScript object | That object, as the default export |
text |
A string | That string, as the default export |
data |
An ArrayBuffer |
That buffer, as the default export |
json takes an object and not a JSON string, because the map is JavaScript and no parser runs on your behalf. But hand it a string and the program imports that string: config.mode reads undefined, and no error anywhere reports a problem. data takes an ArrayBuffer, and the program receives an ArrayBuffer, so a program that wants bytes wraps it.
await executor.execute({ code: `import config from './config.json'import template from './t.txt'import bytes from './logo.bin'
async () => ({ mode: config.mode, shout: template.toUpperCase(), first: new Uint8Array(bytes)[0],})`, modules: { './config.json': { json: { mode: 'strict' } }, './t.txt': { text: 'hello template' }, './logo.bin': { data: new Uint8Array([137, 80, 78, 71]).buffer }, },})// { result: { mode: 'strict', shout: 'HELLO TEMPLATE', first: 137 }, logs: [] }first reads 137 because new Uint8Array(bytes) wraps the buffer the data entry supplied. An object entry naming none of the five kinds fails with Module "<name>" has no supported kind. Supported kinds: js, cjs, json, text, data. The CommonJS facade exists because bundlers still emit CommonJS. It gives that module three of the five names node’s own module wrapper provides: module, exports and require. That require resolves against the same map an import does:
await executor.execute({ code: `import lib from 'a'
async () => lib.value`, modules: { a: { cjs: 'const b = require("b"); module.exports = { value: b.value + 1 }' }, b: { cjs: 'module.exports = { value: 41 }' }, },})// { result: 42, logs: [] }
await executor.execute({ code: `import lib from 'a'
async () => lib.value`, modules: { a: { cjs: 'const fs = require("node:fs"); module.exports = { value: 1 }' } },})// { result: undefined, error: "Cannot find module 'node:fs'", logs: [] }require("b") finds the entry beside it. require("node:fs") has nowhere else to look. Bundles that read __filename or __dirname meet a ReferenceError, and the factory body inherits strict mode from the ES module it is compiled into, where node would have run it sloppy.
Where node builtins come from
Section titled “Where node builtins come from”Your build step decides which of node’s names a program can import. This runtime ships no node module of its own, because it targets the web platform surface and not node’s. Import node:async_hooks from a bundle and you get whatever your build step put in the map under that key. When the map has none, that import meets the not-found text. So a bundle written for node runs here only as far as the shims in your map go. Every run of pnpm verify puts a host migrated from node through exactly that: it aliases node:async_hooks onto a module-map entry of its own, and the run whose map has no such key meets the not-found text.
Bundling
Section titled “Bundling”The map is a flat set of keys, so a host with a real dependency graph bundles first and passes the result. One entry named ./bundle.js with the whole graph inlined is the simplest thing that works. Bundlers that emit one module per file work too, as long as every specifier they emit is a key in the map. Emit relative specifiers your keys match after normalization, and do not lean on extension guessing, because no resolver here adds .js to a specifier that lacks it.
Bundle once and reuse the same source text. Every execution gets a fresh isolate and links its graph again. But a worker keeps V8’s code cache for the process it lives in, keyed by a hash of each source. A graph byte for byte identical to the one that worker last compiled is parsed once and not every time. Hosts that regenerate their bundle per request throw that away for no gain.
Limits of the module map
Section titled “Limits of the module map”Keys and specifiers are both strings. After normalization the two either match or they do not. There is no version resolution, no node_modules walk, no exports field and no conditional resolution. Programs that import https://esm.sh/lodash get whatever the map declares under that key, or the not-found text when it declares nothing, and never a network request. The network guide has the rest of what a refused program tries. Ship bytes as an entry when you do not want the program asking the network for them. The sandbox chapter says how the map is compiled, linked and handed to a dynamic import().
Every execution on this page links its graph again from the map. Handles is the one arrangement where the graph you shipped stays linked between calls and is not thrown away with the isolate.