Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/bundler/OutputFile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ pub struct OutputFile {
pub size_without_sourcemap: usize,
pub hash: bun_core::fmt::ContentHash,
pub is_executable: bool,
/// Position of this file's source map in the list that holds this file, or `u32::MAX`. Code that removes or moves files in that list keeps it valid.
pub source_map_index: u32,
/// As `source_map_index`, for the bytecode file.
pub bytecode_index: u32,
/// As `source_map_index`, for the module info file.
pub module_info_index: u32,
pub output_kind: OutputKind,
/// Relative
Expand Down
8 changes: 8 additions & 0 deletions src/bundler/linker_context/generateChunksInParallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,14 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(
// else: item at `i` will be dropped by truncate below (impl Drop handles deinit)
}
result.truncate(write_idx);
debug_assert!(
result.iter().all(|file| {
file.source_map_index == u32::MAX
&& file.bytecode_index == u32::MAX
&& file.module_info_index == u32::MAX
}),
"a kept file names another file by its position before the filter",
);
debug_assert_no_placeholder_left(c, &result);
return Ok(result);
}
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/api/JSBundler.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export const BuildArtifact = b.class({
returns: b.any.returning("Blob"),
length: 2,
},
/** `Bun.build` fills the field of an output that has a source map, with `undefined` if nothing was written for the map. */
sourcemap: { getter: b.any.returning("BuildArtifact", "null", "undefined"), cache: true, capability: "heap" },
/** `Bun.build` fills the field of an output that has a source map, with the `BuildArtifact` of that map. */
sourcemap: { getter: b.any.returning("BuildArtifact", "null"), cache: true, capability: "heap" },
stream: { ...js, args: { chunkSize: b.any }, returns: b.any.returning("ReadableStream"), passThis: true },
text: read,
type: { getter: b.any.returning("string"), cache: true, capability: "js" },
Expand Down
50 changes: 37 additions & 13 deletions src/runtime/api/js_bundle_completion_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,10 @@ impl JSBundleCompletionTask {
// keep them in the output array. Destroy all other non-entry-point files.
// With --splitting, there can be multiple sourcemap files (one per chunk).
let mut kept: usize = 0;
// Of the files kept, only the entry point names other files by their position.
let entry_source_map_index = output_files[entry_point_index].source_map_index as usize;
let mut entry_at: usize = 0;
let mut entry_source_map_at: u32 = u32::MAX;
// Swap-compact in place via index iteration so each loop body holds
// at most one `&mut` into `output_files`.
for i in 0..output_files.len() {
Expand Down Expand Up @@ -708,12 +712,21 @@ impl JSBundleCompletionTask {
};

if keep_this {
if i == entry_point_index {
entry_at = kept;
} else if i == entry_source_map_index {
entry_source_map_at = kept as u32;
}
output_files.swap(kept, i);
kept += 1;
}
// Trailing (dropped) entries are freed by `truncate` below.
}
output_files.truncate(kept);
let entry = &mut output_files[entry_at];
entry.source_map_index = entry_source_map_at;
entry.bytecode_index = u32::MAX;
entry.module_info_index = u32::MAX;

result
}
Expand Down Expand Up @@ -879,8 +892,9 @@ impl JSBundleCompletionTask {
let dir = this.config.dir.list.clone();
let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir();

let mut to_assign_on_sourcemap = JSValue::ZERO;
for (i, output_file) in output_files.iter_mut().enumerate() {
// Last to first: a source map comes after its file, so its wrapper is made first.
for i in (0..output_files.len()).rev() {
let output_file = &mut output_files[i];
let path: Box<[u8]> = if !outdir.is_empty() {
if outdir_is_abs {
Box::from(join_abs_string::<platform::Auto>(
Expand All @@ -897,17 +911,27 @@ impl JSBundleCompletionTask {
output_file.dest_path.clone()
};
let result = output_file.to_js(Some(&path), global_this);
if to_assign_on_sourcemap != JSValue::ZERO {
crate::generated_bindgen::js_bundler::js_BuildArtifact::sourcemap_set_cached(
to_assign_on_sourcemap,
global_this,
result,
);
to_assign_on_sourcemap = JSValue::ZERO;
}

if output_file.source_map_index != u32::MAX {
to_assign_on_sourcemap = result;
let source_map_index = output_file.source_map_index;
let has_source_map = output_files
.get(source_map_index as usize)
.is_some_and(|file| file.output_kind == OutputKind::Sourcemap);
debug_assert!(
source_map_index == u32::MAX
|| (has_source_map && source_map_index as usize > i),
"output file {i} names {source_map_index}, which is not a later Sourcemap file (see OutputFileListBuilder)",
);
Comment thread
robobun marked this conversation as resolved.
Resolved
if has_source_map {
match output_files_js.get_direct_index(global_this, source_map_index) {
Ok(source_map) if source_map.is_cell() => {
crate::generated_bindgen::js_bundler::js_BuildArtifact::sourcemap_set_cached(
result,
global_this,
source_map,
);
}
Ok(_) => {}
Err(e) => return promise.reject(global_this, Err(e)),
}
}

if let Err(e) = output_files_js.put_index(global_this, i as u32, result) {
Expand Down
18 changes: 13 additions & 5 deletions src/runtime/cli/compile_command/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,7 @@ fn write_product(
build_id: Some(build_id),
..Default::default()
});
build.output_files.truncate(1);
outcome.output_files = core::mem::take(&mut build.output_files);
outcome.output_files = take_executable_file(&mut build.output_files);
return Ok(());
}

Expand Down Expand Up @@ -398,12 +397,21 @@ fn write_product(
write_lines(summary, &mut outcome.trim_log_lines);
}
outcome.executable = details;
// The main entry point's file stands for the executable among the artifacts.
build.output_files.truncate(1);
outcome.output_files = core::mem::take(&mut build.output_files);
outcome.output_files = take_executable_file(&mut build.output_files);
Ok(())
}

/// The main entry point's file stands for the executable among the artifacts. The other files are gone, so it names none of them by position.
fn take_executable_file(output_files: &mut Vec<OutputFile>) -> Vec<OutputFile> {
output_files.truncate(1);
if let Some(file) = output_files.first_mut() {
file.source_map_index = u32::MAX;
file.bytecode_index = u32::MAX;
file.module_info_index = u32::MAX;
}
core::mem::take(output_files)
}

// `to_executable` resolves `outfile` in the working directory on Windows, and puts its last component in `root_dir` elsewhere.
pub(super) fn executable_location(root_dir: Fd, outfile: &[u8]) -> (Fd, &[u8]) {
if cfg!(windows) {
Expand Down
103 changes: 103 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,109 @@ describe("Bun.build", () => {
expect(exitCode).toBe(0);
});

// https://github.com/oven-sh/bun/issues/37184
describe("BuildArtifact.sourcemap is the source map of that output", () => {
const files = {
"tsconfig.json": JSON.stringify({
compilerOptions: { strict: true, module: "esnext", moduleResolution: "bundler", target: "esnext", types: [] },
}),
"shared.ts": `export const shared = { n: 1 };\n`,
"a.ts": `import { shared } from "./shared";\nexport const a: number = shared.n + 1;\n`,
"b.ts": `import { shared } from "./shared";\nexport const b: number = shared.n + 2;\n`,
"styled.ts": `import "./style.css";\nconsole.log("styled");\n`,
"style.css": `body { margin: 0; }\n`,
"page.ts": `console.log("page");\n`,
"index.html": `<!doctype html><html><head><link rel="stylesheet" href="./style.css"></head><body><script type="module" src="./page.ts"></script></body></html>\n`,
};
// No shape puts a map right after its file. With bytecode, a `.jsc` file also sits between the two maps.
const shapes: [
name: string,
entrypoints: string[],
options: Partial<Bun.BuildConfig>,
maps: Record<string, string>,
][] = [
["two entry points", ["a.ts", "b.ts"], {}, { "a.js": "a.js.map", "b.js": "b.js.map" }],
[
"two entry points and a shared chunk",
["a.ts", "b.ts"],
{ splitting: true, naming: { chunk: "shared.[ext]" } },
{ "a.js": "a.js.map", "b.js": "b.js.map", "shared.js": "shared.js.map" },
],
["an entry point that imports CSS", ["styled.ts"], {}, { "styled.js": "styled.js.map" }],
["an HTML entry point", ["index.html"], { naming: { chunk: "page.[ext]" } }, { "page.js": "page.js.map" }],
[
"two entry points with bytecode",
["a.ts", "b.ts"],
{ bytecode: true, target: "bun", format: "cjs" },
{ "a.js": "a.js.map", "b.js": "b.js.map" },
],
[
"two entry points with declaration files",
["a.ts", "b.ts"],
{ dts: true },
{ "a.js": "a.js.map", "b.js": "b.js.map" },
],
];
const cases = shapes
.flatMap(shape =>
(["linked", "external"] as const).flatMap(sourcemap =>
[false, true].map(
outdir =>
[`${shape[0]}, ${sourcemap}, ${outdir ? "outdir" : "no outdir"}`, shape, sourcemap, outdir] as const,
),
),
)
// One case with declaration files: the type checker takes about 2 s in a debug build.
.filter(([, [, , options], sourcemap, outdir]) => !options.dts || (sourcemap === "external" && outdir));

test.concurrent.each(cases)("%s", async (_, [, entrypoints, options, maps], sourcemap, outdir) => {
using dir = tempDir("build-artifact-sourcemap-pairs", files);
const root = String(dir);
const { outputs } = await Bun.build({
...options,
entrypoints: entrypoints.map(entry => join(root, entry)),
tsconfig: join(root, "tsconfig.json"),
sourcemap,
...(outdir ? { outdir: join(root, "out") } : {}),
});
const owners = outputs.filter(output => output.sourcemap !== null);
expect(Object.fromEntries(owners.map(o => [path.basename(o.path), path.basename(o.sourcemap!.path)]))).toEqual(
maps,
);
// `.sourcemap` is the element of `outputs` itself, and that element is a source map.
expect(owners.map(o => outputs.indexOf(o.sourcemap!))).toEqual(
owners.map(o => outputs.findIndex(map => map.kind === "sourcemap" && map.path === o.path + ".map")),
);
});

test("in an onEnd callback and in Bun.inspect", async () => {
using dir = tempDir("build-artifact-sourcemap-on-end", files);
const root = String(dir);
const pairs = (outputs: Bun.BuildArtifact[]) =>
Object.fromEntries(outputs.map(o => [path.basename(o.path), o.sourcemap && path.basename(o.sourcemap.path)]));
let inOnEnd: ReturnType<typeof pairs> | undefined;
const build = await Bun.build({
entrypoints: [join(root, "a.ts"), join(root, "b.ts")],
sourcemap: "external",
plugins: [
{
name: "read-sourcemaps",
setup(builder) {
builder.onEnd(result => {
inOnEnd = pairs(result.outputs);
});
},
},
],
});
const expected = { "a.js": "a.js.map", "b.js": "b.js.map", "a.js.map": null, "b.js.map": null };
expect({ inOnEnd, resolved: pairs(build.outputs) }).toEqual({ inOnEnd: expected, resolved: expected });
expect(Bun.inspect(build.outputs.find(o => path.basename(o.path) === "a.js"))).toMatch(
/\n sourcemap: BuildArtifact \(sourcemap\) \{\n path: "[^"]*a\.js\.map",/,
);
});
});

// test("BuildArtifact properties splitting", async () => {
// Bun.gc(true);
// const x = await Bun.build({
Expand Down
72 changes: 71 additions & 1 deletion test/bundler/bun-build-compile-sourcemap.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
import { basename, join } from "path";

const sourcesOf = (map: { sources: string[] }) => map.sources.map(source => basename(source)).sort();
// `sourcemap` is the position in `outputs` of the output's `.sourcemap`.
const describeOutputs = (outputs: Bun.BuildArtifact[]) =>
Promise.all(
outputs.map(async output => ({
kind: output.kind,
sources: output.kind === "sourcemap" ? sourcesOf(await output.json()) : undefined,
sourcemap: output.sourcemap && outputs.indexOf(output.sourcemap),
})),
);

describe("Bun.build compile with sourcemap", () => {
const helperFiles = {
Expand Down Expand Up @@ -112,6 +123,7 @@ main();`,
// The sourcemap output should appear in build result outputs
const sourcemapOutputs = result.outputs.filter((o: any) => o.kind === "sourcemap");
expect(sourcemapOutputs.length).toBe(1);
expect(executableOutput.sourcemap).toBe(sourcemapOutputs[0]);

// The .map file should exist next to the executable
const mapPath = sourcemapOutputs[0].path;
Expand Down Expand Up @@ -179,6 +191,11 @@ export function greet() {
const sourcemapOutputs = result.outputs.filter((o: any) => o.kind === "sourcemap");
expect(sourcemapOutputs.length).toBeGreaterThanOrEqual(2);

// The executable's `.sourcemap` is the map of the entry chunk, not the map of the lazy chunk.
expect(sourcemapOutputs).toContain(executableOutput.sourcemap);
expect(sourcesOf(await executableOutput.sourcemap!.json())).toEqual(["entry.js"]);
expect(sourcemapOutputs.filter(map => map.sourcemap !== null)).toEqual([]);

// Each sourcemap should be a valid .map file on disk
const mapPaths = new Set<string>();
for (const sm of sourcemapOutputs) {
Expand Down Expand Up @@ -210,6 +227,59 @@ export function greet() {
expect(exitCode).toBe(0);
});

// https://github.com/oven-sh/bun/issues/37184
test("compile with an HTML entry point before the JS entry point: the executable's sourcemap is its own map", async () => {
using dir = tempDir("build-compile-sourcemap-html-entry", {
"index.html": `<!doctype html><html><body><script type="module" src="./page.js"></script></body></html>`,
"page.js": `console.log("page");`,
"app.js": `console.log("app");`,
});

const result = await Bun.build({
entrypoints: [join(String(dir), "index.html"), join(String(dir), "app.js")],
compile: { outfile: join(String(dir), "app") },
sourcemap: "external",
});

expect(result.success).toBe(true);

// The map of the page's chunk comes before the map of the executable.
expect(await describeOutputs(result.outputs)).toEqual([
{ kind: "entry-point", sources: undefined, sourcemap: 2 },
{ kind: "sourcemap", sources: ["page.js"], sourcemap: null },
{ kind: "sourcemap", sources: ["app.js"], sourcemap: null },
]);
});

test("compile with splitting: the executable's sourcemap is null when its own map is not written", async () => {
using dir = tempDir("build-compile-sourcemap-unwritable", {
"entry.js": `
const mod = await import("./lazy.js");
mod.greet();
`,
"lazy.js": `
export function greet() {
console.log("hello from lazy module");
}
`,
// A directory at the path of the executable's map: that one write fails.
"entry.map": {},
});

const result = await Bun.build({
entrypoints: [join(String(dir), "entry.js")],
compile: { outfile: join(String(dir), "entry") },
splitting: true,
sourcemap: "external",
});

Comment thread
robobun marked this conversation as resolved.
Resolved
expect(result.success).toBe(true);
expect(await describeOutputs(result.outputs)).toEqual([
{ kind: "entry-point", sources: undefined, sourcemap: null },
{ kind: "sourcemap", sources: ["lazy.js"], sourcemap: null },
]);
});

test("compile with --outfile subdir/myapp writes .map next to executable", async () => {
using dir = tempDir("build-compile-sourcemap-outfile-subdir", helperFiles);

Expand Down
Loading