ModuleGraph

Run many instances of one app in a single process with Bun.ModuleGraph. Each instance gets fresh module state and its own timers and I/O; compiled code is shared.

Bun.ModuleGraph is experimental.

A Bun.ModuleGraph loads modules into the current global another time. Each graph has its own module instances, its own require.cache, and its own values for the names you pass as globals. Parsed code and bytecode are shared between graphs; ES modules share JIT-compiled code too. The first module a graph import()s is that graph's main module: import.meta.main and require.main === module are true in it, and in no other module of the graph.

host.ts
// A `process` of the tenant's own: its module code sees this one.
const tenantProcess = Object.create(process, { env: { value: { TENANT: "a" }, enumerable: true } });

const graph = new Bun.ModuleGraph({
  globals: { process: tenantProcess }, // free identifiers in the graph's modules
  onError(error, kind) {
    console.error("tenant failed", kind, error);
  },
});

const app = await graph.import("./app.ts");
await graph.run(() => app.handle(new Request("http://localhost/")));

graph.dispose();

What a graph opens is the graph's#

A graph has a context of its own for timers and I/O. Everything its code opens belongs to the graph:

  • timers
  • Bun.serve and Bun.listen servers, sockets, fetch() requests, WebSockets
  • watchers
  • child processes, including Bun.$
  • workers
  • database connections
  • files opened through Bun.file().writer(), bun:sqlite and node:sqlite
  • graphs its code makes

The context follows the code asynchronously, like AsyncLocalStorage: through await, timers, socket handlers and event listeners.

graph.dispose() closes all of it. From then on the graph hears nothing, like a worker that was terminated:

  • No close handler, onExit or 'error' event is called.
  • A fetch() or a connection that was in flight does not reject.
  • A promise waiting on a request, a child's exit, a file read, compression, crypto.subtle, a DNS lookup, Bun.build or a stream never settles.
  • FinalizationRegistry cleanup callbacks are still called.

Microtasks and process.nextTick callbacks the graph had already queued still run, once. Whatever that code starts does not start: a connection is not dialed, a UDP socket is not bound, nobody can connect to a server it listens with. Its promise stays pending rather than rejecting, so code that retries on failure stops there.

The host's references to the graph's objects are dead too. Do not use a graph's socket, worker, child process, stream or Response after dispose(). An operation on one may fail or may never complete: await worker.terminate(), server.close(callback) or waiting for a child's 'exit' can wait for ever. From a database the graph had open, the host gets "Database has closed". What was buffered and not yet written is dropped.

dispose() releases what a graph holds. It is not a sandbox:

  • A process that leftover code starts with Bun.spawn or child_process.spawn() is started and then killed, so a very short command can finish first.
  • Synchronous calls such as Bun.spawnSync and fs.writeFileSync run to completion.
  • A node:http or node:https request goes through an Agent, and what an Agent opens belongs to the script that made the Agent. A request with no agent goes through http.globalAgent, which is shared (see What is shared). Such a request is not the graph's: leftover code's still goes out, one in flight is not cut, and its 'response' and 'error' callbacks are still called after dispose(). Code whose requests should stop with its graph passes an Agent it made itself.
  • node:quic is not covered yet: an endpoint a graph's code opens stays open after dispose(), and its handlers keep running. Code that opens one closes it before its graph is disposed.
  • A file descriptor that script holds is the script's to manage: one from fs.openSync(), and the one inside a FileHandle or a node:fs stream. dispose() does not close it, as terminating a worker does not. Code that opens one closes it before its graph is disposed. A FileHandle that is garbage-collected unclosed is closed then, and reported as it is in any program: Node's ERR_INVALID_STATE ("A FileHandle object was closed during garbage collection") as an uncaught exception of the process.

Native addons are shared, but their asynchronous work is the graph's too. A Node-API async work item or thread-safe function completes in the context of the graph whose code created it. After dispose() the addon's completion callback still runs, so it can free what it allocated. It cannot call into JavaScript: napi_call_function and the like return napi_cannot_run_js, as they do in a worker that is terminating. A promise it settles for the disposed graph stays pending. A promise it settles for the host or for another graph is settled.

A thread-safe function keeps the process running only for as long as the graph that created it lives, whichever script asked it to (napi_ref_threadsafe_function). A ref taken after that graph was disposed holds until it is released. Its calls always run in the context of the graph that created it. If an addon keeps one thread-safe function for everybody and calls your JavaScript callbacks from it, load the addon in the host first, so that the function is the host's.

