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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/bundler/executables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ Bytecode compilation moves parsing overhead for large input files from runtime t

The `--minify` argument reduces the size of the transpiled output code. For a large application, this can save megabytes of space. For smaller applications, it might still improve start time a little.

The `--sourcemap` argument embeds a sourcemap compressed with zstd, so that errors & stacktraces point to their original locations instead of the transpiled location. Bun decompresses & resolves the sourcemap automatically when an error occurs.
The `--sourcemap` argument embeds a sourcemap compressed with zstd, so that errors & stacktraces point to their original locations instead of the transpiled location. Bun decompresses & resolves the sourcemap automatically when an error occurs. If an input file carries its own `//# sourceMappingURL=` comment (for example the output of an earlier build step), that map is composed into the embedded one, so the stack trace names the original source file.

The `--bytecode` argument enables bytecode compilation. Every time you run JavaScript code in Bun, JavaScriptCore (the engine) compiles your source code into bytecode. `--bytecode` moves that parsing work from runtime to bundle time, which shortens startup.

Expand Down
2 changes: 2 additions & 0 deletions docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,8 @@ Specifies the type of sourcemap to generate.

The associated `*.js.map` sourcemap is a JSON file containing an equivalent `debugId` property.

When an input file carries its own `//# sourceMappingURL=` comment (the output of an earlier build step, or a package that ships a `.map` file next to its code), Bun composes that map into the output sourcemap, like esbuild does. The `sources` of the output map then point at the original files instead of the intermediate file, and stack traces from `bun run` and from a `--compile` executable name the original files. The comment can be a `data:` URL, a `file:` URL, or a path relative to the input file. A map file that does not exist is ignored. A map that cannot be read or parsed produces a warning, and the input file then maps to itself.

### minify

Whether to enable minification. Default `false`.
Expand Down
3 changes: 3 additions & 0 deletions src/ast/ast_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub struct Ast<'a> {
// them under the same lifetime-erased contract as `StoreRef`.
pub hashbang: StoreStr,
pub directive: Option<StoreStr>,
/// URL text and location of the file's last `sourceMappingURL` comment.
pub source_mapping_url: Option<crate::Span>,
/// `export default X` where `X` is an import binding in this file. When
/// `X` resolves to a module namespace the linker binds importers of
/// `default` through `X` (a namespace never changes identity, so the
Expand Down Expand Up @@ -125,6 +127,7 @@ impl<'a> Ast<'a> {
import_records: ImportRecordList::new_in(arena),
hashbang: StoreStr::EMPTY,
directive: None,
source_mapping_url: None,
export_default_alias_of_import: Ref::NONE,
parts: PartList::new_in(arena),
symbols: SymbolList::new_in(arena),
Expand Down
4 changes: 4 additions & 0 deletions src/bundler/Graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ pub struct InputFile {
pub unique_key_for_additional_file: Box<[u8], AstAlloc>,
pub content_hash_for_additional_file: u64,
pub flags: InputFileFlags,
/// From the file's `sourceMappingURL` comment. Global heap: drained in `deinit_without_freeing_arena`.
pub input_source_map: Option<Box<bun_sourcemap::InputSourceMap>>,
}

impl Default for InputFile {
Expand All @@ -125,6 +127,7 @@ impl Default for InputFile {
unique_key_for_additional_file: AstAlloc::vec().into_boxed_slice(),
content_hash_for_additional_file: 0,
flags: InputFileFlags::default(),
input_source_map: None,
}
}
}
Expand All @@ -142,6 +145,7 @@ bun_collections::multi_array_columns! {
unique_key_for_additional_file: Box<[u8], AstAlloc>,
content_hash_for_additional_file: u64,
flags: InputFileFlags,
input_source_map: Option<Box<bun_sourcemap::InputSourceMap>>,
}
}

