From 212d4e4c3912ff7e9fc45ee2db9584cd0e7f976b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 14:54:48 -0700 Subject: [PATCH 1/3] fix(module): retain origin for detached import.meta.resolve Bind the lazy resolver to its originating ImportMetaObject and preserve writable, configurable resolver properties. Cover detached calls, explicit parents, independent module origins and resolve hooks. --- CHANGELOG.md | 2 + docs/runtime/nodejs-compat.mdx | 2 + src/jsc/bindings/ImportMetaObject.cpp | 45 ++++++++-------- .../bun/resolve/import-meta-resolve.test.mjs | 51 +++++++++++++++++++ test/js/bun/resolve/import-meta.test.js | 48 +++++++++++++++++ 5 files changed, 125 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf69a85e091b..f4c814c51ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Keep detached `import.meta.resolve` functions bound to their originating module on every platform, including Windows, with Node-compatible writable resolver properties. + - Align WebKit and VM source-position assertions with Node's constructor locations and select these regressions for native stack-formatting changes in both fork CI lanes. - Preserve the native-context count in the fast `v8.getHeapStatistics()` adapter and avoid appending a second strict code-generation flag to inherited worker arguments. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index f242366c62f1..dd9c7b835d24 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -171,6 +171,8 @@ Unreadable selected package metadata also throws `ERR_INVALID_PACKAGE_CONFIG`, r `import.meta.resolve()` validates the package scope of existing `.js`, `.ts`, and extensionless files. Missing-file URLs and `require.resolve()` defer that scope validation. Loading an already-resolved CommonJS file through the ESM loader preserves the original resolution mode. +Detached `import.meta.resolve` functions retain their originating module's resolution base, including on Windows. Calling a resolver with a different receiver leaves that base unchanged. You can still pass an explicit parent as the second argument. + `registerHooks()` supports synchronous resolve/load chaining, source transformation, and deregistration. `nextResolve` honors parent URLs and custom conditions, and hooks preserve query/fragment module identities. Dynamic-import attributes, including attributes returned by resolve hooks, reach the matching load hook. Runtime `Bun.plugin()` hooks retry missing native resolutions with the filesystem refresh policy used by `registerHooks()`. Delegated resolution can load package-import targets created after their directory was cached, including files created inside an `onResolve` callback that returns `undefined`. diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index cd3fb8c7e23e..02e09eac6144 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #include @@ -401,7 +403,7 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - auto thisValue = callFrame->thisValue(); + auto* meta = uncheckedDowncast(callFrame->thisValue()); auto specifierValue = callFrame->argument(0); // 1. Set specifier to ? ToString(specifier). auto specifier = specifierValue.toWTFString(globalObject); @@ -435,24 +437,7 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, } if (!from) { - auto* thisObject = dynamicDowncast(thisValue); - if (!thisObject) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSC::throwTypeError(globalObject, scope, "import.meta.resolve must be bound to an import.meta object"_s); - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::JSValue {})); - } - - auto clientData = WebCore::clientData(vm); - JSValue pathProperty = thisObject->getIfPropertyExists(globalObject, clientData->builtinNames().pathPublicName()); - RETURN_IF_EXCEPTION(scope, {}); - - if (pathProperty && pathProperty.isString()) [[likely]] { - from = pathProperty; - } else { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSC::throwTypeError(globalObject, scope, "import.meta.resolve must be bound to an import.meta object"_s); - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::JSValue {})); - } + from = meta->pathProperty.getInitializedOnMainThread(meta); } ASSERT(from); @@ -462,8 +447,7 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, if (Bun__hasModuleHooks(globalObject->bunVM())) { auto name = Bun::toString(specifier); - auto* meta = explicitParent ? nullptr : dynamicDowncast(thisValue); - auto parent = Bun::toString(meta ? meta->url : fromWTFString); + auto parent = Bun::toString(explicitParent ? fromWTFString : meta->url); auto result = JSValue::decode(Bun__runModuleResolveHooks(globalObject, &name, &parent, true, false, JSValue::encode(jsUndefined()), true)); RETURN_IF_EXCEPTION(scope, {}); if (result.isString()) @@ -517,6 +501,20 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, return JSValue::encode(result); } +JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_resolve, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName propertyName)) +{ + auto* meta = uncheckedDowncast(JSValue::decode(thisValue)); + auto* globalObject = meta->globalObject(); + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* target = JSFunction::create(vm, globalObject, 1, "resolve"_s, functionImportMeta__resolve, ImplementationVisibility::Public); + auto source = makeSource("resolve"_s, SourceOrigin(), SourceTaintedOrigin::Untainted); + auto* resolve = JSBoundFunction::create(vm, globalObject, target, meta, ArgList(), 1, Bun::commonStrings(vm).resolveString(), source); + RETURN_IF_EXCEPTION(scope, {}); + meta->putDirect(vm, propertyName, resolve, 0); + return JSValue::encode(resolve); +} + JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_url, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { ImportMetaObject* thisObject = dynamicDowncast(JSValue::decode(thisValue)); @@ -632,7 +630,6 @@ static const HashTableValue ImportMetaObjectPrototypeValues[] = { { "main"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } }, { "path"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } }, { "require"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_require, jsImportMetaObjectSetter_require } }, - { "resolve"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolve, 0 } }, { "resolveSync"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolveSync, 0 } }, { "url"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_url, 0 } }, }; @@ -647,7 +644,6 @@ static const HashTableValue ImportMetaObjectBakePrototypeValues[] = { { "main"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } }, { "path"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } }, { "require"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_require, jsImportMetaObjectSetter_require } }, - { "resolve"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolve, 0 } }, { "resolveSync"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolveSync, 0 } }, { "url"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_url, 0 } }, }; @@ -715,6 +711,9 @@ void ImportMetaObject::finishCreation(VM& vm) Base::finishCreation(vm); ASSERT(inherits(info())); + // CustomValue preserves a writable data property while binding only on first access. + putDirectCustomAccessor(vm, vm.propertyNames->resolve, CustomGetterSetter::create(vm, jsImportMetaObjectGetter_resolve, nullptr), PropertyAttribute::CustomValue); + this->requireProperty.initLater([](const JSC::LazyProperty::Initializer& init) { auto scope = DECLARE_THROW_SCOPE(init.vm); ImportMetaObject* meta = uncheckedDowncast(init.owner); diff --git a/test/js/bun/resolve/import-meta-resolve.test.mjs b/test/js/bun/resolve/import-meta-resolve.test.mjs index 30042db23626..7fef724d7c77 100644 --- a/test/js/bun/resolve/import-meta-resolve.test.mjs +++ b/test/js/bun/resolve/import-meta-resolve.test.mjs @@ -103,3 +103,54 @@ fileUrlRelTo(() => import.meta.resolve("./something.node"), "./something.node"); throws(() => import.meta.resolve("adsjfdasdf"), "nonexistant package"); throws(() => import.meta.resolve(""), "empty specifier"); + +wrapped("detached import.meta.resolve retains its module", () => { + const { resolve } = import.meta; + const receiver = { + get path() { + throw new Error("resolve must not inspect the receiver"); + }, + }; + for (const specifier of ["./missing.mjs", "./with space/🅱️un.mjs", "node:path", "path"]) { + const expected = specifier.startsWith(".") ? new URL(specifier, import.meta.url).href : "node:path"; + assert.strictEqual(resolve(specifier), expected); + for (const value of [undefined, null, receiver, import.meta]) { + assert.strictEqual(resolve.call(value, specifier), expected); + assert.strictEqual(resolve.apply(value, [specifier]), expected); + assert.strictEqual(resolve.bind(value)(specifier), expected); + } + } + assert.strictEqual(import.meta.resolve, resolve); + assert.strictEqual(resolve.name, "resolve"); + assert.strictEqual(resolve.length, 1); +}); + +wrapped("import.meta.resolve retains its origin after visible path changes", () => { + const { resolve } = import.meta; + const descriptor = Object.getOwnPropertyDescriptor(import.meta, "path"); + Object.defineProperty(import.meta, "path", { value: "ignored", configurable: true }); + try { + assert.strictEqual(resolve("./missing.mjs"), new URL("./missing.mjs", import.meta.url).href); + } finally { + if (descriptor) Object.defineProperty(import.meta, "path", descriptor); + else delete import.meta.path; + } +}); + +wrapped("import.meta.resolve is a writable configurable data property", () => { + const { resolve } = import.meta; + assert.deepStrictEqual(Object.getOwnPropertyDescriptor(import.meta, "resolve"), { + value: resolve, + writable: true, + enumerable: true, + configurable: true, + }); + try { + import.meta.resolve = null; + assert.strictEqual(import.meta.resolve, null); + assert.strictEqual(delete import.meta.resolve, true); + assert.strictEqual(import.meta.resolve, undefined); + } finally { + import.meta.resolve = resolve; + } +}); diff --git a/test/js/bun/resolve/import-meta.test.js b/test/js/bun/resolve/import-meta.test.js index 62f269aa3f0a..86061f946788 100644 --- a/test/js/bun/resolve/import-meta.test.js +++ b/test/js/bun/resolve/import-meta.test.js @@ -87,6 +87,54 @@ it("import.meta.resolveSync", () => { expect(import.meta.resolveSync("./" + import.meta.file, import.meta.path)).toBe(path); }); +it.concurrent("detached import.meta.resolve keeps each module's origin and explicit parents", async () => { + using dir = tempDir("import-meta-detached", { + "entry.mjs": ` + import assert from "node:assert/strict"; + import { registerHooks } from "node:module"; + import { resolve as first } from "./first/module.mjs"; + import { resolve as second } from "./second/module.mjs"; + import { value } from "./overwritten.mjs"; + import { absent } from "./deleted.mjs"; + assert.equal(value, 42); + assert.equal(absent, undefined); + assert.notEqual(first, second); + for (const [resolve, directory] of [[first, "first"], [second, "second"]]) { + assert.equal(resolve("./missing.mjs"), new URL(directory + "/missing.mjs", import.meta.url).href); + assert.equal(resolve("./missing.mjs", import.meta.url), new URL("./missing.mjs", import.meta.url).href); + } + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + return specifier === "detached:origin" + ? { url: context.parentURL, shortCircuit: true } + : nextResolve(specifier, context); + }, + }); + try { + assert.equal(first("detached:origin"), new URL("first/module.mjs", import.meta.url).href); + assert.equal(second("detached:origin"), new URL("second/module.mjs", import.meta.url).href); + assert.equal(first("detached:origin", import.meta.url), import.meta.url); + } finally { + hooks.deregister(); + } + console.log("ok"); + `, + "first/module.mjs": "export const { resolve } = import.meta;", + "second/module.mjs": "export const { resolve } = import.meta;", + "overwritten.mjs": "import.meta.resolve = 42; export const value = import.meta.resolve;", + "deleted.mjs": "delete import.meta.resolve; export const absent = import.meta.resolve;", + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "ok", stderr: "", exitCode: 0 }); +}); + it("Module.createRequire", () => { const require = Module.createRequire(import.meta.path); expect(require.resolve(import.meta.path)).toBe(path); From ee02463aec594fc9541cfb1cd1bef1ceb21dfefe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 15:25:36 -0700 Subject: [PATCH 2/3] fix(build): cast lazy resolver property attributes --- src/jsc/bindings/ImportMetaObject.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index 02e09eac6144..bd76daeeca72 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -712,7 +712,7 @@ void ImportMetaObject::finishCreation(VM& vm) ASSERT(inherits(info())); // CustomValue preserves a writable data property while binding only on first access. - putDirectCustomAccessor(vm, vm.propertyNames->resolve, CustomGetterSetter::create(vm, jsImportMetaObjectGetter_resolve, nullptr), PropertyAttribute::CustomValue); + putDirectCustomAccessor(vm, vm.propertyNames->resolve, CustomGetterSetter::create(vm, jsImportMetaObjectGetter_resolve, nullptr), static_cast(PropertyAttribute::CustomValue)); this->requireProperty.initLater([](const JSC::LazyProperty::Initializer& init) { auto scope = DECLARE_THROW_SCOPE(init.vm); From 47042734c746e4a6c65644cab05f456e169e1117 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 16:00:09 -0700 Subject: [PATCH 3/3] fix(module): preserve detached resolver function name --- src/jsc/bindings/ImportMetaObject.cpp | 3 +++ test/js/bun/resolve/import-meta-resolve.test.mjs | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index bd76daeeca72..9902ff95a3c2 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -511,6 +511,9 @@ JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_resolve, (JSGlobalObject * lex auto source = makeSource("resolve"_s, SourceOrigin(), SourceTaintedOrigin::Untainted); auto* resolve = JSBoundFunction::create(vm, globalObject, target, meta, ArgList(), 1, Bun::commonStrings(vm).resolveString(), source); RETURN_IF_EXCEPTION(scope, {}); + // Node's resolver name does not include JSBoundFunction's lazy "bound " prefix. + resolve->ensureRareData(vm)->setHasReifiedName(); + resolve->putDirect(vm, vm.propertyNames->name, Bun::commonStrings(vm).resolveString(), PropertyAttribute::DontEnum | PropertyAttribute::ReadOnly); meta->putDirect(vm, propertyName, resolve, 0); return JSValue::encode(resolve); } diff --git a/test/js/bun/resolve/import-meta-resolve.test.mjs b/test/js/bun/resolve/import-meta-resolve.test.mjs index 7fef724d7c77..c7cab495befa 100644 --- a/test/js/bun/resolve/import-meta-resolve.test.mjs +++ b/test/js/bun/resolve/import-meta-resolve.test.mjs @@ -122,6 +122,13 @@ wrapped("detached import.meta.resolve retains its module", () => { } assert.strictEqual(import.meta.resolve, resolve); assert.strictEqual(resolve.name, "resolve"); + assert.deepStrictEqual(Object.getOwnPropertyDescriptor(resolve, "name"), { + value: "resolve", + writable: false, + enumerable: false, + configurable: true, + }); + assert.strictEqual(resolve.bind(null).name, "bound resolve"); assert.strictEqual(resolve.length, 1); });