Skip to content

Shipping modules to a program

Programs can import. What one imports comes out of the map you pass and out of nowhere else.

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: [] }

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: [] }

The map is per request. Two executions on one warm executor can carry entirely different graphs. Nothing either of them loaded is visible to the other.

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: [] }

Bare specifiers resolve to a sibling of the importer when the map declares one, and otherwise stay the exact key they were written as. import { z } from 'zod-lite' finds the entry under exactly zod-lite, and a namespaced spelling such as runtime:helpers is left alone, not read as a URL scheme. But no extension is guessed, so ./a never finds ./a.js and fails under the normalized name a. Keys are normalized before they are matched. Two spellings of one module are an error and not a coin toss:

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

A name the map does not declare fails with a text listing the names it does declare, whatever the missing name looks like. Against the map above:

Module "node:fs" is not in the module map. Import one of the names it declares: tools/render.js, tools/format.js.

That same answer covers a node: builtin, a file: URL and an https: URL alike. No second resolution path exists and no network fallback, so the map is the whole filesystem a program has.

String entries are JavaScript. Object entries name the kind, and the kind decides the default export. Five kinds exist:

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

Two of those kinds catch people out. 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 map carried. Object entries carrying none of the five kinds report 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 one module three of the five names node’s own module wrapper provides and no more: module, exports and require. 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. Its require reaches only the map, so a CommonJS module cannot get any further out than an ES one:

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: [] }

They come from you. This runtime carries no node module and adds none, 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, or the not-found text when the map has none. So your build step decides which of node’s names a program can reach. A bundle written for node runs here only as far as the shims in your map reach. On every gate, a host migrated from node does exactly this. 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.

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, and it is where most hosts end up. Bundlers that emit one module per file work too, as long as every specifier they emit is a key in the map. Two rules make that work: 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. A host that regenerates its bundle per request throws that away for no gain.

The map is not a package registry. There is no version resolution, no node_modules walk, no exports field and no conditional resolution. A key is a string and a specifier is a string, and after normalization they either match or they do not. It is not a way out either. A program that imports https://esm.sh/lodash gets the module-map text and not a network request, and the network guide says there is no second surface to reach for. Bytes travel as an entry and not as a fetch, and for something a program needs and the network should not be asked for, that is usually the better answer anyway. The sandbox chapter says how the map is compiled, linked and answered 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.