Disposing#

graph.dispose() also drops the graph's module registry and require.cache. Code of the graph that is still referenced keeps working, and an error in what still runs in its context, such as a tick it had queued, still goes to onError. From then on:

  • graph.import(), graph.run() and the graph's require() fail with ERR_INVALID_STATE.
  • import() from a function of the graph that the host calls fails the same way.
  • import() by the disposed graph's own leftover code stays pending.
  • Modules of the graph that had not run yet never will.

dispose() settles no promise. An import() that had not finished may reject or may never settle: what its modules were waiting for, such as a timer or a request, went with the graph. A host that may dispose a graph while importing into it should race the import the same way as a call, as shown below.

Calling into a graph#

A function runs in its caller's context, whichever graph defined it. Code the host calls directly runs in the host's context, and a function of one graph that another graph's code calls runs in that other graph's. Use run() to enter the graph's own:

app.start(); // what this opens is the host's
graph.run(() => app.start()); // what this opens is the graph's

A promise that run() returns is the graph's: if the graph is disposed before it settles, it never does. When the host awaits a graph's asynchronous function and may dispose the graph meanwhile, race it against a promise of its own:

const disposed = Promise.withResolvers<never>();
const response = await Promise.race([graph.run(() => app.handle(request)), disposed.promise]);
// elsewhere: graph.dispose(); disposed.reject(new Error("tenant was disposed"));

Bun.ModuleGraph.current is the graph whose context the calling code is running in, or undefined in the host's. Host functions that several graphs share through globals can use it to tell their callers apart.

So a host function a graph calls, such as one passed in globals, runs in that graph's context. The asynchronous work it starts is the graph's and is dropped with it at dispose(). For work that must finish whatever happens to the caller, such as an audit log or billing, leave the graph's context with a snapshot taken in the host:

import { AsyncLocalStorage } from "node:async_hooks";

const asHost = AsyncLocalStorage.snapshot(); // taken by the host, outside any graph
const graph = new Bun.ModuleGraph({
  globals: { record: (entry: string) => asHost(() => appendToAuditLog(entry)) },
});

Errors#

Uncaught exceptions and unhandled rejections go to the onError of the graph in whose context they happen, instead of the process-wide handlers. It is the same context that owns what the code opens: a graph's modules and everything they start run in the graph's context, and so does what the host calls through run().

Whose code threw does not matter, only where it ran. A function of the graph's that the host calls directly runs in the host's context, so what it throws later is the host's; call it through run() for the graph to get it. A host function that the graph's code calls runs in the graph's context, so what it throws there is the graph's.

run() rethrows to its caller what the function throws. If the caller does not catch it either, it is still the graph's: it was thrown in the graph's context, whatever it unwound through afterwards.

An error that no code threw belongs to whoever opened the thing it happened to. A socket the graph opened that fails with no error handler reports to the graph's onError.

graph.import() returns its promise to whoever called it. If a module throws while it is evaluated the promise rejects, and if nobody handles that promise it is the caller's unhandled rejection, like a specifier that does not resolve or an import() into a disposed graph.

onError itself runs in the context the graph was made in: the host's, or the enclosing graph's for a graph that a graph's code made. What it throws, rejects or starts is that context's, so an error the handler lets escape again goes on to the host and does not come back to the same handler.

A graph that was given no onError hands its errors to the onError of the graph in whose context it was made, and so on up to the host. It was made in a graph's context if that graph's code made it, or a host function that graph's code called did.

What is shared#

The global object, globalThis properties, plugins and native addons are shared by every graph and the host. So is process, unless you replace it through globals. A graph's listeners on something the host owns are that object's, so they outlive dispose(). That includes signal, process.stdin and 'exit' listeners on the shared process, and listeners on an emitter the host passed in. ModuleGraph separates instances of an app you trust from each other. It is not a security sandbox.

http.globalAgent and https.globalAgent are shared too. A connection belongs to the Agent that opened it, and an Agent to the script that made it, so a request a graph makes without an agent uses a connection that is not the graph's: dispose() does not close it, and it keeps the process running like any connection of the host's. Code whose connections should go with its graph passes an Agent it made itself.

mock.module() in bun test is one of the shared things: it applies to what a graph loads afterwards. A graph that already loaded the module keeps the instance it has.