Expand Down
115 changes: 63 additions & 52 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,7 @@ impl<'a> LinkerContext<'a> {
let mut j = StringJoiner::default();

let sources = self.parse_graph().input_files.items_source();
let input_source_maps = self.parse_graph().input_files.items_input_source_map();
let quoted_source_map_contents = self.graph.files.items_quoted_source_contents();

// Entries in `results` do not 1:1 map to source files, the mapping
Expand All @@ -1233,79 +1234,80 @@ impl<'a> LinkerContext<'a> {
// -->
// Which source index in the generated sourcemap, referred to
// as the "mapping source index" within this function to be distinct.
// A file with an input source map takes one slot per source of that map.
let mut source_id_map: ArrayHashMap<u32, i32> = ArrayHashMap::new();

let source_indices = results.items_source_index();

j.push_static(b"{\n \"version\": 3,\n \"sources\": [");
if !source_indices.is_empty() {
{
let index = source_indices[0];
let path = &sources[index as usize].path;
source_id_map.put_no_clobber(index, 0)?;
let mut next_mapping_source_index: i32 = 0;
for &index in source_indices {
let gop = source_id_map.get_or_put(index)?;
if gop.found_existing {
continue;
}
*gop.value_ptr = next_mapping_source_index;

// Note: the relative path lives in a local owned buffer
// (drops at scope exit).
let mut push_source = |j: &mut StringJoiner, abs_path_or_name: &[u8], is_file: bool| {
let rel_path_storage;
let pretty: &[u8] = if path.is_file() {
rel_path_storage = Self::source_map_relative_path(chunk_abs_dir, path.text)?;
let name: &[u8] = if is_file {
rel_path_storage =
Self::source_map_relative_path(chunk_abs_dir, abs_path_or_name)?;
&rel_path_storage
} else {
path.pretty
abs_path_or_name
};

let mut quote_buf = MutableString::init(pretty.len() + 2)?;
js_printer::quote_for_json(pretty, &mut quote_buf, false)?;
let mut quote_buf = MutableString::init(name.len() + ", ".len() + 2)?;
if next_mapping_source_index > 0 {
quote_buf.append_assume_capacity(b", ");
}
js_printer::quote_for_json(name, &mut quote_buf, false)?;
// `to_default_owned` moves the buffer into the joiner
// (joiner owns it until `done`).
j.push_owned(quote_buf.to_default_owned());
}

let mut next_mapping_source_index: i32 = 1;
for &index in &source_indices[1..] {
let gop = source_id_map.get_or_put(index)?;
if gop.found_existing {
continue;
}

*gop.value_ptr = next_mapping_source_index;
next_mapping_source_index += 1;
Ok::<(), BunError>(())
};

let path = &sources[index as usize].path;

let rel_path_storage;
let pretty: &[u8] = if path.is_file() {
rel_path_storage = Self::source_map_relative_path(chunk_abs_dir, path.text)?;
&rel_path_storage
} else {
path.pretty
};

let mut quote_buf = MutableString::init(pretty.len() + ", ".len() + 2)?;
quote_buf.append_assume_capacity(b", ");
js_printer::quote_for_json(pretty, &mut quote_buf, false)?;
j.push_owned(quote_buf.to_default_owned());
match input_source_maps[index as usize].as_deref() {
None => {
let path = &sources[index as usize].path;
if path.is_file() {
push_source(&mut j, path.text, true)?;
} else {
push_source(&mut j, path.pretty, false)?;
}
}
Some(input_source_map) => {
for source in input_source_map.sources.iter() {
push_source(&mut j, &source.path, source.is_file)?;
}
}
}
}

j.push_static(b"],\n \"sourcesContent\": [");

let source_indices_for_contents = source_id_map.keys();
if !source_indices_for_contents.is_empty() {
j.push_static(b"\n ");
j.push_static(
quoted_source_map_contents[source_indices_for_contents[0] as usize]
.as_deref()
.unwrap_or(b""),
);

for &index in &source_indices_for_contents[1..] {
j.push_static(b",\n ");
j.push_static(
let mut first_content = true;
macro_rules! push_content {
($quoted:expr) => {{
j.push_static(if first_content { b"\n " } else { b",\n " });
first_content = false;
j.push_static($quoted);
}};
}
for &index in source_id_map.keys() {
match input_source_maps[index as usize].as_deref() {
None => push_content!(
quoted_source_map_contents[index as usize]
.as_deref()
.unwrap_or(b""),
);
.unwrap_or(b"")
),
Some(input_source_map) => {
for quoted in input_source_map.quoted_sources_content.iter() {
push_content!(quoted);
}
}
}
}
j.push_static(b"\n ],\n \"mappings\": \"");
Expand Down Expand Up @@ -1345,7 +1347,8 @@ impl<'a> LinkerContext<'a> {
)?;

prev_end_state = chunk.end_state;
prev_end_state.source_index = mapping_source_index;
// Chunk-local source indices count from 0: rebase onto the file's first slot.
prev_end_state.source_index += mapping_source_index;
prev_column_offset = chunk.final_generated_column;

if prev_end_state.generated_line == 0 {
Expand Down Expand Up @@ -1750,6 +1753,10 @@ impl SourceMapData {
if !loader.can_have_source_map() {
return;
}
// The output map carries the input map's `sourcesContent` instead.
if parse_graph.input_files.items_input_source_map()[source_index as usize].is_some() {
return;
}

let source: &Source = &parse_graph.input_files.items_source()[source_index as usize];
// Allocate from the worker's AST allocation state (installed by
Expand Down Expand Up @@ -2430,6 +2437,9 @@ impl<'a> LinkerContext<'a> {
// SAFETY: `self.mangled_props` is not mutated during printing; detached borrow
// outlives only this call (see above).
unsafe { bun_ptr::detach_lifetime_ref(&self.mangled_props) };
let input_source_map: Option<&bun_sourcemap::InputSourceMap> =
parse_graph.input_files.items_input_source_map()[source_index.get() as usize]
.as_deref();

let print_options = js_printer::Options {
bundling: true,
Expand Down Expand Up @@ -2476,6 +2486,7 @@ impl<'a> LinkerContext<'a> {
require_or_import_meta_for_source_callback:
js_printer::RequireOrImportMetaCallback::init(self),
line_offset_tables: Some(line_offset_table),
input_source_map,
target: self.options.target,

hmr_ref: if self.options.output_format == Format::InternalBakeDev {
Expand Down
26 changes: 25 additions & 1 deletion src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ pub(crate) struct Success {

/// The package name from package.json, used for barrel optimization.
pub(crate) package_name: ast::StoreStr,

/// See `InputFile::input_source_map`, where this moves on completion.
pub(crate) input_source_map: Option<Box<bun_sourcemap::InputSourceMap>>,
/// The sidecar map file the source names, loaded or not, for `--watch`.
pub(crate) input_source_map_sidecar: Option<Box<[u8]>>,
}

pub(crate) struct ResultError {
Expand Down Expand Up @@ -835,6 +840,7 @@ pub mod parse_worker {
unique_key_prefix: u64,
unique_key_for_additional_file: &mut FileLoaderHash,
has_any_css_locals: &AtomicU32,
source_mapping_url: &mut Option<bun_ast::Span>,
) -> core::result::Result<JSAst<'static>, AnyError> {
use core::fmt::Write as _;

Expand All @@ -860,7 +866,10 @@ pub mod parse_worker {
// `Cached`/`AlreadyBundled` are runtime-loader
// states that never reach the bundler's `getAST`, so unwrap.
match res {
bun_js_parser::Result::Ast(ast) => Ok(JSAst::init(*ast)),
bun_js_parser::Result::Ast(ast) => {
*source_mapping_url = ast.source_mapping_url;
Ok(JSAst::init(*ast))
}
bun_js_parser::Result::Cached
| bun_js_parser::Result::AlreadyBundled(_) => {
unreachable!("bundler parse never yields Cached/AlreadyBundled")
Expand Down Expand Up @@ -2714,8 +2723,12 @@ pub mod parse_worker {
// SAFETY: task.ctx backref valid for the bundle pass (outlives `'r`).
let task_ctx = unsafe { task.ctx() };
let module_type = opts.module_type;
// The dev server's `SourceMapStore` assumes one `sources` entry per file: no input maps there.
let use_input_source_maps =
topts.source_map != options::SourceMapOption::None && !topts.has_dev_server();
// `topts` (a `&BundleOptions`) is dead past this point; the callees take
// raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably.
let mut source_mapping_url: Option<bun_ast::Span> = None;
let ast_result: core::result::Result<JSAst, AnyError> =
if let Some(message) = unsupported_directive {
log.add_range_error(Some(source), UseDirective::range(entry_contents), message);
Expand All @@ -2732,6 +2745,7 @@ pub mod parse_worker {
task_ctx.unique_key,
&mut unique_key_for_additional_file,
&task_ctx.linker.has_any_css_locals,
&mut source_mapping_url,
)
} else if loader.is_css() {
get_empty_css_ast(log, transpiler, opts, bump, source)
Expand Down Expand Up @@ -2777,6 +2791,13 @@ pub mod parse_worker {

*step = Step::Resolve;

let input_source_map = match source_mapping_url {
Some(comment) if use_input_source_maps => {
crate::input_source_map::load(log, source, comment)
}
_ => crate::input_source_map::Loaded::default(),
};

Ok(Success {
ast,
source: source.clone(),
Expand All @@ -2789,6 +2810,9 @@ pub mod parse_worker {

// Hash the files in here so that we do it in parallel.
content_hash_for_additional_file: unique_key_for_additional_file.content_hash,

input_source_map: input_source_map.map,
input_source_map_sidecar: input_source_map.sidecar,
})
}

Expand Down
2 changes: 2 additions & 0 deletions src/bundler/ServerComponentParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ fn task_callback(
unique_key_for_additional_file: bun_ast::StoreStr::EMPTY,
content_hash_for_additional_file: 0,
package_name: bun_ast::StoreStr::EMPTY,
input_source_map: None,
input_source_map_sidecar: None,
})
}

Expand Down
16 changes: 16 additions & 0 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5623,6 +5623,10 @@ pub mod bv2_impl {
// macro takes only one side (`linker.graph.ast` is a bitwise SoA
// `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its
// aliases, so this is the unique drop.
// `InputFile.input_source_map` is likewise global-heap, not arena.
for input_source_map in self.graph.input_files.items_input_source_map_mut() {
drop(input_source_map.take());
}
{
macro_rules! take_ast_cols {
($ast:expr) => {{
Expand Down Expand Up @@ -7871,6 +7875,18 @@ pub mod bv2_impl {

// Record which loader we used for this file
this.graph.input_files.items_loader_mut()[result_source_index] = result.loader;
// A sidecar map changes with its file, often after it. Watch it too.
if let Some(sidecar) = result.input_source_map_sidecar.take()
&& this.bun_watcher.is_some()
&& this.should_add_watcher(&sidecar)
{
let _ = this
.bun_watcher_mut()
.unwrap()
.add_file_by_path_slow(&sidecar);
}
Comment on lines +7879 to +7887

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.

🟡 nit (optional): a bun build --watch user whose input's sidecar .map does not exist yet at parse time never gets a rebuild when it is written; the output map keeps pointing at the intermediate file. At src/bundler/bundle_v2.rs:7886 the add_file_by_path_slow result is discarded, and that call fails for a missing path (macOS open at src/watcher/Watcher.rs:853-856, Linux watch_path at src/watcher/Watcher.rs:581), so the later creation of the .map is not observed. Fix: make a sidecar that is absent at parse time still trigger a rebuild when it appears, e.g. let the directory watch react to the registered sidecar basename, or re-stat each sidecar before reusing a build.

Why this was flagged

A tool writes x.js and then x.js.map, or a user turns on the upstream tool's source maps while bun build --watch --sourcemap is running, so the sidecar does not exist when bun's rebuild parses x.js. src/bundler/input_source_map.rs:77 records the sidecar path and src/bundler/input_source_map.rs:93-100 returns None on ENOENT, so Success.input_source_map_sidecar is set and src/bundler/bundle_v2.rs:7879-7887 calls add_file_by_path_slow with let _ =. On macOS bun_sys::open of the missing path fails and the function returns false at src/watcher/Watcher.rs:853-856; on Linux self.platform.watch_path(slice)? at src/watcher/Watcher.rs:581 fails with ENOENT and src/watcher/Watcher.rs:871-876 returns false. Only the parent directory watch survives (src/watcher/Watcher.rs:734-748), and the reloader's directory handler skips names whose extension has no loader (.map falls to Loader::File, src/jsc/hot_reloader.rs:1136-1145), so the .map write never enqueues a reload. The user keeps an output map whose sources name the intermediate file until some other watched file changes.

Verification: src/bundler/bundle_v2.rs:7883-7886 then does let _ = this.bun_watcher_mut().unwrap().add_file_by_path_slow(&sidecar); discarding the result. That call fails for a missing path: on macOS/FreeBSD src/watcher/Watcher.rs:853-856 bun_sys::open(z, WATCH_OPEN_FLAGS, 0) → Err(_) => return false; on Linux src/watcher/Watcher.rs:581 self.platform.watch_path(slice)? → Err, so no watchlist entry is appended.

this.graph.input_files.items_input_source_map_mut()[result_source_index] =
result.input_source_map.take();

bun_core::scoped_log!(
Bundle,
Expand Down
Loading
Loading