diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx
index b9a1837b9a10..3ba445007d83 100644
--- a/docs/bundler/index.mdx
+++ b/docs/bundler/index.mdx
@@ -760,12 +760,14 @@ Specifies the type of sourcemap to generate.
| Value | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"none"` | Default. No sourcemap is generated. |
-| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` to be set. You can customize the base URL in this comment with `--public-path`.
`js
//
//# sourceMappingURL=bundle.js.map
` |
+| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` to be set. You can customize the base URL in this comment with `--public-path`.
`js
//
//# debugId=
//# sourceMappingURL=bundle.js.map
` |
| `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment.
Generated bundles contain a debug id that can be used to associate a bundle with its corresponding sourcemap. This `debugId` is added as a comment at the bottom of the file.
`js
//
//# debugId=
` |
-| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.
`js
//
//# sourceMappingURL=data:application/json;base64,
` |
+| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.
`js
//
//# debugId=
//# sourceMappingURL=data:application/json;base64,
` |
The associated `*.js.map` sourcemap is a JSON file containing an equivalent `debugId` property.
+Bun reads the `*.js.map` file beside a bundle built with [`target: "bun"`](#target) the first time it needs a stack trace for that bundle. If the `debugId` in the `*.js.map` file differs from the `//# debugId` comment of the bundle, the map is from another build. Bun prints a warning and does not use the map. Bun does not compare a map that has no `debugId`.
+
### minify
Whether to enable minification. Default `false`.
diff --git a/src/sourcemap/debug_id.rs b/src/sourcemap/debug_id.rs
new file mode 100644
index 000000000000..5a477a4abafa
--- /dev/null
+++ b/src/sourcemap/debug_id.rs
@@ -0,0 +1,122 @@
+//! The debug id of a bundle: `bun build` writes it into the file (`//# debugId=`) and into its map (`"debugId"`).
+
+use bun_core::strings::{self, PathByte};
+use bun_core::{Utf8Bytes, fmt};
+use core::fmt::{Display, Formatter};
+
+pub(crate) struct Check<'a> {
+ pub(crate) file_id: &'a [u8],
+ pub(crate) source_filename: &'a [u8],
+}
+
+impl Check<'_> {
+ /// A UUID is 32 hex digits, written with or without dashes and in either case. Any other id is compared as it is.
+ pub(crate) fn is_id_of_file(&self, map_id: &[u8]) -> bool {
+ fn digits(id: &[u8]) -> impl Iterator- + '_ {
+ id.iter()
+ .filter(|unit| **unit != b'-')
+ .map(u8::to_ascii_lowercase)
+ }
+ let is_uuid = |id: &[u8]| {
+ digits(id).count() == 32 && digits(id).all(|digit| digit.is_ascii_hexdigit())
+ };
+ self.file_id == map_id || (is_uuid(self.file_id) && digits(self.file_id).eq(digits(map_id)))
+ }
+
+ #[cold]
+ pub(crate) fn warn(&self, map_id: &[u8]) {
+ let map_path = [self.source_filename, b".map"].concat();
+ bun_core::warn!(
+ "Ignoring sourcemap {}: its debugId {} is not the debugId {} that {} had when it was last loaded",
+ fmt::EscapeControlChars(fmt::quote(&map_path)),
+ Shown(map_id),
+ Shown(self.file_id),
+ fmt::EscapeControlChars(fmt::quote(self.source_filename)),
+ );
+ bun_core::note!(
+ "positions in this file stay unmapped until Bun loads it again beside a map with the same debugId"
+ );
+ // `note!` does not flush, and a Worker can end with no other flush.
+ bun_core::output::flush();
+ crate::SavedSourceMap::MissingSourceMapNoteInfo::set_seen_invalid(true);
+ }
+}
+
+struct Shown<'a>(&'a [u8]);
+
+impl Shown<'_> {
+ const MAX: usize = 64;
+}
+
+impl Display for Shown<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+ let shown = &self.0[..self.0.len().min(Self::MAX)];
+ write!(f, "{}", fmt::EscapeControlChars(fmt::quote(shown)))?;
+ if shown.len() < self.0.len() {
+ f.write_str("...")?;
+ }
+ Ok(())
+ }
+}
+
+/// The id on the `//# debugId=` line of a `// @bun` file: the last line, or the line above a last `//# sourceMappingURL=` line.
+pub(crate) fn of_file(source: &bun_core::String) -> Option> {
+ let id = if source.is_8bit() {
+ Utf8Bytes::Borrowed(value_of_id_line(source.latin1())?)
+ } else {
+ Utf8Bytes::Owned(strings::to_utf8_alloc(value_of_id_line(source.utf16())?))
+ };
+ // An id is one run of ASCII with no blank and no quote: V8 reads the value of a magic comment this way.
+ let is_id = !id.is_empty()
+ && id
+ .iter()
+ .all(|unit| unit.is_ascii_graphic() && !matches!(*unit, b'"' | b'\''));
+ is_id.then_some(id)
+}
+
+fn value_of_id_line(text: &[T]) -> Option<&[T]> {
+ // Text that does not start as the file does is not the file: for a replaced `Module.wrapper` the loader swaps the first line of a CommonJS file and cuts its end.
+ let first_line = match strip_prefix(text, b"#!") {
+ Some(_) => &text[T::index_of_unit(text, T::from_u8(b'\n'))? + 1..],
+ None => text,
+ };
+ strip_prefix(first_line, b"// @bun")?;
+
+ let (above, last) = split_last_line(text);
+ let line = match strip_prefix(last, b"//# sourceMappingURL=") {
+ Some(_) => split_last_line(above).1,
+ None => last,
+ };
+ let mut value = strip_prefix(line, b"//# debugId=")?;
+ let blank = [T::from_u8(b' '), T::from_u8(b'\t')];
+ while let [first, rest @ ..] = value {
+ if !blank.contains(first) {
+ break;
+ }
+ value = rest;
+ }
+ Some(value)
+}
+
+fn split_last_line(mut text: &[T]) -> (&[T], &[T]) {
+ let blank = (*b"\n\r \t").map(T::from_u8);
+ while let [rest @ .., last] = text {
+ if !blank.contains(last) {
+ break;
+ }
+ text = rest;
+ }
+ match strings::last_index_of_char_t(text, T::from_u8(b'\n')) {
+ Some(line_end) => (&text[..line_end], &text[line_end + 1..]),
+ None => (&text[..0], text),
+ }
+}
+
+fn strip_prefix<'a, T: PathByte>(text: &'a [T], prefix: &[u8]) -> Option<&'a [T]> {
+ let (start, rest) = text.split_at_checked(prefix.len())?;
+ start
+ .iter()
+ .zip(prefix)
+ .all(|(unit, byte)| *unit == T::from_u8(*byte))
+ .then_some(rest)
+}
diff --git a/src/sourcemap/error.rs b/src/sourcemap/error.rs
index 75ee0e57a5d0..cecf9c0cf2eb 100644
--- a/src/sourcemap/error.rs
+++ b/src/sourcemap/error.rs
@@ -28,6 +28,9 @@ pub enum Error {
UnsupportedVersion,
#[error("InvalidSourceMap")]
InvalidSourceMap,
+ /// A `.map` with the debug id of another build. `get_source_map_impl` gives no map for it.
+ #[error("DebugIdMismatch")]
+ DebugIdMismatch,
#[error(transparent)]
Alloc(#[from] bun_alloc::AllocError),
#[error(transparent)]
@@ -52,6 +55,7 @@ impl Error {
Self::InvalidJSON => "Invalid source map JSON",
Self::UnsupportedVersion => "Unsupported source map version",
Self::InvalidSourceMap => "Invalid source map",
+ Self::DebugIdMismatch => "Source map is from another build",
Self::Alloc(_) => "Out of memory",
Self::Core(e) => e.name(),
}
@@ -74,6 +78,7 @@ impl Error {
Self::InvalidJSON => "InvalidJSON",
Self::UnsupportedVersion => "UnsupportedVersion",
Self::InvalidSourceMap => "InvalidSourceMap",
+ Self::DebugIdMismatch => "DebugIdMismatch",
Self::Alloc(_) => "OutOfMemory",
Self::Core(e) => e.name(),
}
diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs
index e4ba62b09d68..f577818ad90b 100644
--- a/src/sourcemap/lib.rs
+++ b/src/sourcemap/lib.rs
@@ -11,6 +11,7 @@ pub use error::{Error, Result};
#[path = "Chunk.rs"]
pub mod chunk;
+mod debug_id;
#[path = "InternalSourceMap.rs"]
pub mod internal_source_map;
#[path = "LineOffsetTable.rs"]
@@ -535,8 +536,11 @@ pub(crate) fn get_source_map_impl(
Err(_) => break 'try_external,
};
- match parse_json(&data, result) {
+ let source = provider.get_source_slice();
+ match parse_sidecar_json(&data, result, &source, source_filename) {
Ok(parsed) => break 'parsed (SourceMapLoadHint::IsExternalMap, parsed),
+ // The warning is in `parse_json_impl`, which has the id of the map.
+ Err(crate::Error::DebugIdMismatch) => return None,
Err(err) => {
// Print warning even if this came from non-visible code like
// calling `error.stack`. This message is only printed if
@@ -870,6 +874,29 @@ pub(crate) fn parse_url(
/// `source` must be in UTF-8 and can be freed after this call.
/// The mappings are owned by the global allocator.
pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Result {
+ parse_json_impl(source, hint, None)
+}
+
+/// `parse_json` for `.map`: `Error::DebugIdMismatch` for a map with another debug id than `source`.
+fn parse_sidecar_json(
+ data: &[u8],
+ hint: ParseUrlResultHint,
+ source: &bun_core::String,
+ source_filename: &[u8],
+) -> crate::Result {
+ let file_id = debug_id::of_file(source);
+ let check = file_id.as_deref().map(|file_id| debug_id::Check {
+ file_id,
+ source_filename,
+ });
+ parse_json_impl(data, hint, check.as_ref())
+}
+
+fn parse_json_impl(
+ source: &[u8],
+ hint: ParseUrlResultHint,
+ check: Option<&debug_id::Check<'_>>,
+) -> crate::Result {
use crate::mapping::SourceMap as SourceMapLog;
use bun_ast::StoreResetGuard as DataStoreScope;
use std::sync::Arc;
@@ -890,6 +917,20 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
Err(_) => return Err(crate::Error::InvalidJSON),
};
let json = parsed.root;
+ let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_));
+
+ if let Some(check) = check
+ && let Some(map_id) = json.get(b"debugId")
+ && let Some(map_id) = map_id.as_utf8_string_literal()
+ && !map_id.is_empty()
+ && !check.is_id_of_file(map_id)
+ {
+ // A `SourceOnly` read follows a lookup that applied the map, so the map on disk changed after that.
+ if !source_only {
+ check.warn(map_id);
+ }
+ return Err(crate::Error::DebugIdMismatch);
+ }
if let Some(version) = json.get(b"version") {
match version.data.as_e_number() {
@@ -930,8 +971,6 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
return Err(crate::Error::InvalidSourceMap);
}
- let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_));
-
// `Vec>` drops automatically on error.
let source_paths_slice: Option>> = if !source_only {
let mut v: Vec> = Vec::with_capacity(sources_content.items().len());
diff --git a/test/aot/runner-suites.known.txt b/test/aot/runner-suites.known.txt
index 4be569aaf71d..fc6066b7d448 100644
--- a/test/aot/runner-suites.known.txt
+++ b/test/aot/runner-suites.known.txt
@@ -30,6 +30,7 @@ test/cli/test/bun-test.test.ts: a test file that can never finish loading > the
test/cli/test/coverage.test.ts: a file loaded more than once counts every load > (unnamed)
test/cli/test/coverage.test.ts: a file with ? in its name is not left out of coverage: it cannot be loaded
test/cli/test/coverage.test.ts: both braces of an object are listed and no brace of a block is: // @bun with a source map
+test/cli/test/coverage.test.ts: coverage of a // @bun file uses the map beside it unless the map has another debug id
# The next one imports a macro. A macro stops the build of a compiled run with TS100053, as docs/compiler/compatibility.mdx says.
test/cli/test/coverage.test.ts: objects that are not in the file do not move the lines that are listed
# The next one cannot pass: it waits until `isolatedModuleCacheSourceType()` of bun:internal-for-testing finds a file in the cache of sources that --isolate keeps, and a compiled file never gets there. Its child run never ends.
diff --git a/test/bundler/bundler_bun.test.ts b/test/bundler/bundler_bun.test.ts
index 08f86e9d1216..77a2efc981f5 100644
--- a/test/bundler/bundler_bun.test.ts
+++ b/test/bundler/bundler_bun.test.ts
@@ -1,5 +1,6 @@
import { Database } from "bun:sqlite";
import { describe, expect } from "bun:test";
+import { mkdirSync } from "node:fs";
import { itBundled } from "./expectBundled";
const nestedFunctions = /* js */ `
@@ -167,6 +168,75 @@ error: Hello World`,
},
},
});
+ // The bundle and its map have one debug id. Each case runs the pair as it was built, then the bundle beside a map
+ // with another id.
+ for (const format of ["esm", "cjs"] as const) {
+ for (const [sourceMap, publicPath] of [
+ ["external"],
+ ["linked"],
+ ["linked", "https://example.com/assets/"],
+ ] as const) {
+ const name = `bun/TargetBunSourcemapDebugId-${format}-${sourceMap}${publicPath ? "-public-path" : ""}`;
+ itBundled(name, () => {
+ const ids = { bundle: "", map: "" };
+ const warnings = (stderr: string) =>
+ stderr.split("\n").filter(line => line.startsWith("warn: ") || line.startsWith("note: "));
+ return {
+ target: "bun",
+ format,
+ outdir: "/out",
+ sourceMap,
+ publicPath,
+ files: {
+ "/entry.ts": /* js */ `
+ function code() {
+ throw new Error("Hello World");
+ }
+ code();
+ `,
+ },
+ onAfterBundle(api) {
+ const map = JSON.parse(api.readFile("/out/entry.js.map"));
+ ids.bundle = map.debugId;
+ ids.map = "0000000000000000" + map.debugId.slice(16);
+ const bundle = api.readFile("/out/entry.js");
+ expect(bundle).toInclude(`\n//# debugId=${ids.bundle}\n`);
+ mkdirSync(api.join("other"), { recursive: true });
+ api.writeFile("/other/entry.js", bundle);
+ api.writeFile("/other/entry.js.map", JSON.stringify({ ...map, debugId: ids.map, sources: ["other.ts"] }));
+ },
+ run: [
+ {
+ file: "/out/entry.js",
+ exitCode: 1,
+ validate({ stderr }) {
+ expect(warnings(stderr)).toEqual([]);
+ expect(stderr).toInclude("error: Hello World");
+ expect(stderr).toContain("entry.ts:2:9)");
+ },
+ },
+ {
+ file: "/other/entry.js",
+ exitCode: 1,
+ validate({ stderr }) {
+ expect(warnings(stderr)).toEqual([
+ expect.stringMatching(
+ new RegExp(
+ `^warn: Ignoring sourcemap ".*entry\\.js\\.map": its debugId "${ids.map}" is not the debugId "${ids.bundle}" that ".*entry\\.js" had when it was last loaded$`,
+ ),
+ ),
+ "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId",
+ ]);
+ expect(stderr).toInclude("error: Hello World");
+ expect(stderr).toInclude("entry.js:");
+ expect(stderr).not.toInclude("other.ts");
+ },
+ },
+ ],
+ };
+ });
+ }
+ }
itBundled("bun/unicode comment", {
target: "bun",
files: {
diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts
index 51502de2e407..f0c2d1b0b20d 100644
--- a/test/cli/test/coverage.test.ts
+++ b/test/cli/test/coverage.test.ts
@@ -1247,6 +1247,68 @@ test("reports an uncalled function on the last line of a // @bun file without a
expect(exitCode).toBe(0);
});
+// `bun build --sourcemap=external` writes one debug id into the file and into the map beside it.
+test.concurrent("coverage of a // @bun file uses the map beside it unless the map has another debug id", async () => {
+ const fileId = "0123456789ABCDEF64756E2164756E21";
+ const otherId = "FEDCBA987654321064756E2164756E21";
+ const prebuilt = `// @bun\nexport function first() {\n return 1;\n}\nexport const second = () => 2;\n//# debugId=${fileId}\n`;
+ // Line 1 of the file is line 11 of the source, and so on. The one source of each map is the file itself.
+ const map = (source: string, debugId: string) =>
+ JSON.stringify({
+ version: 3,
+ sources: [source],
+ sourcesContent: [null],
+ names: [],
+ mappings: "AAUA;AACA;AACA;AACA;AACA;AACA",
+ debugId,
+ });
+ using dir = tempDir("cov-debug-id", {
+ "same-id.js": prebuilt,
+ "same-id.js.map": map("same-id.js", fileId),
+ "other-id.js": prebuilt,
+ "other-id.js.map": map("other-id.js", otherId),
+ "a.test.ts": `
+ import { expect, test } from "bun:test";
+ import * as sameId from "./same-id.js";
+ import * as otherId from "./other-id.js";
+ test("calls first", () => { expect(sameId.first() + otherId.first()).toBe(2); });
+ `,
+ });
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=lcov"],
+ env: bunEnv,
+ cwd: String(dir),
+ stdout: "ignore",
+ stderr: "pipe",
+ });
+ const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
+ expect(stderr).toContain(" 1 pass");
+ const lcov = readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf-8");
+ const linesOf = (file: string) => {
+ const lines = lcov.split("\n").filter(line => line.startsWith("SF:") || line.startsWith("DA:"));
+ const start = lines.indexOf("SF:" + file) + 1;
+ const end = lines.findIndex((line, index) => index >= start && line.startsWith("SF:"));
+ return lines.slice(start, end === -1 ? undefined : end);
+ };
+ expect({
+ sameId: linesOf("same-id.js"),
+ otherId: linesOf("other-id.js"),
+ warnings: stderr.split("\n").filter(line => line.startsWith("warn: ") || line.startsWith("note: ")),
+ }).toEqual({
+ sameId: ["DA:12,1", "DA:15,1", "DA:16,1"],
+ otherId: ["DA:2,1", "DA:3,1", "DA:5,1", "DA:6,1"],
+ warnings: [
+ expect.stringMatching(
+ new RegExp(
+ `^warn: Ignoring sourcemap ".*other-id\\.js\\.map": its debugId "${otherId}" is not the debugId "${fileId}" that ".*other-id\\.js" had when it was last loaded$`,
+ ),
+ ),
+ "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId",
+ ],
+ });
+ expect(exitCode).toBe(0);
+});
+
// `braces.ts` in `source-positions-corpus.ts` has this for a file that Bun transpiles and reports by its source map.
test.concurrent.each(["// @bun", "// @bun with a source map", "coverageIgnoreSourcemaps"])(
"both braces of an object are listed and no brace of a block is: %s",
diff --git a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts
index 7bbdf6465c18..ff5510ab4aa5 100644
--- a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts
+++ b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts
@@ -155,3 +155,288 @@ test("prints the stack trace when a `sources` entry is longer than a path buffer
expect(stderr).toContain(`at t (${join(String(dir), sourceName)}:`);
expect(exitCode).toBe(1);
});
+
+// `bun build --sourcemap=external` and `--sourcemap=linked` write one debug id into the bundle (`//# debugId=`) and
+// into its map (`"debugId"`). A map beside the file that has another id is the map of another build.
+// ";;EAIE" maps generated 3:3 to original 5:3 of source 0.
+describe.concurrent("the debug id of a // @bun file and of the .map beside it", () => {
+ const throwsOnLine3 = `// @bun\nexport function t() {\n throw new Error("SC");\n}\n`;
+ const cjsThrowsOnLine3 = `// @bun @bun-cjs\n(function(exports, require, module, __filename, __dirname) {exports.t = function t() {\n throw new Error("SC");\n};\n})\n`;
+ const fileId = "0123456789ABCDEF64756E2164756E21";
+ const otherId = "FEDCBA987654321064756E2164756E21";
+ const uuid = "01234567-89ab-cdef-6475-6e2164756e21";
+ const longId = Buffer.alloc(64, "a").toString();
+ const map = (extra: object) =>
+ JSON.stringify({
+ version: 3,
+ sources: ["mapped.ts"],
+ sourcesContent: [null],
+ names: [],
+ mappings: ";;EAIE",
+ ...extra,
+ });
+ const external = (id = fileId) => `//# debugId=${id}\n`;
+ const url = (name: string) => `//# sourceMappingURL=${name}.map\n`;
+
+ async function run(files: Record, script: string, args: string[]) {
+ using dir = tempDir("sourcemap-debug-id", { "main.js": script, ...files });
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), ...args],
+ env: bunEnv,
+ cwd: String(dir),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ // A path in a warning is in quotes, and a quoted path that is not ASCII has each backslash twice.
+ const clean = (text: string) =>
+ text.replaceAll("\\\\", "\\").replaceAll(String(dir), "").replaceAll("\\", "/");
+ return { stdout: clean(stdout), stderr: clean(stderr), exitCode };
+ }
+ // A warning shows the first 64 bytes of an id.
+ const shown = (id: string) => (id.length > 64 ? `"${id.slice(0, 64)}"...` : `"${id}"`);
+ const refused = (name: string, id = fileId, mapId = otherId) =>
+ `warn: Ignoring sourcemap "/${name}.map": its debugId ${shown(mapId)} is not the debugId ${shown(id)} that "/${name}" had when it was last loaded\n` +
+ "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId\n";
+ const mapped = "at t (/mapped.ts:5:3)";
+ const frameOf = `const frame = text => text.split("\\n").find(line => line.includes(" at t ")).trim();`;
+
+ test("the map is read unless it has another debug id, and one warning says so", async () => {
+ // [name of the file, what follows its code, the map, the id that the file has when the map has another one]
+ const rows: [string, (name: string) => string, object, string?][] = [
+ ["external.js", () => external(), { debugId: fileId }],
+ ["linked.js", name => external() + url(name), { debugId: fileId }],
+ ["required-external.cjs", () => external(), { debugId: fileId }],
+ ["required-linked.cjs", name => external() + url(name), { debugId: fileId }],
+ // The id is a UUID. Tools write it in lower case and with dashes.
+ ["uuid-in-map.js", () => external(), { debugId: uuid }],
+ ["uuid-in-file.js", () => external(uuid), { debugId: fileId }],
+ ["id-of-another-tool.js", name => external("abc_def") + url(name), { debugId: "abc_def" }],
+ ["long-id.js", () => external(longId + "1"), { debugId: longId + "1" }],
+ ["blank-before-id.js", () => external(" \t" + fileId), { debugId: fileId }],
+ // A tool that writes the map again can drop the id.
+ ["map-without-id.js", () => external(), {}],
+ ["map-with-empty-id.js", name => external() + url(name), { debugId: "" }],
+ ["map-id-is-not-a-string.js", () => external(), { debugId: 123 }],
+ // A file with no id line has nothing to compare.
+ ["file-without-id.js", name => url(name), { debugId: otherId }],
+ ["empty-id.js", () => external(""), { debugId: otherId }],
+ // An id is one run of ASCII characters with no blank and no quote.
+ ["text-after-id.js", () => external(otherId + " text"), { debugId: fileId }],
+ ["quoted-id.js", () => external(`"${otherId}"`), { debugId: fileId }],
+ ["id-is-not-ascii.js", () => external("caf\u00e9"), { debugId: otherId }],
+ // An id line is the last line, or the line above a last `//# sourceMappingURL=` line.
+ ["id-above-a-comment.js", () => external(otherId) + "// end\n", { debugId: fileId }],
+ ["id-two-lines-above-the-url.js", name => external(otherId) + "// x\n" + url(name), { debugId: fileId }],
+ ["indented-id.js", () => " " + external(otherId), { debugId: fileId }],
+
+ ["other-external.js", () => external(), { debugId: otherId }, fileId],
+ ["other-linked.js", name => external() + url(name), { debugId: otherId }, fileId],
+ ["required-other-external.cjs", () => external(), { debugId: otherId }, fileId],
+ ["required-other-linked.cjs", name => external() + url(name), { debugId: otherId }, fileId],
+ ["other-tool.js", name => external("abc_def") + url(name), { debugId: otherId }, "abc_def"],
+ // Only a UUID, which is 32 hex digits, is the same id in another case or with dashes.
+ ["other-case.js", () => external("abc_def"), { debugId: "ABC_DEF" }, "abc_def"],
+ ["other-dashes.js", () => external("abc-def"), { debugId: "abcdef" }, "abc-def"],
+ [
+ "not-hex.js",
+ () => external(fileId.replace("0", "g")),
+ { debugId: fileId.replace("0", "G") },
+ fileId.replace("0", "g"),
+ ],
+ ["crlf-external.js", () => external(), { debugId: otherId }, fileId],
+ ["crlf-linked.js", name => external() + url(name), { debugId: otherId }, fileId],
+ ["no-final-line-end.js", () => external().trimEnd(), { debugId: otherId }, fileId],
+ // A `#!` line can be above the `// @bun` line.
+ ["hashbang-external.js", () => external(), { debugId: otherId }, fileId],
+ ["blank-before-other-id.js", () => external(" " + fileId), { debugId: otherId }, fileId],
+ ["space-after-id.js", () => external(fileId + " \t"), { debugId: otherId }, fileId],
+ ["space-after-id-linked.js", name => external(fileId + " \t") + url(name), { debugId: otherId }, fileId],
+ ["blank-line-above-the-url.js", name => external() + "\n" + url(name), { debugId: otherId }, fileId],
+ // sentry-cli writes the id there.
+ ["id-below-the-url.js", name => url(name) + "\n" + external(), { debugId: otherId }, fileId],
+ ["long-id-other-end.js", () => external(longId + "1"), { debugId: longId + "2" }, longId + "1"],
+ // Characters that are not ASCII above the id line do not hide it, below and above U+00FF.
+ [
+ "not-ascii-above-id.js",
+ () => "/*! Copyright \u00a9 W\u00e4rting \u0100 */\n" + external(),
+ { debugId: otherId },
+ fileId,
+ ],
+ // The id line is read at any distance from the end of the file.
+ [
+ "long-url.js",
+ () => external() + `//# sourceMappingURL=https://example.com/${Buffer.alloc(100_000, "a")}.map\n`,
+ { debugId: otherId },
+ fileId,
+ ],
+ ];
+ const files: Record = {};
+ for (const [name, tail, mapFields] of rows) {
+ const text = (name.endsWith(".cjs") ? cjsThrowsOnLine3 : throwsOnLine3) + tail(name);
+ files[name] = name.startsWith("crlf-")
+ ? text.replaceAll("\n", "\r\n")
+ : name.startsWith("hashbang-")
+ ? "#!/usr/bin/env bun\n" + text
+ : text;
+ files[name + ".map"] = map(mapFields);
+ }
+ // Each error is printed three times: its stack, then twice with the lines of code around it. The line on stderr
+ // shows that the whole warning is there before the program continues.
+ const printFrames = `
+ ${frameOf}
+ for (const file of process.argv.slice(2)) {
+ const { t } = file.endsWith(".cjs") ? require("./" + file) : await import("./" + file);
+ try {
+ t();
+ } catch (e) {
+ console.log(file + ": " + [frame(e.stack), frame(Bun.inspect(e)), frame(Bun.inspect(e))].join(" | "));
+ }
+ console.error(file + ": done");
+ }
+ `;
+
+ const { stdout, stderr, exitCode } = await run(files, printFrames, ["main.js", ...rows.map(([name]) => name)]);
+ const expected = rows.map(([name, , mapFields, idOfFile]) => {
+ const line = name.startsWith("hashbang-") ? 4 : 3;
+ const frame = idOfFile === undefined ? mapped : `at t (/${name}:${line}:18)`;
+ const warning = idOfFile === undefined ? "" : refused(name, idOfFile, (mapFields as { debugId: string }).debugId);
+ return { stdout: `${name}: ${frame} | ${frame} | ${frame}\n`, stderr: `${warning}${name}: done\n` };
+ });
+ expect({ stdout: stdout.split(/(?<=\n)/), stderr: stderr.split(/(?<=done\n)/) }).toEqual({
+ stdout: expected.map(row => row.stdout),
+ stderr: expected.map(row => row.stderr),
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ // The loader changes the text of a CommonJS file for a replaced `Module.wrapper`: it swaps the first line and cuts
+ // the last 4 characters, which are the end of the id here. The id of that text is not compared.
+ test.each([
+ ["", ""],
+ ["", "\n"],
+ ["/*\u0100*/", "\n"],
+ ])("the map of a CommonJS bundle is read after Module.wrapper is replaced with %j and %j", async (...wrapper) => {
+ const files = {
+ "own.cjs": cjsThrowsOnLine3 + external(),
+ "own.cjs.map": map({ debugId: fileId }),
+ "other.js": throwsOnLine3 + external(),
+ "other.js.map": map({ debugId: otherId }),
+ };
+ const script = `
+ ${frameOf}
+ require("node:module").wrapper = JSON.parse(process.argv[2]);
+ for (const load of [() => require("./own.cjs"), () => import("./other.js")]) {
+ try {
+ (await load()).t();
+ } catch (e) {
+ console.log(frame(e.stack));
+ }
+ }
+ `;
+
+ const { stdout, stderr, exitCode } = await run(files, script, ["main.js", JSON.stringify(wrapper)]);
+ expect({ stdout, stderr }).toEqual({
+ stdout: `${mapped}\nat t (/other.js:3:18)\n`,
+ stderr: refused("other.js"),
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ test("an error that nothing catches has the warning and not the notes about a missing map", async () => {
+ const files = {
+ "external.js": throwsOnLine3 + "t();\n" + external(),
+ "external.js.map": map({ debugId: otherId }),
+ };
+
+ const { stdout, stderr, exitCode } = await run(files, "", ["external.js"]);
+ expect(stdout).toBe("");
+ expect(stderr).toStartWith(refused("external.js"));
+ expect(stderr).toContain("at t (/external.js:3:9)");
+ expect(stderr).not.toContain("missing sourcemaps");
+ expect(exitCode).toBe(1);
+ });
+
+ test("a refused map is not read again for that load of the file, and the next load reads the map", async () => {
+ const script = `
+ import { writeFileSync } from "node:fs";
+ ${frameOf}
+ async function frameOfLoad(query) {
+ const { t } = await import("./reloaded.js" + query);
+ try {
+ t();
+ } catch (e) {
+ return frame(e.stack);
+ }
+ }
+ console.log(await frameOfLoad(""));
+ writeFileSync("reloaded.js.map", process.argv[2]);
+ console.log(await frameOfLoad(""));
+ console.log(await frameOfLoad("?again"));
+ `;
+ const files = {
+ "reloaded.js": throwsOnLine3 + external(),
+ "reloaded.js.map": map({ debugId: otherId }),
+ };
+
+ const { stdout, stderr, exitCode } = await run(files, script, ["main.js", map({ debugId: fileId })]);
+ const unmapped = "at t (/reloaded.js:3:18)";
+ expect({ stdout, stderr }).toEqual({
+ stdout: `${unmapped}\n${unmapped}\n${mapped}\n`,
+ stderr: refused("reloaded.js"),
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ // The lines of code of an error are read again each time the error is printed: from the map, or else from the source
+ // file that the map names. ";;ECIE" maps generated 3:3 to original 5:3 of source 1.
+ test.each([
+ ["and the source file is not there", {}, "5 | FIRST 5 | at t (/mapped.ts:5:3)\nat t (/mapped.ts:5:3)\n"],
+ [
+ "and the source file is there",
+ { "mapped.ts": [1, 2, 3, 4, 5, 6].map(line => `FIRST ${line}\n`).join("") },
+ "5 | FIRST 5 | at t (/mapped.ts:5:3)\n5 | FIRST 5 | at t (/mapped.ts:5:3)\n",
+ ],
+ ])(
+ "the lines of code do not come from a map that another build wrote while the file runs, %s",
+ async (_, source, stdoutOfBoth) => {
+ const code = (word: string) => [1, 2, 3, 4, 5, 6].map(line => `${word} ${line}\n`).join("");
+ const script = `
+ import { writeFileSync } from "node:fs";
+ import { t } from "./replaced.js";
+ function codeOfError() {
+ try {
+ t();
+ } catch (e) {
+ const text = Bun.inspect(e);
+ return text.split("\\n").filter(line => / (FIRST|ADDED|OTHER) 5$| at t /.test(line)).map(line => line.trim()).join(" | ");
+ }
+ }
+ console.log(codeOfError());
+ writeFileSync("replaced.js.map", process.argv[2]);
+ console.log(codeOfError());
+ `;
+ const files = {
+ ...source,
+ "replaced.js": throwsOnLine3 + external(),
+ "replaced.js.map": map({
+ debugId: fileId,
+ mappings: ";;ECIE",
+ sources: ["other.ts", "mapped.ts"],
+ sourcesContent: [code("OTHER"), code("FIRST")],
+ }),
+ };
+ // The other build has one more source, so the source of the frame has another index in its map.
+ const ofAnotherBuild = map({
+ debugId: otherId,
+ mappings: ";;ECIE",
+ sources: ["added.ts", "other.ts", "mapped.ts"],
+ sourcesContent: [code("ADDED"), code("OTHER"), code("FIRST")],
+ });
+
+ const { stdout, stderr, exitCode } = await run(files, script, ["main.js", ofAnotherBuild]);
+ expect({ stdout, stderr }).toEqual({ stdout: stdoutOfBoth, stderr: "" });
+ expect(exitCode).toBe(0);
+ },
+ );
+});