Skip to content
Open
6 changes: 4 additions & 2 deletions docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.<br/><br/>`js<br/>// <bundled code here><br/><br/>//# sourceMappingURL=bundle.js.map<br/>` |
| `"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`.<br/><br/>`js<br/>// <bundled code here><br/><br/>//# debugId=<DEBUG ID><br/>//# sourceMappingURL=bundle.js.map<br/>` |
| `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment.<br/><br/>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.<br/><br/>`js<br/>// <generated bundle code><br/><br/>//# debugId=<DEBUG ID><br/>` |
| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.<br/><br/>`js<br/>// <bundled code here><br/><br/>//# sourceMappingURL=data:application/json;base64,<encoded sourcemap here><br/>` |
| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.<br/><br/>`js<br/>// <bundled code here><br/><br/>//# debugId=<DEBUG ID><br/>//# sourceMappingURL=data:application/json;base64,<encoded sourcemap here><br/>` |

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`.
Expand Down
122 changes: 122 additions & 0 deletions src/sourcemap/debug_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! The debug id of a bundle: `bun build` writes it into the file (`//# debugId=<id>`) 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<Item = u8> + '_ {
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<Utf8Bytes<'_>> {
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<T: PathByte>(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<T: PathByte>(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)
}
5 changes: 5 additions & 0 deletions src/sourcemap/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub enum Error {
UnsupportedVersion,
#[error("InvalidSourceMap")]
InvalidSourceMap,
/// A `<file>.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)]
Expand All @@ -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(),
}
Expand All @@ -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(),
}
Expand Down
45 changes: 42 additions & 3 deletions src/sourcemap/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -535,8 +536,11 @@ pub(crate) fn get_source_map_impl<P: SourceProvider + ?Sized>(
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Developers running bun build --watch --outfile with bun --hot now get a spurious warn: Ignoring sourcemap plus unmapped stack traces on some reloads. --outfile writes the bundle before its .map (build_command.rs:1190 walks output_files, where maps follow chunks), so a reload that errors in that window reads the previous map beside the new bundle and src/sourcemap/lib.rs:543 refuses it for the whole load. Fix: make the bundle and its sidecar converge for a hot reloader: write the .map before the bundle in the --outfile writer, as writeOutputFilesToDisk.rs:325 does for --outdir, or re-read a refused map once when it changes on disk. The PR calls this accepted (41 of 519 rebuilds); on the base branch the old map applied silently.

Why this was flagged

Trigger: bun build --target=bun --sourcemap=external --watch --outfile=out.js running beside bun --hot out.js, and the reloaded code throws (or reads error.stack) before the watcher's second write lands. In --outfile mode the in-memory build path fills output_files with chunk entries at indices below index_for_sourcemaps_and_bytecode, and build_command.rs:1190 writes them in that order, so out.js lands before out.js.map. The hot reload registers the new provider, and the first stack lookup reads the still-old out.js.map; parse_json_impl at src/sourcemap/lib.rs:923-934 finds the old debugId, and lib.rs:543 returns None, after which SavedSourceMap.rs:329 removes the entry so that load never maps again. On the base branch the same reload applied the old map (wrong positions, no message); after this change it prints the warning and shows generated positions until the next reload. The --outdir writer writes the map first (writeOutputFilesToDisk.rs:325) and does not hit this.

Verification: --outfile without --outdir leaves output_dir empty (build_command.rs:332-335), so generateChunksInParallel.rs:862-871 takes the in-memory branch; build_command.rs:1190-1191 writes them in that order: bundle first, .map second. src/sourcemap/lib.rs:539-543: Err(crate::Error::DebugIdMismatch) => return None. src/jsc/SavedSourceMap.rs:327-338: on None the provider entry is removed, so the map is not re-read for that load.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it is in the Notes under "Who loses a map that main applies". An inotify trace shows the order: --outfile writes out.js and then out.js.map, and --outdir writes the map first.

I did not change the writer in this PR. The order of the --outfile writes is a producer bug by itself: on main the same reload maps the new bundle with the old map and prints nothing. I passed it on as its own change (write the map before the bundle in the --outfile loop of build_command.rs). #41879 moves --outfile to the writer that --outdir uses, which has that order.

I did not add a second read of a refused map. A lookup that fails is final for that load on main too (SavedSourceMap removes the entry), and the next reload reads the map again, which a test here covers. I leave this thread open for the maintainer decision.

Err(err) => {
// Print warning even if this came from non-visible code like
// calling `error.stack`. This message is only printed if
Expand Down Expand Up @@ -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<ParseUrl> {
parse_json_impl(source, hint, None)
}

/// `parse_json` for `<source_filename>.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<ParseUrl> {
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<ParseUrl> {
use crate::mapping::SourceMap as SourceMapLog;
use bun_ast::StoreResetGuard as DataStoreScope;
use std::sync::Arc;
Expand All @@ -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() {
Expand Down Expand Up @@ -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<Box<[u8]>>` drops automatically on error.
let source_paths_slice: Option<Vec<Box<[u8]>>> = if !source_only {
let mut v: Vec<Box<[u8]>> = Vec::with_capacity(sources_content.items().len());
Expand Down
1 change: 1 addition & 0 deletions test/aot/runner-suites.known.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions test/bundler/bundler_bun.test.ts
Original file line number Diff line number Diff line change
@@ -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 */ `
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading