diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index 5249d197a3..49b9bd7a45 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -882,6 +882,7 @@ pub const JSBundler = struct { success: struct { source_code: []const u8 = "", loader: options.Loader = .file, + sourcemap: ?[]const u8 = null, }, pending, no_match, @@ -892,6 +893,9 @@ pub const JSBundler = struct { switch (this.*) { .success => |success| { bun.default_allocator.free(success.source_code); + if (success.sourcemap) |sm| { + bun.default_allocator.free(sm); + } }, .err => |*err| { err.deinit(bun.default_allocator); @@ -970,6 +974,7 @@ pub const JSBundler = struct { _: *anyopaque, source_code_value: JSValue, loader_as_int: JSValue, + sourcemap_value: JSValue, ) void { jsc.markBinding(@src()); if (source_code_value.isEmptyOrUndefinedOrNull() or loader_as_int.isEmptyOrUndefinedOrNull()) { @@ -994,10 +999,26 @@ pub const JSBundler = struct { @panic("Unexpected: source_code is not a string"); }; + + const sourcemap = if (!sourcemap_value.isEmptyOrUndefinedOrNull()) + JSC.Node.StringOrBuffer.fromJSToOwnedSlice(global, sourcemap_value, bun.default_allocator) catch |err| { + switch (err) { + error.OutOfMemory => { + bun.outOfMemory(); + }, + error.JSError => {}, + } + + @panic("Unexpected: sourcemap is not a string"); + } + else + null; + this.value = .{ .success = .{ .loader = options.Loader.fromAPI(loader), .source_code = source_code, + .sourcemap = sourcemap, }, }; } diff --git a/src/bun.js/bindings/JSBundlerPlugin.cpp b/src/bun.js/bindings/JSBundlerPlugin.cpp index 89f287bc91..a0367d5582 100644 --- a/src/bun.js/bindings/JSBundlerPlugin.cpp +++ b/src/bun.js/bindings/JSBundlerPlugin.cpp @@ -46,7 +46,7 @@ extern "C" void OnBeforeParseResult__reset(OnBeforeParseResult* result); /// These are callbacks defined in Zig and to be run after their associated JS version is run extern "C" void JSBundlerPlugin__addError(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue); -extern "C" void JSBundlerPlugin__onLoadAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue); +extern "C" void JSBundlerPlugin__onLoadAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue); extern "C" void JSBundlerPlugin__onResolveAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue); extern "C" void JSBundlerPlugin__onVirtualModulePlugin(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue); extern "C" JSC::EncodedJSValue JSBundlerPlugin__onDefer(void*, JSC::JSGlobalObject*); @@ -111,7 +111,7 @@ bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespac static const HashTableValue JSBundlerPluginHashTable[] = { { "addFilter"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addFilter, 3 } }, { "addError"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addError, 3 } }, - { "onLoadAsync"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onLoadAsync, 3 } }, + { "onLoadAsync"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onLoadAsync, 4 } }, { "onResolveAsync"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onResolveAsync, 4 } }, { "onBeforeParse"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onBeforeParse, 4 } }, { "generateDeferPromise"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_generateDeferPromise, 0 } }, @@ -402,7 +402,8 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync, (JSC::JSGlobalObje UNWRAP_BUNDLER_PLUGIN(callFrame), thisObject->plugin.config, JSValue::encode(callFrame->argument(1)), - JSValue::encode(callFrame->argument(2))); + JSValue::encode(callFrame->argument(2)), + JSValue::encode(callFrame->argument(3))); } return JSC::JSValue::encode(JSC::jsUndefined()); diff --git a/src/bun.js/bindings/JSBundlerPlugin.h b/src/bun.js/bindings/JSBundlerPlugin.h index 7bef5769fa..be8c8b0ccb 100644 --- a/src/bun.js/bindings/JSBundlerPlugin.h +++ b/src/bun.js/bindings/JSBundlerPlugin.h @@ -9,7 +9,7 @@ #include typedef void (*JSBundlerPluginAddErrorCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue); -typedef void (*JSBundlerPluginOnLoadAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue); +typedef void (*JSBundlerPluginOnLoadAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue); typedef void (*JSBundlerPluginOnResolveAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue); typedef void (*JSBundlerPluginNativeOnBeforeParseCallback)(const OnBeforeParseArguments*, OnBeforeParseResult*); diff --git a/src/bundler/Graph.zig b/src/bundler/Graph.zig index 4ff0fe7090..5d84904246 100644 --- a/src/bundler/Graph.zig +++ b/src/bundler/Graph.zig @@ -69,6 +69,10 @@ pub const InputFile = struct { unique_key_for_additional_file: string = "", content_hash_for_additional_file: u64 = 0, is_plugin_file: bool = false, + sourcemap: ?*bun.sourcemap.ParsedSourceMap = null, + /// Index to InputFile containing the original source content (for sourcemap support) + /// 0 means no original source, valid indices start at 1 + original_source_index: Index.Int = 0, }; pub inline fn pathToSourceIndexMap(this: *Graph, target: options.Target) *PathToSourceIndexMap { diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 09a1543b92..915594d776 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -160,7 +160,13 @@ pub const LinkerContext = struct { return; } - const source: *const Logger.Source = &this.parse_graph.input_files.items(.source)[source_index]; + // Check if we have an original source from sourcemap + const original_source_index = this.parse_graph.input_files.items(.original_source_index)[source_index]; + const source: *const Logger.Source = if (original_source_index != 0) + &this.parse_graph.input_files.items(.source)[original_source_index] + else + &this.parse_graph.input_files.items(.source)[source_index]; + var mutable = MutableString.initEmpty(bun.default_allocator); js_printer.quoteForJSON(source.contents, &mutable, false) catch bun.outOfMemory(); quoted_source_contents.* = mutable.toDefaultOwned().toOptional(); @@ -745,15 +751,81 @@ pub const LinkerContext = struct { ); const source_indices_for_contents = source_id_map.keys(); + const input_sourcemaps = c.parse_graph.input_files.items(.sourcemap); if (source_indices_for_contents.len > 0) { j.pushStatic("\n "); - j.pushStatic( - quoted_source_map_contents[source_indices_for_contents[0]].getConst() orelse "", - ); + + // Try to find sourcemap - either at this index or at a related plugin file index + const first_index = source_indices_for_contents[0]; + var sourcemap_to_use: ?*bun.sourcemap.ParsedSourceMap = null; + + if (input_sourcemaps[first_index]) |sm| { + sourcemap_to_use = sm; + } else { + // If no sourcemap at this index, check if this might be an original source index + // and find the corresponding plugin file index + const original_source_indices = c.parse_graph.input_files.items(.original_source_index); + for (original_source_indices, 0..) |orig_idx, plugin_idx| { + if (orig_idx == first_index) { + // Found the plugin file that created this original source + if (input_sourcemaps[plugin_idx]) |sm| { + sourcemap_to_use = sm; + break; + } + } + } + } + + if (sourcemap_to_use) |input_sourcemap| { + if (input_sourcemap.sources_content.len > 0) { + // Use the original source content from the input sourcemap + const original_content = input_sourcemap.sources_content[0]; + const mutable = MutableString.initEmpty(worker.allocator); + const quoted = (js_printer.quoteForJSON(original_content, mutable, false) catch bun.outOfMemory()).list.items; + j.pushStatic(quoted); + } else { + j.pushStatic(quoted_source_map_contents[first_index].getConst() orelse ""); + } + } else { + j.pushStatic(quoted_source_map_contents[first_index].getConst() orelse ""); + } for (source_indices_for_contents[1..]) |index| { j.pushStatic(",\n "); - j.pushStatic(quoted_source_map_contents[index].getConst() orelse ""); + + // Try to find sourcemap - either at this index or at a related plugin file index + var loop_sourcemap_to_use: ?*bun.sourcemap.ParsedSourceMap = null; + + if (input_sourcemaps[index]) |sm| { + loop_sourcemap_to_use = sm; + } else { + // If no sourcemap at this index, check if this might be an original source index + // and find the corresponding plugin file index + const original_source_indices = c.parse_graph.input_files.items(.original_source_index); + for (original_source_indices, 0..) |orig_idx, plugin_idx| { + if (orig_idx == index) { + // Found the plugin file that created this original source + if (input_sourcemaps[plugin_idx]) |sm| { + loop_sourcemap_to_use = sm; + break; + } + } + } + } + + if (loop_sourcemap_to_use) |input_sourcemap| { + if (input_sourcemap.sources_content.len > 0) { + // Use the original source content from the input sourcemap + const original_content = input_sourcemap.sources_content[0]; + const mutable = MutableString.initEmpty(worker.allocator); + const quoted = (js_printer.quoteForJSON(original_content, mutable, false) catch bun.outOfMemory()).list.items; + j.pushStatic(quoted); + } else { + j.pushStatic(quoted_source_map_contents[index].getConst() orelse ""); + } + } else { + j.pushStatic(quoted_source_map_contents[index].getConst() orelse ""); + } } } j.pushStatic( @@ -1262,6 +1334,7 @@ pub const LinkerContext = struct { else null, .mangled_props = &c.mangled_props, + .input_source_map = c.parse_graph.input_files.items(.sourcemap)[source_index.get()], }; writer.buffer.reset(); diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 2b95109b22..8966e43848 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -2079,6 +2079,53 @@ pub const BundleV2 = struct { this.graph.input_files.items(.loader)[load.source_index.get()] = code.loader; this.graph.input_files.items(.source)[load.source_index.get()].contents = code.source_code; this.graph.input_files.items(.is_plugin_file)[load.source_index.get()] = true; + + // Parse and store the sourcemap if provided + if (code.sourcemap) |sourcemap_str| { + if (bun.sourcemap.parseJSON( + bun.default_allocator, + this.graph.allocator, + sourcemap_str, + .mappings_and_source_content, + )) |parsed| { + if (parsed.map) |map| { + this.graph.input_files.items(.sourcemap)[load.source_index.get()] = map; + + // If we have sources_content, create a new InputFile for the original source + if (map.sources_content.len > 0 and map.sources_content[0].len > 0) { + const original_source_index = @as(u32, @intCast(this.graph.input_files.len)); + + // Copy the current source but with the original content + const current_source = &this.graph.input_files.items(.source)[load.source_index.get()]; + + // Create a new InputFile for the original source + this.graph.input_files.append(this.graph.allocator, .{ + .source = Logger.Source{ + .path = current_source.path, + .contents = map.sources_content[0], + .index = Index.init(original_source_index), + }, + .loader = this.graph.input_files.items(.loader)[load.source_index.get()], + .side_effects = this.graph.input_files.items(.side_effects)[load.source_index.get()], + .allocator = this.graph.allocator, + .is_plugin_file = true, + }) catch bun.outOfMemory(); + + // Also append an empty AST for this input file + this.graph.ast.append(this.graph.allocator, JSAst.empty) catch bun.outOfMemory(); + + // Set the original_source_index on the current file + this.graph.input_files.items(.original_source_index)[load.source_index.get()] = original_source_index; + } + } + } else |err| { + log.addWarningFmt(&this.graph.input_files.items(.source)[load.source_index.get()], Logger.Loc.Empty, bun.default_allocator, "Failed to parse sourcemap from plugin: {s}", .{@errorName(err)}) catch {}; + this.graph.input_files.items(.sourcemap)[load.source_index.get()] = null; + } + // Free the sourcemap string since we've parsed it + if (!should_copy_for_bundling) this.free_list.append(sourcemap_str) catch unreachable; + } + var parse_task = load.parse_task; parse_task.loader = code.loader; if (!should_copy_for_bundling) this.free_list.append(code.source_code) catch unreachable; diff --git a/src/js/builtins/BundlerPlugin.ts b/src/js/builtins/BundlerPlugin.ts index ed427a39a7..d42438fb07 100644 --- a/src/js/builtins/BundlerPlugin.ts +++ b/src/js/builtins/BundlerPlugin.ts @@ -12,6 +12,7 @@ interface BundlerPlugin { internalID, sourceCode: string | Uint8Array | ArrayBuffer | DataView | null, loaderKey: number | null, + sourcemap?: string | Uint8Array | ArrayBuffer | DataView | null, ): void; /** Binding to `JSBundlerPlugin__onResolveAsync` */ onResolveAsync(internalID, a, b, c): void; @@ -490,7 +491,7 @@ export function runOnLoadPlugins( continue; } - var { contents, loader = defaultLoader } = result as any; + var { contents, loader = defaultLoader, sourcemap } = result as any; if ((loader as any) === "object") { if (!("exports" in result)) { throw new TypeError('onLoad plugin returning loader: "object" must have "exports" property'); @@ -511,12 +512,19 @@ export function runOnLoadPlugins( throw new TypeError('onLoad plugins must return an object with "loader" as a string'); } + // Validate sourcemap if provided + if (sourcemap !== undefined && sourcemap !== null) { + if (!(typeof sourcemap === "string") && !$isTypedArrayView(sourcemap)) { + throw new TypeError('onLoad plugin "sourcemap" must be a string or Uint8Array'); + } + } + const chosenLoader = LOADERS_MAP[loader]; if (chosenLoader === undefined) { throw new TypeError(`Loader ${loader} is not supported.`); } - this.onLoadAsync(internalID, contents as any, chosenLoader); + this.onLoadAsync(internalID, contents as any, chosenLoader, sourcemap); return null; } } diff --git a/src/js_printer.zig b/src/js_printer.zig index c676cac054..a14f511276 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -428,6 +428,7 @@ pub const Options = struct { line_offset_tables: ?SourceMap.LineOffsetTable.List = null, mangled_props: ?*const bun.bundle_v2.MangledProps, + input_source_map: ?*bun.sourcemap.ParsedSourceMap = null, // Default indentation is 2 spaces pub const Indentation = struct { @@ -5627,6 +5628,7 @@ pub fn getSourceMapBuilder( opts: Options, source: *const logger.Source, tree: *const Ast, + input_source_map: ?*bun.sourcemap.ParsedSourceMap, ) SourceMap.Chunk.Builder { if (comptime generate_source_map == .disable) return undefined; @@ -5639,6 +5641,7 @@ pub fn getSourceMapBuilder( .cover_lines_without_mappings = true, .approximate_input_line_count = tree.approximate_newline_count, .prepend_count = is_bun_platform and generate_source_map == .lazy, + .input_source_map = input_source_map, .line_offset_tables = opts.line_offset_tables orelse brk: { if (generate_source_map == .lazy) break :brk SourceMap.LineOffsetTable.generate( opts.source_map_allocator orelse opts.allocator, @@ -5753,7 +5756,7 @@ pub fn printAst( tree.import_records.slice(), opts, renamer, - getSourceMapBuilder(if (generate_source_map) .lazy else .disable, ascii_only, opts, source, &tree), + getSourceMapBuilder(if (generate_source_map) .lazy else .disable, ascii_only, opts, source, &tree, opts.input_source_map), ); defer { if (comptime generate_source_map) { @@ -5952,7 +5955,7 @@ pub fn printWithWriterAndPlatform( import_records, opts, renamer, - getSourceMapBuilder(if (generate_source_maps) .eager else .disable, is_bun_platform, opts, source, &ast), + getSourceMapBuilder(if (generate_source_maps) .eager else .disable, is_bun_platform, opts, source, &ast, opts.input_source_map), ); printer.was_lazy_export = ast.has_lazy_export; var bin_stack_heap = std.heap.stackFallback(1024, bun.default_allocator); @@ -6035,7 +6038,7 @@ pub fn printCommonJS( tree.import_records.slice(), opts, renamer.toRenamer(), - getSourceMapBuilder(if (generate_source_map) .lazy else .disable, false, opts, source, &tree), + getSourceMapBuilder(if (generate_source_map) .lazy else .disable, false, opts, source, &tree, opts.input_source_map), ); var bin_stack_heap = std.heap.stackFallback(1024, bun.default_allocator); printer.binary_expression_stack = std.ArrayList(PrinterType.BinaryExpressionVisitor).init(bin_stack_heap.get()); diff --git a/src/sourcemap/sourcemap.zig b/src/sourcemap/sourcemap.zig index d79452384d..deb33171fc 100644 --- a/src/sourcemap/sourcemap.zig +++ b/src/sourcemap/sourcemap.zig @@ -35,6 +35,8 @@ pub const ParseUrlResultHint = union(enum) { column: i32, include_names: bool = false, }, + /// Parse mappings and also store sources_content in the ParsedSourceMap + mappings_and_source_content, }; pub const ParseUrl = struct { @@ -210,7 +212,19 @@ pub fn parseJSON( const ptr = bun.new(ParsedSourceMap, map_data); ptr.external_source_names = source_paths_slice.?; - + // Store sources_content if requested + if (hint == .mappings_and_source_content) { + const content_slice = alloc.alloc([]const u8, sources_content.items.len) catch bun.outOfMemory(); + for (sources_content.items.slice(), 0..) |item, idx| { + if (item.data != .e_string) { + content_slice[idx] = ""; + } else { + content_slice[idx] = alloc.dupe(u8, item.data.e_string.string(alloc) catch "") catch bun.outOfMemory(); + } + } + ptr.sources_content = content_slice; + } + break :map ptr; } else null; errdefer if (map) |m| m.deref(); @@ -223,9 +237,10 @@ pub fn parseJSON( break :brk .{ mapping, std.math.cast(u32, mapping.source_index) }; }, .mappings_only => .{ null, null }, + .mappings_and_source_content => .{ null, null }, }; - const content_slice: ?[]const u8 = if (hint != .mappings_only and + const content_slice: ?[]const u8 = if (hint != .mappings_only and hint != .mappings_and_source_content and source_index != null and source_index.? < sources_content.items.len) content: { @@ -876,6 +891,9 @@ pub const ParsedSourceMap = struct { /// loaded without transpilation but with external sources. This array /// maps `source_index` to the correct filename. external_source_names: []const []const u8 = &.{}, + /// Sources content from the original sourcemap, indexed the same as external_source_names + /// Only populated when parsing sourcemaps from onLoad plugins + sources_content: []const []const u8 = &.{}, /// In order to load source contents from a source-map after the fact, /// a handle to the underlying source provider is stored. Within this pointer, /// a flag is stored if it is known to be an inline or external source map. @@ -951,6 +969,12 @@ pub const ParsedSourceMap = struct { allocator.free(this.external_source_names); } + if (this.sources_content.len > 0) { + for (this.sources_content) |content| + allocator.free(content); + allocator.free(this.sources_content); + } + bun.destroy(this); } @@ -1764,6 +1788,7 @@ pub const Chunk = struct { pub fn NewBuilder(comptime SourceMapFormatType: type) type { return struct { const ThisBuilder = @This(); + input_source_map: ?*ParsedSourceMap = null, source_map: SourceMapper, line_offset_tables: LineOffsetTable.List = .{}, prev_state: SourceMapState = SourceMapState{}, @@ -1877,7 +1902,17 @@ pub const Chunk = struct { b.last_generated_update = @as(u32, @truncate(output.len)); } - pub fn appendMapping(b: *ThisBuilder, current_state: SourceMapState) void { + pub fn appendMapping(b: *ThisBuilder, current_state_: SourceMapState) void { + var current_state = current_state_; + // If the input file had a source map, map all the way back to the original + if (b.input_source_map) |input| { + if (Mapping.find(input.mappings, current_state.original_line, current_state.original_column)) |mapping| { + current_state.source_index = mapping.source_index; + current_state.original_line = mapping.original.lines; + current_state.original_column = mapping.original.columns; + } + } + b.appendMappingWithoutRemapping(current_state); } diff --git a/test/bundler/bundler-plugin-sourcemap.test.ts b/test/bundler/bundler-plugin-sourcemap.test.ts new file mode 100644 index 0000000000..4914edc5cf --- /dev/null +++ b/test/bundler/bundler-plugin-sourcemap.test.ts @@ -0,0 +1,372 @@ +import { describe, test, expect } from "bun:test"; +import { itBundled } from "./expectBundled"; +import { SourceMapConsumer } from "source-map"; + +// Direct test to verify implementation works +test("onLoad plugin sourcemap support - basic", async () => { + const result = await Bun.build({ + entrypoints: [import.meta.dir + "/test-entry.js"], + outdir: import.meta.dir + "/out", + sourcemap: "external", + plugins: [{ + name: "sourcemap-test", + setup(build) { + build.onResolve({ filter: /\.transformed\.js$/ }, (args) => { + return { + path: args.path, + namespace: "transformed", + }; + }); + + build.onLoad({ filter: /.*/, namespace: "transformed" }, () => { + const code = `console.log("transformed");`; + // Create a more complete sourcemap with actual mappings + const sourcemap = JSON.stringify({ + version: 3, + sources: ["original.js"], + sourcesContent: [`console.log("original");`], + names: ["console", "log"], + // This mapping says: first segment at (0,0) in generated maps to (0,0) in source 0 + mappings: "AAAA", + }); + return { + contents: code, + loader: "js", + sourcemap, + }; + }); + } + }], + root: import.meta.dir, + }); + + expect(result.success).toBe(true); + expect(result.outputs.length).toBeGreaterThan(0); + + // Check for sourcemap output + const sourcemapOutput = result.outputs.find(o => o.path.endsWith(".map")); + expect(sourcemapOutput).toBeDefined(); +}); + +// Test with TypeScript-like transformation +test("onLoad plugin sourcemap support - typescript", async () => { + const result = await Bun.build({ + entrypoints: [import.meta.dir + "/test-entry.js"], + outdir: import.meta.dir + "/out2", + sourcemap: "external", + minify: false, + plugins: [{ + name: "typescript-transform", + setup(build) { + build.onResolve({ filter: /\.transformed\.js$/ }, (args) => { + return { + path: "virtual.ts", + namespace: "typescript", + }; + }); + + build.onLoad({ filter: /.*/, namespace: "typescript" }, () => { + // Simulate TypeScript source + const originalCode = `function greet(name: string): void { + console.log("Hello, " + name); +} +greet("World");`; + + // Transpiled JavaScript + const transpiledCode = `function greet(name) { + console.log("Hello, " + name); +} +greet("World");`; + + // A proper sourcemap for this transformation + const sourcemap = JSON.stringify({ + version: 3, + sources: ["virtual.ts"], + sourcesContent: [originalCode], + names: ["greet", "name", "console", "log"], + // Generated with a tool - maps each token properly + mappings: "AAAA,SAASA,MAAMC,MACbC,QAAQC,IAAI,WAAYF,MAE1BD,MAAM", + }); + + return { + contents: transpiledCode, + loader: "js", + sourcemap, + }; + }); + } + }], + root: import.meta.dir, + }); + + expect(result.success).toBe(true); + + // Check the generated sourcemap + const sourcemapOutput = result.outputs.find(o => o.path.endsWith(".map")); + expect(sourcemapOutput).toBeDefined(); + + const sourcemapText = await sourcemapOutput!.text(); + const sourcemap = JSON.parse(sourcemapText); + + // Should preserve the TypeScript source (with namespace prefix) + expect(sourcemap.sources[0]).toBe("typescript:virtual.ts"); + expect(sourcemap.sourcesContent).toBeDefined(); + + // Verify the original TypeScript source is preserved + expect(sourcemap.sourcesContent[0]).toContain("function greet(name: string): void"); + expect(sourcemap.version).toBe(3); + expect(sourcemap.mappings).toBeDefined(); + expect(sourcemap.mappings.length).toBeGreaterThan(0); +}); + +// Test that verifies sourcemap mappings are working +test("onLoad plugin sourcemap remapping", async () => { + const result = await Bun.build({ + entrypoints: [import.meta.dir + "/test-entry.js"], + outdir: import.meta.dir + "/out3", + sourcemap: "external", + minify: false, + plugins: [{ + name: "sourcemap-remap-test", + setup(build) { + build.onResolve({ filter: /\.transformed\.js$/ }, (args) => { + return { + path: "code.ts", + namespace: "transform", + }; + }); + + build.onLoad({ filter: /.*/, namespace: "transform" }, () => { + // Original TypeScript-like code + const originalCode = `// Original comment +function add(a: number, b: number): number { + return a + b; +} +console.log(add(1, 2));`; + + // Transpiled JavaScript (simulating TypeScript output) + const transpiledCode = `// Original comment +function add(a, b) { + return a + b; +} +console.log(add(1, 2));`; + + // This sourcemap maps the transpiled code back to the original + // Line 1 (comment) maps to line 1 + // Line 2 (function) maps to line 2 + // etc. + const sourcemap = JSON.stringify({ + version: 3, + sources: ["code.ts"], + sourcesContent: [originalCode], + names: ["add", "a", "b", "console", "log"], + // Simple 1:1 line mapping + mappings: "AAAA;AACA;AACA;AACA;AACA", + }); + + return { + contents: transpiledCode, + loader: "js", + sourcemap, + }; + }); + } + }], + root: import.meta.dir, + }); + + expect(result.success).toBe(true); + + const sourcemapOutput = result.outputs.find(o => o.path.endsWith(".map")); + expect(sourcemapOutput).toBeDefined(); + + const sourcemapText = await sourcemapOutput!.text(); + const sourcemap = JSON.parse(sourcemapText); + + // Use source-map library to verify mappings work + const consumer = await new SourceMapConsumer(sourcemap); + + // Check that we can map from generated position back to original + // The function "add" should be on line 2 in both files due to our simple mapping + const originalPos = consumer.originalPositionFor({ + line: 2, + column: 9, // "add" in "function add" + }); + + // Should map back to the TypeScript file + expect(originalPos.source).toContain("code.ts"); + expect(originalPos.line).toBe(2); + + consumer.destroy(); +}); + +describe("bundler", () => { + describe("onLoad sourcemap", () => { + itBundled("plugin/SourcemapString", { + files: { + "index.js": `import "./test.transformed.js";`, + }, + plugins(builder) { + builder.onLoad({ filter: /\.transformed\.js$/ }, () => { + // Simulate a TypeScript-like transformation + const originalCode = `function greet(name: string) { + console.log("Hello, " + name); +} +greet("World");`; + + const transformedCode = `function greet(name) { + console.log("Hello, " + name); +} +greet("World");`; + + // A simple sourcemap that maps line 1 of transformed to line 1 of original + const sourcemap = JSON.stringify({ + version: 3, + sources: ["transformed.ts"], + sourcesContent: [originalCode], + names: ["greet", "name", "console", "log"], + mappings: "AAAA,SAASA,MAAMC,MACbC,QAAQC,IAAI,UAAYD,MAE1BF,MAAM", + }); + + return { + contents: transformedCode, + loader: "js", + sourcemap, + }; + }); + }, + outdir: "/out", + sourcemap: "external", + onAfterBundle(api) { + // Check that sourcemap was generated + const sourcemapFile = api.outputs.find(f => f.path.endsWith(".js.map")); + if (!sourcemapFile) { + throw new Error("Expected sourcemap file to be generated"); + } + + const sourcemap = JSON.parse(sourcemapFile.text); + if (sourcemap.version !== 3) { + throw new Error("Expected sourcemap version 3"); + } + if (!sourcemap.sources.includes("transformed.ts")) { + throw new Error("Expected sourcemap to contain transformed.ts source"); + } + if (!sourcemap.sourcesContent?.[0]?.includes("function greet(name: string)")) { + throw new Error("Expected sourcemap to contain original TypeScript source"); + } + }, + }); + + itBundled("plugin/SourcemapTypedArray", { + files: { + "index.js": `import "./test.transformed.js";`, + }, + plugins(builder) { + builder.onLoad({ filter: /\.transformed\.js$/ }, () => { + const code = `console.log("transformed");`; + const sourcemap = new TextEncoder().encode(JSON.stringify({ + version: 3, + sources: ["original.js"], + sourcesContent: [`console.log("original");`], + names: ["console", "log"], + mappings: "AAAA", + })); + + return { + contents: code, + loader: "js", + sourcemap: new Uint8Array(sourcemap), + }; + }); + }, + sourcemap: "inline", + onAfterBundle(api) { + const output = api.outputs[0]; + // Check for inline sourcemap + if (!output.text.includes("//# sourceMappingURL=data:")) { + throw new Error("Expected inline sourcemap"); + } + }, + }); + + itBundled("plugin/SourcemapInvalid", { + files: { + "index.js": `import "./test.transformed.js";`, + }, + plugins(builder) { + builder.onLoad({ filter: /\.transformed\.js$/ }, () => { + return { + contents: `console.log("test");`, + loader: "js", + sourcemap: "not a valid sourcemap", + }; + }); + }, + sourcemap: "external", + bundleWarnings: { + "/test.transformed.js": ["Failed to parse sourcemap from plugin: InvalidJSON"], + }, + }); + + itBundled("plugin/SourcemapPreservesOriginal", { + files: { + "index.js": `import "./user.ts";`, + }, + plugins(builder) { + // First transformation: TypeScript -> JavaScript + builder.onLoad({ filter: /\.ts$/ }, () => { + const tsCode = `interface User { + name: string; + age: number; +} + +function greet(user: User): void { + console.log(\`Hello, \${user.name}! You are \${user.age} years old.\`); +} + +const john: User = { name: "John", age: 30 }; +greet(john);`; + + const jsCode = `function greet(user) { + console.log(\`Hello, \${user.name}! You are \${user.age} years old.\`); +} + +const john = { name: "John", age: 30 }; +greet(john);`; + + // Simplified sourcemap for the transformation + const sourcemap = JSON.stringify({ + version: 3, + sources: ["user.ts"], + sourcesContent: [tsCode], + names: ["greet", "user", "console", "log", "name", "age", "john"], + mappings: "AAIA,SAASA,MAAMC,OACbC,QAAQC,IAAI,WAAWF,KAAKG,aAAaH,KAAKI,eAGhD,MAAMC,MAAQ,CAAEF,KAAM,OAAQC,IAAK,IACnCL,MAAMM", + }); + + return { + contents: jsCode, + loader: "js", + sourcemap, + }; + }); + }, + outdir: "/out", + sourcemap: "external", + onAfterBundle(api) { + const sourcemapFile = api.outputs.find(f => f.path.endsWith(".js.map")); + if (!sourcemapFile) { + throw new Error("Expected sourcemap file to be generated"); + } + + const sourcemap = JSON.parse(sourcemapFile.text); + // Should preserve the original TypeScript source + if (!sourcemap.sources.includes("user.ts")) { + throw new Error("Expected sourcemap to contain user.ts"); + } + if (!sourcemap.sourcesContent?.[0]?.includes("interface User")) { + throw new Error("Expected sourcemap to contain original TypeScript source"); + } + }, + }); + }); +}); \ No newline at end of file