diff --git a/CHANGELOG.md b/CHANGELOG.md index f50d50657c91..ee040a7a77ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,3 +214,5 @@ - Avoid requesting executable access when renaming files on Windows, preventing unnecessary synchronous antivirus scans during compile-cache publication. - Check live `vm.Script` counts and full-workload RSS growth after warmup in the leak regression, preserving the 200 MiB release and 700 MiB ASAN RSS limits. Adapts [oven-sh/bun#42474](https://github.com/oven-sh/bun/pull/42474); thanks @robobun! + +- Keep detached `import.meta.resolve` functions bound to their originating module on every platform, including Windows, with Node-compatible writable resolver properties. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 997daa8e6c1f..5a37290a5d05 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -179,6 +179,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. + Modules embedded by `bun build --compile` share one module record across query and fragment spellings of their path or file URL. Files loaded from the filesystem keep separate ESM identities for distinct suffixes. Async imports of CommonJS files preserve package-scope interop. A `.cjs` file inside a `"type": "module"` package keeps `module.exports` as its default export and exposes its `__esModule` property, while the file still executes as CommonJS. diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index cd3fb8c7e23e..73698b0a5534 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,24 @@ 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, {}); + // 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); + // Freeze/seal reify this CustomValue before changing its attributes. + 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 +634,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 +648,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 +715,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), static_cast(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..c7cab495befa 100644 --- a/test/js/bun/resolve/import-meta-resolve.test.mjs +++ b/test/js/bun/resolve/import-meta-resolve.test.mjs @@ -103,3 +103,61 @@ 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.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); +}); + +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..961c5fafd5aa 100644 --- a/test/js/bun/resolve/import-meta.test.js +++ b/test/js/bun/resolve/import-meta.test.js @@ -87,6 +87,98 @@ 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.concurrent("import.meta.resolve preserves integrity levels before its first access", async () => { + const modes = ["freeze", "seal", "preventExtensions", "foreign"]; + using dir = tempDir("import-meta-resolve-integrity", { + "entry.mjs": `${modes.map(mode => `await import("./${mode}.mjs");`).join("\n")}\nconsole.log("ok");`, + ...Object.fromEntries( + modes.map(mode => [ + `${mode}.mjs`, + ` + import assert from "node:assert/strict"; + const mode = ${JSON.stringify(mode)}; + if (mode !== "foreign") Object[mode](import.meta); + const resolve = mode === "foreign" + ? Reflect.get(import.meta, "resolve", new Proxy({}, { get() { throw new Error("receiver inspected"); } })) + : import.meta.resolve; + assert.equal(resolve("./missing.mjs"), new URL("./missing.mjs", import.meta.url).href); + assert.deepEqual(Object.getOwnPropertyDescriptor(import.meta, "resolve"), { + value: resolve, + writable: mode !== "freeze", + enumerable: true, + configurable: mode !== "freeze" && mode !== "seal", + }); + assert.equal(Object.isExtensible(import.meta), mode === "foreign"); + if (mode === "freeze") { + assert.equal(Object.isFrozen(import.meta), true); + assert.throws(() => { import.meta.resolve = null; }, TypeError); + } + if (mode === "freeze" || mode === "seal") { + assert.equal(Reflect.deleteProperty(import.meta, "resolve"), false); + } + `, + ]), + ), + }); + 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, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); +}); + it("Module.createRequire", () => { const require = Module.createRequire(import.meta.path); expect(require.resolve(import.meta.path)).toBe(path);