diff --git a/src/api/schema.zig b/src/api/schema.zig index ccfa99d386..f4f70201b9 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -321,8 +321,9 @@ pub const ByteWriter = Writer(*std.io.FixedBufferStream([]u8)); pub const FileWriter = Writer(std.fs.File); pub const api = struct { + // these are in sync with BunLoaderType in headers-handwritten.h pub const Loader = enum(u8) { - _none = 255, + _none = 254, jsx = 1, js = 2, ts = 3, diff --git a/src/bun.js/ModuleLoader.zig b/src/bun.js/ModuleLoader.zig index 8398f08614..09921adc0a 100644 --- a/src/bun.js/ModuleLoader.zig +++ b/src/bun.js/ModuleLoader.zig @@ -1600,9 +1600,10 @@ pub export fn Bun__transpileFile( ret: *jsc.ErrorableResolvedSource, allow_promise: bool, is_commonjs_require: bool, - force_loader_type: bun.options.Loader.Optional, + _force_loader_type: bun.schema.api.Loader, ) ?*anyopaque { jsc.markBinding(@src()); + const force_loader_type: bun.options.Loader.Optional = .fromAPI(_force_loader_type); var log = logger.Log.init(jsc_vm.transpiler.allocator); defer log.deinit(); diff --git a/src/bun.js/bindings/JSCommonJSExtensions.cpp b/src/bun.js/bindings/JSCommonJSExtensions.cpp index 4999c8bb8a..1f0fcaf36a 100644 --- a/src/bun.js/bindings/JSCommonJSExtensions.cpp +++ b/src/bun.js/bindings/JSCommonJSExtensions.cpp @@ -138,32 +138,38 @@ void JSCommonJSExtensions::finishCreation(JSC::VM& vm) extern "C" void NodeModuleModule__onRequireExtensionModify( Zig::GlobalObject* globalObject, const BunString* key, - uint32_t kind, + BunLoaderType loader, JSC::JSValue value); +extern "C" void NodeModuleModule__onRequireExtensionModifyNonFunction( + Zig::GlobalObject* globalObject, + const BunString* key); + void onAssign(Zig::GlobalObject* globalObject, JSC::PropertyName propertyName, JSC::JSValue value) { if (propertyName.isSymbol()) return; auto* name = propertyName.publicName(); if (!name->startsWith('.')) return; BunString ext = Bun::toString(name); - uint32_t kind = 0; JSC::CallData callData = JSC::getCallData(value); + if (callData.type == JSC::CallData::Type::None) { + return NodeModuleModule__onRequireExtensionModifyNonFunction(globalObject, &ext); + } + + BunLoaderType loader = BunLoaderTypeNone; if (callData.type == JSC::CallData::Type::Native) { auto* untaggedPtr = callData.native.function.untaggedPtr(); if (untaggedPtr == &jsLoaderJS) { - kind = 1; + loader = BunLoaderTypeJS; } else if (untaggedPtr == &jsLoaderJSON) { - kind = 2; + loader = BunLoaderTypeJSON; } else if (untaggedPtr == &jsLoaderNode) { - kind = 3; + loader = BunLoaderTypeNAPI; } else if (untaggedPtr == &jsLoaderTS) { - kind = 4; + loader = BunLoaderTypeTS; } - } else if (callData.type == JSC::CallData::Type::None) { - kind = -1; } - NodeModuleModule__onRequireExtensionModify(globalObject, &ext, kind, value); + NodeModuleModule__onRequireExtensionModify(globalObject, &ext, loader, value); } bool JSCommonJSExtensions::defineOwnProperty(JSC::JSObject* object, JSC::JSGlobalObject* globalObject, JSC::PropertyName propertyName, const JSC::PropertyDescriptor& descriptor, bool shouldThrow) diff --git a/src/bun.js/bindings/NodeModuleModule.zig b/src/bun.js/bindings/NodeModuleModule.zig index 22eef36724..56545462b8 100644 --- a/src/bun.js/bindings/NodeModuleModule.zig +++ b/src/bun.js/bindings/NodeModuleModule.zig @@ -86,49 +86,46 @@ extern fn JSCommonJSExtensions__swapRemove(global: *jsc.JSGlobalObject, index: u // Memory management is complicated because JSValues are stored in gc-visitable // WriteBarriers in C++ but the hash map for extensions is in Zig for flexibility. -fn onRequireExtensionModify(global: *jsc.JSGlobalObject, str: []const u8, kind: i32, value: jsc.JSValue) !void { - bun.assert(kind >= -1 and kind <= 4); +fn onRequireExtensionModify(global: *jsc.JSGlobalObject, str: []const u8, loader: bun.schema.api.Loader, value: jsc.JSValue) bun.OOM!void { const vm = global.bunVM(); const list = &vm.commonjs_custom_extensions; defer vm.transpiler.resolver.opts.extra_cjs_extensions = list.keys(); const is_built_in = bun.options.defaultLoaders.get(str) != null; - if (kind >= 0) { - const loader: CustomLoader = switch (kind) { - 1 => .{ .loader = .js }, - 2 => .{ .loader = .json }, - 3 => .{ .loader = .napi }, - 4 => .{ .loader = .ts }, - else => .{ .custom = undefined }, // to be filled in later - }; - const gop = try list.getOrPut(bun.default_allocator, str); - if (!gop.found_existing) { - const dupe = try bun.default_allocator.dupe(u8, str); - gop.key_ptr.* = dupe; - if (is_built_in) { - vm.has_mutated_built_in_extensions += 1; + + const gop = try list.getOrPut(bun.default_allocator, str); + if (!gop.found_existing) { + gop.key_ptr.* = try bun.default_allocator.dupe(u8, str); + if (is_built_in) { + vm.has_mutated_built_in_extensions += 1; + } + + gop.value_ptr.* = if (loader != ._none) + .{ .loader = .fromAPI(loader) } + else + .{ .custom = .create(value, global) }; + } else { + if (loader != ._none) { + switch (gop.value_ptr.*) { + .loader => {}, + .custom => |*strong| strong.deinit(), } - gop.value_ptr.* = switch (loader) { - .loader => loader, - .custom => .{ - .custom = .create(value, global), - }, - }; + gop.value_ptr.* = .{ .loader = .fromAPI(loader) }; } else { - switch (loader) { - .loader => { - switch (gop.value_ptr.*) { - .loader => {}, - .custom => |*strong| strong.deinit(), - } - gop.value_ptr.* = loader; - }, - .custom => switch (gop.value_ptr.*) { - .loader => gop.value_ptr.* = .{ .custom = .create(value, global) }, - .custom => |*strong| strong.set(global, value), - }, + switch (gop.value_ptr.*) { + .loader => gop.value_ptr.* = .{ .custom = .create(value, global) }, + .custom => |*strong| strong.set(global, value), } } - } else if (list.fetchSwapRemove(str)) |prev| { + } +} + +fn onRequireExtensionModifyNonFunction(global: *JSGlobalObject, str: []const u8) bun.OOM!void { + const vm = global.bunVM(); + const list = &vm.commonjs_custom_extensions; + defer vm.transpiler.resolver.opts.extra_cjs_extensions = list.keys(); + const is_built_in = bun.options.defaultLoaders.get(str) != null; + + if (list.fetchSwapRemove(str)) |prev| { bun.default_allocator.free(prev.key); if (is_built_in) { vm.has_mutated_built_in_extensions -= 1; @@ -160,20 +157,34 @@ pub fn findLongestRegisteredExtension(vm: *jsc.VirtualMachine, filename: []const fn onRequireExtensionModifyBinding( global: *jsc.JSGlobalObject, str: *const bun.String, - kind: i32, + loader: bun.schema.api.Loader, value: jsc.JSValue, ) callconv(.c) void { var sfa_state = std.heap.stackFallback(8192, bun.default_allocator); const alloc = sfa_state.get(); const str_slice = str.toUTF8(alloc); defer str_slice.deinit(); - onRequireExtensionModify(global, str_slice.slice(), kind, value) catch |err| switch (err) { + onRequireExtensionModify(global, str_slice.slice(), loader, value) catch |err| switch (err) { + error.OutOfMemory => bun.outOfMemory(), + }; +} + +fn onRequireExtensionModifyNonFunctionBinding( + global: *jsc.JSGlobalObject, + str: *const bun.String, +) callconv(.c) void { + var sfa_state = std.heap.stackFallback(8192, bun.default_allocator); + const alloc = sfa_state.get(); + const str_slice = str.toUTF8(alloc); + defer str_slice.deinit(); + onRequireExtensionModifyNonFunction(global, str_slice.slice()) catch |err| switch (err) { error.OutOfMemory => bun.outOfMemory(), }; } comptime { @export(&onRequireExtensionModifyBinding, .{ .name = "NodeModuleModule__onRequireExtensionModify" }); + @export(&onRequireExtensionModifyNonFunctionBinding, .{ .name = "NodeModuleModule__onRequireExtensionModifyNonFunction" }); } const bun = @import("bun"); diff --git a/src/bun.js/bindings/headers-handwritten.h b/src/bun.js/bindings/headers-handwritten.h index 26d03cbb10..956085e43e 100644 --- a/src/bun.js/bindings/headers-handwritten.h +++ b/src/bun.js/bindings/headers-handwritten.h @@ -236,10 +236,9 @@ const JSErrorCode JSErrorCodeOutOfMemoryError = 8; const JSErrorCode JSErrorCodeStackOverflow = 253; const JSErrorCode JSErrorCodeUserErrorCode = 254; -// Must be kept in sync. +// Must be kept in sync with bun.schema.api.Loader in schema.zig typedef uint8_t BunLoaderType; const BunLoaderType BunLoaderTypeNone = 254; -// Must match api/schema.zig Loader enum values const BunLoaderType BunLoaderTypeJSX = 1; const BunLoaderType BunLoaderTypeJS = 2; const BunLoaderType BunLoaderTypeTS = 3; diff --git a/src/options.zig b/src/options.zig index 553ace7668..6a32f61641 100644 --- a/src/options.zig +++ b/src/options.zig @@ -641,6 +641,14 @@ pub const Loader = enum(u8) { pub fn unwrap(opt: Optional) ?Loader { return if (opt == .none) null else @enumFromInt(@intFromEnum(opt)); } + + pub fn fromAPI(loader: bun.schema.api.Loader) Optional { + if (loader == ._none) { + return .none; + } + const l: Loader = .fromAPI(loader); + return @enumFromInt(@intFromEnum(l)); + } }; pub fn isCSS(this: Loader) bool { diff --git a/test/regression/issue/require-extensions-override.test.ts b/test/regression/issue/require-extensions-override.test.ts new file mode 100644 index 0000000000..6b7080ce17 --- /dev/null +++ b/test/regression/issue/require-extensions-override.test.ts @@ -0,0 +1,456 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("Module._extensions override should not allow TypeScript syntax in .js files", async () => { + using dir = tempDir("module-extensions-ts-in-js", { + "index.js": ` + const Module = require("module"); + const orig = Module._extensions[".js"]; + + // Override the .js extension handler with a wrapper + Module._extensions[".js"] = (m, f) => { + return orig(m, f); + }; + + // This should error because it has TypeScript syntax in a .js file + try { + require("./typescript-syntax.js"); + console.log("ERROR: Should have failed to parse TypeScript syntax in .js file"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: Correctly rejected TypeScript syntax in .js file"); + process.exit(0); + } + `, + "typescript-syntax.js": ` + const value: string = "hello"; + export { value }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: Correctly rejected TypeScript syntax in .js file"); +}); + +test("Module._extensions override should not allow JSX syntax in .js files", async () => { + using dir = tempDir("module-extensions-jsx-in-js", { + "index.js": ` + const Module = require("module"); + const orig = Module._extensions[".js"]; + + // Override the .js extension handler + Module._extensions[".js"] = (m, f) => { + console.log("Loading:", f); + return orig(m, f); + }; + + // This should error because it has JSX syntax in a .js file + try { + require("./jsx-syntax.js"); + console.log("ERROR: Should have failed to parse JSX syntax in .js file"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: Correctly rejected JSX syntax in .js file"); + process.exit(0); + } + `, + "jsx-syntax.js": ` + const element =
Hello
; + export { element }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: Correctly rejected JSX syntax in .js file"); +}); + +test("Module._extensions override should preserve loader type for each extension", async () => { + using dir = tempDir("module-extensions-loader-types", { + "index.js": ` + const Module = require("module"); + const results = []; + + // Save original loaders + const origJS = Module._extensions[".js"]; + const origTS = Module._extensions[".ts"]; + const origJSON = Module._extensions[".json"]; + + // Override each with a wrapper + Module._extensions[".js"] = (m, f) => { + results.push(".js loader called"); + return origJS(m, f); + }; + + Module._extensions[".ts"] = (m, f) => { + results.push(".ts loader called"); + return origTS(m, f); + }; + + Module._extensions[".json"] = (m, f) => { + results.push(".json loader called"); + return origJSON(m, f); + }; + + // Test .js file with JavaScript (should work) + try { + const js = require("./plain.js"); + results.push("plain.js loaded: " + js.type); + } catch (err) { + results.push("ERROR loading plain.js: " + err.message); + } + + // Test .ts file with TypeScript (should work) + try { + const ts = require("./typed.ts"); + results.push("typed.ts loaded: " + ts.type); + } catch (err) { + results.push("ERROR loading typed.ts: " + err.message); + } + + // Test .json file (should work) + try { + const json = require("./data.json"); + results.push("data.json loaded: " + json.type); + } catch (err) { + results.push("ERROR loading data.json: " + err.message); + } + + // Test .js file with TypeScript syntax (should fail) + try { + require("./typescript-in-js.js"); + results.push("ERROR: typescript-in-js.js should have failed"); + } catch (err) { + results.push("typescript-in-js.js correctly failed"); + } + + console.log(results.join("\\n")); + `, + "plain.js": ` + module.exports = { type: "javascript" }; + `, + "typed.ts": ` + interface Data { + type: string; + } + const data: Data = { type: "typescript" }; + module.exports = data; + `, + "data.json": ` + { "type": "json" } + `, + "typescript-in-js.js": ` + const value: string = "should fail"; + module.exports = value; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain(".js loader called"); + expect(stdout).toContain(".ts loader called"); + expect(stdout).toContain(".json loader called"); + expect(stdout).toContain("plain.js loaded: javascript"); + expect(stdout).toContain("typed.ts loaded: typescript"); + expect(stdout).toContain("data.json loaded: json"); + expect(stdout).toContain("typescript-in-js.js correctly failed"); +}); + +test("Module._extensions override with custom function should not affect loader type", async () => { + using dir = tempDir("module-extensions-custom-function", { + "index.js": ` + const Module = require("module"); + const fs = require("fs"); + + // Override .js with a completely custom function (not wrapping the original) + Module._extensions[".js"] = function(module, filename) { + // Custom implementation that mimics the original + const content = fs.readFileSync(filename, 'utf8'); + module._compile(content, filename); + }; + + // This should still fail with TypeScript syntax in .js + try { + require("./typescript.js"); + console.log("ERROR: Should have failed with TypeScript in .js"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: Correctly failed with custom loader"); + process.exit(0); + } + `, + "typescript.js": ` + const x: number = 42; + module.exports = x; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: Correctly failed with custom loader"); +}); + +test("Module._extensions override should handle cross-assignment correctly", async () => { + using dir = tempDir("module-extensions-cross-assign", { + "index.js": ` + const Module = require("module"); + + // Save original loaders + const origJS = Module._extensions[".js"]; + const origTS = Module._extensions[".ts"]; + + // Cross-assign: .js uses .ts loader, .ts uses .js loader + Module._extensions[".js"] = origTS; + Module._extensions[".ts"] = origJS; + + // Now .js files should accept TypeScript syntax + try { + const jsWithTS = require("./typescript-syntax.js"); + console.log("SUCCESS: .js with TS loader accepts TypeScript:", jsWithTS.value); + } catch (err) { + console.log("ERROR: .js with TS loader failed:", err.message); + process.exit(1); + } + + // And .ts files should reject TypeScript syntax + try { + require("./typescript-syntax.ts"); + console.log("ERROR: .ts with JS loader should reject TypeScript"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: .ts with JS loader correctly rejects TypeScript"); + } + `, + "typescript-syntax.js": ` + const value: string = "typescript"; + module.exports = { value }; + `, + "typescript-syntax.ts": ` + const value: string = "typescript"; + module.exports = { value }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: .js with TS loader accepts TypeScript"); + expect(stdout).toContain("SUCCESS: .ts with JS loader correctly rejects TypeScript"); +}); + +test("Module._extensions override chain should preserve correct loader", async () => { + using dir = tempDir("module-extensions-chain", { + "index.js": ` + const Module = require("module"); + const origJS = Module._extensions[".js"]; + + // Create a chain of wrappers + Module._extensions[".js"] = (m, f) => { + console.log("Wrapper 1"); + return origJS(m, f); + }; + + const wrapper1 = Module._extensions[".js"]; + Module._extensions[".js"] = (m, f) => { + console.log("Wrapper 2"); + return wrapper1(m, f); + }; + + // Should still reject TypeScript in .js + try { + require("./typescript.js"); + console.log("ERROR: Should reject TypeScript in .js"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: Correctly rejected TypeScript"); + process.exit(0); + } + `, + "typescript.js": ` + type Foo = string; + const x: Foo = "test"; + module.exports = x; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Wrapper 2"); + expect(stdout).toContain("Wrapper 1"); + expect(stdout).toContain("SUCCESS: Correctly rejected TypeScript"); +}); + +test("Module._extensions override should handle .mjs and .cjs correctly", async () => { + using dir = tempDir("module-extensions-mjs-cjs", { + "index.js": ` + const Module = require("module"); + + // Override .mjs and .cjs + const origMJS = Module._extensions[".mjs"]; + const origCJS = Module._extensions[".cjs"] || Module._extensions[".js"]; + + Module._extensions[".mjs"] = (m, f) => { + console.log("Loading .mjs:", f); + return origMJS(m, f); + }; + + Module._extensions[".cjs"] = (m, f) => { + console.log("Loading .cjs:", f); + return origCJS(m, f); + }; + + // .mjs with TypeScript should fail + try { + require("./typescript.mjs"); + console.log("ERROR: .mjs should reject TypeScript"); + } catch (err) { + console.log("SUCCESS: .mjs rejected TypeScript"); + } + + // .cjs with TypeScript should fail + try { + require("./typescript.cjs"); + console.log("ERROR: .cjs should reject TypeScript"); + } catch (err) { + console.log("SUCCESS: .cjs rejected TypeScript"); + } + + // Valid .mjs should work + try { + const mjs = require("./valid.mjs"); + console.log("SUCCESS: .mjs loaded:", mjs.type); + } catch (err) { + console.log("ERROR: Valid .mjs failed:", err.message); + } + `, + "typescript.mjs": ` + const value: string = "typescript"; + export { value }; + `, + "typescript.cjs": ` + const value: string = "typescript"; + module.exports = { value }; + `, + "valid.mjs": ` + export const type = "mjs"; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: .mjs rejected TypeScript"); + expect(stdout).toContain("SUCCESS: .cjs rejected TypeScript"); + expect(stdout).toContain("SUCCESS: .mjs loaded"); +}); + +test("Module._extensions override should not affect JSON parsing", async () => { + using dir = tempDir("module-extensions-json", { + "index.js": ` + const Module = require("module"); + const origJSON = Module._extensions[".json"]; + + Module._extensions[".json"] = (m, f) => { + console.log("Loading JSON:", f); + return origJSON(m, f); + }; + + // Should still parse as JSON, not JavaScript + try { + const data = require("./invalid-json.json"); + console.log("ERROR: Should have failed to parse invalid JSON"); + process.exit(1); + } catch (err) { + console.log("SUCCESS: Correctly failed on invalid JSON"); + } + + // Valid JSON should work + const valid = require("./valid.json"); + console.log("Valid JSON loaded:", valid.test); + `, + "invalid-json.json": ` + // This is a comment, which is invalid in JSON + { "test": "value" } + `, + "valid.json": ` + { "test": "passed" } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SUCCESS: Correctly failed on invalid JSON"); + expect(stdout).toContain("Valid JSON loaded: passed"); + expect(stdout).toContain("Loading JSON:"); +});