mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 18:38:55 +00:00
Fixes Next.js 16 + React Compiler build failure when using Bun runtime.
## Issue
When `Module._resolveFilename` was overridden (e.g., by Next.js's
require-hook), Bun was not passing the `options` parameter (which
contains `paths`) to the override function. This caused resolution
failures when the override tried to use custom resolution paths.
Additionally, when `Module._resolveFilename` was called directly with
`options.paths`, Bun was ignoring the paths parameter and using default
resolution.
## Root Causes
1. In `ImportMetaObject.cpp`, when calling an overridden
`_resolveFilename` function, the options object with paths was not being
passed as the 4th argument.
2. In `NodeModuleModule.cpp`, `jsFunctionResolveFileName` was calling
`Bun__resolveSync` without extracting and using the `options.paths`
parameter.
## Solution
1. In `ImportMetaObject.cpp`: When `userPathList` is provided, construct
an options object with `{paths: userPathList}` and pass it as the 4th
argument to the overridden `_resolveFilename` function.
2. In `NodeModuleModule.cpp`: Extract `options.paths` from the 4th
argument and call `Bun__resolveSyncWithPaths` when paths are provided,
instead of always using `Bun__resolveSync`.
## Reproduction
Before this fix, running:
```bash
bun --bun next build --turbopack
```
on a Next.js 16 app with React Compiler enabled would fail with:
```
Cannot find module './node_modules/babel-plugin-react-compiler'
```
## Testing
- Added comprehensive tests for `Module._resolveFilename` with
`options.paths`
- Verified Next.js 16 + React Compiler + Turbopack builds successfully
with Bun
- All 5 new tests pass with the fix, 3 fail without it
- All existing tests continue to pass
## Files Changed
- `src/bun.js/bindings/ImportMetaObject.cpp` - Pass options to override
- `src/bun.js/modules/NodeModuleModule.cpp` - Handle options.paths in
_resolveFilename
- `test/js/node/module/module-resolve-filename-paths.test.js` - New test
suite
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
202 lines
6.8 KiB
JavaScript
202 lines
6.8 KiB
JavaScript
// Use bun:test in Bun, or node:test in Node.js
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
|
import Module from "module";
|
|
import { tmpdir } from "os";
|
|
import { dirname, join, resolve } from "path";
|
|
|
|
// Detect runtime and import appropriate test framework
|
|
const isBun = typeof Bun !== "undefined";
|
|
let test, expect;
|
|
|
|
if (isBun) {
|
|
({ test, expect } = await import("bun:test"));
|
|
} else {
|
|
// Node.js
|
|
const { createRequire } = await import("module");
|
|
const nodeTest = await import("node:test");
|
|
const assert = await import("node:assert/strict");
|
|
|
|
// In Node.js ES modules, require is not available, so create it
|
|
globalThis.require = createRequire(import.meta.url);
|
|
|
|
test = nodeTest.test;
|
|
// Create Bun-compatible expect from Node assert
|
|
expect = value => ({
|
|
toBe: expected => assert.strictEqual(value, expected),
|
|
toEqual: expected => assert.deepStrictEqual(value, expected),
|
|
toBeDefined: () => assert.notStrictEqual(value, undefined),
|
|
toThrow: () => {
|
|
// This is used with expect(() => ...)
|
|
assert.throws(value);
|
|
},
|
|
});
|
|
}
|
|
|
|
// Helper to create temp directory - works in both Bun and Node
|
|
function createTempDir(prefix, files) {
|
|
// Use realpathSync to resolve symlinks (handles /tmp -> /private/tmp on macOS)
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix + "-")));
|
|
|
|
for (const [filePath, content] of Object.entries(files)) {
|
|
const fullPath = join(dir, filePath);
|
|
const dirPath = dirname(fullPath);
|
|
|
|
// Create parent directories if needed
|
|
mkdirSync(dirPath, { recursive: true });
|
|
writeFileSync(fullPath, content, "utf-8");
|
|
}
|
|
|
|
return {
|
|
path: dir,
|
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
test("Module._resolveFilename respects options.paths for package resolution", () => {
|
|
const { path: dir, cleanup } = createTempDir("module-resolve-paths", {
|
|
"node_modules/test-package/package.json": JSON.stringify({ name: "test-package", main: "index.js" }),
|
|
"node_modules/test-package/index.js": "module.exports = 'test-package';",
|
|
});
|
|
|
|
try {
|
|
// Create a fake parent module in a different directory
|
|
const fakeParent = new Module("/some/other/directory/file.js");
|
|
fakeParent.filename = "/some/other/directory/file.js";
|
|
fakeParent.paths = Module._nodeModulePaths("/some/other/directory");
|
|
|
|
// Without paths option, this should fail
|
|
expect(() => {
|
|
Module._resolveFilename("test-package", fakeParent);
|
|
}).toThrow();
|
|
|
|
// With paths option, this should succeed
|
|
const resolved = Module._resolveFilename("test-package", fakeParent, false, {
|
|
paths: [dir],
|
|
});
|
|
|
|
expect(resolved).toBe(resolve(dir, "node_modules/test-package/index.js"));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("Module._resolveFilename respects options.paths for relative paths", () => {
|
|
const { path: dir, cleanup } = createTempDir("module-resolve-relative", {
|
|
"target.js": "module.exports = 'target';",
|
|
});
|
|
|
|
try {
|
|
const fakeParent = new Module("/some/other/directory/file.js");
|
|
fakeParent.filename = "/some/other/directory/file.js";
|
|
fakeParent.paths = Module._nodeModulePaths("/some/other/directory");
|
|
|
|
// With paths option pointing to dir, should resolve relative to that dir
|
|
const resolved = Module._resolveFilename("./target.js", fakeParent, false, {
|
|
paths: [dir],
|
|
});
|
|
|
|
expect(resolved).toBe(resolve(dir, "target.js"));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("Module._resolveFilename with overridden function receives options.paths", () => {
|
|
const originalResolveFilename = Module._resolveFilename;
|
|
let capturedOptions;
|
|
|
|
try {
|
|
// Override _resolveFilename to capture the options
|
|
Module._resolveFilename = function (request, parent, isMain, options) {
|
|
capturedOptions = options;
|
|
return originalResolveFilename.call(this, request, parent, isMain, options);
|
|
};
|
|
|
|
const { path: dir, cleanup } = createTempDir("module-resolve-override", {
|
|
"node_modules/test-pkg/package.json": JSON.stringify({ name: "test-pkg", main: "index.js" }),
|
|
"node_modules/test-pkg/index.js": "module.exports = 'test';",
|
|
});
|
|
|
|
try {
|
|
const fakeParent = new Module("/some/other/directory/file.js");
|
|
fakeParent.filename = "/some/other/directory/file.js";
|
|
fakeParent.paths = Module._nodeModulePaths("/some/other/directory");
|
|
|
|
const testPaths = [dir];
|
|
|
|
// Call _resolveFilename with paths option
|
|
Module._resolveFilename("test-pkg", fakeParent, false, {
|
|
paths: testPaths,
|
|
});
|
|
|
|
// Verify the override function received the options with paths
|
|
expect(capturedOptions).toBeDefined();
|
|
expect(capturedOptions.paths).toEqual(testPaths);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
} finally {
|
|
Module._resolveFilename = originalResolveFilename;
|
|
}
|
|
});
|
|
|
|
test("require.resolve respects options.paths for package resolution", () => {
|
|
const { path: dir, cleanup } = createTempDir("require-resolve-paths", {
|
|
"node_modules/resolve-test-pkg/package.json": JSON.stringify({
|
|
name: "resolve-test-pkg",
|
|
main: "index.js",
|
|
}),
|
|
"node_modules/resolve-test-pkg/index.js": "module.exports = 'resolve-test';",
|
|
});
|
|
|
|
try {
|
|
// require.resolve should work with paths option
|
|
const resolved = require.resolve("resolve-test-pkg", {
|
|
paths: [dir],
|
|
});
|
|
|
|
expect(resolved).toBe(resolve(dir, "node_modules/resolve-test-pkg/index.js"));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("require.resolve with relative path and options.paths (Next.js use case)", () => {
|
|
// This reproduces the Next.js babel-plugin-react-compiler resolution issue
|
|
const { path: dir, cleanup } = createTempDir("nextjs-style-resolve", {
|
|
"node_modules/babel-plugin-react-compiler/package.json": JSON.stringify({
|
|
name: "babel-plugin-react-compiler",
|
|
main: "dist/index.js",
|
|
}),
|
|
"node_modules/babel-plugin-react-compiler/dist/index.js": "module.exports = {};",
|
|
});
|
|
|
|
try {
|
|
// Simulate what Next.js does: resolve a relative path with explicit paths
|
|
const resolved = require.resolve("./node_modules/babel-plugin-react-compiler", {
|
|
paths: [dir],
|
|
});
|
|
|
|
expect(resolved).toBe(resolve(dir, "node_modules/babel-plugin-react-compiler/dist/index.js"));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("Module._resolveFilename throws ERR_INVALID_ARG_TYPE if options.paths is not an array", () => {
|
|
// Test with string (which is iterable but not an array)
|
|
expect(() => {
|
|
Module._resolveFilename("path", __filename, false, { paths: "/some/path" });
|
|
}).toThrow();
|
|
|
|
// Test with Set (which is iterable but not an array)
|
|
expect(() => {
|
|
Module._resolveFilename("path", __filename, false, { paths: new Set(["/some/path"]) });
|
|
}).toThrow();
|
|
|
|
// Test with object (not iterable)
|
|
expect(() => {
|
|
Module._resolveFilename("path", __filename, false, { paths: { 0: "/some/path" } });
|
|
}).toThrow();
|
|
});
|