diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 07398ce81d0a..5e9f6b1533af 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -5459,6 +5459,73 @@ declare module "bun" { */ function setJITPolicy(scale: number): void; + interface ModuleGraphOptions { + /** + * Variables that only this graph's modules see. Each own enumerable string + * key becomes a free identifier in the graph's module code, in front of + * `globalThis`. Graphs constructed with the same set of names share + * compiled code. + * + * `undefined`, `NaN` and `Infinity` cannot be names (`ERR_INVALID_ARG_VALUE`). + */ + globals?: Record; + /** + * Receives uncaught exceptions and unhandled rejections raised by this + * graph's module code. Without it, and for anything it throws itself, + * errors take the normal process-wide path (`uncaughtException` / + * `unhandledRejection`). + * + * An error is attributed to the graph through its stack, so one thrown + * by code with no graph frame on the stack (e.g. a non-`Error` value + * rejected from a tail call) is reported process-wide. + */ + onError?: (error: unknown) => void; + } + + /** + * Another instance of the ES module graph inside the current global object. + * + * Modules imported through a `ModuleGraph` get their own module records, + * environments, namespaces, `import.meta` and top-level-await state, while + * compiled code is shared with every other instance of the same module. + * Everything else is shared with the rest of the process: `globalThis`, + * intrinsics, builtin modules, CommonJS modules and `require.cache`, timers + * and the event loop. ES modules only. + * + * @example + * ```ts + * using graph = new Bun.unsafe.ModuleGraph({ globals: { tenant: "a" } }); + * const app = await graph.import("./app.ts"); + * ``` + */ + class ModuleGraph implements Disposable { + constructor(options?: ModuleGraphOptions); + + /** + * `import()` into this graph. `specifier` resolves relative to the caller. + * A static or dynamic import made by one of the graph's modules stays in + * the graph. + * + * Rejects with `ERR_INVALID_STATE` once the graph is disposed. + */ + import(specifier: string): Promise; + + /** + * Drop this graph's module registry, and nothing else: code from the graph + * that is still referenced keeps working, but a new `import()` from it (or + * through it) rejects with `ERR_INVALID_STATE`. + */ + dispose(): void; + [Symbol.dispose](): void; + + /** + * The resolved path of the first module imported through this graph, or + * `undefined` before that. `import.meta.main` is `true` in that module and + * `false` in the graph's other modules. + */ + readonly mainModule: string | undefined; + } + /** * Per-process memory footprint in bytes: the memory that only this * process keeps the machine from reusing. diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 727e7e9cef4d..62e7c79745d8 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -230,7 +230,7 @@ declare function $esmNamespaceForCjs(key: string): any | undefined; declare function $esmRegistryDelete(key: string): boolean; declare function $esmRegistryEvaluatedKeys(): string[]; declare function $esmRegistryHasEvaluated(key: string): boolean; -declare function $esmLoadSync(key: string): any; +declare function $esmLoadSync(key: string, graph?: object): any; declare function $get(): TODO; declare function $handleEvent(): TODO; declare function $headers(): TODO; @@ -258,7 +258,10 @@ declare function $removeAbortAlgorithmFromSignal(signal: AbortSignal, algorithmI declare function $redirect(): TODO; declare function $relative(): TODO; declare function $require(): TODO; -declare function $requireESM(path: string): any; +declare function $requireESM(path: string, graph?: object): any; +declare function $requireESMExports(namespace: any): any; +/** The Bun.unsafe.ModuleGraph of the `require()` call in progress, if its caller belongs to one. */ +declare function $requiringModuleGraph(): object | undefined; declare const $requireMap: Map; declare const $internalModuleRegistry: InternalFieldObject; declare function $resolve(name: string, from: string): Promise; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b9593398cb56..0c4b2d751a9f 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -75,6 +75,7 @@ using namespace JSC; macro(encoding) \ macro(end) \ macro(errno) \ + macro(esModule) \ macro(esmLoadSync) \ macro(esmNamespaceForCjs) \ macro(esmRegistryDelete) \ @@ -127,6 +128,7 @@ using namespace JSC; macro(min) \ macro(mockedFunction) \ macro(mode) \ + macro(moduleGraph) \ macro(mtimeMs) \ macro(napiDlopenHandle) \ macro(napiWrappedContents) \ @@ -159,8 +161,10 @@ using namespace JSC; macro(removeAbortAlgorithmFromSignal) \ macro(require) \ macro(requireESM) \ + macro(requireESMExports) \ macro(requireMap) \ macro(requireNativeModule) \ + macro(requiringModuleGraph) \ macro(resolveSync) \ macro(sameSite) \ macro(secure) \ diff --git a/src/js/builtins/CommonJS.ts b/src/js/builtins/CommonJS.ts index 8f3d3db5ffc5..4f97a5eb7bb1 100644 --- a/src/js/builtins/CommonJS.ts +++ b/src/js/builtins/CommonJS.ts @@ -34,6 +34,11 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o return this.$requireNativeModule(id); } else { const existing = $requireMap.$get(id); + if (existing && existing.$esModule && this.$moduleGraph !== undefined) { + // The entry is the host's instance of an ES module. A Bun.unsafe.ModuleGraph's + // modules get their graph's own, which never enters the require cache. + return $requireESMExports($requireESM(id, this.$moduleGraph)); + } if (existing) { // Scenario where this is necessary: // @@ -71,10 +76,13 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o return Bun.jest(this.filename); } - // To handle import/export cycles, we need to create a module object and put - // it into the map before we import it. + // To handle import/export cycles, the module object has to be in the map before + // the module evaluates. Whether `id` is an ES module is not known yet, and an ES + // module is not found in the map while it evaluates (a require cycle gets its live + // namespace from the module loader instead), so $require puts `mod` into the map + // itself, once `id` turns out to be anything else. const mod = $createCommonJSModule(id, {}, false, this); - $requireMap.$set(id, mod); + const graph = this.$moduleGraph; var out: LoaderModule | -1; @@ -97,7 +105,7 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o $argument(1), ); } catch (E) { - $assert($requireMap.$get(id) === undefined, "Module " + JSON.stringify(id) + " should no longer be in the map"); + $assert($requireMap.$get(id) !== mod, "Module " + JSON.stringify(id) + " should no longer be in the map"); throw E; } } else { @@ -106,40 +114,17 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o // -1 means we need to lookup the module from the ESM registry. if (out === -1) { - try { - out = $requireESM(id); - } catch (exception) { - // Since the ESM code is mostly JS, we need to handle exceptions here. - $requireMap.$delete(id); - throw exception; - } - - const namespace = out; - // In a require cycle the namespace is live while the module body is still - // running, so an export named `__esModule` / `module.exports` may be in TDZ. - let esModule, moduleExports; - try { - esModule = namespace.__esModule; - moduleExports = namespace["module.exports"]; - } catch {} - // In Bun, when __esModule is not defined, it's a CustomAccessor on the prototype. - // Various libraries expect __esModule to be set when using ESM from require(). - // We don't want to always inject the __esModule export into every module, - // And creating an Object wrapper causes the actual exports to not be own properties. - // So instead of either of those, we make it so that the __esModule property can be set at runtime. - // It only supports "true" and undefined. Anything non-truthy is treated as undefined. - // https://github.com/oven-sh/bun/issues/14411 - if (esModule === undefined) { - try { - namespace.__esModule = true; - } catch { - // https://github.com/oven-sh/bun/issues/17816 - } - } - - return (mod.exports = moduleExports ?? namespace); + const exports = $requireESMExports($requireESM(id, graph)); + // A Bun.unsafe.ModuleGraph's ES module instances never enter the (shared) require cache. + if (graph !== undefined) return exports; + mod.$esModule = true; + $requireMap.$set(id, mod); + return (mod.exports = exports); } + // A wrapped Module._extensions handler loaded the graph's instance of an ES module into `mod`. + if (graph !== undefined && mod.$esModule) return mod.exports; + const c = $evaluateCommonJSModule(mod, this); if (c && c.indexOf(mod) === -1) { c.push(mod); @@ -171,40 +156,35 @@ export function internalRequire(id: string, parent: JSCommonJSModule) { } $visibility = "Private"; -export function loadEsmIntoCjs(resolvedSpecifier: string) { +export function loadEsmIntoCjs(resolvedSpecifier: string, graph?: object) { // The JSC module loader pipeline is now pure C++. $esmLoadSync sets a VM // flag that makes the loader's internal promise reactions run immediately // (instead of queueing microtasks) whenever the upstream promise is already // settled. Because Bun resolves and reads source code synchronously, the // entire fetch → parse → link → evaluate chain completes within this call // for any module graph that does not use top-level await. - return $esmLoadSync(resolvedSpecifier); + return $esmLoadSync(resolvedSpecifier, graph); } +// `graph` is the Bun.unsafe.ModuleGraph whose instance of the module to load, or +// undefined for the global object's own. $visibility = "Private"; -export function requireESM(this, resolved: string) { +export function requireESM(this, resolved: string, graph?: object) { // `$esmLoadSync` answers from the registry for a record that is already // Evaluated, or still Evaluating because this require() sits inside its own // evaluation (a require cycle), before it loads anything. - const exports = $loadEsmIntoCjs(resolved); + const exports = $loadEsmIntoCjs(resolved, graph); if (exports === undefined) { throw new TypeError(`require() failed to evaluate module "${resolved}". This is an internal consistentency error.`); } return exports; } -export function requireESMFromHijackedExtension(this: JSCommonJSModule, id: string) { - $assert(this); - let namespace; - try { - namespace = $requireESM(id); - } catch (exception) { - // Since the ESM code is mostly JS, we need to handle exceptions here. - $requireMap.$delete(id); - throw exception; - } - - // See `overridableRequire`: TDZ-safe reads for the require-cycle case. +// What require() returns for an ES module's namespace. +$visibility = "Private"; +export function requireESMExports(namespace) { + // In a require cycle the namespace is live while the module body is still + // running, so an export named `__esModule` / `module.exports` may be in TDZ. let esModule, moduleExports; try { esModule = namespace.__esModule; @@ -225,7 +205,21 @@ export function requireESMFromHijackedExtension(this: JSCommonJSModule, id: stri } } - this.exports = moduleExports ?? namespace; + return moduleExports ?? namespace; +} + +export function requireESMFromHijackedExtension(this: JSCommonJSModule, id: string) { + $assert(this); + // The handler ran with `this` in the require cache, as handlers expect. See + // `overridableRequire`: an ES module is not found there while it evaluates. + if ($requireMap.$get(id) === this) $requireMap.$delete(id); + const graph = $requiringModuleGraph(); + const namespace = $requireESM(id, graph); + + this.$esModule = true; + this.exports = $requireESMExports(namespace); + // A Bun.unsafe.ModuleGraph's ES module instances never enter the (shared) require cache. + if (graph === undefined) $requireMap.$set(id, this); } $visibility = "Private"; @@ -254,6 +248,7 @@ export function createRequireCache() { const namespace = $esmNamespaceForCjs(key); if (namespace !== undefined) { const mod = $createCommonJSModule(key, namespace, true, undefined); + mod.$esModule = true; $requireMap.$set(key, mod); return mod; } diff --git a/src/js/private.d.ts b/src/js/private.d.ts index b9f226a790da..d03817368f78 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -69,6 +69,10 @@ declare interface Error { interface JSCommonJSModule { $require(id: string, mod: any, args_count: number, args: Array): any; $requireNativeModule(id: string): any; + /** Set on a `$requireMap` entry whose exports are an ES module's. */ + $esModule?: true; + /** Set on the module behind `import.meta.require` of a Bun.unsafe.ModuleGraph's module. */ + $moduleGraph?: object; children: JSCommonJSModule[]; exports: any; id: string; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 769b4cb550b0..c1efc6cf561d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -391,6 +391,10 @@ pub struct TestIsolationState { // `&JSGlobalObject` is ABI-identical to a non-null `JSGlobalObject*` and C++ // mutating VM/process state through it is interior mutation invisible to Rust. unsafe extern "C" { + safe fn Bun__ModuleGraph__handleUncaughtException( + global: &JSGlobalObject, + err: JSValue, + ) -> bool; safe fn Bun__handleUncaughtException( global: &JSGlobalObject, err: JSValue, @@ -1726,6 +1730,11 @@ impl VirtualMachine { return true; } + // A rejection's owner was decided when it was rejected (GlobalObject::handleRejectedPromises). + if !is_rejection && Bun__ModuleGraph__handleUncaughtException(global_object, err) { + return true; + } + if isBunTest.load(core::sync::atomic::Ordering::Relaxed) { self.unhandled_error_counter += 1; (self.on_unhandled_rejection)(self, global_object, err); diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index dd998bb947f7..312a6acf3551 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -3,6 +3,7 @@ #include "headers.h" #include "ImportMetaObject.h" +#include "ModuleGraph.h" #include "ZigGlobalObject.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -55,14 +56,14 @@ namespace Zig { using namespace JSC; using namespace WebCore; -ImportMetaObject* ImportMetaObject::create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, const WTF::String& url) +ImportMetaObject* ImportMetaObject::create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, const WTF::String& url, Bun::JSModuleGraph* graph) { - ImportMetaObject* ptr = new (NotNull, JSC::allocateCell(vm)) ImportMetaObject(vm, structure, url); + ImportMetaObject* ptr = new (NotNull, JSC::allocateCell(vm)) ImportMetaObject(vm, structure, url, graph); ptr->finishCreation(vm); return ptr; } -ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, const WTF::String& url) +ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, const WTF::String& url, Bun::JSModuleGraph* graph) { VM& vm = globalObject->vm(); Zig::GlobalObject* zigGlobalObject = uncheckedDowncast(globalObject); @@ -73,21 +74,21 @@ ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, co ? zigGlobalObject->ImportMetaBakeObjectStructure() : zigGlobalObject->ImportMetaObjectStructure(); - return create(vm, globalObject, structure, url); + return create(vm, globalObject, structure, url, graph); } -ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, JSValue specifierOrURL) +ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, JSValue specifierOrURL, Bun::JSModuleGraph* graph) { if (WebCore::DOMURL* url = WebCoreCast(JSValue::encode(specifierOrURL))) { - return create(globalObject, url->href().string()); + return create(globalObject, url->href().string(), graph); } WTF::String specifier = specifierOrURL.toWTFString(globalObject); ASSERT(specifier); - return ImportMetaObject::createFromSpecifier(globalObject, specifier); + return ImportMetaObject::createFromSpecifier(globalObject, specifier, graph); } -ImportMetaObject* ImportMetaObject::createFromSpecifier(JSC::JSGlobalObject* globalObject, const String& specifier) +ImportMetaObject* ImportMetaObject::createFromSpecifier(JSC::JSGlobalObject* globalObject, const String& specifier, Bun::JSModuleGraph* graph) { auto index = specifier.find('?'); URL url; @@ -98,7 +99,7 @@ ImportMetaObject* ImportMetaObject::createFromSpecifier(JSC::JSGlobalObject* glo } else { url = URL::fileURLWithFileSystemPath(specifier); } - return create(globalObject, url.string()); + return create(globalObject, url.string(), graph); } extern "C" JSC::EncodedJSValue functionImportMeta__resolveSync(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame) @@ -525,6 +526,16 @@ JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_main, (JSGlobalObject * lexica auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); + if (auto* graph = thisObject->graph()) { + auto* mainModule = graph->mainModule(); + if (!mainModule) + return JSValue::encode(jsBoolean(false)); + // graph.mainModule is a registry key: the path, and the query if there is one. + WTF::URL url(thisObject->url); + auto mainModuleKey = mainModule->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsBoolean(url.protocolIsFile() && mainModuleKey.data == makeString(url.fileSystemPath(), url.queryWithLeadingQuestionMark()))); + } JSValue path = thisObject->pathProperty.getInitializedOnMainThread(thisObject); JSValue bunMain = JSValue::decode(BunObject_getter_main(globalObject)); RETURN_IF_EXCEPTION(scope, {}); @@ -643,7 +654,7 @@ void ImportMetaObject::finishCreation(VM& vm) path = meta->url; } - auto* object = Bun::JSCommonJSModule::createBoundRequireFunction(init.vm, meta->globalObject(), path); + auto* object = Bun::JSCommonJSModule::createBoundRequireFunction(init.vm, meta->globalObject(), path, meta->graph()); RETURN_IF_EXCEPTION(scope, ); ASSERT(object); init.set(uncheckedDowncast(object)); @@ -712,6 +723,7 @@ void ImportMetaObject::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(fn, info()); Base::visitChildren(fn, visitor); + visitor.append(fn->m_graph); fn->requireProperty.visit(visitor); fn->urlProperty.visit(visitor); fn->dirProperty.visit(visitor); diff --git a/src/jsc/bindings/ImportMetaObject.h b/src/jsc/bindings/ImportMetaObject.h index 7a5f5c72382e..81d5f640f669 100644 --- a/src/jsc/bindings/ImportMetaObject.h +++ b/src/jsc/bindings/ImportMetaObject.h @@ -15,6 +15,10 @@ extern "C" JSC::EncodedJSValue Bun__resolveSyncWithPaths(JSC::JSGlobalObject* gl extern "C" JSC::EncodedJSValue Bun__resolveSyncWithSourceIfExists(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, BunString* from, bool is_esm); extern "C" JSC::EncodedJSValue Bun__resolveSyncWithStrings(JSC::JSGlobalObject* global, BunString* specifier, BunString* from, bool is_esm); +namespace Bun { +class JSModuleGraph; +} + namespace Zig { using namespace JSC; @@ -32,13 +36,14 @@ class ImportMetaObject final : public JSC::JSDestructibleObject { } /// Must be called with a valid url string (for `import.meta.url`) - static ImportMetaObject* create(JSC::JSGlobalObject* globalObject, const String& url); + /// `graph` is the Bun.unsafe.ModuleGraph the module belongs to, or nullptr for the global object's own loader. + static ImportMetaObject* create(JSC::JSGlobalObject* globalObject, const String& url, Bun::JSModuleGraph* graph = nullptr); /// Creates an ImportMetaObject from a specifier or URL JSValue /// - URL object -> use that url /// - string -> see the below method for how the string is processed /// - other -> assertion failure - static ImportMetaObject* create(JSC::JSGlobalObject* globalObject, JSValue specifierOrURL); + static ImportMetaObject* create(JSC::JSGlobalObject* globalObject, JSValue specifierOrURL, Bun::JSModuleGraph* graph = nullptr); /// TODO: /// The rules for this function's input is a bit weird. `specifier` is an import path specifier aka a file path. @@ -54,7 +59,7 @@ class ImportMetaObject final : public JSC::JSDestructibleObject { /// /// The above rules get a best estimate bandage to solve the problems /// stated in https://github.com/oven-sh/bun/pull/9399 - static ImportMetaObject* createFromSpecifier(JSC::JSGlobalObject* globalObject, const String& specifier); + static ImportMetaObject* createFromSpecifier(JSC::JSGlobalObject* globalObject, const String& specifier, Bun::JSModuleGraph* graph = nullptr); DECLARE_INFO; DECLARE_VISIT_CHILDREN; @@ -71,6 +76,8 @@ class ImportMetaObject final : public JSC::JSDestructibleObject { static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); static JSValue getPrototype(JSObject*, JSC::JSGlobalObject* globalObject); + Bun::JSModuleGraph* graph() const { return m_graph.get(); } + WTF::String url; LazyProperty requireProperty; LazyProperty dirProperty; @@ -79,14 +86,17 @@ class ImportMetaObject final : public JSC::JSDestructibleObject { LazyProperty pathProperty; private: - static ImportMetaObject* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, const WTF::String& url); + static ImportMetaObject* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, const WTF::String& url, Bun::JSModuleGraph* graph); - ImportMetaObject(JSC::VM& vm, JSC::Structure* structure, const WTF::String& url) + ImportMetaObject(JSC::VM& vm, JSC::Structure* structure, const WTF::String& url, Bun::JSModuleGraph* graph) : Base(vm, structure) , url(url) + , m_graph(graph, JSC::WriteBarrierEarlyInit) { } + JSC::WriteBarrier m_graph; + void finishCreation(JSC::VM&); }; diff --git a/src/jsc/bindings/JSCommonJSExtensions.cpp b/src/jsc/bindings/JSCommonJSExtensions.cpp index 4dece4f37e58..e5dc6343247f 100644 --- a/src/jsc/bindings/JSCommonJSExtensions.cpp +++ b/src/jsc/bindings/JSCommonJSExtensions.cpp @@ -2,6 +2,7 @@ #include "ZigGlobalObject.h" #include "BunProcess.h" #include "ModuleLoader.h" +#include "ModuleGraph.h" #include "JSCommonJSModule.h" namespace Bun { @@ -232,10 +233,12 @@ JSC::EncodedJSValue builtinLoader(JSC::JSGlobalObject* globalObject, JSC::CallFr JSC::VM& vm = globalObject->vm(); ErrorableResolvedSource res; + auto* requiringGraph = global->m_requiringModuleGraph; JSValue result = fetchCommonJSModuleNonBuiltin( global->bunVM(), vm, global, + requiringGraph ? requiringGraph->loader() : global->moduleLoader(), &specifierBunString, specifier, &empty, diff --git a/src/jsc/bindings/JSCommonJSModule.cpp b/src/jsc/bindings/JSCommonJSModule.cpp index 484a71926b11..d6952e7d4b0f 100644 --- a/src/jsc/bindings/JSCommonJSModule.cpp +++ b/src/jsc/bindings/JSCommonJSModule.cpp @@ -63,6 +63,7 @@ #include #include #include "ModuleLoader.h" +#include "ModuleGraph.h" #include #include @@ -1276,29 +1277,32 @@ const JSC::ClassInfo JSCommonJSModule::s_info = { "Module"_s, &Base::s_info, nul const JSC::ClassInfo RequireResolveFunctionPrototype::s_info = { "resolve"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireResolveFunctionPrototype) }; const JSC::ClassInfo RequireFunctionPrototype::s_info = { "require"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireFunctionPrototype) }; -ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue) +ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue, JSCommonJSModule* child) { auto& vm = JSC::getVM(globalObject); JSC::JSValue exception = throwScope.exception(); ASSERT(exception); - // tryClearException() cannot clear a termination, and JSMap::remove with - // it still pending returns false, tripping ASSERT(wasRemoved). + // tryClearException() cannot clear a termination. if (vm.hasPendingTerminationException()) [[unlikely]] RELEASE_AND_RETURN(throwScope, {}); (void)throwScope.tryClearException(); - // On error, remove the module from the require map/ + // On error, remove the module from the require map // so that it can be re-evaluated on the next require. - bool wasRemoved = globalObject->requireMap()->remove(globalObject, specifierValue); + // It is not there when the load failed before the module was cached. + JSValue cached = globalObject->requireMap()->get(globalObject, specifierValue); RETURN_IF_EXCEPTION(throwScope, {}); - ASSERT(wasRemoved); + if (cached == child) { + globalObject->requireMap()->remove(globalObject, specifierValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } throwScope.throwException(globalObject, exception); RELEASE_AND_RETURN(throwScope, {}); } #define REQUIRE_CJS_RETURN_IF_EXCEPTION \ if (throwScope.exception()) [[unlikely]] \ - return finishRequireWithError(globalObject, throwScope, specifierValue) + return finishRequireWithError(globalObject, throwScope, specifierValue, child) // JSCommonJSModule.$require(resolvedId, newModule, userArgumentCount, userOptions) JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) @@ -1312,6 +1316,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlo if (!referrerModule) return throwVMTypeError(globalObject, throwScope); JSValue specifierValue = callframe->uncheckedArgument(0); + // This is always a new JSCommonJSModule object; cast cannot fail. + JSCommonJSModule* child = uncheckedDowncast(callframe->uncheckedArgument(1)); // If Module._resolveFilename is overridden, this could cause this to be a non-string WTF::String specifier = specifierValue.toWTFString(globalObject); REQUIRE_CJS_RETURN_IF_EXCEPTION; @@ -1319,9 +1325,6 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlo WTF::String referrer = referrerModule->filename().toWTFString(globalObject); REQUIRE_CJS_RETURN_IF_EXCEPTION; - // This is always a new JSCommonJSModule object; cast cannot fail. - JSCommonJSModule* child = uncheckedDowncast(callframe->uncheckedArgument(1)); - BunString referrerStr = Bun::toString(referrer); BunString typeAttributeStr = { BunStringTag::Dead }; String typeAttribute = String(); @@ -1348,9 +1351,14 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlo } } + JSValue graphValue = referrerModule->getDirect(vm, WebCore::clientData(vm)->builtinNames().moduleGraphPrivateName()); + auto* graph = graphValue ? uncheckedDowncast(graphValue) : nullptr; + WTF::SetForScope requiringGraphScope(globalObject->m_requiringModuleGraph, graph); + // Load the module JSValue fetchResult = Bun::fetchCommonJSModule( globalObject, + graph ? graph->loader() : globalObject->moduleLoader(), child, specifierValue, specifier, @@ -1657,7 +1665,7 @@ std::optional createCommonJSModule( return commonJSModuleSyntheticSourceCode(sourceOrigin, sourceURL); } -JSObject* JSCommonJSModule::createBoundRequireFunction(VM& vm, JSGlobalObject* lexicalGlobalObject, const WTF::String& pathString) +JSObject* JSCommonJSModule::createBoundRequireFunction(VM& vm, JSGlobalObject* lexicalGlobalObject, const WTF::String& pathString, JSModuleGraph* graph) { ASSERT(!pathString.startsWith("file://"_s)); @@ -1678,6 +1686,8 @@ JSObject* JSCommonJSModule::createBoundRequireFunction(VM& vm, JSGlobalObject* l vm, globalObject->CommonJSModuleObjectStructure(), filename, filename, dirname, SourceCode()); + if (graph) + moduleObject->putDirect(vm, WebCore::clientData(vm)->builtinNames().moduleGraphPrivateName(), graph, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum); SourceCode requireSourceCode = makeSource("require"_s, SourceOrigin(), SourceTaintedOrigin::Untainted); diff --git a/src/jsc/bindings/JSCommonJSModule.h b/src/jsc/bindings/JSCommonJSModule.h index 398057728060..83f7df9357ea 100644 --- a/src/jsc/bindings/JSCommonJSModule.h +++ b/src/jsc/bindings/JSCommonJSModule.h @@ -36,6 +36,8 @@ void populateESMExports( JSC::MarkedArgumentBuffer& exportValues, bool ignoreESModuleAnnotation); +class JSModuleGraph; + class JSCommonJSModule final : public JSC::JSDestructibleObject { public: using Base = JSC::JSDestructibleObject; @@ -110,7 +112,9 @@ class JSCommonJSModule final : public JSC::JSDestructibleObject { JSC::JSString* key, JSValue exportsObject, bool hasEvaluated, JSValue parent); - static JSObject* createBoundRequireFunction(VM& vm, JSGlobalObject* lexicalGlobalObject, const WTF::String& pathString); + // `graph` is the Bun.unsafe.ModuleGraph whose instance require() of an ES module returns, or + // nullptr for the global object's own. + static JSObject* createBoundRequireFunction(VM& vm, JSGlobalObject* lexicalGlobalObject, const WTF::String& pathString, JSModuleGraph* graph = nullptr); void toSyntheticSource(JSC::JSGlobalObject* globalObject, const JSC::Identifier& moduleKey, diff --git a/src/jsc/bindings/ModuleGraph.cpp b/src/jsc/bindings/ModuleGraph.cpp new file mode 100644 index 000000000000..6520418124eb --- /dev/null +++ b/src/jsc/bindings/ModuleGraph.cpp @@ -0,0 +1,484 @@ +#include "root.h" + +#include "ModuleGraph.h" + +#include "ErrorCode.h" +#include "ZigGlobalObject.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Bun { + +using namespace JSC; + +static JSC_DECLARE_HOST_FUNCTION(jsModuleGraphProtoFuncImport); +static JSC_DECLARE_HOST_FUNCTION(jsModuleGraphProtoFuncDispose); +static JSC_DECLARE_CUSTOM_GETTER(jsModuleGraphGetter_mainModule); +static JSC_DECLARE_HOST_FUNCTION(callModuleGraph); +static JSC_DECLARE_HOST_FUNCTION(constructModuleGraph); + +JSModuleGraph* JSModuleGraph::create(VM& vm, Structure* structure, JSModuleLoader* loader, JSValue onError) +{ + auto* graph = new (NotNull, allocateCell(vm)) JSModuleGraph(vm, structure, loader, onError); + graph->finishCreation(vm); + return graph; +} + +JSModuleGraph* JSModuleGraph::forLoader(Zig::GlobalObject* globalObject, JSModuleLoader* loader) +{ + if (loader == globalObject->moduleLoader()) [[likely]] + return nullptr; + if (!globalObject->m_moduleGraphsByLoader.isInitialized()) + return nullptr; + return dynamicDowncast(globalObject->m_moduleGraphsByLoader.get(globalObject)->get(loader)); +} + +JSModuleGraph* JSModuleGraph::forCallee(Zig::GlobalObject* globalObject, JSCell* callee) +{ + auto* jsCallee = callee ? dynamicDowncast(callee) : nullptr; + if (!jsCallee) + return nullptr; + if (auto* function = dynamicDowncast(jsCallee); function && function->isHostOrBuiltinFunction()) + return nullptr; + for (JSScope* scope = jsCallee->scope(); scope; scope = scope->next()) { + if (auto* environment = dynamicDowncast(scope)) + return forLoader(globalObject, environment->moduleRecord()->moduleLoader()); + } + return nullptr; +} + +JSModuleGraph* JSModuleGraph::forStack(Zig::GlobalObject* globalObject, const Vector& stack) +{ + for (auto& frame : stack) { + if (auto* graph = forCallee(globalObject, frame.callee())) + return graph; + } + return nullptr; +} + +JSModuleGraph* JSModuleGraph::forErrorValue(Zig::GlobalObject* globalObject, JSValue error) +{ + auto* errorInstance = dynamicDowncast(error); + if (!errorInstance || !errorInstance->stackTrace()) + return nullptr; + return forStack(globalObject, *errorInstance->stackTrace()); +} + +// The exception that carried `value`, if it is the one thrown last: whoever caught it may have kept only the value. +// Always forgets it, so that it cannot be matched against an unrelated later report of the same value. +static JSModuleGraph* forLastException(Zig::GlobalObject* globalObject, JSValue value) +{ + VM& vm = globalObject->vm(); + auto* exception = vm.lastException(); + auto* graph = exception && exception->value() == value ? JSModuleGraph::forStack(globalObject, exception->stack()) : nullptr; + vm.clearLastException(); + return graph; +} + +void JSModuleGraph::noteRejection(Zig::GlobalObject* globalObject, JSPromise* promise) +{ + if (!globalObject->m_moduleGraphsByLoader.isInitialized()) [[likely]] + return; + // What a graph's code rejects while it runs as an onError is reported process-wide, like what onError throws. + if (globalObject->m_isReportingModuleGraphError) + return; + VM& vm = globalObject->vm(); + JSModuleGraph* graph = nullptr; + StackVisitor::visit(vm.topCallFrame, vm, [&](StackVisitor& visitor) { + if (visitor->isNativeCalleeFrame() || !visitor->callee().isCell()) + return IterationStatus::Continue; + graph = forCallee(globalObject, visitor->callee().asCell()); + return graph ? IterationStatus::Done : IterationStatus::Continue; + }); + // A module body or async function that threw has unwound by the time its promise is rejected. + auto* lastExceptionGraph = forLastException(globalObject, promise->result()); + if (!graph) + graph = lastExceptionGraph; + if (!graph) + graph = forErrorValue(globalObject, promise->result()); + if (graph && !graph->onError().isUndefined()) + globalObject->m_moduleGraphsByRejectedPromise.get(globalObject)->set(vm, promise, graph); +} + +bool JSModuleGraph::reportRejection(Zig::GlobalObject* globalObject, JSPromise* promise) +{ + if (!globalObject->m_moduleGraphsByRejectedPromise.isInitialized()) [[likely]] + return false; + auto* graph = dynamicDowncast(globalObject->m_moduleGraphsByRejectedPromise.get(globalObject)->get(promise)); + return graph && graph->reportError(globalObject, promise->result()); +} + +bool JSModuleGraph::forgetReportedRejection(Zig::GlobalObject* globalObject, JSPromise* promise) +{ + if (!globalObject->m_moduleGraphsByRejectedPromise.isInitialized()) [[likely]] + return false; + return globalObject->m_moduleGraphsByRejectedPromise.get(globalObject)->remove(promise); +} + +bool JSModuleGraph::reportError(Zig::GlobalObject* globalObject, JSValue error) +{ + JSValue onError = m_onError.get(); + if (onError.isUndefined()) + return false; + + VM& vm = globalObject->vm(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + MarkedArgumentBuffer arguments; + arguments.append(error); + // What onError throws must not come back to a graph's onError. + SetForScope reporting(globalObject->m_isReportingModuleGraphError, true); + JSC::profiledCall(globalObject, ProfilingReason::API, onError, JSC::getCallData(onError), jsUndefined(), arguments); + if (auto* exception = scope.exception()) { + if (!vm.isTerminationException(exception)) { + (void)scope.tryClearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + } + } + return true; +} + +// For VirtualMachine::uncaught_exception. True when `error` was thrown by a graph's code and +// that graph's onError took it. +extern "C" bool Bun__ModuleGraph__handleUncaughtException(Zig::GlobalObject* globalObject, EncodedJSValue encodedError) +{ + if (!globalObject->m_moduleGraphsByLoader.isInitialized() || globalObject->m_isReportingModuleGraphError) [[likely]] + return false; + JSValue error = JSValue::decode(encodedError); + JSModuleGraph* graph = nullptr; + if (auto* exception = dynamicDowncast(error)) { + graph = JSModuleGraph::forStack(globalObject, exception->stack()); + error = exception->value(); + } + auto* lastExceptionGraph = forLastException(globalObject, error); + if (!graph) + graph = lastExceptionGraph; + if (!graph) + graph = JSModuleGraph::forErrorValue(globalObject, error); + return graph && graph->reportError(globalObject, error); +} + +void JSModuleGraph::dispose() +{ + if (m_disposed) + return; + m_disposed = true; + m_loader->clearAll(); // takes the loader's cellLock itself +} + +template +void JSModuleGraph::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_loader); + visitor.append(thisObject->m_onError); + visitor.append(thisObject->m_mainModule); +} + +DEFINE_VISIT_CHILDREN(JSModuleGraph); + +const ClassInfo JSModuleGraph::s_info = { "ModuleGraph"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSModuleGraph) }; + +SymbolTable* ModuleGraphSymbolTables::getOrCreate(VM& vm, const Vector& sortedNames) +{ + // createModuleScope() relies on name `i` being variable `i`. + // Length-prefixed, so no name can run into its neighbour whatever characters it holds. + StringBuilder keyBuilder; + for (auto& name : sortedNames) + keyBuilder.append(name.length(), ':', name.string()); + String key = keyBuilder.toString(); + + if (auto it = m_tables.find(key); it != m_tables.end()) { + if (auto* table = it->value.get()) + return table; + } + + m_tables.removeIf([](auto& entry) { return !entry.value; }); + + SymbolTable* table = SymbolTable::create(vm); + for (auto& name : sortedNames) + table->add(NoLockingNecessary, name.impl(), SymbolTableEntry(VarOffset(table->takeNextScopeOffset(NoLockingNecessary)))); + m_tables.set(WTF::move(key), Weak(table)); + return table; +} + +class JSModuleGraphPrototype final : public JSNonFinalObject { +public: + using Base = JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSModuleGraphPrototype* create(VM& vm, JSGlobalObject*, Structure* structure) + { + auto* prototype = new (NotNull, allocateCell(vm)) JSModuleGraphPrototype(vm, structure); + prototype->finishCreation(vm); + return prototype; + } + + template + static GCClient::IsoSubspace* subspaceFor(VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSModuleGraphPrototype, Base); + return &vm.plainObjectSpace(); + } + + DECLARE_INFO; + + static Structure* createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) + { + auto* structure = Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); + structure->setMayBePrototype(true); + return structure; + } + +private: + JSModuleGraphPrototype(VM& vm, Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(VM&); +}; + +static const HashTableValue JSModuleGraphPrototypeTableValues[] = { + { "import"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsModuleGraphProtoFuncImport, 1 } }, + { "dispose"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsModuleGraphProtoFuncDispose, 0 } }, + { "mainModule"_s, static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsModuleGraphGetter_mainModule, 0 } }, +}; + +void JSModuleGraphPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSModuleGraph::info(), JSModuleGraphPrototypeTableValues, *this); + putDirectWithoutTransition(vm, vm.propertyNames->disposeSymbol, getDirect(vm, Identifier::fromString(vm, "dispose"_s)), static_cast(PropertyAttribute::DontEnum)); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +const ClassInfo JSModuleGraphPrototype::s_info = { "ModuleGraph"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSModuleGraphPrototype) }; + +class JSModuleGraphConstructor final : public InternalFunction { +public: + using Base = InternalFunction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSModuleGraphConstructor* create(VM& vm, Structure* structure, JSObject* prototype) + { + auto* constructor = new (NotNull, allocateCell(vm)) JSModuleGraphConstructor(vm, structure); + constructor->finishCreation(vm, prototype); + return constructor; + } + + DECLARE_INFO; + + template + static GCClient::IsoSubspace* subspaceFor(VM& vm) { return &vm.internalFunctionSpace(); } + + static Structure* createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) + { + return Structure::create(vm, globalObject, prototype, TypeInfo(InternalFunctionType, StructureFlags), info()); + } + +private: + JSModuleGraphConstructor(VM& vm, Structure* structure) + : Base(vm, structure, callModuleGraph, constructModuleGraph) + { + } + + void finishCreation(VM& vm, JSObject* prototype) + { + Base::finishCreation(vm, 0, "ModuleGraph"_s); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + } +}; + +const ClassInfo JSModuleGraphConstructor::s_info = { "ModuleGraph"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSModuleGraphConstructor) }; + +JSC_DEFINE_HOST_FUNCTION(callModuleGraph, (JSGlobalObject * globalObject, CallFrame*)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + throwTypeError(globalObject, scope, "Class constructor ModuleGraph cannot be invoked without 'new'"_s); + return {}; +} + +// The scope a graph's module environments are created in: the global lexical environment, or +// for a graph with `globals`, one lexical environment in front of it holding those names. +static JSScope* createModuleScope(Zig::GlobalObject* globalObject, ThrowScope& scope, JSObject* globals) +{ + VM& vm = globalObject->vm(); + JSScope* globalScope = globalObject->globalLexicalEnvironment(); + if (!globals) + return globalScope; + + PropertyNameArrayBuilder builder(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + globals->methodTable()->getOwnPropertyNames(globals, globalObject, builder, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!builder.size()) + return globalScope; + + // Values are read in property order, like Object.entries(). The symbol table is in sorted order, so that one set + // of names is one table whatever order they were written in: variable `i` is the i-th name in sorted order. + MarkedArgumentBuffer values; + for (auto& name : builder) { + // The language treats these three as constants, and so does the transpiler: it folds them and prints them. + if (name == vm.propertyNames->undefinedKeyword || name == vm.propertyNames->NaN || name == vm.propertyNames->Infinity) { + ERR::INVALID_ARG_VALUE(scope, globalObject, "options.globals"_s, jsString(vm, name.string()), "cannot be the name of a graph global"_s); + return nullptr; + } + JSValue value = globals->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, nullptr); + values.append(value); + } + + Vector sortedIndices(builder.size(), [](size_t i) { return static_cast(i); }); + std::ranges::sort(sortedIndices, [&](unsigned a, unsigned b) { + return codePointCompareLessThan(builder[a].string(), builder[b].string()); + }); + Vector sortedNames(builder.size(), [&](size_t i) { return builder[sortedIndices[i]]; }); + + if (!globalObject->m_moduleGraphSymbolTables) + globalObject->m_moduleGraphSymbolTables = makeUnique(); + SymbolTable* symbolTable = globalObject->m_moduleGraphSymbolTables->getOrCreate(vm, sortedNames); + + auto* environment = JSLexicalEnvironment::create(vm, globalObject, globalScope, symbolTable, jsUndefined()); + for (unsigned i = 0; i < sortedIndices.size(); ++i) + environment->variableAt(ScopeOffset(i)).set(vm, environment, values.at(sortedIndices[i])); + return environment; +} + +JSC_DEFINE_HOST_FUNCTION(constructModuleGraph, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + VM& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + + Structure* structure = globalObject->m_JSModuleGraphClassStructure.get(globalObject); + JSValue newTarget = callFrame->newTarget(); + if (globalObject->m_JSModuleGraphClassStructure.constructor(globalObject) != newTarget) [[unlikely]] { + auto* functionGlobalObject = defaultGlobalObject(getFunctionRealm(lexicalGlobalObject, newTarget.getObject())); + RETURN_IF_EXCEPTION(scope, {}); + structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget.getObject(), functionGlobalObject->m_JSModuleGraphClassStructure.get(functionGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + } + + JSObject* globals = nullptr; + JSValue onError = jsUndefined(); + JSValue optionsValue = callFrame->argument(0); + if (!optionsValue.isUndefined()) { + JSObject* options = optionsValue.getObject(); + if (!options) + return ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "options"_s, "object"_s, optionsValue); + + JSValue globalsValue = options->get(lexicalGlobalObject, Identifier::fromString(vm, "globals"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!globalsValue.isUndefined()) { + globals = globalsValue.getObject(); + if (!globals) + return ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "options.globals"_s, "object"_s, globalsValue); + } + + onError = options->get(lexicalGlobalObject, Identifier::fromString(vm, "onError"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (!onError.isUndefined() && !onError.isCallable()) + return ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "options.onError"_s, "function"_s, onError); + } + + JSScope* moduleScope = createModuleScope(globalObject, scope, globals); + RETURN_IF_EXCEPTION(scope, {}); + + auto* loader = JSModuleLoader::create(globalObject, vm, moduleScope); + auto* graph = JSModuleGraph::create(vm, structure, loader, onError); + globalObject->m_moduleGraphsByLoader.get(globalObject)->set(vm, loader, graph); + return JSValue::encode(graph); +} + +JSC_DEFINE_HOST_FUNCTION(jsModuleGraphProtoFuncImport, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + VM& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* graph = dynamicDowncast(callFrame->thisValue()); + if (!graph) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, scope, "ModuleGraph.prototype.import called on an incompatible receiver"_s); + + auto* globalObject = defaultGlobalObject(graph->globalObject()); + // Not a GCOwnedDataScope: requestImportModule below can drive moduleLoaderFetch synchronously; see that function for why no scope may be live. + String specifier = callFrame->argument(0).toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope))); + + // After the conversion, which runs user code that may dispose the graph. + if (graph->isDisposed()) { + ERR::INVALID_STATE(scope, globalObject, "import() from a disposed ModuleGraph"_s); + return JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); + } + + int64_t referrerAsyncOrder; + Identifier key = Zig::GlobalObject::resolveImportSpecifier(globalObject, graph->loader(), WTF::move(specifier), callFrame->callerSourceOrigin(vm), referrerAsyncOrder); + RETURN_IF_EXCEPTION(scope, JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope))); + + graph->setMainModuleIfUnset(vm, jsString(vm, key.string())); + + // The caller is not one of the graph's modules, so its async evaluation order means nothing to the graph's loader. + JSPromise* importPromise = graph->loader()->requestImportModule(globalObject, key, Identifier(), nullptr, nullptr); + RETURN_IF_EXCEPTION(scope, JSValue::encode(JSPromise::rejectedPromiseWithCaughtException(globalObject, scope))); + + // The loader's promise is marked as handled. Like import() (globalFuncImportModule), hand out one that is not, + // so that a failure nobody handles is an unhandled rejection. + scope.release(); + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + if (importPromise->status() == JSPromise::Status::Fulfilled) + promise->fulfill(vm, importPromise->result()); + else + promise->resolve(globalObject, vm, importPromise); + return JSValue::encode(promise); +} + +JSC_DEFINE_HOST_FUNCTION(jsModuleGraphProtoFuncDispose, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); + auto* graph = dynamicDowncast(callFrame->thisValue()); + if (!graph) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, scope, "ModuleGraph.prototype.dispose called on an incompatible receiver"_s); + graph->dispose(); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsModuleGraphGetter_mainModule, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName)) +{ + auto scope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); + auto* graph = dynamicDowncast(JSValue::decode(thisValue)); + if (!graph) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, scope, "ModuleGraph.prototype.mainModule called on an incompatible receiver"_s); + auto* mainModule = graph->mainModule(); + return JSValue::encode(mainModule ? JSValue(mainModule) : jsUndefined()); +} + +void initJSModuleGraphClassStructure(LazyClassStructure::Initializer& init) +{ + auto* prototype = JSModuleGraphPrototype::create(init.vm, init.global, JSModuleGraphPrototype::createStructure(init.vm, init.global, init.global->objectPrototype())); + auto* structure = JSModuleGraph::createStructure(init.vm, init.global, prototype); + auto* constructor = JSModuleGraphConstructor::create(init.vm, JSModuleGraphConstructor::createStructure(init.vm, init.global, init.global->functionPrototype()), prototype); + init.setPrototype(prototype); + init.setStructure(structure); + init.setConstructor(constructor); +} + +extern "C" EncodedJSValue Bun__ModuleGraph__getConstructor(Zig::GlobalObject* globalObject) +{ + return JSValue::encode(globalObject->m_JSModuleGraphClassStructure.constructor(globalObject)); +} + +} // namespace Bun diff --git a/src/jsc/bindings/ModuleGraph.h b/src/jsc/bindings/ModuleGraph.h new file mode 100644 index 000000000000..c425f34ee4df --- /dev/null +++ b/src/jsc/bindings/ModuleGraph.h @@ -0,0 +1,121 @@ +#pragma once + +#include "root.h" +#include "BunClientData.h" +#include +#include +#include +#include +#include +#include + +namespace JSC { +class JSPromise; +class StackFrame; +class SymbolTable; +} + +namespace Bun { + +// `Bun.unsafe.ModuleGraph`: another instance of the ES module graph inside one global object. +// It owns an additional JSModuleLoader, so modules imported through it get their own records, +// environments, namespaces, import.meta and top-level-await state. Everything else (globalThis, +// builtin modules, CommonJS, the event loop) is the global object's. +class JSModuleGraph final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSModuleGraph* create(JSC::VM&, JSC::Structure*, JSC::JSModuleLoader*, JSC::JSValue onError); + + // The graph `loader` belongs to, or nullptr for the global object's own loader. + static JSModuleGraph* forLoader(Zig::GlobalObject*, JSC::JSModuleLoader*); + // The graph whose module code `callee` (a function, or a module body's callee) was created + // in: callee -> scope chain -> module environment -> record -> loader -> graph. + static JSModuleGraph* forCallee(Zig::GlobalObject*, JSC::JSCell* callee); + // The graph of the innermost frame that belongs to one. + static JSModuleGraph* forStack(Zig::GlobalObject*, const WTF::Vector&); + // forStack() of where an Error object was created, while it still has its stack trace. The + // last resort: a frame that made a tail call is in no other stack. + static JSModuleGraph* forErrorValue(Zig::GlobalObject*, JSC::JSValue error); + + // An unhandled rejection belongs to the graph whose code rejected the promise, if that graph + // has an onError. Only knowable while it is being rejected (the rejecting code is on the + // stack, or has just unwound and left its exception in vm.lastException(); forErrorValue() + // after those), so + // noteRejection() records it then and reportRejection() uses it once the rejection is + // reported: true when the graph's onError took it. forgetReportedRejection() is for a promise + // handled after that: true when it was one of those, which the process never heard about. + static void noteRejection(Zig::GlobalObject*, JSC::JSPromise*); + static bool reportRejection(Zig::GlobalObject*, JSC::JSPromise*); + static bool forgetReportedRejection(Zig::GlobalObject*, JSC::JSPromise*); + + // Hands `error` to onError. False when there is no onError; what onError itself throws is + // reported process-wide. + bool reportError(Zig::GlobalObject*, JSC::JSValue error); + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForJSModuleGraph, m_subspaceForJSModuleGraph)); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + JSC::JSModuleLoader* loader() const { return m_loader.get(); } + // A callable, or undefined. + JSC::JSValue onError() const { return m_onError.get(); } + + bool isDisposed() const { return m_disposed; } + void dispose(); + + // The resolved key of the first module imported through the graph; import.meta.main is + // true in that module and nowhere else in the graph. + JSC::JSString* mainModule() const { return m_mainModule.get(); } + void setMainModuleIfUnset(JSC::VM& vm, JSC::JSString* key) + { + if (!m_mainModule) + m_mainModule.set(vm, this, key); + } + +private: + JSModuleGraph(JSC::VM& vm, JSC::Structure* structure, JSC::JSModuleLoader* loader, JSC::JSValue onError) + : Base(vm, structure) + , m_loader(loader, JSC::WriteBarrierEarlyInit) + , m_onError(onError, JSC::WriteBarrierEarlyInit) + { + } + + JSC::WriteBarrier m_loader; + JSC::WriteBarrier m_onError; + JSC::WriteBarrier m_mainModule; + bool m_disposed { false }; +}; + +// Graphs constructed with the same set of `globals` names get the same SymbolTable object for +// their module scope, which is what JSModuleRecord::getOrMakeExecutable requires before it lets +// their records share an executable. Held weakly: an executable keeps its module scope's symbol +// tables alive, so once a table is collected there is no code left to share with. +class ModuleGraphSymbolTables { + WTF_DEPRECATED_MAKE_FAST_ALLOCATED(ModuleGraphSymbolTables); + WTF_MAKE_NONCOPYABLE(ModuleGraphSymbolTables); + +public: + ModuleGraphSymbolTables() = default; + // `sortedNames` must be sorted and free of duplicates. + JSC::SymbolTable* getOrCreate(JSC::VM&, const WTF::Vector& sortedNames); + +private: + WTF::HashMap> m_tables; +}; + +void initJSModuleGraphClassStructure(JSC::LazyClassStructure::Initializer&); + +} // namespace Bun diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 32d233a8bb5a..701db696738f 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -637,8 +637,17 @@ void evaluateCommonJSCustomExtension( RETURN_IF_EXCEPTION(scope, ); } +// require() cannot know up front whether its target is an ES module, which is not found in the require cache while it +// evaluates (see overridableRequire in CommonJS.ts). So the new module enters the cache here, once the target is known +// to be anything else, and before it evaluates so that a cycle finds it. +static void cacheRequireTarget(Zig::GlobalObject* globalObject, JSValue specifierValue, JSCommonJSModule* target) +{ + globalObject->requireMap()->set(globalObject, specifierValue, target); +} + JSValue fetchCommonJSModule( Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, JSCommonJSModule* target, JSValue specifierValue, String specifierWtfString, @@ -666,6 +675,8 @@ JSValue fetchCommonJSModule( // If we assigned module.exports to the virtual module, we're done here. if (promiseOrCommonJSModule == target) { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, target); } JSPromise* promise = uncheckedDowncast(promiseOrCommonJSModule); @@ -689,7 +700,7 @@ JSValue fetchCommonJSModule( JSC::VM::SynchronousModuleQueue queue; queue.prev = vm.m_synchronousModuleQueue; vm.m_synchronousModuleQueue = &queue; - globalObject->moduleLoader()->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); vm.m_synchronousModuleQueue = queue.prev; RETURN_IF_EXCEPTION(scope, {}); @@ -710,6 +721,8 @@ JSValue fetchCommonJSModule( // never give it its source); only ES modules go through the loader, which // fetches its own copy — this one is released with `res`. if (res->result.value.isCommonJSModule) { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); target->evaluate(globalObject, specifierWtfString, res->result.value); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, target); @@ -717,6 +730,8 @@ JSValue fetchCommonJSModule( RELEASE_AND_RETURN(scope, jsNumber(-1)); } case BuiltinModule::Kind::Exports: { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); target->setExportsObject(builtin.exports); target->hasEvaluated = true; RELEASE_AND_RETURN(scope, target); @@ -735,6 +750,8 @@ JSValue fetchCommonJSModule( // If we assigned module.exports to the virtual module, we're done here. if (promiseOrCommonJSModule == target) { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, target); } JSPromise* promise = uncheckedDowncast(promiseOrCommonJSModule); @@ -758,7 +775,7 @@ JSValue fetchCommonJSModule( JSC::VM::SynchronousModuleQueue queue; queue.prev = vm.m_synchronousModuleQueue; vm.m_synchronousModuleQueue = &queue; - globalObject->moduleLoader()->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); vm.m_synchronousModuleQueue = queue.prev; RETURN_IF_EXCEPTION(scope, {}); @@ -770,7 +787,7 @@ JSValue fetchCommonJSModule( } bool hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice = [&]() -> bool { - auto* entry = globalObject->moduleLoader()->registryEntry(JSC::Identifier::fromString(vm, specifierWtfString)); + auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, specifierWtfString)); return entry && entry->status() >= JSC::ModuleRegistryEntry::Status::Fetched; }(); @@ -784,6 +801,8 @@ JSValue fetchCommonJSModule( // The wrapper override only affects CJS evaluation; if it's // active, fall through and re-transpile so the override can run. if (!globalObject->hasOverriddenModuleWrapper) { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); target->evaluate(globalObject, Ref(*cached), cached->m_tag == ResolvedSourceTagPackageJSONTypeModule); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, target); @@ -792,7 +811,7 @@ JSValue fetchCommonJSModule( JSC::VM::SynchronousModuleQueue queue; queue.prev = vm.m_synchronousModuleQueue; vm.m_synchronousModuleQueue = &queue; - globalObject->moduleLoader()->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(Ref(*cached))); + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(Ref(*cached))); if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); vm.m_synchronousModuleQueue = queue.prev; RETURN_IF_EXCEPTION(scope, {}); @@ -801,7 +820,7 @@ JSValue fetchCommonJSModule( } } - return fetchCommonJSModuleNonBuiltin(bunVM, vm, globalObject, &specifier, specifierValue, referrer, typeAttribute, res, target, specifierWtfString, BunLoaderTypeNone, scope); + return fetchCommonJSModuleNonBuiltin(bunVM, vm, globalObject, loader, &specifier, specifierValue, referrer, typeAttribute, res, target, specifierWtfString, BunLoaderTypeNone, scope); } template @@ -809,6 +828,7 @@ JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, JSC::VM& vm, Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, BunString* specifier, JSC::JSValue specifierValue, BunString* referrer, @@ -821,6 +841,8 @@ JSValue fetchCommonJSModuleNonBuiltin( { Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, !isExtension, forceLoaderType); if (res->success && res->result.value.isCommonJSModule) { + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); if constexpr (isExtension) { target->evaluateWithPotentiallyOverriddenCompile(globalObject, specifierWtfString, specifierValue, res->result.value); } else { @@ -851,6 +873,8 @@ JSValue fetchCommonJSModuleNonBuiltin( JSC::JSValue value = JSC::JSONParseWithException(globalObject, jsonSource); RETURN_IF_EXCEPTION(scope, {}); + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); target->putDirect(vm, WebCore::clientData(vm)->builtinNames().exportsPublicName(), value, 0); target->hasEvaluated = true; RELEASE_AND_RETURN(scope, target); @@ -864,6 +888,8 @@ JSValue fetchCommonJSModuleNonBuiltin( RELEASE_AND_RETURN(scope, {}); } + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); target->putDirect(vm, WebCore::clientData(vm)->builtinNames().exportsPublicName(), value, 0); target->hasEvaluated = true; RELEASE_AND_RETURN(scope, target); @@ -873,6 +899,8 @@ JSValue fetchCommonJSModuleNonBuiltin( JSC::throwException(globalObject, scope, JSC::createSyntaxError(globalObject, "Recursive extension. This is a bug in Bun"_s)); RELEASE_AND_RETURN(scope, {}); } + cacheRequireTarget(globalObject, specifierValue, target); + RETURN_IF_EXCEPTION(scope, {}); evaluateCommonJSCustomExtension(globalObject, target, specifierWtfString, specifierValue, JSC::JSValue::decode(res->result.value.cjsCustomExtension)); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, target); @@ -890,7 +918,7 @@ JSValue fetchCommonJSModuleNonBuiltin( JSC::VM::SynchronousModuleQueue queue; queue.prev = vm.m_synchronousModuleQueue; vm.m_synchronousModuleQueue = &queue; - globalObject->moduleLoader()->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(provider)); + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(provider)); if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); vm.m_synchronousModuleQueue = queue.prev; } @@ -903,6 +931,7 @@ template JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, JSC::VM& vm, Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, BunString* specifier, JSC::JSValue specifierValue, BunString* referrer, @@ -916,6 +945,7 @@ template JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, JSC::VM& vm, Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, BunString* specifier, JSC::JSValue specifierValue, BunString* referrer, diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index dc31f18625a9..6051a28e12fd 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -92,8 +92,10 @@ JSValue fetchESMSourceCodeAsync( BunString* referrer, BunString* typeAttribute); +// `loader` is the module loader an ES module is handed to (the result is then -1). JSValue fetchCommonJSModule( Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, JSCommonJSModule* moduleObject, JSValue specifierValue, String specifier, @@ -105,6 +107,7 @@ JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, JSC::VM& vm, Zig::GlobalObject* globalObject, + JSC::JSModuleLoader* loader, BunString* specifier, JSC::JSValue specifierValue, BunString* referrer, diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index bef187094985..958f0ef262b8 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -79,6 +79,7 @@ #include "CallSitePrototype.h" #include "FormatStackTraceForJS.h" #include "JSCommonJSModule.h" +#include "ModuleGraph.h" #include "JSCommonJSExtensions.h" #include "ConsoleObject.h" #include "DOMWrapperWorld-class.h" @@ -831,6 +832,12 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmRegistryEvaluatedKeys, (JSC::JSGlobalObject return JSValue::encode(array); } +JSC_DEFINE_HOST_FUNCTION(functionRequiringModuleGraph, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)) +{ + auto* graph = uncheckedDowncast(globalObject)->m_requiringModuleGraph; + return JSValue::encode(graph ? JSValue(graph) : jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = uncheckedDowncast(lexicalGlobalObject); @@ -841,7 +848,10 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob RETURN_IF_EXCEPTION(scope, {}); auto key = JSC::Identifier::fromString(vm, keyString); - auto* loader = globalObject->moduleLoader(); + auto* graph = dynamicDowncast(callFrame->argument(1)); + if (graph && graph->isDisposed()) [[unlikely]] + return Bun::ERR::INVALID_STATE(scope, globalObject, "require() from a disposed ModuleGraph"_s); + auto* loader = graph ? graph->loader() : globalObject->moduleLoader(); bool entryExistedBefore = false; if (auto* entry = loader->registryEntry(key)) { entryExistedBefore = true; @@ -1138,6 +1148,8 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* switch (operation) { case JSPromiseRejectionOperation::Reject: + // Who rejected it is only knowable now; by the time it is reported the stack is gone. + Bun::JSModuleGraph::noteRejection(globalObj, promise); globalObj->m_aboutToBeNotifiedRejectedPromises.append(obj->vm(), globalObj, promise); break; case JSPromiseRejectionOperation::Handle: @@ -1157,6 +1169,9 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* return; } } + // A graph's onError took it; the process never saw an 'unhandledRejection' for it. + if (Bun::JSModuleGraph::forgetReportedRejection(globalObj, promise)) + break; // The promise rejection has already been notified, now we need to queue it for the rejectionHandled event Bun__handleHandledPromise(globalObj, promise); break; @@ -2083,6 +2098,9 @@ void GlobalObject::finishCreation(VM& vm) m_markdownTagStrings.initialize(); static const LazyClassStructureInit lazyClassStructureInits[] = { + { OBJECT_OFFSETOF(GlobalObject, m_JSModuleGraphClassStructure), [](LazyClassStructure::Initializer& init) { + Bun::initJSModuleGraphClassStructure(init); + } }, { OBJECT_OFFSETOF(GlobalObject, m_JSDirentClassStructure), [](LazyClassStructure::Initializer& init) { Bun::initJSDirentClassStructure(init); } }, @@ -2686,6 +2704,13 @@ void GlobalObject::finishCreation(VM& vm) this->initGeneratedLazyClasses(); + m_moduleGraphsByLoader.initLater([](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::JSWeakMap::create(init.vm, init.owner->weakMapStructure())); + }); + m_moduleGraphsByRejectedPromise.initLater([](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::JSWeakMap::create(init.vm, init.owner->weakMapStructure())); + }); + m_napiTypeTags.initLater([](const JSC::LazyProperty::Initializer& init) { init.set(JSC::JSWeakMap::create(init.vm, init.owner->weakMapStructure())); }); @@ -2800,6 +2825,7 @@ JSC_DEFINE_CUSTOM_GETTER(getConsoleStderr, (JSGlobalObject * globalObject, Encod return JSValue::encode(fn); \ } BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER(getRequireESMBuiltin, commonJSRequireESMCodeGenerator, PropertyAttribute::Builtin | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly) +BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER(getRequireESMExportsBuiltin, commonJSRequireESMExportsCodeGenerator, PropertyAttribute::Builtin | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly) BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER(getLoadEsmIntoCjsBuiltin, commonJSLoadEsmIntoCjsCodeGenerator, PropertyAttribute::Builtin | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly) BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER(getInternalRequireBuiltin, commonJSInternalRequireCodeGenerator, PropertyAttribute::Builtin | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly) #undef BUN_DEFINE_LAZY_GLOBAL_BUILTIN_GETTER @@ -2910,7 +2936,8 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) { BuiltinName::k_esmRegistryDelete, 1, functionEsmRegistryDelete }, { BuiltinName::k_esmRegistryEvaluatedKeys, 0, functionEsmRegistryEvaluatedKeys }, { BuiltinName::k_esmRegistryHasEvaluated, 1, functionEsmRegistryHasEvaluated }, - { BuiltinName::k_esmLoadSync, 1, functionEsmLoadSync }, + { BuiltinName::k_esmLoadSync, 2, functionEsmLoadSync }, + { BuiltinName::k_requiringModuleGraph, 0, functionRequiringModuleGraph }, { BuiltinName::k_makeErrorWithCode, 2, jsFunctionMakeErrorWithCode }, { BuiltinName::k_toClass, 1, jsFunctionToClass }, { BuiltinName::k_inherits, 1, jsFunctionInherits }, @@ -2936,6 +2963,7 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) // lazy getters so their source isn't parsed during global object construction. // (See getRequireESMBuiltin / getLoadEsmIntoCjsBuiltin / getInternalRequireBuiltin above.) putDirectCustomAccessor(vm, builtinNames.requireESMPrivateName(), JSC::CustomGetterSetter::create(vm, getRequireESMBuiltin, nullptr), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | PropertyAttribute::CustomValue); + putDirectCustomAccessor(vm, builtinNames.requireESMExportsPrivateName(), JSC::CustomGetterSetter::create(vm, getRequireESMExportsBuiltin, nullptr), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | PropertyAttribute::CustomValue); putDirectCustomAccessor(vm, builtinNames.loadEsmIntoCjsPrivateName(), JSC::CustomGetterSetter::create(vm, getLoadEsmIntoCjsBuiltin, nullptr), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | PropertyAttribute::CustomValue); putDirectCustomAccessor(vm, builtinNames.internalRequirePrivateName(), JSC::CustomGetterSetter::create(vm, getInternalRequireBuiltin, nullptr), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | PropertyAttribute::CustomValue); @@ -3329,6 +3357,8 @@ void GlobalObject::handleRejectedPromises() continue; inflight.index = i + 1; + if (Bun::JSModuleGraph::reportRejection(this, promise)) + continue; Bun__handleRejectedPromise(this, promise); if (auto ex = scope.exception()) { if (virtual_machine.isTerminationException(ex)) [[unlikely]] @@ -3569,36 +3599,18 @@ JSC::Identifier StandaloneGlobalObject::moduleLoaderResolve(JSGlobalObject* glob return GlobalObject::moduleLoaderResolve(globalObject, loader, key, referrer, WTF::move(fetcher), b); } -JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, +JSC::Identifier GlobalObject::resolveImportSpecifier(Zig::GlobalObject* globalObject, JSModuleLoader* loader, - JSString* moduleNameValue, - RefPtr parameters, + WTF::String moduleName, const SourceOrigin& sourceOrigin, - bool deferred) + int64_t& referrerAsyncOrder) { - UNUSED_PARAM(deferred); - auto* globalObject = static_cast(jsGlobalObject); - VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - { - JSC::JSPromise* result = NodeVM::importModule(globalObject, moduleNameValue, parameters, sourceOrigin); - RETURN_IF_EXCEPTION(scope, nullptr); - if (result) { - return result; - } - } - - JSC::Identifier resolvedIdentifier; - - // Not `auto` (GCOwnedDataScope): importModule below can drive moduleLoaderFetch synchronously; see that function for why no scope may be live. - WTF::String moduleName = moduleNameValue->value(globalObject); - RETURN_IF_EXCEPTION(scope, nullptr); - auto sourceURL = sourceOrigin.url(); String sourceOriginStringHolder; - int64_t referrerAsyncOrder = -1; + referrerAsyncOrder = -1; if (sourceURL.isEmpty()) { sourceOriginStringHolder = String("."_s); } else if (sourceURL.protocolIsFile()) { @@ -3616,45 +3628,69 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO } if (globalObject->onLoadPlugins.hasVirtualModules()) { - if (auto resolution = globalObject->onLoadPlugins.resolveVirtualModule(moduleName, sourceURL.protocolIsFile() ? sourceOriginStringHolder : String())) { - resolvedIdentifier = JSC::Identifier::fromString(vm, resolution.value()); + if (auto resolution = globalObject->onLoadPlugins.resolveVirtualModule(moduleName, sourceURL.protocolIsFile() ? sourceOriginStringHolder : String())) + return JSC::Identifier::fromString(vm, resolution.value()); + } - auto result = loader->requestImportModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, /* deferred */ false, referrerAsyncOrder); - if (scope.exception()) [[unlikely]] { - return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); - } - return result; + if (moduleName.startsWith("file://"_s)) { + auto url = WTF::URL(moduleName); + if (url.isValid() && !url.isEmpty()) { + moduleName = url.fileSystemPath(); } } - { - if (moduleName.startsWith("file://"_s)) { - auto url = WTF::URL(moduleName); - if (url.isValid() && !url.isEmpty()) { - moduleName = url.fileSystemPath(); - } - } + ErrorableString res; + BunString moduleNameZ = Bun::toString(moduleName); + BunString sourceOriginZ = Bun::toString(sourceOriginStringHolder); + BunString queryZ = BunStringEmpty; + Zig__GlobalObject__resolve(&res, globalObject, &moduleNameZ, &sourceOriginZ, &queryZ); + RETURN_IF_EXCEPTION(scope, {}); + if (!res.success) [[unlikely]] { + throwException(scope, res.result.err, globalObject); + return {}; + } + auto resolved = res.result.value.transferToWTFString(); + auto query = queryZ.transferToWTFString(); - ErrorableString res; - BunString moduleNameZ = Bun::toString(moduleName); - BunString sourceOriginZ = Bun::toString(sourceOriginStringHolder); - BunString queryZ = BunStringEmpty; - Zig__GlobalObject__resolve(&res, globalObject, &moduleNameZ, &sourceOriginZ, &queryZ); - RETURN_IF_EXCEPTION(scope, JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); - if (!res.success) [[unlikely]] { - throwException(scope, res.result.err, globalObject); - return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); - } - auto resolved = res.result.value.transferToWTFString(); - auto query = queryZ.transferToWTFString(); + if (query.isEmpty()) + return JSC::Identifier::fromString(vm, resolved); + return JSC::Identifier::fromString(vm, makeString(resolved, query)); +} - if (query.isEmpty()) { - resolvedIdentifier = JSC::Identifier::fromString(vm, resolved); - } else { - resolvedIdentifier = JSC::Identifier::fromString(vm, makeString(resolved, query)); +JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, + JSModuleLoader* loader, + JSString* moduleNameValue, + RefPtr parameters, + const SourceOrigin& sourceOrigin, + bool deferred) +{ + UNUSED_PARAM(deferred); + auto* globalObject = static_cast(jsGlobalObject); + + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + { + JSC::JSPromise* result = NodeVM::importModule(globalObject, moduleNameValue, parameters, sourceOrigin); + RETURN_IF_EXCEPTION(scope, nullptr); + if (result) { + return result; } } + if (auto* graph = Bun::JSModuleGraph::forLoader(globalObject, loader); graph && graph->isDisposed()) [[unlikely]] { + Bun::ERR::INVALID_STATE(scope, globalObject, "import() from a disposed ModuleGraph"_s); + return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); + } + + // Not `auto` (GCOwnedDataScope): importModule below can drive moduleLoaderFetch synchronously; see that function for why no scope may be live. + WTF::String moduleName = moduleNameValue->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + + int64_t referrerAsyncOrder; + JSC::Identifier resolvedIdentifier = resolveImportSpecifier(globalObject, loader, WTF::move(moduleName), sourceOrigin, referrerAsyncOrder); + RETURN_IF_EXCEPTION(scope, JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); + // The C++ module loader now extracts `with.type` into a // ScriptFetchParameters before calling this hook, so `parameters` is // already the parsed RefPtr (or null). Just forward it. @@ -4206,7 +4242,7 @@ JSC::JSObject* GlobalObject::moduleLoaderCreateImportMetaProperties(JSGlobalObje JSModuleRecord* record, RefPtr) { - return Zig::ImportMetaObject::create(globalObject, key); + return Zig::ImportMetaObject::create(globalObject, key, Bun::JSModuleGraph::forLoader(static_cast(globalObject), loader)); } extern "C" bool Bun__VM__entryEvaluationStarted(void*); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 71be788a977a..abf5a936a7a6 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -39,6 +39,8 @@ class JSNextTickQueue; class Process; class SecureContextCache; class GCProfilerObserver; +class ModuleGraphSymbolTables; +class JSModuleGraph; } // namespace Bun namespace v8 { @@ -190,6 +192,8 @@ class GlobalObject : public Bun::GlobalScope { static void reportUncaughtExceptionAtEventLoop(JSGlobalObject*, JSC::Exception*); static JSGlobalObject* deriveShadowRealmGlobalObject(JSGlobalObject* globalObject); static JSC::JSPromise* moduleLoaderImportModule(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSString* moduleNameValue, RefPtr, const JSC::SourceOrigin&, bool deferred); + // The registry key `moduleName` imported from `sourceOrigin` resolves to; throws on failure. + static JSC::Identifier resolveImportSpecifier(Zig::GlobalObject*, JSC::JSModuleLoader*, WTF::String moduleName, const JSC::SourceOrigin&, int64_t& referrerAsyncOrder); static JSC::Identifier moduleLoaderResolve(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSValue key, JSC::JSValue referrer, RefPtr, bool useImportMap); static JSC::JSPromise* moduleLoaderFetch(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSValue key, const WTF::String& referrer, RefPtr, RefPtr); static JSC::JSObject* moduleLoaderCreateImportMetaProperties(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSValue key, JSC::JSModuleRecord*, RefPtr); @@ -580,6 +584,11 @@ class GlobalObject : public Bun::GlobalScope { V(public, LazyClassStructure, m_JSBufferClassStructure) \ V(public, LazyClassStructure, m_NodeVMScriptClassStructure) \ V(public, LazyClassStructure, m_NodeVMSourceTextModuleClassStructure) \ + V(public, LazyClassStructure, m_JSModuleGraphClassStructure) \ + /* JSModuleLoader -> JSModuleGraph, for every loader a Bun.unsafe.ModuleGraph created. */ \ + V(public, LazyPropertyOfGlobalObject, m_moduleGraphsByLoader) \ + /* JSPromise -> the JSModuleGraph (one with an onError) whose code rejected it. */ \ + V(public, LazyPropertyOfGlobalObject, m_moduleGraphsByRejectedPromise) \ V(public, LazyClassStructure, m_NodeVMSyntheticModuleClassStructure) \ V(public, LazyClassStructure, m_JSX509CertificateClassStructure) \ V(public, LazyClassStructure, m_JSWebViewClassStructure) \ @@ -804,6 +813,15 @@ class GlobalObject : public Bun::GlobalScope { // does not leave the observer registered. std::unique_ptr m_gcProfilerObserver; + // Created by the first Bun.unsafe.ModuleGraph with `globals`. Holds its SymbolTables weakly, + // so no visitChildren wiring. + std::unique_ptr m_moduleGraphSymbolTables; + // The graph of the `require()` call in progress, when its caller is one of a graph's + // modules. Kept alive by that caller's bound require, which is on the stack. + Bun::JSModuleGraph* m_requiringModuleGraph { nullptr }; + // While a graph's onError runs. What it throws is reported process-wide. + bool m_isReportingModuleGraphError { false }; + WTF::Vector> m_napiEnvs; Ref makeNapiEnv(const napi_module&); napi_env makeNapiEnvForFFI(); diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index 0ca8a219c18d..cb3f0064dba6 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -43,6 +43,7 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForNodeVMSourceTextModule { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNodeVMSyntheticModule { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSCommonJSModule { nullptr }; + GCClient::IsoSubspace* m_clientSubspaceForJSModuleGraph { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSCommonJSExtensions { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSMockImplementation { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSModuleMock { nullptr }; diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index 0d5914930ec3..045d8bacdf05 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -44,6 +44,7 @@ class DOMIsoSubspaces { IsoSubspace* m_subspaceForNodeVMSourceTextModule { nullptr }; IsoSubspace* m_subspaceForNodeVMSyntheticModule { nullptr }; IsoSubspace* m_subspaceForJSCommonJSModule { nullptr }; + IsoSubspace* m_subspaceForJSModuleGraph { nullptr }; IsoSubspace* m_subspaceForJSCommonJSExtensions { nullptr }; IsoSubspace* m_subspaceForJSMockImplementation { nullptr }; IsoSubspace* m_subspaceForJSModuleMock { nullptr }; diff --git a/src/runtime/api/UnsafeObject.rs b/src/runtime/api/UnsafeObject.rs index 8ede484b942d..c100c11b7361 100644 --- a/src/runtime/api/UnsafeObject.rs +++ b/src/runtime/api/UnsafeObject.rs @@ -6,7 +6,7 @@ use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSType, JSValue, JsResult} pub(crate) fn create(global: &JSGlobalObject) -> JSValue { // NB: helper sizes inline capacity from `fns.len()`, fixing the prior // `len = 3` vs 4-entry drift. - jsc::create_host_function_object( + let object = jsc::create_host_function_object( global, &[ ("gcAggressionLevel", __jsc_host_gc_aggression_level, 1), @@ -15,7 +15,13 @@ pub(crate) fn create(global: &JSGlobalObject) -> JSValue { ("memoryFootprint", __jsc_host_memory_footprint, 1), ("setJITPolicy", __jsc_host_set_jit_policy, 1), ], - ) + ); + object.put( + global, + b"ModuleGraph", + Bun__ModuleGraph__getConstructor(global), + ); + object } #[bun_jsc::host_fn] @@ -57,6 +63,7 @@ fn array_buffer_to_string(global: &JSGlobalObject, frame: &CallFrame) -> JsResul } unsafe extern "C" { + safe fn Bun__ModuleGraph__getConstructor(global: &JSGlobalObject) -> JSValue; safe fn dump_zone_malloc_stats(); safe fn Bun__memoryFootprint() -> usize; } diff --git a/src/runtime/bake/BakeGlobalObject.cpp b/src/runtime/bake/BakeGlobalObject.cpp index caf5a1716038..7c0f82a70674 100644 --- a/src/runtime/bake/BakeGlobalObject.cpp +++ b/src/runtime/bake/BakeGlobalObject.cpp @@ -25,7 +25,7 @@ bakeModuleLoaderImportModule(JSC::JSGlobalObject* global, WTF::String keyString = moduleNameValue->getString(global); if (keyString.startsWith("bake:/"_s)) { auto& vm = JSC::getVM(global); - return JSC::importModule(global, JSC::Identifier::fromString(vm, keyString), + return moduleLoader->requestImportModule(global, JSC::Identifier::fromString(vm, keyString), JSC::Identifier(), WTF::move(parameters), nullptr); } @@ -47,7 +47,7 @@ bakeModuleLoaderImportModule(JSC::JSGlobalObject* global, BunString result = BakeProdResolve(global, &refererBunString, &keyBunString); RETURN_IF_EXCEPTION(scope, nullptr); - return JSC::importModule(global, JSC::Identifier::fromString(vm, result.transferToWTFString()), + return moduleLoader->requestImportModule(global, JSC::Identifier::fromString(vm, result.transferToWTFString()), JSC::Identifier(), WTF::move(parameters), nullptr); } diff --git a/test/bundler/bundler_compile_module_graph.test.ts b/test/bundler/bundler_compile_module_graph.test.ts new file mode 100644 index 000000000000..8c49174d9729 --- /dev/null +++ b/test/bundler/bundler_compile_module_graph.test.ts @@ -0,0 +1,673 @@ +import { afterEach, beforeEach, describe } from "bun:test"; +import { isASAN, isDebug, isWindows } from "harness"; +import { BundlerTestInput, BundlerTestRunOptions, itBundled as itBundledBase } from "./expectBundled"; + +// Bun.unsafe.ModuleGraph inside `bun build --compile --bytecode --format=esm` executables: each graph's loader gets +// its own records of the embedded modules. The entry of every case imports ITSELF (`import.meta.url`) or another +// embedded entry point (`import.meta.resolve("./other.ts")`) through graphs and branches on the `tenant` graph global: +// the host's instance drives and prints, the graphs' instances report through a `report` graph global. As in +// bundler_compile_prelinked.test.ts, each executable runs with the pre-resolved module graph in use, with it +// cross-checked against the specification's ResolveExport, and with it disabled; all three must print the same thing, +// nothing on stderr, and exit 0. +const itBundled = (id: string, opts: BundlerTestInput) => itBundledBase(id, { backend: "cli", ...opts }); + +type LoaderMode = "graph" | "graph+validate" | "by-name"; +const loaderModes: { mode: LoaderMode; env: Record }[] = [ + { mode: "graph", env: {} }, + { mode: "graph+validate", env: { BUN_JSC_validatePrelinkedModuleInfo: "1" } }, + { mode: "by-name", env: { BUN_JSC_usePrelinkedModuleInfo: "0" } }, +]; + +// Windows looks up a new executable's reputation inside its first CreateProcess (seconds under Smart App Control), and +// Bun.spawn makes that call on the JS thread, where it stalls every other case in flight. Started through cmd.exe, the +// wait is cmd.exe's. expectBundled puts bunArgs in front of the executable's path. +const launcher = isWindows ? ["cmd.exe", "/d", "/c"] : []; + +function eachMode(run: (mode: LoaderMode) => BundlerTestRunOptions): BundlerTestRunOptions[] { + return loaderModes.map(({ mode, env }) => { + const options = run(mode); + return { ...options, bunArgs: launcher, env: { ...options.env, ...env } }; + }); +} + +type PerVariant = T | ((splitting: boolean) => T); +const variant = (v: PerVariant, splitting: boolean): T => (typeof v === "function" ? (v as any)(splitting) : v); + +interface GraphCase { + files: Record; + /** Written next to the bundle after it is built; never embedded. The executable runs with the bundle root as cwd. */ + runtimeFiles?: Record; + stdout: PerVariant; + /** + * Every file listed here is also an entry point, which embeds it under a path of its own that a run-time import() + * or graph.import() can name. With splitting it is a chunk that shares the state of the modules it has in common + * with the main entry; without splitting it is a bundle of its own with its own copy of them. + */ + entries: string[]; +} + +// Registers `compile/module-graph/` (one bundle per entry point) and `compile/module-graph/+splitting` +// (entries and dynamic imports become separate chunks that import each other's bindings). +function graphCase(id: string, c: GraphCase) { + for (const splitting of [false, true]) { + itBundled(`compile/module-graph/${id}${splitting ? "+splitting" : ""}`, { + compile: true, + bytecode: true, + format: "esm", + splitting, + files: c.files, + runtimeFiles: c.runtimeFiles, + entryPointsRaw: c.entries.map(e => "./" + e.replace(/^\//, "")), + outfile: "dist/out", + run: eachMode(() => ({ + stdout: variant(c.stdout, splitting), + stderr: "", + file: "dist/out", + setCwd: c.runtimeFiles !== undefined, + })), + }); + } +} + +describe.concurrent("bundler", () => { + // Every case links an executable: `bun build --compile` copies the bun binary and writes it again with the bundle + // patched in. The 20 links describe.concurrent would start at once ran CI out of memory in bundler_compile.test.ts, + // so a case takes a slot first, in beforeEach, where its own timeout is not running yet. An ASAN or debug binary is + // about a gigabyte and its link is bound by disk writeback, so those run one at a time. + let freeSlots = isASAN || isDebug ? 1 : 3; + const waiting: (() => void)[] = []; + beforeEach(async () => { + if (freeSlots > 0) freeSlots--; + else await new Promise(resolve => waiting.push(resolve)); + }, Infinity); + afterEach(() => { + const next = waiting.shift(); + if (next) next(); + else freeSlots++; + }); + + // Three graphs and the host each have their own state, and an import() made by a graph's entry stays in that graph: + // it is the namespace graph.import() and import.meta.require() of the same embedded entry point give for that graph. + graphCase("StateAndImportAffinity", { + files: { + "/entry.ts": /* js */ ` + import { bump, counter } from "./state"; + declare const tenant: string | undefined; + declare const report: (line: string) => void; + const runtime = (specifier: string) => specifier; + export const lazy = typeof tenant === "undefined" ? undefined : import(runtime("./lazy.ts")); + export const required = () => import.meta.require(runtime("./lazy.ts")); + if (typeof tenant === "undefined") { + bump(); + const names = ["a", "b", "c"]; + const lines: string[] = []; + const graphs = names.map(name => new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name, report: (line: string) => lines.push(line) } })); + const selves: any[] = []; + for (const graph of graphs) selves.push(await graph.import(import.meta.url)); + const lazies: any[] = []; + for (const [i, graph] of graphs.entries()) { + const own = await selves[i].lazy; + lazies.push(own); + const viaGraph = await graph.import(import.meta.resolve("./lazy.ts")); + lines.push(names[i] + " lazy " + own.describe() + " " + (own === viaGraph) + " " + (own.describe === selves[i].required().describe)); + } + const hostLazy = await import(runtime("./lazy.ts")); + lines.push("host " + counter() + " " + hostLazy.describe() + " distinct " + new Set([hostLazy, ...lazies]).size + " " + new Set(selves).size); + console.log(lines.join("\\n")); + } else { + for (let i = 0; i < tenant.charCodeAt(0) - 96; i++) bump(); + report(tenant + " " + counter()); + } + `, + "/state.ts": /* js */ ` + let n = 0; + export function bump() { n++; } + export function counter() { return n; } + `, + "/lazy.ts": /* js */ ` + import { bump, counter } from "./state"; + declare const tenant: string | undefined; + bump(); + export function describe() { return (typeof tenant === "undefined" ? "host" : tenant) + ":" + counter(); } + `, + }, + entries: ["/entry.ts", "/lazy.ts"], + // Without splitting lazy.ts is a bundle with its own copy of state.ts, so its counter only sees its own bump(). + stdout: splitting => + splitting + ? "a 1\nb 2\nc 3\na lazy a:2 true true\nb lazy b:3 true true\nc lazy c:4 true true\nhost 2 host:2 distinct 4 3" + : "a 1\nb 2\nc 3\na lazy a:1 true true\nb lazy b:1 true true\nc lazy c:1 true true\nhost 1 host:1 distinct 4 3", + }); + + // Graph globals are variables of every embedded module of that graph (the entry, a bundled dependency, another + // entry point loaded at run time): assignment writes the graph's variable, a module's own top-level declaration + // shadows it, and neither the host's instance, the options object, globalThis nor `new Function` code sees it. + graphCase("Globals", { + files: { + "/entry.ts": /* js */ ` + import { readTenant, writeTenant, viaFunctionConstructor } from "./dep"; + import { shadowed } from "./shadow"; + declare let tenant: string | undefined; + declare const report: (line: string) => void; + const runtime = (specifier: string) => specifier; + if (typeof tenant === "undefined") { + const lines: string[] = []; + for (const name of ["a", "b"]) { + const globals = { tenant: name, report: (line: string) => lines.push(line) }; + await new (Bun.unsafe as any).ModuleGraph({ globals }).import(import.meta.url); + lines.push(name + " options object " + globals.tenant); + } + lines.push("host " + readTenant() + " " + typeof (globalThis as any).tenant + " " + shadowed()); + console.log(lines.join("\\n")); + } else { + const lazy = await import(runtime("./lazy.ts")); + const before = [tenant, readTenant(), lazy.readTenant()].join(); + writeTenant(tenant + "!"); + const afterDep = [tenant, readTenant(), lazy.readTenant()].join(); + lazy.writeTenant(tenant + "?"); + const afterLazy = [tenant, readTenant(), lazy.readTenant()].join(); + report([before, afterDep, afterLazy, shadowed(), viaFunctionConstructor()].join(" ")); + } + `, + "/dep.ts": /* js */ ` + declare let tenant: string | undefined; + export function readTenant() { return typeof tenant === "undefined" ? "none" : tenant; } + export function writeTenant(value: string) { tenant = value; } + export function viaFunctionConstructor() { return new Function("return typeof tenant")(); } + `, + "/shadow.ts": /* js */ ` + const tenant = ["shadow"][0]; + export function shadowed() { return tenant; } + `, + "/lazy.ts": /* js */ ` + declare let tenant: string | undefined; + export function readTenant() { return tenant; } + export function writeTenant(value: string) { tenant = value; } + `, + }, + entries: ["/entry.ts", "/lazy.ts"], + stdout: + "a,a,a a!,a!,a! a!?,a!?,a!? shadow undefined\na options object a\n" + + "b,b,b b!,b!,b! b!?,b!?,b!? shadow undefined\nb options object b\n" + + "host none undefined shadow", + }); + + // An import cycle (entry -> a -> b -> a) whose live bindings are per graph: b calls a hoisted function of a while a + // is unevaluated, and reads a's `counter` after each instance bumped its own. + graphCase("CycleAcrossChunks", { + files: { + "/entry.ts": /* js */ ` + import { fromA, counter, bump } from "./a"; + import { fromB, readCounterFromB, bumpViaB } from "./b"; + declare const tenant: string | undefined; + declare const report: (line: string) => void; + const runtime = (specifier: string) => specifier; + const bumps = typeof tenant === "undefined" ? 1 : tenant.length + 1; + for (let i = 0; i < bumps; i++) bump(); + bumpViaB(); + const again = await import(runtime("./a.ts")); + const line = [fromA, fromB, counter, readCounterFromB(), again.counter, again.bump === bump].join(" | "); + if (typeof tenant === "undefined") { + const lines: string[] = []; + for (const name of ["x", "yy"]) { + await new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name, report: (line: string) => lines.push(line) } }).import(import.meta.url); + } + console.log([...lines, line, counter].join("\\n")); + } else { + report(line); + } + `, + "/a.ts": /* js */ ` + import { describeB } from "./b"; + declare const tenant: string | undefined; + export let counter = 0; + export function bump() { counter++; } + export function nameA() { return "A" + (typeof tenant === "undefined" ? "" : tenant); } + export const fromA = "a sees " + describeB(); + `, + "/b.ts": /* js */ ` + import { nameA, counter, bump } from "./a"; + export function describeB() { return "B(" + nameA() + ")"; } + export const fromB = "b sees " + nameA(); + export function readCounterFromB() { return counter; } + export function bumpViaB() { bump(); } + `, + }, + // Of the cycle only the module the main entry imports first is listed; see bundler_compile_prelinked.test.ts. + entries: ["/entry.ts", "/a.ts"], + // Without splitting the a.ts entry point is a bundle with its own copy of the cycle. + stdout: splitting => + splitting + ? "a sees B(Ax) | b sees Ax | 3 | 3 | 3 | true\na sees B(Ayy) | b sees Ayy | 4 | 4 | 4 | true\na sees B(A) | b sees A | 2 | 2 | 2 | true\n2" + : "a sees B(Ax) | b sees Ax | 3 | 3 | 0 | false\na sees B(Ayy) | b sees Ayy | 4 | 4 | 0 | false\na sees B(A) | b sees A | 2 | 2 | 0 | false\n2", + }); + + // Two graphs evaluate the same embedded module with top-level await at the same time; the host releases the second + // graph's await first, and the first graph's instance is still suspended when the second graph's import has settled. + graphCase("TopLevelAwaitConcurrent", { + files: { + "/entry.ts": /* js */ ` + import { value, log } from "./slow"; + import { fast } from "./fast"; + declare const tenant: string | undefined; + declare const report: (line: string) => void; + if (typeof tenant === "undefined") { + const lines: string[] = []; + const start = (name: string) => { + const gate = Promise.withResolvers(), started = Promise.withResolvers(); + const graph = new (Bun.unsafe as any).ModuleGraph({ + globals: { tenant: name, gate: gate.promise, started: started.resolve, report: (line: string) => lines.push(line) }, + }); + return { gate, started: started.promise, imported: graph.import(import.meta.url) }; + }; + const a = start("a"), b = start("b"); + await Promise.all([a.started, b.started]); + lines.push("both suspended"); + b.gate.resolve("released first"); + lines.push("b imported " + (await b.imported).value); + a.gate.resolve("released second"); + lines.push("a imported " + (await a.imported).value); + console.log([...lines, "host " + value + " " + fast + " " + log.join(">")].join("\\n")); + } else { + report(tenant + " entry " + value + " " + fast + " " + log.join(">")); + } + export { value }; + `, + "/slow.ts": /* js */ ` + declare const gate: Promise | undefined; + declare const started: () => void; + export const log: string[] = []; + log.push("slow-start"); + if (typeof gate !== "undefined") started(); + export const value = typeof gate === "undefined" ? "host" : await gate; + log.push("slow-end"); + `, + "/fast.ts": /* js */ ` + import { log, value } from "./slow"; + log.push("fast"); + export const fast = "fast(" + value + ")"; + `, + }, + entries: ["/entry.ts", "/slow.ts", "/fast.ts"], + stdout: + "both suspended\n" + + "b entry released first fast(released first) slow-start>slow-end>fast\n" + + "b imported released first\n" + + "a entry released second fast(released second) slow-start>slow-end>fast\n" + + "a imported released second\n" + + "host host fast(host) slow-start>slow-end>fast", + }); + + // Uncaught exceptions and unhandled rejections raised by embedded graph code (the entry, a bundled dependency, a + // function of the graph the host hands to setImmediate) reach that graph's onError: nothing on stderr, exit code 0. + graphCase("OnError", { + files: { + "/entry.ts": /* js */ ` + import { rejectInDependency, throwLater } from "./raises"; + declare const tenant: string | undefined; + if (typeof tenant === "undefined") { + const names = ["a", "b"]; + const seen: Record = { a: [], b: [] }; + const all = names.map(name => { + const done = Promise.withResolvers(); + const graph = new (Bun.unsafe as any).ModuleGraph({ + globals: { tenant: name }, + onError(error: Error) { + if (seen[name].push(error.message) === 4) done.resolve(); + }, + }); + graph.import(import.meta.url).then((self: any) => setImmediate(self.throwLater)); + return done.promise; + }); + await Promise.all(all); + for (const name of names) console.log(name + ": " + seen[name].sort().join(", ")); + } else { + queueMicrotask(() => { throw new Error("exception in " + tenant); }); + Promise.reject(new Error("rejection in " + tenant)); + rejectInDependency(); + } + export { throwLater }; + `, + "/raises.ts": /* js */ ` + declare const tenant: string; + export function rejectInDependency() { Promise.reject(new Error("dependency rejection in " + tenant)); } + export function throwLater() { throw new Error("late exception in " + tenant); } + `, + }, + entries: ["/entry.ts", "/raises.ts"], + stdout: + "a: dependency rejection in a, exception in a, late exception in a, rejection in a\n" + + "b: dependency rejection in b, exception in b, late exception in b, rejection in b", + }); + + // After dispose(), graph.import(), import() run by the graph's code (of an embedded entry point and of a builtin) + // and import.meta.require() of an embedded ES module fail with ERR_INVALID_STATE; what was already imported keeps + // working, mainModule keeps its value, and a new graph is unaffected. + graphCase("DisposeThenImport", { + files: { + "/entry.ts": /* js */ ` + declare const tenant: string | undefined; + const runtime = (specifier: string) => specifier; + export const load = () => import(runtime("./lazy.ts")); + export const loadBuiltin = () => import(runtime("node:os")); + export const requireLazy = async () => import.meta.require(runtime("./lazy.ts")); + if (typeof tenant === "undefined") { + const graph = new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: "a" } }); + const self = await graph.import(import.meta.url); + const lazy = await self.load(); + const before = [typeof (await self.loadBuiltin()).cpus, (await self.requireLazy()).describe === lazy.describe].join(); + graph.dispose(); + graph.dispose(); + const attempts = [ + () => graph.import(import.meta.url), + () => graph.import(import.meta.resolve("./lazy.ts")), + () => graph.import("node:os"), + self.load, + self.loadBuiltin, + self.requireLazy, + ]; + const codes: string[] = []; + for (const attempt of attempts) codes.push(await attempt().then(() => "resolved", (error: any) => error.code)); + console.log(before); + console.log(codes.join("\\n")); + console.log(lazy.describe(), lazy.describe(), graph.mainModule === import.meta.path, graph[Symbol.dispose] === graph.dispose); + + using fresh = new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: "b" } }); + const freshSelf = await fresh.import(import.meta.url); + console.log((await freshSelf.load()).describe(), (await load()).describe()); + } + `, + "/lazy.ts": /* js */ ` + declare const tenant: string | undefined; + let calls = 0; + export function describe() { return (typeof tenant === "undefined" ? "host" : tenant) + ":" + ++calls; } + `, + }, + entries: ["/entry.ts", "/lazy.ts"], + stdout: + "function,true\n" + + "ERR_INVALID_STATE\nERR_INVALID_STATE\nERR_INVALID_STATE\nERR_INVALID_STATE\nERR_INVALID_STATE\nERR_INVALID_STATE\n" + + "a:1 a:2 true true\n" + + "b:1 host:1", + }); + + // An embedded .cjs file is not a CommonJS module at run time: the bundler compiled it into the ES module chunk that + // imports it (as a `__commonJS` closure), so it is part of that chunk's state and every graph's record of the chunk + // has its own `module.exports`. A CommonJS file loaded from disk is the shared instance (NotEmbeddedFile). + graphCase("EmbeddedCommonJS", { + files: { + "/entry.ts": /* js */ ` + import shared from "./shared.cjs"; + declare const tenant: string | undefined; + if (typeof tenant === "undefined") { + for (const name of ["a", "b"]) { + const self = await new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name } }).import(import.meta.url); + console.log(name, self.shared === shared); + } + } + export { shared }; + `, + "/shared.cjs": /* js */ ` + module.exports = { token: {} }; + `, + }, + entries: ["/entry.ts"], + stdout: "a false\nb false", + }); + + // Builtin modules imported by embedded graph modules (statically, with import(), with require() and with + // graph.import()) expose the host's functions. + graphCase("Builtins", { + files: { + "/entry.ts": /* js */ ` + import * as path from "node:path"; + import { join } from "node:path"; + import { heapStats } from "bun:jsc"; + import { viaDependency } from "./dep"; + declare const tenant: string | undefined; + declare const host: { join: typeof join; heapStats: typeof heapStats }; + declare const report: (line: string) => void; + const runtime = (specifier: string) => specifier; + if (typeof tenant === "undefined") { + const lines: string[] = []; + for (const name of ["a", "b"]) { + const graph = new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name, host: { join, heapStats }, report: (line: string) => lines.push(line) } }); + await graph.import(import.meta.url); + const [viaGraphPath, viaGraphJsc] = [await graph.import("node:path"), await graph.import("bun:jsc")]; + lines.push(name + " graph.import " + (viaGraphPath.join === join) + " " + (viaGraphJsc.heapStats === heapStats)); + } + console.log(lines.join("\\n")); + } else { + const [dynamicPath, dynamicJsc] = [await import(runtime("node:path")), await import(runtime("bun:jsc"))]; + report([ + tenant, + join === host.join, + path.join === host.join, + dynamicPath.join === host.join, + require(runtime("node:path")).join === host.join, + viaDependency().join === host.join, + heapStats === host.heapStats, + dynamicJsc.heapStats === host.heapStats, + viaDependency().heapStats === host.heapStats, + join("embedded", tenant), + typeof heapStats().heapSize, + ].join(" ")); + } + `, + "/dep.ts": /* js */ ` + import { join } from "node:path"; + import { heapStats } from "bun:jsc"; + export const viaDependency = () => ({ join, heapStats }); + `, + }, + entries: ["/entry.ts", "/dep.ts"], + stdout: ["a", "b"] + .map( + name => + `${name} true true true true true true true true ${isWindows ? "embedded\\" + name : "embedded/" + name} number\n` + + `${name} graph.import true true`, + ) + .join("\n"), + }); + + // Files that are NOT embedded, written next to the executable: a graph created inside the executable imports an ES + // module from disk with per-graph state and graph globals, and the CommonJS module on disk that the embedded entry + // and the ES module on disk both load is the one shared CommonJS instance. + graphCase("NotEmbeddedFile", { + files: { + "/entry.ts": /* js */ ` + import { resolve } from "node:path"; + declare const tenant: string | undefined; + const runtime = (specifier: string) => specifier; + export const shared = require(runtime(resolve("external/shared-on-disk.cjs"))); + if (typeof tenant === "undefined") { + const plugin = resolve("external/plugin-on-disk.mjs"); + const lines: string[] = []; + for (const name of ["a", "b"]) { + const graph = new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name } }); + const self = await graph.import(import.meta.url); + const onDisk = await graph.import(plugin); + lines.push([onDisk.describe(), onDisk.describe(), onDisk.shared === shared, self.shared === shared, onDisk === (await graph.import(plugin))].join(" ")); + } + const hostOnDisk = await import(runtime(plugin)); + lines.push([hostOnDisk.describe(), hostOnDisk.shared === shared, Object.keys(require.cache).filter(key => key.endsWith("shared-on-disk.cjs")).length].join(" ")); + console.log(lines.join("\\n")); + } + `, + }, + runtimeFiles: { + "/external/plugin-on-disk.mjs": /* js */ ` + import { next } from "./helper-on-disk.mjs"; + import shared from "./shared-on-disk.cjs"; + export function describe() { return (typeof tenant === "undefined" ? "host" : tenant) + ":" + next() + ":" + import.meta.main; } + export { shared }; + `, + "/external/helper-on-disk.mjs": /* js */ ` + let calls = 0; + export function next() { return ++calls; } + `, + "/external/shared-on-disk.cjs": /* js */ ` + module.exports = { token: {} }; + `, + }, + entries: ["/entry.ts"], + stdout: "a:1:false a:2:false true true true\nb:1:false b:2:false true true true\nhost:1:false true 1", + }); + + // 50 graphs import the entry; every instance has its own state. + graphCase("ManyGraphs", { + files: { + "/entry.ts": /* js */ ` + import { bump, counter } from "./state"; + declare const index: number | undefined; + if (typeof index === "undefined") { + const graphs: any[] = [], selves: any[] = []; + for (let i = 1; i <= 50; i++) { + const graph = new (Bun.unsafe as any).ModuleGraph({ globals: { index: i } }); + graphs.push(graph); + selves.push(await graph.import(import.meta.url)); + } + for (const self of selves) self.bump(); + const counters = selves.map(self => self.counter()); + console.log(new Set(selves).size, counters.every((count, i) => count === i + 2), counters.reduce((a, b) => a + b, 0), counter()); + for (const graph of graphs) graph.dispose(); + for (const self of selves) self.bump(); + console.log(selves.map(self => self.counter()).reduce((a, b) => a + b, 0), graphs.every(graph => graph.mainModule === import.meta.path)); + } else { + for (let i = 0; i < index; i++) bump(); + } + export { bump, counter }; + `, + "/state.ts": /* js */ ` + let n = 0; + export function bump() { n++; } + export function counter() { return n; } + `, + }, + entries: ["/entry.ts", "/state.ts"], + stdout: "50 true 1325 0\n1375 true", + }); + + // A graph created by a graph's instance of the entry, three levels deep: every level has its own state. + graphCase("NestedGraphs", { + files: { + "/entry.ts": /* js */ ` + import { bump, counter } from "./state"; + declare const depth: number | undefined; + declare const report: (line: string) => void; + const level = typeof depth === "undefined" ? 0 : depth; + const lines: string[] = []; + const push = level === 0 ? (line: string) => lines.push(line) : report; + for (let i = 0; i <= level; i++) bump(); + let inner = "none"; + if (level < 3) { + const graph = new (Bun.unsafe as any).ModuleGraph({ globals: { depth: level + 1, report: push } }); + const self = await graph.import(import.meta.url); + inner = self.counter() + " " + (self.counter !== counter) + " " + (graph.mainModule === import.meta.path); + } + push("depth " + level + " counter " + counter() + " inner " + inner); + if (level === 0) console.log(lines.join("\\n")); + export { counter }; + `, + "/state.ts": /* js */ ` + let n = 0; + export function bump() { n++; } + export function counter() { return n; } + `, + }, + entries: ["/entry.ts", "/state.ts"], + stdout: + "depth 3 counter 4 inner none\n" + + "depth 2 counter 3 inner 4 true true\n" + + "depth 1 counter 2 inner 3 true true\n" + + "depth 0 counter 1 inner 2 true true", + }); + + // Functions of the embedded entry (loaded from its bytecode) get hot in one graph's instance before the next + // instance exists: each instance keeps reading its own module-level `let`, imported binding and graph global. + graphCase("HotFunctions", { + files: { + "/entry.ts": /* js */ ` + import { count, increment } from "./state"; + declare let tenant: number | undefined; + let calls = 0; + export function hot(step: number) { + calls += step; + return calls + count * 1000 + (typeof tenant === "undefined" ? 0 : tenant) * 1000000; + } + export function setTenant(value: number) { tenant = value; } + export { increment }; + if (typeof tenant === "undefined") { + const create = (tenant: number) => new (Bun.unsafe as any).ModuleGraph({ globals: { tenant } }).import(import.meta.url); + const warmUp = (fn: typeof hot) => { + let last = 0; + for (let i = 0; i < 100_000; i++) last = fn(1); + return last; + }; + const a = await create(1); + a.increment(); + const lines = ["a " + warmUp(a.hot)]; + const b = await create(2); + b.increment(); + b.increment(); + lines.push("b " + b.hot(1), "host " + hot(1)); + b.setTenant(3); + lines.push("b " + warmUp(b.hot), "a " + a.hot(1), "host " + warmUp(hot)); + console.log(lines.join("\\n")); + } + `, + "/state.ts": /* js */ ` + export let count = 0; + export function increment() { count++; } + `, + }, + entries: ["/entry.ts", "/state.ts"], + stdout: "a 1101000\nb 2002001\nhost 1\nb 3102001\na 1101001\nhost 100001", + }); + + // graph.mainModule is the embedded path of the first import (undefined before it, unchanged by later imports). + // `bun build --compile` makes import.meta.main a bundle-time constant, true in entry points and false elsewhere, so + // a graph's instances see the host's values. + graphCase("MainModule", { + files: { + "/entry.ts": /* js */ ` + import { dependencyIsMain } from "./dep"; + declare const tenant: string | undefined; + declare const report: (line: string) => void; + if (typeof tenant === "undefined") { + const lines: string[] = []; + const globals = { tenant: "a", report: (line: string) => lines.push(line) }; + const graph = new (Bun.unsafe as any).ModuleGraph({ globals }); + lines.push("before " + graph.mainModule); + await graph.import(import.meta.url); + lines.push("first import " + (graph.mainModule === import.meta.path) + " " + (graph.mainModule === Bun.main)); + const other = await graph.import(import.meta.resolve("./other.ts")); + lines.push("second import " + (graph.mainModule === import.meta.path) + " " + (other.path !== import.meta.path)); + + const otherFirst = new (Bun.unsafe as any).ModuleGraph({ globals: { ...globals, tenant: "b" } }); + const otherPath = (await otherFirst.import(import.meta.resolve("./other.ts"))).path; + await otherFirst.import(import.meta.url); + lines.push("other first " + (otherFirst.mainModule === otherPath) + " " + (otherPath === other.path)); + lines.push("host " + import.meta.main + " " + dependencyIsMain() + " " + (Bun.main === import.meta.path)); + console.log(lines.join("\\n")); + } else if (tenant === "a") { + report("a " + import.meta.main + " " + dependencyIsMain()); + } + `, + "/dep.ts": /* js */ ` + export function dependencyIsMain() { return import.meta.main; } + `, + "/other.ts": /* js */ ` + export const path = import.meta.path; + `, + }, + entries: ["/entry.ts", "/other.ts"], + stdout: + "before undefined\n" + + "a true false\n" + + "first import true true\n" + + "second import true true\n" + + "other first true true\n" + + "host true false true", + }); +}); diff --git a/test/bundler/bundler_compile_prelinked.test.ts b/test/bundler/bundler_compile_prelinked.test.ts index f33b6d0820c1..67788998565d 100644 --- a/test/bundler/bundler_compile_prelinked.test.ts +++ b/test/bundler/bundler_compile_prelinked.test.ts @@ -550,4 +550,38 @@ describe.concurrent("bundler", () => { // Without splitting everything is the entry module itself, which require.cache does not list. stdout: splitting => (splitting ? "0\ntrue 1\nfresh 1" : "0\nfalse 1\nsingle 1"), }); + // A Bun.unsafe.ModuleGraph's loader gets its own records of the embedded modules, built and wired from the same + // pre-resolved graph: the entry imports itself through two graphs, and each instance has its own state, builtin + // dependency record and import() affinity. + graphCase("ModuleGraph", { + files: { + "/entry.ts": /* js */ ` + import { basename } from "node:path"; + import { bump, counter } from "./state"; + declare const tenant: string | undefined; + declare const report: (line: string) => void; + if (typeof tenant === "undefined") { + bump(); + const lines: string[] = []; + for (const name of ["a", "b"]) { + using graph = new (Bun.unsafe as any).ModuleGraph({ globals: { tenant: name, report: (line: string) => lines.push(line) } }); + await graph.import(import.meta.url); + lines.push(name + " main " + (graph.mainModule === import.meta.path)); + } + console.log(["host " + counter(), ...lines].join("\\n")); + } else { + for (let i = 0; i < tenant.length + (tenant === "b" ? 2 : 0); i++) bump(); + const again = await import("./state"); + report(tenant + " " + counter() + " " + again.counter() + " " + typeof basename + " " + import.meta.main); + } + `, + "/state.ts": /* js */ ` + let n = 0; + export function bump() { n++; } + export function counter() { return n; } + `, + }, + entries: ["/entry.ts", "/state.ts"], + stdout: "host 1\na 1 1 function true\na main true\nb 3 3 function true\nb main true", + }); }); diff --git a/test/js/bun/module-graph/module-graph-code-sharing.test.ts b/test/js/bun/module-graph/module-graph-code-sharing.test.ts new file mode 100644 index 000000000000..dce820414478 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-code-sharing.test.ts @@ -0,0 +1,1167 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// Instances of a module that share compiled code share its ModuleProgramExecutable and the FunctionExecutable of every +// function declared in it, so sharing is observable as the number of live executables `heapStats()` of "bun:jsc" +// reports: an instance that shares adds [0, 0] ([module executables, function executables]), an instance that +// compiles its own adds one module executable per module and one function executable per function in them. +const prelude = ` + import { heapStats, numberOfDFGCompiles } from "bun:jsc"; + import { readFileSync, writeFileSync } from "node:fs"; + const { ModuleGraph } = Bun.unsafe; + + function executables() { + Bun.gc(true); + const { ModuleProgramExecutable = 0, FunctionExecutable = 0 } = heapStats().objectTypeCounts; + return [ModuleProgramExecutable, FunctionExecutable]; + } + // What \`create()\` returns stays referenced, so the difference is exactly what it had to compile. + const kept = []; + async function added(create) { + const before = executables(); + kept.push(await create()); + const after = executables(); + return [after[0] - before[0], after[1] - before[1]]; + } + // Calls \`fn\` 100k times and, unless the JIT is off (then this is 1000000), until the DFG has compiled it. + function warmUp(fn, argument) { + for (let i = 0; i < 100_000; i++) fn(argument); + while (numberOfDFGCompiles(fn) === 0) fn(argument); + } +`; + +// Every fixture module exists twice. main.mjs runs `scenario` on the "warm-up" copies first, so that every harness +// function, closure and lazily created builtin already exists when it runs again on the "measured" copies (other +// module keys, so nothing is shared with the first run) and the counts contain the fixture modules' executables only. +function fixture(name: string, modules: Record, scenario: string) { + const files: Record = { + "main.mjs": ` + ${prelude} + ${scenario} + await scenario(import.meta.dir + "/warm-up"); + console.log(JSON.stringify(await scenario(import.meta.dir + "/measured"))); + `, + }; + for (const [file, source] of Object.entries(modules)) { + files["warm-up/" + file] = source; + files["measured/" + file] = source; + } + return tempDir(name, files); +} + +async function runMain(dir: string, env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: { ...bunEnv, ...env }, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + try { + return { result: JSON.parse(stdout), exitCode }; + } catch { + return { result: { stdout, stderr }, exitCode }; + } +} + +describe.concurrent("Bun.unsafe.ModuleGraph compiled-code sharing", () => { + test("a graph without globals shares with the host, whichever instance compiled first", async () => { + using dir = fixture( + "module-graph-code-sharing-host", + { + "host-first.mjs": `export function run(n) { return n + 1; }`, + "graph-first.mjs": `export function run(n) { return n + 2; }`, + }, + ` + async function scenario(dir) { + const results = []; + async function viaHost(file) { + const ns = await import(file); + results.push(ns.run(1)); + return ns; + } + async function viaGraph(file) { + const ns = await new ModuleGraph().import(file); + results.push(ns.run(1)); + return ns; + } + return { + host: await added(() => viaHost(dir + "/host-first.mjs")), + graphAfterHost: await added(() => viaGraph(dir + "/host-first.mjs")), + graph: await added(() => viaGraph(dir + "/graph-first.mjs")), + hostAfterGraph: await added(() => viaHost(dir + "/graph-first.mjs")), + distinctFunctions: new Set(kept.slice(-4).map(ns => ns.run)).size, + results, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + host: [1, 1], + graphAfterHost: [0, 0], + graph: [1, 1], + hostAfterGraph: [0, 0], + distinctFunctions: 4, + results: [2, 2, 3, 3], + }); + expect(exitCode).toBe(0); + }); + + test("graphs without globals share with each other when the host never imports the module", async () => { + using dir = fixture( + "module-graph-code-sharing-no-host", + { + "only-graphs.mjs": ` + let calls = 0; + export function run() { return ++calls; } + `, + }, + ` + async function scenario(dir) { + async function create() { + const ns = await new ModuleGraph().import(dir + "/only-graphs.mjs"); + ns.run(); + return ns; + } + const counts = [await added(create), await added(create), await added(create)]; + const [first, second, third] = kept.slice(-3); + return { counts, results: [first.run(), second.run(), third.run(), third.run(), first.run()] }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + counts: [ + [1, 1], + [0, 0], + [0, 0], + ], + results: [2, 2, 2, 3, 3], + }); + expect(exitCode).toBe(0); + }); + + test("`globals: {}` and globals with only symbol or non-enumerable keys share with the host", async () => { + using dir = fixture( + "module-graph-code-sharing-empty-globals", + { "empty-globals.mjs": `export function run() { return typeof hidden; }` }, + ` + async function scenario(dir) { + const file = dir + "/empty-globals.mjs"; + const results = []; + async function create(globals) { + const ns = await new ModuleGraph({ globals }).import(file); + results.push(ns.run()); + return ns; + } + async function host() { + const ns = await import(file); + results.push(ns.run()); + return ns; + } + return { + host: await added(host), + empty: await added(() => create({})), + symbolOnly: await added(() => create({ [Symbol("hidden")]: 1 })), + nonEnumerable: await added(() => create(Object.defineProperty({}, "hidden", { value: 1, enumerable: false }))), + named: await added(() => create({ hidden: 1 })), + results, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + host: [1, 1], + empty: [0, 0], + symbolOnly: [0, 0], + nonEnumerable: [0, 0], + named: [1, 1], + results: ["undefined", "undefined", "undefined", "undefined", "number"], + }); + expect(exitCode).toBe(0); + }); + + test("the same names in a different insertion order share", async () => { + using dir = fixture( + "module-graph-code-sharing-order", + { "ordered.mjs": `export function run() { return [alpha, beta, gamma]; }` }, + ` + async function scenario(dir) { + const results = []; + async function create(globals) { + const ns = await new ModuleGraph({ globals }).import(dir + "/ordered.mjs"); + results.push(ns.run()); + return ns; + } + return { + first: await added(() => create({ alpha: 1, beta: 2, gamma: 3 })), + reversed: await added(() => create({ gamma: 30, beta: 20, alpha: 10 })), + rotated: await added(() => create({ beta: 200, gamma: 300, alpha: 100 })), + results, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [1, 1], + reversed: [0, 0], + rotated: [0, 0], + results: [ + [1, 2, 3], + [10, 20, 30], + [100, 200, 300], + ], + }); + expect(exitCode).toBe(0); + }); + + test("the same names with different values and value types share", async () => { + using dir = fixture( + "module-graph-code-sharing-values", + { "typed.mjs": `export function run() { return typeof tenant + ":" + String(tenant); }` }, + ` + const values = ["a", "b", 1, 1.5, 10n, true, null, undefined, { toString: () => "object" }, () => {}, Symbol.iterator]; + async function scenario(dir) { + const results = []; + async function create(tenant) { + const ns = await new ModuleGraph({ globals: { tenant } }).import(dir + "/typed.mjs"); + results.push(ns.run()); + return ns; + } + async function createRest() { + const rest = []; + for (const tenant of values.slice(1)) rest.push(await create(tenant)); + return rest; + } + return { first: await added(() => create(values[0])), rest: await added(createRest), results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [1, 1], + rest: [0, 0], + results: [ + "string:a", + "string:b", + "number:1", + "number:1.5", + "bigint:10", + "boolean:true", + "object:null", + "undefined:undefined", + "object:object", + "function:() => {}", + "symbol:Symbol(Symbol.iterator)", + ], + }); + expect(exitCode).toBe(0); + }); + + test("subset, superset and disjoint name sets share within their own set only", async () => { + using dir = fixture( + "module-graph-code-sharing-name-sets", + { + "name-sets.mjs": ` + export function run() { + return [typeof one, typeof two, typeof three, typeof other].map(type => type === "number" ? 1 : 0).join(""); + } + `, + }, + ` + const sets = [undefined, { one: 1 }, { one: 1, two: 2 }, { one: 1, two: 2, three: 3 }, { other: 4 }]; + async function scenario(dir) { + const results = []; + async function create(globals) { + const ns = await new ModuleGraph({ globals }).import(dir + "/name-sets.mjs"); + results.push(ns.run()); + return ns; + } + const firstOfItsSet = [], secondOfItsSet = []; + for (const globals of sets) firstOfItsSet.push(await added(() => create(globals))); + for (const globals of sets) secondOfItsSet.push(await added(() => create(globals && { ...globals }))); + return { firstOfItsSet, secondOfItsSet, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + // `run` and the arrow function in it + firstOfItsSet: [ + [1, 2], + [1, 2], + [1, 2], + [1, 2], + [1, 2], + ], + secondOfItsSet: [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + ], + results: ["0000", "1000", "1100", "1110", "0001", "0000", "1000", "1100", "1110", "0001"], + }); + expect(exitCode).toBe(0); + }); + + test("every kind of function declared in the module is shared", async () => { + using dir = fixture( + "module-graph-code-sharing-kinds", + { + "kinds.mjs": ` + let state = 0; + export function declaration() { return ++state; } + export const arrow = () => ++state; + export async function asyncFunction() { return ++state; } + export function* generator() { yield ++state; } + export async function* asyncGenerator() { yield ++state; } + export function outer() { + function inner() { return () => ++state; } + return inner; + } + export class Counter { + constructor() { state++; } + method() { return ++state; } + static staticMethod() { return ++state; } + get accessor() { return ++state; } + } + export const object = { method() { return ++state; } }; + export default function () { return state; } + `, + }, + ` + async function scenario(dir) { + const states = []; + async function create(globals) { + const ns = await new ModuleGraph({ globals }).import(dir + "/kinds.mjs"); + ns.declaration(); + ns.arrow(); + await ns.asyncFunction(); + for (const _ of ns.generator()); + for await (const _ of ns.asyncGenerator()); + const inner = ns.outer(), innermost = inner(); + innermost(); + const counter = new ns.Counter(); + counter.method(); + ns.Counter.staticMethod(); + counter.accessor; + ns.object.method(); + states.push(ns.default()); + // The closures keep the nested functions' executables alive whatever happens to \`outer\`'s code. + return [ns, inner, innermost]; + } + return { + first: await added(() => create({ tenant: 1 })), + sameNames: await added(() => create({ tenant: 2 })), + otherNames: await added(() => create({ other: 3 })), + states, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + // The 14 functions written in kinds.mjs, plus the separate body function of the generator and of the async + // generator. + first: [1, 16], + sameNames: [0, 0], + otherNames: [1, 16], + states: [11, 11, 11], + }); + expect(exitCode).toBe(0); + }); + + test("an importer and its dependency are both shared, and each instance is linked to its own dependency", async () => { + using dir = fixture( + "module-graph-code-sharing-imports", + { + "dependency.mjs": ` + export let count = 0; + export const registry = []; + export function increment() { return ++count; } + `, + "importer.mjs": ` + import { count, increment, registry } from "./dependency.mjs"; + import * as dependency from "./dependency.mjs"; + export function run(label) { + registry.push(label); + return [increment(), count, dependency.count, registry.join()]; + } + export { increment, registry }; + `, + }, + ` + async function scenario(dir) { + const results = [], ownDependency = []; + async function viaHost() { + const ns = await import(dir + "/importer.mjs"); + results.push(ns.run("host"), ns.run("host")); + return ns; + } + async function viaGraph(label) { + const graph = new ModuleGraph(); + const ns = await graph.import(dir + "/importer.mjs"); + results.push(ns.run(label)); + ownDependency.push((await graph.import(dir + "/dependency.mjs")).increment === ns.increment); + return ns; + } + const counts = [await added(viaHost), await added(() => viaGraph("first")), await added(() => viaGraph("second"))]; + const [host, first, second] = kept.slice(-3); + results.push(second.run("second"), host.run("host"), first.run("first")); + return { counts, ownDependency, distinctRegistries: new Set([host.registry, first.registry, second.registry]).size, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + // Two modules with one function each. + counts: [ + [2, 2], + [0, 0], + [0, 0], + ], + ownDependency: [true, true], + distinctRegistries: 3, + results: [ + [1, 1, 1, "host"], + [2, 2, 2, "host,host"], + [1, 1, 1, "first"], + [1, 1, 1, "second"], + [2, 2, 2, "second,second"], + [3, 3, 3, "host,host,host"], + [2, 2, 2, "first,first"], + ], + }); + expect(exitCode).toBe(0); + }); + + test("modules in an import cycle are shared", async () => { + using dir = fixture( + "module-graph-code-sharing-cycle", + { + "cycle-even.mjs": ` + import { isOdd } from "./cycle-odd.mjs"; + export let evenCalls = 0; + export function isEven(n) { evenCalls++; return n === 0 ? true : isOdd(n - 1); } + `, + "cycle-odd.mjs": ` + import { isEven, evenCalls } from "./cycle-even.mjs"; + export function isOdd(n) { return n === 0 ? false : isEven(n - 1); } + export function evenCallsSeenFromOdd() { return evenCalls; } + `, + }, + ` + async function scenario(dir) { + const even = dir + "/cycle-even.mjs", odd = dir + "/cycle-odd.mjs"; + const results = []; + async function create(entry, other, n) { + const graph = new ModuleGraph(); + const ns = { ...await graph.import(entry), ...await graph.import(other) }; + results.push([ns.isEven(n), ns.isOdd(n), ns.evenCallsSeenFromOdd()]); + return ns; + } + return { + enteredAtEven: await added(() => create(even, odd, 10)), + enteredAtOdd: await added(() => create(odd, even, 4)), + results, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + enteredAtEven: [2, 3], + enteredAtOdd: [0, 0], + results: [ + [true, false, 11], + [true, false, 5], + ], + }); + expect(exitCode).toBe(0); + }); + + test("an importer whose dependency's source changed is not shared and runs the new dependency", async () => { + using dir = fixture( + "module-graph-code-sharing-rewrite", + { + "rewritten-dependency.mjs": `export function value() { return "original"; }\n`, + "stable-importer.mjs": ` + import { value } from "./rewritten-dependency.mjs"; + export function read() { return value(); } + export { value }; + `, + }, + ` + async function scenario(dir) { + const dependency = dir + "/rewritten-dependency.mjs"; + const original = readFileSync(dependency, "utf8"); + const results = []; + async function create() { + const ns = await new ModuleGraph().import(dir + "/stable-importer.mjs"); + results.push(ns.read(), ns.value()); + return ns; + } + const first = await added(create); + const sameSource = await added(create); + writeFileSync(dependency, original.replace("original", "rewritten")); + const afterRewrite = await added(create); + const sameRewrittenSource = await added(create); + writeFileSync(dependency, original); + kept.push(await create()); + for (const ns of kept.slice(-5)) results.push(ns.read()); + return { first, sameSource, afterRewrite, sameRewrittenSource, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [2, 2], + sameSource: [0, 0], + afterRewrite: [2, 2], + sameRewrittenSource: [0, 0], + results: [ + ...["original", "original", "original", "original"], + ...["rewritten", "rewritten", "rewritten", "rewritten"], + ...["original", "original"], + ...["original", "original", "rewritten", "rewritten", "original"], + ], + }); + expect(exitCode).toBe(0); + }); + + test("a module whose own source changed is not shared, but its unchanged dependency is", async () => { + using dir = fixture( + "module-graph-code-sharing-rewrite-importer", + { + "stable-dependency.mjs": ` + let calls = 0; + export function count() { return ++calls; } + `, + "rewritten-importer.mjs": ` + import { count } from "./stable-dependency.mjs"; + export function read() { return "original " + count(); } + `, + }, + ` + async function scenario(dir) { + const file = dir + "/rewritten-importer.mjs"; + const results = []; + async function create() { + const ns = await new ModuleGraph().import(file); + results.push(ns.read()); + return ns; + } + const first = await added(create); + writeFileSync(file, readFileSync(file, "utf8").replace("original", "rewritten")); + const afterRewrite = await added(create); + const [original, rewritten] = kept.slice(-2); + results.push(rewritten.read(), original.read()); + return { first, afterRewrite, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [2, 2], + // Only the importer and its one function. + afterRewrite: [1, 1], + results: ["original 1", "rewritten 1", "rewritten 2", "original 2"], + }); + expect(exitCode).toBe(0); + }); + + test("different importers of the same dependency share the dependency only", async () => { + using dir = fixture( + "module-graph-code-sharing-two-importers", + { + "common-leaf.mjs": ` + let calls = 0; + export function leaf() { return ++calls; } + `, + "importer-one.mjs": ` + import { leaf } from "./common-leaf.mjs"; + export function run() { return "one " + leaf(); } + `, + "importer-two.mjs": ` + import { leaf } from "./common-leaf.mjs"; + export function run() { return "two " + leaf(); } + `, + }, + ` + async function scenario(dir) { + const results = []; + async function create(file) { + const ns = await new ModuleGraph().import(dir + file); + results.push(ns.run()); + return ns; + } + const one = await added(() => create("/importer-one.mjs")); + const two = await added(() => create("/importer-two.mjs")); + results.push(kept.at(-2).run()); + return { one, two, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + one: [2, 2], + two: [1, 1], + results: ["one 1", "two 1", "one 2"], + }); + expect(exitCode).toBe(0); + }); + + test("sharing is keyed by module key, not by content", async () => { + using dir = fixture( + "module-graph-code-sharing-key", + { + "content-one.mjs": `export function same() { return import.meta.file; }`, + "content-two.mjs": `export function same() { return import.meta.file; }`, + }, + ` + async function scenario(dir) { + const results = []; + async function create(specifier) { + const ns = await new ModuleGraph().import(specifier); + results.push(ns.same()); + return ns; + } + return { + first: await added(() => create(dir + "/content-one.mjs")), + otherPath: await added(() => create(dir + "/content-two.mjs")), + otherQuery: await added(() => create(dir + "/content-one.mjs?query")), + fileUrl: await added(() => create(Bun.pathToFileURL(dir + "/content-one.mjs").href)), + results, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [1, 1], + otherPath: [1, 1], + otherQuery: [1, 1], + fileUrl: [0, 0], + results: ["content-one.mjs", "content-two.mjs", "content-one.mjs", "content-one.mjs"], + }); + expect(exitCode).toBe(0); + }); + + test("import() and import.meta inside shared functions belong to the calling instance's graph", async () => { + using dir = fixture( + "module-graph-code-sharing-affinity", + { + "lazy-target.mjs": ` + export const token = {}; + export function getToken() { return token; } + `, + "affinity.mjs": ` + export const load = () => import("./lazy-target.mjs"); + export function meta() { return import.meta; } + export function required() { return import.meta.require("./lazy-target.mjs"); } + `, + }, + ` + async function scenario(dir) { + const file = dir + "/affinity.mjs", lazy = dir + "/lazy-target.mjs"; + async function viaHost() { + const ns = await import(file); + const loaded = await ns.load(); + return { ns, loaded, token: loaded.getToken(), meta: ns.meta(), required: ns.required(), own: await import(lazy) }; + } + async function viaGraph() { + const graph = new ModuleGraph(); + const ns = await graph.import(file); + const loaded = await ns.load(); + return { ns, loaded, token: loaded.getToken(), meta: ns.meta(), required: ns.required(), own: await graph.import(lazy) }; + } + const counts = [await added(viaHost), await added(viaGraph), await added(viaGraph)]; + const instances = kept.slice(-3); + return { + counts, + loadedOwn: instances.map(instance => instance.loaded === instance.own), + requiredOwn: instances.map(instance => instance.required.token === instance.own.token), + distinctTokens: new Set(instances.map(instance => instance.token)).size, + distinctMetas: new Set(instances.map(instance => instance.meta)).size, + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + // affinity.mjs with its three functions and lazy-target.mjs with its one + counts: [ + [2, 4], + [0, 0], + [0, 0], + ], + loadedOwn: [true, true, true], + requiredOwn: [true, true, true], + distinctTokens: 3, + distinctMetas: 3, + }); + expect(exitCode).toBe(0); + }); + + test("errors raised by shared code go to the instance that raised them", async () => { + using dir = fixture( + "module-graph-code-sharing-errors", + { + "raises.mjs": ` + export function throwLater(label) { setTimeout(() => { throw new Error("exception " + label); }, 0); } + export function reject(label) { Promise.reject(new Error("rejection " + label)); } + `, + }, + ` + let log = []; + process.on("uncaughtException", error => log.push("process: " + error.message)); + process.on("unhandledRejection", error => log.push("process: " + error.message)); + + async function scenario(dir) { + log = []; + async function create(label) { + const onError = error => log.push(label + ": " + error.message); + const ns = label === "host" ? await import(dir + "/raises.mjs") : await new ModuleGraph({ onError }).import(dir + "/raises.mjs"); + ns.throwLater(label); + ns.reject(label); + return ns; + } + const counts = [await added(() => create("host")), await added(() => create("first")), await added(() => create("second"))]; + while (log.length < 6) await new Promise(resolve => setImmediate(resolve)); + return { counts, log: log.sort() }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + // `throwLater`, the timer callback in it, and `reject` + counts: [ + [1, 3], + [0, 0], + [0, 0], + ], + log: [ + "first: exception first", + "first: rejection first", + "process: exception host", + "process: rejection host", + "second: exception second", + "second: rejection second", + ], + }); + expect(exitCode).toBe(0); + }); + + test("an instance shares the code of an instance that is suspended in top-level await", async () => { + using dir = fixture( + "module-graph-code-sharing-tla", + { + "suspended.mjs": ` + export function early() { return "early " + tenant; } + started(early); + export const released = await gate; + export function late() { return released + " " + tenant; } + `, + }, + ` + async function scenario(dir) { + async function start(tenant) { + const gate = Promise.withResolvers(), started = Promise.withResolvers(); + const imported = new ModuleGraph({ globals: { tenant, gate: gate.promise, started: started.resolve } }).import(dir + "/suspended.mjs"); + const early = await started.promise; + return { gate, imported, early, earlyResult: early() }; + } + const counts = [await added(() => start("first")), await added(() => start("second"))]; + const [first, second] = kept.slice(-2); + second.gate.resolve("released-second"); + const secondLate = (await second.imported).late(); + first.gate.resolve("released-first"); + return { counts, results: [first.earlyResult, second.earlyResult, (await first.imported).late(), secondLate] }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + counts: [ + [1, 2], + [0, 0], + ], + results: ["early first", "early second", "released-first first", "released-second second"], + }); + expect(exitCode).toBe(0); + }); + + test("an instance shares the code of an instance whose evaluation threw", async () => { + using dir = fixture( + "module-graph-code-sharing-threw", + { + "throws-for-some.mjs": ` + export function read() { return tenant; } + collect(read); + read(); + if (shouldThrow) throw new Error("threw in " + tenant); + `, + }, + ` + async function scenario(dir) { + const outcomes = []; + async function create(tenant, shouldThrow) { + let read; + const graph = new ModuleGraph({ globals: { tenant, shouldThrow, collect(fn) { read = fn; } } }); + outcomes.push(await graph.import(dir + "/throws-for-some.mjs").then(ns => ns.read === read, error => error.message)); + return read; + } + const counts = [ + await added(() => create("a", true)), + await added(() => create("b", false)), + await added(() => create("c", true)), + await added(() => create("d", false)), + ]; + return { counts, outcomes, results: kept.slice(-4).map(read => read()) }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + counts: [ + [1, 1], + [0, 0], + [0, 0], + [0, 0], + ], + outcomes: ["threw in a", true, "threw in c", true], + results: ["a", "b", "c", "d"], + }); + expect(exitCode).toBe(0); + }); + + test("sharing survives Bun.gc(true) while one instance is alive", async () => { + using dir = fixture( + "module-graph-code-sharing-gc-alive", + { + "survivor.mjs": ` + let calls = 0; + export function run() { return typeof tenant + ++calls; } + `, + }, + ` + async function scenario(dir) { + const results = []; + // Only the function of the first instance of each kind is kept. + async function create(globals) { + const ns = await new ModuleGraph({ globals }).import(dir + "/survivor.mjs"); + results.push(ns.run()); + return ns.run; + } + const first = [await added(() => create()), await added(() => create({ tenant: "a" }))]; + for (let i = 0; i < 3; i++) { + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + } + const afterGC = [await added(() => create()), await added(() => create({ tenant: "b" }))]; + for (const run of kept.slice(-4)) results.push(run()); + return { first, afterGC, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [ + [1, 1], + [1, 1], + ], + afterGC: [ + [0, 0], + [0, 0], + ], + results: ["undefined1", "string1", "undefined1", "string1", "undefined2", "string2", "undefined2", "string2"], + }); + expect(exitCode).toBe(0); + }); + + test("a disposed graph's instance still shares its code with new graphs", async () => { + using dir = fixture( + "module-graph-code-sharing-dispose", + { + "disposed.mjs": ` + let calls = 0; + export function run() { return ++calls; } + `, + }, + ` + async function scenario(dir) { + const results = []; + async function create(tenant, dispose) { + const graph = new ModuleGraph({ globals: { tenant } }); + const ns = await graph.import(dir + "/disposed.mjs"); + results.push(ns.run()); + if (dispose) graph.dispose(); + return ns; + } + const disposed = await added(() => create(1, true)); + const afterDispose = await added(() => create(2, false)); + results.push(kept.at(-2).run()); + return { disposed, afterDispose, results }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ disposed: [1, 1], afterDispose: [0, 0], results: [1, 1, 2] }); + expect(exitCode).toBe(0); + }); + + test("20 instances calling the same shared functions interleaved keep their own state", async () => { + using dir = fixture( + "module-graph-code-sharing-interleaved", + { + "interleaved-dependency.mjs": ` + export let total = 0; + export function add(n) { total += n; } + `, + "interleaved.mjs": ` + import { add, total } from "./interleaved-dependency.mjs"; + let calls = 0; + export function step() { + add(id); + seen.push(id); + return [id, ++calls, total]; + } + `, + }, + ` + async function scenario(dir) { + const seen = []; + async function create(from, to) { + const instances = []; + for (let id = from; id < to; id++) { + const globals = { id, seen: [] }; + seen.push(globals.seen); + const ns = await new ModuleGraph({ globals }).import(dir + "/interleaved.mjs"); + ns.step(); + instances.push(ns); + } + return instances; + } + const first = await added(() => create(0, 1)); + const rest = await added(() => create(1, 20)); + const instances = kept.slice(-2).flat(); + + let last; + for (let round = 0; round < 500; round++) last = instances.map(instance => instance.step()); + return { first, rest, last, seen: seen.map(ids => ids.length + "x" + [...new Set(ids)].join()) }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir)); + expect(result).toEqual({ + first: [2, 2], + rest: [0, 0], + last: Array.from({ length: 20 }, (_, id) => [id, 501, id * 501]), + seen: Array.from({ length: 20 }, (_, id) => `501x${id}`), + }); + expect(exitCode).toBe(0); + }); + + // A shared function that is already hot in one instance (100k calls, and compiled by the DFG unless the JIT is off) + // must not have that instance's module environment, imported bindings or graph globals folded into the code another + // instance runs. + describe.each([ + ["with the JIT", {}], + ["with BUN_JSC_useJIT=0", { BUN_JSC_useJIT: "0" }], + ] as [string, Record][])("an instance created after a shared function got hot %s", (_, env) => { + test("reads its own imported bindings", async () => { + using dir = fixture( + "module-graph-code-sharing-hot-imports", + { + "hot-dependency.mjs": ` + export let count = 0; + export const registry = { owner: undefined }; + export function increment() { return ++count; } + `, + "hot-importer.mjs": ` + import { count, increment, registry } from "./hot-dependency.mjs"; + export function read() { return count; } + export function owner() { return registry.owner; } + export function callIncrement() { return increment(); } + export { increment, registry }; + `, + }, + ` + async function scenario(dir) { + async function createHot() { + const a = await new ModuleGraph().import(dir + "/hot-importer.mjs"); + a.registry.owner = "a"; + a.increment(); + a.increment(); + a.callIncrement(); + warmUp(a.read); + warmUp(a.owner); + return a; + } + const hot = await added(createHot); + const afterHot = await added(() => new ModuleGraph().import(dir + "/hot-importer.mjs")); + const [a, b] = kept.slice(-2); + const fresh = [b.read(), String(b.owner()), a.read(), a.owner()]; + b.registry.owner = "b"; + b.callIncrement(); + warmUp(b.read); + warmUp(b.owner); + warmUp(a.read); + a.callIncrement(); + return { hot, afterHot, fresh, stillHot: [b.read(), b.owner(), a.read(), a.owner()] }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir), env); + expect(result).toEqual({ + hot: [2, 4], + afterHot: [0, 0], + fresh: [0, "undefined", 3, "a"], + stillHot: [1, "b", 4, "a"], + }); + expect(exitCode).toBe(0); + }); + + test("reads and writes its own module-level `let` and `const`", async () => { + using dir = fixture( + "module-graph-code-sharing-hot-let", + { + "hot-state.mjs": ` + let calls = 0; + const table = { label: "unset" }; + export let live = "initial"; + export function next() { return ++calls; } + export function peek() { return calls; } + export function getTable() { return table; } + export function setLive(value) { live = value; } + `, + }, + ` + async function scenario(dir) { + async function createHot() { + const a = await new ModuleGraph().import(dir + "/hot-state.mjs"); + a.getTable().label = "a"; + warmUp(a.next); + warmUp(a.peek); + warmUp(a.getTable); + warmUp(a.setLive, "a"); + return a; + } + const hot = await added(createHot); + const afterHot = await added(() => new ModuleGraph().import(dir + "/hot-state.mjs")); + const [a, b] = kept.slice(-2); + const aCalls = a.peek(); + const fresh = [b.peek(), b.next(), b.getTable().label, b.live, a.peek() - aCalls, a.live]; + b.getTable().label = "b"; + b.setLive("b"); + warmUp(b.peek); + warmUp(b.getTable); + warmUp(a.peek); + return { + hot, + afterHot, + aCallsAtLeast100k: aCalls >= 100_000, + fresh, + stillHot: [b.peek(), b.getTable().label, b.live, a.peek() - aCalls, a.getTable().label, a.live], + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir), env); + expect(result).toEqual({ + hot: [1, 4], + afterHot: [0, 0], + aCallsAtLeast100k: true, + fresh: [0, 1, "unset", "initial", 0, "a"], + stillHot: [1, "b", "b", 0, "a", "a"], + }); + expect(exitCode).toBe(0); + }); + + test("reads and writes its own graph globals", async () => { + using dir = fixture( + "module-graph-code-sharing-hot-globals", + { + "hot-globals.mjs": ` + export function read() { return tenant; } + export function kind() { return typeof tenant; } + export function write(value) { tenant = value; } + export function bump() { return ++counter; } + `, + }, + ` + async function scenario(dir) { + const aGlobals = { tenant: "a", counter: 0 }, bGlobals = { counter: 1000.5, tenant: { name: "b" } }; + async function createHot() { + const a = await new ModuleGraph({ globals: aGlobals }).import(dir + "/hot-globals.mjs"); + warmUp(a.read); + warmUp(a.kind); + warmUp(a.write, "a2"); + warmUp(a.bump); + return a; + } + const hot = await added(createHot); + const afterHot = await added(() => new ModuleGraph({ globals: bGlobals }).import(dir + "/hot-globals.mjs")); + const [a, b] = kept.slice(-2); + const aCounter = a.bump(); + const fresh = [b.read(), b.kind(), b.bump(), a.read(), a.kind(), a.bump() - aCounter]; + b.write(7); + warmUp(b.read); + warmUp(b.kind); + warmUp(a.read); + return { + hot, + afterHot, + fresh, + stillHot: [b.read(), b.kind(), b.bump(), a.read(), a.kind(), a.bump() - aCounter], + optionsObjects: [aGlobals, bGlobals], + leaked: [typeof globalThis.tenant, typeof globalThis.counter], + }; + } + `, + ); + + const { result, exitCode } = await runMain(String(dir), env); + expect(result).toEqual({ + hot: [1, 4], + afterHot: [0, 0], + fresh: [{ name: "b" }, "object", 1001.5, "a2", "string", 1], + stillHot: [7, "number", 1002.5, "a2", "string", 2], + optionsObjects: [ + { tenant: "a", counter: 0 }, + { counter: 1000.5, tenant: { name: "b" } }, + ], + leaked: ["undefined", "undefined"], + }); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-errors.test.ts b/test/js/bun/module-graph/module-graph-errors.test.ts new file mode 100644 index 000000000000..ff77ada84746 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-errors.test.ts @@ -0,0 +1,1796 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; + +const { ModuleGraph } = Bun.unsafe as any; + +// Every way this file makes graph code fail. `error` is whatever the caller wants thrown or rejected. +const faults = ` + import { EventEmitter } from "node:events"; + import { readFile } from "node:fs"; + + export const throwIn = { + setTimeout(error) { setTimeout(() => { throw error; }, 0); }, + setInterval(error) { const timer = setInterval(() => { clearInterval(timer); throw error; }, 1); }, + setImmediate(error) { setImmediate(() => { throw error; }); }, + queueMicrotask(error) { queueMicrotask(() => { throw error; }); }, + nextTick(error) { process.nextTick(() => { throw error; }); }, + readFile(error) { readFile(import.meta.path, () => { throw error; }); }, + }; + export const listeners = { + emitter(error) { const emitter = new EventEmitter(); emitter.on("fault", () => { throw error; }); return emitter; }, + target(error) { const target = new EventTarget(); target.addEventListener("fault", () => { throw error; }); return target; }, + abort(error) { const controller = new AbortController(); controller.signal.addEventListener("abort", () => { throw error; }); return controller; }, + }; + export const rejectIn = { + asyncFunction(error) { (async () => { throw error; })(); }, + promiseReject(error) { Promise.reject(error); }, + executor(error) { new Promise((resolve, reject) => { reject(error); }); }, + executorThrow(error) { new Promise(() => { throw error; }); }, + withResolvers(error) { const { reject } = Promise.withResolvers(); reject(error); }, + thenCallback(error) { Promise.resolve().then(() => { throw error; }); }, + catchCallback(error) { Promise.reject(1).catch(() => { throw error; }); }, + finallyCallback(error) { Promise.resolve().finally(() => { throw error; }); }, + afterAwait(error, awaited) { (async () => { await awaited; throw error; })(); }, + asyncGenerator(error) { (async function* () { throw error; })().next(); }, + asyncGeneratorSecondNext(error) { + const generator = (async function* () { yield 1; throw error; })(); + generator.next().then(() => { generator.next(); }); + }, + rejectedLater(error) { const { reject } = Promise.withResolvers(); setTimeout(() => { reject(error); }, 0); }, + // Tail calls: this function's frame is gone by the time the promise is rejected. + tailCall: error => Promise.reject(error), + tailCallOfOwnError: message => Promise.reject(new Error(message)), + tailCallOfOwnErrorWithStackRead: message => { + const error = new Error(message); + error.stack; + return Promise.reject(error); + }, + }; + export const create = { + error(message) { const error = new Error(message); create.last = error; return error; }, + last: undefined, + }; + export const throwOwnErrorIn = { + nextTick(message) { const error = create.error(message); process.nextTick(() => { throw error; }); }, + readFile(message) { const error = create.error(message); readFile(import.meta.path, () => { throw error; }); }, + setTimeoutWithStackRead(message) { const error = create.error(message); error.stack; setTimeout(() => { throw error; }, 0); }, + }; + export const rejectOwnErrorWithStackRead = message => { const error = create.error(message); error.stack; Promise.reject(error); }; + export const call = fn => { fn(); }; + export const callInTimer = fn => { setTimeout(() => { fn(); }, 0); }; + export const throwNow = error => { throw error; }; + export const rejected = error => { const promise = Promise.reject(error); return { promise }; }; + export const object = { get throwing() { throw new Error("getter"); } }; +`; + +const nextTurn = () => new Promise(resolve => setImmediate(resolve)); +async function until(condition: () => boolean) { + while (!condition()) await nextTurn(); +} + +// The start of every subprocess fixture: a log of everything the process and the graphs are told about. +const prelude = ` + const { ModuleGraph } = Bun.unsafe; + const log = []; + const show = value => (value instanceof Error ? value.message : typeof value + " " + String(value)); + process.on("uncaughtException", error => log.push("process uncaughtException: " + show(error))); + process.on("unhandledRejection", error => log.push("process unhandledRejection: " + show(error))); + process.on("rejectionHandled", () => log.push("process rejectionHandled")); + const onError = name => error => log.push(name + ".onError: " + show(error)); + const nextTurn = () => new Promise(resolve => setImmediate(resolve)); + // Each fault is reported once the event loop gets to it; wait for that report. + async function reported(act, count = 1) { + const before = log.length; + act(); + while (log.length < before + count) await nextTurn(); + } +`; + +async function run(cwd: string, args: string[], env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, ...env }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("Bun.unsafe.ModuleGraph onError", () => { + describe("uncaught exceptions of the graph's code", () => { + test.each(["setTimeout", "setInterval", "setImmediate", "queueMicrotask"])("thrown in %s", async source => { + using dir = tempDir("module-graph-errors-throw-in", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + const error = new Error(source); + throwIn[source](error); + await until(() => errors.length > 0); + expect(errors).toEqual([error]); + expect(errors[0]).toBe(error); + }); + + test.each(["nextTick", "readFile", "setTimeoutWithStackRead"])( + "the graph's own Error thrown in %s", + async source => { + using dir = tempDir("module-graph-errors-throw-own", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { throwOwnErrorIn, create } = await graph.import(join(String(dir), "faults.mjs")); + + throwOwnErrorIn[source](source); + await until(() => errors.length > 0); + expect(errors).toEqual([expect.objectContaining({ message: source })]); + expect(errors[0]).toBe(create.last); + }, + ); + + // The exception's own stack (the graph's callback threw it) decides, whoever created the value. + test.concurrent.each(["nextTick", "readFile"])("a value the graph did not create, thrown in %s", async source => { + using dir = tempDir("module-graph-errors-throw-foreign", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const { throwIn } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/faults.mjs"); + const source = process.argv[2]; + await reported(() => throwIn[source](new Error("host Error"))); + await reported(() => throwIn[source]("a string")); + console.log(JSON.stringify(log)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs", source]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["graph.onError: host Error", "graph.onError: string a string"]); + expect(exitCode).toBe(0); + }); + + test("thrown by an EventEmitter listener the host emits to", async () => { + using dir = tempDir("module-graph-errors-emitter", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { listeners } = await graph.import(join(String(dir), "faults.mjs")); + + const error = new Error("listener"); + const emitter = listeners.emitter(error); + setImmediate(() => emitter.emit("fault")); + await until(() => errors.length > 0); + expect(errors[0]).toBe(error); + expect(errors).toHaveLength(1); + }); + + test("thrown by an EventTarget listener the host dispatches to", async () => { + using dir = tempDir("module-graph-errors-event-target", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { listeners } = await graph.import(join(String(dir), "faults.mjs")); + + const error = new Error("listener"); + const target = listeners.target(error); + const dispatched: boolean[] = []; + setImmediate(() => dispatched.push(target.dispatchEvent(new Event("fault")))); + await until(() => errors.length > 0); + expect(errors[0]).toBe(error); + // dispatchEvent() itself does not throw: the listener's exception is reported, not propagated. + expect({ dispatched, reports: errors.length }).toEqual({ dispatched: [true], reports: 1 }); + }); + + test("thrown by an AbortSignal listener when the host aborts", async () => { + using dir = tempDir("module-graph-errors-abort", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { listeners } = await graph.import(join(String(dir), "faults.mjs")); + + const error = new Error("abort listener"); + const controller = listeners.abort(error); + setImmediate(() => controller.abort()); + await until(() => errors.length > 0); + expect(errors[0]).toBe(error); + expect({ aborted: controller.signal.aborted, reports: errors.length }).toEqual({ aborted: true, reports: 1 }); + }); + + test.each([["a string"], [42], [0n], [undefined], [null], [{ plain: "object" }], [Symbol.for("thrown")]])( + "a thrown %p arrives as is", + async value => { + using dir = tempDir("module-graph-errors-throw-value", { "faults.mjs": faults }); + const calls: unknown[][] = []; + using graph = new ModuleGraph({ onError: (...args: unknown[]) => calls.push(args) }); + const { throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + throwIn.setTimeout(value); + await until(() => calls.length > 0); + expect(calls).toEqual([[value]]); + expect(calls[0][0]).toBe(value); + }, + ); + + test("a stack overflow in a timer", async () => { + using dir = tempDir("module-graph-errors-overflow", { + "overflow.mjs": ` + export const overflowInTimer = () => { + setTimeout(() => { + const recurse = () => recurse() + 1; + recurse(); + }, 0); + }; + `, + }); + const errors: any[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { overflowInTimer } = await graph.import(join(String(dir), "overflow.mjs")); + + overflowInTimer(); + await until(() => errors.length > 0); + expect(errors.map(error => [error instanceof RangeError, error.message])).toEqual([ + [true, "Maximum call stack size exceeded."], + ]); + }); + + test("what host code catches is not reported", async () => { + using dir = tempDir("module-graph-errors-caught", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { object, throwNow, rejected, throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + const caught: unknown[] = []; + try { + object.throwing; + } catch (error: any) { + caught.push(error.message); + } + try { + throwNow(new Error("thrown to the host")); + } catch (error: any) { + caught.push(error.message); + } + try { + await rejected(new Error("awaited by the host")).promise; + } catch (error: any) { + caught.push(error.message); + } + + // Anything reported for the above would come before this one. + const last = new Error("the only report"); + throwIn.setTimeout(last); + await until(() => errors.length > 0); + expect({ caught, errors }).toEqual({ + caught: ["getter", "thrown to the host", "awaited by the host"], + errors: [last], + }); + }); + + describe("whatever kind of graph function the host's timer reaches", () => { + const shapes = ` + let value; + export const willThrow = next => { value = next; }; + const fail = () => { throw value; }; + export class Thrower { + static fail() { throw value; } + fail() { throw value; } + get failing() { throw value; } + set failing(ignored) { throw value; } + static { this.fromStaticBlock = () => { throw value; }; } + } + export const nested = (() => () => () => { throw value; })()(); + export const proxy = new Proxy({}, { get() { throw value; } }); + export const coerced = { toString() { throw value; }, [Symbol.toPrimitive]: undefined }; + export const iterable = { [Symbol.iterator]() { return { next() { throw value; } }; } }; + export const rejectingThenable = { then(resolve, reject) { reject(value); } }; + export async function* asyncGenerator() { throw value; } + export const tagged = strings => { throw value; }; + export const withDefault = (argument = fail()) => argument; + export const sort = () => [3, 2, 1].sort(fail); + export const parse = () => JSON.parse("[1]", fail); + export const replace = () => "abc".replace(/b/, fail); + `; + const operations: Record unknown> = { + "a static method": namespace => namespace.Thrower.fail(), + "a method": namespace => new namespace.Thrower().fail(), + "a getter": namespace => new namespace.Thrower().failing, + "a setter": namespace => (new namespace.Thrower().failing = 1), + "a function made in a static block": namespace => namespace.Thrower.fromStaticBlock(), + "a bound function": namespace => namespace.Thrower.fail.bind(null)(), + "a nested closure": namespace => namespace.nested(), + "Reflect.apply": namespace => Reflect.apply(namespace.Thrower.fail, undefined, []), + "a Proxy trap": namespace => namespace.proxy.anything, + "toString called by the host's String()": namespace => String(namespace.coerced), + "an iterator's next() called by the host's for-of": namespace => { + for (const _ of namespace.iterable); + }, + "an iterator's next() called by Array.from": namespace => Array.from(namespace.iterable), + "a tagged template": namespace => namespace.tagged`template`, + "a default parameter initializer": namespace => namespace.withDefault(), + "an Array.prototype.sort comparator": namespace => namespace.sort(), + "a JSON.parse reviver": namespace => namespace.parse(), + "a String.prototype.replace callback": namespace => namespace.replace(), + "the host's forEach callback calling it": namespace => [1].forEach(() => namespace.Thrower.fail()), + "the host's try/finally around it": namespace => { + try { + namespace.Thrower.fail(); + } finally { + Math.max(1, 2); + } + }, + }; + + test.each(Object.keys(operations))("%s", async name => { + using dir = tempDir("module-graph-errors-shapes", { "shapes.mjs": shapes }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const namespace = await graph.import(join(String(dir), "shapes.mjs")); + + // Not an Error: only the stack of the exception can say whose it is. + namespace.willThrow(name); + setTimeout(() => { + operations[name](namespace); + }, 0); + await until(() => errors.length > 0); + expect(errors).toEqual([name]); + }); + + test("an async generator, and a thenable that calls reject()", async () => { + using dir = tempDir("module-graph-errors-shapes-rejections", { "shapes.mjs": shapes }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const namespace = await graph.import(join(String(dir), "shapes.mjs")); + + namespace.willThrow("async generator"); + setTimeout(() => { + namespace.asyncGenerator().next(); + }, 0); + await until(() => errors.length === 1); + namespace.willThrow("thenable"); + setTimeout(() => { + Promise.resolve(namespace.rejectingThenable); + }, 0); + await until(() => errors.length === 2); + expect(errors).toEqual(["async generator", "thenable"]); + }); + + // Attribution is by stack, and here no stack is left that names the graph: the job that calls then() catches what it + // throws and rejects the promise by calling back into the VM, which forgets the exception; the value was not made + // by the graph either. The graph's own Error, thrown the same way, is attributed. + test.concurrent("a thenable whose then() throws a value the graph did not make is nobody's", async () => { + using dir = tempDir("module-graph-errors-thenable", { + "thenable.mjs": ` + export const thenable = value => ({ then() { throw value; } }); + export const resolveWithThenable = value => { Promise.resolve(thenable(value)); }; + `, + "main.mjs": ` + ${prelude} + const { thenable, resolveWithThenable } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/thenable.mjs"); + await reported(() => resolveWithThenable("resolved by the graph")); + await reported(() => { Promise.resolve(thenable("resolved by the host")); }); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process unhandledRejection: string resolved by the graph", + "process unhandledRejection: string resolved by the host", + ]); + expect(exitCode).toBe(0); + }); + + // The same: generatorResume catches and rethrows, so the exception the host sees was thrown by a builtin. + test.concurrent("a generator that throws a value the graph did not make is nobody's", async () => { + using dir = tempDir("module-graph-errors-generator", { + "generator.mjs": ` + export function* throwing(value) { throw value; } + export function* throwingAfterYield(value) { yield 1; throw value; } + `, + "main.mjs": ` + ${prelude} + const { throwing, throwingAfterYield } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/generator.mjs"); + await reported(() => { setTimeout(() => { throwing("a string").next(); }, 0); }); + await reported(() => { setTimeout(() => { throwing(new Error("host Error")).next(); }, 0); }); + await reported(() => { setTimeout(() => { const generator = throwingAfterYield("after a yield"); generator.next(); generator.next(); }, 0); }); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process uncaughtException: string a string", + "process uncaughtException: host Error", + "process uncaughtException: string after a yield", + ]); + expect(exitCode).toBe(0); + }); + }); + + test("thrown by a MessagePort's onmessage and by a Bun.spawn onExit callback", async () => { + using dir = tempDir("module-graph-errors-native-callbacks", { + "callbacks.mjs": ` + export const inMessagePort = error => { + const { port1, port2 } = new MessageChannel(); + port1.onmessage = () => { port1.close(); throw error; }; + port2.postMessage("fault"); + }; + export const inSpawnExit = (error, cmd, env) => { + Bun.spawn({ cmd, env, stdout: "ignore", stderr: "ignore", onExit() { throw error; } }); + }; + `, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { inMessagePort, inSpawnExit } = await graph.import(join(String(dir), "callbacks.mjs")); + + const fromPort = new Error("onmessage"); + inMessagePort(fromPort); + await until(() => errors.length === 1); + const fromExit = new Error("onExit"); + inSpawnExit(fromExit, [bunExe(), "--version"], bunEnv); + await until(() => errors.length === 2); + expect(errors).toEqual([fromPort, fromExit]); + }); + + test.concurrent("CommonJS is shared: its functions are nobody's, its callers may be the graph's", async () => { + using dir = tempDir("module-graph-errors-commonjs", { + "shared.cjs": ` + exports.throwNow = value => { throw value; }; + exports.throwInTimer = value => { setTimeout(() => { throw value; }, 0); }; + exports.rejectNow = value => { Promise.reject(value); }; + `, + "uses-shared.mjs": ` + import shared from "./shared.cjs"; + export { shared }; + export const callSharedInTimer = (name, value) => { setTimeout(() => { shared[name](value); }, 0); }; + `, + "main.mjs": ` + ${prelude} + const { shared, callSharedInTimer } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/uses-shared.mjs"); + await reported(() => callSharedInTimer("throwNow", "thrown under the graph's timer")); + await reported(() => callSharedInTimer("rejectNow", "rejected under the graph's timer")); + await reported(() => { setTimeout(() => shared.throwNow("thrown under the host's timer"), 0); }); + await reported(() => { setTimeout(() => shared.rejectNow("rejected under the host's timer"), 0); }); + // The graph's frame is long gone when CommonJS's own timer fires. + await reported(() => callSharedInTimer("throwInTimer", "thrown in the CommonJS module's timer")); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: string thrown under the graph's timer", + "graph.onError: string rejected under the graph's timer", + "process uncaughtException: string thrown under the host's timer", + "process unhandledRejection: string rejected under the host's timer", + "process uncaughtException: string thrown in the CommonJS module's timer", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("modules the graph's entry imports, statically and dynamically", async () => { + using dir = tempDir("module-graph-errors-dependencies", { + "static-dependency.mjs": `export const throwFromStatic = value => { throw value; };`, + "dynamic-dependency.mjs": `export const throwFromDynamic = value => { throw value; };`, + "throws-when-evaluated.mjs": `throw new Error("thrown while a dropped import() evaluated it");`, + "entry.mjs": ` + export * from "./static-dependency.mjs"; + export const load = () => import("./dynamic-dependency.mjs"); + export const dropImport = specifier => { import(specifier); }; + `, + "main.mjs": ` + ${prelude} + const entry = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/entry.mjs"); + const { throwFromDynamic } = await entry.load(); + await reported(() => { setTimeout(() => entry.throwFromStatic("static dependency"), 0); }); + await reported(() => { setTimeout(() => throwFromDynamic("dynamic dependency"), 0); }); + await reported(() => entry.dropImport("./throws-when-evaluated.mjs")); + // Nothing of the graph's is on any stack when the resolver's error rejects the import() promise. + await reported(() => entry.dropImport("./missing.mjs")); + console.log(JSON.stringify(log.map(line => line.split(" imported from ")[0]), null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: string static dependency", + "graph.onError: string dynamic dependency", + "graph.onError: thrown while a dropped import() evaluated it", + "process unhandledRejection: Cannot find module './missing.mjs'", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("a Bun.serve fetch handler: the server handles it, onError does not", async () => { + using dir = tempDir("module-graph-errors-serve", { + "server.mjs": ` + export const serve = (options, error) => Bun.serve({ port: 0, ...options, fetch() { throw error; } }); + export const serveAsync = (options, error) => Bun.serve({ port: 0, ...options, async fetch() { await null; throw error; } }); + export const serveWithOwnHandler = error => Bun.serve({ + port: 0, + fetch() { throw error; }, + error(thrown) { return new Response("graph handler: " + thrown.message, { status: 500 }); }, + }); + `, + "main.mjs": ` + ${prelude} + const graph = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/server.mjs"); + const responses = []; + const seen = []; + const error = thrown => { seen.push(thrown); return new Response("host handler: " + thrown.message, { status: 500 }); }; + const thrown = [new Error("sync"), new Error("async"), new Error("own")]; + for (const server of [graph.serve({ error }, thrown[0]), graph.serveAsync({ error }, thrown[1]), graph.serveWithOwnHandler(thrown[2])]) { + const response = await fetch(server.url); + responses.push([response.status, await response.text()]); + await server.stop(true); + } + await nextTurn(); + console.log(JSON.stringify({ responses, sameErrors: seen.map((error, i) => error === thrown[i]), log })); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + responses: [ + [500, "host handler: sync"], + [500, "host handler: async"], + [500, "graph handler: own"], + ], + sameErrors: [true, true], + log: [], + }); + expect(exitCode).toBe(0); + }); + + test.concurrent( + "a Bun.serve fetch handler without an error handler: the server prints it and answers 500", + async () => { + using dir = tempDir("module-graph-errors-serve-default", { + "server.mjs": ` + export const serve = () => Bun.serve({ port: 0, fetch() { throw new Error("thrown by the fetch handler"); } }); + `, + "main.mjs": ` + ${prelude} + const { serve } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/server.mjs"); + const server = serve(); + const response = await fetch(server.url); + await server.stop(true); + await nextTurn(); + console.log(JSON.stringify({ status: response.status, log })); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toContain("thrown by the fetch handler"); + expect(JSON.parse(stdout)).toEqual({ status: 500, log: [] }); + // As for a host server: an error Bun.serve had to print sets the exit code. + expect(exitCode).toBe(1); + }, + ); + }); + + describe("unhandled rejections of the graph's code", () => { + test.each([ + "asyncFunction", + "promiseReject", + "executor", + "executorThrow", + "withResolvers", + "thenCallback", + "catchCallback", + "finallyCallback", + "afterAwait", + "asyncGenerator", + "asyncGeneratorSecondNext", + "rejectedLater", + ])("rejected in %s", async source => { + using dir = tempDir("module-graph-errors-reject-in", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + + // Created by the host: only the stack at the time of the rejection says it is the graph's. + const error = new Error(source); + rejectIn[source](error, Promise.resolve("a host promise")); + await until(() => errors.length > 0); + expect(errors).toEqual([error]); + expect(errors[0]).toBe(error); + }); + + test.each([["a string"], [42], [undefined], [null], [{ plain: "object" }], [Symbol.for("rejected")]])( + "a %p rejected while the graph's frame is live arrives as is", + async value => { + using dir = tempDir("module-graph-errors-reject-value", { "faults.mjs": faults }); + const calls: unknown[][] = []; + using graph = new ModuleGraph({ onError: (...args: unknown[]) => calls.push(args) }); + const { rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + + rejectIn.promiseReject(value); + rejectIn.asyncFunction(value); + rejectIn.thenCallback(value); + await until(() => calls.length === 3); + expect(calls).toEqual([[value], [value], [value]]); + expect(calls.map(([arg]) => arg === value)).toEqual([true, true, true]); + }, + ); + + test.concurrent("from a tail call only the graph's own, unread Error is attributed", async () => { + using dir = tempDir("module-graph-errors-tail-call", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const { rejectIn } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/faults.mjs"); + await reported(() => { rejectIn.tailCallOfOwnError("own Error"); }); + await reported(() => { rejectIn.tailCallOfOwnErrorWithStackRead("own Error, stack read"); }); + await reported(() => { rejectIn.tailCall(new Error("host Error")); }); + await reported(() => { rejectIn.tailCall("a string"); }); + await reported(() => { rejectIn.tailCall(undefined); }); + await reported(() => { rejectIn.tailCall({ toString: () => "an object" }); }); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: own Error", + "process unhandledRejection: own Error, stack read", + "process unhandledRejection: host Error", + "process unhandledRejection: string a string", + "process unhandledRejection: undefined undefined", + "process unhandledRejection: object an object", + ]); + expect(exitCode).toBe(0); + }); + + test("an Error whose stack was read, rejected while the graph's frame is live", async () => { + using dir = tempDir("module-graph-errors-stack-read", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejectOwnErrorWithStackRead, create } = await graph.import(join(String(dir), "faults.mjs")); + + rejectOwnErrorWithStackRead("stack was read"); + await until(() => errors.length > 0); + expect(errors).toEqual([expect.objectContaining({ message: "stack was read" })]); + expect(errors[0]).toBe(create.last); + }); + + test("an Error the graph created is the graph's even when host code throws or rejects it", async () => { + using dir = tempDir("module-graph-errors-created", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { create } = await graph.import(join(String(dir), "faults.mjs")); + + const rejectedByHost = create.error("rejected by the host"); + Promise.reject(rejectedByHost); + await until(() => errors.length === 1); + + const thrownByHost = create.error("thrown by the host"); + setTimeout(() => { + throw thrownByHost; + }, 0); + await until(() => errors.length === 2); + + const thrownInHostAsyncFunction = create.error("thrown in the host's async function"); + (async () => { + throw thrownInHostAsyncFunction; + })(); + await until(() => errors.length === 3); + expect(errors).toEqual([rejectedByHost, thrownByHost, thrownInHostAsyncFunction]); + }); + + test("an async function the module starts while it is evaluated", async () => { + using dir = tempDir("module-graph-errors-evaluation", { + "starts-failing-work.mjs": ` + export const error = new Error("started during evaluation"); + (async () => { throw error; })(); + (async () => { await null; throw "after an await"; })(); + export const evaluated = true; + `, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const namespace = await graph.import(join(String(dir), "starts-failing-work.mjs")); + + await until(() => errors.length === 2); + expect(namespace.evaluated).toBe(true); + expect(errors).toEqual([namespace.error, "after an await"]); + }); + + test("a failed graph.import() rejects the caller's promise and is not an onError event", async () => { + using dir = tempDir("module-graph-errors-import-fails", { + "faults.mjs": faults, + "throws.mjs": `throw new Error("thrown during evaluation");`, + "throws-after-await.mjs": `await null; throw new Error("thrown after top-level await");`, + "syntax-error.mjs": `export const = ;`, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + + const results = await Promise.allSettled([ + graph.import(join(String(dir), "throws.mjs")), + graph.import(join(String(dir), "throws-after-await.mjs")), + graph.import(join(String(dir), "syntax-error.mjs")), + graph.import(join(String(dir), "missing.mjs")), + ]); + + const { throwIn } = await graph.import(join(String(dir), "faults.mjs")); + const last = new Error("the only report"); + throwIn.setTimeout(last); + await until(() => errors.length > 0); + expect({ statuses: results.map(result => result.status), errors }).toEqual({ + statuses: ["rejected", "rejected", "rejected", "rejected"], + errors: [last], + }); + }); + + // The promise graph.import() returns is the caller's, like the one import() returns: a failure nobody handles is an + // unhandled rejection, which the graph's onError takes when it was the graph's code that threw. + test.concurrent("a failed graph.import() nobody handles is reported", async () => { + using dir = tempDir("module-graph-errors-import-unhandled", { + "throws.mjs": `throw new Error("thrown during evaluation");`, + "host-throws.mjs": `throw new Error("thrown during the host's evaluation");`, + "main.mjs": ` + ${prelude} + const graph = new ModuleGraph({ onError: onError("graph") }); + await reported(() => { import(import.meta.dir + "/host-throws.mjs"); }); + await reported(() => { graph.import(import.meta.dir + "/throws.mjs"); }); + await reported(() => { Promise.reject(new Error("made after the failed graph.import()")); }); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process unhandledRejection: thrown during the host's evaluation", + "graph.onError: thrown during evaluation", + "process unhandledRejection: made after the failed graph.import()", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("a promise native code rejects is the graph's only once the graph's code awaits it", async () => { + using dir = tempDir("module-graph-errors-native-rejection", { + "reads.mjs": ` + import { promises } from "node:fs"; + export const awaited = path => { (async () => { await promises.readFile(path); })(); }; + export const chained = path => { promises.readFile(path).then(() => {}); }; + export const dropped = path => { promises.readFile(path); }; + `, + "main.mjs": ` + ${prelude} + const reads = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/reads.mjs"); + for (const name of ["awaited", "chained", "dropped"]) await reported(() => reads[name](import.meta.dir + "/missing-" + name)); + console.log(JSON.stringify(log.map(line => line.split(", open")[0]), null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: ENOENT: no such file or directory", + "process unhandledRejection: ENOENT: no such file or directory", + "process unhandledRejection: ENOENT: no such file or directory", + ]); + expect(exitCode).toBe(0); + }); + + test("the same Error rejected twice is reported twice", async () => { + using dir = tempDir("module-graph-errors-twice", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejectIn, throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + const error = new Error("twice"); + rejectIn.promiseReject(error); + rejectIn.asyncFunction(error); + throwIn.setTimeout(error); + await until(() => errors.length === 3); + expect(errors).toEqual([error, error, error]); + }); + + test("a deep stack", async () => { + using dir = tempDir("module-graph-errors-deep", { + "deep.mjs": ` + export function rejectAtDepth(depth, value) { + if (depth === 0) { + Promise.reject(value); + return 0; + } + return rejectAtDepth(depth - 1, value) + 1; + } + `, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejectAtDepth } = await graph.import(join(String(dir), "deep.mjs")); + + // Not an Error, so only the live stack can attribute it. + expect(rejectAtDepth(5000, "rejected 5000 frames deep")).toBe(5000); + await until(() => errors.length > 0); + expect(errors).toEqual(["rejected 5000 frames deep"]); + }); + }); + + describe("ownership", () => { + const files = { + "faults.mjs": faults, + }; + + test("the innermost graph frame decides between graphs", async () => { + using dir = tempDir("module-graph-errors-innermost", files); + const file = join(String(dir), "faults.mjs"); + const reports: [string, unknown][] = []; + using graphA = new ModuleGraph({ onError: (error: unknown) => reports.push(["a", error]) }); + using graphB = new ModuleGraph({ onError: (error: unknown) => reports.push(["b", error]) }); + const a = await graphA.import(file); + const b = await graphB.import(file); + + // b's timer callback calls a's function. + b.callInTimer(() => a.throwNow("a throws inside b's timer")); + await until(() => reports.length === 1); + b.callInTimer(() => a.rejectIn.promiseReject("a rejects inside b's timer")); + await until(() => reports.length === 2); + // a's function calls b's function which calls a host function that throws. + a.callInTimer(() => + b.call(() => { + throw "the host throws inside b inside a's timer"; + }), + ); + await until(() => reports.length === 3); + a.callInTimer(() => b.call(() => a.call(() => b.rejectIn.promiseReject("b is innermost of a-b-a-b")))); + await until(() => reports.length === 4); + + expect(reports).toEqual([ + ["a", "a throws inside b's timer"], + ["a", "a rejects inside b's timer"], + ["b", "the host throws inside b inside a's timer"], + ["b", "b is innermost of a-b-a-b"], + ]); + }); + + test("a host function the graph calls, and a graph function a host timer calls", async () => { + using dir = tempDir("module-graph-errors-host-frames", files); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { callInTimer, call, throwNow, rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + + callInTimer(() => { + throw "host function in the graph's timer"; + }); + await until(() => errors.length === 1); + callInTimer(() => { + Promise.reject("host function rejecting in the graph's timer"); + }); + await until(() => errors.length === 2); + setTimeout(() => throwNow("graph function in the host's timer"), 0); + await until(() => errors.length === 3); + setTimeout(() => { + rejectIn.promiseReject("graph function rejecting in the host's timer"); + }, 0); + await until(() => errors.length === 4); + setTimeout( + () => + call(() => { + throw "host function called by the graph in the host's timer"; + }), + 0, + ); + await until(() => errors.length === 5); + + expect(errors).toEqual([ + "host function in the graph's timer", + "host function rejecting in the graph's timer", + "graph function in the host's timer", + "graph function rejecting in the host's timer", + "host function called by the graph in the host's timer", + ]); + }); + + test("a host function in `globals` that throws when the graph's timer calls it", async () => { + using dir = tempDir("module-graph-errors-globals-function", { + "uses-global.mjs": ` + export const callGlobalInTimer = () => { setTimeout(() => { fail("from the global"); }, 0); }; + `, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ + globals: { + fail(message: string) { + throw new Error(message); + }, + }, + onError: (error: any) => errors.push(error.message), + }); + const { callGlobalInTimer } = await graph.import(join(String(dir), "uses-global.mjs")); + + callGlobalInTimer(); + await until(() => errors.length > 0); + expect(errors).toEqual(["from the global"]); + }); + + test("two graphs of the same module each get their own", async () => { + using dir = tempDir("module-graph-errors-interleaved", files); + const file = join(String(dir), "faults.mjs"); + const reports: { a: unknown[]; b: unknown[] } = { a: [], b: [] }; + using graphA = new ModuleGraph({ onError: (error: unknown) => reports.a.push(error) }); + using graphB = new ModuleGraph({ globals: { tenant: "b" }, onError: (error: unknown) => reports.b.push(error) }); + const namespaces = { a: await graphA.import(file), b: await graphB.import(file) }; + + const expected: { a: string[]; b: string[] } = { a: [], b: [] }; + for (let i = 0; i < 42; i++) { + const name = i % 2 ? "a" : "b"; + namespaces[name].rejectIn[["promiseReject", "asyncFunction", "thenCallback"][i % 3]](name + i); + expected[name].push(name + i); + } + await until(() => reports.a.length + reports.b.length === 42); + expect({ a: reports.a.toSorted(), b: reports.b.toSorted() }).toEqual({ + a: expected.a.toSorted(), + b: expected.b.toSorted(), + }); + }); + + test("thirty graphs", async () => { + using dir = tempDir("module-graph-errors-many", files); + const file = join(String(dir), "faults.mjs"); + const reports: [number, unknown][] = []; + const namespaces = await Promise.all( + Array.from({ length: 30 }, (_, i) => + new ModuleGraph({ onError: (error: unknown) => reports.push([i, error]) }).import(file), + ), + ); + + namespaces.forEach((namespace, i) => + (i % 2 ? namespace.throwIn.setImmediate : namespace.rejectIn.thenCallback)(i), + ); + await until(() => reports.length === 30); + expect(reports.toSorted(([a], [b]) => a - b)).toEqual(Array.from({ length: 30 }, (_, i) => [i, i])); + }); + + test.concurrent( + "a graph without onError on the inside is process-wide even inside another graph's timer", + async () => { + using dir = tempDir("module-graph-errors-silent-inner", { + ...files, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const loud = await new ModuleGraph({ onError: onError("loud") }).import(file); + const silent = await new ModuleGraph().import(file); + await reported(() => loud.callInTimer(() => silent.throwNow("silent inside loud's timer"))); + await reported(() => loud.callInTimer(() => silent.rejectIn.promiseReject("silent rejects inside loud's timer"))); + await reported(() => silent.callInTimer(() => loud.throwNow("loud inside silent's timer"))); + await reported(() => silent.callInTimer(() => loud.rejectIn.promiseReject("loud rejects inside silent's timer"))); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process uncaughtException: string silent inside loud's timer", + "process unhandledRejection: string silent rejects inside loud's timer", + "loud.onError: string loud inside silent's timer", + "loud.onError: string loud rejects inside silent's timer", + ]); + expect(exitCode).toBe(0); + }, + ); + }); + + describe("process-wide", () => { + test.concurrent("host errors while graphs exist", async () => { + using dir = tempDir("module-graph-errors-host", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const graph = await new ModuleGraph({ onError: onError("graph") }).import(file); + const host = await import(file); + await reported(() => host.throwIn.setTimeout(new Error("host timer"))); + await reported(() => host.throwIn.nextTick(new Error("host nextTick"))); + await reported(() => host.rejectIn.asyncFunction(new Error("host async function"))); + await reported(() => host.rejectIn.promiseReject("host string")); + await reported(() => { Promise.reject(new Error("main module")); }); + // The graph's namespace object is reachable, its code is not on the stack. + await reported(() => { setTimeout(() => { graph.create; throw new Error("host timer touching the graph"); }, 0); }); + await reported(() => graph.throwIn.setTimeout(new Error("the graph still gets its own"))); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process uncaughtException: host timer", + "process uncaughtException: host nextTick", + "process unhandledRejection: host async function", + "process unhandledRejection: string host string", + "process unhandledRejection: main module", + "process uncaughtException: host timer touching the graph", + "graph.onError: the graph still gets its own", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("a graph without onError", async () => { + using dir = tempDir("module-graph-errors-without", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const other = await new ModuleGraph({ onError: onError("other") }).import(file); + const silent = await new ModuleGraph({ onError: undefined }).import(file); + await reported(() => silent.throwIn.setTimeout(new Error("timer"))); + await reported(() => silent.throwIn.queueMicrotask("microtask")); + await reported(() => silent.rejectIn.asyncFunction(new Error("async function"))); + await reported(() => silent.rejectIn.tailCallOfOwnError("tail call")); + await reported(() => silent.listeners.target(new Error("listener")).dispatchEvent(new Event("fault"))); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process uncaughtException: timer", + "process uncaughtException: string microtask", + "process unhandledRejection: async function", + "process unhandledRejection: tail call", + "process uncaughtException: listener", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("what onError throws, once, and never to a graph", async () => { + using dir = tempDir("module-graph-errors-throwing-handler", { + "faults.mjs": faults, + "handler.mjs": ` + export const throwingHandler = error => { record("handler saw " + error.message); throw new Error("thrown by " + name + "'s code as onError: " + error.message); }; + `, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const record = message => log.push(message); + let fail = true; + const flaky = await new ModuleGraph({ + onError(error) { + onError("flaky")(error); + if (fail) throw new Error("thrown by onError: " + show(error)); + }, + }).import(file); + const bystander = await new ModuleGraph({ onError: onError("bystander") }).import(file); + // onError implemented by another graph's code: what it throws is not that graph's error either. + const handlers = await new ModuleGraph({ globals: { record, name: "handlers" }, onError: onError("handlers") }).import(import.meta.dir + "/handler.mjs"); + const delegating = await new ModuleGraph({ onError: handlers.throwingHandler }).import(file); + + await reported(() => flaky.throwIn.setTimeout(new Error("exception")), 2); + await reported(() => flaky.rejectIn.promiseReject(new Error("rejection")), 2); + await reported(() => flaky.throwIn.setTimeout("a string"), 2); + await reported(() => delegating.throwIn.setTimeout(new Error("delegated")), 2); + fail = false; + await reported(() => flaky.throwIn.setTimeout(new Error("after onError threw"))); + await reported(() => bystander.throwIn.setTimeout(new Error("bystander"))); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "flaky.onError: exception", + "process uncaughtException: thrown by onError: exception", + "flaky.onError: rejection", + "process uncaughtException: thrown by onError: rejection", + "flaky.onError: string a string", + "process uncaughtException: thrown by onError: string a string", + "handler saw delegated", + "process uncaughtException: thrown by handlers's code as onError: delegated", + "flaky.onError: after onError threw", + "bystander.onError: bystander", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("an exception reported while onError runs, and an onError that cannot be called", async () => { + using dir = tempDir("module-graph-errors-during-handler", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + process.on("uncaughtExceptionMonitor", error => log.push("process uncaughtExceptionMonitor: " + show(error))); + const file = import.meta.dir + "/faults.mjs"; + let target; + const dispatching = await new ModuleGraph({ + onError(error) { + onError("dispatching")(error); + // dispatchEvent() does not throw; the listener's exception is reported from inside onError. + target.dispatchEvent(new Event("fault")); + }, + }).import(file); + target = dispatching.listeners.target(new Error("thrown by the graph's listener during onError")); + await reported(() => dispatching.throwIn.setTimeout(new Error("first")), 3); + + const constructorAsHandler = await new ModuleGraph({ onError: class { constructor() { log.push("constructed"); } } }).import(file); + await reported(() => constructorAsHandler.throwIn.setTimeout(new Error("never seen")), 2); + console.log(JSON.stringify(log.map(line => line.replace(/class constructor.*/, "class constructor")), null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "dispatching.onError: first", + "process uncaughtExceptionMonitor: thrown by the graph's listener during onError", + "process uncaughtException: thrown by the graph's listener during onError", + "process uncaughtExceptionMonitor: Cannot call a class constructor", + "process uncaughtException: Cannot call a class constructor", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent("a rejected promise from the host's onError is the host's rejection", async () => { + using dir = tempDir("module-graph-errors-rejecting-handler", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const asyncHandler = await new ModuleGraph({ + async onError(error) { + onError("async")(error); + throw new Error("thrown by async onError: " + error.message); + }, + }).import(file); + const asyncHandlerAfterAwait = await new ModuleGraph({ + async onError(error) { + onError("async after await")(error); + await null; + throw new Error("thrown by async onError after await: " + error.message); + }, + }).import(file); + const returnsRejected = await new ModuleGraph({ + onError(error) { + onError("returns rejected")(error); + return Promise.reject("rejected by onError: " + error.message); + }, + }).import(file); + + await reported(() => asyncHandler.throwIn.setTimeout(new Error("exception")), 2); + await reported(() => asyncHandler.rejectIn.asyncFunction(new Error("rejection")), 2); + await reported(() => asyncHandlerAfterAwait.throwIn.setTimeout(new Error("exception")), 2); + await reported(() => returnsRejected.rejectIn.promiseReject(new Error("rejection")), 2); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "async.onError: exception", + "process unhandledRejection: thrown by async onError: exception", + "async.onError: rejection", + "process unhandledRejection: thrown by async onError: rejection", + "async after await.onError: exception", + "process unhandledRejection: thrown by async onError after await: exception", + "returns rejected.onError: rejection", + "process unhandledRejection: string rejected by onError: rejection", + ]); + expect(exitCode).toBe(0); + }); + + // An onError implemented by the graph's own code: the promise it rejects while it runs is what + // "onError itself throws", so it must not come back to onError. + test.concurrent.each(["asyncFunction", "promiseReject"])( + "a rejection made by the graph's own code while it runs as onError (%s) is not re-delivered", + async source => { + using dir = tempDir("module-graph-errors-own-handler", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + let deliveries = 0; + let namespace; + const graph = new ModuleGraph({ + onError(error) { + onError("graph")(error); + // Give up instead of spinning forever when every rejection comes back here. + if (++deliveries === 5) { + console.log(JSON.stringify(log, null, 2)); + process.exit(0); + } + namespace.rejectIn[process.argv[2]](new Error("rejected inside onError")); + }, + }); + namespace = await graph.import(import.meta.dir + "/faults.mjs"); + await reported(() => namespace.throwIn.setTimeout(new Error("first")), 2); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs", source]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: first", + "process unhandledRejection: rejected inside onError", + ]); + expect(exitCode).toBe(0); + }, + ); + }); + + describe("handled rejections", () => { + test("handled before the microtask checkpoint: never reported", async () => { + using dir = tempDir("module-graph-errors-handled-early", { + "faults.mjs": faults, + "handles.mjs": ` + export const rejectAndCatch = error => { Promise.reject(error).catch(() => {}); }; + export const rejectAndCatchInMicrotask = error => { const promise = Promise.reject(error); queueMicrotask(() => { promise.catch(() => {}); }); }; + export const rejectAndAwait = async error => { try { await Promise.reject(error); } catch {} }; + export const rejectAndThenWithHandler = error => { (async () => { throw error; })().then(undefined, () => {}); }; + `, + }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const handles = await graph.import(join(String(dir), "handles.mjs")); + const { rejected, throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + handles.rejectAndCatch(new Error("caught by the graph")); + handles.rejectAndCatchInMicrotask(new Error("caught by the graph in a microtask")); + await handles.rejectAndAwait(new Error("awaited by the graph")); + handles.rejectAndThenWithHandler(new Error("then(undefined, handler)")); + rejected(new Error("caught by the host")).promise.catch(() => {}); + + const last = new Error("the only report"); + throwIn.setTimeout(last); + await until(() => errors.length > 0); + expect(errors).toEqual([last]); + }); + + test.concurrent("handled after delivery to onError: no rejectionHandled", async () => { + using dir = tempDir("module-graph-errors-handled-late", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const graph = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/faults.mjs"); + let promise; + await reported(() => { ({ promise } = graph.rejected(new Error("handled late"))); }); + const handled = []; + promise.catch(error => handled.push(error.message)); + let tail; + await reported(() => { tail = graph.rejectIn.tailCallOfOwnError("handled late, tail call"); }); + tail.catch(error => handled.push(error.message)); + // A host rejection handled late is reported after the two above would have been. + await reported(() => { promise = Promise.reject(new Error("host, handled late")); }); + await reported(() => { promise.catch(error => handled.push(error.message)); }); + console.log(JSON.stringify({ log, handled }, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + log: [ + "graph.onError: handled late", + "graph.onError: handled late, tail call", + "process unhandledRejection: host, handled late", + "process rejectionHandled", + ], + handled: ["handled late", "handled late, tail call", "host, handled late"], + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("a graph without onError, handled late: unhandledRejection then rejectionHandled", async () => { + using dir = tempDir("module-graph-errors-handled-late-silent", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const file = import.meta.dir + "/faults.mjs"; + const other = await new ModuleGraph({ onError: onError("other") }).import(file); + const silent = await new ModuleGraph().import(file); + const promises = []; + process.on("rejectionHandled", promise => log.push("it is the rejected promise: " + (promise === promises[0]))); + await reported(() => { promises.push(silent.rejected(new Error("silent, handled late")).promise); }); + await reported(() => { promises[0].catch(() => {}); }, 2); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "process unhandledRejection: silent, handled late", + "process rejectionHandled", + "it is the rejected promise: true", + ]); + expect(exitCode).toBe(0); + }); + + test("a rejection delivered to onError is delivered once, however many times it is handled later", async () => { + using dir = tempDir("module-graph-errors-once", { "faults.mjs": faults }); + const errors: unknown[] = []; + using graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejected, throwIn } = await graph.import(join(String(dir), "faults.mjs")); + + const first = new Error("delivered once"); + const { promise } = rejected(first); + await until(() => errors.length === 1); + const handled: unknown[] = []; + promise.catch((error: unknown) => handled.push(error)); + promise.then(undefined, (error: unknown) => handled.push(error)); + await promise.catch(() => {}); + // A derived promise nobody handles is the host's: \`promise.then()\` was called here. + const last = new Error("the last report"); + throwIn.setTimeout(last); + await until(() => errors.length === 2); + expect({ errors, handled }).toEqual({ errors: [first, last], handled: [first, first] }); + }); + }); + + describe("--unhandled-rejections", () => { + const files = { + "faults.mjs": faults, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const log = []; + const graph = await new ModuleGraph({ onError: error => log.push("graph.onError: " + error.message) }).import(import.meta.dir + "/faults.mjs"); + if (process.argv[2] === "graph") { + graph.rejectIn.promiseReject(new Error("rejected by the graph")); + graph.rejectIn.asyncFunction(new Error("thrown by the graph's async function")); + while (log.length < 2) await new Promise(resolve => setImmediate(resolve)); + } else { + Promise.reject(new Error("rejected by the host")); + await new Promise(resolve => setImmediate(resolve)); + } + console.log(JSON.stringify(log)); + `, + }; + const modes = [ + { mode: "default", args: [], hostExitCode: 1, hostPrints: true }, + { mode: "strict", args: ["--unhandled-rejections=strict"], hostExitCode: 1, hostPrints: true }, + { mode: "throw", args: ["--unhandled-rejections=throw"], hostExitCode: 1, hostPrints: true }, + { mode: "warn", args: ["--unhandled-rejections=warn"], hostExitCode: 0, hostPrints: true }, + { + mode: "warn-with-error-code", + args: ["--unhandled-rejections=warn-with-error-code"], + hostExitCode: 1, + hostPrints: true, + }, + { mode: "none", args: ["--unhandled-rejections=none"], hostExitCode: 0, hostPrints: false }, + ]; + + test.concurrent.each(modes)("$mode: the graph's rejection goes to onError", async ({ args }) => { + using dir = tempDir("module-graph-errors-mode-graph", files); + const { stdout, stderr, exitCode } = await run(String(dir), [...args, "main.mjs", "graph"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: rejected by the graph", + "graph.onError: thrown by the graph's async function", + ]); + expect(exitCode).toBe(0); + }); + + test.concurrent.each(modes)( + "$mode: the host's rejection is unchanged", + async ({ args, hostExitCode, hostPrints }) => { + using dir = tempDir("module-graph-errors-mode-host", files); + const { stdout, stderr, exitCode } = await run(String(dir), [...args, "main.mjs", "host"]); + expect(stderr.includes("rejected by the host")).toBe(hostPrints); + expect(stdout).not.toContain("graph.onError"); + expect(exitCode).toBe(hostExitCode); + }, + ); + }); + + describe("the default fatal path", () => { + const files = { + "faults.mjs": faults, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/faults.mjs"; + const seen = []; + const graph = await new ModuleGraph({ + onError(error) { + seen.push(error.message); + if (process.argv[2] === "onError throws") throw new Error("thrown by onError"); + }, + }).import(file); + const silent = await new ModuleGraph().import(file); + const host = await import(file); + process.on("exit", () => console.log(JSON.stringify(seen))); + switch (process.argv[2]) { + case "host exception": host.throwIn.setImmediate(new Error("fatal: host exception")); break; + case "host rejection": host.rejectIn.asyncFunction(new Error("fatal: host rejection")); break; + case "graph without onError exception": silent.throwIn.setImmediate(new Error("fatal: silent exception")); break; + case "graph without onError rejection": silent.rejectIn.asyncFunction(new Error("fatal: silent rejection")); break; + case "onError throws": graph.throwIn.setImmediate(new Error("taken by onError")); break; + case "graph exception": graph.throwIn.setImmediate(new Error("taken by onError")); break; + case "graph rejection": graph.rejectIn.asyncFunction(new Error("taken by onError")); break; + } + // The fault is reported (or ends the process) before an immediate queued after it runs. + await new Promise(resolve => setImmediate(resolve)); + `, + }; + + test.concurrent.each([ + ["host exception", "fatal: host exception"], + ["host rejection", "fatal: host rejection"], + ["graph without onError exception", "fatal: silent exception"], + ["graph without onError rejection", "fatal: silent rejection"], + ])("%s: printed, exit code 1", async (scenario, message) => { + using dir = tempDir("module-graph-errors-fatal", files); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs", scenario]); + expect(stderr).toContain(message); + expect(JSON.parse(stdout)).toEqual([]); + expect(exitCode).toBe(1); + }); + + test.concurrent("onError throws: printed, exit code 1", async () => { + using dir = tempDir("module-graph-errors-fatal-handler", files); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs", "onError throws"]); + expect(stderr).toContain("thrown by onError"); + expect(stderr).not.toContain("taken by onError"); + expect(JSON.parse(stdout)).toEqual(["taken by onError"]); + expect(exitCode).toBe(1); + }); + + test.concurrent.each(["graph exception", "graph rejection"])( + "%s with onError: nothing printed, exit code 0", + async scenario => { + using dir = tempDir("module-graph-errors-not-fatal", files); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs", scenario]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["taken by onError"]); + expect(exitCode).toBe(0); + }, + ); + }); + + describe("the onError call", () => { + test("`this` is undefined and the error is the only argument", async () => { + using dir = tempDir("module-graph-errors-call-shape", { "faults.mjs": faults }); + const calls: unknown[] = []; + using graph = new ModuleGraph({ + onError: function (this: unknown) { + "use strict"; + calls.push({ this: this, arguments: [...arguments] }); + }, + }); + const { throwIn, rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + + const thrown = new Error("thrown"); + const rejected = new Error("rejected"); + throwIn.setTimeout(thrown); + await until(() => calls.length === 1); + rejectIn.promiseReject(rejected); + await until(() => calls.length === 2); + expect(calls).toEqual([ + { this: undefined, arguments: [thrown] }, + { this: undefined, arguments: [rejected] }, + ]); + }); + + test("onError is read once, at construction", async () => { + using dir = tempDir("module-graph-errors-read-once", { "faults.mjs": faults }); + const errors: unknown[] = []; + let reads = 0; + const options = { + get onError() { + reads++; + return (error: unknown) => errors.push(error); + }, + }; + using graph = new ModuleGraph(options); + const { throwIn } = await graph.import(join(String(dir), "faults.mjs")); + graph.onError = () => errors.push("a property set on the ModuleGraph object"); + + throwIn.setTimeout("first"); + await until(() => errors.length === 1); + throwIn.setTimeout("second"); + await until(() => errors.length === 2); + expect({ errors, reads }).toEqual({ errors: ["first", "second"], reads: 1 }); + }); + + test("onError may be any callable", async () => { + using dir = tempDir("module-graph-errors-callables", { "faults.mjs": faults }); + const file = join(String(dir), "faults.mjs"); + const seen: unknown[] = []; + const record = (...args: unknown[]) => seen.push(args); + using proxied = new ModuleGraph({ onError: new Proxy(record, {}) }); + using bound = new ModuleGraph({ onError: record.bind(null, "bound") }); + using native = new ModuleGraph({ onError: Array.prototype.push.bind(seen) }); + + (await proxied.import(file)).throwIn.setTimeout("to the Proxy"); + await until(() => seen.length === 1); + (await bound.import(file)).throwIn.setTimeout("to the bound function"); + await until(() => seen.length === 2); + (await native.import(file)).throwIn.setTimeout("to the native function"); + await until(() => seen.length === 3); + expect(seen).toEqual([["to the Proxy"], ["bound", "to the bound function"], "to the native function"]); + }); + + test.each([[null], ["onError"], [{}], [Symbol("onError")], [0], [false]])("onError: %p is not accepted", value => { + expect(() => new ModuleGraph({ onError: value })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); + + test("after dispose()", async () => { + using dir = tempDir("module-graph-errors-disposed", { "faults.mjs": faults }); + const errors: unknown[] = []; + const graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { throwIn, rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + graph.dispose(); + + throwIn.setTimeout("exception after dispose()"); + await until(() => errors.length === 1); + rejectIn.asyncFunction("rejection after dispose()"); + await until(() => errors.length === 2); + rejectIn.tailCallOfOwnError("tail call after dispose()"); + await until(() => errors.length === 3); + expect(errors).toEqual([ + "exception after dispose()", + "rejection after dispose()", + expect.objectContaining({ message: "tail call after dispose()" }), + ]); + }); + + test("an error pending when dispose() is called", async () => { + using dir = tempDir("module-graph-errors-dispose-pending", { "faults.mjs": faults }); + const errors: unknown[] = []; + const graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { throwIn, rejectIn } = await graph.import(join(String(dir), "faults.mjs")); + + rejectIn.promiseReject("rejected before dispose()"); + throwIn.setTimeout("scheduled before dispose()"); + graph.dispose(); + await until(() => errors.length === 2); + expect(errors).toEqual(["rejected before dispose()", "scheduled before dispose()"]); + }); + + test("after the ModuleGraph object was collected", async () => { + using dir = tempDir("module-graph-errors-collected", { "faults.mjs": faults }); + const file = join(String(dir), "faults.mjs"); + const errors: unknown[] = []; + async function load() { + const graph = new ModuleGraph({ onError: (error: unknown) => errors.push(error) }); + const { rejectIn, create } = await graph.import(file); + return { rejectIn, create }; + } + const { rejectIn, create } = await load(); + Bun.gc(true); + + rejectIn.asyncFunction("rejection"); + await until(() => errors.length === 1); + Bun.gc(true); + rejectIn.tailCallOfOwnError("tail call"); + await until(() => errors.length === 2); + Bun.gc(true); + Promise.reject(create.error("created by the collected graph's code")); + await until(() => errors.length === 3); + expect(errors).toEqual([ + "rejection", + expect.objectContaining({ message: "tail call" }), + expect.objectContaining({ message: "created by the collected graph's code" }), + ]); + }); + + test("onError can create graphs, import into its graph and dispose it", async () => { + using dir = tempDir("module-graph-errors-reentrant", { + "faults.mjs": faults, + "loaded-by-on-error.mjs": `export const loaded = true;`, + }); + const file = join(String(dir), "faults.mjs"); + const errors: unknown[] = []; + const work: Promise[] = []; + const graph = new ModuleGraph({ + onError(error: unknown) { + errors.push(error); + if (errors.length > 1) return; + Bun.gc(true); + work.push(graph.import(join(String(dir), "loaded-by-on-error.mjs"))); + const nested = new ModuleGraph({ onError: (error: unknown) => errors.push(["nested", error]) }); + work.push( + nested + .import(file) + .then((namespace: any) => namespace.throwIn.setTimeout("from the graph onError created")), + ); + graph.dispose(); + work.push(graph.import(file).catch((error: any) => error.code)); + }, + }); + const { throwIn } = await graph.import(file); + + throwIn.setTimeout("first"); + await until(() => errors.length === 2); + throwIn.setTimeout("after onError disposed the graph"); + await until(() => errors.length === 3); + expect(errors).toEqual([ + "first", + ["nested", "from the graph onError created"], + "after onError disposed the graph", + ]); + expect(await Promise.all(work)).toEqual([ + expect.objectContaining({ loaded: true }), + undefined, + "ERR_INVALID_STATE", + ]); + }); + + test("an error the graph's code raises while onError runs is a new report", async () => { + using dir = tempDir("module-graph-errors-nested-report", { "faults.mjs": faults }); + const errors: unknown[] = []; + let namespace: any; + using graph = new ModuleGraph({ + onError(error: unknown) { + errors.push(error); + if (error === "first") namespace.throwIn.setTimeout("scheduled by onError"); + }, + }); + namespace = await graph.import(join(String(dir), "faults.mjs")); + + namespace.throwIn.setTimeout("first"); + await until(() => errors.length === 2); + expect(errors).toEqual(["first", "scheduled by onError"]); + }); + }); + + test.concurrent( + "code from node:vm, new Function and eval belongs to the graph only under the graph's frames", + async () => { + using dir = tempDir("module-graph-errors-vm", { + "evaluates.mjs": ` + import vm from "node:vm"; + const message = "direct eval closure in a timer"; + export const make = { + vmFunction: () => vm.runInThisContext("(act) => { act(); }"), + newFunction: () => new Function("act", "act();"), + indirectEval: () => (0, eval)("(act) => { act(); }"), + }; + export const throwInTimerFromDirectEval = () => { eval("setTimeout(() => { throw new Error(message); }, 0);"); }; + export const callInTimer = (fn, ...args) => { setTimeout(() => { fn(...args); }, 0); }; + export const throwNow = message => { throw new Error(message); }; + `, + "main.mjs": ` + ${prelude} + const graph = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/evaluates.mjs"); + for (const kind of ["vmFunction", "newFunction", "indirectEval"]) { + const evaluated = graph.make[kind](); + // The graph's timer callback calls it: the graph's frame is below it. + await reported(() => graph.callInTimer(evaluated, () => { throw new Error(kind + " under the graph's timer"); })); + // It calls the graph's function: the graph's frame is above it. + await reported(() => { setTimeout(() => evaluated(() => graph.throwNow(kind + " calling the graph")), 0); }); + // No module code on the stack at all: it has no module scope, so it is nobody's. + await reported(() => { setTimeout(() => evaluated(() => { throw new Error(kind + " in the host's timer"); }), 0); }); + } + await reported(() => graph.throwInTimerFromDirectEval()); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: vmFunction under the graph's timer", + "graph.onError: vmFunction calling the graph", + "process uncaughtException: vmFunction in the host's timer", + "graph.onError: newFunction under the graph's timer", + "graph.onError: newFunction calling the graph", + "process uncaughtException: newFunction in the host's timer", + "graph.onError: indirectEval under the graph's timer", + "graph.onError: indirectEval calling the graph", + "process uncaughtException: indirectEval in the host's timer", + "graph.onError: direct eval closure in a timer", + ]); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("Error.stackTraceLimit and Error.prepareStackTrace", async () => { + using dir = tempDir("module-graph-errors-stack-api", { + "faults.mjs": faults, + "main.mjs": ` + ${prelude} + const { throwIn, rejectIn, throwOwnErrorIn } = await new ModuleGraph({ onError: onError("graph") }).import(import.meta.dir + "/faults.mjs"); + Error.prepareStackTrace = () => "prepared"; + await reported(() => throwIn.setTimeout("prepareStackTrace: thrown")); + await reported(() => { rejectIn.tailCallOfOwnError("prepareStackTrace: tail call of own Error"); }); + Error.prepareStackTrace = undefined; + Error.stackTraceLimit = 0; + await reported(() => throwIn.setTimeout("stackTraceLimit 0: thrown")); + await reported(() => rejectIn.promiseReject("stackTraceLimit 0: rejected")); + await reported(() => rejectIn.afterAwait("stackTraceLimit 0: thrown after await")); + // The Error captured no frames, so where it was created is unknown. + await reported(() => { rejectIn.tailCallOfOwnError("stackTraceLimit 0: tail call of own Error"); }); + console.log(JSON.stringify(log, null, 2)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["main.mjs"]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "graph.onError: string prepareStackTrace: thrown", + "graph.onError: prepareStackTrace: tail call of own Error", + "graph.onError: string stackTraceLimit 0: thrown", + "graph.onError: string stackTraceLimit 0: rejected", + "graph.onError: string stackTraceLimit 0: thrown after await", + "process unhandledRejection: stackTraceLimit 0: tail call of own Error", + ]); + expect(exitCode).toBe(0); + }); + + describe("in a Worker", () => { + const files = { + "faults.mjs": faults, + "worker.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const errors = []; + const withOnError = await new ModuleGraph({ onError: error => errors.push(error.message) }).import(import.meta.dir + "/faults.mjs"); + withOnError.throwIn.setTimeout(new Error("worker graph exception")); + withOnError.rejectIn.asyncFunction(new Error("worker graph rejection")); + while (errors.length < 2) await new Promise(resolve => setImmediate(resolve)); + postMessage(errors.sort()); + self.onmessage = async () => { + const silent = await new ModuleGraph().import(import.meta.dir + "/faults.mjs"); + silent.throwIn.setTimeout(new Error("worker graph without onError")); + }; + `, + }; + + test("the worker's graph reports to its onError, not to the parent", async () => { + using dir = tempDir("module-graph-errors-worker", files); + const worker = new Worker(join(String(dir), "worker.mjs")); + const events: string[] = []; + const message = Promise.withResolvers(); + const errorEvent = Promise.withResolvers(); + worker.addEventListener("message", event => message.resolve(event.data)); + worker.addEventListener("error", event => { + events.push(event.message); + errorEvent.resolve(); + }); + + try { + expect(await message.promise).toEqual(["worker graph exception", "worker graph rejection"]); + expect(events).toEqual([]); + // Without onError the same fault is the worker's uncaught exception. + worker.postMessage("now without onError"); + await errorEvent.promise; + expect(events).toEqual([expect.stringContaining("worker graph without onError")]); + } finally { + await worker.terminate(); + } + }); + }); + + describe("in bun test", () => { + const files = { + "fault.mjs": ` + export const throwInTimer = (message, thrown) => { setTimeout(() => { thrown(); throw new Error(message); }, 0); }; + export const rejectInTimer = (message, rejected) => { setTimeout(() => { rejected(); Promise.reject(new Error(message)); }, 0); }; + `, + "graph.test.mjs": ` + import { expect, test } from "bun:test"; + const { ModuleGraph } = Bun.unsafe; + const errors = []; + const options = process.env.MODULE_GRAPH_ON_ERROR === "1" ? { onError: error => errors.push(error.message) } : {}; + + test("the graph throws in a timer", async () => { + const { throwInTimer } = await new ModuleGraph(options).import(import.meta.dir + "/fault.mjs"); + const { promise, resolve } = Promise.withResolvers(); + throwInTimer("exception from the graph", resolve); + await promise; + }); + test("the graph rejects in a timer", async () => { + const { rejectInTimer } = await new ModuleGraph(options).import(import.meta.dir + "/fault.mjs"); + const { promise, resolve } = Promise.withResolvers(); + rejectInTimer("rejection from the graph", resolve); + await promise; + await new Promise(resolve => setImmediate(resolve)); + }); + test("what onError saw", () => { + expect(errors).toEqual(process.env.MODULE_GRAPH_ON_ERROR === "1" ? ["exception from the graph", "rejection from the graph"] : []); + }); + `, + }; + + test.concurrent("without onError the graph's error fails the running test", async () => { + using dir = tempDir("module-graph-errors-bun-test-fails", files); + const { stderr, exitCode } = await run(String(dir), ["test", "./graph.test.mjs"], { MODULE_GRAPH_ON_ERROR: "0" }); + expect(stderr).toContain("exception from the graph"); + expect(stderr).toContain("rejection from the graph"); + expect(stderr).toContain(" 1 pass"); + expect(stderr).toContain(" 2 fail"); + expect(exitCode).toBe(1); + }); + + test.concurrent("with onError it does not", async () => { + using dir = tempDir("module-graph-errors-bun-test-passes", files); + const { stderr, exitCode } = await run(String(dir), ["test", "./graph.test.mjs"], { MODULE_GRAPH_ON_ERROR: "1" }); + expect(stderr).not.toContain("from the graph"); + expect(stderr).toContain(" 3 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-globals.test.ts b/test/js/bun/module-graph/module-graph-globals.test.ts new file mode 100644 index 000000000000..0ae73be6c1a4 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-globals.test.ts @@ -0,0 +1,2013 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join, sep } from "node:path"; + +const { ModuleGraph } = Bun.unsafe as any; + +describe("Bun.unsafe.ModuleGraph `globals`", () => { + describe("reads and writes", () => { + test("reads and `typeof`", async () => { + using dir = tempDir("module-graph-globals-reads", { + "reads.mjs": ` + export const values = () => [count, label, typeof count, typeof label, typeof missing]; + export const readMissing = () => missing; + export const atTopLevel = [count, label, typeof missing]; + export default label; + `, + }); + using graph = new ModuleGraph({ globals: { count: 1, label: "a" } }); + const mod = await graph.import(join(String(dir), "reads.mjs")); + + expect(mod.values()).toEqual([1, "a", "number", "string", "undefined"]); + expect(mod.atTopLevel).toEqual([1, "a", "undefined"]); + expect(mod.default).toBe("a"); + expect(mod.readMissing).toThrow(ReferenceError); + }); + + test("assignment in strict module code writes the graph's variable", async () => { + using dir = tempDir("module-graph-globals-assign", { + "assign.mjs": ` + export const write = value => (tenant = value); + export const read = () => tenant; + export const writeMissing = () => { missing = 1; }; + `, + }); + const file = join(String(dir), "assign.mjs"); + using a = new ModuleGraph({ globals: { tenant: "a" } }); + using b = new ModuleGraph({ globals: { tenant: "b" } }); + const inA = await a.import(file); + const inB = await b.import(file); + + const object = {}; + expect(inA.write(object)).toBe(object); + expect(inA.read()).toBe(object); + expect(inB.read()).toBe("b"); + inB.write(undefined); + expect([inA.read(), inB.read()]).toEqual([object, undefined]); + + // A name the graph does not have is still an ordinary strict-mode unresolvable reference. + expect(inA.writeMissing).toThrow(ReferenceError); + expect("missing" in globalThis).toBe(false); + }); + + test("compound assignment, `++` and `--`", async () => { + using dir = tempDir("module-graph-globals-compound", { + "compound.mjs": ` + export const run = () => { + const log = [n++, ++n, n--, --n, (n += 5), (n -= 1), (n *= 2), (n /= 4), (n **= 2), (n %= 5), (n <<= 2), (n |= 1), (n &= 3), (n ^= 7)]; + log.push((s += "b"), (nothing ??= "filled"), (nothing ??= "again"), (zero ||= "or"), (one &&= "and"), big++, ++big); + return { log, n, s, nothing, zero, one, big }; + }; + `, + }); + const file = join(String(dir), "compound.mjs"); + const globals = { n: 1, s: "a", nothing: null, zero: 0, one: 1, big: 1n }; + using a = new ModuleGraph({ globals }); + using b = new ModuleGraph({ globals }); + const expected = { + log: [1, 3, 3, 1, 6, 5, 10, 2.5, 6.25, 1.25, 4, 5, 1, 6, "ab", "filled", "filled", "or", "and", 1n, 3n], + n: 6, + s: "ab", + nothing: "filled", + zero: "or", + one: "and", + big: 3n, + }; + + expect((await a.import(file)).run()).toEqual(expected); + expect((await b.import(file)).run()).toEqual(expected); + expect(globals).toEqual({ n: 1, s: "a", nothing: null, zero: 0, one: 1, big: 1n }); + }); + + test("`delete name` is the strict-mode SyntaxError it always is", async () => { + using dir = tempDir("module-graph-globals-delete", { + "delete-static.mjs": `export const run = () => delete tenant;`, + "delete-eval.mjs": ` + export function run() { return eval("delete tenant"); } + export const deleteFromGlobalThis = () => delete globalThis.tenant; + export const read = () => tenant; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + + expect(graph.import(join(String(dir), "delete-static.mjs"))).rejects.toBeInstanceOf(SyntaxError); + const mod = await graph.import(join(String(dir), "delete-eval.mjs")); + expect(mod.run).toThrow(SyntaxError); + expect(mod.deleteFromGlobalThis()).toBe(true); + expect(mod.read()).toBe("a"); + }); + + test("destructuring assignment targets and `for` heads", async () => { + using dir = tempDir("module-graph-globals-destructure", { + "destructure.mjs": ` + export const run = () => { + [first, second = "default"] = ["one"]; + ({ third, inner: { fourth }, ...rest } = { third: 3, inner: { fourth: 4 }, extra: 5, more: 6 }); + [, ...tail] = [0, 1, 2]; + const seen = []; + for (item of ["x", "y"]) seen.push(item); + for (key in { k1: 1, k2: 2 }) seen.push(key); + [first, second] = [second, first]; + return { first, second, third, fourth, rest, tail, item, key, seen }; + }; + `, + }); + const file = join(String(dir), "destructure.mjs"); + const names = ["first", "second", "third", "fourth", "rest", "tail", "item", "key"]; + const globals = Object.fromEntries(names.map(name => [name, "initial " + name])); + using a = new ModuleGraph({ globals }); + using b = new ModuleGraph({ globals }); + const inA = await a.import(file); + await b.import(file); + + expect(inA.run()).toEqual({ + first: "default", + second: "one", + third: 3, + fourth: 4, + rest: { extra: 5, more: 6 }, + tail: [1, 2], + item: "y", + key: "k2", + seen: ["x", "y", "k1", "k2"], + }); + expect(globals).toEqual(Object.fromEntries(names.map(name => [name, "initial " + name]))); + }); + + test("a graph global used as a call target gets no `this`", async () => { + using dir = tempDir("module-graph-globals-call", { + "call.mjs": ` + export const run = () => ({ + strict: strictThis(), + sloppyIsGlobalThis: sloppyThis() === globalThis, + viaCall: strictThis.call(7), + optionalCall: [maybe?.(), strictThis?.()], + tagged: tag\`a\${1}b\`, + constructed: new Klass(5).value, + isInstance: new Klass(1) instanceof Klass, + subclass: new (class extends Klass {})(6).value, + }); + `, + }); + function strictThis(this: unknown) { + return this; + } + class Klass { + constructor(public value: number) {} + } + using graph = new ModuleGraph({ + globals: { + strictThis, + sloppyThis: new Function("return this"), + maybe: undefined, + tag(this: unknown, strings: string[], ...values: unknown[]) { + return [this, strings.raw, values]; + }, + Klass, + }, + }); + const mod = await graph.import(join(String(dir), "call.mjs")); + + expect(mod.run()).toEqual({ + strict: undefined, + sloppyIsGlobalThis: true, + viaCall: 7, + optionalCall: [undefined, undefined], + tagged: [undefined, ["a", "b"], [1]], + constructed: 5, + isInstance: true, + subclass: 6, + }); + }); + + test("closures capture the graph's variable, not its value", async () => { + using dir = tempDir("module-graph-globals-closures", { + "closures.mjs": ` + export const makeCounter = () => () => ++count; + export const makeReader = () => { const reader = () => count; return reader; }; + export const reset = () => { count = 0; }; + `, + }); + const file = join(String(dir), "closures.mjs"); + using a = new ModuleGraph({ globals: { count: 10 } }); + using b = new ModuleGraph({ globals: { count: 20 } }); + const inA = await a.import(file); + const inB = await b.import(file); + const [counterA1, counterA2, readerA, counterB] = [ + inA.makeCounter(), + inA.makeCounter(), + inA.makeReader(), + inB.makeCounter(), + ]; + + expect([counterA1(), counterA2(), counterA1(), readerA(), counterB()]).toEqual([11, 12, 13, 13, 21]); + inA.reset(); + expect([readerA(), counterA2(), counterB()]).toEqual([0, 1, 22]); + }); + + test("every kind of function body sees them", async () => { + using dir = tempDir("module-graph-globals-function-kinds", { + "kinds.mjs": ` + export function declaration() { return tenant; } + export const arrow = () => tenant; + export const nested = () => (() => (function () { return (() => tenant)(); })())(); + export class Klass { + field = tenant; + #hidden = tenant; + static staticField = tenant; + static { this.fromStaticBlock = tenant; } + method() { return tenant; } + get getter() { return tenant; } + get hidden() { return this.#hidden; } + static staticMethod() { return tenant; } + [tenant]() { return "computed key"; } + } + export const object = { method() { return tenant; }, get getter() { return tenant; } }; + export function* generator() { yield tenant; tenant = "set by the generator"; yield tenant; } + export async function asyncFunction() { await null; return tenant; } + export async function* asyncGenerator() { yield tenant; await null; yield tenant; } + export const asyncArrow = async () => { await null; return tenant; }; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "kinds.mjs")); + const instance = new mod.Klass(); + + expect({ + declaration: mod.declaration(), + arrow: mod.arrow(), + nested: mod.nested(), + klass: [ + instance.field, + instance.hidden, + mod.Klass.staticField, + mod.Klass.fromStaticBlock, + instance.method(), + instance.getter, + mod.Klass.staticMethod(), + instance.a(), + ], + object: [mod.object.method(), mod.object.getter], + asyncFunction: await mod.asyncFunction(), + asyncArrow: await mod.asyncArrow(), + asyncGenerator: await Array.fromAsync(mod.asyncGenerator()), + generator: [...mod.generator()], + afterGenerator: mod.arrow(), + }).toEqual({ + declaration: "a", + arrow: "a", + nested: "a", + klass: ["a", "a", "a", "a", "a", "a", "a", "computed key"], + object: ["a", "a"], + asyncFunction: "a", + asyncArrow: "a", + asyncGenerator: ["a", "a"], + generator: ["a", "set by the generator"], + afterGenerator: "set by the generator", + }); + }); + + test("direct eval sees them; indirect eval and `new Function` do not", async () => { + using dir = tempDir("module-graph-globals-eval", { + "evals.mjs": ` + export function direct(code) { return eval(code); } + export const indirect = code => (0, eval)(code); + export const viaGlobalThis = code => globalThis.eval(code); + export const viaFunction = code => new Function(code)(); + export const read = () => tenant; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "evals.mjs")); + + expect({ + direct: [mod.direct("tenant"), mod.direct("typeof tenant"), mod.direct("(() => tenant)()")], + indirect: [mod.indirect("typeof tenant"), mod.viaGlobalThis("typeof tenant")], + viaFunction: mod.viaFunction("return typeof tenant"), + }).toEqual({ + direct: ["a", "string", "a"], + indirect: ["undefined", "undefined"], + viaFunction: "undefined", + }); + expect(() => mod.indirect("tenant")).toThrow(ReferenceError); + expect(() => mod.viaGlobalThis("tenant")).toThrow(ReferenceError); + expect(() => mod.viaFunction("return tenant")).toThrow(ReferenceError); + expect(() => mod.viaFunction("tenant = 1")).not.toThrow(); + try { + // Sloppy-mode `new Function` code created a property of globalThis; the graph's variable is untouched. + expect([(globalThis as any).tenant, mod.read()]).toEqual([1, "a"]); + } finally { + delete (globalThis as any).tenant; + } + + // A `var` of a strict direct eval is local to that eval. + expect(mod.direct("var tenant = 'local to the eval'; tenant")).toBe("local to the eval"); + expect(mod.read()).toBe("a"); + + // Code created by a direct eval keeps resolving to the graph's variable. + const reader = mod.direct("() => tenant"); + expect(mod.direct("tenant = 'written by eval'")).toBe("written by eval"); + expect([reader(), mod.read()]).toEqual(["written by eval", "written by eval"]); + }); + + test("template literals, default parameters, shorthand, spread and optional chaining", async () => { + using dir = tempDir("module-graph-globals-expressions", { + "expressions.mjs": ` + export const template = () => \`\${greeting}, \${who}!\`; + export const defaults = (value = who, other = value + "!") => [value, other]; + export const destructuredDefault = ({ name = who } = {}) => name; + export const shorthand = () => ({ who, [who]: greeting }); + export const spread = () => [...list, ...list]; + export const spreadCall = () => Math.max(...list); + export const optional = () => [config?.nested?.value, config.missing?.value, nothing?.anything]; + export const comparisons = () => [who === "world", list instanceof Array, "nested" in config]; + `, + }); + using graph = new ModuleGraph({ + globals: { greeting: "hello", who: "world", list: [1, 2], config: { nested: { value: 1 } }, nothing: null }, + }); + const mod = await graph.import(join(String(dir), "expressions.mjs")); + + expect({ + template: mod.template(), + defaults: [mod.defaults(), mod.defaults("given")], + destructuredDefault: [mod.destructuredDefault(), mod.destructuredDefault({ name: "given" })], + shorthand: mod.shorthand(), + spread: mod.spread(), + spreadCall: mod.spreadCall(), + optional: mod.optional(), + comparisons: mod.comparisons(), + }).toEqual({ + template: "hello, world!", + defaults: [ + ["world", "world!"], + ["given", "given!"], + ], + destructuredDefault: ["world", "given"], + shorthand: { who: "world", world: "hello" }, + spread: [1, 2, 1, 2], + spreadCall: 2, + optional: [1, undefined, undefined], + comparisons: [true, true, true], + }); + }); + + test("`for` loops", async () => { + using dir = tempDir("module-graph-globals-loops", { + "loops.mjs": ` + export const run = () => { + const out = []; + for (index = 0; index < limit; index++) out.push(index); + for (const value of list) out.push(value); + const closures = []; + for (let i = 0; i < limit; i++) closures.push(() => i * limit); + out.push(...closures.map(closure => closure())); + let steps = 0; + while (index > 0) { index--; steps++; } + return { out, steps, index }; + }; + `, + }); + using graph = new ModuleGraph({ globals: { index: "unset", limit: 3, list: ["a", "b"] } }); + const mod = await graph.import(join(String(dir), "loops.mjs")); + + expect(mod.run()).toEqual({ out: [0, 1, 2, "a", "b", 0, 3, 6], steps: 3, index: 0 }); + }); + + test("a hot function shared by two graphs reads and writes its own graph's variables", async () => { + using dir = tempDir("module-graph-globals-hot", { + "hot.mjs": ` + export function hot(n) { + let sum = 0; + for (let i = 0; i < n; i++) { sum += step; counter++; } + return [sum, counter]; + } + export const setStep = value => { step = value; }; + `, + }); + const file = join(String(dir), "hot.mjs"); + using a = new ModuleGraph({ globals: { step: 1, counter: 0 } }); + using b = new ModuleGraph({ globals: { counter: 100, step: "s" } }); + const inA = await a.import(file); + const inB = await b.import(file); + + expect(inA.hot(200_000)).toEqual([200_000, 200_000]); + expect(inB.hot(3)).toEqual(["0sss", 103]); + expect(inA.hot(1)).toEqual([1, 200_001]); + inA.setStep(2.5); + expect(inA.hot(2)).toEqual([5, 200_003]); + expect(inB.hot(1)).toEqual(["0s", 104]); + }); + + test("they keep working after dispose()", async () => { + using dir = tempDir("module-graph-globals-disposed", { + "disposed.mjs": ` + export const read = () => tenant; + export const write = value => { tenant = value; }; + `, + }); + const graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "disposed.mjs")); + graph.dispose(); + + expect(mod.read()).toBe("a"); + mod.write("after dispose"); + expect(mod.read()).toBe("after dispose"); + }); + }); + + describe("every module of the graph sees them", () => { + test("static, dynamic, required, TypeScript and later-loaded modules share one variable", async () => { + using dir = tempDir("module-graph-globals-dependencies", { + "entry.mjs": ` + import * as staticDep from "./static-dep.mjs"; + import * as typed from "./typed-dep.ts"; + export { staticDep, typed }; + export const required = import.meta.require("./required-dep.mjs"); + export const loadLazy = () => import("./lazy-dep.mjs"); + export const read = () => tenant; + `, + "static-dep.mjs": `export const read = () => tenant; export const atEvaluation = tenant;`, + "typed-dep.ts": ` + declare let tenant: string; + export const read = (): string => tenant; + export const write = (value: string): void => { tenant = value; }; + `, + "required-dep.mjs": `export const read = () => tenant; export const atEvaluation = tenant;`, + "lazy-dep.mjs": `export const read = () => tenant; export const atEvaluation = tenant;`, + "later.mjs": `export const read = () => tenant; export const atEvaluation = tenant;`, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const entry = await graph.import(join(String(dir), "entry.mjs")); + entry.typed.write("b"); + const lazy = await entry.loadLazy(); + entry.typed.write("c"); + const later = await graph.import(join(String(dir), "later.mjs")); + entry.typed.write("d"); + + expect({ + atEvaluation: [ + entry.staticDep.atEvaluation, + entry.required.atEvaluation, + lazy.atEvaluation, + later.atEvaluation, + ], + now: [entry, entry.staticDep, entry.typed, entry.required, lazy, later].map(mod => mod.read()), + }).toEqual({ + atEvaluation: ["a", "a", "b", "c"], + now: ["d", "d", "d", "d", "d", "d"], + }); + }); + + test("top-level code reads them in evaluation order", async () => { + using dir = tempDir("module-graph-globals-evaluation-order", { + "order-entry.mjs": ` + import "./order-first.mjs"; + import "./order-second.mjs"; + log.push("entry sees " + stage); + stage = "entry ran"; + const lazy = await import("./order-lazy.mjs"); + log.push("entry after await sees " + stage); + export default lazy.default; + `, + "order-first.mjs": `log.push("first sees " + stage); stage = "first ran";`, + "order-second.mjs": `log.push("second sees " + stage); stage = "second ran";`, + "order-lazy.mjs": `log.push("lazy sees " + stage); stage = "lazy ran"; export default stage;`, + }); + const log: string[] = []; + using graph = new ModuleGraph({ globals: { log, stage: "nothing ran" } }); + const entry = await graph.import(join(String(dir), "order-entry.mjs")); + + expect(log).toEqual([ + "first sees nothing ran", + "second sees first ran", + "entry sees second ran", + "lazy sees entry ran", + "entry after await sees lazy ran", + ]); + expect(entry.default).toBe("lazy ran"); + }); + + test("`data:` and `blob:` modules", async () => { + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const url = URL.createObjectURL( + new Blob(["export default [tenant, typeof tenant];"], { type: "text/javascript" }), + ); + try { + expect((await graph.import(url)).default).toEqual(["a", "string"]); + } finally { + URL.revokeObjectURL(url); + } + expect((await graph.import("data:text/javascript,export default [tenant, typeof tenant];")).default).toEqual([ + "a", + "string", + ]); + }); + + test("TypeScript decorators", async () => { + using dir = tempDir("module-graph-globals-decorators", { + "decorated.ts": ` + declare const decorate: any; + @decorate + export class Decorated { + @decorate method() {} + } + `, + }); + const decorated: string[] = []; + using graph = new ModuleGraph({ + globals: { decorate: (target: any, key?: string) => void decorated.push(key ?? target.name) }, + }); + await graph.import(join(String(dir), "decorated.ts")); + + expect(decorated).toEqual(["method", "Decorated"]); + }); + + test("a hoisted function called across an import cycle reads them before its module evaluates", async () => { + using dir = tempDir("module-graph-globals-cycle", { + "cycle-a.mjs": ` + import { fromB } from "./cycle-b.mjs"; + export function hoisted() { return tenant; } + export function hoistedWrite(value) { tenant = value; } + export function readOwn() { return own; } + export let own = "a's own binding"; + export const afterB = tenant; + export { fromB }; + `, + "cycle-b.mjs": ` + import { hoisted, hoistedWrite, readOwn } from "./cycle-a.mjs"; + const before = hoisted(); + hoistedWrite("written by b through a"); + let ownBinding; + try { ownBinding = readOwn(); } catch (error) { ownBinding = error.name; } + export const fromB = { before, after: hoisted(), ownBinding }; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "cycle-a.mjs")); + + expect(mod.fromB).toEqual({ before: "a", after: "written by b through a", ownBinding: "ReferenceError" }); + expect(mod.afterB).toBe("written by b through a"); + }); + + test("a module using top-level await reads and writes them on both sides of the await", async () => { + using dir = tempDir("module-graph-globals-tla", { + "tla-globals.mjs": ` + export const before = tenant; + tenant = "set before the await"; + await Promise.resolve(); + export const after = tenant; + tenant = "set after the await"; + export const read = () => tenant; + `, + }); + const file = join(String(dir), "tla-globals.mjs"); + using a = new ModuleGraph({ globals: { tenant: "a" } }); + using b = new ModuleGraph({ globals: { tenant: "b" } }); + const [inA, inB] = await Promise.all([a.import(file), b.import(file)]); + + expect([inA.before, inA.after, inA.read()]).toEqual(["a", "set before the await", "set after the await"]); + expect([inB.before, inB.after, inB.read()]).toEqual(["b", "set before the await", "set after the await"]); + }); + }); + + describe("values", () => { + test("every kind of value arrives with its identity", async () => { + class HostClass {} + const values: Record = { + vNumber: 1.5, + vNegativeZero: -0, + vNaN: NaN, + vString: "s", + vEmptyString: "", + vTrue: true, + vFalse: false, + vUndefined: undefined, + vNull: null, + vSymbol: Symbol("local"), + vRegisteredSymbol: Symbol.for("module-graph-globals"), + vWellKnownSymbol: Symbol.iterator, + vBigInt: 2n ** 80n, + vObject: { nested: {} }, + vNullPrototype: Object.create(null), + vFrozen: Object.freeze({}), + vArray: [1, 2, 3], + vFunction() {}, + vArrow: () => {}, + vAsyncFunction: async () => {}, + vGeneratorFunction: function* () {}, + vBound: function () {}.bind(null), + vClass: HostClass, + vInstance: new HostClass(), + vMap: new Map(), + vWeakRef: new WeakRef({}), + vPromise: Promise.resolve(1), + vDate: new Date(0), + vRegExp: /x/g, + vError: new Error("e"), + vTypedArray: new Uint8Array(4), + vArrayBuffer: new ArrayBuffer(4), + vBuffer: Buffer.from("b"), + vResponseConstructor: Response, + vRequest: new Request("http://localhost/"), + vBlob: new Blob(["b"]), + vURL: new URL("http://localhost/"), + vHeaders: new Headers(), + vAbortController: new AbortController(), + vTextEncoder: new TextEncoder(), + vBunFile: Bun.file("/does/not/exist"), + vGlobalThis: globalThis, + vBun: Bun, + vProcess: process, + vModuleGraphConstructor: ModuleGraph, + }; + const names = Object.keys(values); + using dir = tempDir("module-graph-globals-values", { + "values.mjs": ` + export const read = () => ({ ${names.join(", ")} }); + export const types = () => ({ ${names.map(name => `${name}: typeof ${name}`).join(", ")} }); + `, + }); + using graph = new ModuleGraph({ globals: values }); + const mod = await graph.import(join(String(dir), "values.mjs")); + const read = mod.read(); + + expect(Object.fromEntries(names.map(name => [name, Object.is(read[name], values[name])]))).toEqual( + Object.fromEntries(names.map(name => [name, true])), + ); + expect(mod.types()).toEqual(Object.fromEntries(names.map(name => [name, typeof values[name]]))); + }); + + test("a name whose value is `undefined` is declared", async () => { + using dir = tempDir("module-graph-globals-undefined-value", { + "undefined-value.mjs": ` + export const read = () => [declared, typeof declared]; + export const write = () => { declared = "now defined"; return declared; }; + `, + }); + const file = join(String(dir), "undefined-value.mjs"); + using graph = new ModuleGraph({ globals: { declared: undefined } }); + using other = new ModuleGraph({ globals: { somethingElse: 1 } }); + const mod = await graph.import(file); + const inOther = await other.import(file); + + expect(mod.read()).toEqual([undefined, "undefined"]); + expect(mod.write()).toBe("now defined"); + expect(inOther.read).toThrow(ReferenceError); + expect(inOther.write).toThrow(ReferenceError); + }); + + test("a Proxy is passed through untouched", async () => { + using dir = tempDir("module-graph-globals-proxy-value", { + "proxy-value.mjs": ` + export const identity = () => proxied; + export const touch = () => [proxied.answer, "answer" in proxied, typeof proxied]; + `, + }); + const log: string[] = []; + const proxied = new Proxy( + { answer: 42 }, + { + get: (target, key, receiver) => (log.push(`get ${String(key)}`), Reflect.get(target, key, receiver)), + has: (target, key) => (log.push(`has ${String(key)}`), Reflect.has(target, key)), + ownKeys: target => (log.push("ownKeys"), Reflect.ownKeys(target)), + getOwnPropertyDescriptor: (target, key) => ( + log.push(`getOwnPropertyDescriptor ${String(key)}`), + Reflect.getOwnPropertyDescriptor(target, key) + ), + }, + ); + using graph = new ModuleGraph({ globals: { proxied } }); + const mod = await graph.import(join(String(dir), "proxy-value.mjs")); + + expect(mod.identity()).toBe(proxied); + expect(log).toEqual([]); + expect(mod.touch()).toEqual([42, true, "object"]); + expect(log).toEqual(["get answer", "has answer"]); + }); + + test("another ModuleGraph", async () => { + using dir = tempDir("module-graph-globals-graph-value", { + "outer.mjs": ` + // Not a tail call: a relative specifier resolves against the calling module's frame. + export const loadInner = async () => await inner.import("./leaf.mjs"); + export const loadOwn = () => import("./leaf.mjs"); + export const innerGraph = () => inner; + `, + "leaf.mjs": `export const types = () => [typeof tenant, typeof inner, typeof innerOnly];`, + }); + using inner = new ModuleGraph({ globals: { innerOnly: 1 } }); + using outer = new ModuleGraph({ globals: { tenant: "outer", inner } }); + const mod = await outer.import(join(String(dir), "outer.mjs")); + const leafInInner = await mod.loadInner(); + const leafInOuter = await mod.loadOwn(); + + expect(mod.innerGraph()).toBe(inner); + expect(leafInInner).not.toBe(leafInOuter); + expect(await inner.import(join(String(dir), "leaf.mjs"))).toBe(leafInInner); + expect(leafInInner.types()).toEqual(["undefined", "undefined", "number"]); + expect(leafInOuter.types()).toEqual(["string", "object", "undefined"]); + }); + + test("a graph can be given itself through a mutable value", async () => { + using dir = tempDir("module-graph-globals-self", { + "self.mjs": `export const loadAgain = () => self.graph.import(import.meta.path);`, + }); + const self: { graph?: unknown } = {}; + using graph = new ModuleGraph({ globals: { self } }); + self.graph = graph; + const mod = await graph.import(join(String(dir), "self.mjs")); + + expect(await mod.loadAgain()).toBe(mod); + }); + + test("a module namespace object", async () => { + using dir = tempDir("module-graph-globals-namespace-value", { + "ns-counter.mjs": ` + export let count = 0; + export const increment = () => ++count; + `, + "ns-user.mjs": ` + import * as own from "./ns-counter.mjs"; + export const incrementHosts = () => hostCounter.increment(); + export const counts = () => [hostCounter.count, own.count]; + export const isHosts = namespace => namespace === hostCounter; + export { own }; + `, + }); + const hostCounter = await import(join(String(dir), "ns-counter.mjs")); + using graph = new ModuleGraph({ globals: { hostCounter } }); + const mod = await graph.import(join(String(dir), "ns-user.mjs")); + + expect(mod.isHosts(hostCounter)).toBe(true); + expect(mod.own).not.toBe(hostCounter); + mod.incrementHosts(); + mod.incrementHosts(); + expect(mod.counts()).toEqual([2, 0]); + expect(hostCounter.count).toBe(2); + }); + + test("one object given to two graphs is one object, but two variables", async () => { + using dir = tempDir("module-graph-globals-shared-value", { + "shared-value.mjs": ` + export const mutate = value => { shared.value = value; }; + export const rebind = value => { shared = value; }; + export const read = () => shared; + `, + }); + const file = join(String(dir), "shared-value.mjs"); + const shared: { value?: string } = {}; + const globals = { shared }; + using a = new ModuleGraph({ globals }); + using b = new ModuleGraph({ globals }); + const inA = await a.import(file); + const inB = await b.import(file); + + inA.mutate("from a"); + expect(inB.read()).toBe(shared); + expect(shared.value).toBe("from a"); + inA.rebind("a's own"); + expect([inA.read(), inB.read(), globals.shared]).toEqual(["a's own", shared, shared]); + }); + }); + + describe("the `globals` object", () => { + const typesOf = (names: string[]) => + `export const types = () => ({ ${names.map(name => `${name}: typeof ${name}`).join(", ")} });`; + + test("only own enumerable string keys become names", async () => { + using dir = tempDir("module-graph-globals-own-enumerable", { + "own-enumerable.mjs": typesOf([ + "own", + "ownAccessor", + "hidden", + "hiddenAccessor", + "inherited", + "inheritedAccessor", + ]), + }); + const symbol = Symbol("symbolKey"); + const log: string[] = []; + const prototype = { + inherited: 1, + get inheritedAccessor() { + log.push("inheritedAccessor"); + return 1; + }, + }; + const globals = Object.create(prototype, { + own: { value: 1, enumerable: true }, + ownAccessor: { get: () => (log.push("ownAccessor"), 1), enumerable: true }, + hidden: { value: 1, enumerable: false }, + hiddenAccessor: { get: () => (log.push("hiddenAccessor"), 1), enumerable: false }, + [symbol]: { get: () => (log.push("symbol"), 1), enumerable: true }, + [Symbol.iterator]: { get: () => (log.push("Symbol.iterator"), 1), enumerable: true }, + }); + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "own-enumerable.mjs")); + + expect(mod.types()).toEqual({ + own: "number", + ownAccessor: "number", + hidden: "undefined", + hiddenAccessor: "undefined", + inherited: "undefined", + inheritedAccessor: "undefined", + }); + expect(log).toEqual(["ownAccessor"]); + }); + + test("getters run exactly once, at construction, in property order", async () => { + using dir = tempDir("module-graph-globals-getters", { + "getters.mjs": `export const read = () => [z, b, a, m, Z, _, aa]; export const write = () => { z = "written"; };`, + }); + const log: string[] = []; + const globals = {}; + for (const name of ["z", "b", "10", "a", "m", "2", "Z", "_", "é", "aa"]) { + Object.defineProperty(globals, name, { + enumerable: true, + get() { + log.push(name); + return "value of " + name; + }, + set() { + log.push("set " + name); + }, + }); + } + using graph = new ModuleGraph({ globals }); + // The order of Object.keys(): integer keys ascending, then strings in insertion order. + expect(log).toEqual(["2", "10", "z", "b", "a", "m", "Z", "_", "é", "aa"]); + + const mod = await graph.import(join(String(dir), "getters.mjs")); + mod.write(); + expect(mod.read()).toEqual( + ["written", "b", "a", "m", "Z", "_", "aa"].map((name, i) => (i ? "value of " + name : name)), + ); + expect(log).toHaveLength(10); + }); + + test("a getter that throws makes the constructor throw that error", () => { + const error = new Error("from the getter"); + const log: string[] = []; + const globals = { + get a() { + log.push("a"); + return 1; + }, + get b(): never { + log.push("b"); + throw error; + }, + get c() { + log.push("c"); + return 3; + }, + }; + + let thrown; + try { + new ModuleGraph({ globals }); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(error); + expect(log).toEqual(["a", "b"]); + // Anything can be thrown, not just errors. + try { + new ModuleGraph({ + globals: { + get x() { + throw 42; + }, + }, + }); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(42); + }); + + test("the `globals` option itself is read once", () => { + let reads = 0; + new ModuleGraph({ + get globals() { + reads++; + return { tenant: "a" }; + }, + }); + expect(reads).toBe(1); + + const error = new Error("from the option getter"); + expect( + () => + new ModuleGraph({ + get globals() { + throw error; + }, + }), + ).toThrow(error); + }); + + test("names are snapshotted before any getter runs", async () => { + using dir = tempDir("module-graph-globals-mutating-getter", { + "mutating-getter.mjs": `export const types = () => [typeof first, typeof removed, typeof added]; export const readRemoved = () => removed;`, + }); + const globals: Record = { + get first() { + delete globals.removed; + globals.added = 1; + return 1; + }, + removed: "still here when the names were read", + }; + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "mutating-getter.mjs")); + + // `removed` was a name when construction began, so it is declared; its value was read after the delete. + expect(mod.types()).toEqual(["number", "undefined", "undefined"]); + expect(mod.readRemoved()).toBeUndefined(); + }); + + test("is copied: later changes to it do not reach the graph, and the graph never writes to it", async () => { + using dir = tempDir("module-graph-globals-copied", { + "copied.mjs": ` + export const read = () => [kept, typeof addedLater, removedLater]; + export const write = () => { kept = "written by the graph"; removedLater = "also written"; }; + `, + }); + const file = join(String(dir), "copied.mjs"); + const sets: string[] = []; + let accessorValue = "accessor"; + const globals: Record = { + kept: "original", + get removedLater() { + return accessorValue; + }, + set removedLater(value: string) { + sets.push(value); + }, + }; + using graph = new ModuleGraph({ globals }); + + // Before the first import... + globals.kept = "changed by the host"; + globals.addedLater = 1; + accessorValue = "changed accessor"; + const mod = await graph.import(file); + expect(mod.read()).toEqual(["original", "undefined", "accessor"]); + + // ...and after it. + delete globals.removedLater; + Object.freeze(globals); + mod.write(); + expect(mod.read()).toEqual(["written by the graph", "undefined", "also written"]); + expect(globals).toEqual({ kept: "changed by the host", addedLater: 1 }); + expect(sets).toEqual([]); + }); + + test("is left as it was", () => { + const globals = { + a: 1, + get b() { + return 2; + }, + }; + const before = Object.getOwnPropertyDescriptors(globals); + new ModuleGraph({ globals }); + + expect(Object.getOwnPropertyDescriptors(globals)).toEqual(before); + expect([Object.isExtensible(globals), Object.isFrozen(globals)]).toEqual([true, false]); + }); + + test("`{}`, `undefined` and no options at all add nothing", async () => { + using dir = tempDir("module-graph-globals-empty", { + "empty.mjs": `export const probe = () => [typeof tenant, typeof globals, typeof onError];`, + }); + const file = join(String(dir), "empty.mjs"); + const graphs = [ + new ModuleGraph(), + new ModuleGraph(undefined), + new ModuleGraph({}), + new ModuleGraph({ globals: undefined }), + new ModuleGraph({ globals: {} }), + new ModuleGraph({ globals: Object.create({ tenant: "inherited" }) }), + new ModuleGraph({ globals: { [Symbol("tenant")]: 1 } }), + ]; + + for (const graph of graphs) { + expect((await graph.import(file)).probe()).toEqual(["undefined", "undefined", "undefined"]); + graph.dispose(); + } + }); + + test("may be a Proxy", async () => { + using dir = tempDir("module-graph-globals-proxy-object", { + "proxy-object.mjs": `export const read = () => [a, b, typeof hidden, typeof virtual];`, + }); + const log: string[] = []; + const target = Object.defineProperty({ a: 1, b: 2, [Symbol("s")]: 3 }, "hidden", { value: 4, enumerable: false }); + const globals = new Proxy(target, { + ownKeys: t => (log.push("ownKeys"), Reflect.ownKeys(t)), + getOwnPropertyDescriptor: (t, key) => ( + log.push(`getOwnPropertyDescriptor ${String(key)}`), + Reflect.getOwnPropertyDescriptor(t, key) + ), + get: (t, key, receiver) => ( + log.push(`get ${String(key)}`), + key === "a" ? "from the trap" : Reflect.get(t, key, receiver) + ), + has: (t, key) => (log.push(`has ${String(key)}`), Reflect.has(t, key)), + set: (t, key) => (log.push(`set ${String(key)}`), true), + }); + using graph = new ModuleGraph({ globals }); + const afterConstruction = [...log]; + const mod = await graph.import(join(String(dir), "proxy-object.mjs")); + + expect(mod.read()).toEqual(["from the trap", 2, "undefined", "undefined"]); + expect(afterConstruction).toEqual([ + "ownKeys", + "getOwnPropertyDescriptor a", + "getOwnPropertyDescriptor b", + "getOwnPropertyDescriptor hidden", + "get a", + "get b", + ]); + expect(log).toEqual(afterConstruction); + }); + + test("a Proxy whose trap throws makes the constructor throw that error", () => { + const error = new Error("from the trap"); + for (const trap of ["ownKeys", "getOwnPropertyDescriptor", "get"]) { + const globals = new Proxy( + { a: 1 }, + { + [trap]() { + throw error; + }, + }, + ); + expect(() => new ModuleGraph({ globals })).toThrow(error); + } + const revocable = Proxy.revocable({ a: 1 }, {}); + revocable.revoke(); + expect(() => new ModuleGraph({ globals: revocable.proxy })).toThrow(TypeError); + }); + + test("may be an array, a function, a class instance, a null-prototype object or a Map", async () => { + using dir = tempDir("module-graph-globals-object-kinds", { + "object-kinds.mjs": ` + export const read = () => typeof named === "undefined" ? undefined : named; + export const types = () => [typeof length, typeof prototype, typeof method, typeof accessor, typeof size]; + `, + }); + const file = join(String(dir), "object-kinds.mjs"); + class Instance { + named = "class instance"; + method() {} + get accessor() { + return 1; + } + } + const kinds = { + array: Object.assign(["element"], { named: "array" }), + function: Object.assign(function () {}, { named: "function" }), + instance: new Instance(), + nullPrototype: Object.assign(Object.create(null), { named: "null prototype" }), + map: Object.assign(new Map([["named", "a Map entry is not a property"]]), { named: "map" }), + plainArray: ["element"], + }; + + const results: Record = {}; + for (const [kind, globals] of Object.entries(kinds)) { + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(file); + results[kind] = [mod.read(), mod.types()]; + } + const noTypes = ["undefined", "undefined", "undefined", "undefined", "undefined"]; + expect(results).toEqual({ + array: ["array", noTypes], + function: ["function", noTypes], + instance: ["class instance", noTypes], + nullPrototype: ["null prototype", noTypes], + map: ["map", noTypes], + plainArray: [undefined, noTypes], + }); + }); + + test("may be globalThis", async () => { + using dir = tempDir("module-graph-globals-global-this", { + "global-this.mjs": ` + export const sameValues = () => fetch === globalThis.fetch && setTimeout === globalThis.setTimeout; + export const write = () => { fetch = "the graph's"; return [fetch, typeof globalThis.fetch]; }; + `, + }); + // `fetch` and `setTimeout` are enumerable properties of globalThis, so they become names of the graph. + const realFetch = fetch; + using graph = new ModuleGraph({ globals: globalThis }); + const mod = await graph.import(join(String(dir), "global-this.mjs")); + + expect(mod.sameValues()).toBe(true); + expect(mod.write()).toEqual(["the graph's", "function"]); + expect(globalThis.fetch).toBe(realFetch); + }); + + test("may be a module namespace object, unless one of its bindings is uninitialized", async () => { + using dir = tempDir("module-graph-globals-namespace-object", { + "namespace-globals.mjs": ` + import * as self from "./namespace-globals.mjs"; + export let early; + try { + new Bun.unsafe.ModuleGraph({ globals: self }); + early = "constructed"; + } catch (error) { + early = error.name; + } + export const tenant = "from the namespace"; + export const makeGraph = () => new Bun.unsafe.ModuleGraph({ globals: self }); + `, + "namespace-reader.mjs": `export const read = () => [tenant, early, typeof makeGraph];`, + }); + const host = await import(join(String(dir), "namespace-globals.mjs")); + using graph = host.makeGraph(); + const mod = await graph.import(join(String(dir), "namespace-reader.mjs")); + + expect(host.early).toBe("ReferenceError"); + expect(mod.read()).toEqual(["from the namespace", "ReferenceError", "function"]); + }); + + test.each([ + ["a number", 1], + ["zero", 0], + ["a string", "tenant"], + ["an empty string", ""], + ["null", null], + ["true", true], + ["false", false], + ["a symbol", Symbol("globals")], + ["a bigint", 1n], + ])("throws ERR_INVALID_ARG_TYPE when it is %s", (_, globals) => { + expect(() => new ModuleGraph({ globals })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE", name: "TypeError" }), + ); + }); + + test("works through a subclass of ModuleGraph", async () => { + using dir = tempDir("module-graph-globals-subclass", { + "subclass.mjs": `export const read = () => tenant;`, + }); + class TenantGraph extends ModuleGraph { + constructor(tenant: string) { + super({ globals: { tenant } }); + } + } + using graph = new TenantGraph("from the subclass") as any; + + expect(graph).toBeInstanceOf(TenantGraph); + expect((await graph.import(join(String(dir), "subclass.mjs"))).read()).toBe("from the subclass"); + }); + }); + + describe("names", () => { + test("names that are not identifiers are harmless", async () => { + using dir = tempDir("module-graph-globals-non-identifiers", { + "non-identifiers.mjs": ` + export const read = () => [valid, also_valid]; + export const onGlobalThis = names => names.filter(name => name in globalThis); + `, + }); + const odd = [ + "a b", + "a-b", + "", + "0", + "1", + "1.5", + "-1", + "with.dot", + "a;b", + "a\nb", + "a\0b", + "#private", + "@at", + "💥", + " ", + "var x", + ]; + const globals: Record = { valid: "v", also_valid: "av" }; + for (const name of odd) globals[name] = "odd"; + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "non-identifiers.mjs")); + + expect(mod.read()).toEqual(["v", "av"]); + expect(mod.onGlobalThis(odd)).toEqual([]); + }); + + test("unicode identifiers, escapes, `$` and `_`", async () => { + using dir = tempDir("module-graph-globals-unicode", { + "unicode.mjs": ` + export const read = () => [ñ, 日本語, 𠮷, ℮, ᚠ, $, _, $$, __, a‍b, π]; + export const escaped = () => [\\u0074enant, \\u{74}enant, tenant, \\u{20BB7}]; + export const write = () => { \\u00F1 = "written through an escape"; return ñ; }; + `, + }); + const globals = { + ñ: 1, + 日本語: 2, + "𠮷": 3, + "℮": 4, + ᚠ: 5, + $: 6, + _: 7, + $$: 8, + __: 9, + "a‍b": 10, + π: 11, + tenant: "t", + }; + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "unicode.mjs")); + + expect(mod.read()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + expect(mod.escaped()).toEqual(["t", "t", "t", 3]); + expect(mod.write()).toBe("written through an escape"); + }); + + test("names differing only in case or normalization are different names", async () => { + using dir = tempDir("module-graph-globals-case", { + "case.mjs": `export const read = () => [tenant, Tenant, TENANT, é, é];`, + }); + using graph = new ModuleGraph({ + globals: { tenant: 1, Tenant: 2, TENANT: 3, "é": "precomposed", "é": "decomposed" }, + }); + const mod = await graph.import(join(String(dir), "case.mjs")); + + expect(mod.read()).toEqual([1, 2, 3, "precomposed", "decomposed"]); + }); + + test("a very long name", async () => { + const name = "n" + Buffer.alloc(10_000, "x").toString(); + using dir = tempDir("module-graph-globals-long-name", { + "long-name.mjs": `export const read = () => ${name}; export const write = value => { ${name} = value; };`, + }); + using graph = new ModuleGraph({ globals: { [name]: "long" } }); + const mod = await graph.import(join(String(dir), "long-name.mjs")); + + expect(mod.read()).toBe("long"); + mod.write("still long"); + expect(mod.read()).toBe("still long"); + }); + + test("names of globalThis properties shadow them for the graph only", async () => { + const names = [ + "console", + "process", + "fetch", + "Bun", + "setTimeout", + "globalThis", + "Array", + "Object", + "Symbol", + "JSON", + "Math", + "Promise", + "Error", + "queueMicrotask", + ]; + using dir = tempDir("module-graph-globals-shadow-global-object", { + "shadow-global-object.mjs": ` + export const read = () => ({ ${names.join(", ")} }); + export const moduleAndExports = () => [module, exports]; + export const literals = () => [[].constructor === Array, ({}).constructor === Object]; + `, + }); + const file = join(String(dir), "shadow-global-object.mjs"); + const real = Object.fromEntries(names.map(name => [name, (globalThis as any)[name]])); + const descriptors = names.map(name => Object.getOwnPropertyDescriptor(globalThis, name)); + const globals = Object.fromEntries(names.map(name => [name, "the graph's " + name])); + using graph = new ModuleGraph({ + globals: { ...globals, module: "the graph's module", exports: "the graph's exports" }, + }); + using plain = new ModuleGraph({ globals: { unrelated: 1 } }); + const mod = await graph.import(file); + + expect(mod.read()).toEqual(globals); + expect(mod.moduleAndExports()).toEqual(["the graph's module", "the graph's exports"]); + // Literals do not go through the identifier. + expect(mod.literals()).toEqual([false, false]); + + // globalThis, the host's instance and a graph without those names are untouched. + expect(names.map(name => Object.getOwnPropertyDescriptor(globalThis, name))).toEqual(descriptors); + const inHost = (await import(file)).read(); + const inPlain = (await plain.import(file)).read(); + expect( + names.filter(name => !Object.is(inHost[name], real[name]) || !Object.is(inPlain[name], real[name])), + ).toEqual([]); + }); + + test("assigning to a shadowing name writes the graph's variable, not the globalThis property", async () => { + using dir = tempDir("module-graph-globals-shadow-write", { + "shadow-write.mjs": ` + export const write = () => { console = "replaced"; setTimeout = "replaced too"; return [console, setTimeout]; }; + export const viaGlobalThis = () => [typeof globalThis.console, typeof globalThis.setTimeout]; + `, + }); + const realConsole = console; + const realSetTimeout = setTimeout; + using graph = new ModuleGraph({ globals: { console: "the graph's", setTimeout: "the graph's" } }); + const mod = await graph.import(join(String(dir), "shadow-write.mjs")); + + expect(mod.write()).toEqual(["replaced", "replaced too"]); + expect(mod.viaGlobalThis()).toEqual(["object", "function"]); + expect([globalThis.console, globalThis.setTimeout]).toEqual([realConsole, realSetTimeout]); + }); + + // The language's three constant globals. The transpiler folds them and prints them, so a graph cannot rebind them. + test.each(["undefined", "NaN", "Infinity"])("`%s` cannot be a name", name => { + const log: string[] = []; + const globals = { + get before() { + log.push("before"); + return 1; + }, + [name]: "the graph's", + get after() { + log.push("after"); + return 2; + }, + }; + expect(() => new ModuleGraph({ globals })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE", message: expect.stringContaining(name) }), + ); + expect(log).toEqual(["before"]); + }); + + test("reserved words and contextual keywords as names", async () => { + using dir = tempDir("module-graph-globals-reserved", { + "reserved.mjs": ` + export const literals = function () { return [this, null, true, false, typeof import.meta, typeof new.target, typeof void 0]; }; + export const contextual = () => [async, of, get, set, from, as, target, meta, type, accessor]; + export const valid = () => tenant; + `, + }); + const reserved = [ + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + ]; + const contextual = ["async", "of", "get", "set", "from", "as", "target", "meta", "type", "accessor"]; + const globals: Record = { tenant: "a" }; + for (const name of [...reserved, ...contextual]) globals[name] = "the graph's " + name; + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "reserved.mjs")); + + expect(mod.literals.call(undefined)).toEqual([undefined, null, true, false, "object", "undefined", "undefined"]); + expect(mod.contextual()).toEqual(contextual.map(name => "the graph's " + name)); + expect(mod.valid()).toBe("a"); + }); + + test("`arguments` and `eval` as names", async () => { + using dir = tempDir("module-graph-globals-arguments-eval", { + "arguments-eval.mjs": ` + export const atTopLevel = arguments; + export const inArrow = () => arguments; + export function inFunction() { return [typeof arguments, arguments.length, (() => arguments[0])()]; } + export const evalIdentifier = () => eval; + export const callsEval = () => eval("1 + 1"); + `, + }); + const file = join(String(dir), "arguments-eval.mjs"); + const graphEval = (code: string) => "the graph's eval got " + code; + using graph = new ModuleGraph({ globals: { arguments: "the graph's arguments", eval: graphEval } }); + const mod = await graph.import(file); + + expect({ + atTopLevel: mod.atTopLevel, + inArrow: mod.inArrow(), + inFunction: mod.inFunction("first", "second"), + evalIdentifier: mod.evalIdentifier(), + callsEval: mod.callsEval(), + }).toEqual({ + atTopLevel: "the graph's arguments", + inArrow: "the graph's arguments", + inFunction: ["object", 2, "first"], + evalIdentifier: graphEval, + callsEval: "the graph's eval got 1 + 1", + }); + expect(globalThis.eval("1 + 1")).toBe(2); + }); + + test("`__proto__` and Object.prototype names", async () => { + using dir = tempDir("module-graph-globals-proto", { + "proto.mjs": ` + export const read = () => [__proto__, constructor, typeof hasOwnProperty, toString === Object.prototype.toString, typeof valueOf]; + export const literal = () => Object.getPrototypeOf({ __proto__: null }); + `, + }); + const globals = JSON.parse(`{ "__proto__": "the graph's __proto__", "constructor": "the graph's constructor" }`); + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "proto.mjs")); + + // The scope holding the names is not an object: it has no prototype chain of its own, so + // names it lacks still come from globalThis (which inherits from Object.prototype). + expect(mod.read()).toEqual(["the graph's __proto__", "the graph's constructor", "function", true, "function"]); + expect(mod.literal()).toBeNull(); + }); + + test("`require`, `__dirname` and `__filename` stay the module's own", async () => { + using dir = tempDir("module-graph-globals-dirname", { + "dirname.mjs": `export const read = () => [__dirname, __filename, typeof require, require("node:path").sep];`, + }); + const file = join(String(dir), "dirname.mjs"); + using graph = new ModuleGraph({ + globals: { __dirname: "the graph's", __filename: "the graph's", require: "the graph's" }, + }); + + const read = (await graph.import(file)).read(); + expect(read).toEqual((await import(file)).read()); + expect(read.slice(2)).toEqual(["function", sep]); + }); + }); + + describe("shadowing", () => { + test("a module's own top-level declarations and imports win in that module only", async () => { + const names = [ + "viaConst", + "viaLet", + "viaVar", + "viaFunction", + "viaClass", + "viaImport", + "viaDefaultImport", + "viaNamespaceImport", + "viaRenamedImport", + ]; + using dir = tempDir("module-graph-globals-shadowing", { + "shadower.mjs": ` + import { viaImport, original as viaRenamedImport } from "./shadow-exports.mjs"; + import viaDefaultImport from "./shadow-exports.mjs"; + import * as viaNamespaceImport from "./shadow-exports.mjs"; + const viaConst = "module's const"; + let viaLet = "module's let"; + var viaVar = "module's var"; + function viaFunction() {} + class viaClass {} + export const read = () => ({ ${names.join(", ")} }); + export const write = () => { viaLet = "module's let, written"; viaVar = "module's var, written"; }; + export { read as readOther, write as writeOther } from "./bystander.mjs"; + `, + "shadow-exports.mjs": ` + export const viaImport = "imported"; + export const original = "renamed import"; + export default "default import"; + `, + "bystander.mjs": ` + export const read = () => ({ ${names.join(", ")} }); + export const write = () => { viaLet = "graph's, written by the bystander"; }; + `, + }); + const globals = Object.fromEntries(names.map(name => [name, "graph's " + name])); + using graph = new ModuleGraph({ globals }); + const mod = await graph.import(join(String(dir), "shadower.mjs")); + + const own = mod.read(); + expect({ + ...own, + viaFunction: typeof own.viaFunction, + viaClass: typeof own.viaClass, + viaNamespaceImport: own.viaNamespaceImport.default, + }).toEqual({ + viaConst: "module's const", + viaLet: "module's let", + viaVar: "module's var", + viaFunction: "function", + viaClass: "function", + viaImport: "imported", + viaDefaultImport: "default import", + viaNamespaceImport: "default import", + viaRenamedImport: "renamed import", + }); + expect(mod.readOther()).toEqual(globals); + + mod.write(); + mod.writeOther(); + expect([mod.read().viaLet, mod.read().viaVar]).toEqual(["module's let, written", "module's var, written"]); + expect(mod.readOther()).toEqual({ ...globals, viaLet: "graph's, written by the bystander" }); + }); + + test("a module's own binding in its temporal dead zone does not fall through to the graph's", async () => { + using dir = tempDir("module-graph-globals-tdz", { + "tdz.mjs": ` + export function read() { return shadowed; } + let early; + try { early = read(); } catch (error) { early = error.name; } + export { early }; + export let shadowed = "module's"; + export const hoistedVar = (() => viaVar)(); + export var viaVar = "module's var"; + `, + }); + using graph = new ModuleGraph({ globals: { shadowed: "graph's", viaVar: "graph's" } }); + const mod = await graph.import(join(String(dir), "tdz.mjs")); + + expect([mod.early, mod.read(), mod.hoistedVar]).toEqual(["ReferenceError", "module's", undefined]); + }); + + test("parameters, block bindings, catch bindings and function names shadow locally", async () => { + using dir = tempDir("module-graph-globals-local-shadowing", { + "local-shadowing.mjs": ` + export const parameter = tenant => tenant; + export const defaulted = (tenant = "default") => tenant; + export const destructured = ({ tenant }) => tenant; + export const block = () => { const out = []; { let tenant = "block"; out.push(tenant); } out.push(tenant); return out; }; + export const loop = () => { const out = []; for (const tenant of ["loop"]) out.push(tenant); out.push(tenant); return out; }; + export const caught = () => { try { throw "caught"; } catch (tenant) { return [tenant, (() => tenant)()]; } }; + export const named = function tenant() { return typeof tenant; }; + export const klass = () => { class tenant { static self() { return tenant; } } return tenant.self() === tenant; }; + export const hoistedVar = () => { const before = tenant; var tenant = "function var"; return [before, tenant]; }; + export const writeLocal = tenant => { tenant = "written to the parameter"; return tenant; }; + export const read = () => tenant; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "graph's" } }); + const mod = await graph.import(join(String(dir), "local-shadowing.mjs")); + + expect({ + parameter: mod.parameter("parameter"), + defaulted: [mod.defaulted(), mod.defaulted("given")], + destructured: mod.destructured({ tenant: "destructured" }), + block: mod.block(), + loop: mod.loop(), + caught: mod.caught(), + named: mod.named(), + klass: mod.klass(), + hoistedVar: mod.hoistedVar(), + writeLocal: mod.writeLocal("x"), + read: mod.read(), + }).toEqual({ + parameter: "parameter", + defaulted: ["default", "given"], + destructured: "destructured", + block: ["block", "graph's"], + loop: ["loop", "graph's"], + caught: ["caught", "caught"], + named: "function", + klass: true, + hoistedVar: [undefined, "function var"], + writeLocal: "written to the parameter", + read: "graph's", + }); + }); + + test("a graph global cannot be exported by name, as in the host", async () => { + using dir = tempDir("module-graph-globals-export-by-name", { + "export-by-name.mjs": `export { tenant };`, + "export-default.mjs": `export { tenant as default };`, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + + const inHost = await import(join(String(dir), "export-by-name.mjs")).catch(error => error); + const inGraph = await graph.import(join(String(dir), "export-by-name.mjs")).catch((error: unknown) => error); + expect(inHost).toBeInstanceOf(Error); + expect(inGraph.message).toBe(inHost.message); + expect(graph.import(join(String(dir), "export-default.mjs"))).rejects.toThrow(); + }); + + test("a module may export a binding with a graph global's name", async () => { + using dir = tempDir("module-graph-globals-export-same-name", { + "exporter.mjs": `export let tenant = "exported"; export const setTenant = value => { tenant = value; };`, + "importer.mjs": ` + import * as exporter from "./exporter.mjs"; + export const read = () => [tenant, exporter.tenant]; + export const setBoth = value => { tenant = value + " (graph's)"; exporter.setTenant(value + " (export)"); }; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "graph's" } }); + const mod = await graph.import(join(String(dir), "importer.mjs")); + + expect(mod.read()).toEqual(["graph's", "exported"]); + mod.setBoth("changed"); + expect(mod.read()).toEqual(["changed (graph's)", "changed (export)"]); + }); + }); + + describe("isolation", () => { + test("they are not properties of globalThis", async () => { + using dir = tempDir("module-graph-globals-not-on-global-object", { + "not-on-global-object.mjs": ` + export const probe = () => ({ + in: "tenant" in globalThis, + value: globalThis.tenant, + hasOwn: Object.hasOwn(globalThis, "tenant"), + reflectHas: Reflect.has(globalThis, "tenant"), + descriptor: Object.getOwnPropertyDescriptor(globalThis, "tenant"), + viaSelf: typeof self === "undefined" ? undefined : self.tenant, + }); + export const write = () => { tenant = "written"; }; + `, + }); + const keysBefore = Object.keys(globalThis); + const namesBefore = Object.getOwnPropertyNames(globalThis); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "not-on-global-object.mjs")); + mod.write(); + + const absent = { + in: false, + value: undefined, + hasOwn: false, + reflectHas: false, + descriptor: undefined, + viaSelf: undefined, + }; + expect(mod.probe()).toEqual(absent); + expect("tenant" in globalThis).toBe(false); + expect((globalThis as any).tenant).toBeUndefined(); + expect(Object.keys(globalThis)).toEqual(keysBefore); + expect(Object.getOwnPropertyNames(globalThis)).toEqual(namesBefore); + }); + + test("a globalThis property of the same name is independent of the graph's variable", async () => { + using dir = tempDir("module-graph-globals-vs-global-property", { + "vs-global-property.mjs": ` + export const read = () => [moduleGraphGlobalsTestKey, globalThis.moduleGraphGlobalsTestKey]; + export const writeVariable = value => { moduleGraphGlobalsTestKey = value; }; + export const writeProperty = value => { globalThis.moduleGraphGlobalsTestKey = value; }; + `, + }); + const file = join(String(dir), "vs-global-property.mjs"); + using graph = new ModuleGraph({ globals: { moduleGraphGlobalsTestKey: "graph's" } }); + using plain = new ModuleGraph(); + const mod = await graph.import(file); + const inPlain = await plain.import(file); + // The plain graph's reference is unresolvable until the property exists. + expect(mod.read()).toEqual(["graph's", undefined]); + expect(inPlain.read).toThrow(ReferenceError); + try { + (globalThis as any).moduleGraphGlobalsTestKey = "property"; + expect([mod.read(), inPlain.read()]).toEqual([ + ["graph's", "property"], + ["property", "property"], + ]); + + mod.writeVariable("graph's, written"); + mod.writeProperty("property, written by the graph"); + inPlain.writeVariable("property, written by the plain graph"); + expect([mod.read(), inPlain.read()]).toEqual([ + ["graph's, written", "property, written by the plain graph"], + ["property, written by the plain graph", "property, written by the plain graph"], + ]); + } finally { + delete (globalThis as any).moduleGraphGlobalsTestKey; + } + expect(mod.read()).toEqual(["graph's, written", undefined]); + }); + + test("the host's instance of the same file does not see them", async () => { + using dir = tempDir("module-graph-globals-host-instance", { + "host-instance.mjs": ` + export const read = () => tenant; + export const type = () => typeof tenant; + export const write = () => { tenant = "written"; }; + `, + }); + const file = join(String(dir), "host-instance.mjs"); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const inGraph = await graph.import(file); + const host = await import(file); + + expect([inGraph.read(), inGraph.type(), host.type()]).toEqual(["a", "string", "undefined"]); + expect(host.read).toThrow(ReferenceError); + expect(host.write).toThrow(ReferenceError); + inGraph.write(); + expect([inGraph.read(), host.type()]).toEqual(["written", "undefined"]); + }); + + test("a module that fails in the host for want of a name loads in a graph that has it", async () => { + using dir = tempDir("module-graph-globals-top-level-reference", { + "top-level-reference.mjs": `export const atEvaluation = tenant;`, + }); + const file = join(String(dir), "top-level-reference.mjs"); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + using without = new ModuleGraph({ globals: { other: "b" } }); + + expect(import(file)).rejects.toBeInstanceOf(ReferenceError); + expect(without.import(file)).rejects.toBeInstanceOf(ReferenceError); + expect((await graph.import(file)).atEvaluation).toBe("a"); + }); + + test("CommonJS modules reached from the graph do not see them", async () => { + using dir = tempDir("module-graph-globals-commonjs", { + "uses-commonjs.mjs": ` + import { createRequire } from "node:module"; + import imported from "./shared-commonjs.cjs"; + export { imported }; + export const viaImportMetaRequire = import.meta.require("./shared-commonjs.cjs"); + export const viaCreateRequire = createRequire(import.meta.url)("./shared-commonjs.cjs"); + export const read = () => tenant; + `, + "shared-commonjs.cjs": ` + exports.type = () => typeof tenant; + exports.read = () => tenant; + exports.typeAtEvaluation = typeof tenant; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const mod = await graph.import(join(String(dir), "uses-commonjs.mjs")); + + expect(mod.read()).toBe("a"); + expect(mod.viaImportMetaRequire).toBe(mod.imported); + expect(mod.viaCreateRequire).toBe(mod.imported); + expect([mod.imported.type(), mod.imported.typeAtEvaluation]).toEqual(["undefined", "undefined"]); + expect(mod.imported.read).toThrow(ReferenceError); + }); + + test("scoping is lexical: host functions the graph calls do not see them", async () => { + using dir = tempDir("module-graph-globals-lexical", { + "lexical.mjs": ` + export const callHost = () => hostProbe(); + export const callback = fn => fn(); + `, + }); + using graph = new ModuleGraph({ + globals: { tenant: "a", hostProbe: () => typeof (globalThis as any).tenant + "/" + eval("typeof tenant") }, + }); + const mod = await graph.import(join(String(dir), "lexical.mjs")); + + expect(mod.callHost()).toBe("undefined/undefined"); + expect(mod.callback(() => eval("typeof tenant"))).toBe("undefined"); + }); + + test("a global lexical declaration made later by a script sits behind the graph's names", async () => { + using dir = tempDir("module-graph-globals-global-lexical", { + "lexical-reader.mjs": ` + export const read = () => { try { return [tenant, lateLexical]; } catch (error) { return error.name; } }; + export const write = value => { tenant = value; }; + export function hot(n) { let sum = 0; for (let i = 0; i < n; i++) sum += typeof lateLexical === "number" ? lateLexical : 0; return sum; } + `, + "main.mjs": ` + import vm from "node:vm"; + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/lexical-reader.mjs"; + const withTenant = await new ModuleGraph({ globals: { tenant: "graph's" } }).import(file); + const plain = await new ModuleGraph().import(file); + const host = await import(file); + const instances = [withTenant, plain, host]; + + const before = instances.map(instance => [instance.read(), instance.hot(20000)]); + new vm.Script("let lateLexical = 1; let tenant = 'script';").runInThisContext(); + const after = instances.map(instance => [instance.read(), instance.hot(20000)]); + withTenant.write("graph's, written"); + plain.write("script's, written by the plain graph"); + const afterWrites = instances.map(instance => instance.read()[0]); + const shadowing = await new ModuleGraph({ globals: { lateLexical: "graph's" } }).import(file); + + console.log(JSON.stringify({ + before, + after, + afterWrites, + script: new vm.Script("tenant").runInThisContext(), + shadowing: [shadowing.read(), shadowing.hot(10)], + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + before: [ + ["ReferenceError", 0], + ["ReferenceError", 0], + ["ReferenceError", 0], + ], + after: [ + [["graph's", 1], 20000], + [["script", 1], 20000], + [["script", 1], 20000], + ], + afterWrites: [ + "graph's, written", + "script's, written by the plain graph", + "script's, written by the plain graph", + ], + script: "script's, written by the plain graph", + shadowing: [["script's, written by the plain graph", "graph's"], 0], + }); + expect(exitCode).toBe(0); + }); + + test("a graph without a name does not see another graph's", async () => { + using dir = tempDir("module-graph-globals-other-graph", { + "other-graph.mjs": ` + export const read = () => tenant; + export const types = () => [typeof tenant, typeof region]; + `, + }); + const file = join(String(dir), "other-graph.mjs"); + using both = new ModuleGraph({ globals: { tenant: "a", region: "us" } }); + using regionOnly = new ModuleGraph({ globals: { region: "eu" } }); + using none = new ModuleGraph(); + const inBoth = await both.import(file); + const inRegionOnly = await regionOnly.import(file); + const inNone = await none.import(file); + + expect([inBoth.types(), inRegionOnly.types(), inNone.types()]).toEqual([ + ["string", "string"], + ["undefined", "string"], + ["undefined", "undefined"], + ]); + expect(inBoth.read()).toBe("a"); + expect(inRegionOnly.read).toThrow(ReferenceError); + expect(inNone.read).toThrow(ReferenceError); + }); + + test("graphs with the same names in a different order behave identically", async () => { + using dir = tempDir("module-graph-globals-order", { + "order.mjs": ` + export const read = () => ({ x, y, z }); + export const rotate = () => { [x, y, z] = [y, z, x]; }; + `, + }); + const file = join(String(dir), "order.mjs"); + using a = new ModuleGraph({ globals: { x: "ax", y: "ay", z: "az" } }); + using b = new ModuleGraph({ globals: { z: "bz", y: "by", x: "bx" } }); + using c = new ModuleGraph({ globals: { y: "cy", z: "cz", x: "cx" } }); + const [inA, inB, inC] = [await a.import(file), await b.import(file), await c.import(file)]; + + expect([inA.read(), inB.read(), inC.read()]).toEqual([ + { x: "ax", y: "ay", z: "az" }, + { x: "bx", y: "by", z: "bz" }, + { x: "cx", y: "cy", z: "cz" }, + ]); + inB.rotate(); + expect([inA.read(), inB.read(), inC.read()]).toEqual([ + { x: "ax", y: "ay", z: "az" }, + { x: "by", y: "bz", z: "bx" }, + { x: "cx", y: "cy", z: "cz" }, + ]); + }); + + test("one name being a prefix or superset of another graph's names", async () => { + using dir = tempDir("module-graph-globals-name-sets", { + "name-sets.mjs": `export const types = () => [typeof a, typeof ab, typeof b, typeof a1b];`, + }); + const file = join(String(dir), "name-sets.mjs"); + const sets: Record[] = [ + { a: 1, b: 1 }, + { ab: 1 }, + { a: 1, ab: 1, b: 1 }, + { a1b: 1 }, + { "a:1": 1, b: 1 }, + { a: 1 }, + ]; + + const results = []; + for (const globals of sets) { + using graph = new ModuleGraph({ globals }); + results.push((await graph.import(file)).types()); + } + const [n, u] = ["number", "undefined"]; + expect(results).toEqual([ + [n, u, n, u], + [u, n, u, u], + [n, n, n, u], + [u, u, u, n], + [u, u, n, u], + [n, u, u, u], + ]); + }); + }); + + describe("scale", () => { + test("500 names", async () => { + const names = Array.from({ length: 500 }, (_, i) => `name${i}`); + using dir = tempDir("module-graph-globals-many-names", { + "many-names.mjs": ` + export const read = () => [${names.join(", ")}]; + export const bump = () => { ${names.map(name => `${name}++;`).join(" ")} }; + `, + }); + const file = join(String(dir), "many-names.mjs"); + using a = new ModuleGraph({ globals: Object.fromEntries(names.map((name, i) => [name, i])) }); + using b = new ModuleGraph({ globals: Object.fromEntries(names.toReversed().map((name, i) => [name, -i])) }); + const inA = await a.import(file); + const inB = await b.import(file); + inA.bump(); + + expect(inA.read()).toEqual(names.map((_, i) => i + 1)); + expect(inB.read()).toEqual(names.map((_, i) => -(499 - i))); + }); + + test("100 graphs keep their own values across a full collection", async () => { + using dir = tempDir("module-graph-globals-many-graphs", { + "many-graphs.mjs": ` + export const read = () => [id, payload.id, typeof only]; + export const bump = () => { id += 1000; }; + `, + }); + const file = join(String(dir), "many-graphs.mjs"); + async function load(id: number) { + // Every tenth graph has a different set of names, so not all of them share one layout. + const graph = new ModuleGraph({ globals: { id, payload: { id }, ...(id % 10 === 0 ? { only: true } : {}) } }); + return await graph.import(file); + } + const instances = []; + for (let id = 0; id < 100; id++) instances.push(await load(id)); + Bun.gc(true); + for (const instance of instances) if (instance.read()[0] % 2) instance.bump(); + Bun.gc(true); + + expect(instances.map(instance => instance.read())).toEqual( + Array.from({ length: 100 }, (_, id) => [id % 2 ? id + 1000 : id, id, id % 10 === 0 ? "boolean" : "undefined"]), + ); + }); + + test("a set of names can be used again after every graph that had it is collected", async () => { + using dir = tempDir("module-graph-globals-names-reused", { + "names-reused.mjs": ` + export function sum(n) { let total = 0; for (let i = 0; i < n; i++) total += reusedName; return total; } + `, + }); + const file = join(String(dir), "names-reused.mjs"); + async function round(value: number) { + const graph = new ModuleGraph({ globals: { reusedName: value } }); + return (await graph.import(file)).sum(10_000); + } + + const sums = []; + for (let value = 0; value < 5; value++) { + sums.push(await round(value)); + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + Bun.gc(true); + } + expect(sums).toEqual([0, 10_000, 20_000, 30_000, 40_000]); + }); + + test("values are kept alive by the graph's code alone", async () => { + using dir = tempDir("module-graph-globals-keeps-values-alive", { + "keeps-values-alive.mjs": `export const read = () => payload.text;`, + }); + const file = join(String(dir), "keeps-values-alive.mjs"); + async function load() { + const graph = new ModuleGraph({ globals: { payload: { text: Buffer.alloc(1024, "p").toString() } } }); + return (await graph.import(file)).read; + } + const read = await load(); + Bun.gc(true); + + expect(read()).toBe(Buffer.alloc(1024, "p").toString()); + }); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-import-meta.test.ts b/test/js/bun/module-graph/module-graph-import-meta.test.ts new file mode 100644 index 000000000000..9322726e01e5 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-import-meta.test.ts @@ -0,0 +1,1283 @@ +import { describe, expect, test } from "bun:test"; +import { isWindows, tempDir } from "harness"; +import { symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const { ModuleGraph } = Bun.unsafe as any; + +// A graph whose modules can reach the graph itself as `holder.graph`. +function graphWithHolder() { + const holder: any = {}; + holder.graph = new ModuleGraph({ globals: { holder } }); + return holder; +} + +const rejection = (promise: Promise) => + promise.then( + () => "resolved", + error => ({ name: error?.name, code: error?.code, specifier: error?.specifier, referrer: error?.referrer }), + ); + +describe("import.meta in a graph", () => { + const strings = ` + export const strings = () => ({ + url: import.meta.url, + path: import.meta.path, + dir: import.meta.dir, + dirname: import.meta.dirname, + file: import.meta.file, + filename: import.meta.filename, + }); + `; + + test("url, path, dir, dirname, file and filename are the host's strings", async () => { + using dir = tempDir("module-graph-meta-strings", { "sub/strings.mjs": strings }); + const file = join(String(dir), "sub", "strings.mjs"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).strings(); + expect(inGraph).toEqual({ + url: pathToFileURL(file).href, + path: file, + dir: join(String(dir), "sub"), + dirname: join(String(dir), "sub"), + file: "strings.mjs", + filename: file, + }); + expect(inGraph).toEqual((await import(file)).strings()); + }); + + test("a path that needs percent-encoding has the host's url", async () => { + using dir = tempDir("module-graph-meta-encoded", { "with space é.mjs": strings }); + const file = join(String(dir), "with space é.mjs"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).strings(); + expect(inGraph).toEqual({ + url: pathToFileURL(file).href, + path: file, + dir: String(dir), + dirname: String(dir), + file: "with space é.mjs", + filename: file, + }); + expect(inGraph).toEqual((await import(file)).strings()); + }); + + test("a TypeScript module has the host's strings", async () => { + using dir = tempDir("module-graph-meta-typescript", { + "typed.ts": ` + export const strings = (): Record => ({ + url: import.meta.url, + path: import.meta.path, + file: import.meta.file, + }); + `, + }); + const file = join(String(dir), "typed.ts"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).strings(); + expect(inGraph).toEqual({ url: pathToFileURL(file).href, path: file, file: "typed.ts" }); + expect(inGraph).toEqual((await import(file)).strings()); + }); + + test("env, require, resolve and resolveSync have the host's types", async () => { + using dir = tempDir("module-graph-meta-types", { + "types.mjs": ` + export const types = () => ({ + env: typeof import.meta.env, + require: typeof import.meta.require, + requireResolve: typeof import.meta.require.resolve, + resolve: typeof import.meta.resolve, + resolveSync: typeof import.meta.resolveSync, + main: typeof import.meta.main, + }); + `, + }); + const file = join(String(dir), "types.mjs"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).types(); + expect(inGraph).toEqual({ + env: "object", + require: "function", + requireResolve: "function", + resolve: "function", + resolveSync: "function", + main: "boolean", + }); + expect(inGraph).toEqual((await import(file)).types()); + }); + + test("is a distinct object per graph per module", async () => { + using dir = tempDir("module-graph-meta-distinct", { + "distinct-dep.mjs": `export const meta = import.meta;`, + "distinct-main.mjs": ` + export { meta as depMeta } from "./distinct-dep.mjs"; + export const meta = import.meta; + `, + }); + const file = join(String(dir), "distinct-main.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const namespaces = [await a.import(file), await b.import(file), await import(file)]; + const metas = namespaces.flatMap(ns => [ns.meta, ns.depMeta]); + + expect(new Set(metas).size).toBe(6); + expect(metas.map(meta => meta.url)).toEqual([ + pathToFileURL(file).href, + pathToFileURL(join(String(dir), "distinct-dep.mjs")).href, + pathToFileURL(file).href, + pathToFileURL(join(String(dir), "distinct-dep.mjs")).href, + pathToFileURL(file).href, + pathToFileURL(join(String(dir), "distinct-dep.mjs")).href, + ]); + }); + + test("is the same object every time one module instance reads it", async () => { + using dir = tempDir("module-graph-meta-stable", { + "stable.mjs": ` + export const meta = import.meta; + export const sameInOneExpression = import.meta === import.meta; + export function read() { return import.meta; } + export const readInCallback = () => [1].map(() => import.meta)[0]; + `, + }); + const file = join(String(dir), "stable.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + expect({ + sameInOneExpression: inGraph.sameInOneExpression, + read: inGraph.read() === inGraph.meta, + readAgain: inGraph.read() === inGraph.read(), + readInCallback: inGraph.readInCallback() === inGraph.meta, + reimported: (await graph.import(file)).meta === inGraph.meta, + }).toEqual({ sameInOneExpression: true, read: true, readAgain: true, readInCallback: true, reimported: true }); + }); + + test("properties assigned to it stay in that instance", async () => { + using dir = tempDir("module-graph-meta-assign", { + "assign.mjs": ` + export const tag = value => { import.meta.tag = value; }; + export const read = () => import.meta.tag; + export const keys = () => Object.keys(import.meta); + `, + }); + const file = join(String(dir), "assign.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(file); + const inB = await b.import(file); + const host = await import(file); + + inA.tag("a"); + expect([inA.read(), inB.read(), host.read()]).toEqual(["a", undefined, undefined]); + inB.tag("b"); + host.tag("host"); + expect([inA.read(), inB.read(), host.read()]).toEqual(["a", "b", "host"]); + expect([inA.keys(), inB.keys(), host.keys()]).toEqual([["tag"], ["tag"], ["tag"]]); + }); + + test("overwriting a builtin property affects that instance only", async () => { + using dir = tempDir("module-graph-meta-overwrite", { + "overwrite.mjs": ` + export const overwrite = () => { import.meta.url = "overwritten"; }; + export const read = () => import.meta.url; + `, + }); + const file = join(String(dir), "overwrite.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(file); + const inB = await b.import(file); + const host = await import(file); + + host.overwrite(); + const hostBehaviour = host.read(); + inA.overwrite(); + expect([inA.read(), inB.read()]).toEqual([hostBehaviour, pathToFileURL(file).href]); + }); + + test("env is process.env", async () => { + using dir = tempDir("module-graph-meta-env", { + "env.mjs": ` + export const env = () => import.meta.env; + export const read = key => import.meta.env[key]; + export const write = (key, value) => { import.meta.env[key] = value; }; + `, + }); + const file = join(String(dir), "env.mjs"); + const key = "MODULE_GRAPH_IMPORT_META_ENV_TEST"; + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + try { + expect(inGraph.env()).toBe(process.env); + expect(inGraph.env()).toBe(host.env()); + + inGraph.write(key, "from the graph"); + expect([process.env[key], host.read(key)]).toEqual(["from the graph", "from the graph"]); + process.env[key] = "from the host"; + expect(inGraph.read(key)).toBe("from the host"); + } finally { + delete process.env[key]; + } + expect(inGraph.read(key)).toBeUndefined(); + }); + + test("of a module loaded by import() inside the graph belongs to the graph", async () => { + using dir = tempDir("module-graph-meta-dynamic", { + "dynamic-dep.mjs": `export const meta = import.meta;`, + "dynamic-main.mjs": ` + export async function load() { return await import("./dynamic-dep.mjs"); } + `, + }); + const file = join(String(dir), "dynamic-main.mjs"); + const dep = join(String(dir), "dynamic-dep.mjs"); + + using graph = new ModuleGraph(); + const loaded = await (await graph.import(file)).load(); + const hostLoaded = await (await import(file)).load(); + + expect(loaded.meta).toBe((await graph.import(dep)).meta); + expect(loaded.meta).not.toBe(hostLoaded.meta); + expect({ url: loaded.meta.url, main: loaded.meta.main, hostUrl: hostLoaded.meta.url }).toEqual({ + url: pathToFileURL(dep).href, + main: false, + hostUrl: pathToFileURL(dep).href, + }); + }); + + test("of a module loaded by import.meta.require() belongs to the graph", async () => { + using dir = tempDir("module-graph-meta-required", { + "required-dep.mjs": `export const meta = import.meta;`, + "required-main.mjs": ` + export function load() { return import.meta.require("./required-dep.mjs"); } + `, + }); + const file = join(String(dir), "required-main.mjs"); + const dep = join(String(dir), "required-dep.mjs"); + + using graph = new ModuleGraph(); + const required = (await graph.import(file)).load(); + const host = await import(dep); + + expect(required.meta).toBe((await graph.import(dep)).meta); + expect(required.meta).not.toBe(host.meta); + expect([required.meta.url, required.meta.main]).toEqual([host.meta.url, false]); + }); + + test("is the graph's when first read by a function the host calls later", async () => { + using dir = tempDir("module-graph-meta-late", { + "late.mjs": ` + export function late() { + return { meta: import.meta, url: import.meta.url, main: import.meta.main }; + } + `, + "late-other.mjs": `export {};`, + }); + const file = join(String(dir), "late.mjs"); + + const graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + await graph.import(join(String(dir), "late-other.mjs")); + graph.dispose(); + Bun.gc(true); + + const fromGraph = inGraph.late(); + const fromHost = host.late(); + expect(fromGraph.meta).not.toBe(fromHost.meta); + expect(inGraph.late().meta).toBe(fromGraph.meta); + expect({ url: fromGraph.url, main: fromGraph.main, hostUrl: fromHost.url, hostMain: fromHost.main }).toEqual({ + url: pathToFileURL(file).href, + main: true, + hostUrl: pathToFileURL(file).href, + hostMain: false, + }); + }); + + test("a graph module's import.meta.require called by the host loads the graph's instance", async () => { + using dir = tempDir("module-graph-meta-require-host-call", { + "host-call-state.mjs": ` + export let count = 0; + export const increment = () => ++count; + `, + "host-call-entry.mjs": ` + import { increment } from "./host-call-state.mjs"; + export { increment }; + export const require = import.meta.require; + `, + }); + const file = join(String(dir), "host-call-entry.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + inGraph.increment(); + + expect(inGraph.require).toBe(inGraph.require); + expect({ + graph: inGraph.require("./host-call-state.mjs").count, + graphIsImported: inGraph.require("./host-call-state.mjs").increment === inGraph.increment, + host: host.require("./host-call-state.mjs").count, + }).toEqual({ graph: 1, graphIsImported: true, host: 0 }); + }); + + describe("resolution", () => { + const files = { + "package.json": `{ "name": "module-graph-meta-resolve-root" }`, + "node_modules/conditional/package.json": JSON.stringify({ + name: "conditional", + exports: { ".": { import: "./esm.mjs", require: "./cjs.cjs" }, "./feature": "./feature.mjs" }, + }), + "node_modules/conditional/esm.mjs": `export const kind = "esm";`, + "node_modules/conditional/cjs.cjs": `exports.kind = "cjs";`, + "node_modules/conditional/feature.mjs": `export const kind = "feature";`, + "other/sibling.mjs": `export {};`, + "src/sibling.mjs": `export {};`, + "src/resolver.mjs": ` + export function resolveAll(specifier) { + return { + resolve: import.meta.resolve(specifier), + resolveSync: import.meta.resolveSync(specifier), + bunResolveSync: Bun.resolveSync(specifier, import.meta.dir), + requireResolve: import.meta.require.resolve(specifier), + }; + } + export function resolveFrom(specifier, parent) { + return import.meta.resolveSync(specifier, parent); + } + export function failure(fn) { + try { + return fn(); + } catch (error) { + return { name: error.name, code: error.code, message: error.message }; + } + } + export const failures = specifier => ({ + resolveSync: failure(() => import.meta.resolveSync(specifier)), + bunResolveSync: failure(() => Bun.resolveSync(specifier, import.meta.dir)), + requireResolve: failure(() => import.meta.require.resolve(specifier)), + }); + export const meta = import.meta; + `, + }; + + test("a relative specifier resolves against the graph module", async () => { + using dir = tempDir("module-graph-meta-resolve-relative", files); + const file = join(String(dir), "src", "resolver.mjs"); + const sibling = join(String(dir), "src", "sibling.mjs"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).resolveAll("./sibling.mjs"); + expect(inGraph).toEqual({ + resolve: pathToFileURL(sibling).href, + resolveSync: sibling, + bunResolveSync: sibling, + requireResolve: sibling, + }); + expect(inGraph).toEqual((await import(file)).resolveAll("./sibling.mjs")); + }); + + test("a package resolves with the `import` condition, require.resolve with `require`", async () => { + using dir = tempDir("module-graph-meta-resolve-package", files); + const file = join(String(dir), "src", "resolver.mjs"); + const pkg = join(String(dir), "node_modules", "conditional"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).resolveAll("conditional"); + expect(inGraph).toEqual({ + resolve: pathToFileURL(join(pkg, "esm.mjs")).href, + resolveSync: join(pkg, "esm.mjs"), + bunResolveSync: join(pkg, "esm.mjs"), + requireResolve: join(pkg, "cjs.cjs"), + }); + expect(inGraph).toEqual((await import(file)).resolveAll("conditional")); + }); + + test.each(["conditional/feature", "node:path", "fs", "bun:sqlite", "bun"])( + "%s resolves as in the host", + async specifier => { + using dir = tempDir("module-graph-meta-resolve-each", files); + const file = join(String(dir), "src", "resolver.mjs"); + + using graph = new ModuleGraph(); + expect((await graph.import(file)).resolveAll(specifier)).toEqual((await import(file)).resolveAll(specifier)); + }, + ); + + test("an absolute path and a file: URL resolve as in the host", async () => { + using dir = tempDir("module-graph-meta-resolve-absolute", files); + const file = join(String(dir), "src", "resolver.mjs"); + const other = join(String(dir), "other", "sibling.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + expect(inGraph.resolveAll(other)).toEqual({ + resolve: pathToFileURL(other).href, + resolveSync: other, + bunResolveSync: other, + requireResolve: other, + }); + expect(inGraph.resolveAll(other)).toEqual(host.resolveAll(other)); + expect(inGraph.resolveAll(pathToFileURL(other).href)).toEqual(host.resolveAll(pathToFileURL(other).href)); + }); + + test("resolveSync with an explicit parent resolves against that parent", async () => { + using dir = tempDir("module-graph-meta-resolve-parent", files); + const file = join(String(dir), "src", "resolver.mjs"); + const parent = join(String(dir), "other", "sibling.mjs"); + + using graph = new ModuleGraph(); + const inGraph = (await graph.import(file)).resolveFrom("./sibling.mjs", parent); + expect(inGraph).toBe(join(String(dir), "other", "sibling.mjs")); + expect(inGraph).toBe((await import(file)).resolveFrom("./sibling.mjs", parent)); + }); + + test("a specifier that does not resolve fails as in the host", async () => { + using dir = tempDir("module-graph-meta-resolve-missing", files); + const file = join(String(dir), "src", "resolver.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + for (const specifier of ["./missing.mjs", "missing-package", "conditional/not-exported"]) { + const failures = inGraph.failures(specifier); + expect(failures).toEqual(host.failures(specifier)); + expect(failures.resolveSync).toMatchObject({ name: "ResolveMessage", code: "ERR_MODULE_NOT_FOUND" }); + } + expect(inGraph.meta.resolve("./missing.mjs")).toBe(host.meta.resolve("./missing.mjs")); + }); + + test("the resolve functions called by the host resolve against the graph module", async () => { + using dir = tempDir("module-graph-meta-resolve-host-call", files); + const file = join(String(dir), "src", "resolver.mjs"); + const sibling = join(String(dir), "src", "sibling.mjs"); + + using graph = new ModuleGraph(); + const { meta } = await graph.import(file); + expect({ + resolve: meta.resolve("./sibling.mjs"), + resolveSync: meta.resolveSync("./sibling.mjs"), + requireResolve: meta.require.resolve("./sibling.mjs"), + }).toEqual({ resolve: pathToFileURL(sibling).href, resolveSync: sibling, requireResolve: sibling }); + }); + }); + + describe.skipIf(isWindows)("symlinks", () => { + const files = { + "real/target.mjs": ` + export const meta = import.meta; + export const strings = { url: import.meta.url, path: import.meta.path, dir: import.meta.dir }; + `, + }; + + test("a symlinked file is the module at its real path", async () => { + using dir = tempDir("module-graph-meta-symlink-file", files); + const target = join(String(dir), "real", "target.mjs"); + const link = join(String(dir), "link.mjs"); + symlinkSync(target, link); + + using graph = new ModuleGraph(); + const viaLink = await graph.import(link); + const hostViaLink = await import(link); + + expect(graph.mainModule).toBe(target); + expect(await graph.import(target)).toBe(viaLink); + expect(await import(target)).toBe(hostViaLink); + expect(viaLink.meta).not.toBe(hostViaLink.meta); + expect(viaLink.meta.main).toBe(true); + expect(viaLink.strings).toEqual({ + url: pathToFileURL(target).href, + path: target, + dir: join(String(dir), "real"), + }); + expect(viaLink.strings).toEqual(hostViaLink.strings); + }); + + test("a module under a symlinked directory is the module at its real path", async () => { + using dir = tempDir("module-graph-meta-symlink-dir", files); + const target = join(String(dir), "real", "target.mjs"); + symlinkSync(join(String(dir), "real"), join(String(dir), "linked")); + const viaLinkedDir = join(String(dir), "linked", "target.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(viaLinkedDir); + const host = await import(viaLinkedDir); + + expect(graph.mainModule).toBe(target); + expect(await graph.import(target)).toBe(inGraph); + expect(await graph.import(pathToFileURL(viaLinkedDir).href)).toBe(inGraph); + expect(await import(target)).toBe(host); + expect(inGraph.strings).toEqual({ + url: pathToFileURL(target).href, + path: target, + dir: join(String(dir), "real"), + }); + expect(inGraph.strings).toEqual(host.strings); + }); + }); +}); + +describe("import.meta.main and graph.mainModule", () => { + test("mainModule is undefined until the first import", () => { + using graph = new ModuleGraph(); + using withOptions = new ModuleGraph({ globals: { mainModule: "not this" }, onError() {} }); + expect([graph.mainModule, withOptions.mainModule]).toEqual([undefined, undefined]); + }); + + test("mainModule is the first import's resolved path and later imports do not change it", async () => { + using dir = tempDir("module-graph-main-first", { + "first.mjs": `export {};`, + "second.mjs": `export {};`, + }); + const first = join(String(dir), "first.mjs"); + const second = join(String(dir), "second.mjs"); + + using graph = new ModuleGraph(); + await graph.import(first); + expect(graph.mainModule).toBe(first); + await graph.import(second); + await graph.import(first); + await graph.import("node:path"); + expect(graph.mainModule).toBe(first); + }); + + test("import.meta.main is true in the main module only", async () => { + using dir = tempDir("module-graph-main-only", { + "only-leaf.mjs": `export const leafMain = import.meta.main;`, + "only-dep.mjs": ` + export * from "./only-leaf.mjs"; + export const depMain = import.meta.main; + `, + "only-main.mjs": ` + export * from "./only-dep.mjs"; + export const main = import.meta.main; + export const lazyMain = () => import.meta.main; + export async function dynamicMain() { return (await import("./only-dynamic.mjs")).dynamicMain; } + `, + "only-dynamic.mjs": `export const dynamicMain = import.meta.main;`, + "only-later-root.mjs": ` + export { main as importedMain } from "./only-main.mjs"; + export const laterRootMain = import.meta.main; + `, + }); + const main = join(String(dir), "only-main.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(main); + const laterRoot = await graph.import(join(String(dir), "only-later-root.mjs")); + + expect({ + main: inGraph.main, + lazyMain: inGraph.lazyMain(), + depMain: inGraph.depMain, + leafMain: inGraph.leafMain, + dynamicMain: await inGraph.dynamicMain(), + laterRootMain: laterRoot.laterRootMain, + importedMain: laterRoot.importedMain, + }).toEqual({ + main: true, + lazyMain: true, + depMain: false, + leafMain: false, + dynamicMain: false, + laterRootMain: false, + importedMain: true, + }); + }); + + test("a later root's dependency is not main even when it was a root of another graph", async () => { + using dir = tempDir("module-graph-main-per-graph", { + "per-graph-a.mjs": `export const main = import.meta.main;`, + "per-graph-b.mjs": ` + export { main as aMain } from "./per-graph-a.mjs"; + export const main = import.meta.main; + `, + }); + const fileA = join(String(dir), "per-graph-a.mjs"); + const fileB = join(String(dir), "per-graph-b.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const aInA = await a.import(fileA); + const bInB = await b.import(fileB); + const bInA = await a.import(fileB); + + expect({ + mainModules: [a.mainModule, b.mainModule], + a: { a: aInA.main, b: bInA.main, aThroughB: bInA.aMain }, + b: { b: bInB.main, aThroughB: bInB.aMain }, + }).toEqual({ + mainModules: [fileA, fileB], + a: { a: true, b: false, aThroughB: true }, + b: { b: true, aThroughB: false }, + }); + }); + + test("mainModule is already set while the main module evaluates", async () => { + using dir = tempDir("module-graph-main-during-evaluation", { + "during.mjs": ` + export const seen = holder.graph.mainModule; + await Promise.resolve(); + export const seenAfterAwait = holder.graph.mainModule; + `, + }); + const file = join(String(dir), "during.mjs"); + + const holder = graphWithHolder(); + using graph = holder.graph; + const promise = graph.import(file); + expect(graph.mainModule).toBe(file); + expect(await promise).toEqual(expect.objectContaining({ seen: file, seenAfterAwait: file })); + }); + + test("the first of two concurrent imports is the main module", async () => { + using dir = tempDir("module-graph-main-concurrent", { + "concurrent-first.mjs": ` + await Promise.resolve(); + export const main = import.meta.main; + `, + "concurrent-second.mjs": `export const main = import.meta.main;`, + }); + const first = join(String(dir), "concurrent-first.mjs"); + const second = join(String(dir), "concurrent-second.mjs"); + + using graph = new ModuleGraph(); + const [inFirst, inSecond] = await Promise.all([graph.import(first), graph.import(second)]); + expect({ mainModule: graph.mainModule, first: inFirst.main, second: inSecond.main }).toEqual({ + mainModule: first, + first: true, + second: false, + }); + }); + + test("mainModule is set by a first import that fails to parse", async () => { + using dir = tempDir("module-graph-main-syntax-error", { + "syntax-error.mjs": `export const x = ;`, + "after-syntax-error.mjs": `export const main = import.meta.main;`, + }); + const broken = join(String(dir), "syntax-error.mjs"); + + using graph = new ModuleGraph(); + expect( + await graph.import(broken).then( + () => "resolved", + () => "rejected", + ), + ).toBe("rejected"); + expect(graph.mainModule).toBe(broken); + + const after = await graph.import(join(String(dir), "after-syntax-error.mjs")); + expect([graph.mainModule, after.main]).toEqual([broken, false]); + }); + + test("mainModule is set by a first import that throws while evaluating", async () => { + using dir = tempDir("module-graph-main-throws", { + "throws.mjs": ` + holder.mainWhileEvaluating = import.meta.main; + throw new Error("thrown by throws.mjs"); + `, + "after-throws.mjs": `export const main = import.meta.main;`, + }); + const throws = join(String(dir), "throws.mjs"); + + const holder = graphWithHolder(); + using graph = holder.graph; + expect(graph.import(throws)).rejects.toThrow("thrown by throws.mjs"); + await graph.import(throws).catch(() => {}); + expect([graph.mainModule, holder.mainWhileEvaluating]).toEqual([throws, true]); + + const after = await graph.import(join(String(dir), "after-throws.mjs")); + expect([graph.mainModule, after.main]).toEqual([throws, false]); + }); + + test("mainModule is set by a first import whose dependency is missing", async () => { + using dir = tempDir("module-graph-main-missing-dep", { + "missing-dep.mjs": `import "./this-file-does-not-exist.mjs";`, + }); + const file = join(String(dir), "missing-dep.mjs"); + + using graph = new ModuleGraph(); + expect(await rejection(graph.import(file))).toMatchObject({ code: "ERR_MODULE_NOT_FOUND" }); + expect(graph.mainModule).toBe(file); + }); + + test("an import that does not resolve leaves mainModule for the next import", async () => { + using dir = tempDir("module-graph-main-unresolved", { + "resolves.mjs": `export const main = import.meta.main;`, + }); + const file = join(String(dir), "resolves.mjs"); + + using graph = new ModuleGraph(); + const unresolved = [ + join(String(dir), "unresolved.mjs"), + "./unresolved-relative.mjs", + "unresolved-package-for-module-graph", + "", + Symbol("not a specifier"), + { + toString() { + throw new Error("toString"); + }, + }, + ]; + for (const specifier of unresolved) { + expect( + await graph.import(specifier).then( + () => "resolved", + () => "rejected", + ), + ).toBe("rejected"); + expect(graph.mainModule).toBeUndefined(); + } + + const inGraph = await graph.import(file); + expect([graph.mainModule, inGraph.main]).toEqual([file, true]); + }); + + test("mainModule is a path when the first import is a file: URL", async () => { + using dir = tempDir("module-graph-main-url", { + "url-string.mjs": `export const main = import.meta.main;`, + "url-object.mjs": `export const main = import.meta.main;`, + }); + const urlString = join(String(dir), "url-string.mjs"); + const urlObject = join(String(dir), "url-object.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(pathToFileURL(urlString).href); + const inB = await b.import(pathToFileURL(urlObject)); + expect({ a: [a.mainModule, inA.main], b: [b.mainModule, inB.main] }).toEqual({ + a: [urlString, true], + b: [urlObject, true], + }); + }); + + test("mainModule is the resolved specifier when the first import is a builtin", async () => { + using dir = tempDir("module-graph-main-builtin", { + "after-builtin.mjs": `export const main = import.meta.main;`, + }); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + await a.import("path"); + await b.import("bun:jsc"); + const after = await a.import(join(String(dir), "after-builtin.mjs")); + expect({ a: a.mainModule, b: b.mainModule, afterMain: after.main }).toEqual({ + a: "node:path", + b: "bun:jsc", + afterMain: false, + }); + }); + + // The main module is the instance `graph.import()` created: its key, query included, is `mainModule`. + test("the first import with a ?query is the main module", async () => { + using dir = tempDir("module-graph-main-query", { + "query-main.mjs": `export const main = import.meta.main;`, + }); + const file = join(String(dir), "query-main.mjs"); + + using graph = new ModuleGraph(); + const withQuery = await graph.import(file + "?tenant=1"); + const plain = await graph.import(file); + expect({ mainModule: graph.mainModule, withQuery: withQuery.main, plain: plain.main }).toEqual({ + mainModule: file + "?tenant=1", + withQuery: true, + plain: false, + }); + }); + + test("mainModule is relative to the module that called import()", async () => { + using dir = tempDir("module-graph-main-relative", { + "nested/relative-main.mjs": `export const main = import.meta.main;`, + "nested/relative-caller.mjs": ` + export async function importInto(graph, specifier) { return await graph.import(specifier); } + `, + }); + + using outer = new ModuleGraph(); + using inner = new ModuleGraph(); + const caller = await outer.import(join(String(dir), "nested", "relative-caller.mjs")); + const inInner = await caller.importInto(inner, "./relative-main.mjs"); + expect([inner.mainModule, inInner.main]).toEqual([join(String(dir), "nested", "relative-main.mjs"), true]); + }); + + test("dispose() does not change mainModule", async () => { + using dir = tempDir("module-graph-main-dispose", { + "disposed-main.mjs": `export const main = () => import.meta.main;`, + }); + const file = join(String(dir), "disposed-main.mjs"); + + const graph = new ModuleGraph(); + const inGraph = await graph.import(file); + graph.dispose(); + expect([graph.mainModule, inGraph.main()]).toEqual([file, true]); + + const neverImported = new ModuleGraph(); + neverImported.dispose(); + expect(await rejection(neverImported.import(file))).toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(neverImported.mainModule).toBeUndefined(); + }); + + test("mainModule is a read-only accessor on the prototype", async () => { + using dir = tempDir("module-graph-main-accessor", { "accessor.mjs": `export {};` }); + const file = join(String(dir), "accessor.mjs"); + + using graph = new ModuleGraph(); + await graph.import(file); + + const descriptor = Object.getOwnPropertyDescriptor(ModuleGraph.prototype, "mainModule")!; + expect(Object.hasOwn(graph, "mainModule")).toBe(false); + expect(descriptor).toEqual({ get: expect.any(Function), set: undefined, enumerable: true, configurable: true }); + expect(() => { + graph.mainModule = "assigned"; + }).toThrow(TypeError); + expect(Reflect.set(graph, "mainModule", "assigned")).toBe(false); + expect(graph.mainModule).toBe(file); + }); + + test("the mainModule getter throws a TypeError on another receiver", () => { + const { get } = Object.getOwnPropertyDescriptor(ModuleGraph.prototype, "mainModule")!; + for (const receiver of [undefined, null, {}, ModuleGraph.prototype, Object.create(new ModuleGraph())]) { + expect(() => get!.call(receiver)).toThrow(TypeError); + } + expect(() => ModuleGraph.prototype.mainModule).toThrow(TypeError); + }); + + test("graphs never change the host's import.meta.main or Bun.main", async () => { + using dir = tempDir("module-graph-main-host", { + "host-main.mjs": ` + export const main = import.meta.main; + export const bunMain = Bun.main; + export const processArgv1 = process.argv[1]; + `, + }); + const file = join(String(dir), "host-main.mjs"); + const before = { main: import.meta.main, bunMain: Bun.main, argv1: process.argv[1] }; + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + + expect({ main: import.meta.main, bunMain: Bun.main, argv1: process.argv[1] }).toEqual(before); + expect({ main: inGraph.main, bunMain: inGraph.bunMain, argv1: inGraph.processArgv1 }).toEqual({ + main: true, + bunMain: before.bunMain, + argv1: before.argv1, + }); + expect(host.main).toBe(false); + }); +}); + +describe("graph.import() specifiers", () => { + test("an absolute path, a file: URL string, a URL and an object with toString() name the same module", async () => { + using dir = tempDir("module-graph-specifier-forms", { "forms.mjs": `export const meta = import.meta;` }); + const file = join(String(dir), "forms.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const others = await Promise.all([ + graph.import(pathToFileURL(file).href), + graph.import(pathToFileURL(file)), + graph.import({ toString: () => file }), + graph.import(new String(file)), + graph.import(join(String(dir), ".", "nested", "..", "forms.mjs")), + ]); + expect(others.map(ns => ns === inGraph)).toEqual([true, true, true, true, true]); + expect(inGraph).not.toBe(await import(file)); + }); + + test("a file: URL with percent-encoding names the decoded path", async () => { + using dir = tempDir("module-graph-specifier-encoded", { + "encoded name é.mjs": `export const file = import.meta.file;`, + }); + const file = join(String(dir), "encoded name é.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(pathToFileURL(file).href); + expect(pathToFileURL(file).href).toContain("encoded%20name%20%C3%A9.mjs"); + expect(inGraph.file).toBe("encoded name é.mjs"); + expect(await graph.import(file)).toBe(inGraph); + }); + + test("each ?query is its own module instance, as in the host", async () => { + using dir = tempDir("module-graph-specifier-query", { + "query.mjs": ` + export const meta = import.meta; + export let count = 0; + export const increment = () => ++count; + `, + }); + const file = join(String(dir), "query.mjs"); + + using graph = new ModuleGraph(); + const [plain, a, b] = [await graph.import(file), await graph.import(file + "?a"), await graph.import(file + "?b")]; + const [hostPlain, hostA] = [await import(file), await import(file + "?a")]; + + a.increment(); + expect(new Set([plain, a, b, hostPlain, hostA]).size).toBe(5); + expect(await graph.import(file + "?a")).toBe(a); + expect([plain.count, a.count, b.count, hostA.count]).toEqual([0, 1, 0, 0]); + expect({ url: a.meta.url, path: a.meta.path, file: a.meta.file }).toEqual({ + url: hostA.meta.url, + path: hostA.meta.path, + file: hostA.meta.file, + }); + expect(a.meta.url).toBe(pathToFileURL(file).href + "?a"); + }); + + test("a relative specifier resolves against the module that called import()", async () => { + using dir = tempDir("module-graph-specifier-relative", { + "where.mjs": `export const where = "root";`, + "nested/where.mjs": `export const where = "nested";`, + "nested/deeper/relative-caller.mjs": ` + export async function importRelative(specifier) { return await holder.graph.import(specifier); } + `, + }); + + const holder = graphWithHolder(); + using graph = holder.graph; + const caller = await graph.import(join(String(dir), "nested", "deeper", "relative-caller.mjs")); + const nested = await caller.importRelative("../where.mjs"); + const root = await caller.importRelative("../../where.mjs"); + + expect([nested.where, root.where]).toEqual(["nested", "root"]); + expect(nested).toBe(await graph.import(join(String(dir), "nested", "where.mjs"))); + }); + + test("a relative specifier from a host module resolves against that host module", async () => { + using graph = new ModuleGraph(); + expect(await rejection(graph.import("./module-graph-import-meta-missing-fixture.mjs"))).toEqual({ + name: "ResolveMessage", + code: "ERR_MODULE_NOT_FOUND", + specifier: "./module-graph-import-meta-missing-fixture.mjs", + referrer: import.meta.path, + }); + }); + + test("a relative specifier from another graph's module resolves against that module", async () => { + using dir = tempDir("module-graph-specifier-cross-graph", { + "cross/target.mjs": `export const meta = import.meta;`, + "cross/cross-caller.mjs": ` + export async function importInto(graph, specifier) { return await graph.import(specifier); } + export async function importHere(specifier) { return await import(specifier); } + `, + }); + + using outer = new ModuleGraph(); + using inner = new ModuleGraph(); + const caller = await outer.import(join(String(dir), "cross", "cross-caller.mjs")); + const inInner = await caller.importInto(inner, "./target.mjs"); + const inOuter = await caller.importHere("./target.mjs"); + + expect(inInner).not.toBe(inOuter); + expect(inInner).toBe(await inner.import(join(String(dir), "cross", "target.mjs"))); + expect(inOuter).toBe(await outer.import(join(String(dir), "cross", "target.mjs"))); + expect(inInner.meta.url).toBe(inOuter.meta.url); + }); + + test("an extensionless path and a directory resolve as in the host", async () => { + using dir = tempDir("module-graph-specifier-extensionless", { + "extensionless.mjs": `export const which = "file";`, + "directory/index.mjs": `export const which = "index";`, + }); + + using graph = new ModuleGraph(); + const file = await graph.import(join(String(dir), "extensionless")); + const index = await graph.import(join(String(dir), "directory")); + expect([file.which, index.which]).toEqual(["file", "index"]); + expect(file).toBe(await graph.import(join(String(dir), "extensionless.mjs"))); + expect(index).toBe(await graph.import(join(String(dir), "directory", "index.mjs"))); + expect([ + (await import(join(String(dir), "extensionless"))).which, + (await import(join(String(dir), "directory"))).which, + ]).toEqual(["file", "index"]); + }); + + describe("packages", () => { + const files = { + "package.json": `{ "name": "module-graph-specifier-packages-root" }`, + "node_modules/dual/package.json": JSON.stringify({ + name: "dual", + exports: { + ".": { import: "./dual-esm.mjs", require: "./dual-cjs.cjs" }, + "./state": "./dual-state.mjs", + }, + }), + "node_modules/dual/dual-esm.mjs": ` + export const kind = "esm"; + export const meta = import.meta; + `, + "node_modules/dual/dual-cjs.cjs": `exports.kind = "cjs";`, + "node_modules/dual/dual-state.mjs": ` + export let count = 0; + export const increment = () => ++count; + `, + "node_modules/typed-module/package.json": JSON.stringify({ + name: "typed-module", + type: "module", + main: "./main.js", + }), + "node_modules/typed-module/main.js": ` + export const isModule = typeof module === "undefined" && typeof import.meta.url === "string"; + export const meta = import.meta; + `, + "app/importer.mjs": ` + export async function viaGraph(specifier) { return await holder.graph.import(specifier); } + export async function viaImport(specifier) { return await import(specifier); } + export function viaRequire(specifier) { return import.meta.require(specifier); } + `, + }; + + test("a bare specifier resolves from the calling module's node_modules", async () => { + using dir = tempDir("module-graph-specifier-bare", files); + const esm = join(String(dir), "node_modules", "dual", "dual-esm.mjs"); + + const holder = graphWithHolder(); + using graph = holder.graph; + const importer = await graph.import(join(String(dir), "app", "importer.mjs")); + const viaGraph = await importer.viaGraph("dual"); + + expect(viaGraph).toBe(await importer.viaImport("dual")); + expect(viaGraph).toBe(await graph.import(esm)); + expect({ kind: viaGraph.kind, url: viaGraph.meta.url, main: viaGraph.meta.main }).toEqual({ + kind: "esm", + url: pathToFileURL(esm).href, + main: false, + }); + + // The test module has no such package above it. + expect(await rejection(graph.import("dual"))).toEqual({ + name: "ResolveMessage", + code: "ERR_MODULE_NOT_FOUND", + specifier: "dual", + referrer: import.meta.path, + }); + }); + + test("`exports` conditions: import() takes `import`, import.meta.require() takes `require`", async () => { + using dir = tempDir("module-graph-specifier-conditions", files); + + const holder = graphWithHolder(); + using graph = holder.graph; + const importer = await graph.import(join(String(dir), "app", "importer.mjs")); + const host = await import(join(String(dir), "app", "importer.mjs")); + + expect({ + viaGraph: (await importer.viaGraph("dual")).kind, + viaImport: (await importer.viaImport("dual")).kind, + viaRequire: importer.viaRequire("dual").kind, + }).toEqual({ viaGraph: "esm", viaImport: "esm", viaRequire: "cjs" }); + // The CommonJS build is the one shared instance. + expect(importer.viaRequire("dual")).toBe(host.viaRequire("dual")); + }); + + test("a package subpath is a per-graph instance", async () => { + using dir = tempDir("module-graph-specifier-subpath", files); + + const holder = graphWithHolder(); + using graph = holder.graph; + const importer = await graph.import(join(String(dir), "app", "importer.mjs")); + const host = await import(join(String(dir), "app", "importer.mjs")); + + const state = await importer.viaGraph("dual/state"); + const hostState = await host.viaImport("dual/state"); + state.increment(); + expect([state.count, hostState.count, importer.viaRequire("dual/state").count]).toEqual([1, 0, 1]); + expect(await rejection(importer.viaGraph("dual/not-exported"))).toMatchObject({ + name: "ResolveMessage", + specifier: "dual/not-exported", + referrer: join(String(dir), "app", "importer.mjs"), + }); + }); + + test('a .js file of a `"type": "module"` package is an ES module of the graph', async () => { + using dir = tempDir("module-graph-specifier-type-module", files); + const main = join(String(dir), "node_modules", "typed-module", "main.js"); + + const holder = graphWithHolder(); + using graph = holder.graph; + const importer = await graph.import(join(String(dir), "app", "importer.mjs")); + const inGraph = await importer.viaGraph("typed-module"); + const host = await (await import(join(String(dir), "app", "importer.mjs"))).viaImport("typed-module"); + + expect(inGraph).not.toBe(host); + expect(inGraph.meta).not.toBe(host.meta); + expect({ isModule: inGraph.isModule, url: inGraph.meta.url, hostUrl: host.meta.url }).toEqual({ + isModule: true, + url: pathToFileURL(main).href, + hostUrl: pathToFileURL(main).href, + }); + }); + }); + + test('a .js file next to a `"type": "module"` package.json is an ES module of the graph', async () => { + using dir = tempDir("module-graph-specifier-type-module-local", { + "package.json": `{ "type": "module" }`, + "local-dep.js": `export let count = 0; export const increment = () => ++count;`, + "local-main.js": ` + export * from "./local-dep.js"; + export const main = import.meta.main; + export const isModule = typeof module === "undefined"; + `, + }); + const file = join(String(dir), "local-main.js"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(file); + const host = await import(file); + inGraph.increment(); + expect({ main: inGraph.main, isModule: inGraph.isModule, count: inGraph.count, hostCount: host.count }).toEqual({ + main: true, + isModule: true, + count: 1, + hostCount: 0, + }); + }); + + test.each(["node:path", "path", "bun:jsc", "bun", "node:fs/promises"])( + "%s exposes the host's exports", + async specifier => { + using graph = new ModuleGraph(); + const inGraph = await graph.import(specifier); + const host = await import(specifier); + + expect(Object.keys(inGraph)).toEqual(Object.keys(host)); + expect(inGraph.default).toBe(host.default); + for (const key of Object.keys(host)) expect(inGraph[key]).toBe(host[key]); + expect(await graph.import(specifier)).toBe(inGraph); + }, + ); + + test("`path` and `node:path` are one module of the graph", async () => { + using graph = new ModuleGraph(); + expect(await graph.import("path")).toBe(await graph.import("node:path")); + expect((await graph.import("node:path")).join).toBe(join); + }); + + test("a data: URL is a module of the graph", async () => { + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const specifier = "data:text/javascript,export default typeof tenant;"; + const inGraph = await graph.import(specifier); + expect(inGraph.default).toBe("string"); + expect(await graph.import(specifier)).toBe(inGraph); + }); + + describe("rejects and never throws", () => { + test.each([ + ["a number", 42, "42"], + ["a bigint", 10n, "10"], + ["a boolean", true, "true"], + ["null", null, "null"], + ["undefined", undefined, "undefined"], + ["an object with toString()", { toString: () => "module-graph-no-such-package" }, "module-graph-no-such-package"], + ["an empty string", "", ""], + ])("%s that does not resolve", async (_, value, specifier) => { + using graph = new ModuleGraph(); + let promise!: Promise; + expect(() => { + promise = graph.import(value); + }).not.toThrow(); + expect(promise).toBeInstanceOf(Promise); + expect(await rejection(promise)).toEqual({ + name: "ResolveMessage", + code: "ERR_MODULE_NOT_FOUND", + specifier, + referrer: import.meta.path, + }); + }); + + test("no argument", async () => { + using graph = new ModuleGraph(); + let promise!: Promise; + expect(() => { + promise = graph.import(); + }).not.toThrow(); + expect(await rejection(promise)).toMatchObject({ name: "ResolveMessage", specifier: "undefined" }); + }); + + test("a Symbol", async () => { + using graph = new ModuleGraph(); + let promise!: Promise; + expect(() => { + promise = graph.import(Symbol("specifier")); + }).not.toThrow(); + expect(promise).toBeInstanceOf(Promise); + expect(promise).rejects.toBeInstanceOf(TypeError); + }); + + test("an object whose toString() throws", async () => { + using graph = new ModuleGraph(); + const thrown = new RangeError("thrown by toString"); + let promise!: Promise; + expect(() => { + promise = graph.import({ + toString() { + throw thrown; + }, + }); + }).not.toThrow(); + expect( + await promise.then( + () => "resolved", + error => error, + ), + ).toBe(thrown); + }); + + test("a missing file", async () => { + using dir = tempDir("module-graph-specifier-missing", { "present.mjs": `export {};` }); + const missing = join(String(dir), "missing.mjs"); + + using graph = new ModuleGraph(); + let promise!: Promise; + expect(() => { + promise = graph.import(missing); + }).not.toThrow(); + const error = await promise.then( + () => "resolved", + error => error, + ); + expect({ name: error.name, code: error.code, specifier: error.specifier, referrer: error.referrer }).toEqual({ + name: "ResolveMessage", + code: "ERR_MODULE_NOT_FOUND", + specifier: missing, + referrer: import.meta.path, + }); + expect(error.message).toContain(missing); + expect(await rejection(graph.import(pathToFileURL(missing).href))).toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + // The host reports the same failure. + expect(await rejection(import(missing))).toEqual(await rejection(graph.import(missing))); + }); + + test("a path with a #fragment, as in the host", async () => { + using dir = tempDir("module-graph-specifier-fragment", { "fragment.mjs": `export {};` }); + const specifier = join(String(dir), "fragment.mjs") + "#fragment"; + + using graph = new ModuleGraph(); + expect(await rejection(graph.import(specifier))).toEqual(await rejection(import(specifier))); + }); + }); + + test("a rejected import does not poison the graph", async () => { + using dir = tempDir("module-graph-specifier-recover", { "recover.mjs": `export const ok = true;` }); + + using graph = new ModuleGraph(); + expect(await rejection(graph.import(join(String(dir), "recover-missing.mjs")))).toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + expect((await graph.import(join(String(dir), "recover.mjs"))).ok).toBe(true); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-lifecycle.test.ts b/test/js/bun/module-graph/module-graph-lifecycle.test.ts new file mode 100644 index 000000000000..a28721c6d013 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-lifecycle.test.ts @@ -0,0 +1,2360 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import vm from "node:vm"; + +const { ModuleGraph } = Bun.unsafe as any; + +const tick = () => new Promise(resolve => setImmediate(resolve)); +const invalidArgType = expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }); +const invalidState = { code: "ERR_INVALID_STATE" }; + +describe("Bun.unsafe.ModuleGraph API shape", () => { + test("the constructor", () => { + expect({ + type: typeof ModuleGraph, + name: ModuleGraph.name, + length: ModuleGraph.length, + ownKeys: Reflect.ownKeys(ModuleGraph).sort(), + stable: Bun.unsafe.ModuleGraph === Bun.unsafe.ModuleGraph, + prototypeOf: Object.getPrototypeOf(ModuleGraph) === Function.prototype, + }).toEqual({ + type: "function", + name: "ModuleGraph", + length: 0, + ownKeys: ["length", "name", "prototype"], + stable: true, + prototypeOf: true, + }); + + const { value, ...flags } = Object.getOwnPropertyDescriptor(ModuleGraph, "prototype")!; + expect(flags).toEqual({ writable: false, enumerable: false, configurable: false }); + expect(value.constructor).toBe(ModuleGraph); + expect(Object.getPrototypeOf(value)).toBe(Object.prototype); + }); + + test("the prototype's members", () => { + const proto = ModuleGraph.prototype; + expect(Reflect.ownKeys(proto)).toEqual([ + "import", + "dispose", + "mainModule", + "constructor", + Symbol.dispose, + Symbol.toStringTag, + ]); + + const describeMethod = (key: PropertyKey) => { + const { value, ...flags } = Object.getOwnPropertyDescriptor(proto, key)!; + return { type: typeof value, name: value.name, length: value.length, ...flags }; + }; + expect({ + import: describeMethod("import"), + dispose: describeMethod("dispose"), + symbolDispose: describeMethod(Symbol.dispose), + constructor: describeMethod("constructor"), + }).toEqual({ + import: { type: "function", name: "import", length: 1, writable: true, enumerable: true, configurable: true }, + dispose: { type: "function", name: "dispose", length: 0, writable: true, enumerable: true, configurable: true }, + symbolDispose: { + type: "function", + name: "dispose", + length: 0, + writable: true, + enumerable: false, + configurable: true, + }, + constructor: { + type: "function", + name: "ModuleGraph", + length: 0, + writable: true, + enumerable: false, + configurable: true, + }, + }); + expect(proto[Symbol.dispose]).toBe(proto.dispose); + + const { get, ...mainModule } = Object.getOwnPropertyDescriptor(proto, "mainModule")!; + expect(mainModule).toEqual({ set: undefined, enumerable: true, configurable: true }); + expect({ type: typeof get, name: get!.name, length: get!.length }).toEqual({ + type: "function", + name: "get mainModule", + length: 0, + }); + + expect(Object.getOwnPropertyDescriptor(proto, Symbol.toStringTag)).toEqual({ + value: "ModuleGraph", + writable: false, + enumerable: false, + configurable: true, + }); + }); + + test("methods are not constructors", () => { + expect(() => new ModuleGraph.prototype.import("x")).toThrow(TypeError); + expect(() => new ModuleGraph.prototype.dispose()).toThrow(TypeError); + }); + + test("an instance", () => { + using graph = new ModuleGraph(); + expect({ + tag: Object.prototype.toString.call(graph), + string: String(graph), + instance: graph instanceof ModuleGraph, + prototype: Object.getPrototypeOf(graph) === ModuleGraph.prototype, + ownKeys: Reflect.ownKeys(graph), + json: JSON.stringify(graph), + extensible: Object.isExtensible(graph), + mainModule: graph.mainModule, + }).toEqual({ + tag: "[object ModuleGraph]", + string: "[object ModuleGraph]", + instance: true, + prototype: true, + ownKeys: [], + json: "{}", + extensible: true, + mainModule: undefined, + }); + }); + + test("Bun.inspect() shows the members and the main module", async () => { + using dir = tempDir("module-graph-lifecycle-inspect", { "inspected.mjs": `export default 1;` }); + const file = join(String(dir), "inspected.mjs"); + + using graph = new ModuleGraph({ globals: { hidden: 1 }, onError() {} }); + const inspected = (mainModule: string) => + [ + "ModuleGraph {", + " import: [Function: import],", + " dispose: [Function: dispose],", + ` mainModule: ${mainModule},`, + " [Symbol(Symbol.dispose)]: [Function: dispose],", + "}", + ].join("\n"); + expect(Bun.inspect(graph)).toBe(inspected("undefined")); + await graph.import(file); + expect(Bun.inspect(graph)).toBe(inspected(JSON.stringify(file))); + graph.dispose(); + expect(Bun.inspect(graph)).toBe(inspected(JSON.stringify(file))); + expect(Bun.inspect(ModuleGraph)).toBe("[class ModuleGraph]"); + }); + + test("cannot be structured-cloned", () => { + using graph = new ModuleGraph(); + const errors: unknown[] = []; + for (const clone of [() => structuredClone(graph), () => structuredClone({ nested: [graph] })]) { + try { + clone(); + } catch (error) { + errors.push(error); + } + } + expect(errors.map((error: any) => [error instanceof DOMException, error.name])).toEqual([ + [true, "DataCloneError"], + [true, "DataCloneError"], + ]); + + const { port1, port2 } = new MessageChannel(); + try { + expect(() => port1.postMessage(graph)).toThrow(expect.objectContaining({ name: "DataCloneError" })); + expect(() => port1.postMessage(null, [graph])).toThrow(); + } finally { + port1.close(); + port2.close(); + } + }); + + test("takes expando properties, and works frozen", async () => { + using dir = tempDir("module-graph-lifecycle-frozen", { "frozen.mjs": `export const value = "loaded";` }); + const file = join(String(dir), "frozen.mjs"); + + using graph = new ModuleGraph(); + graph.label = "mine"; + graph[Symbol.for("module-graph-lifecycle")] = 1; + expect(Reflect.ownKeys(graph)).toEqual(["label", Symbol.for("module-graph-lifecycle")]); + + Object.freeze(graph); + expect(Object.isFrozen(graph)).toBe(true); + expect((await graph.import(file)).value).toBe("loaded"); + expect(graph.mainModule).toBe(file); + graph.dispose(); + await expect(graph.import(file)).rejects.toMatchObject(invalidState); + }); + + test("`mainModule` is read-only and an own property can shadow nothing in the graph", async () => { + "use strict"; + using dir = tempDir("module-graph-lifecycle-readonly", { + "first.mjs": `export const main = import.meta.main;`, + "second.mjs": `export const main = import.meta.main;`, + }); + const first = join(String(dir), "first.mjs"); + const second = join(String(dir), "second.mjs"); + + using graph = new ModuleGraph(); + expect(() => { + graph.mainModule = second; + }).toThrow(TypeError); + Object.defineProperty(graph, "mainModule", { value: second, configurable: true }); + const getter = Object.getOwnPropertyDescriptor(ModuleGraph.prototype, "mainModule")!.get!; + + expect({ + first: (await graph.import(first)).main, + second: (await graph.import(second)).main, + own: graph.mainModule, + real: getter.call(graph), + }).toEqual({ first: true, second: false, own: second, real: first }); + }); + + test("import() returns a promise of the realm and never throws for a bad specifier", async () => { + using graph = new ModuleGraph(); + const results = [ + graph.import(), + graph.import(Symbol("s")), + graph.import({ toString: () => ({}) }), + graph.import(""), + ]; + expect(results.map(promise => promise instanceof Promise)).toEqual([true, true, true, true]); + const settled = await Promise.allSettled(results); + expect(settled.map(result => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"]); + expect(settled.slice(1, 3).map((result: any) => result.reason instanceof TypeError)).toEqual([true, true]); + expect(graph.mainModule).toBeUndefined(); + }); +}); + +describe("new ModuleGraph(options)", () => { + test("accepts no options, undefined, any object, and ignores unknown options", async () => { + using dir = tempDir("module-graph-lifecycle-options", { + "options.mjs": `export const seen = [typeof globals, typeof onError, typeof unknown];`, + }); + const file = join(String(dir), "options.mjs"); + + const candidates = [ + [], + [undefined], + [{}], + [{ globals: undefined, onError: undefined }], + [{ unknown: 1, Globals: { unknown: 1 }, onerror: 1 }], + [Object.create(null)], + [function () {}], + [[]], + [undefined, { globals: { unknown: 1 } }], + ]; + for (const args of candidates) { + using graph = new ModuleGraph(...args); + expect((await graph.import(file)).seen).toEqual(["undefined", "undefined", "undefined"]); + } + }); + + test("options are read from the prototype chain too", async () => { + using dir = tempDir("module-graph-lifecycle-inherited", { "inherited.mjs": `export const value = inherited;` }); + using graph = new ModuleGraph(Object.create({ globals: { inherited: "from the prototype" } })); + expect((await graph.import(join(String(dir), "inherited.mjs"))).value).toBe("from the prototype"); + }); + + test("rejects options that are not an object", () => { + for (const options of [null, 0, 1, "", "options", true, false, Symbol("options"), 1n]) { + expect(() => new ModuleGraph(options)).toThrow(invalidArgType); + expect(() => new ModuleGraph(options)).toThrow(TypeError); + } + }); + + test("rejects `globals` that is not an object and `onError` that is not a function", () => { + for (const globals of [null, 0, 1, "", "globals", true, Symbol("globals"), 1n]) { + expect(() => new ModuleGraph({ globals })).toThrow(invalidArgType); + } + for (const onError of [null, 0, 1, "", "onError", true, Symbol("onError"), 1n, {}, [], /x/]) { + expect(() => new ModuleGraph({ onError })).toThrow(invalidArgType); + } + // Callable objects of every kind are functions. + for (const onError of [ + () => {}, + async () => {}, + function* () {}, + class {}, + new Proxy(() => {}, {}), + (() => {}).bind(null), + ]) { + using graph = new ModuleGraph({ onError }); + expect(graph).toBeInstanceOf(ModuleGraph); + } + }); + + test("reads `globals` then `onError`, each once, and validates as it goes", () => { + const log: string[] = []; + const options = (globals: unknown, onError: unknown) => ({ + get onError() { + log.push("onError"); + return onError; + }, + get globals() { + log.push("globals"); + return globals; + }, + }); + + using graph = new ModuleGraph(options({}, () => {})); + expect(log.splice(0)).toEqual(["globals", "onError"]); + expect(() => new ModuleGraph(options(1, () => {}))).toThrow(invalidArgType); + expect(log.splice(0)).toEqual(["globals"]); + expect(() => new ModuleGraph(options({}, 1))).toThrow(invalidArgType); + expect(log.splice(0)).toEqual(["globals", "onError"]); + }); + + test("a throwing option getter propagates", () => { + const thrown = new RangeError("from a getter"); + const throwing = { + get() { + throw thrown; + }, + enumerable: true, + }; + const capture = (options: object) => { + try { + new ModuleGraph(options); + } catch (error) { + return error; + } + }; + expect([ + capture(Object.defineProperty({}, "globals", throwing)), + capture(Object.defineProperty({}, "onError", throwing)), + capture({ globals: Object.defineProperty({}, "value", throwing) }), + capture(new Proxy({}, throwing)), + capture({ + globals: new Proxy( + {}, + { + ownKeys() { + throw thrown; + }, + }, + ), + }), + ]).toEqual([thrown, thrown, thrown, thrown, thrown]); + }); + + test("options and `globals` may be Proxies", async () => { + using dir = tempDir("module-graph-lifecycle-proxy", { + "proxied.mjs": `export const read = () => [first, second];`, + }); + + const log: string[] = []; + const logging = (name: string): ProxyHandler => ({ + get(target, key, receiver) { + log.push(`${name}.get ${String(key)}`); + return Reflect.get(target, key, receiver); + }, + ownKeys(target) { + log.push(`${name}.ownKeys`); + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, key) { + log.push(`${name}.getOwnPropertyDescriptor ${String(key)}`); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + const globals = new Proxy({ first: 1, second: 2, [Symbol("ignored")]: 3 }, logging("globals")); + using graph = new ModuleGraph(new Proxy({ globals }, logging("options"))); + expect(log).toEqual([ + "options.get globals", + "options.get onError", + "globals.ownKeys", + "globals.getOwnPropertyDescriptor first", + "globals.getOwnPropertyDescriptor second", + "globals.get first", + "globals.get second", + ]); + + log.length = 0; + expect((await graph.import(join(String(dir), "proxied.mjs"))).read()).toEqual([1, 2]); + expect(log).toEqual([]); + }); + + test("must be called with `new`", () => { + expect(() => ModuleGraph()).toThrow(TypeError); + expect(() => ModuleGraph({})).toThrow(TypeError); + expect(() => Reflect.apply(ModuleGraph, undefined, [])).toThrow(TypeError); + expect(() => ModuleGraph.call(new ModuleGraph())).toThrow(TypeError); + }); + + test("Reflect.construct() takes the prototype from newTarget", async () => { + using dir = tempDir("module-graph-lifecycle-new-target", { "target.mjs": `export const value = "loaded";` }); + const file = join(String(dir), "target.mjs"); + + class Unrelated {} + const graph: any = Reflect.construct(ModuleGraph, [], Unrelated); + function NoPrototype() {} + NoPrototype.prototype = 1 as any; + const fallback = Reflect.construct(ModuleGraph, [], NoPrototype); + const proxied = Reflect.construct(ModuleGraph, [], new Proxy(ModuleGraph, {})); + const bound = Reflect.construct(ModuleGraph, [], ModuleGraph.bind(null)); + + expect({ + graph: [Object.getPrototypeOf(graph) === Unrelated.prototype, graph instanceof ModuleGraph, "import" in graph], + fallback: Object.getPrototypeOf(fallback) === ModuleGraph.prototype, + proxied: Object.getPrototypeOf(proxied) === ModuleGraph.prototype, + bound: Object.getPrototypeOf(bound) === ModuleGraph.prototype, + }).toEqual({ graph: [true, false, false], fallback: true, proxied: true, bound: true }); + + // It is still a ModuleGraph underneath. + expect((await ModuleGraph.prototype.import.call(graph, file)).value).toBe("loaded"); + expect(Object.getOwnPropertyDescriptor(ModuleGraph.prototype, "mainModule")!.get!.call(graph)).toBe(file); + ModuleGraph.prototype.dispose.call(graph); + await expect(ModuleGraph.prototype.import.call(graph, file)).rejects.toMatchObject(invalidState); + expect(() => Reflect.construct(ModuleGraph, [], () => {})).toThrow(TypeError); + }); + + test("can be subclassed", async () => { + using dir = tempDir("module-graph-lifecycle-subclass", { + "subclassed.mjs": `export const read = () => tenant; export const again = () => import("./subclassed.mjs");`, + }); + const file = join(String(dir), "subclassed.mjs"); + + const log: string[] = []; + class TenantGraph extends ModuleGraph { + tenant: string; + constructor(tenant: string) { + super({ globals: { tenant } }); + this.tenant = tenant; + } + async import(specifier: string) { + log.push(`${this.tenant} imports`); + return { namespace: await super.import(specifier) }; + } + dispose() { + log.push(`${this.tenant} disposed through the override`); + super.dispose(); + } + get extra() { + return this.mainModule; + } + } + + let escaped; + { + using graph = new TenantGraph("a") as any; + escaped = graph; + const { namespace } = await graph.import(file); + expect({ + instances: [graph instanceof TenantGraph, graph instanceof ModuleGraph], + chain: [ + Object.getPrototypeOf(graph) === TenantGraph.prototype, + Object.getPrototypeOf(TenantGraph.prototype) === ModuleGraph.prototype, + Object.getPrototypeOf(TenantGraph) === ModuleGraph, + ], + tag: Object.prototype.toString.call(graph), + ownKeys: Reflect.ownKeys(graph), + read: namespace.read(), + extra: graph.extra, + sameNamespace: (await namespace.again()) === namespace, + }).toEqual({ + instances: [true, true], + chain: [true, true, true], + tag: "[object ModuleGraph]", + ownKeys: ["tenant"], + read: "a", + extra: file, + sameNamespace: true, + }); + } + // `using` calls the inherited [Symbol.dispose], which is the base `dispose`, not the override. + expect(log).toEqual(["a imports"]); + await expect(ModuleGraph.prototype.import.call(escaped, file)).rejects.toMatchObject(invalidState); + + // A subclass that returns before calling super() never makes a graph. + class Broken extends ModuleGraph { + constructor() { + return {} as any; + super(); + } + } + expect(() => ModuleGraph.prototype.dispose.call(new Broken())).toThrow(TypeError); + }); + + test("constructed from a node:vm context, the graph still belongs to the constructor's realm", async () => { + using dir = tempDir("module-graph-lifecycle-vm-realm", { + "realm.mjs": `export const intrinsics = [Array, globalThis]; export const read = () => tenant;`, + }); + const file = join(String(dir), "realm.mjs"); + + const context = vm.createContext({ ModuleGraph, file }); + const graph = vm.runInContext(`new ModuleGraph({ globals: { tenant: "made in a context" } })`, context); + const namespace = await vm.runInContext(`(graph => graph.import(file))`, context)(graph); + const ForeignTarget = vm.runInContext(`(function ForeignTarget() {})`, context); + const foreign = Reflect.construct(ModuleGraph, [], ForeignTarget); + const NoPrototype = vm.runInContext(`function NoPrototype() {} NoPrototype.prototype = 1; NoPrototype`, context); + const fallback = Reflect.construct(ModuleGraph, [], NoPrototype); + + expect({ + prototype: Object.getPrototypeOf(graph) === ModuleGraph.prototype, + intrinsics: [namespace.intrinsics[0] === Array, namespace.intrinsics[1] === globalThis], + read: namespace.read(), + mainModule: graph.mainModule, + foreign: Object.getPrototypeOf(foreign) === ForeignTarget.prototype, + fallback: Object.getPrototypeOf(fallback) === ModuleGraph.prototype, + }).toEqual({ + prototype: true, + intrinsics: [true, true], + read: "made in a context", + mainModule: file, + foreign: true, + fallback: true, + }); + expect(() => vm.runInContext(`ModuleGraph()`, context)).toThrow("cannot be invoked without 'new'"); + expect(() => vm.runInContext(`new ModuleGraph(1)`, context)).toThrow(invalidArgType); + }); + + test("a ShadowRealm has its own ModuleGraph whose modules run in that realm", async () => { + using dir = tempDir("module-graph-lifecycle-shadow-realm", { + "shadow.mjs": ` + let count = 0; + export const increment = () => ++count; + export const intrinsics = [Array, globalThis]; + export const read = () => tenant; + `, + }); + const file = join(String(dir), "shadow.mjs"); + + const realm = new (globalThis as any).ShadowRealm(); + const load = realm.evaluate(`(file, done) => { + const { ModuleGraph } = Bun.unsafe; + globalThis.graphs ??= []; + const graph = new ModuleGraph({ globals: { tenant: "shadow " + graphs.length } }); + graphs.push(graph); + graph.import(file).then( + namespace => done(JSON.stringify({ + count: [namespace.increment(), namespace.increment()], + realmIntrinsics: [namespace.intrinsics[0] === Array, namespace.intrinsics[1] === globalThis], + read: namespace.read(), + mainModule: graph.mainModule === file, + sameGraph: graph instanceof ModuleGraph, + })), + error => done(JSON.stringify({ rejected: String(error) })), + ); + }`) as (file: string, done: (result: string) => void) => void; + + const results: unknown[] = []; + for (let i = 0; i < 2; i++) { + const { promise, resolve } = Promise.withResolvers(); + load(file, resolve); + results.push(JSON.parse(await promise)); + Bun.gc(true); + } + const host = await import(file); + expect(results).toEqual([ + { count: [1, 2], realmIntrinsics: [true, true], read: "shadow 0", mainModule: true, sameGraph: true }, + { count: [1, 2], realmIntrinsics: [true, true], read: "shadow 1", mainModule: true, sameGraph: true }, + ]); + expect(host.increment()).toBe(1); + expect(host.intrinsics).toEqual([Array, globalThis]); + expect(realm.evaluate(`Bun.unsafe.ModuleGraph === Bun.unsafe.ModuleGraph`)).toBe(true); + }); +}); + +describe("receivers", () => { + test("methods throw a TypeError synchronously on anything but a ModuleGraph", () => { + using graph = new ModuleGraph(); + class Sub extends ModuleGraph {} + const getter = Object.getOwnPropertyDescriptor(ModuleGraph.prototype, "mainModule")!.get!; + const receivers = [ + undefined, + null, + 1, + "graph", + Symbol("graph"), + {}, + () => {}, + ModuleGraph, + ModuleGraph.prototype, + Sub.prototype, + Object.create(graph), + new Proxy(graph, {}), + Promise.resolve(), + ]; + for (const receiver of receivers) { + expect(() => ModuleGraph.prototype.import.call(receiver, "node:fs")).toThrow(TypeError); + expect(() => ModuleGraph.prototype.dispose.call(receiver)).toThrow(TypeError); + expect(() => ModuleGraph.prototype[Symbol.dispose].call(receiver)).toThrow(TypeError); + expect(() => getter.call(receiver)).toThrow(TypeError); + } + expect(() => ModuleGraph.prototype.mainModule).toThrow(TypeError); + expect(() => ModuleGraph.prototype.import.call({}, "x")).toThrow( + "ModuleGraph.prototype.import called on an incompatible receiver", + ); + }); + + test("a method borrowed from one graph works on another", async () => { + using dir = tempDir("module-graph-lifecycle-borrowed", { + "borrowed.mjs": `let count = 0; export const increment = () => ++count; export const read = () => tenant;`, + }); + const file = join(String(dir), "borrowed.mjs"); + + using a = new ModuleGraph({ globals: { tenant: "a" } }); + using b = new ModuleGraph({ globals: { tenant: "b" } }); + const { import: importOfA, dispose: disposeOfA } = a; + const inB = await importOfA.call(b, file); + const inA = await a.import(file); + inB.increment(); + + expect({ + read: [inA.read(), inB.read()], + counts: [inA.increment(), inB.increment()], + same: (await b.import(file)) === inB, + mainModules: [a.mainModule, b.mainModule], + }).toEqual({ read: ["a", "b"], counts: [1, 2], same: true, mainModules: [file, file] }); + + disposeOfA.call(b); + await expect(b.import(file)).rejects.toMatchObject(invalidState); + expect(await a.import(file)).toBe(inA); + }); + + test("an unbound import() resolves relative specifiers against its caller", async () => { + using dir = tempDir("module-graph-lifecycle-unbound", { + // Not a tail call: the caller's frame has to be on the stack to be the referrer. + "unbound.mjs": `export async function load(importInto, graph) { return await importInto.call(graph, "./sibling.mjs"); }`, + "sibling.mjs": `export const name = "sibling";`, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const { load } = await a.import(join(String(dir), "unbound.mjs")); + const sibling = await load(ModuleGraph.prototype.import, b); + expect(sibling.name).toBe("sibling"); + expect(sibling).toBe(await b.import(join(String(dir), "sibling.mjs"))); + expect(sibling).not.toBe(await a.import(join(String(dir), "sibling.mjs"))); + }); +}); + +describe("using", () => { + test("`using` disposes the graph at scope exit", async () => { + using dir = tempDir("module-graph-lifecycle-using", { + "scoped.mjs": `export const again = () => import("./scoped.mjs");`, + }); + const file = join(String(dir), "scoped.mjs"); + + let escaped, namespace; + { + using graph = new ModuleGraph(); + escaped = graph; + namespace = await graph.import(file); + expect(await namespace.again()).toBe(namespace); + } + await expect(escaped.import(file)).rejects.toMatchObject(invalidState); + await expect(namespace.again()).rejects.toMatchObject(invalidState); + }); + + test("`using` disposes the graph when the scope throws", async () => { + using dir = tempDir("module-graph-lifecycle-using-throw", { "thrown.mjs": `export default 1;` }); + const file = join(String(dir), "thrown.mjs"); + + let escaped; + const thrown = new Error("out of the scope"); + async function scope() { + using graph = new ModuleGraph(); + escaped = graph; + await graph.import(file); + throw thrown; + } + await expect(scope()).rejects.toBe(thrown); + await expect(escaped.import(file)).rejects.toMatchObject(invalidState); + }); + + test("`await using` falls back to [Symbol.dispose]; there is no [Symbol.asyncDispose]", async () => { + using dir = tempDir("module-graph-lifecycle-await-using", { "awaited.mjs": `export default 1;` }); + const file = join(String(dir), "awaited.mjs"); + + expect(Symbol.asyncDispose in ModuleGraph.prototype).toBe(false); + let escaped; + { + await using graph = new ModuleGraph(); + escaped = graph; + await graph.import(file); + } + await expect(escaped.import(file)).rejects.toMatchObject(invalidState); + }); + + test("DisposableStack and AsyncDisposableStack dispose graphs", async () => { + using dir = tempDir("module-graph-lifecycle-stack", { "stacked.mjs": `export default 1;` }); + const file = join(String(dir), "stacked.mjs"); + + const stack = new DisposableStack(); + const used = stack.use(new ModuleGraph()); + const adopted = stack.adopt(new ModuleGraph(), (graph: any) => graph.dispose()); + const asyncStack = new AsyncDisposableStack(); + const usedAsync = asyncStack.use(new ModuleGraph()); + const graphs = [used, adopted, usedAsync]; + for (const graph of graphs) await graph.import(file); + + stack.dispose(); + await asyncStack.disposeAsync(); + const results = await Promise.allSettled(graphs.map(graph => graph.import(file))); + expect(results.map((result: any) => result.reason?.code)).toEqual([ + "ERR_INVALID_STATE", + "ERR_INVALID_STATE", + "ERR_INVALID_STATE", + ]); + }); +}); + +describe("dispose()", () => { + test("is idempotent and returns undefined", async () => { + using dir = tempDir("module-graph-lifecycle-idempotent", { "idempotent.mjs": `export const value = 1;` }); + const file = join(String(dir), "idempotent.mjs"); + + const graph = new ModuleGraph(); + const namespace = await graph.import(file); + const returned: unknown[] = []; + for (let i = 0; i < 5; i++) { + returned.push(graph.dispose(), graph[Symbol.dispose]()); + Bun.gc(true); + } + expect(returned).toEqual(Array(10).fill(undefined)); + expect(namespace.value).toBe(1); + expect(graph.mainModule).toBe(file); + }); + + test("before any import", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-first", { "never.mjs": `evaluated();` }); + const file = join(String(dir), "never.mjs"); + + let evaluations = 0; + const graph = new ModuleGraph({ globals: { evaluated: () => evaluations++ } }); + graph.dispose(); + const results = await Promise.allSettled([ + graph.import(file), + graph.import("node:fs"), + graph.import("./missing.mjs"), + ]); + expect(results.map((result: any) => [result.reason instanceof Error, result.reason?.code])).toEqual([ + [true, "ERR_INVALID_STATE"], + [true, "ERR_INVALID_STATE"], + [true, "ERR_INVALID_STATE"], + ]); + expect({ evaluations, mainModule: graph.mainModule }).toEqual({ evaluations: 0, mainModule: undefined }); + }); + + test("rejects import() of a module the graph had already loaded, and keeps `mainModule`", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-loaded", { + "loaded.mjs": `import "./loaded-dep.mjs"; export const value = 1;`, + "loaded-dep.mjs": `export const value = 2;`, + }); + const loaded = join(String(dir), "loaded.mjs"); + const dep = join(String(dir), "loaded-dep.mjs"); + + const graph = new ModuleGraph(); + const namespace = await graph.import(loaded); + expect(await graph.import(dep)).toEqual({ value: 2 }); + graph.dispose(); + + const results = await Promise.allSettled([graph.import(loaded), graph.import(dep), graph.import("node:path")]); + expect(results.map((result: any) => result.reason?.code)).toEqual([ + "ERR_INVALID_STATE", + "ERR_INVALID_STATE", + "ERR_INVALID_STATE", + ]); + expect({ mainModule: graph.mainModule, value: namespace.value }).toEqual({ mainModule: loaded, value: 1 }); + // The host and other graphs are unaffected. + using other = new ModuleGraph(); + expect((await other.import(loaded)).value).toBe(1); + expect((await import(loaded)).value).toBe(1); + }); + + test("everything already obtained keeps working", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-survivors", { + "survivor-dep.mjs": ` + export let live = 0; + export const bump = () => ++live; + `, + "survivor.mjs": ` + import { EventEmitter } from "node:events"; + import * as dep from "./survivor-dep.mjs"; + export { live, bump } from "./survivor-dep.mjs"; + export class Counter extends EventEmitter { + #count = 0; + static created = 0; + constructor() { super(); Counter.created++; } + increment() { this.emit("count", ++this.#count); return this; } + } + export const closure = (() => { let calls = 0; return () => [++calls, tenant, dep.live]; })(); + export function* generate() { yield tenant; yield dep.bump(); } + export async function later() { await null; return [tenant, import.meta.url, new.target === undefined]; } + export const meta = import.meta; + export const requireEsm = () => import.meta.require("./survivor-dep.mjs"); + export const resolved = () => import.meta.resolve("./survivor-dep.mjs"); + export const loadDep = () => import("./survivor-dep.mjs"); + export const loadBuiltin = () => import("node:events"); + `, + }); + const file = join(String(dir), "survivor.mjs"); + + const graph = new ModuleGraph({ globals: { tenant: "kept" } }); + const namespace = await graph.import(file); + graph.dispose(); + Bun.gc(true); + + const counts: number[] = []; + new namespace.Counter() + .on("count", (count: number) => counts.push(count)) + .increment() + .increment(); + expect({ + counts, + created: namespace.Counter.created, + closure: [namespace.closure(), namespace.closure()], + bump: namespace.bump(), + live: namespace.live, + generated: [...namespace.generate()], + liveAfterGenerate: namespace.live, + later: await namespace.later(), + metaMain: namespace.meta.main, + resolved: namespace.resolved(), + keys: Object.keys(namespace).sort(), + }).toEqual({ + counts: [1, 2], + created: 1, + closure: [ + [1, "kept", 0], + [2, "kept", 0], + ], + bump: 1, + live: 1, + generated: ["kept", 2], + liveAfterGenerate: 2, + later: ["kept", namespace.meta.url, true], + metaMain: true, + resolved: Bun.pathToFileURL(join(String(dir), "survivor-dep.mjs")).href, + keys: [ + "Counter", + "bump", + "closure", + "generate", + "later", + "live", + "loadBuiltin", + "loadDep", + "meta", + "requireEsm", + "resolved", + ], + }); + + await expect(namespace.loadDep()).rejects.toMatchObject(invalidState); + await expect(namespace.loadBuiltin()).rejects.toMatchObject(invalidState); + expect(() => namespace.requireEsm()).toThrow(expect.objectContaining(invalidState)); + }); + + test("while an import is in flight, the import still completes", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-in-flight", { + "in-flight.mjs": ` + import { value as dep } from "./in-flight-dep.mjs"; + export const before = dep; + started(); + export const value = await gate; + export const dynamic = await import("./in-flight-dep.mjs").then(() => "fulfilled", error => error.code); + export const after = "evaluated to the end"; + `, + "in-flight-dep.mjs": `export const value = "dep";`, + }); + const file = join(String(dir), "in-flight.mjs"); + + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + const graph = new ModuleGraph({ globals: { gate: gate.promise, started: started.resolve } }); + const pending = graph.import(file); + await started.promise; + graph.dispose(); + Bun.gc(true); + gate.resolve("opened"); + + expect({ ...(await pending) }).toEqual({ + before: "dep", + value: "opened", + dynamic: "ERR_INVALID_STATE", + after: "evaluated to the end", + }); + expect(graph.mainModule).toBe(file); + await expect(graph.import(file)).rejects.toMatchObject(invalidState); + }); + + test("right after import() was called, the import still completes", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-just-called", { + "just-called.mjs": ` + import { value } from "./just-called-dep.mjs"; + export const seen = [value, typeof tenant]; + `, + "just-called-dep.mjs": `export const value = "dep";`, + }); + const file = join(String(dir), "just-called.mjs"); + + const graph = new ModuleGraph({ globals: { tenant: 1 } }); + const pending = graph.import(file); + graph.dispose(); + expect((await pending).seen).toEqual(["dep", "number"]); + expect(graph.mainModule).toBe(file); + await expect(graph.import(file)).rejects.toMatchObject(invalidState); + }); + + test("while an import is in flight and the module then throws, the import rejects with that error", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-in-flight-throw", { + "in-flight-throw.mjs": `started(); await gate; throw new Error("after the gate");`, + }); + + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + const graph = new ModuleGraph({ globals: { gate: gate.promise, started: started.resolve } }); + const pending = graph.import(join(String(dir), "in-flight-throw.mjs")); + await started.promise; + graph.dispose(); + gate.resolve(); + await expect(pending).rejects.toThrow("after the gate"); + }); + + test("from the graph's own module during evaluation", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-self", { + "self.mjs": ` + import * as dep from "./self-dep.mjs"; + self().dispose(); + export const count = dep.increment(); + export const dynamic = await import("./self-dep.mjs").then(() => "fulfilled", error => error.code); + export const viaGraph = await self().import("./self-dep.mjs").then(() => "fulfilled", error => error.code); + export const late = () => import("./self-dep.mjs"); + `, + "self-dep.mjs": `let count = 0; export const increment = () => ++count;`, + }); + const file = join(String(dir), "self.mjs"); + + const graph = new ModuleGraph({ globals: { self: () => graph } }); + const namespace = await graph.import(file); + expect({ count: namespace.count, dynamic: namespace.dynamic, viaGraph: namespace.viaGraph }).toEqual({ + count: 1, + dynamic: "ERR_INVALID_STATE", + viaGraph: "ERR_INVALID_STATE", + }); + await expect(namespace.late()).rejects.toMatchObject(invalidState); + expect(graph.mainModule).toBe(file); + }); + + test("from a dependency, before the importer is evaluated", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-dependency", { + "importer.mjs": ` + import { disposed } from "./disposer.mjs"; + import { value } from "./innocent.mjs"; + export const seen = [disposed, value]; + `, + "disposer.mjs": `self().dispose(); export const disposed = true;`, + "innocent.mjs": `export const value = "evaluated after the dispose";`, + }); + + const graph = new ModuleGraph({ globals: { self: () => graph } }); + expect((await graph.import(join(String(dir), "importer.mjs"))).seen).toEqual([true, "evaluated after the dispose"]); + }); + + test("from inside onError", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-on-error", { + "faulty.mjs": ` + export const throwInTimer = message => { setTimeout(() => { throw new Error(message); }, 0); }; + export const again = () => import("./faulty.mjs"); + `, + }); + const file = join(String(dir), "faulty.mjs"); + + const errors: string[] = []; + const graph = new ModuleGraph({ + onError(error: Error) { + graph.dispose(); + Bun.gc(true); + errors.push(error.message); + }, + }); + const namespace = await graph.import(file); + namespace.throwInTimer("first"); + while (errors.length < 1) await tick(); + await expect(namespace.again()).rejects.toMatchObject(invalidState); + + // onError outlives the dispose. + namespace.throwInTimer("second"); + while (errors.length < 2) await tick(); + expect(errors).toEqual(["first", "second"]); + }); + + test("in the middle of loading many large modules, the import still completes and evaluates each once", async () => { + // Large enough that the modules are still being fetched when import() returns. + const padding = Buffer.alloc( + 128 * 1024, + "// padding padding padding padding padding padding padding padding\n", + ).toString(); + const leaves = 8; + const files: Record = { + "large-shared.mjs": `${padding}\nevaluated("shared"); export const shared = {};`, + "large-entry.mjs": ` + ${Array.from({ length: leaves }, (_, i) => `import { shared as shared${i} } from "./large-${i}.mjs";`).join("\n")} + export const shared = [${Array.from({ length: leaves }, (_, i) => `shared${i}`).join(", ")}]; + `, + }; + for (let i = 0; i < leaves; i++) { + files[`large-${i}.mjs`] = `${padding}\nexport { shared } from "./large-shared.mjs"; evaluated("leaf");`; + } + using dir = tempDir("module-graph-lifecycle-dispose-loading", files); + const file = join(String(dir), "large-entry.mjs"); + + const outcomes: unknown[] = []; + for (const ticksBeforeDispose of [0, 1, 3]) { + const evaluated: string[] = []; + const graph = new ModuleGraph({ globals: { evaluated: (name: string) => evaluated.push(name) } }); + const pending = graph.import(file); + for (let i = 0; i < ticksBeforeDispose; i++) await tick(); + graph.dispose(); + Bun.gc(true); + const { shared } = await pending; + outcomes.push({ + distinctShared: new Set(shared).size, + evaluated: evaluated.toSorted(), + importAfter: await graph.import(file).then( + () => "fulfilled", + error => error.code, + ), + }); + } + const outcome = { + distinctShared: 1, + evaluated: [...Array(leaves).fill("leaf"), "shared"], + importAfter: "ERR_INVALID_STATE", + }; + expect(outcomes).toEqual([outcome, outcome, outcome]); + }); + + // The disposed check runs before the specifier is converted to a string, so this import() + // loads a module into a graph that is already disposed. + test("from the specifier's toString(), the import() that is converting it rejects", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-to-string", { "converted.mjs": `export default 1;` }); + const file = join(String(dir), "converted.mjs"); + + const graph = new ModuleGraph(); + const specifier = { + toString() { + graph.dispose(); + return file; + }, + }; + await expect(graph.import(specifier)).rejects.toMatchObject(invalidState); + }); + + test("`import.meta` first touched after dispose() and a collection is still the graph's", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-meta", { + "lazy-meta.mjs": `import "./lazy-meta-dep.mjs"; export const meta = () => import.meta;`, + "lazy-meta-dep.mjs": `export const meta = () => import.meta;`, + }); + const file = join(String(dir), "lazy-meta.mjs"); + + const graph = new ModuleGraph(); + const main = await graph.import(file); + const dep = await graph.import(join(String(dir), "lazy-meta-dep.mjs")); + const host = await import(file); + graph.dispose(); + Bun.gc(true); + expect({ + main: [main.meta().main, main.meta().path], + dep: [dep.meta().main, dep.meta().file], + distinct: main.meta() !== host.meta(), + stable: main.meta() === main.meta(), + }).toEqual({ main: [true, file], dep: [false, "lazy-meta-dep.mjs"], distinct: true, stable: true }); + }); + + test("import() from inside onError, into the same graph and into a new one", async () => { + using dir = tempDir("module-graph-lifecycle-on-error-import", { + "reporter.mjs": `export const throwInTimer = message => { setTimeout(() => { throw new Error(message); }, 0); };`, + "recovery.mjs": `export const recovered = message => tenant + " recovered from " + message;`, + }); + const recovery = join(String(dir), "recovery.mjs"); + + const results: string[] = []; + const graph = new ModuleGraph({ + globals: { tenant: "same graph" }, + async onError(error: Error) { + const fresh = new ModuleGraph({ globals: { tenant: "new graph" } }); + const namespaces = await Promise.all([graph.import(recovery), fresh.import(recovery)]); + results.push(...namespaces.map(namespace => namespace.recovered(error.message))); + fresh.dispose(); + }, + }); + (await graph.import(join(String(dir), "reporter.mjs"))).throwInTimer("a timer"); + while (results.length < 2) await tick(); + expect(results).toEqual(["same graph recovered from a timer", "new graph recovered from a timer"]); + graph.dispose(); + }); + + test("of one graph leaves its siblings and the host alone", async () => { + using dir = tempDir("module-graph-lifecycle-dispose-siblings", { + "sibling-a.mjs": `export const again = () => import("./sibling-b.mjs");`, + "sibling-b.mjs": `export const name = "b";`, + }); + const file = join(String(dir), "sibling-a.mjs"); + + const graphs = Array.from({ length: 4 }, () => new ModuleGraph()); + const namespaces = await Promise.all(graphs.map(graph => graph.import(file))); + graphs[1].dispose(); + graphs[3].dispose(); + const results = await Promise.allSettled(namespaces.map(namespace => namespace.again())); + expect(results.map((result: any) => result.value?.name ?? result.reason?.code)).toEqual([ + "b", + "ERR_INVALID_STATE", + "b", + "ERR_INVALID_STATE", + ]); + expect((await (await import(file)).again()).name).toBe("b"); + graphs[0].dispose(); + graphs[2].dispose(); + }); +}); + +// Records which registered objects the garbage collector has finalized. `until()` never touches +// the objects themselves (a WeakRef#deref() would keep its target alive for the current job). +function finalizations() { + const collected = new Set(); + const registry = new FinalizationRegistry(name => collected.add(name)); + return { + collected, + register: (target: object, name: string) => registry.register(target, name), + async until(done: () => boolean) { + for (let i = 0; i < 200 && !done(); i++) { + Bun.gc(true); + await tick(); + } + return done(); + }, + async settle(rounds = 10) { + for (let i = 0; i < rounds; i++) { + Bun.gc(true); + await tick(); + } + }, + }; +} + +describe("garbage collection", () => { + test("a dropped graph whose namespace is retained keeps working, and import() stays in it", async () => { + using dir = tempDir("module-graph-lifecycle-gc-namespace", { + "retained.mjs": ` + import { increment } from "./retained-state.mjs"; + export { increment }; + export const loadState = () => import("./retained-state.mjs"); + export const loadFresh = () => import("./retained-fresh.mjs"); + `, + "retained-state.mjs": `let count = 0; export const increment = () => ++count; export const read = () => [tenant, count];`, + "retained-fresh.mjs": `import { read } from "./retained-state.mjs"; export const seen = [tenant, read()];`, + }); + const file = join(String(dir), "retained.mjs"); + + async function load() { + const graph = new ModuleGraph({ globals: { tenant: { name: "a dropped graph" } } }); + return await graph.import(file); + } + const namespace = await load(); + Bun.gc(true); + namespace.increment(); + await tick(); + Bun.gc(true); + + const state = await namespace.loadState(); + Bun.gc(true); + expect(state.increment).toBe(namespace.increment); + expect(state.read()).toEqual([{ name: "a dropped graph" }, 1]); + expect(await namespace.loadState()).toBe(state); + // A module first loaded after the ModuleGraph object was dropped joins the same graph. + expect((await namespace.loadFresh()).seen).toEqual([{ name: "a dropped graph" }, [{ name: "a dropped graph" }, 1]]); + expect((await import(file)).increment()).toBe(1); + }); + + test("a retained graph whose namespaces were dropped still has the same module instances", async () => { + using dir = tempDir("module-graph-lifecycle-gc-registry", { + "registered.mjs": ` + import "./registered-dep.mjs"; + evaluated("registered"); + let count = 0; + export const increment = () => ++count; + `, + "registered-dep.mjs": `evaluated("registered-dep");`, + }); + const file = join(String(dir), "registered.mjs"); + + const evaluations: string[] = []; + using graph = new ModuleGraph({ globals: { evaluated: (name: string) => evaluations.push(name) } }); + const counts: number[] = []; + for (let i = 0; i < 5; i++) { + await (async () => counts.push((await graph.import(file)).increment()))(); + Bun.gc(true); + await tick(); + Bun.gc(true); + } + expect({ counts, evaluations }).toEqual({ counts: [1, 2, 3, 4, 5], evaluations: ["registered-dep", "registered"] }); + }); + + test("only `import.meta` retained: its require() still returns the graph's instances", async () => { + using dir = tempDir("module-graph-lifecycle-gc-meta", { + "meta-holder.mjs": `import { increment } from "./meta-state.mjs"; increment(); export const meta = import.meta;`, + "meta-state.mjs": `let count = 0; export const increment = () => ++count; export const read = () => [tenant, count];`, + }); + const file = join(String(dir), "meta-holder.mjs"); + + const meta = await (async () => (await new ModuleGraph({ globals: { tenant: "meta" } }).import(file)).meta)(); + Bun.gc(true); + await tick(); + Bun.gc(true); + expect(meta.require("./meta-state.mjs").read()).toEqual(["meta", 1]); + expect(meta.main).toBe(true); + }); + + test("a graph with nothing retained but a pending import() still settles it", async () => { + using dir = tempDir("module-graph-lifecycle-gc-pending", { + "pending.mjs": ` + import { value } from "./pending-dep.mjs"; + started(); + const opened = await gate; + export const seen = [value, opened, tenant, (await import("./pending-late.mjs")).value]; + `, + "pending-dep.mjs": `export const value = "dep";`, + "pending-late.mjs": `export const value = "loaded after the gate, " + tenant;`, + }); + const file = join(String(dir), "pending.mjs"); + + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + const pending = (() => + new ModuleGraph({ globals: { gate: gate.promise, started: started.resolve, tenant: "t" } }).import(file))(); + Bun.gc(true); + await started.promise; + for (let i = 0; i < 3; i++) { + Bun.gc(true); + await tick(); + } + gate.resolve("opened"); + expect((await pending).seen).toEqual(["dep", "opened", "t", "loaded after the gate, t"]); + }); + + test("collections in the middle of loading and evaluating", async () => { + const files: Record = { + "gc-entry.mjs": ` + import { chain } from "./gc-0.mjs"; + Bun.gc(true); + export const result = [chain, (await import("./gc-dynamic.mjs")).value]; + `, + "gc-dynamic.mjs": `Bun.gc(true); await null; Bun.gc(true); export const value = tenant.name;`, + }; + for (let i = 0; i < 10; i++) { + files[`gc-${i}.mjs`] = + i === 9 + ? `Bun.gc(true); export const chain = [tenant.name];` + : `import { chain as rest } from "./gc-${i + 1}.mjs"; Bun.gc(true); export const chain = [...rest, ${i}];`; + } + using dir = tempDir("module-graph-lifecycle-gc-during", files); + const file = join(String(dir), "gc-entry.mjs"); + + const pending: Promise[] = []; + for (const name of ["a", "b", "c"]) { + pending.push(new ModuleGraph({ globals: { tenant: { name } } }).import(file)); + Bun.gc(true); + } + const results = (await Promise.all(pending)).map(namespace => namespace.result); + expect(results).toEqual(["a", "b", "c"].map(name => [[name, 8, 7, 6, 5, 4, 3, 2, 1, 0], name])); + }); + + describe.each(["dropped", "disposed and dropped"])("300 graphs, %s, are reclaimed", mode => { + test("along with their modules' state", async () => { + using dir = tempDir("module-graph-lifecycle-gc-reclaim", { + "heavy.mjs": ` + const buffer = new ArrayBuffer(1024 * 1024); + export const token = { buffer }; + export const size = () => buffer.byteLength; + `, + }); + const file = join(String(dir), "heavy.mjs"); + + const total = 300; + const { collected, register, until } = finalizations(); + async function create(i: number) { + const graph = new ModuleGraph({ globals: { index: i } }); + const namespace = await graph.import(file); + register(namespace.token, `token ${i}`); + register(graph, `graph ${i}`); + if (mode === "disposed and dropped") graph.dispose(); + return namespace.size(); + } + let bytes = 0; + for (let i = 0; i < total; i++) bytes += await create(i); + expect(bytes).toBe(total * 1024 * 1024); + + const count = (prefix: string) => [...collected].filter(name => name.startsWith(prefix)).length; + await until(() => count("token") >= total - 10 && count("graph") >= total - 10); + expect(count("token")).toBeGreaterThanOrEqual(total - 10); + expect(count("graph")).toBeGreaterThanOrEqual(total - 10); + }); + }); + + // The tests below make several graphs at a time and require all but one to be collected: a + // graph with `globals` that imported something has been observed to stay uncollected until the + // next such graph is made. + const several = 6; + const count = (collected: Set, prefix: string) => + [...collected].filter(name => name.startsWith(prefix)).length; + + test("the ModuleGraph object, its globals and its onError live exactly as long as its code", async () => { + using dir = tempDir("module-graph-lifecycle-gc-globals", { + "holder.mjs": `export const unrelated = () => 1; export const read = () => token.name;`, + }); + const file = join(String(dir), "holder.mjs"); + + const { collected, register, until, settle } = finalizations(); + // The graphs' code is held through this array only, never in a local of this async function. + const held: (() => number)[] = []; + async function create(i: number) { + const token = { name: "the token" }; + const onError = () => {}; + const graph = new ModuleGraph({ globals: { token }, onError }); + register(token, `token ${i}`); + register(onError, `onError ${i}`); + register(graph, `graph ${i}`); + // A function that does not even mention `token`. + held.push((await graph.import(file)).unrelated); + } + for (let i = 0; i < several; i++) await create(i); + await settle(); + expect([...collected]).toEqual([]); + expect(held.map(unrelated => unrelated())).toEqual(Array(several).fill(1)); + + held.length = 0; + const counts = () => ["token", "onError", "graph"].map(prefix => count(collected, prefix)); + await until(() => counts().every(n => n >= several - 1)); + expect(Math.min(...counts())).toBeGreaterThanOrEqual(several - 1); + }); + + test("a disposed graph's globals are still alive for its code, and are released with it", async () => { + using dir = tempDir("module-graph-lifecycle-gc-disposed-globals", { + "disposed-holder.mjs": `export const read = () => token.name;`, + }); + const file = join(String(dir), "disposed-holder.mjs"); + + const { collected, register, until, settle } = finalizations(); + const held: (() => string)[] = []; + async function create(i: number) { + const token = { name: `token ${i}` }; + register(token, token.name); + const graph = new ModuleGraph({ globals: { token } }); + held.push((await graph.import(file)).read); + graph.dispose(); + } + for (let i = 0; i < several; i++) await create(i); + await settle(); + expect([...collected]).toEqual([]); + expect(held.map(read => read())).toEqual(Array.from({ length: several }, (_, i) => `token ${i}`)); + + held.length = 0; + await until(() => collected.size >= several - 1); + expect(collected.size).toBeGreaterThanOrEqual(several - 1); + }); + + test("graphs that were never used are collected", async () => { + const { collected, register, until } = finalizations(); + (() => { + register(new ModuleGraph(), "no options"); + register(new ModuleGraph({ globals: { big: new ArrayBuffer(1024) }, onError() {} }), "options"); + const disposed = new ModuleGraph({ globals: { big: new ArrayBuffer(1024) } }); + disposed.dispose(); + register(disposed, "disposed"); + })(); + expect(await until(() => collected.size === 3)).toBe(true); + }); + + test("graphs that refer to themselves through their globals and onError are collected", async () => { + using dir = tempDir("module-graph-lifecycle-gc-cycle", { + "cycle.mjs": `export const self = () => graph(); export const token = holder;`, + }); + const file = join(String(dir), "cycle.mjs"); + + const { collected, register, until } = finalizations(); + async function create(i: number) { + const holder: any = {}; + const graph = new ModuleGraph({ globals: { holder, graph: () => graph }, onError: () => graph }); + holder.graph = graph; + holder.namespace = await graph.import(file); + register(graph, `graph ${i}`); + register(holder, `holder ${i}`); + return holder.namespace.self() === graph; + } + for (let i = 0; i < several; i++) expect(await create(i)).toBe(true); + const counts = () => ["graph", "holder"].map(prefix => count(collected, prefix)); + await until(() => counts().every(n => n >= several - 1)); + expect(Math.min(...counts())).toBeGreaterThanOrEqual(several - 1); + }); + + test("abandoned import()s that never finish evaluating do not pin their graphs", async () => { + using dir = tempDir("module-graph-lifecycle-gc-abandoned", { + "abandoned.mjs": `started(); await never; export const unreachable = token;`, + }); + const file = join(String(dir), "abandoned.mjs"); + + const { collected, register, until } = finalizations(); + async function create(i: number) { + const token = {}; + const started = Promise.withResolvers(); + const graph = new ModuleGraph({ globals: { token, never: new Promise(() => {}), started: started.resolve } }); + register(token, `token ${i}`); + register(graph, `graph ${i}`); + graph.import(file); + await started.promise; + } + for (let i = 0; i < several; i++) await create(i); + const counts = () => ["graph", "token"].map(prefix => count(collected, prefix)); + await until(() => counts().every(n => n >= several - 1)); + expect(Math.min(...counts())).toBeGreaterThanOrEqual(several - 1); + }); + + test("heap snapshots can be taken while graphs are alive", async () => { + using dir = tempDir("module-graph-lifecycle-gc-snapshot", { + "snapshotted.mjs": `export const read = () => tenant;`, + }); + using graph = new ModuleGraph({ globals: { tenant: { name: "snapshotted" } }, onError() {} }); + const { read } = await graph.import(join(String(dir), "snapshotted.mjs")); + + expect((Bun.generateHeapSnapshot() as any).nodeClassNames).toContain("ModuleGraph"); + expect(Bun.generateHeapSnapshot("v8")).toContain("ModuleGraph"); + expect(read()).toEqual({ name: "snapshotted" }); + }); + + test("one graph's function held only by another graph's globals", async () => { + using dir = tempDir("module-graph-lifecycle-gc-cross", { + "producer.mjs": `let count = 0; export const produce = () => [tenant, ++count];`, + "consumer.mjs": `export const consume = () => [tenant, produce()];`, + }); + + async function create() { + const producer = await new ModuleGraph({ globals: { tenant: "producer" } }).import( + join(String(dir), "producer.mjs"), + ); + const graph = new ModuleGraph({ globals: { tenant: "consumer", produce: producer.produce } }); + return (await graph.import(join(String(dir), "consumer.mjs"))).consume; + } + const consume = await create(); + Bun.gc(true); + await tick(); + Bun.gc(true); + expect([consume(), consume()]).toEqual([ + ["consumer", ["producer", 1]], + ["consumer", ["producer", 2]], + ]); + }); + + describe.each([ + ["BUN_JSC_collectContinuously", "1", 10], + ["BUN_JSC_slowPathAllocsBetweenGCs", "10", 24], + ])("under %s=%s", (option, value, graphs) => { + test.concurrent("graphs are created, nested, disposed mid-import, dropped and still used", async () => { + using dir = tempDir("module-graph-lifecycle-gc-stress", { + "stressed-state.mjs": `let count = 0; export const increment = () => ++count;`, + "stressed-inner.mjs": ` + import { increment } from "./stressed-state.mjs"; + export const read = () => [tenant.name, increment()]; + `, + "stressed.mjs": ` + import { increment } from "./stressed-state.mjs"; + export { increment }; + started(); + export const opened = await gate; + export const dynamic = await import("./stressed-state.mjs").then(state => state.increment === increment, error => error.code); + const innerGraph = new Bun.unsafe.ModuleGraph({ globals: { tenant: { name: tenant.name + " > inner" } } }); + export const inner = await innerGraph.import("./stressed-inner.mjs"); + export const meta = import.meta; + export const rejectLater = () => { Promise.reject(new Error("rejected by " + tenant.name)); }; + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/stressed.mjs"; + const errors = []; + async function load(i) { + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + const graph = new ModuleGraph({ + globals: { tenant: { name: "graph " + i }, gate: gate.promise, started: started.resolve }, + onError: error => errors.push(error.message), + }); + const pending = graph.import(file); + await started.promise; + if (i % 2) graph.dispose(); + gate.resolve("opened " + i); + return pending; + } + function required(namespace) { + try { + return namespace.meta.require("./stressed-state.mjs").increment === namespace.increment; + } catch (error) { + return error.code; + } + } + const results = []; + for (let i = 0; i < ${graphs}; i++) { + const namespace = await load(i); + namespace.increment(); + results.push([namespace.opened, namespace.dynamic, namespace.inner.read(), namespace.increment(), required(namespace)]); + namespace.rejectLater(); + } + while (errors.length < ${graphs}) await new Promise(resolve => setImmediate(resolve)); + console.log(JSON.stringify({ results, errors })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: { ...bunEnv, [option]: value }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + results: Array.from({ length: graphs }, (_, i) => { + const disposed = i % 2 === 1; + return [ + `opened ${i}`, + disposed ? "ERR_INVALID_STATE" : true, + [`graph ${i} > inner`, 1], + 2, + disposed ? "ERR_INVALID_STATE" : true, + ]; + }), + errors: Array.from({ length: graphs }, (_, i) => `rejected by graph ${i}`), + }); + expect(exitCode).toBe(0); + }); + }); +}); + +describe("scale", () => { + describe("1,000 live graphs of the same module", () => { + const total = 1000; + const namespaces: any[] = []; + + // In batches, so that no single test is slow on a debug build. + test.each([0, 250, 500, 750])("graphs %d and up", async first => { + using dir = tempDir("module-graph-lifecycle-scale-graphs", { + "scaled.mjs": ` + export let count = 0; + export const increment = () => ++count; + export const read = () => [index, count]; + `, + }); + const file = join(String(dir), "scaled.mjs"); + for (let i = first; i < first + 250; i++) { + const graph = new ModuleGraph({ globals: { index: i } }); + const namespace = await graph.import(file); + for (let n = 0; n <= i % 3; n++) namespace.increment(); + if (i % 2) graph.dispose(); + namespaces.push(namespace); + } + }); + + test("each has its own state", () => { + Bun.gc(true); + expect(new Set(namespaces).size).toBe(total); + expect(namespaces.map(namespace => namespace.read())).toEqual( + Array.from({ length: total }, (_, i) => [i, (i % 3) + 1]), + ); + expect(namespaces.map(namespace => namespace.count)).toEqual( + Array.from({ length: total }, (_, i) => (i % 3) + 1), + ); + namespaces.length = 0; + }); + }); + + test("one graph imports 300 modules", async () => { + const total = 300; + const files: Record = { + "wide.mjs": ` + ${Array.from({ length: total }, (_, i) => `import { value as v${i} } from "./wide-${i}.mjs";`).join("\n")} + export const values = [${Array.from({ length: total }, (_, i) => `v${i}`).join(", ")}]; + `, + }; + for (let i = 0; i < total; i++) { + // Every module also imports its predecessor, so the graph is wide and 300 deep. + files[`wide-${i}.mjs`] = + i === 0 + ? `evaluated(); export const value = base;` + : `import { value as previous } from "./wide-${i - 1}.mjs"; evaluated(); export const value = previous + 1;`; + } + using dir = tempDir("module-graph-lifecycle-scale-modules", files); + const file = join(String(dir), "wide.mjs"); + + const evaluations = { a: 0, b: 0 }; + using a = new ModuleGraph({ globals: { base: 0, evaluated: () => evaluations.a++ } }); + using b = new ModuleGraph({ globals: { base: 1000, evaluated: () => evaluations.b++ } }); + const [inA, inB] = await Promise.all([a.import(file), b.import(file)]); + expect(inA.values).toEqual(Array.from({ length: total }, (_, i) => i)); + expect(inB.values).toEqual(Array.from({ length: total }, (_, i) => 1000 + i)); + expect(evaluations).toEqual({ a: total, b: total }); + expect((await a.import(join(String(dir), "wide-299.mjs"))).value).toBe(299); + }); + + test("50 graphs import concurrently", async () => { + using dir = tempDir("module-graph-lifecycle-scale-concurrent", { + "concurrent.mjs": ` + import { order } from "./concurrent-dep.mjs"; + await Promise.resolve(); + export const result = [index, order, (await import("./concurrent-late.mjs")).late]; + `, + "concurrent-dep.mjs": `await null; export const order = next();`, + "concurrent-late.mjs": `export const late = index * 2;`, + }); + const file = join(String(dir), "concurrent.mjs"); + + let order = 0; + const graphs = Array.from( + { length: 50 }, + (_, index) => new ModuleGraph({ globals: { index, next: () => order++ } }), + ); + const namespaces = await Promise.all(graphs.map(graph => graph.import(file))); + const results = namespaces.map(namespace => namespace.result); + expect(results.map(([index, , late]) => [index, late])).toEqual(Array.from({ length: 50 }, (_, i) => [i, i * 2])); + expect(results.map(([, order]) => order).sort((a, b) => a - b)).toEqual(Array.from({ length: 50 }, (_, i) => i)); + for (const graph of graphs) graph.dispose(); + }); + + test("100 concurrent import()s of one module in one graph evaluate it once", async () => { + using dir = tempDir("module-graph-lifecycle-scale-same", { + "same.mjs": `evaluated(); await gate; export const value = "once";`, + }); + const file = join(String(dir), "same.mjs"); + + let evaluations = 0; + const gate = Promise.withResolvers(); + using graph = new ModuleGraph({ globals: { evaluated: () => evaluations++, gate: gate.promise } }); + const pending = Array.from({ length: 100 }, () => graph.import(file)); + Bun.gc(true); + gate.resolve(); + const namespaces = await Promise.all(pending); + expect({ distinct: new Set(namespaces).size, evaluations, value: namespaces[0].value }).toEqual({ + distinct: 1, + evaluations: 1, + value: "once", + }); + }); +}); + +describe("nesting", () => { + test("a graph made by another graph's code, from a constructor passed through `globals`", async () => { + using dir = tempDir("module-graph-lifecycle-nested-globals", { + "outer.mjs": ` + import * as state from "./nested-state.mjs"; + state.increment(); + export const inner = new Graph({ globals: { tenant: tenant + " > inner" } }); + // Relative to this module, which is the caller. + export const innerState = await inner.import("./nested-state.mjs"); + export { state }; + `, + "nested-state.mjs": `let count = 0; export const increment = () => ++count; export const read = () => [tenant, count];`, + }); + + using outer = new ModuleGraph({ globals: { tenant: "outer", Graph: ModuleGraph } }); + const namespace = await outer.import(join(String(dir), "outer.mjs")); + namespace.innerState.increment(); + namespace.innerState.increment(); + expect({ + inner: namespace.inner instanceof ModuleGraph, + distinct: namespace.innerState !== namespace.state, + outer: namespace.state.read(), + innerState: namespace.innerState.read(), + mainModules: [outer.mainModule, namespace.inner.mainModule], + }).toEqual({ + inner: true, + distinct: true, + outer: ["outer", 1], + innerState: ["outer > inner", 2], + mainModules: [join(String(dir), "outer.mjs"), join(String(dir), "nested-state.mjs")], + }); + + // Disposing the outer graph leaves the inner one usable. + outer.dispose(); + expect(await namespace.inner.import(join(String(dir), "nested-state.mjs"))).toBe(namespace.innerState); + namespace.inner.dispose(); + }); + + test("a module that loads itself into a new graph, five levels deep", async () => { + using dir = tempDir("module-graph-lifecycle-nested-recursive", { + "recursive.mjs": ` + export const level = depth; + export const main = import.meta.main; + export const child = depth < 5 + ? await new Bun.unsafe.ModuleGraph({ globals: { depth: depth + 1 } }).import(import.meta.path) + : null; + `, + }); + + using graph = new ModuleGraph({ globals: { depth: 1 } }); + const levels: unknown[] = []; + for ( + let namespace = await graph.import(join(String(dir), "recursive.mjs")); + namespace; + namespace = namespace.child + ) { + levels.push([namespace.level, namespace.main]); + Bun.gc(true); + } + expect(levels).toEqual([ + [1, true], + [2, true], + [3, true], + [4, true], + [5, true], + ]); + }); + + test("one graph's namespace as another graph's global", async () => { + using dir = tempDir("module-graph-lifecycle-nested-namespace", { + "library.mjs": `let count = 0; export const increment = () => ++count; export const owner = () => tenant;`, + "consumer.mjs": ` + import * as own from "./library.mjs"; + export const result = () => [library.increment(), library.owner(), own.increment(), own.owner(), library === own]; + `, + }); + + using a = new ModuleGraph({ globals: { tenant: "a" } }); + const library = await a.import(join(String(dir), "library.mjs")); + using b = new ModuleGraph({ globals: { tenant: "b", library } }); + const consumer = await b.import(join(String(dir), "consumer.mjs")); + library.increment(); + expect(consumer.result()).toEqual([2, "a", 1, "b", false]); + a.dispose(); + expect(consumer.result()).toEqual([3, "a", 2, "b", false]); + }); + + test("a module that imports itself through its own graph object while it evaluates", async () => { + using dir = tempDir("module-graph-lifecycle-nested-self", { + "reentrant.mjs": ` + export const viaGraph = self().import(import.meta.path); + export const viaSibling = self().import("./reentrant-sibling.mjs"); + export const value = "evaluated"; + `, + "reentrant-sibling.mjs": `export const sibling = typeof self;`, + }); + const file = join(String(dir), "reentrant.mjs"); + + const graph = new ModuleGraph({ globals: { self: () => graph } }); + const namespace = await graph.import(file); + expect(await namespace.viaGraph).toBe(namespace); + expect((await namespace.viaSibling).sibling).toBe("function"); + expect(graph.mainModule).toBe(file); + graph.dispose(); + }); +}); + +describe("shared runtime state", () => { + test("builtin modules: a record per graph, the same exports", async () => { + using graph = new ModuleGraph(); + const [fs, bun, bunTest] = await Promise.all([ + graph.import("node:fs"), + graph.import("bun"), + graph.import("bun:test"), + ]); + expect({ + mainModule: graph.mainModule, + readFileSync: fs.readFileSync === readFileSync, + again: (await graph.import("node:fs")) === fs, + bun: bun.default === Bun, + expect: bunTest.expect === expect, + }).toEqual({ mainModule: "node:fs", readFileSync: true, again: true, bun: true, expect: true }); + }); + + test("a graph's module can use bun:test's expect and mocks", async () => { + using dir = tempDir("module-graph-lifecycle-bun-test", { + "asserts.mjs": ` + import { expect, mock } from "bun:test"; + export const check = value => { expect(value).toBe(tenant); }; + export const spy = mock(() => tenant); + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "expected" } }); + const { check, spy } = await graph.import(join(String(dir), "asserts.mjs")); + check("expected"); + expect(() => check("something else")).toThrow(); + expect(spy()).toBe("expected"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("timers interoperate with the host's", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-timers", { + "timers.mjs": ` + export const start = callback => setTimeout(callback, 0, "from the graph"); + export const startInterval = callback => setInterval(callback, 1); + export const cancel = timer => clearTimeout(timer); + export const sameFunctions = [setTimeout === globalThis.setTimeout, clearTimeout === globalThis.clearTimeout]; + `, + }); + using graph = new ModuleGraph(); + const namespace = await graph.import(join(String(dir), "timers.mjs")); + + const fired: string[] = []; + const done = Promise.withResolvers(); + const cancelledByHost = namespace.start(() => fired.push("cancelled by the host")); + clearTimeout(cancelledByHost); + const cancelledByGraph = setTimeout(() => fired.push("cancelled by the graph"), 0); + namespace.cancel(cancelledByGraph); + let ticks = 0; + const interval = namespace.startInterval(() => { + if (++ticks === 3) { + clearInterval(interval); + namespace.start((message: string) => { + fired.push(message); + done.resolve(); + }); + } + }); + graph.dispose(); + await done.promise; + + expect({ fired, ticks, sameFunctions: namespace.sameFunctions, timeout: cancelledByHost.constructor.name }).toEqual( + { + fired: ["from the graph"], + ticks: 3, + sameFunctions: [true, true], + timeout: "Timeout", + }, + ); + }); + + test("AsyncLocalStorage context flows into and through graph code", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-als", { + "als.mjs": ` + import { AsyncLocalStorage } from "node:async_hooks"; + export const own = new AsyncLocalStorage(); + export async function observe(storage) { + const seen = [storage.getStore()]; + await null; + seen.push(storage.getStore()); + await new Promise(resolve => setImmediate(resolve)); + seen.push(storage.getStore()); + await import("./als-late.mjs"); + seen.push(storage.getStore()); + return seen; + } + export const runOwn = (store, fn) => own.run(store, fn); + `, + "als-late.mjs": `export default 1;`, + }); + using graph = new ModuleGraph(); + const namespace = await graph.import(join(String(dir), "als.mjs")); + + const storage = new AsyncLocalStorage(); + const [first, second, outside] = await Promise.all([ + storage.run("first", () => namespace.observe(storage)), + storage.run("second", () => namespace.observe(storage)), + namespace.observe(storage), + ]); + expect({ first, second, outside }).toEqual({ + first: ["first", "first", "first", "first"], + second: ["second", "second", "second", "second"], + outside: [undefined, undefined, undefined, undefined], + }); + // A store the graph created is visible to host code it calls. + expect(namespace.runOwn("graph store", () => namespace.own.getStore())).toBe("graph store"); + expect(namespace.own).toBeInstanceOf(AsyncLocalStorage); + }); + + test("microtasks and process.nextTick interleave with the host's in order", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-ordering", { + "ordering.mjs": ` + export function schedule(log) { + process.nextTick(() => log.push("graph nextTick")); + queueMicrotask(() => log.push("graph microtask")); + Promise.resolve().then(() => log.push("graph then")); + setImmediate(() => log.push("graph immediate")); + } + `, + }); + using graph = new ModuleGraph(); + const { schedule } = await graph.import(join(String(dir), "ordering.mjs")); + function scheduleHost(log: string[]) { + process.nextTick(() => log.push("host nextTick")); + queueMicrotask(() => log.push("host microtask")); + Promise.resolve().then(() => log.push("host then")); + setImmediate(() => log.push("host immediate")); + } + + const log: string[] = []; + const done = Promise.withResolvers(); + setImmediate(() => { + scheduleHost(log); + schedule(log); + scheduleHost(log); + setImmediate(() => done.resolve()); + }); + await done.promise; + + const interleaved = (phase: string) => [`host ${phase}`, `graph ${phase}`, `host ${phase}`]; + const microtasks = ["host microtask", "host then", "graph microtask", "graph then", "host microtask", "host then"]; + expect(log.filter(entry => entry.endsWith("nextTick"))).toEqual(interleaved("nextTick")); + expect(log.filter(entry => entry.endsWith("immediate"))).toEqual(interleaved("immediate")); + expect(log.filter(entry => entry.endsWith("microtask") || entry.endsWith("then"))).toEqual(microtasks); + expect(log.slice(-3)).toEqual(interleaved("immediate")); + }); + + test("values made in a graph are ordinary values of the realm", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-values", { + "values.mjs": ` + export class Point { constructor(x) { this.x = x; } } + export const make = () => ({ + map: new Map([["key", new Set([1])]]), + date: new Date(0), + error: new RangeError("from the graph"), + bytes: new Uint8Array([1, 2, 3]), + point: new Point(1), + now: performance.now(), + }); + export const samePerformance = performance === globalThis.performance; + `, + }); + using graph = new ModuleGraph(); + const namespace = await graph.import(join(String(dir), "values.mjs")); + + const before = performance.now(); + const made = namespace.make(); + const after = performance.now(); + const cloned = structuredClone(made); + expect({ + instances: [made.map instanceof Map, made.error instanceof RangeError, made.bytes instanceof Uint8Array], + cloned: [ + cloned.map.get("key").has(1), + cloned.date.getTime(), + cloned.error.message, + [...cloned.bytes], + cloned.point, + ], + clock: before <= made.now && made.now <= after, + samePerformance: namespace.samePerformance, + inspected: Bun.inspect(made.point), + }).toEqual({ + instances: [true, true, true], + cloned: [true, 0, "from the graph", [1, 2, 3], { x: 1 }], + clock: true, + samePerformance: true, + inspected: "Point {\n x: 1,\n}", + }); + // The namespace object itself is not cloneable, like the host's. + expect(() => structuredClone(namespace)).toThrow(expect.objectContaining({ name: "DataCloneError" })); + }); + + test("Bun.serve() with a graph's fetch handler, fetched by another graph", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-serve", { + "handler.mjs": ` + let requests = 0; + export default { + async fetch(request) { + const body = await request.text(); + return Response.json({ tenant, requests: ++requests, body, url: new URL(request.url).pathname }); + }, + }; + `, + "client.mjs": ` + export const call = async (url, body) => (await fetch(url, { method: "POST", body })).json(); + `, + }); + using serverGraph = new ModuleGraph({ globals: { tenant: "server graph" } }); + using clientGraph = new ModuleGraph(); + const handler = (await serverGraph.import(join(String(dir), "handler.mjs"))).default; + const { call } = await clientGraph.import(join(String(dir), "client.mjs")); + + await using server = Bun.serve({ port: 0, fetch: handler.fetch }); + serverGraph.dispose(); + Bun.gc(true); + const responses = [await call(`${server.url}first`, "one"), await call(`${server.url}second`, "two")]; + expect(responses).toEqual([ + { tenant: "server graph", requests: 1, body: "one", url: "/first" }, + { tenant: "server graph", requests: 2, body: "two", url: "/second" }, + ]); + }); + + test("an error thrown by a graph's Bun.serve() handler reaches the server's error handler", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-serve-error", { + "throwing-handler.mjs": `export const fetch = () => { throw new Error("from the handler"); };`, + }); + const errors: string[] = []; + using graph = new ModuleGraph({ onError: (error: Error) => errors.push(`onError: ${error.message}`) }); + const handler = await graph.import(join(String(dir), "throwing-handler.mjs")); + + await using server = Bun.serve({ + port: 0, + fetch: handler.fetch, + error(error) { + errors.push(`server.error: ${error.message}`); + return new Response("handled", { status: 500 }); + }, + }); + const response = await fetch(server.url); + expect([response.status, await response.text()]).toEqual([500, "handled"]); + expect(errors).toEqual(["server.error: from the handler"]); + }); + + test("EventEmitter and EventTarget across the graph boundary", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-events", { + "events.mjs": ` + import { EventEmitter } from "node:events"; + export const emitter = new EventEmitter(); + export const listen = (target, log) => { + target.on?.("ping", value => log.push("graph heard " + value)); + target.addEventListener?.("ping", event => log.push("graph heard " + event.type)); + }; + export const emit = (target, value) => target.emit(value, "from the graph"); + export const sameClass = [EventEmitter]; + `, + }); + using graph = new ModuleGraph(); + const namespace = await graph.import(join(String(dir), "events.mjs")); + + const log: string[] = []; + const hostEmitter = new EventEmitter(); + const hostTarget = new EventTarget(); + namespace.listen(hostEmitter, log); + namespace.listen(hostTarget, log); + namespace.emitter.on("pong", (value: string) => log.push(`host heard ${value}`)); + hostEmitter.emit("ping", "the host"); + hostTarget.dispatchEvent(new Event("ping")); + namespace.emit(namespace.emitter, "pong"); + + expect(log).toEqual(["graph heard the host", "graph heard ping", "host heard from the graph"]); + expect(namespace.sameClass).toEqual([EventEmitter]); + }); + + test("MessageChannel and BroadcastChannel between a graph and the host", async () => { + using dir = tempDir("module-graph-lifecycle-runtime-channels", { + "channels.mjs": ` + export function echo(port) { + port.onmessage = event => { port.postMessage({ echoed: event.data, tenant }); port.close(); }; + } + export function broadcast(name, message) { + const channel = new BroadcastChannel(name); + channel.postMessage({ message, tenant }); + channel.close(); + } + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "channels" } }); + const namespace = await graph.import(join(String(dir), "channels.mjs")); + + const { port1, port2 } = new MessageChannel(); + const echoed = Promise.withResolvers(); + port1.onmessage = event => echoed.resolve(event.data); + namespace.echo(port2); + port1.postMessage(["to the graph"]); + expect(await echoed.promise).toEqual({ echoed: ["to the graph"], tenant: "channels" }); + port1.close(); + + const name = `module-graph-lifecycle-${crypto.randomUUID()}`; + const receiver = new BroadcastChannel(name); + const received = Promise.withResolvers(); + receiver.onmessage = event => received.resolve(event.data); + namespace.broadcast(name, "hello"); + expect(await received.promise).toEqual({ message: "hello", tenant: "channels" }); + receiver.close(); + }); +}); + +describe("Worker threads", () => { + const workerFiles = { + "worker-state.mjs": `let count = 0; export const increment = () => ++count; export const read = () => [tenant, count];`, + "worker.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/worker-state.mjs"; + self.onmessage = async ({ data: graphs }) => { + const results = []; + for (let i = 0; i < graphs; i++) { + using graph = new ModuleGraph({ globals: { tenant: "worker graph " + i } }); + const state = await graph.import(file); + for (let n = 0; n <= i; n++) state.increment(); + results.push(state.read()); + Bun.gc(true); + } + // The worker's own instance has no \`tenant\`. + const own = await import(file); + let ownRead; + try { ownRead = own.read(); } catch (error) { ownRead = error.name; } + postMessage({ results, ownRead }); + }; + `, + }; + + test("a worker makes its own graphs while the main thread makes some too", async () => { + using dir = tempDir("module-graph-lifecycle-worker", workerFiles); + const file = join(String(dir), "worker-state.mjs"); + + const worker = new Worker(join(String(dir), "worker.mjs")); + const message = Promise.withResolvers(); + worker.onmessage = event => message.resolve(event.data); + worker.onerror = event => message.reject(new Error(event.message)); + worker.postMessage(4); + + // Main-thread graphs of the same file, concurrently with the worker's. + const mine: unknown[] = []; + for (let i = 0; i < 4; i++) { + using graph = new ModuleGraph({ globals: { tenant: `main graph ${i}` } }); + const state = await graph.import(file); + state.increment(); + mine.push(state.read()); + } + + expect(await message.promise).toEqual({ + results: [ + ["worker graph 0", 1], + ["worker graph 1", 2], + ["worker graph 2", 3], + ["worker graph 3", 4], + ], + ownRead: "ReferenceError", + }); + expect(mine).toEqual([ + ["main graph 0", 1], + ["main graph 1", 1], + ["main graph 2", 1], + ["main graph 3", 1], + ]); + const closed = Promise.withResolvers(); + worker.addEventListener("close", () => closed.resolve()); + worker.terminate(); + await closed.promise; + }); + + test("a worker terminated while it holds graphs with imports in flight", async () => { + using dir = tempDir("module-graph-lifecycle-worker-terminate", { + "busy-state.mjs": ` + let ticks = 0; + export const spin = () => { setInterval(() => ticks++, 1); }; + `, + "busy-pending.mjs": `await new Promise(() => {}); export const unreachable = true;`, + "busy-worker.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const graphs = []; + for (let i = 0; i < 8; i++) { + const graph = new ModuleGraph({ globals: { tenant: { i } }, onError() {} }); + graphs.push(graph); + (await graph.import(import.meta.dir + "/busy-state.mjs")).spin(); + graph.import(import.meta.dir + "/busy-pending.mjs"); + if (i % 2) graph.dispose(); + } + postMessage("holding " + graphs.length + " graphs"); + `, + }); + + const worker = new Worker(join(String(dir), "busy-worker.mjs")); + const ready = Promise.withResolvers(); + const closed = Promise.withResolvers(); + worker.onmessage = event => ready.resolve(event.data); + worker.onerror = event => ready.reject(new Error(event.message)); + worker.addEventListener("close", event => closed.resolve((event as any).code)); + expect(await ready.promise).toBe("holding 8 graphs"); + worker.terminate(); + expect(await closed.promise).toBe(0); + + // The main thread's graphs are unaffected. + Bun.gc(true); + using graph = new ModuleGraph(); + expect(typeof (await graph.import(join(String(dir), "busy-state.mjs"))).spin).toBe("function"); + }); + + test("a worker that finishes while one of its graphs has an import in flight", async () => { + using dir = tempDir("module-graph-lifecycle-worker-finish", { + "finish-state.mjs": `let count = 0; export const increment = () => ++count;`, + "finish-pending.mjs": `await new Promise(() => {}); export const unreachable = true;`, + "finish-worker.mjs": ` + const { ModuleGraph } = Bun.unsafe; + new ModuleGraph({ globals: { tenant: {} } }).import(import.meta.dir + "/finish-pending.mjs"); + const state = await new ModuleGraph().import(import.meta.dir + "/finish-state.mjs"); + postMessage("counted to " + state.increment()); + `, + }); + + const worker = new Worker(join(String(dir), "finish-worker.mjs")); + const message = Promise.withResolvers(); + const closed = Promise.withResolvers(); + worker.onmessage = event => message.resolve(event.data); + worker.onerror = event => message.reject(new Error(event.message)); + worker.addEventListener("close", event => closed.resolve((event as any).code)); + expect(await message.promise).toBe("counted to 1"); + expect(await closed.promise).toBe(0); + }); + + test("a graph cannot be posted or transferred to a worker", async () => { + using dir = tempDir("module-graph-lifecycle-worker-post", { "idle-worker.mjs": `self.onmessage = () => {};` }); + const worker = new Worker(join(String(dir), "idle-worker.mjs")); + try { + using graph = new ModuleGraph(); + const dataCloneError = expect.objectContaining({ name: "DataCloneError" }); + expect(() => worker.postMessage(graph)).toThrow(dataCloneError); + expect(() => worker.postMessage({ graph })).toThrow(dataCloneError); + expect(() => worker.postMessage("graph", [graph])).toThrow(); + expect(() => new Worker(join(String(dir), "idle-worker.mjs"), { workerData: graph } as any)).toThrow( + dataCloneError, + ); + } finally { + const closed = Promise.withResolvers(); + worker.addEventListener("close", () => closed.resolve()); + worker.terminate(); + await closed.promise; + } + }); + + test.concurrent("a process exits while graphs in the main thread and a worker have imports in flight", async () => { + using dir = tempDir("module-graph-lifecycle-worker-exit", { + "exit-pending.mjs": `started(); await new Promise(() => {});`, + "exit-worker.mjs": ` + const started = Promise.withResolvers(); + globalThis.graph = new Bun.unsafe.ModuleGraph({ globals: { started: started.resolve } }); + graph.import(import.meta.dir + "/exit-pending.mjs"); + await started.promise; + postMessage("pending"); + setInterval(() => {}, 1000); + `, + "main.mjs": ` + const started = Promise.withResolvers(); + const graph = new Bun.unsafe.ModuleGraph({ globals: { started: started.resolve } }); + graph.import(import.meta.dir + "/exit-pending.mjs"); + await started.promise; + const worker = new Worker(import.meta.dir + "/exit-worker.mjs"); + worker.onmessage = event => { + console.log(JSON.stringify({ worker: event.data, mainModule: graph.mainModule === import.meta.dir + "/exit-pending.mjs" })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ worker: "pending", mainModule: true }); + expect(exitCode).toBe(0); + }); +}); + +describe("node:vm", () => { + test("vm code run by a graph's module sees neither its `globals` nor its module scope", async () => { + using dir = tempDir("module-graph-lifecycle-vm-run", { + "vm-user.mjs": ` + import vm from "node:vm"; + const secret = "module scope"; + export const results = { + own: [typeof tenant, typeof secret], + thisContext: vm.runInThisContext("[typeof tenant, typeof secret, typeof Bun]"), + newContext: vm.runInNewContext("[typeof tenant, typeof secret, typeof Bun, passed]", { passed: tenant }), + script: new vm.Script("[typeof tenant, typeof secret, value * 2]").runInContext(vm.createContext({ value: 21 })), + compiled: vm.compileFunction("return [typeof tenant, typeof secret, argument]", ["argument"])(tenant), + viaFunction: new Function("return [typeof tenant, typeof secret]")(), + viaIndirectEval: (0, eval)("[typeof tenant, typeof secret]"), + viaDirectEval: eval("[typeof tenant, typeof secret]"), + }; + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "vm tenant" } }); + const { results } = await graph.import(join(String(dir), "vm-user.mjs")); + expect(results).toEqual({ + own: ["string", "string"], + thisContext: ["undefined", "undefined", "object"], + newContext: ["undefined", "undefined", "undefined", "vm tenant"], + script: ["undefined", "undefined", 42], + compiled: ["undefined", "undefined", "vm tenant"], + viaFunction: ["undefined", "undefined"], + viaIndirectEval: ["undefined", "undefined"], + viaDirectEval: ["string", "string"], + }); + }); + + test("vm.SourceTextModule and vm.SyntheticModule made by a graph's module", async () => { + using dir = tempDir("module-graph-lifecycle-vm-modules", { + "vm-modules.mjs": ` + import vm from "node:vm"; + export async function run() { + // The callback is the graph's code; the module source is not. + const synthetic = new vm.SyntheticModule(["seen"], function () { this.setExport("seen", typeof tenant); }); + const module = new vm.SourceTextModule( + 'import { seen } from "synthetic"; export default [seen, typeof tenant]; export const load = () => import("anything");', + { importModuleDynamically: () => synthetic }, + ); + await module.link(() => synthetic); + await module.evaluate(); + return [module.namespace.default, (await module.namespace.load()).seen]; + } + `, + }); + using graph = new ModuleGraph({ globals: { tenant: "vm modules" } }); + const { run } = await graph.import(join(String(dir), "vm-modules.mjs")); + expect(await run()).toEqual([["string", "undefined"], "string"]); + }); + + test("import() from code a graph's module compiles at run time", async () => { + using dir = tempDir("module-graph-lifecycle-vm-import", { + "vm-importer.mjs": ` + import vm from "node:vm"; + const target = import.meta.dir + "/vm-target.mjs"; + export const own = () => import("./vm-target.mjs"); + export const viaDirectEval = () => eval("import(target)"); + export const viaIndirectEval = () => (0, eval)("target => import(target)")(target); + export const viaFunction = () => new Function("target", "return import(target)")(target); + const source = "import(" + JSON.stringify(target) + ")"; + export const viaScript = options => new vm.Script(source, options).runInThisContext(); + export const viaScriptInContext = options => new vm.Script(source, options).runInContext(vm.createContext({})); + export const useMainContextDefaultLoader = vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER; + `, + "vm-target.mjs": `export const state = {};`, + }); + using graph = new ModuleGraph(); + const importer = await graph.import(join(String(dir), "vm-importer.mjs")); + const inGraph = await importer.own(); + const inHost = await import(join(String(dir), "vm-target.mjs")); + const owner = (namespace: unknown) => (namespace === inGraph ? "graph" : namespace === inHost ? "host" : namespace); + + const callbackCalls: unknown[] = []; + const importModuleDynamically = (specifier: string, script: unknown, attributes: unknown) => { + callbackCalls.push([specifier, script instanceof vm.Script, attributes]); + return inGraph; + }; + expect({ + own: owner(inGraph), + viaDirectEval: owner(await importer.viaDirectEval()), + viaIndirectEval: owner(await importer.viaIndirectEval()), + viaFunction: owner(await importer.viaFunction()), + viaScriptCallback: owner(await importer.viaScript({ importModuleDynamically })), + viaScriptDefaultLoader: owner( + await importer.viaScript({ importModuleDynamically: importer.useMainContextDefaultLoader }), + ), + viaScriptInContextDefaultLoader: owner( + await importer.viaScriptInContext({ importModuleDynamically: importer.useMainContextDefaultLoader }), + ), + }).toEqual({ + own: "graph", + viaDirectEval: "graph", + // Code with no module of its own belongs to the host. + viaIndirectEval: "host", + viaFunction: "host", + viaScriptCallback: "graph", + viaScriptDefaultLoader: "host", + viaScriptInContextDefaultLoader: "host", + }); + expect(callbackCalls).toEqual([[join(String(dir), "vm-target.mjs"), true, {}]]); + await expect(importer.viaScript()).rejects.toMatchObject({ code: "ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING" }); + + // After dispose(), only import() from the graph's own modules is refused. + graph.dispose(); + const afterDispose = await Promise.allSettled([importer.own(), importer.viaDirectEval(), importer.viaFunction()]); + expect(afterDispose.map((result: any) => result.reason?.code ?? owner(result.value))).toEqual([ + "ERR_INVALID_STATE", + "ERR_INVALID_STATE", + "host", + ]); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-linking.test.ts b/test/js/bun/module-graph/module-graph-linking.test.ts new file mode 100644 index 000000000000..c54fc0caae96 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-linking.test.ts @@ -0,0 +1,2086 @@ +import { describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; +import fs from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const { ModuleGraph } = Bun.unsafe as any; + +// A graph whose modules can call `log(event)`; the events land in `events`. +function graphWithLog(extraGlobals: Record = {}) { + const events: string[] = []; + const graph = new ModuleGraph({ globals: { log: (event: string) => void events.push(event), ...extraGlobals } }); + return { graph, events }; +} + +const settled = (promise: Promise) => + promise.then( + value => ({ status: "fulfilled" as const, value }), + reason => ({ status: "rejected" as const, reason }), + ); + +async function until(condition: () => boolean) { + while (!condition()) await new Promise(resolve => setImmediate(resolve)); +} + +describe("Bun.unsafe.ModuleGraph linking", () => { + describe("import cycles", () => { + const twoModuleCycle = { + "cycle-a.mjs": ` + import { b, readA } from "./cycle-b.mjs"; + log("a:start"); + export function hoisted() { return "hoisted in a"; } + export let a = "a-value"; + export const readB = () => b; + export { readA }; + log("a:end"); + `, + "cycle-b.mjs": ` + import { a, hoisted } from "./cycle-a.mjs"; + log("b:start"); + export const calledBeforeExporterEvaluated = hoisted(); + export let tdz; + try { + tdz = String(a); + } catch (error) { + tdz = [error.constructor === ReferenceError, error instanceof ReferenceError]; + } + export const b = "b-value"; + export const readA = () => a; + log("b:end"); + `, + }; + + test("two modules: hoisted function works and a `let` is in its TDZ before the exporter evaluated", async () => { + using dir = tempDir("module-graph-linking-cycle2", twoModuleCycle); + const { graph, events } = graphWithLog(); + using _ = graph; + + const a = await graph.import(join(String(dir), "cycle-a.mjs")); + const b = await graph.import(join(String(dir), "cycle-b.mjs")); + expect({ + events, + calledBeforeExporterEvaluated: b.calledBeforeExporterEvaluated, + tdz: b.tdz, + readA: b.readA(), + readB: a.readB(), + reExportIsSameFunction: a.readA === b.readA, + }).toEqual({ + events: ["b:start", "b:end", "a:start", "a:end"], + calledBeforeExporterEvaluated: "hoisted in a", + tdz: [true, true], + readA: "a-value", + readB: "b-value", + reExportIsSameFunction: true, + }); + }); + + test("the module a cycle is entered through evaluates last, per graph", async () => { + using dir = tempDir("module-graph-linking-cycle-entry", twoModuleCycle); + const fromA = graphWithLog(); + const fromB = graphWithLog(); + using _a = fromA.graph; + using _b = fromB.graph; + + const inA = await fromA.graph.import(join(String(dir), "cycle-a.mjs")); + const inB = await fromB.graph.import(join(String(dir), "cycle-b.mjs")); + expect({ + fromA: fromA.events, + fromB: fromB.events, + // Entered through b, a evaluates first and b's bindings are the ones in their TDZ. + tdzInB: (await fromB.graph.import(join(String(dir), "cycle-b.mjs"))).tdz, + sameInstance: inA.readA === inB.readA, + }).toEqual({ + fromA: ["b:start", "b:end", "a:start", "a:end"], + fromB: ["a:start", "a:end", "b:start", "b:end"], + tdzInB: "a-value", + sameInstance: false, + }); + }); + + test("three modules", async () => { + using dir = tempDir("module-graph-linking-cycle3", { + "ring-a.mjs": ` + import { fromB } from "./ring-b.mjs"; + log("a"); + export function fromA() { return "A"; } + export const viaB = fromB(); + `, + "ring-b.mjs": ` + import { fromC } from "./ring-c.mjs"; + log("b"); + export function fromB() { return "B" + fromC(); } + `, + "ring-c.mjs": ` + import { fromA, viaB } from "./ring-a.mjs"; + log("c"); + export function fromC() { return "C" + fromA(); } + export const early = fromA(); + export const readViaB = () => viaB; + export let viaBWasInTDZ = false; + try { readViaB(); } catch (error) { viaBWasInTDZ = error instanceof ReferenceError; } + `, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + + const a = await graph.import(join(String(dir), "ring-a.mjs")); + const c = await graph.import(join(String(dir), "ring-c.mjs")); + expect({ events, viaB: a.viaB, early: c.early, viaBWasInTDZ: c.viaBWasInTDZ, readViaB: c.readViaB() }).toEqual({ + events: ["c", "b", "a"], + viaB: "BCA", + early: "A", + viaBWasInTDZ: true, + readViaB: "BCA", + }); + }); + + test("the same cycle in two graphs and the host is three independent cycles", async () => { + using dir = tempDir("module-graph-linking-cycle-independent", { + "journal.mjs": `export const entries = [];`, + "ping.mjs": ` + import { entries } from "./journal.mjs"; + import { pong, pongCount } from "./pong.mjs"; + entries.push("ping"); + export let pingCount = 0; + export function ping(n) { pingCount++; return n > 0 ? pong(n - 1) : "ping"; } + export const counts = () => [pingCount, pongCount]; + export { entries }; + `, + "pong.mjs": ` + import { entries } from "./journal.mjs"; + import { ping, pingCount } from "./ping.mjs"; + entries.push("pong"); + export let pongCount = 0; + export function pong(n) { pongCount++; return n > 0 ? ping(n - 1) : "pong"; } + export { pingCount }; + `, + }); + const file = join(String(dir), "ping.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = await Promise.all([a.import(file), b.import(file), import(file)]); + + expect([inA.ping(4), inB.ping(1), host.ping(0)]).toEqual(["ping", "pong", "ping"]); + expect({ + a: inA.counts(), + b: inB.counts(), + host: host.counts(), + entries: [inA.entries, inB.entries, host.entries], + distinctEntries: new Set([inA.entries, inB.entries, host.entries]).size, + }).toEqual({ + a: [3, 2], + b: [1, 1], + host: [1, 0], + entries: [ + ["pong", "ping"], + ["pong", "ping"], + ["pong", "ping"], + ], + distinctEntries: 3, + }); + }); + + test("a module that imports itself", async () => { + using dir = tempDir("module-graph-linking-self-import", { + "selfish.mjs": ` + import * as self from "./selfish.mjs"; + import { value as own } from "./selfish.mjs"; + export let value = 1; + export const bump = () => ++value; + export const read = () => [self.value, own]; + export { self }; + `, + }); + const file = join(String(dir), "selfish.mjs"); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(file); + const inB = await b.import(file); + inA.bump(); + expect({ + selfIsNamespace: [inA.self === inA, inB.self === inB, inA.self === inB.self], + a: inA.read(), + b: inB.read(), + }).toEqual({ selfIsNamespace: [true, true, false], a: [2, 2], b: [1, 1] }); + }); + + test("reading a namespace property whose binding is in its TDZ throws, per graph", async () => { + using dir = tempDir("module-graph-linking-cycle-namespace-tdz", { + "outer.mjs": ` + import "./inner.mjs"; + export const late = "late"; + export function early() { return "early"; } + `, + "inner.mjs": ` + import * as outer from "./outer.mjs"; + export const observed = { keys: Reflect.ownKeys(outer).slice(0, 2), early: outer.early() }; + try { outer.late; } catch (error) { observed.late = error.name; } + try { Object.getOwnPropertyDescriptor(outer, "late"); } catch (error) { observed.descriptor = error.name; } + try { Object.keys(outer); } catch (error) { observed.objectKeys = error.name; } + observed.has = "late" in outer; + export const readLate = () => outer.late; + `, + }); + using graph = new ModuleGraph(); + await graph.import(join(String(dir), "outer.mjs")); + const inner = await graph.import(join(String(dir), "inner.mjs")); + expect({ ...inner.observed, afterwards: inner.readLate() }).toEqual({ + keys: ["early", "late"], + early: "early", + late: "ReferenceError", + descriptor: "ReferenceError", + objectKeys: "ReferenceError", + has: true, + afterwards: "late", + }); + }); + }); + + describe("bindings", () => { + test("an exported `let` is a live binding, per graph", async () => { + using dir = tempDir("module-graph-linking-live", { + "source.mjs": ` + export let value = "initial"; + export let count = 0; + export function set(next) { value = next; count++; } + `, + "reader.mjs": ` + import { value, count } from "./source.mjs"; + import * as source from "./source.mjs"; + export const read = () => [value, count, source.value, source.count]; + `, + "writer.mjs": `export { set } from "./source.mjs";`, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const readers = [ + await a.import(join(String(dir), "reader.mjs")), + await b.import(join(String(dir), "reader.mjs")), + await import(join(String(dir), "reader.mjs")), + ]; + (await a.import(join(String(dir), "writer.mjs"))).set("from a"); + (await a.import(join(String(dir), "writer.mjs"))).set("from a again"); + (await b.import(join(String(dir), "writer.mjs"))).set("from b"); + + expect(readers.map(reader => reader.read())).toEqual([ + ["from a again", 2, "from a again", 2], + ["from b", 1, "from b", 1], + ["initial", 0, "initial", 0], + ]); + }); + + test("live bindings survive a chain of re-exports", async () => { + using dir = tempDir("module-graph-linking-reexport-chain", { + "origin.mjs": ` + export let level = 0; + export const raise = () => ++level; + export default "origin default"; + `, + "hop1.mjs": `export { level as renamedOnce, raise, default as originDefault } from "./origin.mjs";`, + "hop2.mjs": `export * from "./hop1.mjs";`, + "hop3.mjs": ` + import { renamedOnce } from "./hop2.mjs"; + export { renamedOnce as renamedTwice, raise, originDefault as default } from "./hop2.mjs"; + export const viaImport = () => renamedOnce; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "hop3.mjs")); + const inB = await b.import(join(String(dir), "hop3.mjs")); + inA.raise(); + inA.raise(); + inB.raise(); + const originInA = await a.import(join(String(dir), "origin.mjs")); + + expect({ + a: [inA.renamedTwice, inA.viaImport(), inA.default, Object.keys(inA)], + b: [inB.renamedTwice, inB.viaImport(), inB.default, Object.keys(inB)], + sameFunctionThroughTheChain: inA.raise === originInA.raise, + originLevel: originInA.level, + }).toEqual({ + a: [2, 2, "origin default", ["default", "raise", "renamedTwice", "viaImport"]], + b: [1, 1, "origin default", ["default", "raise", "renamedTwice", "viaImport"]], + sameFunctionThroughTheChain: true, + originLevel: 2, + }); + }); + + test("`export *` skips `default` and lets a local export win", async () => { + using dir = tempDir("module-graph-linking-star", { + "star-source.mjs": ` + export default "source default"; + export const shadowed = "from source"; + export const kept = "kept"; + `, + "star.mjs": ` + export * from "./star-source.mjs"; + export const shadowed = "local"; + `, + }); + using graph = new ModuleGraph(); + const star = await graph.import(join(String(dir), "star.mjs")); + expect({ ...star }).toEqual({ kept: "kept", shadowed: "local" }); + expect("default" in star).toBe(false); + }); + + test("`export * as ns` is the graph's namespace of that module", async () => { + using dir = tempDir("module-graph-linking-star-as", { + "inner-ns.mjs": ` + export let hits = 0; + export const hit = () => ++hits; + export default "inner default"; + `, + "outer-ns.mjs": ` + export * as inner from "./inner-ns.mjs"; + import * as imported from "./inner-ns.mjs"; + export { imported }; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "outer-ns.mjs")); + const inB = await b.import(join(String(dir), "outer-ns.mjs")); + inA.inner.hit(); + expect({ + sameAsImportStar: inA.inner === inA.imported, + sameAsGraphImport: inA.inner === (await a.import(join(String(dir), "inner-ns.mjs"))), + sameAcrossGraphs: inA.inner === inB.inner, + hits: [inA.inner.hits, inB.inner.hits], + default: inA.inner.default, + }).toEqual({ + sameAsImportStar: true, + sameAsGraphImport: true, + sameAcrossGraphs: false, + hits: [1, 0], + default: "inner default", + }); + }); + + test("a name that two `export *` disagree on is left out of the namespace", async () => { + using dir = tempDir("module-graph-linking-star-conflict", { + "left.mjs": `export const clash = "left"; export const onlyLeft = 1;`, + "right.mjs": `export const clash = "right"; export const onlyRight = 2;`, + "both.mjs": ` + export * from "./left.mjs"; + export * from "./right.mjs"; + `, + }); + using graph = new ModuleGraph(); + const both = await graph.import(join(String(dir), "both.mjs")); + expect({ keys: Object.keys(both), clash: both.clash, hasClash: "clash" in both }).toEqual({ + keys: ["onlyLeft", "onlyRight"], + clash: undefined, + hasClash: false, + }); + }); + + test("importing an ambiguous star export by name is a SyntaxError that stays in its graph", async () => { + using dir = tempDir("module-graph-linking-star-ambiguous", { + "amb-left.mjs": `export const clash = "left";`, + "amb-right.mjs": `export const clash = "right";`, + "amb-both.mjs": ` + export * from "./amb-left.mjs"; + export * from "./amb-right.mjs"; + `, + "amb-user.mjs": ` + import { clash } from "./amb-both.mjs"; + export const value = clash; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const failure = await settled(a.import(join(String(dir), "amb-user.mjs"))); + expect(failure).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + + // The modules that did link are still usable in the same graph, and nothing leaked to another graph. + const both = await a.import(join(String(dir), "amb-both.mjs")); + const left = await b.import(join(String(dir), "amb-left.mjs")); + expect([Object.keys(both), left.clash]).toEqual([[], "left"]); + }); + + test("the same binding reached through two `export *` paths is not ambiguous", async () => { + using dir = tempDir("module-graph-linking-star-diamond", { + "gem.mjs": `export let gem = "ruby"; export const recut = next => { gem = next; };`, + "facet1.mjs": `export * from "./gem.mjs";`, + "facet2.mjs": `export * from "./gem.mjs";`, + "crown.mjs": ` + export * from "./facet1.mjs"; + export * from "./facet2.mjs"; + `, + }); + using graph = new ModuleGraph(); + const crown = await graph.import(join(String(dir), "crown.mjs")); + crown.recut("emerald"); + expect({ ...crown, recut: undefined }).toEqual({ gem: "emerald", recut: undefined }); + }); + + test("importing a name a module does not export is a SyntaxError in that graph only", async () => { + using dir = tempDir("module-graph-linking-missing-export", { + "provider.mjs": `export const provided = "provided";`, + "bad-consumer.mjs": ` + import { missing } from "./provider.mjs"; + export const value = missing; + `, + "good-consumer.mjs": `export { provided } from "./provider.mjs";`, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const failure = await settled(a.import(join(String(dir), "bad-consumer.mjs"))); + expect(failure).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + expect(failure.reason.message).toContain("missing"); + + const good = [ + await a.import(join(String(dir), "good-consumer.mjs")), + await b.import(join(String(dir), "good-consumer.mjs")), + await import(join(String(dir), "good-consumer.mjs")), + ]; + expect(good.map(ns => ns.provided)).toEqual(["provided", "provided", "provided"]); + }); + + test("default exports: expression, function, class, anonymous", async () => { + using dir = tempDir("module-graph-linking-default", { + "default-expression.mjs": `export default { made: "here" };`, + "default-function.mjs": `export default function named() { return "named"; }`, + "default-class.mjs": `export default class Shape { static kind = "shape"; }`, + "default-anonymous.mjs": `export default function () {}`, + "default-arrow.mjs": `export default () => {};`, + "defaults.mjs": ` + import expression from "./default-expression.mjs"; + import fn from "./default-function.mjs"; + import Shape from "./default-class.mjs"; + import anonymous from "./default-anonymous.mjs"; + import { default as arrow } from "./default-arrow.mjs"; + export { expression, fn, Shape, anonymous, arrow }; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "defaults.mjs")); + const inB = await b.import(join(String(dir), "defaults.mjs")); + expect({ + expression: inA.expression, + names: [inA.fn.name, inA.Shape.name, inA.arrow.name], + typeofAnonymous: typeof inA.anonymous, + called: inA.fn(), + kind: inA.Shape.kind, + distinctPerGraph: ["expression", "fn", "Shape", "anonymous", "arrow"].map(key => inA[key] !== inB[key]), + sameAsGraphImport: (await a.import(join(String(dir), "default-class.mjs"))).default === inA.Shape, + }).toEqual({ + expression: { made: "here" }, + names: ["named", "Shape", "default"], + typeofAnonymous: "function", + called: "named", + kind: "shape", + distinctPerGraph: [true, true, true, true, true], + sameAsGraphImport: true, + }); + }); + + test('`export { x as "string name" }`', async () => { + using dir = tempDir("module-graph-linking-string-names", { + "string-exporter.mjs": ` + let counter = 0; + const bump = () => ++counter; + export { counter as "the counter", bump as "bump it!", counter as " " }; + `, + "string-importer.mjs": ` + import { "the counter" as counter, "bump it!" as bump } from "./string-exporter.mjs"; + export { " " as "re-exported space" } from "./string-exporter.mjs"; + export const read = () => counter; + export { bump }; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "string-importer.mjs")); + const inB = await b.import(join(String(dir), "string-importer.mjs")); + const exporterInA = await a.import(join(String(dir), "string-exporter.mjs")); + inA.bump(); + inA.bump(); + expect({ + exporterKeys: Object.keys(exporterInA), + exporter: [exporterInA["the counter"], exporterInA[" "], exporterInA["bump it!"] === inA.bump], + a: [inA.read(), inA["re-exported space"]], + b: [inB.read(), inB["re-exported space"]], + }).toEqual({ + exporterKeys: [" ", "bump it!", "the counter"], + exporter: [2, 2, true], + a: [2, 2], + b: [0, 0], + }); + }); + + test("`export default function` and `export { x as default }` are live, `export default ` is not", async () => { + using dir = tempDir("module-graph-linking-default-live", { + "default-live-function.mjs": ` + export default function original() { return "original"; } + export const replace = () => { original = () => "replaced"; }; + `, + "default-live-alias.mjs": ` + let counter = 1; + export { counter as default }; + export const bump = () => ++counter; + `, + "default-snapshot.mjs": ` + let counter = 1; + export default counter; + export const bump = () => ++counter; + `, + "default-reader.mjs": ` + import fn, { replace } from "./default-live-function.mjs"; + import alias, { bump as bumpAlias } from "./default-live-alias.mjs"; + import snapshot, { bump as bumpSnapshot } from "./default-snapshot.mjs"; + export const read = () => [fn(), alias, snapshot]; + export const change = () => { replace(); bumpAlias(); bumpSnapshot(); }; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "default-reader.mjs")); + const inB = await b.import(join(String(dir), "default-reader.mjs")); + inA.change(); + expect({ a: inA.read(), b: inB.read() }).toEqual({ a: ["replaced", 2, 1], b: ["original", 1, 1] }); + }); + + test("an imported binding cannot be assigned, even though the exporter can", async () => { + using dir = tempDir("module-graph-linking-readonly-import", { + "owner.mjs": ` + export let owned = "original"; + export const assign = next => { owned = next; }; + `, + "borrower.mjs": ` + import * as owner from "./owner.mjs"; + export function tryAssign() { + try { owner.owned = "stolen"; } catch (error) { return error.constructor === TypeError; } + } + export const read = () => owner.owned; + export { assign } from "./owner.mjs"; + `, + }); + using graph = new ModuleGraph(); + const borrower = await graph.import(join(String(dir), "borrower.mjs")); + expect([borrower.tryAssign(), borrower.read()]).toEqual([true, "original"]); + borrower.assign("reassigned"); + expect(borrower.read()).toBe("reassigned"); + }); + }); + + describe("module instances", () => { + test("byte-identical modules at different paths are different modules", async () => { + const index = ` + export { name } from "./twin-dep.mjs"; + export let count = 0; + export const increment = () => ++count; + export const url = import.meta.url; + `; + using dir = tempDir("module-graph-linking-twins", { + "first/twin.mjs": index, + "first/twin-dep.mjs": `export const name = "first";`, + "second/twin.mjs": index, + "second/twin-dep.mjs": `export const name = "second";`, + }); + const paths = [join(String(dir), "first", "twin.mjs"), join(String(dir), "second", "twin.mjs")]; + for (const globals of [undefined, { tenant: "a" }, { tenant: "b" }]) { + using graph = new ModuleGraph({ globals }); + const [first, second] = [await graph.import(paths[0]), await graph.import(paths[1])]; + first.increment(); + expect({ + names: [first.name, second.name], + counts: [first.count, second.count], + urls: [first.url, second.url], + }).toEqual({ + names: ["first", "second"], + counts: [1, 0], + urls: paths.map(path => pathToFileURL(path).href), + }); + } + }); + + test("a graph created after a file changed loads the new source; existing instances keep theirs", async () => { + using dir = tempDir("module-graph-linking-changed-file", { + "changing.mjs": `export const version = "first";`, + }); + const file = join(String(dir), "changing.mjs"); + using before = new ModuleGraph(); + const first = await before.import(file); + fs.writeFileSync(file, `export const version = "second, and longer"; export const added = true;`); + using after = new ModuleGraph(); + const second = await after.import(file); + + expect({ + before: { ...first }, + beforeAgain: { ...(await before.import(file)) }, + after: { ...second }, + }).toEqual({ + before: { version: "first" }, + beforeAgain: { version: "first" }, + after: { version: "second, and longer", added: true }, + }); + }); + + test("a JSON module is an object per graph", async () => { + using dir = tempDir("module-graph-linking-json", { + "settings.json": `{ "list": [1, 2, 3] }`, + "settings-reader.mjs": ` + import settings from "./settings.json"; + import * as namespace from "./settings.json"; + export { settings }; + export const sameThroughNamespace = namespace.default === settings; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "settings-reader.mjs")); + const inB = await b.import(join(String(dir), "settings-reader.mjs")); + inA.settings.list.push(4); + expect({ + a: inA.settings, + b: inB.settings, + sameThroughNamespace: [inA.sameThroughNamespace, inB.sameThroughNamespace], + sameAsGraphImport: (await a.import(join(String(dir), "settings.json"))).default === inA.settings, + }).toEqual({ + a: { list: [1, 2, 3, 4] }, + b: { list: [1, 2, 3] }, + sameThroughNamespace: [true, true], + sameAsGraphImport: true, + }); + }); + + test("twenty graphs loading the same modules at once", async () => { + using dir = tempDir("module-graph-linking-twenty", { + "twenty-top.mjs": ` + import { token as left } from "./twenty-left.mjs"; + import { token as right } from "./twenty-right.mjs"; + export const result = [index, left === right, left.index]; + `, + "twenty-left.mjs": `await null; export { token } from "./twenty-bottom.mjs";`, + "twenty-right.mjs": `export { token } from "./twenty-bottom.mjs";`, + "twenty-bottom.mjs": `export const token = { index }; await new Promise(resolve => setImmediate(resolve));`, + }); + const graphs = Array.from({ length: 20 }, (_, index) => new ModuleGraph({ globals: { index } })); + const namespaces = await Promise.all(graphs.map(graph => graph.import(join(String(dir), "twenty-top.mjs")))); + expect(namespaces.map(ns => ns.result)).toEqual(graphs.map((_, index) => [index, true, index])); + for (const graph of graphs) graph.dispose(); + }); + }); + + describe("namespace objects", () => { + test("are spec-shaped module namespace exotic objects in every graph", async () => { + using dir = tempDir("module-graph-linking-namespace-shape", { + "shape.mjs": ` + export const zebra = 1; + export let apple = 2; + export default 3; + export function _underscore() {} + export const Upper = 4; + export const $dollar = 5; + `, + }); + const file = join(String(dir), "shape.mjs"); + using graph = new ModuleGraph(); + const describeNamespace = (ns: any) => ({ + keys: Object.keys(ns), + ownKeys: Reflect.ownKeys(ns), + tag: ns[Symbol.toStringTag], + tagDescriptor: Object.getOwnPropertyDescriptor(ns, Symbol.toStringTag), + toString: Object.prototype.toString.call(ns), + extensible: Object.isExtensible(ns), + sealed: Object.isSealed(ns), + frozen: Object.isFrozen(ns), + constDescriptor: Object.getOwnPropertyDescriptor(ns, "zebra"), + letDescriptor: Object.getOwnPropertyDescriptor(ns, "apple"), + set: Reflect.set(ns, "zebra", 100), + defineSame: Reflect.defineProperty(ns, "zebra", { value: 1 }), + defineOther: Reflect.defineProperty(ns, "zebra", { value: 2 }), + defineNew: Reflect.defineProperty(ns, "fresh", { value: 1 }), + deleteExisting: Reflect.deleteProperty(ns, "zebra"), + deleteMissing: Reflect.deleteProperty(ns, "fresh"), + setPrototypeToObject: Reflect.setPrototypeOf(ns, {}), + zebra: ns.zebra, + }); + + const expected = { + keys: ["$dollar", "Upper", "_underscore", "apple", "default", "zebra"], + ownKeys: ["$dollar", "Upper", "_underscore", "apple", "default", "zebra", Symbol.toStringTag], + tag: "Module", + tagDescriptor: { value: "Module", writable: false, enumerable: false, configurable: false }, + toString: "[object Module]", + extensible: false, + sealed: true, + frozen: false, + constDescriptor: { value: 1, writable: true, enumerable: true, configurable: false }, + letDescriptor: { value: 2, writable: true, enumerable: true, configurable: false }, + set: false, + defineSame: true, + defineOther: false, + defineNew: false, + deleteExisting: false, + deleteMissing: true, + setPrototypeToObject: false, + zebra: 1, + }; + expect(describeNamespace(await graph.import(file))).toEqual(expected); + expect(describeNamespace(await import(file))).toEqual(expected); + expect(Object.getPrototypeOf(await graph.import(file))).toBe(Object.getPrototypeOf(await import(file))); + }); + + test("`import * as ns` is one object within a graph and a different one in every other graph", async () => { + using dir = tempDir("module-graph-linking-namespace-identity", { + "subject.mjs": `export const subject = {};`, + "observer1.mjs": ` + import * as ns from "./subject.mjs"; + export { ns }; + export const dynamic = () => import("./subject.mjs"); + `, + "observer2.mjs": ` + import * as ns from "./subject.mjs"; + export { ns }; + export * as starAs from "./subject.mjs"; + `, + }); + const [subject, observer1, observer2] = ["subject.mjs", "observer1.mjs", "observer2.mjs"].map(name => + join(String(dir), name), + ); + async function collect(load: (specifier: string) => Promise) { + const one = await load(observer1); + const two = await load(observer2); + return [one.ns, two.ns, two.starAs, await one.dynamic(), await load(subject)]; + } + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await collect(specifier => a.import(specifier)); + const inB = await collect(specifier => b.import(specifier)); + const inHost = await collect(specifier => import(specifier)); + + expect([inA, inB, inHost].map(all => new Set(all).size)).toEqual([1, 1, 1]); + expect(new Set([inA[0], inB[0], inHost[0]]).size).toBe(3); + expect(new Set([inA[0].subject, inB[0].subject, inHost[0].subject]).size).toBe(3); + }); + }); + + describe("evaluation order", () => { + test("is post-order and each module evaluates once per graph", async () => { + using dir = tempDir("module-graph-linking-order", { + "order-root.mjs": ` + log("root:before-imports-is-impossible"); + import "./order-left.mjs"; + import "./order-right.mjs"; + import "./order-left.mjs"; + log("root"); + `, + "order-left.mjs": ` + import "./order-left-leaf.mjs"; + log("left"); + `, + "order-left-leaf.mjs": `log("left-leaf");`, + "order-right.mjs": ` + import "./order-right-leaf.mjs"; + import "./order-left-leaf.mjs"; + log("right"); + `, + "order-right-leaf.mjs": `log("right-leaf");`, + }); + const root = join(String(dir), "order-root.mjs"); + const first = graphWithLog(); + const second = graphWithLog(); + using _a = first.graph; + using _b = second.graph; + + await first.graph.import(root); + await first.graph.import(root); + await first.graph.import(join(String(dir), "order-right.mjs")); + await second.graph.import(join(String(dir), "order-right.mjs")); + await second.graph.import(root); + + expect({ first: first.events, second: second.events }).toEqual({ + first: ["left-leaf", "left", "right-leaf", "right", "root:before-imports-is-impossible", "root"], + second: ["right-leaf", "left-leaf", "right", "left", "root:before-imports-is-impossible", "root"], + }); + }); + + test("a diamond's shared dependency evaluates once per graph", async () => { + using dir = tempDir("module-graph-linking-diamond", { + "diamond-top.mjs": ` + import { token as left } from "./diamond-left.mjs"; + import { token as right } from "./diamond-right.mjs"; + import { token } from "./diamond-bottom.mjs"; + log("top"); + export const tokens = [left, right, token]; + `, + "diamond-left.mjs": `log("left"); export { token } from "./diamond-bottom.mjs";`, + "diamond-right.mjs": `log("right"); export { token } from "./diamond-bottom.mjs";`, + "diamond-bottom.mjs": `log("bottom"); export const token = {};`, + }); + const top = join(String(dir), "diamond-top.mjs"); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + const inA = await a.graph.import(top); + const inB = await b.graph.import(top); + + expect({ + a: a.events, + b: b.events, + oneTokenPerGraph: [new Set(inA.tokens).size, new Set(inB.tokens).size], + tokensDifferAcrossGraphs: inA.tokens[0] !== inB.tokens[0], + }).toEqual({ + a: ["bottom", "left", "right", "top"], + b: ["bottom", "left", "right", "top"], + oneTokenPerGraph: [1, 1], + tokensDifferAcrossGraphs: true, + }); + }); + + test("a later entry point reuses what the graph already evaluated", async () => { + using dir = tempDir("module-graph-linking-second-entry", { + "entry-one.mjs": `import { id } from "./entry-shared.mjs"; log("one"); export { id };`, + "entry-two.mjs": `import { id } from "./entry-shared.mjs"; log("two"); export { id };`, + "entry-shared.mjs": `log("shared"); export const id = {};`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const one = await graph.import(join(String(dir), "entry-one.mjs")); + const two = await graph.import(join(String(dir), "entry-two.mjs")); + expect({ events, sameId: one.id === two.id }).toEqual({ events: ["shared", "one", "two"], sameId: true }); + }); + + test("function declarations are initialized before any module of the graph evaluates", async () => { + using dir = tempDir("module-graph-linking-hoisting", { + "hoist-main.mjs": ` + import { useHelper } from "./hoist-dep.mjs"; + export const result = useHelper(); + export function helper() { return "helper from main"; } + `, + "hoist-dep.mjs": ` + import { helper } from "./hoist-main.mjs"; + export const duringEvaluation = helper(); + export const useHelper = () => helper(); + `, + }); + using graph = new ModuleGraph(); + const main = await graph.import(join(String(dir), "hoist-main.mjs")); + const dep = await graph.import(join(String(dir), "hoist-dep.mjs")); + expect([main.result, dep.duringEvaluation]).toEqual(["helper from main", "helper from main"]); + }); + }); + + describe("top-level await", () => { + test("graph.import() settles once the awaiting module finished", async () => { + using dir = tempDir("module-graph-linking-tla-basic", { + "awaiting.mjs": ` + log("before"); + export let value = "not yet"; + value = await gate; + log("after"); + `, + }); + const gate = Promise.withResolvers(); + const { graph, events } = graphWithLog({ gate: gate.promise }); + using _ = graph; + + let namespace: any; + const pending = graph.import(join(String(dir), "awaiting.mjs")).then((ns: any) => (namespace = ns)); + await until(() => events.length === 1); + expect({ events: [...events], settled: namespace !== undefined }).toEqual({ events: ["before"], settled: false }); + + gate.resolve("the awaited value"); + await pending; + expect({ events, value: namespace.value }).toEqual({ events: ["before", "after"], value: "the awaited value" }); + }); + + test("an importer evaluates after its dependency's await completed", async () => { + using dir = tempDir("module-graph-linking-tla-dependency", { + "tla-leaf.mjs": ` + export let stage = "start"; + await null; + stage = "after first await"; + await new Promise(resolve => setImmediate(resolve)); + stage = "done"; + `, + "tla-middle.mjs": ` + import { stage } from "./tla-leaf.mjs"; + export const seenByMiddle = stage; + `, + "tla-top.mjs": ` + import { seenByMiddle } from "./tla-middle.mjs"; + import { stage } from "./tla-leaf.mjs"; + export const seen = [seenByMiddle, stage]; + `, + }); + using graph = new ModuleGraph(); + const top = await graph.import(join(String(dir), "tla-top.mjs")); + expect(top.seen).toEqual(["done", "done"]); + }); + + test("two graphs awaiting at the same time finish independently", async () => { + using dir = tempDir("module-graph-linking-tla-concurrent", { + "tla-gated.mjs": ` + import { mark } from "./tla-gated-dep.mjs"; + export const before = mark("before"); + export const awaited = await gate; + export const after = mark("after"); + `, + "tla-gated-dep.mjs": ` + const marks = []; + export const mark = name => { marks.push(name); return [...marks]; }; + `, + }); + const file = join(String(dir), "tla-gated.mjs"); + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + using a = new ModuleGraph({ globals: { gate: gates[0].promise } }); + using b = new ModuleGraph({ globals: { gate: gates[1].promise } }); + + const finished: string[] = []; + const imports = Promise.all([ + a.import(file).then((ns: any) => (finished.push("a"), ns)), + b.import(file).then((ns: any) => (finished.push("b"), ns)), + ]); + gates[1].resolve("b's value"); + await until(() => finished.length === 1); + gates[0].resolve("a's value"); + const [inA, inB] = await imports; + + expect({ + finished, + a: [inA.before, inA.awaited, inA.after], + b: [inB.before, inB.awaited, inB.after], + }).toEqual({ + finished: ["b", "a"], + a: [["before"], "a's value", ["before", "after"]], + b: [["before"], "b's value", ["before", "after"]], + }); + }); + + test("importing a module again while its await is pending gives the one instance", async () => { + using dir = tempDir("module-graph-linking-tla-reimport", { + "tla-pending.mjs": ` + log("evaluating"); + export const token = {}; + await gate; + log("finished"); + export const dynamic = () => import("./tla-pending.mjs"); + `, + "tla-pending-user.mjs": ` + import { token } from "./tla-pending.mjs"; + log("user"); + export { token }; + `, + }); + const file = join(String(dir), "tla-pending.mjs"); + const gate = Promise.withResolvers(); + const { graph, events } = graphWithLog({ gate: gate.promise }); + using _ = graph; + + const first = graph.import(file); + await until(() => events.length === 1); + const second = graph.import(file); + const user = graph.import(join(String(dir), "tla-pending-user.mjs")); + const third = graph.import(pathToFileURL(file).href); + gate.resolve(); + const namespaces = await Promise.all([first, second, third]); + namespaces.push(await namespaces[0].dynamic()); + + expect({ + events, + distinctNamespaces: new Set(namespaces).size, + userSawTheSameToken: (await user).token === namespaces[0].token, + }).toEqual({ events: ["evaluating", "finished", "user"], distinctNamespaces: 1, userSawTheSameToken: true }); + }); + + test("siblings that await do not block each other; the parent waits for all of them", async () => { + using dir = tempDir("module-graph-linking-tla-siblings", { + "sib-parent.mjs": ` + import "./sib-slow.mjs"; + import "./sib-sync.mjs"; + import "./sib-fast.mjs"; + log("parent"); + `, + "sib-slow.mjs": `log("slow:start"); await slowGate; log("slow:end");`, + "sib-sync.mjs": `log("sync");`, + "sib-fast.mjs": `log("fast:start"); await fastGate; log("fast:end");`, + }); + const slowGate = Promise.withResolvers(); + const fastGate = Promise.withResolvers(); + const { graph, events } = graphWithLog({ slowGate: slowGate.promise, fastGate: fastGate.promise }); + using _ = graph; + + const pending = graph.import(join(String(dir), "sib-parent.mjs")); + await until(() => events.length === 3); + fastGate.resolve(); + await until(() => events.length === 4); + slowGate.resolve(); + await pending; + expect(events).toEqual(["slow:start", "sync", "fast:start", "fast:end", "slow:end", "parent"]); + }); + + test("in a cycle", async () => { + using dir = tempDir("module-graph-linking-tla-cycle", { + "tla-cycle-a.mjs": ` + import { b, readA } from "./tla-cycle-b.mjs"; + log("a:start"); + export const a = "a after " + b; + await null; + log("a:end"); + export { readA }; + `, + "tla-cycle-b.mjs": ` + import { a } from "./tla-cycle-a.mjs"; + log("b:start"); + await gate; + export const b = "b"; + export const readA = () => a; + log("b:end"); + `, + }); + const gate = Promise.withResolvers(); + const { graph, events } = graphWithLog({ gate: gate.promise }); + using _ = graph; + + const throughA = graph.import(join(String(dir), "tla-cycle-a.mjs")); + await until(() => events.length === 1); + // b is suspended at its await and a has not started: importing b now waits for the whole cycle. + const eventsWhenBSettled = graph.import(join(String(dir), "tla-cycle-b.mjs")).then(() => [...events]); + gate.resolve(); + + expect({ + a: (await throughA).readA(), + eventsWhenBSettled: await eventsWhenBSettled, + }).toEqual({ + a: "a after b", + eventsWhenBSettled: ["b:start", "b:end", "a:start", "a:end"], + }); + }); + + test("a module can await another graph's and the host's instance of itself", async () => { + using dir = tempDir("module-graph-linking-tla-nested", { + "nesting-doll.mjs": ` + export const depth = + typeof importElsewhere === "function" ? 1 + (await importElsewhere(import.meta.path)).depth : 0; + `, + }); + const file = join(String(dir), "nesting-doll.mjs"); + using inner = new ModuleGraph({ globals: { importElsewhere: (specifier: string) => import(specifier) } }); + using outer = new ModuleGraph({ globals: { importElsewhere: (specifier: string) => inner.import(specifier) } }); + const outerNamespace = await outer.import(file); + expect([outerNamespace.depth, (await inner.import(file)).depth, (await import(file)).depth]).toEqual([2, 1, 0]); + }); + + test("a rejected await fails that graph's module for good and no other graph's", async () => { + using dir = tempDir("module-graph-linking-tla-reject", { + "tla-rejecting.mjs": ` + log("evaluating in " + label); + export const before = "before"; + await Promise.reject(new Error("rejected in " + label)); + log("unreachable"); + `, + "tla-rejecting-parent.mjs": ` + import "./tla-rejecting-sibling.mjs"; + import "./tla-rejecting.mjs"; + log("unreachable parent"); + `, + "tla-rejecting-sibling.mjs": `log("sibling in " + label);`, + }); + const file = join(String(dir), "tla-rejecting.mjs"); + const parent = join(String(dir), "tla-rejecting-parent.mjs"); + const a = graphWithLog({ label: "a" }); + const b = graphWithLog({ label: "b" }); + using _a = a.graph; + using _b = b.graph; + + const viaParent = await settled(a.graph.import(parent)); + const direct = await settled(a.graph.import(file)); + const again = await settled(a.graph.import(parent)); + const inB = await settled(b.graph.import(file)); + + expect(viaParent).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "rejected in a" }) }); + expect(inB).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "rejected in b" }) }); + expect({ + sameErrorEveryTime: [direct.reason === viaParent.reason, again.reason === viaParent.reason], + a: a.events, + b: b.events, + }).toEqual({ + sameErrorEveryTime: [true, true], + a: ["sibling in a", "evaluating in a"], + b: ["evaluating in b"], + }); + }); + + test("a rejection reaches every importer that was waiting for it and nobody else", async () => { + using dir = tempDir("module-graph-linking-tla-reject-shared", { + "waits-one.mjs": `import "./slow-good.mjs"; import "./slow-bad.mjs"; log("one");`, + "waits-two.mjs": `import "./slow-bad.mjs"; import "./slow-good.mjs"; log("two");`, + "waits-three.mjs": `import "./slow-good.mjs"; log("three"); export const ok = true;`, + "slow-good.mjs": `log("good:start"); await gate; log("good:end");`, + "slow-bad.mjs": `log("bad:start"); await gate; throw new Error("slow-bad failed");`, + }); + const gate = Promise.withResolvers(); + const { graph, events } = graphWithLog({ gate: gate.promise }); + using _ = graph; + + const one = settled(graph.import(join(String(dir), "waits-one.mjs"))); + await until(() => events.length === 2); + const two = settled(graph.import(join(String(dir), "waits-two.mjs"))); + const three = settled(graph.import(join(String(dir), "waits-three.mjs"))); + gate.resolve(); + const results = await Promise.all([one, two, three]); + + expect(results.map(result => result.status)).toEqual(["rejected", "rejected", "fulfilled"]); + expect({ + message: results[0].reason.message, + sameError: results[0].reason === results[1].reason, + events, + }).toEqual({ + message: "slow-bad failed", + sameError: true, + events: ["good:start", "bad:start", "good:end", "three"], + }); + }); + + test("a rejection in a cycle fails every module of the cycle", async () => { + using dir = tempDir("module-graph-linking-tla-reject-cycle", { + "rejecting-cycle-a.mjs": ` + import "./rejecting-cycle-b.mjs"; + log("a:start"); + await null; + throw new Error("a rejected"); + `, + "rejecting-cycle-b.mjs": ` + import "./rejecting-cycle-a.mjs"; + log("b:start"); + await null; + log("b:end"); + `, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const throughA = await settled(graph.import(join(String(dir), "rejecting-cycle-a.mjs"))); + const thenB = await settled(graph.import(join(String(dir), "rejecting-cycle-b.mjs"))); + expect(throughA).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "a rejected" }) }); + expect({ sameError: thenB.reason === throughA.reason, events }).toEqual({ + sameError: true, + events: ["b:start", "b:end", "a:start"], + }); + }); + }); + + describe("errors", () => { + test("a module that throws while evaluating fails once per graph", async () => { + using dir = tempDir("module-graph-linking-throw", { + "thrower.mjs": ` + const where = typeof label === "string" ? label : "host"; + if (typeof log === "function") log("evaluating in " + where); + export const before = "before"; + throw new Error("thrown in " + where); + `, + }); + const file = join(String(dir), "thrower.mjs"); + const a = graphWithLog({ label: "a" }); + const b = graphWithLog({ label: "b" }); + using _a = a.graph; + using _b = b.graph; + + const first = await settled(a.graph.import(file)); + const second = await settled(a.graph.import(file)); + const third = await settled(a.graph.import(pathToFileURL(file).href)); + const inB = await settled(b.graph.import(file)); + const inHost = await settled(import(file)); + + expect([first, inB, inHost]).toEqual([ + { status: "rejected", reason: expect.objectContaining({ message: "thrown in a" }) }, + { status: "rejected", reason: expect.objectContaining({ message: "thrown in b" }) }, + { status: "rejected", reason: expect.objectContaining({ message: "thrown in host" }) }, + ]); + expect(first.reason).toBeInstanceOf(Error); + expect({ + sameErrorEveryTime: [second.reason === first.reason, third.reason === first.reason], + a: a.events, + b: b.events, + }).toEqual({ sameErrorEveryTime: [true, true], a: ["evaluating in a"], b: ["evaluating in b"] }); + }); + + test.each([ + ["42"], + ["null"], + ["undefined"], + ['"a string"'], + ["Symbol.for('thrown symbol')"], + ["{ thrown: 'object' }"], + ])("a module that throws %s", async thrown => { + using dir = tempDir("module-graph-linking-throw-value", { + "throws-value.mjs": ` + log("evaluating"); + throw ${thrown}; + `, + }); + const file = join(String(dir), "throws-value.mjs"); + const { graph, events } = graphWithLog(); + using _ = graph; + const first = await settled(graph.import(file)); + const second = await settled(graph.import(file)); + expect({ first, sameReason: Object.is(first.reason, second.reason), events }).toEqual({ + first: { status: "rejected", reason: new Function(`return ${thrown}`)() }, + sameReason: true, + events: ["evaluating"], + }); + }); + + test("after a failed import the graph keeps what did evaluate and can import more", async () => { + using dir = tempDir("module-graph-linking-throw-recover", { + "recover-entry.mjs": ` + import { id } from "./recover-dep.mjs"; + log("entry"); + throw new Error("entry failed"); + `, + "recover-dep.mjs": `log("dep"); export const id = {}; export let uses = 0; export const use = () => ++uses;`, + "recover-other.mjs": ` + import { id, use } from "./recover-dep.mjs"; + log("other"); + export const used = use(); + export { id }; + `, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const failure = await settled(graph.import(join(String(dir), "recover-entry.mjs"))); + const other = await graph.import(join(String(dir), "recover-other.mjs")); + const dep = await graph.import(join(String(dir), "recover-dep.mjs")); + + expect(failure).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "entry failed" }) }); + expect({ events, used: other.used, uses: dep.uses, sameDep: other.id === dep.id }).toEqual({ + events: ["dep", "entry", "other"], + used: 1, + uses: 1, + sameDep: true, + }); + expect(graph.mainModule).toBe(join(String(dir), "recover-entry.mjs")); + }); + + test("a throwing dependency fails its importers with the same error and they never evaluate", async () => { + using dir = tempDir("module-graph-linking-throw-dependency", { + "importer-one.mjs": `import "./first-dep.mjs"; import "./throwing-dep.mjs"; import "./never-dep.mjs"; log("importer one");`, + "importer-two.mjs": `import "./throwing-dep.mjs"; log("importer two");`, + "first-dep.mjs": `log("first dep");`, + "never-dep.mjs": `log("never dep");`, + "throwing-dep.mjs": `log("throwing dep"); throw new RangeError("dependency failed");`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const one = await settled(graph.import(join(String(dir), "importer-one.mjs"))); + const two = await settled(graph.import(join(String(dir), "importer-two.mjs"))); + const dep = await settled(graph.import(join(String(dir), "throwing-dep.mjs"))); + + expect(one).toEqual({ status: "rejected", reason: expect.any(RangeError) }); + expect({ + sameError: [two.reason === one.reason, dep.reason === one.reason], + events, + }).toEqual({ sameError: [true, true], events: ["first dep", "throwing dep"] }); + + // The sibling that was never reached is still importable. + await graph.import(join(String(dir), "never-dep.mjs")); + expect(events).toEqual(["first dep", "throwing dep", "never dep"]); + }); + + test("importing a module that depends on one that already failed", async () => { + using dir = tempDir("module-graph-linking-throw-then-importer", { + "already-failed.mjs": `log("failing"); throw new Error("failed earlier");`, + "late-importer.mjs": `import "./late-importer-dep.mjs"; import "./already-failed.mjs"; log("late importer");`, + "late-importer-dep.mjs": `log("late importer dep");`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const direct = await settled(graph.import(join(String(dir), "already-failed.mjs"))); + const viaImporter = await settled(graph.import(join(String(dir), "late-importer.mjs"))); + expect(direct).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "failed earlier" }) }); + expect({ sameError: viaImporter.reason === direct.reason, events }).toEqual({ + sameError: true, + events: ["failing", "late importer dep"], + }); + }); + + test("a module that failed in the host evaluates afresh in a graph", async () => { + using dir = tempDir("module-graph-linking-throw-host-first", { + "host-fails.mjs": ` + export const evaluatedWith = permit; + `, + }); + const file = join(String(dir), "host-fails.mjs"); + const inHost = await settled(import(file)); + using graph = new ModuleGraph({ globals: { permit: "permitted" } }); + const inGraph = await graph.import(file); + expect(inHost).toEqual({ status: "rejected", reason: expect.any(ReferenceError) }); + expect(inGraph.evaluatedWith).toBe("permitted"); + expect((await settled(import(file))).reason).toBe(inHost.reason); + }); + + test("an error in a cycle fails every module of the cycle with the same error", async () => { + using dir = tempDir("module-graph-linking-throw-cycle", { + "failing-cycle-a.mjs": ` + import "./failing-cycle-b.mjs"; + log("a"); + throw new Error("a failed"); + `, + "failing-cycle-b.mjs": ` + import "./failing-cycle-a.mjs"; + log("b"); + export const fine = true; + `, + }); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + + const throughA = await settled(a.graph.import(join(String(dir), "failing-cycle-a.mjs"))); + const thenB = await settled(a.graph.import(join(String(dir), "failing-cycle-b.mjs"))); + expect(throughA).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "a failed" }) }); + expect({ sameError: thenB.reason === throughA.reason, events: a.events }).toEqual({ + sameError: true, + events: ["b", "a"], + }); + + // Entered through b in another graph: a evaluates first and throws, b never runs. + const throughB = await settled(b.graph.import(join(String(dir), "failing-cycle-b.mjs"))); + expect(throughB).toEqual({ status: "rejected", reason: expect.objectContaining({ message: "a failed" }) }); + expect({ differentError: throughB.reason !== throughA.reason, events: b.events }).toEqual({ + differentError: true, + events: ["a"], + }); + }); + + test("the same file can fail in one graph and succeed in another and in the host", async () => { + using dir = tempDir("module-graph-linking-throw-isolated", { + "sometimes.mjs": ` + import { loaded } from "./sometimes-dep.mjs"; + if (typeof shouldThrow !== "undefined" && shouldThrow) throw new Error("told to throw"); + export const ok = loaded; + `, + "sometimes-dep.mjs": `export const loaded = "loaded";`, + }); + const file = join(String(dir), "sometimes.mjs"); + using failing = new ModuleGraph({ globals: { shouldThrow: true } }); + using working = new ModuleGraph({ globals: { shouldThrow: false } }); + + const results = [ + await settled(failing.import(file)), + await settled(working.import(file)), + await settled(import(file)), + await settled(failing.import(file)), + ]; + expect( + results.map(result => (result.status === "fulfilled" ? (result.value as any).ok : result.reason.message)), + ).toEqual(["told to throw", "loaded", "loaded", "told to throw"]); + expect((await failing.import(join(String(dir), "sometimes-dep.mjs"))).loaded).toBe("loaded"); + }); + + test("a SyntaxError in a dependency", async () => { + using dir = tempDir("module-graph-linking-syntax-error", { + "syntax-entry.mjs": ` + import "./syntax-good.mjs"; + import "./syntax-broken.mjs"; + log("entry"); + `, + "syntax-good.mjs": `log("good"); export const good = true;`, + "syntax-broken.mjs": `export const broken = ;`, + }); + const entry = join(String(dir), "syntax-entry.mjs"); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + + const inHost = await settled(import(join(String(dir), "syntax-broken.mjs"))); + const first = await settled(a.graph.import(entry)); + const second = await settled(a.graph.import(entry)); + const broken = await settled(a.graph.import(join(String(dir), "syntax-broken.mjs"))); + expect(inHost.status).toBe("rejected"); + expect( + [first, second, broken].map(result => [result.status, result.reason?.constructor, result.reason?.message]), + ).toEqual(Array(3).fill(["rejected", inHost.reason.constructor, inHost.reason.message])); + // Nothing evaluates when the graph of modules fails to load. + expect(a.events).toEqual([]); + + const good = await a.graph.import(join(String(dir), "syntax-good.mjs")); + const goodInB = await b.graph.import(join(String(dir), "syntax-good.mjs")); + expect({ good: [good.good, goodInB.good], a: a.events, b: b.events }).toEqual({ + good: [true, true], + a: ["good"], + b: ["good"], + }); + }); + + test("a dependency that does not resolve", async () => { + using dir = tempDir("module-graph-linking-missing", { + "missing-entry.mjs": ` + import "./missing-good.mjs"; + import "./does-not-exist.mjs"; + log("entry"); + `, + "missing-good.mjs": `log("good"); export const good = true;`, + "missing-unrelated.mjs": `log("unrelated"); export const unrelated = true;`, + }); + const entry = join(String(dir), "missing-entry.mjs"); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + + const inHost = await settled(import(entry)); + const first = await settled(a.graph.import(entry)); + const second = await settled(a.graph.import(entry)); + expect(inHost.status).toBe("rejected"); + expect(inHost.reason.message).toContain("does-not-exist.mjs"); + expect( + [first, second].map(result => [result.status, result.reason?.constructor, result.reason?.message]), + ).toEqual(Array(2).fill(["rejected", inHost.reason.constructor, inHost.reason.message])); + expect(a.events).toEqual([]); + + const unrelated = await a.graph.import(join(String(dir), "missing-unrelated.mjs")); + const good = await a.graph.import(join(String(dir), "missing-good.mjs")); + const goodInB = await b.graph.import(join(String(dir), "missing-good.mjs")); + expect({ values: [unrelated.unrelated, good.good, goodInB.good], a: a.events, b: b.events }).toEqual({ + values: [true, true, true], + a: ["unrelated", "good"], + b: ["good"], + }); + expect(a.graph.mainModule).toBe(entry); + }); + + test("import() of a failed module inside the graph rejects with the graph's error", async () => { + using dir = tempDir("module-graph-linking-throw-dynamic", { + "dynamic-thrower.mjs": `log("thrower"); throw new Error("thrown once");`, + "dynamic-catcher.mjs": ` + export const attempt = () => import("./dynamic-thrower.mjs").then(() => "fulfilled", error => error); + `, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const catcher = await graph.import(join(String(dir), "dynamic-catcher.mjs")); + const fromInside = await catcher.attempt(); + const fromOutside = await settled(graph.import(join(String(dir), "dynamic-thrower.mjs"))); + const fromInsideAgain = await catcher.attempt(); + + expect(fromInside).toBeInstanceOf(Error); + expect({ + message: fromInside.message, + same: [fromOutside.reason === fromInside, fromInsideAgain === fromInside], + events, + }).toEqual({ message: "thrown once", same: [true, true], events: ["thrower"] }); + }); + }); + + describe("dynamic import()", () => { + test("resolves relative to the importing module and gives the graph's namespace", async () => { + using dir = tempDir("module-graph-linking-dynamic-relative", { + "nested/relative-loader.mjs": ` + import * as staticInner from "./inner-target.mjs"; + import * as staticOuter from "../outer-target.mjs"; + export { staticInner, staticOuter }; + export const inner = () => import("./inner-target.mjs"); + export const outer = () => import("../outer-target.mjs"); + export const computed = (directory, name) => import(directory + "/" + name + ".mjs"); + `, + "nested/inner-target.mjs": `export const where = "nested/inner-target";`, + "outer-target.mjs": `export const where = "outer-target";`, + }); + using graph = new ModuleGraph(); + const loader = await graph.import(join(String(dir), "nested", "relative-loader.mjs")); + const [inner, outer, computedInner, computedOuter] = await Promise.all([ + loader.inner(), + loader.outer(), + loader.computed(".", "inner-target"), + loader.computed("..", "outer-target"), + ]); + const host = await import(join(String(dir), "outer-target.mjs")); + + expect({ + where: [inner.where, outer.where], + sameAsStatic: [inner === loader.staticInner, outer === loader.staticOuter], + sameWhenComputed: [computedInner === inner, computedOuter === outer], + sameAsGraphImport: outer === (await graph.import(join(String(dir), "outer-target.mjs"))), + sameAsHost: outer === host, + }).toEqual({ + where: ["nested/inner-target", "outer-target"], + sameAsStatic: [true, true], + sameWhenComputed: [true, true], + sameAsGraphImport: true, + sameAsHost: false, + }); + }); + + test("of a module the host loaded first gives the graph's own instance", async () => { + using dir = tempDir("module-graph-linking-dynamic-host-first", { + "host-first.mjs": ` + export let count = 0; + export const increment = () => ++count; + `, + "host-first-loader.mjs": `export const load = () => import("./host-first.mjs");`, + }); + const host = await import(join(String(dir), "host-first.mjs")); + host.increment(); + host.increment(); + host.increment(); + + using graph = new ModuleGraph(); + const loaded = await (await graph.import(join(String(dir), "host-first-loader.mjs"))).load(); + const hostLoaded = await (await import(join(String(dir), "host-first-loader.mjs"))).load(); + expect({ graph: loaded.count, host: hostLoaded.count, hostIsHost: hostLoaded === host }).toEqual({ + graph: 0, + host: 3, + hostIsHost: true, + }); + loaded.increment(); + expect([loaded.count, host.count]).toEqual([1, 3]); + }); + + test("from a graph function the host calls long after graph.import() finished", async () => { + using dir = tempDir("module-graph-linking-dynamic-late", { + "late-loader.mjs": ` + export const load = () => import("./late-target.mjs"); + export async function loadAfterAwaits() { + await null; + await new Promise(resolve => setImmediate(resolve)); + return import("./late-target.mjs"); + } + `, + "late-target.mjs": `log("late target"); export const id = {};`, + }); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + const loaderA = await a.graph.import(join(String(dir), "late-loader.mjs")); + const loaderB = await b.graph.import(join(String(dir), "late-loader.mjs")); + await new Promise(resolve => setImmediate(resolve)); + expect([a.events, b.events]).toEqual([[], []]); + + const fromTimer = await new Promise(resolve => setTimeout(() => resolve(loaderA.load()), 0)); + const fromReaction = await Promise.resolve().then(() => loaderA.load()); + const afterAwaits = await loaderA.loadAfterAwaits(); + const inB = await loaderB.loadAfterAwaits(); + + expect({ + distinctInA: new Set([fromTimer, fromReaction, afterAwaits]).size, + sameAsGraphImport: fromTimer === (await a.graph.import(join(String(dir), "late-target.mjs"))), + differsInB: inB !== fromTimer, + events: [a.events, b.events], + }).toEqual({ + distinctInA: 1, + sameAsGraphImport: true, + differsInB: true, + events: [["late target"], ["late target"]], + }); + }); + + test("from timers, microtasks and promise reactions created by graph code", async () => { + using dir = tempDir("module-graph-linking-dynamic-callbacks", { + "callbacks.mjs": ` + export const fromTimeout = () => new Promise((resolve, reject) => { + setTimeout(() => { import("./callback-target.mjs").then(resolve, reject); }, 0); + }); + export const fromImmediate = () => new Promise((resolve, reject) => { + setImmediate(() => { import("./callback-target.mjs").then(resolve, reject); }); + }); + export const fromNextTick = () => new Promise((resolve, reject) => { + process.nextTick(() => { import("./callback-target.mjs").then(resolve, reject); }); + }); + export const fromMicrotask = () => new Promise((resolve, reject) => { + queueMicrotask(() => { import("./callback-target.mjs").then(resolve, reject); }); + }); + export const fromReaction = () => Promise.resolve().then(() => { return import("./callback-target.mjs"); }); + export const fromRejectionReaction = () => Promise.reject(0).catch(() => { return import("./callback-target.mjs"); }); + export const fromFinally = () => { + let result; + return Promise.resolve().finally(() => { result = import("./callback-target.mjs"); }).then(() => result); + }; + `, + "callback-target.mjs": `log("callback target"); export const id = {};`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const callbacks = await graph.import(join(String(dir), "callbacks.mjs")); + + const namespaces: Record = {}; + for (const name of Object.keys(callbacks)) namespaces[name] = await callbacks[name](); + const expected = await graph.import(join(String(dir), "callback-target.mjs")); + + expect(Object.keys(namespaces)).toEqual([ + "fromFinally", + "fromImmediate", + "fromMicrotask", + "fromNextTick", + "fromReaction", + "fromRejectionReaction", + "fromTimeout", + ]); + for (const name of Object.keys(namespaces)) expect([name, namespaces[name] === expected]).toEqual([name, true]); + expect(events).toEqual(["callback target"]); + }); + + test("every way of spelling a module is the graph's one instance", async () => { + using dir = tempDir("module-graph-linking-dynamic-spellings", { + "spellings.mjs": ` + export const spellings = () => Promise.all([ + import("./spelled.mjs"), + import(import.meta.dir + "/spelled.mjs"), + import(new URL("./spelled.mjs", import.meta.url).href), + import(import.meta.resolve("./spelled.mjs")), + import(new URL("./spelled.mjs", import.meta.url)), + import("./nowhere/../spelled.mjs"), + ]); + `, + "spelled.mjs": `log("spelled"); export const id = {};`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const namespaces = await (await graph.import(join(String(dir), "spellings.mjs"))).spellings(); + namespaces.push(await graph.import(pathToFileURL(join(String(dir), "spelled.mjs")).href)); + namespaces.push(await graph.import(join(String(dir), "spelled.mjs"))); + expect({ count: namespaces.length, distinct: new Set(namespaces).size, events }).toEqual({ + count: 8, + distinct: 1, + events: ["spelled"], + }); + }); + + test("of builtin modules gives the shared exports", async () => { + using dir = tempDir("module-graph-linking-dynamic-builtin", { + "dynamic-builtins.mjs": ` + export const load = async () => ({ + fs: (await import("node:fs")).default, + join: (await import("node:path")).join, + bareJoin: (await import("path")).join, + bunFile: (await import("bun")).file, + }); + `, + }); + using graph = new ModuleGraph(); + const loaded = await (await graph.import(join(String(dir), "dynamic-builtins.mjs"))).load(); + expect(loaded.fs).toBe(fs); + expect(loaded.join).toBe(join); + expect(loaded.bareJoin).toBe(join); + expect(loaded.bunFile).toBe(Bun.file); + }); + + test("that fails to resolve rejects and leaves the graph usable", async () => { + using dir = tempDir("module-graph-linking-dynamic-missing", { + "dynamic-missing.mjs": ` + export const missing = () => import("./not-there.mjs"); + export const present = () => import("./is-there.mjs"); + `, + "is-there.mjs": `export const here = true;`, + }); + using graph = new ModuleGraph(); + const loader = await graph.import(join(String(dir), "dynamic-missing.mjs")); + const failure = await settled(loader.missing()); + expect(failure.status).toBe("rejected"); + expect(failure.reason.message).toContain("not-there.mjs"); + expect((await loader.present()).here).toBe(true); + expect((await settled(loader.missing())).status).toBe("rejected"); + }); + + test("in direct eval code belongs to the graph; in indirect eval and `new Function` code it belongs to the host", async () => { + using dir = tempDir("module-graph-linking-dynamic-eval", { + "evaluator.mjs": ` + const target = JSON.stringify(import.meta.dir + "/eval-target.mjs"); + export const direct = () => eval('import("./eval-target.mjs")'); + export const indirect = () => (0, eval)("import(" + target + ")"); + export const constructed = () => new Function("return import(" + target + ")")(); + `, + "eval-target.mjs": `export const id = {};`, + }); + using graph = new ModuleGraph(); + const evaluator = await graph.import(join(String(dir), "evaluator.mjs")); + const inGraph = await graph.import(join(String(dir), "eval-target.mjs")); + const inHost = await import(join(String(dir), "eval-target.mjs")); + expect({ + direct: (await evaluator.direct()) === inGraph, + indirect: (await evaluator.indirect()) === inHost, + constructed: (await evaluator.constructed()) === inHost, + }).toEqual({ direct: true, indirect: true, constructed: true }); + }); + + test("belongs to the module whose code contains it, not to whoever calls it", async () => { + using dir = tempDir("module-graph-linking-dynamic-owner", { + "owner-caller.mjs": ` + export const call = fn => fn(); + export const callHost = () => hostImport(import.meta.dir + "/owner-target.mjs"); + export const load = () => import("./owner-target.mjs"); + `, + "owner-target.mjs": `export const id = {};`, + }); + const target = join(String(dir), "owner-target.mjs"); + const hostImport = (specifier: string) => import(specifier); + using a = new ModuleGraph({ globals: { hostImport } }); + using b = new ModuleGraph({ globals: { hostImport } }); + const callerA = await a.import(join(String(dir), "owner-caller.mjs")); + const callerB = await b.import(join(String(dir), "owner-caller.mjs")); + const [inA, inB, inHost] = [await a.import(target), await b.import(target), await import(target)]; + + expect({ + hostFunctionCalledByGraph: (await callerA.callHost()) === inHost, + graphFunctionCalledByOtherGraph: (await callerB.call(callerA.load)) === inA, + graphFunctionCalledByItsGraph: (await callerB.call(callerB.load)) === inB, + hostFunctionPassedToGraph: (await callerA.call(() => import(target))) === inHost, + distinct: new Set([inA, inB, inHost]).size, + }).toEqual({ + hostFunctionCalledByGraph: true, + graphFunctionCalledByOtherGraph: true, + graphFunctionCalledByItsGraph: true, + hostFunctionPassedToGraph: true, + distinct: 3, + }); + }); + + test("of the module itself while it is evaluating resolves to its namespace", async () => { + using dir = tempDir("module-graph-linking-dynamic-self", { + "reentrant.mjs": ` + log("reentrant"); + export const self = import("./reentrant.mjs"); + export const viaGraph = graphImport(import.meta.path); + export const otherViaGraph = graphImport(import.meta.dir + "/reentrant-other.mjs"); + `, + "reentrant-other.mjs": `log("other"); export const other = true;`, + }); + const events: string[] = []; + using graph = new ModuleGraph({ + globals: { + log: (event: string) => void events.push(event), + graphImport: (specifier: string) => graph.import(specifier), + }, + }); + const namespace = await graph.import(join(String(dir), "reentrant.mjs")); + expect({ + self: (await namespace.self) === namespace, + viaGraph: (await namespace.viaGraph) === namespace, + other: (await namespace.otherViaGraph) === (await graph.import(join(String(dir), "reentrant-other.mjs"))), + events, + }).toEqual({ self: true, viaGraph: true, other: true, events: ["reentrant", "other"] }); + }); + + test("after the ModuleGraph object was garbage collected", async () => { + using dir = tempDir("module-graph-linking-dynamic-collected", { + "collected-loader.mjs": ` + import * as statically from "./collected-target.mjs"; + export const load = async () => (await import("./collected-target.mjs")) === statically; + export const loadNew = () => import("./collected-new.mjs"); + `, + "collected-target.mjs": `export const id = {};`, + "collected-new.mjs": `export const label = tenant;`, + }); + async function loaders() { + const graph = new ModuleGraph({ globals: { tenant: "collected tenant" } }); + const { load, loadNew } = await graph.import(join(String(dir), "collected-loader.mjs")); + return { load, loadNew }; + } + const { load, loadNew } = await loaders(); + Bun.gc(true); + expect({ sameAsStatic: await load(), label: (await loadNew()).label }).toEqual({ + sameAsStatic: true, + label: "collected tenant", + }); + }); + + test("during evaluation of a static importer sees the same instance", async () => { + using dir = tempDir("module-graph-linking-dynamic-during-evaluation", { + "during-main.mjs": ` + import { id } from "./during-dep.mjs"; + const dynamic = await import("./during-dep.mjs"); + export const observed = { sameId: dynamic.id === id }; + log("main"); + `, + "during-dep.mjs": `log("dep"); export const id = {};`, + }); + const { graph, events } = graphWithLog(); + using _ = graph; + const main = await graph.import(join(String(dir), "during-main.mjs")); + expect({ observed: main.observed, events }).toEqual({ + observed: { sameId: true }, + events: ["dep", "main"], + }); + }); + }); + + describe("many modules", () => { + const chainLength = 120; + const chain: Record = {}; + for (let i = 0; i < chainLength; i++) { + chain[`chain-${i}.mjs`] = + i === chainLength - 1 + ? `log(${i}); export const depth = 0; export const bottom = {};` + : `import { depth as next } from "./chain-${i + 1}.mjs"; export { bottom } from "./chain-${i + 1}.mjs"; log(${i}); export const depth = next + 1;`; + } + + test(`a chain of ${chainLength} modules in three graphs`, async () => { + using dir = tempDir("module-graph-linking-chain", chain); + const graphs = [graphWithLog(), graphWithLog(), graphWithLog()]; + const tops = await Promise.all(graphs.map(({ graph }) => graph.import(join(String(dir), "chain-0.mjs")))); + const middles = await Promise.all(graphs.map(({ graph }) => graph.import(join(String(dir), "chain-60.mjs")))); + const descending = Array.from({ length: chainLength }, (_, i) => chainLength - 1 - i); + + expect({ + depths: tops.map(top => top.depth), + middleDepths: middles.map(middle => middle.depth), + bottomsPerGraph: tops.map((top, i) => top.bottom === middles[i].bottom), + distinctBottoms: new Set(tops.map(top => top.bottom)).size, + }).toEqual({ + depths: [119, 119, 119], + middleDepths: [59, 59, 59], + bottomsPerGraph: [true, true, true], + distinctBottoms: 3, + }); + for (const { graph, events } of graphs) { + expect(events).toEqual(descending); + graph.dispose(); + } + }); + + test("a fan of 100 modules over one shared base", async () => { + const width = 100; + const files: Record = { + "fan-base.mjs": `log("base"); export const token = {}; export let touched = 0; export const touch = () => ++touched;`, + }; + const indices = Array.from({ length: width }, (_, i) => i); + for (const i of indices) { + files[`fan-leaf-${i}.mjs`] = ` + import { token, touch } from "./fan-base.mjs"; + touch(); + export const leaf = { token, index: ${i} }; + `; + } + files["fan-root.mjs"] = ` + ${indices.map(i => `import { leaf as leaf${i} } from "./fan-leaf-${i}.mjs";`).join("\n")} + export { touched } from "./fan-base.mjs"; + export const leaves = [${indices.map(i => `leaf${i}`).join(", ")}]; + `; + using dir = tempDir("module-graph-linking-fan", files); + const root = join(String(dir), "fan-root.mjs"); + const a = graphWithLog(); + const b = graphWithLog(); + using _a = a.graph; + using _b = b.graph; + const [inA, inB] = await Promise.all([a.graph.import(root), b.graph.import(root)]); + + const summarize = (ns: any) => ({ + touched: ns.touched, + indices: ns.leaves.map((leaf: any) => leaf.index), + tokens: new Set(ns.leaves.map((leaf: any) => leaf.token)).size, + }); + expect([summarize(inA), summarize(inB)]).toEqual(Array(2).fill({ touched: width, indices, tokens: 1 })); + expect({ a: a.events, b: b.events, sameToken: inA.leaves[0].token === inB.leaves[0].token }).toEqual({ + a: ["base"], + b: ["base"], + sameToken: false, + }); + }); + + test("concurrent imports of overlapping module sets into one graph give single instances", async () => { + const sharedCount = 24; + const files: Record = {}; + for (let i = 0; i < sharedCount; i++) { + files[`overlap-shared-${i}.mjs`] = ` + ${i + 1 < sharedCount && i % 3 === 0 ? `import "./overlap-shared-${i + 1}.mjs";` : ""} + log("shared ${i}"); + ${i % 4 === 0 ? "await null;" : ""} + ${i % 8 === 0 ? "await new Promise(resolve => setImmediate(resolve));" : ""} + export const token = { index: ${i} }; + `; + } + const entryCount = 8; + for (let entry = 0; entry < entryCount; entry++) { + const picks = Array.from({ length: 9 }, (_, k) => (entry * 3 + k) % sharedCount); + files[`overlap-entry-${entry}.mjs`] = ` + ${picks.map(i => `import { token as token${i} } from "./overlap-shared-${i}.mjs";`).join("\n")} + log("entry ${entry}"); + export const tokens = { ${picks.map(i => `${i}: token${i}`).join(", ")} }; + `; + } + using dir = tempDir("module-graph-linking-overlap", files); + const entries = Array.from({ length: entryCount }, (_, entry) => join(String(dir), `overlap-entry-${entry}.mjs`)); + const { graph, events } = graphWithLog(); + using _ = graph; + + const namespaces = await Promise.all([...entries, ...entries].map(entry => graph.import(entry))); + const tokensByIndex = new Map>(); + for (const ns of namespaces) { + for (const [index, token] of Object.entries(ns.tokens)) { + if (!tokensByIndex.has(index)) tokensByIndex.set(index, new Set()); + tokensByIndex.get(index)!.add(token); + } + } + + expect({ + sameNamespaceBothTimes: entries.map((_, i) => namespaces[i] === namespaces[i + entryCount]), + tokensSeenPerSharedModule: [...new Set([...tokensByIndex.values()].map(tokens => tokens.size))], + sharedModulesSeen: tokensByIndex.size, + evaluations: [...events].sort(), + }).toEqual({ + sameNamespaceBothTimes: Array(entryCount).fill(true), + tokensSeenPerSharedModule: [1], + sharedModulesSeen: sharedCount, + evaluations: [ + ...Array.from({ length: entryCount }, (_, entry) => `entry ${entry}`), + ...Array.from({ length: sharedCount }, (_, i) => `shared ${i}`), + ].sort(), + }); + for (const [index, tokens] of tokensByIndex) { + expect([...tokens][0]).toBe((await graph.import(join(String(dir), `overlap-shared-${index}.mjs`))).token); + } + }); + + test("the same module imported 30 times at once, into each of three graphs", async () => { + using dir = tempDir("module-graph-linking-same-at-once", { + "at-once.mjs": ` + import "./at-once-dep.mjs"; + log("at-once"); + await null; + export const id = {}; + `, + "at-once-dep.mjs": `log("at-once-dep");`, + }); + const file = join(String(dir), "at-once.mjs"); + const graphs = [graphWithLog(), graphWithLog(), graphWithLog()]; + const namespaces = await Promise.all( + Array.from({ length: 90 }, (_, i) => graphs[i % 3].graph.import(i % 2 ? file : pathToFileURL(file).href)), + ); + expect({ + distinct: new Set(namespaces).size, + perGraph: [0, 1, 2].map(g => new Set(namespaces.filter((_, i) => i % 3 === g)).size), + events: graphs.map(({ events }) => events), + }).toEqual({ + distinct: 3, + perGraph: [1, 1, 1], + events: Array(3).fill(["at-once-dep", "at-once"]), + }); + for (const { graph } of graphs) graph.dispose(); + }); + }); + + describe("what graphs share", () => { + test("globalThis and the intrinsics are the host's", async () => { + using dir = tempDir("module-graph-linking-intrinsics", { + "intrinsics.mjs": ` + class Local {} + async function asyncFunction() {} + function* generator() {} + export const intrinsics = { + globalThis, Object, Function, Array, Promise, Error, TypeError, Symbol, Map, WeakRef, JSON, Math, Reflect, Proxy, + console, process, Bun, Buffer, URL, Response, setTimeout, queueMicrotask, structuredClone, + objectLiteralPrototype: Object.getPrototypeOf({}), + arrayLiteralPrototype: Object.getPrototypeOf([]), + regExpLiteralPrototype: Object.getPrototypeOf(/x/), + functionPrototype: Object.getPrototypeOf(Local), + asyncFunctionPrototype: Object.getPrototypeOf(asyncFunction), + generatorPrototype: Object.getPrototypeOf(generator), + promiseOfAsyncFunction: Object.getPrototypeOf(asyncFunction()), + promiseOfImport: Object.getPrototypeOf(import("./intrinsics.mjs")), + importMetaPrototype: Object.getPrototypeOf(import.meta), + templateObjectPrototype: Object.getPrototypeOf((strings => strings)\`\`), + registeredSymbol: Symbol.for("module-graph-linking"), + iterator: Symbol.iterator, + }; + `, + }); + const file = join(String(dir), "intrinsics.mjs"); + using graph = new ModuleGraph({ globals: { unrelated: 1 } }); + const inGraph = (await graph.import(file)).intrinsics; + const inHost = (await import(file)).intrinsics; + + expect(Object.keys(inGraph).filter(key => inGraph[key] !== inHost[key])).toEqual([]); + expect(inGraph.globalThis).toBe(globalThis); + expect(inGraph.promiseOfImport).toBe(Promise.prototype); + }); + + test("builtin modules expose the host's exports", async () => { + using dir = tempDir("module-graph-linking-builtins", { + "builtins.mjs": ` + import fs, { readFileSync } from "node:fs"; + import * as fsNamespace from "node:fs"; + import { join } from "node:path"; + import path from "path"; + import { EventEmitter } from "node:events"; + import { file as bunFile } from "bun"; + import { expect } from "bun:test"; + export { fs, readFileSync, join, path, EventEmitter, expect }; + export const fsNamespaceReadFileSync = fsNamespace.readFileSync; + export { bunFile }; + `, + }); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(join(String(dir), "builtins.mjs")); + const inB = await b.import(join(String(dir), "builtins.mjs")); + const inHost = await import(join(String(dir), "builtins.mjs")); + + expect(Object.keys(inHost).filter(key => inA[key] !== inHost[key] || inB[key] !== inHost[key])).toEqual([]); + expect(inA.fs).toBe(fs); + expect(inA.readFileSync).toBe(fs.readFileSync); + expect(inA.join).toBe(join); + expect(inA.bunFile).toBe(Bun.file); + expect(inA.expect).toBe(expect); + expect((await a.import("node:fs")).default).toBe(fs); + expect(await a.import("node:fs")).toBe(await a.import("node:fs")); + }); + + test("a class defined in a module is a different class in every graph", async () => { + using dir = tempDir("module-graph-linking-classes", { + "shapes.mjs": ` + export class Shape { + static instances = 0; + #secret = "secret"; + constructor() { Shape.instances++; } + static hasSecret(value) { return #secret in value; } + } + export class ShapeError extends Error {} + export const make = () => new Shape(); + export const fail = () => { throw new ShapeError("bad shape"); }; + export const list = () => [new Shape()]; + export const local = Symbol("local"); + export const registered = Symbol.for("module-graph-linking-shapes"); + `, + }); + const file = join(String(dir), "shapes.mjs"); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, inHost] = [await a.import(file), await b.import(file), await import(file)]; + const fromA = inA.make(); + inA.make(); + let thrown: any; + try { + inA.fail(); + } catch (error) { + thrown = error; + } + + expect({ + distinctClasses: new Set([inA.Shape, inB.Shape, inHost.Shape]).size, + instanceOf: [fromA instanceof inA.Shape, fromA instanceof inB.Shape, fromA instanceof inHost.Shape], + privateBrand: [inA.Shape.hasSecret(fromA), inB.Shape.hasSecret(fromA), inHost.Shape.hasSecret(fromA)], + instances: [inA.Shape.instances, inB.Shape.instances, inHost.Shape.instances], + thrown: [ + thrown instanceof inA.ShapeError, + thrown instanceof inB.ShapeError, + thrown instanceof Error, + Object.getPrototypeOf(inA.ShapeError) === Error, + ], + arrays: [ + inA.list() instanceof Array, + Array.isArray(inA.list()), + Object.getPrototypeOf(inA.list()) === Array.prototype, + ], + symbols: [inA.local === inB.local, inA.registered === inB.registered, inA.registered === inHost.registered], + }).toEqual({ + distinctClasses: 3, + instanceOf: [true, false, false], + privateBrand: [true, false, false], + instances: [2, 0, 0], + thrown: [true, false, true, true], + arrays: [true, true, true], + symbols: [false, true, true], + }); + }); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-loaders.test.ts b/test/js/bun/module-graph/module-graph-loaders.test.ts new file mode 100644 index 000000000000..b4f7bba3c42f --- /dev/null +++ b/test/js/bun/module-graph/module-graph-loaders.test.ts @@ -0,0 +1,1442 @@ +import { dlopen } from "bun:ffi"; +import { heapStats } from "bun:jsc"; +import { Database } from "bun:sqlite"; +import { describe, expect, mock, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import * as hostChildProcess from "node:child_process"; +import * as hostEvents from "node:events"; +import * as hostFs from "node:fs"; +import { readdirSync } from "node:fs"; +import * as hostOs from "node:os"; +import * as hostPath from "node:path"; +import { join } from "node:path"; +import * as hostUrl from "node:url"; +import * as hostUtil from "node:util"; + +const { ModuleGraph } = Bun.unsafe as any; + +// Runs `main.mjs` in `cwd` and returns what it printed. +async function runMain(cwd: string, env: Record = bunEnv) { + await using proc = Bun.spawn({ cmd: [bunExe(), "main.mjs"], env, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// The frames of `error.stack` inside `dir`, with `dir` stripped. +function framesIn(error: Error, dir: string) { + return error + .stack!.split("\n") + .filter(line => line.includes(dir)) + .map(line => line.trim().replace(dir, "")); +} + +function caught(fn: () => unknown): Error { + try { + fn(); + } catch (error) { + return error as Error; + } + throw new Error("expected the function to throw"); +} + +describe("Bun.unsafe.ModuleGraph loaders", () => { + describe("source languages", () => { + test.each([ + ["counter.js", `export let count = 0; export const increment = () => ++count;`], + ["counter.mjs", `export let count = 0; export const increment = () => ++count;`], + ["counter.ts", `export let count: number = 0; export const increment = (): number => ++count;`], + ["counter.mts", `export let count: number = 0; export const increment = (): number => ++count;`], + ["counter.jsx", `export let count = 0; export const increment = () => ++count;`], + ["counter.tsx", `export let count: number = 0; export const increment = (): number => ++count;`], + ])("%s has one instance per graph", async (name, source) => { + using dir = tempDir("module-graph-loaders-language", { "package.json": `{ "type": "module" }`, [name]: source }); + const file = join(String(dir), name); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + inA.increment(); + inA.increment(); + inB.increment(); + expect({ a: inA.count, b: inB.count, host: host.count }).toEqual({ a: 2, b: 1, host: 0 }); + }); + + test("TypeScript-only syntax", async () => { + using dir = tempDir("module-graph-loaders-ts-syntax", { + "syntax.ts": ` + enum Color { Red, Green = 5, Blue } + const enum Flag { A = 1, B = 2 } + namespace Shapes { export const sides = 4; } + declare const neverDefined: number; + abstract class Base { constructor(public name: string, private readonly age: number = 3) {} } + class Derived extends Base {} + function overloaded(x: string): string; + function overloaded(x: number): number; + function overloaded(x: any) { return x; } + const checked = { a: 1 } satisfies Record; + export const result = { + Color, + flags: [Flag.A, Flag.B], + sides: Shapes.sides, + checked, + derived: { ...new Derived("d") }, + cast: (overloaded(1) as unknown as number)!, + generic: ((x: T): T => x)("generic"), + }; + export let count = 0; + export const increment = () => ++count; + `, + }); + const file = join(String(dir), "syntax.ts"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + const expected = { + Color: { 0: "Red", 5: "Green", 6: "Blue", Red: 0, Green: 5, Blue: 6 }, + flags: [1, 2], + sides: 4, + checked: { a: 1 }, + derived: { name: "d", age: 3 }, + cast: 1, + generic: "generic", + }; + expect(inGraph.result).toEqual(expected); + expect(host.result).toEqual(expected); + expect(inGraph.result.Color).not.toBe(host.result.Color); + inGraph.increment(); + expect([inGraph.count, host.count]).toEqual([1, 0]); + }); + + test("type-only imports are elided", async () => { + using dir = tempDir("module-graph-loaders-type-imports", { + "shapes.ts": ` + evaluated.push("shapes.ts"); + export interface Shape { sides: number } + `, + "uses-types.ts": ` + import type { Missing } from "./this-file-does-not-exist"; + import { type AlsoMissing } from "./nor-does-this-one"; + import { Shape } from "./shapes.ts"; + evaluated.push("uses-types.ts"); + export const square: Shape = { sides: 4 }; + `, + }); + + const evaluated: string[] = []; + using graph = new ModuleGraph({ globals: { evaluated } }); + const inGraph = await graph.import(join(String(dir), "uses-types.ts")); + expect({ square: inGraph.square, evaluated }).toEqual({ square: { sides: 4 }, evaluated: ["uses-types.ts"] }); + }); + + test("experimental decorators", async () => { + using dir = tempDir("module-graph-loaders-decorators", { + "tsconfig.json": `{ "compilerOptions": { "experimentalDecorators": true } }`, + "decorated.ts": ` + export const log: string[] = []; + function tag(name: string) { + return (target: any, key?: string) => { log.push(name + ":" + (key ?? target.name)); }; + } + @tag("class") + export class Service { + @tag("method") run() {} + } + `, + }); + const file = join(String(dir), "decorated.ts"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.log).toEqual(["method:run", "class:Service"]); + expect(host.log).toEqual(["method:run", "class:Service"]); + expect(inGraph.log).not.toBe(host.log); + expect(inGraph.Service).not.toBe(host.Service); + }); + + test("TSX with a `@jsxImportSource` pragma: each graph has its own JSX runtime instance", async () => { + using dir = tempDir("module-graph-loaders-tsx", { + "node_modules/local-jsx/package.json": JSON.stringify({ + name: "local-jsx", + type: "module", + exports: { "./jsx-runtime": "./runtime.mjs", "./jsx-dev-runtime": "./runtime.mjs" }, + }), + "node_modules/local-jsx/runtime.mjs": ` + export let calls = 0; + export const Fragment = "fragment"; + export function jsx(type, props) { calls++; return { type, props }; } + export function jsxs(type, props) { calls++; return { type, props }; } + export function jsxDEV(type, props) { calls++; return { type, props }; } + `, + "view.tsx": ` + /** @jsxImportSource local-jsx */ + import { calls } from "local-jsx/jsx-runtime"; + export const Item = (props: { n: number }) => {props.n}; + export const element =
<>text
; + export const runtimeCalls = () => calls; + `, + }); + const file = join(String(dir), "view.tsx"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB] = [await a.import(file), await b.import(file)]; + expect(inA.element).toEqual({ + type: "div", + props: { + id: "root", + children: [ + { type: inA.Item, props: { n: 1 } }, + { type: "fragment", props: { children: "text" } }, + ], + }, + }); + expect(inA.Item).not.toBe(inB.Item); + inA.Item({ n: 2 }); + expect([inA.runtimeCalls(), inB.runtimeCalls()]).toEqual([4, 3]); + }); + + // The runtime takes JSX settings from the tsconfig.json of the directory it was started in. + test.concurrent("JSX with the process's tsconfig `jsxImportSource`", async () => { + using dir = tempDir("module-graph-loaders-jsx-tsconfig", { + "tsconfig.json": `{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "configured-jsx" } }`, + "node_modules/configured-jsx/package.json": JSON.stringify({ + name: "configured-jsx", + type: "module", + exports: { "./jsx-runtime": "./runtime.mjs", "./jsx-dev-runtime": "./runtime.mjs" }, + }), + "node_modules/configured-jsx/runtime.mjs": ` + export let calls = 0; + export function jsx(type, props) { calls++; return { type, props }; } + export function jsxs(type, props) { calls++; return { type, props }; } + export function jsxDEV(type, props) { calls++; return { type, props }; } + `, + "configured.jsx": ` + import { calls } from "configured-jsx/jsx-runtime"; + export const element =

text

; + export const render = () => ; + export const runtimeCalls = () => calls; + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/configured.jsx"; + const [inA, inB, host] = [ + await new ModuleGraph().import(file), + await new ModuleGraph().import(file), + await import(file), + ]; + inA.render(); + inA.render(); + inB.render(); + console.log(JSON.stringify({ + element: inA.element, + calls: [inA.runtimeCalls(), inB.runtimeCalls(), host.runtimeCalls()], + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + element: { type: "p", props: { title: "configured", children: "text" } }, + calls: [3, 2, 1], + }); + expect(exitCode).toBe(0); + }); + + test("JSX with a `@jsx` pragma to a local factory", async () => { + using dir = tempDir("module-graph-loaders-jsx-pragma", { + "pragma.jsx": ` + /** @jsxRuntime classic */ + /** @jsx h */ + /** @jsxFrag Frag */ + const Frag = "frag"; + function h(type, props, ...children) { return { type, props, children }; } + export const element =
  • a
  • <>b
; + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "pragma.jsx")); + expect(inGraph.element).toEqual({ + type: "ul", + props: { class: "list" }, + children: [ + { type: "li", props: null, children: ["a"] }, + { type: "frag", props: null, children: ["b"] }, + ], + }); + }); + + test("tsconfig `paths`", async () => { + using dir = tempDir("module-graph-loaders-tsconfig-paths", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@lib/*": ["src/lib/*"], "@state": ["src/lib/state.ts"] } }, + }), + "src/lib/state.ts": `export let count = 0; export const increment = (): number => ++count;`, + "uses-paths.ts": ` + import { increment } from "@lib/state"; + import * as exact from "@state"; + export { increment, exact }; + export const dynamic = () => import("@lib/state"); + `, + }); + const file = join(String(dir), "uses-paths.ts"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + inGraph.increment(); + expect({ + graph: inGraph.exact.count, + host: host.exact.count, + wildcardAndExactAreOneModule: inGraph.exact.increment === inGraph.increment, + dynamicStaysInGraph: (await inGraph.dynamic()) === inGraph.exact, + }).toEqual({ graph: 1, host: 0, wildcardAndExactAreOneModule: true, dynamicStaysInGraph: true }); + }); + }); + + describe("data and asset loaders", () => { + test("JSON: default and named imports, one parsed object per graph", async () => { + using dir = tempDir("module-graph-loaders-json", { + "settings.json": `{ "name": "settings", "nested": { "list": [1, 2, 3] } }`, + "reads-json.mjs": ` + import settings, { name, nested } from "./settings.json"; + export { settings, name, nested }; + `, + }); + const file = join(String(dir), "reads-json.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + expect(inA.settings).toEqual({ name: "settings", nested: { list: [1, 2, 3] } }); + expect({ name: inA.name, namedIsAProperty: inA.nested === inA.settings.nested }).toEqual({ + name: "settings", + namedIsAProperty: true, + }); + expect(inA.settings).not.toBe(inB.settings); + expect(inA.settings).not.toBe(host.settings); + + // A mutation made through one graph's object is invisible everywhere else. + inA.settings.nested.list.push(4); + inA.settings.added = true; + expect([inB.settings, host.settings]).toEqual([ + { name: "settings", nested: { list: [1, 2, 3] } }, + { name: "settings", nested: { list: [1, 2, 3] } }, + ]); + }); + + test("graph.import() of a JSON file", async () => { + using dir = tempDir("module-graph-loaders-json-direct", { "direct.json": `{ "a": { "b": 1 } }` }); + const file = join(String(dir), "direct.json"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect({ ...inGraph }).toEqual({ default: { a: { b: 1 } }, a: { b: 1 } }); + expect(inGraph.default).not.toBe(host.default); + expect(graph.mainModule).toBe(file); + }); + + test.each([ + ["config.toml", `title = "t"\n[owner]\nname = "o"\n`, { title: "t", owner: { name: "o" } }], + ["config.yaml", `title: y\nlist:\n - 1\n - 2\n`, { title: "y", list: [1, 2] }], + ["config.jsonc", `// comment\n{ "k": 1, /* inline */ "t": [1,], }\n`, { k: 1, t: [1] }], + ["config.json5", `{ k: 1, t: [1,], s: 'single' }\n`, { k: 1, t: [1], s: "single" }], + ])("%s is parsed once per graph", async (name, contents, expected) => { + using dir = tempDir("module-graph-loaders-data", { + [name]: contents, + "reads-data.mjs": `export { default as data } from "./${name}";`, + }); + const file = join(String(dir), "reads-data.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.data).toEqual(expected); + expect(host.data).toEqual(expected); + expect(inGraph.data).not.toBe(host.data); + }); + + test('`with { type: "json" }` on another extension', async () => { + using dir = tempDir("module-graph-loaders-json-attribute", { + "payload.data": `{ "from": "attribute" }`, + "reads-payload.mjs": ` + import payload from "./payload.data" with { type: "json" }; + export { payload }; + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "reads-payload.mjs")); + expect(inGraph.payload).toEqual({ from: "attribute" }); + }); + + test('`with { type: "text" }`, also of a file that would otherwise be evaluated', async () => { + using dir = tempDir("module-graph-loaders-text", { + "note.txt": "plain text\n", + "not-evaluated.mjs": `throw new Error("evaluated");`, + "reads-text.mjs": ` + import note from "./note.txt" with { type: "text" }; + import implicit from "./note.txt"; + import source from "./not-evaluated.mjs" with { type: "text" }; + export { note, implicit, source }; + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "reads-text.mjs")); + expect({ ...inGraph }).toEqual({ + note: "plain text\n", + implicit: "plain text\n", + source: `throw new Error("evaluated");`, + }); + }); + + test('`with { type: "file" }` is the path', async () => { + using dir = tempDir("module-graph-loaders-file", { + "blob.bin": Buffer.from([0, 1, 2, 3]), + "reads-file.mjs": ` + import path from "./blob.bin" with { type: "file" }; + export { path }; + export const bytes = [...await Bun.file(path).bytes()]; + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "reads-file.mjs")); + expect({ ...inGraph }).toEqual({ path: join(String(dir), "blob.bin"), bytes: [0, 1, 2, 3] }); + }); + + test("import attributes on dynamic import()", async () => { + using dir = tempDir("module-graph-loaders-dynamic-attributes", { + "letter.txt": "dear graph", + "numbers.data": `[1, 2, 3]`, + "loads-dynamically.mjs": ` + import numbers from "./numbers.data" with { type: "json" }; + export { numbers }; + export const loadText = () => import("./letter.txt", { with: { type: "text" } }); + export const loadJSON = () => import("./numbers.data", { with: { type: "json" } }); + `, + }); + const file = join(String(dir), "loads-dynamically.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + const [text, json, hostJSON] = [await inGraph.loadText(), await inGraph.loadJSON(), await host.loadJSON()]; + expect({ text: text.default, json: json.default }).toEqual({ text: "dear graph", json: [1, 2, 3] }); + // The dynamic import finds the graph's record of the static import, not the host's. + expect(json.default).toBe(inGraph.numbers); + expect(json.default).not.toBe(hostJSON.default); + }); + + test("JSON through node:module's require() is the shared CommonJS copy", async () => { + using dir = tempDir("module-graph-loaders-json-require", { + "required.json": `{ "k": { "v": 1 } }`, + "requires-json.mjs": ` + import { createRequire } from "node:module"; + export const required = createRequire(import.meta.url)("./required.json"); + `, + }); + const file = join(String(dir), "requires-json.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.required).toEqual({ k: { v: 1 } }); + expect(inGraph.required).toBe(host.required); + expect(require.cache[join(String(dir), "required.json")]?.exports).toBe(inGraph.required); + }); + + test("import.meta.require() of a JSON file the graph has not imported", async () => { + using dir = tempDir("module-graph-loaders-json-meta-require", { + "only-required.json": `{ "k": { "v": 1 } }`, + "meta-requires-json.mjs": `export const required = import.meta.require("./only-required.json");`, + }); + const file = join(String(dir), "meta-requires-json.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect([inGraph.required, host.required]).toEqual([{ k: { v: 1 } }, { k: { v: 1 } }]); + }); + + test("import.meta.require() of a JSON file the graph has imported", async () => { + using dir = tempDir("module-graph-loaders-json-meta-require-imported", { + "both.json": `{ "k": { "v": 1 } }`, + "imports-and-requires-json.mjs": ` + import imported from "./both.json"; + export { imported }; + export const required = import.meta.require("./both.json"); + `, + }); + const file = join(String(dir), "imports-and-requires-json.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + // Whatever shape the host gives it, the graph gives the same shape around its own parsed object. + expect({ ...inGraph.required }).toEqual({ ...host.required }); + expect(inGraph.required.k).toBe(inGraph.imported.k); + expect(inGraph.required.k).not.toBe(host.imported.k); + }); + + test(".wasm imports as its path; the graph's module instantiates it", async () => { + using dir = tempDir("module-graph-loaders-wasm", { + // (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)) + "add.wasm": Buffer.from([ + 0, 97, 115, 109, 1, 0, 0, 0, 1, 7, 1, 96, 2, 127, 127, 1, 127, 3, 2, 1, 0, 7, 7, 1, 3, 97, 100, 100, 0, 0, 10, + 9, 1, 7, 0, 32, 0, 32, 1, 106, 11, + ]), + "uses-wasm.mjs": ` + import path from "./add.wasm"; + export { path }; + const { instance } = await WebAssembly.instantiate(await Bun.file(path).arrayBuffer()); + export const add = instance.exports.add; + `, + }); + const file = join(String(dir), "uses-wasm.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect({ path: inGraph.path, sum: inGraph.add(2, 3) }).toEqual({ path: join(String(dir), "add.wasm"), sum: 5 }); + expect(inGraph.add).not.toBe(host.add); + }); + + test(".node rejects with a TypeError", async () => { + using dir = tempDir("module-graph-loaders-napi", { + "addon.node": "not a real addon", + "imports-addon.mjs": `import addon from "./addon.node"; export { addon };`, + }); + + using graph = new ModuleGraph(); + const message = "To load Node-API modules, use require() or process.dlopen instead of import."; + const [direct, nested] = await Promise.allSettled([ + graph.import(join(String(dir), "addon.node")), + graph.import(join(String(dir), "imports-addon.mjs")), + ]); + expect([direct, nested]).toEqual([ + { status: "rejected", reason: expect.objectContaining({ name: "TypeError", message }) }, + { status: "rejected", reason: expect.objectContaining({ name: "TypeError", message }) }, + ]); + expect(direct.status === "rejected" && direct.reason).toBeInstanceOf(TypeError); + }); + + test('`with { type: "sqlite" }` opens one Database per graph', async () => { + using dir = tempDir("module-graph-loaders-sqlite", { + "reads-sqlite.mjs": ` + import db from "./rows.sqlite" with { type: "sqlite" }; + export { db }; + export const rows = db.query("select v from t").all(); + `, + }); + { + using db = new Database(join(String(dir), "rows.sqlite")); + db.run("create table t (v text)"); + db.run("insert into t values ('row')"); + } + const file = join(String(dir), "reads-sqlite.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + try { + expect(inGraph.rows).toEqual([{ v: "row" }]); + expect(inGraph.db).toBeInstanceOf(Database); + expect(inGraph.db).not.toBe(host.db); + } finally { + inGraph.db.close(); + host.db.close(); + } + }); + + test(".html imports as one HTMLBundle per graph", async () => { + using dir = tempDir("module-graph-loaders-html", { + "page.html": `page`, + "imports-html.mjs": `import page from "./page.html"; export { page };`, + }); + const file = join(String(dir), "imports-html.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.page.index).toBe(join(String(dir), "page.html")); + expect(inGraph.page.index).toBe(host.page.index); + expect(inGraph.page).not.toBe(host.page); + }); + }); + + describe("builtin modules", () => { + test("each graph has its own record of a builtin with the host's exports", async () => { + using dir = tempDir("module-graph-loaders-builtins", { + "imports-builtins.mjs": ` + export * as fs from "node:fs"; + export * as bareFs from "fs"; + export * as path from "node:path"; + export * as barePath from "path"; + export * as os from "node:os"; + export * as events from "node:events"; + export * as util from "node:util"; + export * as url from "node:url"; + export * as childProcess from "node:child_process"; + `, + }); + const file = join(String(dir), "imports-builtins.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB] = [await a.import(file), await b.import(file)]; + const host: Record = { + fs: hostFs, + bareFs: hostFs, + path: hostPath, + barePath: hostPath, + os: hostOs, + events: hostEvents, + util: hostUtil, + url: hostUrl, + childProcess: hostChildProcess, + }; + + const differing: string[] = []; + for (const [name, hostNamespace] of Object.entries(host)) { + if (inA[name] === hostNamespace || inA[name] === inB[name]) differing.push(name + ": namespace is shared"); + for (const key of Object.keys(hostNamespace)) { + if (inA[name][key] !== hostNamespace[key]) differing.push(name + "." + key); + } + if (Object.keys(inA[name]).length !== Object.keys(hostNamespace).length) differing.push(name + ": keys"); + } + expect(differing).toEqual([]); + expect(inA.bareFs).toBe(inA.fs); + expect(inA.barePath).toBe(inA.path); + }); + + test("bun:* modules", async () => { + using dir = tempDir("module-graph-loaders-bun-builtins", { + "imports-bun.mjs": ` + import * as jsc from "bun:jsc"; + import * as sqlite from "bun:sqlite"; + import * as ffi from "bun:ffi"; + import * as bun from "bun"; + export const exports = { + heapStats: jsc.heapStats, + Database: sqlite.Database, + dlopen: ffi.dlopen, + spawn: bun.spawn, + file: bun.file, + }; + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "imports-bun.mjs")); + expect(inGraph.exports.heapStats).toBe(heapStats); + expect(inGraph.exports.Database).toBe(Database); + expect(inGraph.exports.dlopen).toBe(dlopen); + expect(inGraph.exports.spawn).toBe(Bun.spawn); + expect(inGraph.exports.file).toBe(Bun.file); + }); + + test("graph.import() of a builtin specifier", async () => { + using graph = new ModuleGraph(); + const [os, bareOs] = [await graph.import("node:os"), await graph.import("os")]; + expect(os).toBe(bareOs); + expect(os).not.toBe(hostOs); + expect(os.platform).toBe(hostOs.platform); + expect(os.default).toBe(hostOs.default); + expect(graph.mainModule).toBe("node:os"); + }); + + test("bun:test imported by graph code is the running test's bun:test", async () => { + using dir = tempDir("module-graph-loaders-bun-test", { + "imports-bun-test.mjs": ` + import { describe, expect, mock, test } from "bun:test"; + export { describe, expect, mock, test }; + export const assertInGraph = value => expect(value).toBe(1); + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "imports-bun-test.mjs")); + expect({ + describe: inGraph.describe === describe, + expect: inGraph.expect === expect, + mock: inGraph.mock === mock, + test: inGraph.test === test, + }).toEqual({ describe: true, expect: true, mock: true, test: true }); + inGraph.assertInGraph(1); + expect(() => inGraph.assertInGraph(2)).toThrow(); + }); + }); + + describe("CommonJS", () => { + test("default, named, function and __esModule interop", async () => { + using dir = tempDir("module-graph-loaders-cjs-interop", { + "plain.cjs": `exports.named = "named"; exports.other = 2;`, + "function.cjs": `module.exports = function callable() { return "called"; }; module.exports.extra = 1;`, + "transpiled.cjs": ` + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = "the default"; + exports.other = "other"; + `, + "imports-cjs.mjs": ` + import plain, { named } from "./plain.cjs"; + import callable, { extra } from "./function.cjs"; + import transpiledDefault, { other } from "./transpiled.cjs"; + import * as transpiled from "./transpiled.cjs"; + export const result = { + plain, named, called: callable(), extra, extraIsProperty: callable.extra === extra, + transpiledDefault, other, transpiledKeys: Object.keys(transpiled), + }; + `, + }); + const file = join(String(dir), "imports-cjs.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.result).toEqual({ + plain: { named: "named", other: 2 }, + named: "named", + called: "called", + extra: 1, + extraIsProperty: true, + transpiledDefault: "the default", + other: "other", + transpiledKeys: ["default", "other"], + }); + expect(inGraph.result.plain).toBe(host.result.plain); + }); + + test("a CommonJS file imported by two graphs and the host is evaluated once", async () => { + using dir = tempDir("module-graph-loaders-cjs-once", { + "evaluations.cjs": `module.exports = [];`, + "stateful.cjs": ` + require("./evaluations.cjs").push("stateful.cjs"); + let count = 0; + exports.increment = () => ++count; + exports.token = {}; + `, + "imports-stateful.mjs": ` + import stateful, { increment, token } from "./stateful.cjs"; + import evaluations from "./evaluations.cjs"; + export { stateful, increment, token, evaluations }; + `, + }); + const file = join(String(dir), "imports-stateful.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + expect(inA.evaluations).toEqual(["stateful.cjs"]); + expect([inA.increment(), inB.increment(), host.increment()]).toEqual([1, 2, 3]); + expect({ + exports: inA.stateful === host.stateful && inB.stateful === host.stateful, + token: inA.token === host.token && inB.token === host.token, + namespace: inA === inB, + requireCache: require.cache[join(String(dir), "stateful.cjs")]?.exports === inA.stateful, + }).toEqual({ exports: true, token: true, namespace: false, requireCache: true }); + }); + + test("graph.import() of a CommonJS file", async () => { + using dir = tempDir("module-graph-loaders-cjs-direct", { + "direct.cjs": `exports.token = {}; exports.name = "direct";`, + }); + const file = join(String(dir), "direct.cjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(Object.keys(inGraph)).toEqual(["default", "name", "token"]); + expect(inGraph).not.toBe(host); + expect(inGraph.default).toBe(host.default); + expect(inGraph.token).toBe(host.token); + }); + + test.each([ + ["detected.js", `module.exports = { kind: "commonjs by detection", token: {} };`], + ["typed.cts", `const kind: string = "cts"; export = { kind, token: {} };`], + ])("%s is shared", async (name, source) => { + using dir = tempDir("module-graph-loaders-cjs-kinds", { + [name]: source, + "imports-it.mjs": `export { default as exports } from "./${name}";`, + }); + const file = join(String(dir), "imports-it.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + expect(inGraph.exports.token).toBeObject(); + expect(inGraph.exports).toBe(host.exports); + }); + + describe("an ES module loaded by CommonJS code is the host's instance", () => { + const files = { + "state.mjs": `export let count = 0; export const increment = () => ++count;`, + "loads-esm.cjs": ` + exports.required = () => require("./state.mjs"); + exports.imported = () => import("./state.mjs"); + `, + "entry.mjs": ` + import { createRequire } from "node:module"; + import loader from "./loads-esm.cjs"; + import * as state from "./state.mjs"; + export { loader, state }; + export const viaCreateRequire = () => createRequire(import.meta.url)("./state.mjs"); + export const viaImportMetaRequire = () => import.meta.require("./state.mjs"); + `, + }; + + test("when the host loaded it first", async () => { + using dir = tempDir("module-graph-loaders-cjs-esm-host-first", files); + const host = await import(join(String(dir), "state.mjs")); + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "entry.mjs")); + + inGraph.state.increment(); + expect(inGraph.state.increment).not.toBe(host.increment); + expect(inGraph.loader.required().increment).toBe(host.increment); + expect((await inGraph.loader.imported()).increment).toBe(host.increment); + expect(inGraph.viaCreateRequire().increment).toBe(host.increment); + expect(inGraph.viaImportMetaRequire().increment).toBe(inGraph.state.increment); + expect([inGraph.state.count, host.count]).toEqual([1, 0]); + }); + + test("when the graph loaded it first", async () => { + using dir = tempDir("module-graph-loaders-cjs-esm-graph-first", files); + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "entry.mjs")); + inGraph.state.increment(); + const required = inGraph.loader.required(); + const host = await import(join(String(dir), "state.mjs")); + + expect(required.increment).not.toBe(inGraph.state.increment); + expect(required.increment).toBe(host.increment); + expect((await inGraph.loader.imported()).increment).toBe(host.increment); + expect([inGraph.state.count, host.count]).toEqual([1, 0]); + }); + }); + }); + + describe("packages", () => { + const packages = { + "node_modules/conditions/package.json": JSON.stringify({ + name: "conditions", + type: "module", + exports: { + ".": { bun: "./bun.mjs", import: "./import.mjs", default: "./default.mjs" }, + "./feature": { require: "./feature-require.cjs", import: "./feature-import.mjs" }, + "./fallback": { "no-such-condition": "./import.mjs", default: "./default.mjs" }, + "./sub/*": "./sub/*.mjs", + "./sub/private": null, + }, + imports: { "#internal": "./internal.mjs" }, + }), + "node_modules/conditions/bun.mjs": ` + export const picked = "bun"; + export let count = 0; + export const increment = () => ++count; + export { internal } from "#internal"; + export * as self from "conditions/feature"; + `, + "node_modules/conditions/import.mjs": `export const picked = "import";`, + "node_modules/conditions/default.mjs": `export const picked = "default";`, + "node_modules/conditions/feature-import.mjs": `export const picked = "feature-import";`, + "node_modules/conditions/feature-require.cjs": `exports.picked = "feature-require";`, + "node_modules/conditions/internal.mjs": `export const internal = "internal";`, + "node_modules/conditions/sub/a.mjs": `export const name = "sub/a";`, + "node_modules/conditions/sub/private.mjs": `export const name = "sub/private";`, + "node_modules/commonjs-package/package.json": JSON.stringify({ name: "commonjs-package", main: "./index.js" }), + "node_modules/commonjs-package/index.js": ` + require("./evaluations.js").push("commonjs-package"); + module.exports = { kind: "commonjs", token: {} }; + `, + "node_modules/commonjs-package/evaluations.js": `module.exports = [];`, + "node_modules/dual/package.json": JSON.stringify({ + name: "dual", + exports: { ".": { import: "./esm.mjs", require: "./cjs.cjs" } }, + }), + "node_modules/dual/esm.mjs": `export const kind = "esm"; export const token = {};`, + "node_modules/dual/cjs.cjs": `exports.kind = "cjs"; exports.token = {};`, + "app/package.json": JSON.stringify({ + name: "app", + type: "module", + imports: { "#config": "./config.js", "#dependency": "conditions" }, + exports: { ".": "./index.js", "./config": "./config.js" }, + }), + "app/config.js": `export const config = { value: 0 };`, + }; + + test("`exports` conditions, subpath patterns and blocked subpaths", async () => { + using dir = tempDir("module-graph-loaders-package-exports", { + ...packages, + "app/index.js": ` + export * as root from "conditions"; + export * as feature from "conditions/feature"; + export * as fallback from "conditions/fallback"; + export * as sub from "conditions/sub/a"; + export const blocked = await import("conditions/sub/private").then(() => "resolved", error => error.code); + export const missing = await import("conditions/not-exported").then(() => "resolved", error => error.code); + `, + }); + const file = join(String(dir), "app/index.js"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + expect({ + root: inA.root.picked, + feature: inA.feature.picked, + fallback: inA.fallback.picked, + sub: inA.sub.name, + blocked: inA.blocked, + missing: inA.missing, + }).toEqual({ + root: "bun", + feature: "feature-import", + fallback: "default", + sub: "sub/a", + blocked: "ERR_MODULE_NOT_FOUND", + missing: "ERR_MODULE_NOT_FOUND", + }); + + inA.root.increment(); + expect([inA.root.count, inB.root.count, host.root.count]).toEqual([1, 0, 0]); + }); + + test("graph.import() of a bare specifier resolves from the caller", async () => { + using dir = tempDir("module-graph-loaders-package-bare", { + ...packages, + "app/index.js": ` + export async function importBare(graph) { + const namespace = await graph.import("conditions"); + return namespace; + } + `, + }); + const file = join(String(dir), "app/index.js"); + + using graph = new ModuleGraph(); + const host = await import(file); + const root = await host.importBare(graph); + const hostRoot = await import(join(String(dir), "node_modules/conditions/bun.mjs")); + root.increment(); + expect([root.picked, root.count, hostRoot.count]).toEqual(["bun", 1, 0]); + expect(graph.mainModule).toBe(join(String(dir), "node_modules/conditions/bun.mjs")); + }); + + test("subpath imports (`#internal`) and self-reference stay in the graph", async () => { + using dir = tempDir("module-graph-loaders-package-imports", { + ...packages, + "app/index.js": ` + export * as root from "conditions"; + export * as feature from "conditions/feature"; + export * as dependency from "#dependency"; + export * as self from "app/config"; + export { config } from "#config"; + `, + }); + const file = join(String(dir), "app/index.js"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + inGraph.config.value = 5; + expect({ + internal: inGraph.root.internal, + packageSelfReference: inGraph.root.self === inGraph.feature, + importsMapToPackage: inGraph.dependency === inGraph.root, + selfReference: inGraph.self.config === inGraph.config, + hostRoot: inGraph.root === host.root, + values: [inGraph.config.value, host.config.value], + }).toEqual({ + internal: "internal", + packageSelfReference: true, + importsMapToPackage: true, + selfReference: true, + hostRoot: false, + values: [5, 0], + }); + }); + + test("a CommonJS package is shared; a dual package is per graph through `import` and shared through `require`", async () => { + using dir = tempDir("module-graph-loaders-package-dual", { + ...packages, + "app/index.js": ` + import { createRequire } from "node:module"; + import commonjs from "commonjs-package"; + import evaluations from "commonjs-package/evaluations.js"; + import * as dual from "dual"; + export const dualRequired = createRequire(import.meta.url)("dual"); + export { commonjs, evaluations, dual }; + `, + }); + const file = join(String(dir), "app/index.js"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + expect({ + evaluations: inA.evaluations, + commonjsIsShared: inA.commonjs === host.commonjs && inB.commonjs === host.commonjs, + kinds: [inA.dual.kind, inA.dualRequired.kind], + dualImportIsShared: inA.dual.token === host.dual.token || inA.dual.token === inB.dual.token, + dualRequireIsShared: inA.dualRequired === host.dualRequired && inB.dualRequired === host.dualRequired, + }).toEqual({ + evaluations: ["commonjs-package"], + commonjsIsShared: true, + kinds: ["esm", "cjs"], + dualImportIsShared: false, + dualRequireIsShared: true, + }); + }); + }); + + describe("query strings", () => { + const files = { + "versioned.mjs": ` + export let count = 0; + export const increment = () => ++count; + export const url = import.meta.url; + `, + "imports-versions.mjs": ` + import * as v1 from "./versioned.mjs?v=1"; + import * as v1Again from "./versioned.mjs?v=1"; + import * as v2 from "./versioned.mjs?v=2"; + import * as hashed from "./versioned.mjs?v=1#hash"; + import * as plain from "./versioned.mjs"; + export { v1, v1Again, v2, hashed, plain }; + export const dynamic = () => import("./versioned.mjs?v=1"); + `, + }; + + test("each query string is its own instance within a graph", async () => { + using dir = tempDir("module-graph-loaders-query", files); + const file = join(String(dir), "imports-versions.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + inGraph.v1.increment(); + inGraph.v2.increment(); + inGraph.v2.increment(); + inGraph.hashed.increment(); + inGraph.hashed.increment(); + inGraph.hashed.increment(); + + const counts = (m: any) => [m.v1.count, m.v2.count, m.hashed.count, m.plain.count]; + const urls = (m: any) => [m.v1.url, m.v2.url, m.hashed.url, m.plain.url]; + expect({ graph: counts(inGraph), host: counts(host) }).toEqual({ graph: [1, 2, 3, 0], host: [0, 0, 0, 0] }); + expect(inGraph.v1Again).toBe(inGraph.v1); + expect(await inGraph.dynamic()).toBe(inGraph.v1); + expect(await inGraph.dynamic()).not.toBe(await host.dynamic()); + expect(urls(inGraph)).toEqual(urls(host)); + expect(urls(inGraph).map(url => url.slice(url.lastIndexOf("/")))).toEqual([ + "/versioned.mjs?v=1", + "/versioned.mjs?v=2", + "/versioned.mjs?v=1#hash", + "/versioned.mjs", + ]); + }); + + test("graph.import() with a query string", async () => { + using dir = tempDir("module-graph-loaders-query-direct", files); + const file = join(String(dir), "versioned.mjs"); + + using graph = new ModuleGraph(); + const [v1, v2, plain] = [ + await graph.import(file + "?v=1"), + await graph.import(file + "?v=2"), + await graph.import(file), + ]; + v1.increment(); + expect([v1.count, v2.count, plain.count]).toEqual([1, 0, 0]); + expect(await graph.import(file + "?v=1")).toBe(v1); + expect((await graph.import(join(String(dir), "imports-versions.mjs"))).v1).toBe(v1); + }); + }); + + describe("plugins", () => { + test.concurrent("a virtual module from builder.module() has one instance per graph", async () => { + using dir = tempDir("module-graph-loaders-plugin-virtual", { + "uses-virtual.mjs": `export * as virtual from "virtual:counter";`, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const calls = []; + Bun.plugin({ + name: "virtual", + setup(builder) { + builder.module("virtual:counter", () => { + calls.push("virtual:counter"); + return { contents: "export let count = 0; export const increment = () => ++count;", loader: "js" }; + }); + }, + }); + + const file = import.meta.dir + "/uses-virtual.mjs"; + const a = new ModuleGraph(); + const b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + const callsAfterStaticImports = calls.length; + inA.virtual.increment(); + inA.virtual.increment(); + inB.virtual.increment(); + console.log(JSON.stringify({ + counts: [inA.virtual.count, inB.virtual.count, host.virtual.count], + callsAfterStaticImports, + direct: (await a.import("virtual:counter")) === inA.virtual, + calls: calls.length, + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ counts: [2, 1, 0], callsAfterStaticImports: 3, direct: true, calls: 3 }); + expect(exitCode).toBe(0); + }); + + test.concurrent("onResolve() into a namespace and its onLoad() run for the graph's load", async () => { + using dir = tempDir("module-graph-loaders-plugin-namespace", { + "uses-namespace.mjs": `export * as thing from "things:thing";`, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const calls = []; + Bun.plugin({ + name: "namespace", + setup(builder) { + builder.onResolve({ filter: /.*/, namespace: "things" }, ({ path, importer }) => { + calls.push("resolve " + path + " from " + importer.slice(import.meta.dir.length)); + return { path: "resolved-" + path, namespace: "things" }; + }); + builder.onLoad({ filter: /.*/, namespace: "things" }, ({ path }) => { + calls.push("load " + path); + return { contents: "export let count = 0; export const increment = () => ++count;", loader: "js" }; + }); + }, + }); + + const file = import.meta.dir + "/uses-namespace.mjs"; + const a = new ModuleGraph(); + const b = new ModuleGraph(); + const inA = await a.import(file); + const callsForA = calls.splice(0); + const inB = await b.import(file); + const callsForB = calls.splice(0); + const host = await import(file); + inA.thing.increment(); + console.log(JSON.stringify({ + counts: [inA.thing.count, inB.thing.count, host.thing.count], + callsForA, + callsForB, + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + counts: [1, 0, 0], + callsForA: ["resolve thing from /uses-namespace.mjs", "load resolved-thing"], + callsForB: ["resolve thing from /uses-namespace.mjs", "load resolved-thing"], + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("an async onLoad() for a custom extension", async () => { + using dir = tempDir("module-graph-loaders-plugin-extension", { + "message.shout": "hello", + "uses-extension.mjs": ` + import message, { token } from "./message.shout"; + export { message, token }; + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + let loads = 0; + Bun.plugin({ + name: "shout", + setup(builder) { + builder.onLoad({ filter: /\\.shout$/ }, async ({ path }) => { + loads++; + const text = await Bun.file(path).text(); + return { contents: "export default " + JSON.stringify(text.toUpperCase()) + "; export const token = {};", loader: "js" }; + }); + }, + }); + + const file = import.meta.dir + "/uses-extension.mjs"; + const [inA, inB, host] = [ + await new ModuleGraph().import(file), + await new ModuleGraph().import(file), + await import(file), + ]; + console.log(JSON.stringify({ + messages: [inA.message, inB.message, host.message], + tokenIsShared: inA.token === inB.token || inA.token === host.token, + loads, + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ messages: ["HELLO", "HELLO", "HELLO"], tokenIsShared: false, loads: 3 }); + expect(exitCode).toBe(0); + }); + + test.concurrent('an onLoad() returning `loader: "object"` exposes the plugin\'s values in each graph', async () => { + using dir = tempDir("module-graph-loaders-plugin-object", { + "uses-object.mjs": ` + import value, { answer, shared } from "virtual:object"; + export * as namespace from "virtual:object"; + export { value, answer, shared }; + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const shared = { shared: true }; + let loads = 0; + Bun.plugin({ + name: "object", + setup(builder) { + builder.module("virtual:object", () => { + loads++; + return { exports: { default: shared, shared, answer: 42 }, loader: "object" }; + }); + }, + }); + + const file = import.meta.dir + "/uses-object.mjs"; + const [inA, inB, host] = [ + await new ModuleGraph().import(file), + await new ModuleGraph().import(file), + await import(file), + ]; + console.log(JSON.stringify({ + answer: inA.answer, + valuesAreThePlugins: [inA, inB, host].every(m => m.value === shared && m.shared === shared), + namespaceIsShared: inA.namespace === inB.namespace || inA.namespace === host.namespace, + loads, + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ answer: 42, valuesAreThePlugins: true, namespaceIsShared: false, loads: 3 }); + expect(exitCode).toBe(0); + }); + + test.concurrent("a plugin registered after the graph was created is honoured by later imports", async () => { + using dir = tempDir("module-graph-loaders-plugin-late", { + "data.late": "late file", + "loads-later.mjs": ` + export const loadVirtual = () => import("virtual:late"); + export const loadFile = () => import("./data.late"); + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const graph = new ModuleGraph(); + const inGraph = await graph.import(import.meta.dir + "/loads-later.mjs"); + const before = await inGraph.loadVirtual().then(() => "resolved", error => error.code); + + Bun.plugin({ + name: "late", + setup(builder) { + builder.module("virtual:late", () => ({ contents: "export const from = 'builder.module()';", loader: "js" })); + builder.onLoad({ filter: /\\.late$/ }, () => ({ contents: "export const from = 'onLoad()';", loader: "js" })); + }, + }); + + console.log(JSON.stringify({ + before, + virtual: (await inGraph.loadVirtual()).from, + file: (await inGraph.loadFile()).from, + direct: (await graph.import("virtual:late")).from, + })); + `, + }); + + const { stdout, stderr, exitCode } = await runMain(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + before: "ERR_MODULE_NOT_FOUND", + virtual: "builder.module()", + file: "onLoad()", + direct: "builder.module()", + }); + expect(exitCode).toBe(0); + }); + }); + + describe("mock.module()", () => { + test("a module mocked before the graph imports it is the mock in every graph", async () => { + using dir = tempDir("module-graph-loaders-mock-before", { + "mocked-before.mjs": `export const kind = "real";`, + "imports-mocked-before.mjs": `export * as dependency from "./mocked-before.mjs";`, + }); + const file = join(String(dir), "imports-mocked-before.mjs"); + + const token = {}; + mock.module(join(String(dir), "mocked-before.mjs"), () => ({ kind: "mock", token })); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + expect([{ ...inA.dependency }, { ...inB.dependency }, { ...host.dependency }]).toEqual([ + { kind: "mock", token }, + { kind: "mock", token }, + { kind: "mock", token }, + ]); + expect(inA.dependency.token).toBe(token); + expect(inA.dependency).not.toBe(host.dependency); + expect(inA.dependency).not.toBe(inB.dependency); + }); + + test("mocking after a graph loaded the module does not change that graph's instance", async () => { + using dir = tempDir("module-graph-loaders-mock-after", { + "mocked-after.mjs": ` + export const kind = "real"; + export let count = 0; + export const increment = () => ++count; + `, + "imports-mocked-after.mjs": ` + export * as dependency from "./mocked-after.mjs"; + export const again = () => import("./mocked-after.mjs"); + `, + }); + const file = join(String(dir), "imports-mocked-after.mjs"); + + using early = new ModuleGraph(); + using late = new ModuleGraph(); + const inEarly = await early.import(file); + inEarly.dependency.increment(); + mock.module(join(String(dir), "mocked-after.mjs"), () => ({ kind: "mock" })); + const inLate = await late.import(file); + + expect({ ...inEarly.dependency }).toEqual({ kind: "real", count: 1, increment: inEarly.dependency.increment }); + expect(await inEarly.again()).toBe(inEarly.dependency); + expect({ ...inLate.dependency }).toEqual({ kind: "mock" }); + }); + + test("a module mocked after the graph was created, before the graph's first import of it", async () => { + using dir = tempDir("module-graph-loaders-mock-dynamic", { + "mocked-lazily.mjs": `export const kind = "real";`, + "imports-lazily.mjs": `export const load = () => import("./mocked-lazily.mjs");`, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "imports-lazily.mjs")); + mock.module(join(String(dir), "mocked-lazily.mjs"), () => ({ kind: "mock" })); + expect({ ...(await inGraph.load()) }).toEqual({ kind: "mock" }); + }); + }); + + test("a macro import runs at transpile time for the graph's module", async () => { + using dir = tempDir("module-graph-loaders-macro", { + "shout.ts": `export function shout(text: string) { return text.toUpperCase() + "!"; }`, + "uses-macro.ts": ` + import { shout } from "./shout.ts" with { type: "macro" }; + export const value = shout("inlined"); + `, + }); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "uses-macro.ts")); + expect(inGraph.value).toBe("INLINED!"); + }); + + describe("stack traces", () => { + test("TypeScript positions from graph code match the host's instance", async () => { + using dir = tempDir("module-graph-loaders-stack-ts", { + "thrower.ts": `type Unused = { a: number }; +interface AlsoUnused { b: string } + +export function fail(message: string): never { + const error: Error = new Error(message); + throw error; +} +`, + }); + const file = join(String(dir), "thrower.ts"); + + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const [inGraph, host] = [await graph.import(file), await import(file)]; + const frames = framesIn( + caught(() => inGraph.fail("graph")), + String(dir), + ); + expect(frames).toEqual(["at fail (/thrower.ts:5:28)"]); + expect(frames).toEqual( + framesIn( + caught(() => host.fail("host")), + String(dir), + ), + ); + }); + + test("a module with a sourceMappingURL comment", async () => { + // Generated line 2, column 8 maps to original.src line 41, column 2. + const map = { version: 3, sources: ["original.src"], names: [], mappings: ";QAwCE" }; + using dir = tempDir("module-graph-loaders-stack-source-map", { + "mapped.mjs": + `export function boom() {\n throw new Error("mapped");\n}\n` + + `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}\n`, + }); + const file = join(String(dir), "mapped.mjs"); + + using graph = new ModuleGraph(); + const [inGraph, host] = [await graph.import(file), await import(file)]; + const frames = framesIn(caught(inGraph.boom), String(dir)); + expect(frames).toHaveLength(1); + expect(frames).toEqual(framesIn(caught(host.boom), String(dir))); + }); + + test("a 2 MB module", async () => { + const padding = Buffer.alloc(1000, "p").toString(); + const lines: string[] = []; + for (let i = 0; i < 2000; i++) lines.push(`function f${i}(x) { return x + ${i}; } // ${padding}`); + lines.push(`export let count = 0; export const increment = () => ++count; export const last = f1999;`); + lines.push(`export function failAtEnd() { throw new Error("end"); }`); + using dir = tempDir("module-graph-loaders-large", { "large.mjs": lines.join("\n") + "\n" }); + const file = join(String(dir), "large.mjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const [inA, inB, host] = [await a.import(file), await b.import(file), await import(file)]; + inA.increment(); + expect({ counts: [inA.count, inB.count, host.count], last: inB.last(1) }).toEqual({ + counts: [1, 0, 0], + last: 2000, + }); + const frames = framesIn(caught(inA.failAtEnd), String(dir)); + expect(frames).toEqual(["at failAtEnd (/large.mjs:2002:41)"]); + expect(frames).toEqual(framesIn(caught(host.failAtEnd), String(dir))); + }); + }); + + test("a file served from the transpiler cache has independent instances in each graph", async () => { + const lines: string[] = []; + for (let i = 0; i < 600; i++) lines.push(`function f${i}(x: number): number { return x + ${i}; } // cached`); + lines.push( + `export let count: number = 0; export const increment = (): number => ++count; export const last = f599;`, + ); + using dir = tempDir("module-graph-loaders-transpiler-cache", { + "cached.ts": Buffer.alloc(60 * 1024, "/").toString() + "\n" + lines.join("\n") + "\n", + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/cached.ts"; + const [inA, inB, inC, host] = [ + await new ModuleGraph().import(file), + await new ModuleGraph().import(file), + await new ModuleGraph({ globals: { tenant: "c" } }).import(file), + await import(file), + ]; + inA.increment(); + inA.increment(); + inB.increment(); + console.log(JSON.stringify({ counts: [inA.count, inB.count, inC.count, host.count], last: inC.last(1) })); + `, + }); + const cache = join(String(dir), ".cache"); + const env = { + ...bunEnv, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache, + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + }; + + const cold = await runMain(String(dir), env); + expect(JSON.parse(cold.stdout)).toEqual({ counts: [2, 1, 0, 0], last: 600 }); + const entries = readdirSync(cache); + expect(entries).toHaveLength(1); + + const warm = await runMain(String(dir), env); + expect(JSON.parse(warm.stdout)).toEqual({ counts: [2, 1, 0, 0], last: 600 }); + expect(readdirSync(cache)).toEqual(entries); + expect([cold.exitCode, warm.exitCode]).toEqual([0, 0]); + }); +}); diff --git a/test/js/bun/module-graph/module-graph-require.test.ts b/test/js/bun/module-graph/module-graph-require.test.ts new file mode 100644 index 000000000000..3eb0e0507c26 --- /dev/null +++ b/test/js/bun/module-graph/module-graph-require.test.ts @@ -0,0 +1,1598 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const { ModuleGraph } = Bun.unsafe as any; + +// The host's require: the one every CommonJS module and every host ES module uses. +const hostRequire = createRequire(import.meta.url); + +// What a graph's (or the host's) module code can do, handed to the test. +const probe = ` + export const metaRequire = import.meta.require; + export const load = specifier => import(specifier); + export const attempt = specifier => { + try { + return { value: import.meta.require(specifier) }; + } catch (error) { + return { error, name: error?.name, code: error?.code, message: error?.message }; + } + }; +`; + +const counter = ` + export let count = 0; + export function increment() { return ++count; } +`; + +/** Whether some `require.cache` entry's exports is the module instance that owns `fn`. */ +function requireCacheHolds(name: string, fn: unknown) { + return Object.values(hostRequire.cache).some((module: any) => module?.exports?.[name] === fn); +} + +async function run(dir: { toString(): string }, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("import.meta.require() in a Bun.unsafe.ModuleGraph", () => { + describe("of an ES module", () => { + test("is the graph's own instance, the one its `import` sees", async () => { + using dir = tempDir("module-graph-require-own", { + "counter.mjs": counter, + "entry.mjs": ` + import * as imported from "./counter.mjs"; + export { imported }; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(entry); + const inB = await b.import(entry); + + const required = inA.metaRequire("./counter.mjs"); + required.increment(); + required.increment(); + inB.metaRequire("./counter.mjs").increment(); + + expect({ + isTheImportedNamespace: required === inA.imported, + sameFunctions: required.increment === inA.imported.increment, + repeated: inA.metaRequire("./counter.mjs") === required, + dynamicImport: (await inA.load("./counter.mjs")) === required, + isHosts: required === host.metaRequire("./counter.mjs"), + isOtherGraphs: required === inB.metaRequire("./counter.mjs"), + hostRequiredIsHostImported: host.metaRequire("./counter.mjs") === host.imported, + counts: [host.imported.count, inA.imported.count, inB.imported.count], + }).toEqual({ + isTheImportedNamespace: true, + sameFunctions: true, + repeated: true, + dynamicImport: true, + isHosts: false, + isOtherGraphs: false, + hostRequiredIsHostImported: true, + counts: [0, 2, 1], + }); + }); + + // What the host did with the module before (and again after) the graph required it. + const hostTouches: Record Promise> = { + "the host never touched it": async () => {}, + "the host imported it": async path => await import(path), + "the host required it": async path => hostRequire(path), + "the host imported, then required it": async path => [await import(path), hostRequire(path)], + "the host imported it and read require.cache[path]": async path => [await import(path), hostRequire.cache[path]], + "the host imported it and enumerated require.cache": async path => [ + await import(path), + Object.keys(hostRequire.cache), + ], + "the host imported it and asked `path in require.cache`": async path => [ + await import(path), + path in hostRequire.cache, + ], + }; + + test.each(Object.keys(hostTouches))("never enters require.cache: %s", async touch => { + using dir = tempDir("module-graph-require-cache", { + "imported.mjs": counter, + "lazy.mjs": counter, + "entry.mjs": ` + import * as imported from "./imported.mjs"; + export { imported }; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + const paths = [join(String(dir), "imported.mjs"), join(String(dir), "lazy.mjs")]; + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + for (const path of paths) await hostTouches[touch](path); + + // "lazy.mjs" is loaded into the graph by this require(). + const required = paths.map(path => inGraph.metaRequire(path)); + for (const instance of required) instance.increment(); + const leakedBefore = required.map(instance => requireCacheHolds("increment", instance.increment)); + + for (const path of paths) await hostTouches[touch](path); + const hostInstances = await Promise.all(paths.map(path => import(path))); + + expect({ + requiredIsImported: [required[0] === inGraph.imported, required[1] === (await inGraph.load(paths[1]))], + leakedBefore, + leakedAfter: required.map(instance => requireCacheHolds("increment", instance.increment)), + hostRequiresItsOwn: paths.map((path, i) => hostRequire(path) === hostInstances[i]), + requireCacheHoldsHosts: paths.map((path, i) => hostRequire.cache[path].exports === hostInstances[i]), + counts: [hostInstances.map(instance => instance.count), required.map(instance => instance.count)], + }).toEqual({ + requiredIsImported: [true, true], + leakedBefore: [false, false], + leakedAfter: [false, false], + hostRequiresItsOwn: [true, true], + requireCacheHoldsHosts: [true, true], + counts: [ + [0, 0], + [1, 1], + ], + }); + }); + + test("loads a module nobody has loaded, without a require.cache entry", async () => { + using dir = tempDir("module-graph-require-first-load", { + "fresh.mjs": counter, + "static.mjs": `export * as viaStatic from "./fresh.mjs";`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const fresh = join(String(dir), "fresh.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const required = inGraph.metaRequire("./fresh.mjs"); + + expect({ + inRequireCache: [fresh in hostRequire.cache, Object.keys(hostRequire.cache).includes(fresh)], + entry: hostRequire.cache[fresh], + dynamicImport: (await inGraph.load("./fresh.mjs")) === required, + staticImport: (await inGraph.load("./static.mjs")).viaStatic === required, + graphImport: (await graph.import(fresh)) === required, + }).toEqual({ + inRequireCache: [false, false], + entry: undefined, + dynamicImport: true, + staticImport: true, + graphImport: true, + }); + }); + + test("the required module's imports are the graph's too", async () => { + using dir = tempDir("module-graph-require-transitive", { + "leaf.mjs": counter, + "middle.mjs": ` + export * as leaf from "./leaf.mjs"; + export const leafRequired = import.meta.require("./leaf.mjs"); + export const loadLeaf = () => import("./leaf.mjs"); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const middle = inGraph.metaRequire("./middle.mjs"); + const hostMiddle = host.metaRequire("./middle.mjs"); + middle.leaf.increment(); + + expect({ + nestedRequire: middle.leafRequired === middle.leaf, + nestedImport: (await middle.loadLeaf()) === middle.leaf, + graphImport: (await inGraph.load("./leaf.mjs")) === middle.leaf, + isHosts: middle.leaf === hostMiddle.leaf, + counts: [hostMiddle.leaf.count, middle.leaf.count], + leaked: [requireCacheHolds("leaf", middle.leaf), requireCacheHolds("increment", middle.leaf.increment)], + }).toEqual({ + nestedRequire: true, + nestedImport: true, + graphImport: true, + isHosts: false, + counts: [0, 1], + leaked: [false, false], + }); + }); + + test("a module loaded by require() sees the graph's `globals`", async () => { + using dir = tempDir("module-graph-require-globals", { + "tenant.mjs": `export const read = () => typeof tenant === "undefined" ? undefined : tenant;`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using a = new ModuleGraph({ globals: { tenant: "a" } }); + using b = new ModuleGraph({ globals: { tenant: "b" } }); + using plain = new ModuleGraph(); + const instances = [host, await a.import(entry), await b.import(entry), await plain.import(entry)]; + + expect(instances.map(instance => instance.metaRequire("./tenant.mjs").read())).toEqual([ + undefined, + "a", + "b", + undefined, + ]); + }); + + test("the required module's import.meta is the graph's", async () => { + using dir = tempDir("module-graph-require-meta", { + "meta.mjs": `export const meta = import.meta;`, + "entry.mjs": ` + export const meta = import.meta; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const required = inGraph.metaRequire("./meta.mjs"); + const hostRequired = host.metaRequire("./meta.mjs"); + + expect({ + sameAsGraphImport: required.meta === (await inGraph.load("./meta.mjs")).meta, + isHosts: required.meta === hostRequired.meta, + url: required.meta.url === hostRequired.meta.url, + main: [inGraph.meta.main, required.meta.main, hostRequired.meta.main], + requireIsCached: inGraph.meta.require === inGraph.metaRequire, + requireIsHosts: inGraph.meta.require === host.meta.require, + requireIsPerModule: required.meta.require === inGraph.meta.require, + }).toEqual({ + sameAsGraphImport: true, + isHosts: false, + url: true, + main: [true, false, false], + requireIsCached: true, + requireIsHosts: false, + requireIsPerModule: false, + }); + }); + + test("the require function remembers its graph wherever it is called from", async () => { + using dir = tempDir("module-graph-require-remembers", { + "counter.mjs": counter, + "entry.mjs": ` + export const call = (fn, specifier) => [fn(specifier)]; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(entry); + const inB = await b.import(entry); + const ofA = await inA.load("./counter.mjs"); + const ofB = await inB.load("./counter.mjs"); + const ofHost = await host.load("./counter.mjs"); + + expect({ + aFromHost: inA.metaRequire("./counter.mjs") === ofA, + aFromB: inB.call(inA.metaRequire, "./counter.mjs")[0] === ofA, + bFromA: inA.call(inB.metaRequire, "./counter.mjs")[0] === ofB, + hostFromA: inA.call(host.metaRequire, "./counter.mjs")[0] === ofHost, + hostRequireFromA: inA.call(hostRequire, join(String(dir), "counter.mjs"))[0] === ofHost, + distinct: new Set([ofA, ofB, ofHost]).size, + }).toEqual({ + aFromHost: true, + aFromB: true, + bFromA: true, + hostFromA: true, + hostRequireFromA: true, + distinct: 3, + }); + }); + + test("a bare `require` in the graph's ES module is import.meta.require", async () => { + using dir = tempDir("module-graph-require-bare", { + "counter.mjs": counter, + "entry.mjs": ` + import * as imported from "./counter.mjs"; + export const required = require("./counter.mjs"); + export const requiredIsImported = required === imported; + export const lazily = () => require("./later.mjs"); + export const load = specifier => import(specifier); + `, + "later.mjs": counter, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const later = inGraph.lazily(); + + expect({ + requiredIsImported: [host.requiredIsImported, inGraph.requiredIsImported], + isHosts: [inGraph.required === host.required, later === host.lazily()], + laterIsGraphs: later === (await inGraph.load("./later.mjs")), + leaked: [inGraph.required, later].map(instance => requireCacheHolds("increment", instance.increment)), + }).toEqual({ + requiredIsImported: [true, true], + isHosts: [false, false], + laterIsGraphs: true, + leaked: [false, false], + }); + }); + + test("every way to spell the specifier reaches the same instance", async () => { + using dir = tempDir("module-graph-require-specifiers", { + "counter.mjs": counter, + "typed.ts": `export const typed = (value: number): number => value;`, + "plain.js": `export const plain = () => "esm in .js";`, + "package/package.json": JSON.stringify({ name: "package", type: "module", main: "./index.js" }), + "package/index.js": `export const packaged = () => "package";`, + "node_modules/bare-esm/package.json": JSON.stringify({ + name: "bare-esm", + type: "module", + exports: "./main.js", + }), + "node_modules/bare-esm/main.js": `export const bare = () => "bare";`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const counterPath = join(String(dir), "counter.mjs"); + + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const imported = await inGraph.load("./counter.mjs"); + + expect({ + relative: inGraph.metaRequire("./counter.mjs") === imported, + absolute: inGraph.metaRequire(counterPath) === imported, + fileURL: inGraph.metaRequire(pathToFileURL(counterPath).href) === imported, + extensionless: inGraph.metaRequire("./counter") === imported, + typescript: inGraph.metaRequire("./typed.ts") === (await inGraph.load("./typed.ts")), + esmInJs: inGraph.metaRequire("./plain.js") === (await inGraph.load("./plain.js")), + directory: inGraph.metaRequire("./package") === (await inGraph.load("./package/index.js")), + bare: inGraph.metaRequire("bare-esm") === (await inGraph.load("bare-esm")), + isHosts: ["./counter.mjs", "./typed.ts", "./plain.js", "./package", "bare-esm"].map( + specifier => inGraph.metaRequire(specifier) === host.metaRequire(specifier), + ), + leaked: [ + requireCacheHolds("typed", inGraph.metaRequire("./typed.ts").typed), + requireCacheHolds("plain", inGraph.metaRequire("./plain.js").plain), + requireCacheHolds("packaged", inGraph.metaRequire("./package").packaged), + requireCacheHolds("bare", inGraph.metaRequire("bare-esm").bare), + ], + }).toEqual({ + relative: true, + absolute: true, + fileURL: true, + extensionless: true, + typescript: true, + esmInJs: true, + directory: true, + bare: true, + isHosts: [false, false, false, false, false], + leaked: [false, false, false, false], + }); + }); + + test("a module that throws while evaluating: require() throws, again on retry, and leaves nothing behind", async () => { + using dir = tempDir("module-graph-require-throws", { + "throws.mjs": ` + export const marker = () => {}; + evaluated(); + if (shouldThrow) throw new Error("thrown by throws.mjs"); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const throws = join(String(dir), "throws.mjs"); + + let evaluations = 0; + const evaluated = () => ++evaluations; + using failing = new ModuleGraph({ globals: { evaluated, shouldThrow: true } }); + using working = new ModuleGraph({ globals: { evaluated, shouldThrow: false } }); + const inFailing = await failing.import(entry); + const inWorking = await working.import(entry); + + const first = inFailing.attempt("./throws.mjs"); + const second = inFailing.attempt("./throws.mjs"); + const imported = await inFailing.load("./throws.mjs").catch((error: unknown) => error); + const leftover = [throws in hostRequire.cache, Object.keys(hostRequire.cache).includes(throws)]; + const ok = inWorking.attempt("./throws.mjs"); + + expect({ + messages: [first.message, second.message], + sameError: [first.error === second.error, imported === first.error], + isError: first.error instanceof Error, + leftover, + otherGraphLoadsIt: typeof ok.value?.marker, + evaluations, + leaked: requireCacheHolds("marker", ok.value?.marker), + stillNothing: throws in hostRequire.cache, + }).toEqual({ + messages: ["thrown by throws.mjs", "thrown by throws.mjs"], + sameError: [true, true], + isError: true, + leftover: [false, false], + otherGraphLoadsIt: "function", + evaluations: 2, + leaked: false, + stillNothing: false, + }); + }); + + test("after the graph's require() threw, the host still requires its own instance", async () => { + using dir = tempDir("module-graph-require-throws-host", { + "sometimes.mjs": ` + export const marker = () => {}; + if (typeof shouldThrow !== "undefined") throw new Error("only in the graph"); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const sometimes = join(String(dir), "sometimes.mjs"); + + using graph = new ModuleGraph({ globals: { shouldThrow: true } }); + const inGraph = await graph.import(entry); + const failed = inGraph.attempt("./sometimes.mjs"); + const leftover = sometimes in hostRequire.cache; + const hostInstance = hostRequire(sometimes); + const failedAgain = inGraph.attempt("./sometimes.mjs"); + + expect({ + failed: [failed.message, failedAgain.message, failedAgain.error === failed.error], + leftover, + host: typeof hostInstance.marker, + hostIsImported: hostInstance === (await import(sometimes)), + requireCacheHoldsHosts: hostRequire.cache[sometimes].exports === hostInstance, + }).toEqual({ + failed: ["only in the graph", "only in the graph", true], + leftover: false, + host: "function", + hostIsImported: true, + requireCacheHoldsHosts: true, + }); + }); + + test("a module that fails to parse throws what the host's require() throws", async () => { + using dir = tempDir("module-graph-require-syntax-error", { + "broken.mjs": `export const = ;`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const broken = join(String(dir), "broken.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const host = await import(entry); + const failed = inGraph.attempt("./broken.mjs"); + const leftover = broken in hostRequire.cache; + const hostFailed = host.attempt("./broken.mjs"); + + expect(failed.error).toBeInstanceOf(Error); + expect({ name: failed.name, message: failed.message, leftover }).toEqual({ + name: hostFailed.name, + message: hostFailed.message, + leftover: false, + }); + }); + + test('`__esModule` and the "module.exports" export name work as they do for the host', async () => { + using dir = tempDir("module-graph-require-interop", { + "unmarked.mjs": ` + export default "default export"; + export const named = "named"; + `, + "marked.mjs": ` + export const __esModule = true; + export default "default export"; + `, + "marked-false.mjs": ` + export const __esModule = false; + export const named = "named"; + `, + "module-exports.mjs": ` + export let calls = 0; + function main() { return ++calls; } + export { main as "module.exports" }; + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const describeExports = (exports: any) => ({ + type: typeof exports, + keys: Object.keys(exports), + esModule: exports.__esModule, + default: exports.default, + tag: Object.prototype.toString.call(exports), + }); + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const specifiers = ["./unmarked.mjs", "./marked.mjs", "./marked-false.mjs", "./module-exports.mjs"]; + const inGraphExports = specifiers.map(specifier => describeExports(inGraph.metaRequire(specifier))); + + expect(inGraphExports).toEqual(specifiers.map(specifier => describeExports(host.metaRequire(specifier)))); + expect(inGraphExports).toEqual([ + { + type: "object", + keys: ["default", "named"], + esModule: true, + default: "default export", + tag: "[object Module]", + }, + { + type: "object", + keys: ["__esModule", "default"], + esModule: true, + default: "default export", + tag: "[object Module]", + }, + { type: "object", keys: ["__esModule", "named"], esModule: false, default: undefined, tag: "[object Module]" }, + { type: "function", keys: [], esModule: undefined, default: undefined, tag: "[object Function]" }, + ]); + + // "module.exports" is the graph's function, not the host's. + const main = inGraph.metaRequire("./module-exports.mjs"); + const namespace = await inGraph.load("./module-exports.mjs"); + main(); + expect({ + isTheGraphsExport: main === namespace["module.exports"], + isHosts: main === host.metaRequire("./module-exports.mjs"), + calls: [namespace.calls, (await host.load("./module-exports.mjs")).calls], + leaked: Object.values(hostRequire.cache).some((module: any) => module?.exports === main), + }).toEqual({ isTheGraphsExport: true, isHosts: false, calls: [1, 0], leaked: false }); + }); + + test("a module with top-level await is a TypeError, as for the host", async () => { + using dir = tempDir("module-graph-require-tla", { + "tla.mjs": ` + await new Promise(resolve => setImmediate(resolve)); + export const done = () => true; + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const tla = join(String(dir), "tla.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const failed = inGraph.attempt("./tla.mjs"); + const leftover = tla in hostRequire.cache; + + expect(failed.error).toBeInstanceOf(TypeError); + expect({ message: failed.message, leftover }).toEqual({ + message: `require() async module "${tla}" is unsupported. use "await import()" instead.`, + leftover: false, + }); + + // Once the graph has finished importing it, require() answers with that instance. + const imported = await inGraph.load("./tla.mjs"); + expect({ + required: inGraph.metaRequire("./tla.mjs") === imported, + leaked: requireCacheHolds("done", imported.done), + }).toEqual({ required: true, leaked: false }); + }); + + test("a require cycle through an import behaves like the host's", async () => { + using dir = tempDir("module-graph-require-cycle", { + "a.mjs": ` + export const b = import.meta.require("./b.mjs"); + export const value = "a"; + `, + "b.mjs": ` + import * as a from "./a.mjs"; + export { a }; + export const sawValue = (() => { try { return a.value; } catch (error) { return error.name; } })(); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const observe = async (instance: any) => { + const a = await instance.load("./a.mjs"); + return { + sawValue: a.b.sawValue, + cycleCloses: a.b.a === a, + value: a.b.a.value, + requiredAgain: instance.metaRequire("./a.mjs") === a && instance.metaRequire("./b.mjs") === a.b, + }; + }; + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const host = await import(entry); + const observed = await observe(inGraph); + + expect(observed).toEqual(await observe(host)); + expect(observed).toEqual({ sawValue: "ReferenceError", cycleCloses: true, value: "a", requiredAgain: true }); + expect((await inGraph.load("./a.mjs")).b).not.toBe((await host.load("./a.mjs")).b); + }); + + test("a require cycle through another require behaves like the host's", async () => { + using dir = tempDir("module-graph-require-cycle-require", { + "c.mjs": ` + export const d = import.meta.require("./d.mjs"); + export const value = "c"; + `, + "d.mjs": ` + // c.mjs is still evaluating: this is its live namespace, whose bindings are in their temporal dead zone. + const c = import.meta.require("./c.mjs"); + export const keysOfC = Reflect.ownKeys(c).filter(key => typeof key === "string"); + export const readC = () => c.value; + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const paths = [join(String(dir), "c.mjs"), join(String(dir), "d.mjs")]; + + const observe = (instance: any) => { + const c = instance.metaRequire("./c.mjs"); + return { keys: Object.keys(c), keysOfC: c.d.keysOfC, readC: c.d.readC() }; + }; + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const observed = observe(inGraph); + const leftover = paths.map(path => path in hostRequire.cache); + const host = await import(entry); + + expect(observed).toEqual({ keys: ["d", "value"], keysOfC: ["d", "value"], readC: "c" }); + expect(observe(host)).toEqual(observed); + expect(leftover).toEqual([false, false]); + }); + + test("each graph evaluates a module it requires once, however often and however it asks", async () => { + using dir = tempDir("module-graph-require-evaluations", { + "evaluated.mjs": ` + export const evaluation = evaluated(); + export const marker = () => {}; + `, + "entry.mjs": ` + export const viaBareRequire = () => require("./evaluated.mjs"); + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + let evaluations = 0; + const globals = { evaluated: () => ++evaluations }; + const seen: number[][] = []; + const markers = new Set(); + for (let i = 0; i < 3; i++) { + const inGraph = await new ModuleGraph({ globals }).import(entry); + const instances = [ + inGraph.metaRequire("./evaluated.mjs"), + inGraph.metaRequire(join(String(dir), "evaluated.mjs")), + inGraph.viaBareRequire(), + await inGraph.load("./evaluated.mjs"), + inGraph.metaRequire("./evaluated.mjs"), + ]; + seen.push(instances.map(instance => instance.evaluation)); + for (const instance of instances) markers.add(instance.marker); + } + + expect({ seen, evaluations, distinctInstances: markers.size }).toEqual({ + seen: [ + [1, 1, 1, 1, 1], + [2, 2, 2, 2, 2], + [3, 3, 3, 3, 3], + ], + evaluations: 3, + distinctInstances: 3, + }); + expect([...markers].map(marker => requireCacheHolds("marker", marker))).toEqual([false, false, false]); + }); + + // While a graph's require() is evaluating an ES module for the first time, a placeholder for + // it sits in require.cache, and a CommonJS module that requires the same file gets the + // placeholder's empty exports instead of the host's instance. + test("a CommonJS require() during the graph's first load still gets the host's instance", async () => { + using dir = tempDir("module-graph-require-placeholder", { + "helper.cjs": `exports.requireSubject = () => require("./subject.mjs");`, + "subject.mjs": ` + import helper from "./helper.cjs"; + export const marker = () => {}; + export const seenByCommonJS = typeof inGraph === "undefined" ? undefined : helper.requireSubject(); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const hostSubject = await import(join(String(dir), "subject.mjs")); + using graph = new ModuleGraph({ globals: { inGraph: true } }); + const inGraph = await graph.import(entry); + const subject = inGraph.metaRequire("./subject.mjs"); + + expect(subject.marker).not.toBe(hostSubject.marker); + expect(subject.seenByCommonJS).toBe(hostSubject); + }); + + test("another graph's require() during the first load still gets its own instance", async () => { + using dir = tempDir("module-graph-require-placeholder-graphs", { + "subject.mjs": ` + export const marker = () => {}; + export const seenByOtherGraph = duringEvaluation(); + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + using other = new ModuleGraph({ globals: { duringEvaluation: () => undefined } }); + const inOther = await other.import(entry); + using graph = new ModuleGraph({ globals: { duringEvaluation: () => inOther.metaRequire("./subject.mjs") } }); + const inGraph = await graph.import(entry); + const subject = inGraph.metaRequire("./subject.mjs"); + + expect(subject.seenByOtherGraph).toBe(await inOther.load("./subject.mjs")); + expect(subject.seenByOtherGraph.marker).not.toBe(subject.marker); + }); + }); + + describe("of a CommonJS module", () => { + test("is the one shared instance for the host and every graph", async () => { + using dir = tempDir("module-graph-require-cjs-shared", { + "shared.cjs": ` + exports.module = module; + exports.token = {}; + `, + "entry.mjs": ` + import imported from "./shared.cjs"; + export { imported }; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + const shared = join(String(dir), "shared.cjs"); + + using a = new ModuleGraph(); + using b = new ModuleGraph({ globals: { tenant: "b" } }); + using c = new ModuleGraph(); + const instances = [await a.import(entry), await b.import(entry), await c.import(entry), await import(entry)]; + const exports = hostRequire(shared); + + expect({ + required: instances.map(instance => instance.metaRequire("./shared.cjs") === exports), + imported: instances.map(instance => instance.imported === exports), + inRequireCache: hostRequire.cache[shared] === exports.module, + loaded: exports.module.loaded, + }).toEqual({ + required: [true, true, true, true], + imported: [true, true, true, true], + inRequireCache: true, + loaded: true, + }); + }); + + test("required by a graph first, it is what the host gets afterwards", async () => { + using dir = tempDir("module-graph-require-cjs-graph-first", { + "child.cjs": `exports.module = module;`, + "parent.cjs": ` + exports.module = module; + exports.child = require("./child.cjs"); + exports.seesGraphGlobals = typeof tenant !== "undefined"; + `, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const parent = join(String(dir), "parent.cjs"); + + using graph = new ModuleGraph({ globals: { tenant: "a" } }); + const inGraph = await graph.import(entry); + const required = inGraph.metaRequire("./parent.cjs"); + const { module } = required; + + expect({ + hostGetsTheSame: hostRequire(parent) === required, + inRequireCache: hostRequire.cache[parent] === module, + id: module.id, + loaded: module.loaded, + parent: [module.parent.id, module.parent.filename], + parentKnowsIt: module.parent.children.includes(module), + children: module.children.map((child: any) => child.id), + childParent: required.child.module.parent === module, + seesGraphGlobals: required.seesGraphGlobals, + }).toEqual({ + hostGetsTheSame: true, + inRequireCache: true, + id: parent, + loaded: true, + parent: [entry, entry], + parentKnowsIt: true, + children: [join(String(dir), "child.cjs")], + childParent: true, + seesGraphGlobals: false, + }); + }); + + describe.each(["a graph", "the host"])("first required by %s", first => { + test("its own require() and import() of an ES module give the host's instance", async () => { + using dir = tempDir("module-graph-require-cjs-esm", { + "counter.mjs": counter, + "inner.cjs": `exports.counter = require("./counter.mjs");`, + "outer.cjs": ` + exports.atLoad = require("./counter.mjs"); + exports.later = () => require("./counter.mjs"); + exports.lazy = () => require("./lazy.mjs"); + exports.nested = require("./inner.cjs").counter; + exports.dynamic = () => import("./counter.mjs"); + `, + "lazy.mjs": counter, + "entry.mjs": ` + import * as imported from "./counter.mjs"; + export { imported }; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + const outerPath = join(String(dir), "outer.cjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + using other = new ModuleGraph(); + const inOther = await other.import(entry); + const outer = first === "a graph" ? inGraph.metaRequire("./outer.cjs") : hostRequire(outerPath); + const hostCounter = await import(join(String(dir), "counter.mjs")); + inGraph.imported.increment(); + + expect({ + sameForEveryone: [ + inGraph.metaRequire("./outer.cjs"), + inOther.metaRequire("./outer.cjs"), + hostRequire(outerPath), + ].map(exports => exports === outer), + atLoad: outer.atLoad === hostCounter, + later: outer.later() === hostCounter, + nested: outer.nested === hostCounter, + dynamic: (await outer.dynamic()) === hostCounter, + lazy: outer.lazy() === (await import(join(String(dir), "lazy.mjs"))), + graphLazyIsItsOwn: inGraph.metaRequire("./lazy.mjs") !== outer.lazy(), + graphKeepsItsOwn: inGraph.metaRequire("./counter.mjs") === inGraph.imported, + counts: [hostCounter.count, inGraph.imported.count, inOther.imported.count], + }).toEqual({ + sameForEveryone: [true, true, true], + atLoad: true, + later: true, + nested: true, + dynamic: true, + lazy: true, + graphLazyIsItsOwn: true, + graphKeepsItsOwn: true, + counts: [0, 1, 0], + }); + }); + }); + + test("a CommonJS module that throws is retried, for the graph as for the host", async () => { + using dir = tempDir("module-graph-require-cjs-throws", { + "flaky.cjs": ` + const attempts = require("./attempts.cjs"); + if (++attempts.count < 2) throw new Error("not yet"); + exports.attempts = attempts; + `, + "attempts.cjs": `exports.count = 0;`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const flaky = join(String(dir), "flaky.cjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const failed = inGraph.attempt("./flaky.cjs"); + const leftover = flaky in hostRequire.cache; + const retried = inGraph.attempt("./flaky.cjs"); + + expect({ + failed: failed.message, + leftover, + attempts: retried.value?.attempts.count, + hostGetsTheSame: hostRequire(flaky) === retried.value, + }).toEqual({ failed: "not yet", leftover: false, attempts: 2, hostGetsTheSame: true }); + }); + }); + + describe("of everything else", () => { + test("builtin modules are the shared ones", async () => { + using dir = tempDir("module-graph-require-builtins", { "entry.mjs": probe }); + using graph = new ModuleGraph(); + const inGraph = await graph.import(join(String(dir), "entry.mjs")); + + const specifiers = ["node:fs", "fs", "node:path", "node:module", "node:events", "bun:sqlite", "bun:jsc", "bun"]; + expect(specifiers.map(specifier => inGraph.metaRequire(specifier) === hostRequire(specifier))).toEqual( + specifiers.map(() => true), + ); + expect(inGraph.metaRequire("bun")).toBe(Bun); + }); + + test("JSON and TOML are the shared objects", async () => { + using dir = tempDir("module-graph-require-data", { + "data.json": JSON.stringify({ name: "data", list: [1, 2, 3] }), + "config.toml": `title = "config"`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const json = inGraph.metaRequire("./data.json"); + const toml = inGraph.metaRequire("./config.toml"); + + expect({ json, toml }).toEqual({ json: { name: "data", list: [1, 2, 3] }, toml: { title: "config" } }); + expect({ + json: [hostRequire(join(String(dir), "data.json")) === json, inGraph.metaRequire("./data.json") === json], + toml: [hostRequire(join(String(dir), "config.toml")) === toml, inGraph.metaRequire("./config.toml") === toml], + }).toEqual({ json: [true, true], toml: [true, true] }); + }); + + test("a file that is not a native addon fails as it does for the host", async () => { + using dir = tempDir("module-graph-require-native", { + "addon.node": "not a shared library", + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + const addon = join(String(dir), "addon.node"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const host = await import(entry); + const failed = inGraph.attempt("./addon.node"); + const hostFailed = host.attempt("./addon.node"); + + expect(failed.error).toBeInstanceOf(Error); + expect({ code: failed.code, message: failed.message, leftover: addon in hostRequire.cache }).toEqual({ + code: "ERR_DLOPEN_FAILED", + message: hostFailed.message, + leftover: false, + }); + }); + + test("a module that does not resolve throws the usual error and leaves no require.cache entry", async () => { + using dir = tempDir("module-graph-require-missing", { "entry.mjs": probe }); + const entry = join(String(dir), "entry.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const host = await import(entry); + const before = Object.keys(hostRequire.cache).length; + const specifiers = ["./missing.mjs", "./missing.cjs", "module-graph-require-missing-package", "node:missing"]; + const failed = specifiers.map(specifier => inGraph.attempt(specifier)); + const hostFailed = specifiers.map(specifier => host.attempt(specifier)); + + expect(failed.map(({ name, code, message }) => ({ name, code, message }))).toEqual( + hostFailed.map(({ name, code, message }) => ({ name, code, message })), + ); + expect({ + name: failed[0].name, + code: failed[0].code, + message: failed[0].message, + leftover: join(String(dir), "missing.mjs") in hostRequire.cache, + newEntries: Object.keys(hostRequire.cache).length - before, + }).toEqual({ + name: "ResolveMessage", + code: "MODULE_NOT_FOUND", + message: `Cannot find module './missing.mjs'\nRequire stack:\n- ${entry}`, + leftover: false, + newEntries: 0, + }); + }); + + test("createRequire() called by the graph's code is an ordinary host require", async () => { + using dir = tempDir("module-graph-require-create-require", { + "counter.mjs": counter, + "shared.cjs": `exports.token = {};`, + "entry.mjs": ` + import { createRequire } from "node:module"; + import * as imported from "./counter.mjs"; + export { imported }; + export const created = createRequire(import.meta.url); + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + + expect({ + esmIsHosts: inGraph.created("./counter.mjs") === host.imported, + esmIsGraphs: inGraph.created("./counter.mjs") === inGraph.imported, + commonJS: inGraph.created("./shared.cjs") === inGraph.metaRequire("./shared.cjs"), + cache: inGraph.created.cache === hostRequire.cache, + resolve: inGraph.created.resolve("./counter.mjs"), + graphKeepsItsOwn: inGraph.metaRequire("./counter.mjs") === inGraph.imported, + }).toEqual({ + esmIsHosts: true, + esmIsGraphs: false, + commonJS: true, + cache: true, + resolve: join(String(dir), "counter.mjs"), + graphKeepsItsOwn: true, + }); + }); + + test("require.resolve, require.cache and require.main", async () => { + using dir = tempDir("module-graph-require-properties", { + "counter.mjs": counter, + "node_modules/resolved-package/package.json": JSON.stringify({ name: "resolved-package", main: "./main.cjs" }), + "node_modules/resolved-package/main.cjs": `module.exports = "resolved-package";`, + "elsewhere/marker.cjs": `module.exports = "elsewhere";`, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + using graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const host = await import(entry); + const { metaRequire } = inGraph; + let resolveError: any; + try { + metaRequire.resolve("./missing.mjs"); + } catch (error) { + resolveError = error; + } + + expect({ + relative: metaRequire.resolve("./counter.mjs"), + bare: metaRequire.resolve("resolved-package"), + builtin: metaRequire.resolve("node:fs"), + paths: metaRequire.resolve("./marker.cjs", { paths: [join(String(dir), "elsewhere")] }), + missing: [resolveError?.name, resolveError?.code], + resolvingLoadsNothing: join(String(dir), "counter.mjs") in hostRequire.cache, + cache: metaRequire.cache === hostRequire.cache, + main: metaRequire.main === host.metaRequire.main, + ownKeys: Reflect.ownKeys(metaRequire).sort(), + }).toEqual({ + relative: join(String(dir), "counter.mjs"), + bare: join(String(dir), "node_modules/resolved-package/main.cjs"), + builtin: "node:fs", + paths: join(String(dir), "elsewhere/marker.cjs"), + missing: ["ResolveMessage", "MODULE_NOT_FOUND"], + resolvingLoadsNothing: false, + cache: true, + main: true, + ownKeys: Reflect.ownKeys(host.metaRequire).sort(), + }); + }); + }); + + describe("after dispose()", () => { + test("an ES module throws ERR_INVALID_STATE, loaded before or not", async () => { + using dir = tempDir("module-graph-require-disposed-esm", { + "loaded.mjs": counter, + "never-loaded.mjs": counter, + "entry.mjs": ` + import * as imported from "./loaded.mjs"; + export { imported }; + ${probe} + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const before = inGraph.metaRequire("./loaded.mjs"); + graph.dispose(); + const neverLoaded = inGraph.attempt("./never-loaded.mjs"); + const loaded = inGraph.attempt("./loaded.mjs"); + + expect(neverLoaded.error).toBeInstanceOf(Error); + expect({ + codes: [neverLoaded.code, loaded.code], + leftover: join(String(dir), "never-loaded.mjs") in hostRequire.cache, + alreadyObtainedStillWorks: [before.increment(), inGraph.imported.count], + hostIsUnaffected: hostRequire(join(String(dir), "never-loaded.mjs")).increment(), + }).toEqual({ + codes: ["ERR_INVALID_STATE", "ERR_INVALID_STATE"], + leftover: false, + alreadyObtainedStillWorks: [1, 1], + hostIsUnaffected: 1, + }); + }); + + test("CommonJS, builtins, JSON and require.resolve keep working", async () => { + using dir = tempDir("module-graph-require-disposed-cjs", { + "counter.mjs": counter, + "before.cjs": `exports.token = {};`, + "after.cjs": ` + exports.token = {}; + exports.counter = require("./counter.mjs"); + `, + "data.json": JSON.stringify({ ok: true }), + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const graph = new ModuleGraph(); + const inGraph = await graph.import(entry); + const before = inGraph.metaRequire("./before.cjs"); + graph.dispose(); + const after = inGraph.metaRequire("./after.cjs"); + + expect({ + loadedBefore: inGraph.metaRequire("./before.cjs") === before, + loadedAfter: hostRequire(join(String(dir), "after.cjs")) === after, + commonJSRequiresHostsESM: after.counter === (await import(join(String(dir), "counter.mjs"))), + builtin: inGraph.metaRequire("node:fs") === hostRequire("node:fs"), + json: inGraph.metaRequire("./data.json"), + resolve: inGraph.metaRequire.resolve("./counter.mjs"), + missing: inGraph.attempt("./missing.cjs").code, + }).toEqual({ + loadedBefore: true, + loadedAfter: true, + commonJSRequiresHostsESM: true, + builtin: true, + json: { ok: true }, + resolve: join(String(dir), "counter.mjs"), + missing: "MODULE_NOT_FOUND", + }); + }); + + test("other graphs and the host keep requiring their own instances", async () => { + using dir = tempDir("module-graph-require-disposed-others", { + "counter.mjs": counter, + "entry.mjs": probe, + }); + const entry = join(String(dir), "entry.mjs"); + + const disposed = new ModuleGraph(); + using other = new ModuleGraph(); + const inDisposed = await disposed.import(entry); + const inOther = await other.import(entry); + const host = await import(entry); + disposed.dispose(); + + const ofOther = inOther.metaRequire("./counter.mjs"); + const ofHost = host.metaRequire("./counter.mjs"); + expect({ + disposed: inDisposed.attempt("./counter.mjs").code, + other: ofOther === (await inOther.load("./counter.mjs")), + host: ofHost === (await host.load("./counter.mjs")), + distinct: ofOther !== ofHost, + }).toEqual({ disposed: "ERR_INVALID_STATE", other: true, host: true, distinct: true }); + }); + }); + + describe("when the host patches node:module", () => { + const files = { + "imported.mjs": counter, + "lazy.mjs": counter, + "typed.ts": ` + export let count: number = 0; + export function increment(): number { return ++count; } + `, + "plain.js": counter, + "common.cjs": ` + globalThis.commonEvaluations = (globalThis.commonEvaluations ?? 0) + 1; + exports.imported = require("./imported.mjs"); + exports.injected = typeof injectedByWrapper === "undefined" ? undefined : injectedByWrapper; + `, + "entry.mjs": ` + import * as imported from "./imported.mjs"; + export { imported }; + ${probe} + `, + "main.mjs": ` + import Module, { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const dir = import.meta.dir; + const relative = value => typeof value === "string" ? value.replace(dir + "/", "") : typeof value; + + let log = []; + const wrapExtensions = wrap => { + for (const extension of [".js", ".mjs", ".ts"]) { + Module._extensions[extension] = wrap(Module._extensions[extension], extension); + } + }; + const patches = { + "nothing patched": () => {}, + "Module._extensions wrapped, calling the original": () => + wrapExtensions(original => (module, filename) => original(module, filename)), + "Module._extensions wrapped, logging": () => + wrapExtensions((original, extension) => function (module, filename) { + log.push(extension + " " + relative(filename)); + return original.call(this, module, filename); + }), + "Module.prototype.require overridden": () => { + const original = Module.prototype.require; + Module.prototype.require = function (id) { + log.push("require " + relative(id)); + return original.call(this, id); + }; + }, + "Module._resolveFilename overridden": () => { + const original = Module._resolveFilename; + Module._resolveFilename = function (request, ...rest) { + log.push("resolve " + relative(request)); + return original.call(this, request === "redirected" ? dir + "/imported.mjs" : request, ...rest); + }; + }, + "Module._load overridden": () => { + const original = Module._load; + Module._load = function (request, ...rest) { + log.push("load " + relative(request)); + return original.call(this, request, ...rest); + }; + }, + "Module.wrap overridden": () => { + const original = Module.wrap; + Module.wrap = source => { + log.push("wrap"); + return original(source); + }; + }, + "Module.wrapper modified": () => { + Module.wrapper[0] += "var injectedByWrapper = 'Module.wrapper';"; + }, + }; + const [name, when] = process.argv.slice(2); + + if (when === "before the graph loads anything") patches[name](); + const graph = await new Bun.unsafe.ModuleGraph().import(dir + "/entry.mjs"); + if (when === "after the graph loaded something") patches[name](); + const host = await import(dir + "/entry.mjs"); + + const result = { modules: {} }; + const logs = { host: [], graph: [] }; + const requireInGraph = file => { + log = logs.graph; + return graph.metaRequire("./" + file); + }; + const requireInHost = file => { + log = logs.host; + return require("./" + file); + }; + for (const [file, first] of [["imported.mjs", "host"], ["lazy.mjs", "graph"], ["typed.ts", "host"], ["plain.js", "graph"]]) { + if (first === "graph") requireInGraph(file); + const hostRequired = requireInHost(file); + const required = requireInGraph(file); + required.increment(); + result.modules[file] = { + graphsOwn: required === await graph.load("./" + file), + hostsOwn: hostRequired === await import(dir + "/" + file), + counts: [hostRequired.count, required.count], + leaked: Object.values(require.cache).some(module => module?.exports?.increment === required.increment), + }; + } + log = logs.graph; + try { + result.redirected = graph.metaRequire("redirected") === graph.imported; + } catch (error) { + result.redirected = error.code; + } + const common = graph.metaRequire("./common.cjs"); + result.commonJS = { + shared: common === require("./common.cjs"), + requiresHostsESM: common.imported === host.imported, + evaluations: globalThis.commonEvaluations, + injected: common.injected, + }; + result.logs = logs; + console.log(JSON.stringify(result)); + `, + }; + + const graphsOwn = { graphsOwn: true, hostsOwn: true, counts: [0, 1], leaked: false }; + const unpatched = { + modules: { "imported.mjs": graphsOwn, "lazy.mjs": graphsOwn, "typed.ts": graphsOwn, "plain.js": graphsOwn }, + redirected: "MODULE_NOT_FOUND", + commonJS: { shared: true, requiresHostsESM: true, evaluations: 1 }, + logs: { host: [], graph: [] }, + }; + // An extension handler runs for a require() that finds neither a loaded instance nor a require.cache + // entry: never for "imported.mjs", and for the graph only where it required the file before the host did. + const extensionLogs = { + host: [".mjs lazy.mjs", ".ts typed.ts", ".js plain.js"], + graph: [".mjs lazy.mjs", ".js plain.js"], + }; + const hostRequests = ["./imported.mjs", "./lazy.mjs", "./typed.ts", "./plain.js"]; + const graphRequests = ["./imported.mjs", "./lazy.mjs", "./lazy.mjs", "./typed.ts", "./plain.js", "./plain.js"]; + + test.concurrent.each([ + ["nothing patched", "before the graph loads anything", unpatched], + ["Module._extensions wrapped, calling the original", "before the graph loads anything", unpatched], + ["Module._extensions wrapped, calling the original", "after the graph loaded something", unpatched], + ["Module._extensions wrapped, logging", "before the graph loads anything", { ...unpatched, logs: extensionLogs }], + [ + "Module._extensions wrapped, logging", + "after the graph loaded something", + { ...unpatched, logs: extensionLogs }, + ], + [ + "Module.prototype.require overridden", + "before the graph loads anything", + { + ...unpatched, + logs: { + host: hostRequests.map(request => "require " + request), + graph: [ + ...graphRequests.map(request => "require " + request), + "require redirected", + "require ./common.cjs", + "require ./imported.mjs", + "require ./common.cjs", + ], + }, + }, + ], + [ + "Module._resolveFilename overridden", + "before the graph loads anything", + { + ...unpatched, + redirected: true, + logs: { + host: hostRequests.map(request => "resolve " + request), + graph: [ + ...graphRequests.map(request => "resolve " + request), + "resolve redirected", + "resolve ./common.cjs", + "resolve ./imported.mjs", + "resolve ./common.cjs", + ], + }, + }, + ], + ["Module._load overridden", "before the graph loads anything", unpatched], + ["Module.wrap overridden", "before the graph loads anything", unpatched], + [ + "Module.wrapper modified", + "after the graph loaded something", + { ...unpatched, commonJS: { ...unpatched.commonJS, injected: "Module.wrapper" } }, + ], + ])("%s, %s", async (patch, when, expected) => { + using dir = tempDir("module-graph-require-patched", files); + const { stdout, stderr, exitCode } = await run(dir, "main.mjs", patch, when); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + test.concurrent("a wrapped Module._extensions handler is handed the graph's instance", async () => { + using dir = tempDir("module-graph-require-extension-handler", { + "counter.mjs": counter, + "entry.mjs": probe, + "main.mjs": ` + import Module, { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const path = import.meta.dir + "/counter.mjs"; + + const handled = []; + const original = Module._extensions[".mjs"]; + Module._extensions[".mjs"] = function (module, filename) { + original.call(this, module, filename); + handled.push(module); + }; + + const graph = await new Bun.unsafe.ModuleGraph().import(import.meta.dir + "/entry.mjs"); + const required = graph.metaRequire("./counter.mjs"); + const afterGraph = [path in require.cache, Object.keys(require.cache).includes(path)]; + const hostRequired = require(path); + + console.log(JSON.stringify({ + handled: handled.map(module => module.id === path), + handlerSawGraphsInstance: handled[0].exports === required, + handlerSawHostsInstance: handled[1].exports === hostRequired, + graphsOwn: required === await graph.load("./counter.mjs"), + distinct: required !== hostRequired, + afterGraph, + requireCacheHoldsHosts: require.cache[path] === handled[1], + requiredAgain: graph.metaRequire("./counter.mjs") === required, + })); + `, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.mjs"); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + handled: [true, true], + handlerSawGraphsInstance: true, + handlerSawHostsInstance: true, + graphsOwn: true, + distinct: true, + afterGraph: [false, false], + requireCacheHoldsHosts: true, + requiredAgain: true, + }); + expect(exitCode).toBe(0); + }); + + // Debug builds abort with `ASSERTION FAILED: wasRemoved` in finishRequireWithError + // (JSCommonJSModule.cpp). The host's own require() does the same without any ModuleGraph. + test("a wrapped Module._extensions handler and a module that throws while evaluating", async () => { + using dir = tempDir("module-graph-require-extension-throws", { + "throws.mjs": `throw new Error("thrown by throws.mjs");`, + "entry.mjs": probe, + "main.mjs": ` + import Module, { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const original = Module._extensions[".mjs"]; + Module._extensions[".mjs"] = (module, filename) => original(module, filename); + + const graph = await new Bun.unsafe.ModuleGraph().import(import.meta.dir + "/entry.mjs"); + const attempts = [graph.attempt("./throws.mjs"), graph.attempt("./throws.mjs")]; + console.log(JSON.stringify({ + messages: attempts.map(attempt => attempt.message), + leftover: (import.meta.dir + "/throws.mjs") in require.cache, + })); + `, + }); + const { stdout, exitCode } = await run(dir, "main.mjs"); + + expect(JSON.parse(stdout)).toEqual({ + messages: ["thrown by throws.mjs", "thrown by throws.mjs"], + leftover: false, + }); + expect(exitCode).toBe(0); + }); + }); + + describe("and the process-wide require.cache", () => { + test.concurrent("one evaluation of a CommonJS module for the host and every graph", async () => { + using dir = tempDir("module-graph-require-cjs-evaluations", { + "shared.cjs": ` + globalThis.sharedEvaluations = (globalThis.sharedEvaluations ?? 0) + 1; + exports.token = {}; + `, + "entry.mjs": ` + import imported from "./shared.cjs"; + export { imported }; + ${probe} + `, + "main.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const entry = import.meta.dir + "/entry.mjs"; + + const graphs = []; + for (let i = 0; i < 4; i++) graphs.push(await new Bun.unsafe.ModuleGraph().import(entry)); + const tokens = graphs.flatMap(graph => [graph.imported.token, graph.metaRequire("./shared.cjs").token]); + const host = await import(entry); + tokens.push(host.imported.token, require("./shared.cjs").token); + + console.log(JSON.stringify({ + distinctTokens: new Set(tokens).size, + evaluations: globalThis.sharedEvaluations, + })); + `, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.mjs"); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ distinctTokens: 1, evaluations: 1 }); + expect(exitCode).toBe(0); + }); + + test.concurrent("deleting require.cache[path] leaves the graph's instances alone", async () => { + using dir = tempDir("module-graph-require-cache-delete", { + "counter.mjs": counter, + "shared.cjs": `exports.token = {};`, + "entry.mjs": ` + import * as imported from "./counter.mjs"; + export { imported }; + ${probe} + `, + "main.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const path = import.meta.dir + "/counter.mjs"; + const sharedPath = import.meta.dir + "/shared.cjs"; + + const graph = await new Bun.unsafe.ModuleGraph().import(import.meta.dir + "/entry.mjs"); + graph.imported.increment(); + const hostBefore = require(path); + const sharedBefore = graph.metaRequire("./shared.cjs"); + + const deleted = [delete require.cache[path], delete require.cache[sharedPath]]; + const hostAfter = require(path); + + console.log(JSON.stringify({ + deleted, + graphRequire: graph.metaRequire("./counter.mjs") === graph.imported, + graphImport: (await graph.load("./counter.mjs")) === graph.imported, + graphCount: graph.metaRequire("./counter.mjs").count, + hostReloaded: hostAfter !== hostBefore, + hostIsNotGraphs: hostAfter !== graph.imported, + // CommonJS is not the graph's: the graph sees the reloaded module like everyone else. + commonJSReloaded: graph.metaRequire("./shared.cjs") !== sharedBefore, + commonJSShared: graph.metaRequire("./shared.cjs") === require(sharedPath), + })); + `, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.mjs"); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + deleted: [true, true], + graphRequire: true, + graphImport: true, + graphCount: 1, + hostReloaded: true, + hostIsNotGraphs: true, + commonJSReloaded: true, + commonJSShared: true, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent( + "a module the host plants in require.cache[path] is what a graph's require() returns too", + async () => { + using dir = tempDir("module-graph-require-cache-assign", { + "counter.mjs": counter, + "replacement.cjs": `exports.replacement = true;`, + "entry.mjs": probe, + "main.mjs": ` + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const path = import.meta.dir + "/counter.mjs"; + + const graph = await new Bun.unsafe.ModuleGraph().import(import.meta.dir + "/entry.mjs"); + const replacement = require("./replacement.cjs"); + require.cache[path] = require.cache[import.meta.dir + "/replacement.cjs"]; + const required = graph.metaRequire("./counter.mjs"); + + const imported = await graph.load("./counter.mjs"); + const result = { + hostGetsReplacement: require(path) === replacement, + graphGetsReplacement: required === replacement && graph.metaRequire("./counter.mjs") === replacement, + graphImportIsUnaffected: typeof imported.increment === "function", + }; + delete require.cache[path]; + result.afterDelete = graph.metaRequire("./counter.mjs") === imported; + console.log(JSON.stringify(result)); + `, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.mjs"); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + hostGetsReplacement: true, + graphGetsReplacement: true, + graphImportIsUnaffected: true, + afterDelete: true, + }); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("require.main is the entry point's module, from a CommonJS entry point too", async () => { + using dir = tempDir("module-graph-require-main", { + "entry.mjs": probe, + "main.cjs": ` + new Bun.unsafe.ModuleGraph().import(__dirname + "/entry.mjs").then(graph => { + console.log(JSON.stringify({ + isRequireMain: graph.metaRequire.main === require.main, + isThisModule: graph.metaRequire.main === module, + graphMainModule: typeof graph.metaRequire.main.filename, + })); + }); + `, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.cjs"); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ isRequireMain: true, isThisModule: true, graphMainModule: "string" }); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/module-graph/module-graph.test.ts b/test/js/bun/module-graph/module-graph.test.ts new file mode 100644 index 000000000000..2626682baf5e --- /dev/null +++ b/test/js/bun/module-graph/module-graph.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; + +const { ModuleGraph } = Bun.unsafe as any; + +describe("Bun.unsafe.ModuleGraph", () => { + test("each graph has its own module instances", async () => { + using dir = tempDir("module-graph-instances", { + "counter.mjs": ` + export let count = 0; + export function increment() { return ++count; } + `, + "entry.mjs": ` + export * from "./counter.mjs"; + export const loadCounter = () => import("./counter.mjs"); + `, + }); + const entry = join(String(dir), "entry.mjs"); + + const host = await import(entry); + using a = new ModuleGraph(); + using b = new ModuleGraph(); + const inA = await a.import(entry); + const inB = await b.import(entry); + + expect(inA).not.toBe(host); + expect(inA).not.toBe(inB); + expect(await a.import(entry)).toBe(inA); + + inA.increment(); + inA.increment(); + inB.increment(); + expect({ host: host.count, a: inA.count, b: inB.count }).toEqual({ host: 0, a: 2, b: 1 }); + + // import() inside a graph's module stays in that graph. + expect((await inA.loadCounter()).count).toBe(2); + expect((await inB.loadCounter()).count).toBe(1); + expect((await host.loadCounter()).count).toBe(0); + }); + + test("`globals` are free identifiers of the graph's modules only", async () => { + using dir = tempDir("module-graph-globals", { + "tenant.mjs": ` + export const read = () => [tenant, typeof region]; + export const write = value => { tenant = value; }; + export const global = globalThis; + `, + }); + const file = join(String(dir), "tenant.mjs"); + + using a = new ModuleGraph({ globals: { tenant: "a", region: "us" } }); + using b = new ModuleGraph({ globals: { region: "eu", tenant: "b" } }); + const inA = await a.import(file); + const inB = await b.import(file); + + expect(inA.read()).toEqual(["a", "string"]); + expect(inB.read()).toEqual(["b", "string"]); + inA.write("changed"); + expect(inA.read()).toEqual(["changed", "string"]); + expect(inB.read()).toEqual(["b", "string"]); + + expect(inA.global).toBe(globalThis); + expect("tenant" in globalThis).toBe(false); + expect((await import(file)).read).toThrow(ReferenceError); + }); + + test("import.meta belongs to the graph", async () => { + using dir = tempDir("module-graph-meta", { + "dep.mjs": `export const meta = import.meta;`, + "main.mjs": ` + export { meta as depMeta } from "./dep.mjs"; + export const meta = import.meta; + `, + }); + const main = join(String(dir), "main.mjs"); + + using graph = new ModuleGraph(); + expect(graph.mainModule).toBeUndefined(); + const inGraph = await graph.import(main); + const host = await import(main); + + expect(graph.mainModule).toBe(main); + expect(inGraph.meta).not.toBe(host.meta); + expect(inGraph.meta.url).toBe(host.meta.url); + expect({ + graphMain: inGraph.meta.main, + graphDep: inGraph.depMeta.main, + hostMain: host.meta.main, + }).toEqual({ graphMain: true, graphDep: false, hostMain: false }); + }); + + test("top-level await state is per graph", async () => { + using dir = tempDir("module-graph-tla", { + "tla.mjs": ` + export const id = nextId(); + await Promise.resolve(); + export const done = true; + `, + }); + const file = join(String(dir), "tla.mjs"); + + let ids = 0; + const globals = { nextId: () => ++ids }; + using a = new ModuleGraph({ globals }); + using b = new ModuleGraph({ globals }); + const [inA, inB] = await Promise.all([a.import(file), b.import(file)]); + expect([inA.id, inB.id].sort()).toEqual([1, 2]); + expect([inA.done, inB.done]).toEqual([true, true]); + }); + + test("dispose() drops the registry and nothing else", async () => { + using dir = tempDir("module-graph-dispose", { + "counter.mjs": ` + export let count = 0; + export function increment() { return ++count; } + export const again = () => import("./counter.mjs"); + `, + }); + const file = join(String(dir), "counter.mjs"); + + const graph = new ModuleGraph(); + expect(graph[Symbol.dispose]).toBe(graph.dispose); + const counter = await graph.import(file); + graph.dispose(); + graph.dispose(); + + expect(counter.increment()).toBe(1); + expect(graph.import(file)).rejects.toMatchObject({ code: "ERR_INVALID_STATE" }); + expect(counter.again()).rejects.toMatchObject({ code: "ERR_INVALID_STATE" }); + }); + + test("graphs with the same `globals` names share compiled code", async () => { + using dir = tempDir("module-graph-shared-code", { + "mod.mjs": ` + export function run(n) { + let sum = typeof tenant === "string" ? 1 : 0; + for (let i = 0; i < n; i++) sum += i; + return sum; + } + `, + "main.mjs": ` + import { heapStats } from "bun:jsc"; + const { ModuleGraph } = Bun.unsafe; + const file = import.meta.dir + "/mod.mjs"; + + // One ModuleProgramExecutable is the compiled code of one module. Instances that share it add none. + const executables = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.ModuleProgramExecutable ?? 0; + }; + const instances = []; + async function added(count, globals) { + const before = executables(); + for (let i = 0; i < count; i++) { + const instance = await new ModuleGraph({ globals: globals(i) }).import(file); + instances.push(instance); + } + return executables() - before; + } + + instances.push(await import(file)); + console.log(JSON.stringify({ + withoutGlobals: await added(10, () => undefined), + sameNames: await added(10, i => ({ tenant: "tenant " + i, region: i })), + sameNamesInAnotherOrder: await added(10, i => ({ region: i, tenant: "tenant " + i })), + differentNames: await added(10, i => ({ ["tenant" + i]: i })), + results: [...new Set(instances.map(instance => instance.run(3)))].sort(), + })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + // They run the host's. + withoutGlobals: 0, + sameNames: 1, + sameNamesInAnotherOrder: 0, + differentNames: 10, + results: [3, 4], + }); + expect(exitCode).toBe(0); + }); + + describe("import.meta.require() of an ES module returns the graph's instance", () => { + const files = { + "state.mjs": ` + export let count = 0; + export const increment = () => ++count; + `, + "shared.cjs": ` + globalThis.sharedEvaluations = (globalThis.sharedEvaluations ?? 0) + 1; + exports.value = {}; + `, + "entry.mjs": ` + import * as imported from "./state.mjs"; + import shared from "./shared.cjs"; + export const required = import.meta.require("./state.mjs"); + export const requiredIsImported = required.increment === imported.increment; + export const sharedValues = [shared.value, import.meta.require("./shared.cjs").value]; + `, + "main.mjs": ` + import Module, { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const statePath = import.meta.dir + "/state.mjs"; + const entryPath = import.meta.dir + "/entry.mjs"; + + const hostState = await import(statePath); + switch (process.argv[2]) { + case "host enumerated require.cache": + Object.keys(require.cache); + require.cache[statePath]; + break; + case "host required it": + require(statePath); + break; + case "host wrapped Module._extensions": + for (const extension of [".js", ".mjs"]) { + const original = Module._extensions[extension]; + Module._extensions[extension] = (module, filename) => original(module, filename); + } + break; + } + + const host = await import(entryPath); + const graph = await new Bun.unsafe.ModuleGraph().import(entryPath); + graph.required.increment(); + const cached = require.cache[statePath]; + console.log(JSON.stringify({ + requiredIsImported: [host.requiredIsImported, graph.requiredIsImported], + counts: [hostState.count, graph.required.count], + requireCacheHoldsGraphInstance: cached?.exports?.increment === graph.required.increment, + commonJSIsShared: graph.sharedValues.every(value => value === host.sharedValues[0]), + sharedEvaluations: globalThis.sharedEvaluations, + })); + `, + }; + + test.concurrent.each([ + "host never touched it", + "host enumerated require.cache", + "host required it", + "host wrapped Module._extensions", + ])("%s", async mode => { + using dir = tempDir("module-graph-require", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs", mode], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + requiredIsImported: [true, true], + counts: [0, 1], + requireCacheHoldsGraphInstance: false, + commonJSIsShared: true, + sharedEvaluations: 1, + }); + expect(exitCode).toBe(0); + }); + }); + + test("onError receives the graph's uncaught exceptions and unhandled rejections", async () => { + using dir = tempDir("module-graph-on-error", { + "faults.mjs": ` + export const throwInTimer = message => { setTimeout(() => { throw new Error(message); }, 0); }; + export const rejectInAsyncFunction = message => { (async () => { throw new Error(message); })(); }; + export const rejectDirectly = message => { Promise.reject(new Error(message)); }; + // A tail call: this function's frame is gone by the time the promise is rejected. + export const rejectInTailCall = message => Promise.reject(new Error(message)); + export const callInTimer = fn => { setTimeout(() => { fn(); }, 0); }; + `, + "main.mjs": ` + const { ModuleGraph } = Bun.unsafe; + const log = []; + process.on("uncaughtException", error => log.push("process uncaughtException: " + error.message)); + process.on("unhandledRejection", error => log.push("process unhandledRejection: " + error.message)); + process.on("rejectionHandled", () => log.push("process rejectionHandled")); + + const file = import.meta.dir + "/faults.mjs"; + const reports = name => error => log.push(name + ".onError: " + error.message); + const a = await new ModuleGraph({ onError: reports("a") }).import(file); + const throwing = await new ModuleGraph({ + onError(error) { + reports("throwing")(error); + throw new Error("thrown by onError"); + }, + }).import(file); + const silent = await new ModuleGraph().import(file); + const host = await import(file); + + // Each fault is reported once the event loop gets to it; wait for that report. + async function reported(act) { + const before = log.length; + act(); + while (log.length === before) await new Promise(resolve => setImmediate(resolve)); + } + + await reported(() => a.throwInTimer("timer")); + await reported(() => a.rejectInAsyncFunction("async function")); + await reported(() => a.rejectDirectly("direct")); + await reported(() => a.callInTimer(() => { throw new Error("host function the graph called"); })); + let handledLate; + await reported(() => { handledLate = a.rejectInTailCall("tail call"); }); + handledLate.catch(() => {}); + await reported(() => throwing.throwInTimer("throwing graph")); + await reported(() => silent.throwInTimer("graph without onError")); + await reported(() => host.throwInTimer("host timer")); + await reported(() => host.rejectInAsyncFunction("host async function")); + console.log(JSON.stringify(log, null, 2)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + "a.onError: timer", + "a.onError: async function", + "a.onError: direct", + "a.onError: host function the graph called", + "a.onError: tail call", + "throwing.onError: throwing graph", + "process uncaughtException: thrown by onError", + "process uncaughtException: graph without onError", + "process uncaughtException: host timer", + "process unhandledRejection: host async function", + ]); + expect(exitCode).toBe(0); + }); + + test("a graph lives as long as its code does", async () => { + using dir = tempDir("module-graph-lifetime", { + "fault.mjs": ` + export const throwInTimer = () => { setTimeout(() => { throw new Error("from " + tenant); }, 0); }; + `, + }); + const file = join(String(dir), "fault.mjs"); + + const errors: string[] = []; + async function load() { + const graph = new ModuleGraph({ globals: { tenant: "a collected graph" }, onError: e => errors.push(e.message) }); + return (await graph.import(file)).throwInTimer; + } + const throwInTimer = await load(); + Bun.gc(true); + + throwInTimer(); + while (errors.length === 0) await new Promise(resolve => setImmediate(resolve)); + expect(errors).toEqual(["from a collected graph"]); + }); + + test("validates its options", () => { + expect(() => ModuleGraph()).toThrow(TypeError); + expect(() => new ModuleGraph(1)).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + expect(() => new ModuleGraph({ globals: 1 })).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + expect(() => new ModuleGraph({ onError: 1 })).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + expect(() => ModuleGraph.prototype.import.call({}, "x")).toThrow(TypeError); + }); +});