Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 26 additions & 23 deletions src/jsc/bindings/ImportMetaObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#include <JavaScriptCore/FunctionPrototype.h>
#include <JavaScriptCore/HeapAnalyzer.h>
#include <JavaScriptCore/CallData.h>
#include <JavaScriptCore/CustomGetterSetter.h>
#include <JavaScriptCore/JSBoundFunction.h>

#include <JavaScriptCore/JSDestructibleObjectHeapCellType.h>
#include <JavaScriptCore/SlotVisitorMacros.h>
Expand Down Expand Up @@ -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<ImportMetaObject>(callFrame->thisValue());
auto specifierValue = callFrame->argument(0);
// 1. Set specifier to ? ToString(specifier).
auto specifier = specifierValue.toWTFString(globalObject);
Expand Down Expand Up @@ -435,24 +437,7 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve,
}

if (!from) {
auto* thisObject = dynamicDowncast<JSC::JSObject>(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);

Expand All @@ -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<ImportMetaObject>(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())
Expand Down Expand Up @@ -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<ImportMetaObject>(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<ImportMetaObject>(JSValue::decode(thisValue));
Expand Down Expand Up @@ -632,7 +634,6 @@ static const HashTableValue ImportMetaObjectPrototypeValues[] = {
{ "main"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } },
{ "path"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } },
{ "require"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_require, jsImportMetaObjectSetter_require } },
{ "resolve"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolve, 0 } },
{ "resolveSync"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolveSync, 0 } },
{ "url"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_url, 0 } },
};
Expand All @@ -647,7 +648,6 @@ static const HashTableValue ImportMetaObjectBakePrototypeValues[] = {
{ "main"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } },
{ "path"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } },
{ "require"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_require, jsImportMetaObjectSetter_require } },
{ "resolve"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolve, 0 } },
{ "resolveSync"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, functionImportMeta__resolveSync, 0 } },
{ "url"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_url, 0 } },
};
Expand Down Expand Up @@ -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<unsigned>(PropertyAttribute::CustomValue));

this->requireProperty.initLater([](const JSC::LazyProperty<JSC::JSObject, JSC::JSCell>::Initializer& init) {
auto scope = DECLARE_THROW_SCOPE(init.vm);
ImportMetaObject* meta = uncheckedDowncast<ImportMetaObject>(init.owner);
Expand Down
58 changes: 58 additions & 0 deletions test/js/bun/resolve/import-meta-resolve.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

function wrapped(name, f) {
if (test) {
test(name, f);

Check failure on line 9 in test/js/bun/resolve/import-meta-resolve.test.mjs

View workflow job for this annotation

GitHub Actions / linux-x64

error: Cannot call test() after the test run has completed

at wrapped (/home/runner/_work/bun/bun/test/js/bun/resolve/import-meta-resolve.test.mjs:9:5) at /home/runner/_work/bun/bun/test/js/bun/resolve/import-meta-resolve.test.mjs:107:1
} else {
f();
console.log("✅", name);
Expand Down Expand Up @@ -103,3 +103,61 @@

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;
}
});
92 changes: 92 additions & 0 deletions test/js/bun/resolve/import-meta.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading