Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
5029d601a6 fix(module): transpile source in Module._compile for ESM/TypeScript support
Module.prototype._compile() was wrapping raw source code in a CJS function
without transpiling it first. If the source contained ESM import/export
statements or TypeScript syntax, the evaluation would fail because these
constructs are invalid inside a function body.

This fixes the issue by running source code through Bun's transpiler before
evaluation. For CJS source, the transpiler adds the standard function wrapper.
For ESM source, it's provided to JSC's module loader and evaluated through
the ESM path, with exports populated back into the CJS module.

This is the code path used by Next.js (and other tools) that register custom
Module._extensions handlers and call _compile with raw .ts file content.

Closes #26874

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-10 16:17:41 +00:00
2 changed files with 279 additions and 31 deletions

View File

@@ -69,6 +69,7 @@
#include "ZigSourceProvider.h"
#include <JavaScriptCore/FunctionPrototype.h>
#include "JSCommonJSModule.h"
#include <JavaScriptCore/JSModuleLoader.h>
#include <JavaScriptCore/JSModuleNamespaceObject.h>
#include <JavaScriptCore/JSSourceCode.h>
#include <JavaScriptCore/LazyPropertyInlines.h>
@@ -683,6 +684,19 @@ JSC_DEFINE_CUSTOM_SETTER(setterUnderscoreCompile,
return true;
}
static BunLoaderType loaderFromFilename(const String& filename)
{
if (filename.endsWith(".ts"_s))
return BunLoaderTypeTS;
if (filename.endsWith(".tsx"_s))
return BunLoaderTypeTSX;
if (filename.endsWith(".jsx"_s))
return BunLoaderTypeJSX;
// _compile always receives JavaScript/TypeScript source code.
// Default to JS for unknown extensions (e.g. .custom, .node, etc.)
return BunLoaderTypeJS;
}
JSC_DEFINE_HOST_FUNCTION(functionJSCommonJSModule_compile, (JSGlobalObject * globalObject, CallFrame* callframe))
{
auto* moduleObject = jsDynamicCast<JSCommonJSModule*>(callframe->thisValue());
@@ -700,46 +714,95 @@ JSC_DEFINE_HOST_FUNCTION(functionJSCommonJSModule_compile, (JSGlobalObject * glo
String filenameString = filenameValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(throwScope, {});
String wrappedString;
auto* zigGlobalObject = jsCast<Zig::GlobalObject*>(globalObject);
if (zigGlobalObject->hasOverriddenModuleWrapper) [[unlikely]] {
wrappedString = makeString(
zigGlobalObject->m_moduleWrapperStart,
sourceString,
zigGlobalObject->m_moduleWrapperEnd);
} else {
wrappedString = makeString(
"(function(exports,require,module,__filename,__dirname){"_s,
sourceString,
"\n})"_s);
// Determine loader from filename extension.
BunLoaderType loader = loaderFromFilename(filenameString);
// Transpile the source through Bun's transpiler. This handles:
// - TypeScript syntax (.ts, .tsx)
// - JSX syntax (.jsx, .tsx)
// - ESM import/export → CJS conversion (when code uses CJS features)
BunString specifier = Bun::toStringRef(filenameString);
BunString referrer = BunStringEmpty;
auto sourceUTF8 = sourceString.utf8();
ZigString sourceZig = { reinterpret_cast<const unsigned char*>(sourceUTF8.data()), sourceUTF8.length() };
ErrorableResolvedSource transpileResult = {};
Bun__transpileVirtualModule(globalObject, &specifier, &referrer, &sourceZig, loader, &transpileResult);
RETURN_IF_EXCEPTION(throwScope, {});
if (!transpileResult.success) {
if (!throwScope.exception()) {
throwException(globalObject, throwScope, createError(globalObject, "Failed to transpile source in Module._compile"_s));
}
return {};
}
moduleObject->sourceCode = makeSource(
WTF::move(wrappedString),
SourceOrigin(URL::fileURLWithFileSystemPath(filenameString)),
JSC::SourceTaintedOrigin::Untainted,
filenameString,
WTF::TextPosition(),
JSC::SourceProviderSourceType::Program);
ResolvedSource& source = transpileResult.result.value;
EncodedJSValue encodedFilename = JSValue::encode(filenameValue);
if (source.isCommonJSModule) {
// The transpiler detected CJS usage and already wrapped the code in
// (function(exports, require, module, __filename, __dirname) { ... }).
String wrappedString = source.source_code.toWTFString(BunString::ZeroCopy);
if (zigGlobalObject->hasOverriddenModuleWrapper) [[unlikely]] {
// Strip the default wrapper and re-wrap with the custom one.
auto trimStart = wrappedString.find('\n');
if (trimStart != WTF::notFound) {
wrappedString = makeString(
zigGlobalObject->m_moduleWrapperStart,
wrappedString.substring(trimStart, wrappedString.length() - trimStart - 4),
zigGlobalObject->m_moduleWrapperEnd);
}
}
moduleObject->sourceCode = makeSource(
WTF::move(wrappedString),
SourceOrigin(URL::fileURLWithFileSystemPath(filenameString)),
JSC::SourceTaintedOrigin::Untainted,
filenameString,
WTF::TextPosition(),
JSC::SourceProviderSourceType::Program);
EncodedJSValue encodedFilename = JSValue::encode(filenameValue);
#if OS(WINDOWS)
JSValue dirnameValue = JSValue::decode(Bun__Path__dirname(globalObject, true, &encodedFilename, 1));
JSValue dirnameValue = JSValue::decode(Bun__Path__dirname(globalObject, true, &encodedFilename, 1));
#else
JSValue dirnameValue = JSValue::decode(Bun__Path__dirname(globalObject, false, &encodedFilename, 1));
JSValue dirnameValue = JSValue::decode(Bun__Path__dirname(globalObject, false, &encodedFilename, 1));
#endif
RETURN_IF_EXCEPTION(throwScope, {});
RETURN_IF_EXCEPTION(throwScope, {});
String dirnameString = dirnameValue.toWTFString(globalObject);
String dirnameString = dirnameValue.toWTFString(globalObject);
WTF::NakedPtr<JSC::Exception> exception;
evaluateCommonJSModuleOnce(
vm,
jsCast<Zig::GlobalObject*>(globalObject),
moduleObject,
jsString(vm, dirnameString),
jsString(vm, filenameString));
RETURN_IF_EXCEPTION(throwScope, {});
evaluateCommonJSModuleOnce(
vm,
zigGlobalObject,
moduleObject,
jsString(vm, dirnameString),
jsString(vm, filenameString));
RETURN_IF_EXCEPTION(throwScope, {});
} else {
// The source contains ESM syntax (import/export). The transpiler kept it as ESM
// (with TS/JSX stripped). Provide the transpiled source to JSC's module loader
// and evaluate it as ESM, then populate the CJS module's exports.
auto provider = Zig::SourceProvider::create(zigGlobalObject, source, JSC::SourceProviderSourceType::Module, false);
zigGlobalObject->moduleLoader()->provideFetch(globalObject, filenameValue, JSC::SourceCode(WTF::move(provider)));
RETURN_IF_EXCEPTION(throwScope, {});
// Use requireESMFromHijackedExtension to synchronously evaluate the ESM module
// and populate the CJS module's exports from the ESM namespace.
JSC::JSFunction* requireESM = zigGlobalObject->requireESMFromHijackedExtension();
JSC::MarkedArgumentBuffer args;
args.append(filenameValue);
JSC::CallData callData = JSC::getCallData(requireESM);
NakedPtr<JSC::Exception> returnedException = nullptr;
JSC::profiledCall(globalObject, JSC::ProfilingReason::API, requireESM, callData, moduleObject, args, returnedException);
if (returnedException) [[unlikely]] {
throwException(globalObject, throwScope, returnedException->value());
return {};
}
}
return JSValue::encode(jsUndefined());
}

View File

@@ -0,0 +1,185 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("Module._compile handles ESM import/export syntax", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const Module = require('module');
const source = \`
import path from "node:path";
import { fileURLToPath } from "node:url";
const config = {
value: 42,
};
export default config;
\`;
const m = new Module('/tmp/test-26874.ts');
m.filename = '/tmp/test-26874.ts';
m.paths = Module._nodeModulePaths('/tmp/');
m._compile(source, '/tmp/test-26874.ts');
console.log(JSON.stringify(m.exports.default));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"value":42}');
expect(exitCode).toBe(0);
});
test("Module._compile handles TypeScript syntax", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const Module = require('module');
const source = \`
interface Config {
value: number;
name: string;
}
const config: Config = {
value: 123,
name: "test",
};
module.exports = config;
\`;
const m = new Module('/tmp/test-26874-ts.ts');
m.filename = '/tmp/test-26874-ts.ts';
m.paths = Module._nodeModulePaths('/tmp/');
m._compile(source, '/tmp/test-26874-ts.ts');
console.log(JSON.stringify(m.exports));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"value":123,"name":"test"}');
expect(exitCode).toBe(0);
});
test("Module._compile still works with plain CJS source", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const Module = require('module');
const source = \`
const x = 10;
const y = 20;
module.exports = { sum: x + y };
\`;
const m = new Module('/tmp/test-26874-cjs.js');
m.filename = '/tmp/test-26874-cjs.js';
m.paths = Module._nodeModulePaths('/tmp/');
m._compile(source, '/tmp/test-26874-cjs.js');
console.log(JSON.stringify(m.exports));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"sum":30}');
expect(exitCode).toBe(0);
});
test("Module._compile handles ESM with .js filename", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const Module = require('module');
const source = \`
export const hello = "world";
export default { greeting: "hi" };
\`;
const m = new Module('/tmp/test-26874-esm.js');
m.filename = '/tmp/test-26874-esm.js';
m.paths = Module._nodeModulePaths('/tmp/');
m._compile(source, '/tmp/test-26874-esm.js');
console.log(JSON.stringify(m.exports.default));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"greeting":"hi"}');
expect(exitCode).toBe(0);
});
test("Module._compile handles ESM TypeScript with imports and exports", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const Module = require('module');
const source = \`
import path from "node:path";
import { fileURLToPath } from "node:url";
interface Config {
turbopack: { root: string };
}
const config: Config = {
turbopack: {
root: "/test",
},
};
export default config;
\`;
const m = new Module('/tmp/test-26874-esm-ts.ts');
m.filename = '/tmp/test-26874-esm-ts.ts';
m.paths = Module._nodeModulePaths('/tmp/');
m._compile(source, '/tmp/test-26874-esm-ts.ts');
console.log(JSON.stringify(m.exports.default));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('{"turbopack":{"root":"/test"}}');
expect(exitCode).toBe(0);
});