Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cec6058
bundler: hash the output path and the source map into the chunk hash,…
robobun Oct 28, 2026
8ccaddf
sourcemap: run the finalize unit test under miri
robobun Oct 28, 2026
7288893
test: compare two builds instead of pinning the hash of a build with …
robobun Oct 28, 2026
bdd4f62
test: re-pin the chunk hashes, etags and debug IDs that the chunk has…
robobun Oct 28, 2026
701c882
test: compare the in-memory and outdir builds without their hashes
robobun Oct 28, 2026
b0e3918
bundler: sum the closure so chunk order does not reach the hash, hash…
robobun Oct 28, 2026
9e998fd
test: cover chunk order, the source map mode, the debug ID of each ch…
robobun Oct 28, 2026
d160ef4
test: re-pin the chunk hashes, etags and debug IDs for the closure su…
robobun Oct 28, 2026
0814454
docs: a source map edit also changes the hash of the bundles that imp…
robobun Oct 28, 2026
306a123
Merge remote-tracking branch 'origin/main' into robobun/ba5545df/chun…
robobun Oct 29, 2026
b39c37f
test: check that the hash of a build with a source map is set, stable…
robobun Oct 29, 2026
6f26ef2
bundler: change two lines of the final_chunk_hashes doc comment, not …
robobun Oct 29, 2026
2d183e4
bundler: delete the source_map field of CompileResult::Css, which not…
robobun Oct 29, 2026
672c3f8
Merge remote-tracking branch 'origin/main' into robobun/ba5545df/chun…
robobun Oct 29, 2026
3da96bb
Merge remote-tracking branch 'origin/main' into robobun/ba5545df/chun…
robobun Oct 29, 2026
864f707
ci: retrigger
robobun Oct 30, 2026
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
2 changes: 1 addition & 1 deletion docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,7 @@ The `naming` field customizes the names and locations of the generated files. It

- `[name]` - The name of the entrypoint file, without the extension.
- `[ext]` - The extension of the generated bundle.
- `[hash]` - A hash of the bundle contents: 8 lowercase alphanumeric characters. If two outputs with different contents would print the same characters, both get enough extra characters to differ (as do the outputs that reference them). `[hash9]` through `[hash13]` set a wider minimum; `[hash13]` is the full 64-bit hash.
- `[hash]` - A hash of the bundle contents: 8 lowercase alphanumeric characters. With a [`sourcemap`](#sourcemap), the contents include the source map, so an edit that changes only the map (a comment, for example) also changes the hash of that bundle and of every bundle that imports it. If two outputs with different contents would print the same characters, both get enough extra characters to differ (as do the outputs that reference them). `[hash9]` through `[hash13]` set a wider minimum; `[hash13]` is the full 64-bit hash.
- `[dir]` - The relative path from the project root to the parent directory of the source file.

For example:
Expand Down
1 change: 1 addition & 0 deletions scripts/rust-miri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const MIRI_CRATES = [
"bun_resolve_builtins",
"bun_semver",
"bun_shell_parser",
"bun_sourcemap",
"bun_threading",
"bun_url",
"bun_wyhash",
Expand Down
32 changes: 11 additions & 21 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ impl<'a> Chunk<'a> {
.unwrap_or_else(|| bun_core::fmt::ContentHash::short(self.isolated_hash))
}

/// Names this chunk and its source map. It covers what the final content hash covers.
pub(crate) fn debug_id(&self) -> source_map::DebugIDFormatter {
source_map::DebugIDFormatter {
id: self.id().value,
}
}

/// The chunks reachable from chunk `start` through cross-chunk imports of the given kinds, `start` first.
pub(crate) fn reachable_chunks(
chunks: &[Chunk],
Expand Down Expand Up @@ -824,12 +831,7 @@ impl IntermediateOutput {

let debug_id_len = if ENABLE_SOURCE_MAP_SHIFTS && FeatureFlags::SOURCE_MAP_DEBUG_ID
{
bun_core::fmt::count(format_args!(
"\n//# debugId={}\n",
source_map::DebugIDFormatter {
id: chunk.isolated_hash
}
))
bun_core::fmt::count(format_args!("\n//# debugId={}\n", chunk.debug_id()))
} else {
0
};
Expand Down Expand Up @@ -1036,14 +1038,8 @@ impl IntermediateOutput {
// This comment must go before the //# sourceMappingURL comment
let mut cursor: &mut [u8] = remain;
let before_len = cursor.len();
write!(
&mut cursor,
"\n//# debugId={}\n",
source_map::DebugIDFormatter {
id: chunk.isolated_hash
}
)
.unwrap_or_else(|_| panic!("unexpected NoSpaceLeft error from bufPrint"));
write!(&mut cursor, "\n//# debugId={}\n", chunk.debug_id())
.unwrap_or_else(|_| panic!("unexpected NoSpaceLeft error from bufPrint"));
let written = before_len - cursor.len();
remain = &mut remain[written..];
}
Expand Down Expand Up @@ -1075,13 +1071,7 @@ impl IntermediateOutput {
if ENABLE_SOURCE_MAP_SHIFTS && FeatureFlags::SOURCE_MAP_DEBUG_ID {
// This comment must go before the //# sourceMappingURL comment
let mut debug_id_fmt = Vec::new();
let _ = write!(
&mut debug_id_fmt,
"\n//# debugId={}\n",
source_map::DebugIDFormatter {
id: chunk.isolated_hash
}
);
let _ = write!(&mut debug_id_fmt, "\n//# debugId={}\n", chunk.debug_id());

let _ = arena; // Note: StringJoiner::done* allocates from global mimalloc; arena token is plumbing-only.
break 'brk joiner.done_with_end(&debug_id_fmt)?;
Expand Down
61 changes: 40 additions & 21 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ use bun_ast::{Data, Loc, Log, Range, Source};
use bun_collections::{ArrayHashMap, AutoBitSet, HashMap, MultiArrayList, VecExt, index_sort};
use bun_core::{self as bun, FeatureFlags, Output};
use bun_core::{MutableString, string_joiner::StringJoiner, strings};
use bun_sourcemap::{
self as SourceMap, DebugIDFormatter, LineOffsetTable, SourceMapPieces, SourceMapState,
};
use bun_sourcemap::{self as SourceMap, LineOffsetTable, SourceMapPieces, SourceMapState};
// Note: alias the *module* (not the `ThreadPool` struct) so
// `ThreadPoolLib::Task` / `ThreadPoolLib::Batch` resolve as nested items.
use crate::bake_types as bake;
Expand Down Expand Up @@ -1208,7 +1206,6 @@ impl<'a> LinkerContext<'a> {

pub(crate) fn generate_source_map_for_chunk(
&mut self,
isolated_hash: u64,
_worker: &mut crate::thread_pool::Worker,
results: &MultiArrayList<CompileResultForSourceMap<'_>>,
chunk_abs_dir: &[u8],
Expand Down Expand Up @@ -1352,17 +1349,7 @@ impl<'a> LinkerContext<'a> {
}
let mapping_end = j.len;

if FeatureFlags::SOURCE_MAP_DEBUG_ID {
j.push_static(b"\",\n \"debugId\": \"");
let mut buf = Vec::<u8>::new();
use std::io::Write;
write!(&mut buf, "{}", DebugIDFormatter { id: isolated_hash })
.expect("infallible: in-memory write");
j.push_owned(buf.into_boxed_slice());
j.push_static(b"\",\n \"names\": []\n}");
} else {
j.push_static(b"\",\n \"names\": []\n}");
}
SourceMapPieces::push_suffix(&mut j);

let done = j.done()?;
debug_assert!(done[0] == b'{');
Expand Down Expand Up @@ -1924,6 +1911,11 @@ pub(crate) unsafe fn pending_part_range_prologue<'c, 'a>(
}

impl<'a> LinkerContext<'a> {
/// The `PathTemplate::print` argument for a chunk path, the same for its hash and its final path.
pub(crate) fn sanitize_parent_dirs(&self) -> bool {
!self.options.compile_mode.is_executable()
}

pub(crate) fn generate_isolated_hash(&mut self, chunk: &Chunk, arena: &'a Bump) -> u64 {
let _trace = bun::perf::trace("Bundler.generateIsolatedHash");

Expand Down Expand Up @@ -1978,6 +1970,10 @@ impl<'a> LinkerContext<'a> {
// any import to be considered different if the import's output path has changed.
hasher.write(&chunk.template.data);

// esbuild's template has `[dir]`, `[name]` and `[ext]` filled in by now. Ours prints them here.
debug_assert!(chunk.template.placeholder.hash.is_none());
hasher.write_path(&chunk.template, self.sanitize_parent_dirs());

let public_path: &[u8] = if chunk
.flags
.contains(crate::chunk::Flags::IS_BROWSER_CHUNK_FROM_SERVER_BUILD)
Expand Down Expand Up @@ -2009,8 +2005,19 @@ impl<'a> LinkerContext<'a> {
// data in the spans between them.
match &chunk.intermediate_output {
crate::chunk::IntermediateOutput::Pieces(pieces) => {
// Standalone HTML prints the bytes of an asset here, not its path.
let inlined_assets = self.options.compile_mode.is_standalone_html().then(|| {
self.parse_graph()
.input_files
.items_content_hash_for_additional_file()
});
for piece in pieces.slice() {
hasher.write(piece.data());
if let Some(content_hashes) = inlined_assets
&& piece.query.kind() == crate::chunk::QueryKind::Asset
{
hasher.write(&content_hashes[piece.query.index() as usize].to_ne_bytes());
}
}
}
crate::chunk::IntermediateOutput::Joiner(joiner) => {
Expand Down Expand Up @@ -2041,6 +2048,9 @@ impl<'a> LinkerContext<'a> {
hasher.write(&chunk.output_source_map.mappings);
hasher.write(&chunk.output_source_map.suffix);

// The mode decides what follows the code: a debug ID, a link to the map, or the map itself.
hasher.write(&[chunk.content.sourcemap(self.options.source_maps) as u8]);

hasher.digest()
}

Expand Down Expand Up @@ -2580,11 +2590,11 @@ impl<'a> LinkerContext<'a> {
}

/// Each chunk's final content hash: its own isolated hash (plus the
/// asset paths its output pieces reference) and that of every chunk it
/// asset paths and chunks its output pieces name) and that of every chunk it
/// transitively reaches through cross-chunk imports or output pieces, so a
/// change anywhere below a chunk renames it. Cycles are fine: reachability
/// is a fixpoint over bitsets, and each chunk digests its own hash first,
/// then the rest of its closure in chunk order (so two chunks that reach
/// then the sum of the rest of its closure (so two chunks that reach
/// each other still differ).
///
/// The width is the template's (`[hash]` = 8, `[hashN]` = N) unless two
Expand Down Expand Up @@ -2636,13 +2646,19 @@ impl<'a> LinkerContext<'a> {
}
}
crate::chunk::QueryKind::Chunk | crate::chunk::QueryKind::ChunkId => {
out.push(piece.query.index())
let chunk_index = piece.query.index();
out.push(chunk_index);
// Two pieces can trade chunks and keep the closure. esbuild keeps the name then.
hash.write(&chunks[chunk_index as usize].isolated_hash.to_ne_bytes());
}
crate::chunk::QueryKind::Scb => {
let chunk_index = self.graph.files.items_entry_point_chunk_index()
[piece.query.index() as usize];
if chunk_index != u32::MAX {
out.push(chunk_index);
hash.write(
&chunks[chunk_index as usize].isolated_hash.to_ne_bytes(),
);
}
}
crate::chunk::QueryKind::None | crate::chunk::QueryKind::HtmlImport => {}
Comment thread
robobun marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -2682,14 +2698,17 @@ impl<'a> LinkerContext<'a> {
.iter()
.enumerate()
.map(|(i, bits)| {
let mut hash = ContentHasher::default();
hash.write(&own[i].to_ne_bytes());
// A sum: one more entry point can put the same chunks in another order.
let mut rest: u64 = 0;
let mut iter = bits.iterator::<true, true>();
while let Some(j) = iter.next() {
if j != i {
hash.write(&own[j].to_ne_bytes());
rest = rest.wrapping_add(own[j]);
}
}
let mut hash = ContentHasher::default();
hash.write(&own[i].to_ne_bytes());
hash.write(&rest.to_ne_bytes());
hash.digest()
})
.collect();
Expand Down
44 changes: 41 additions & 3 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8042,7 +8042,6 @@ pub mod bv2_impl {
Css {
result: crate::Result<Box<[u8]>>,
source_index: IndexInt,
source_map: Option<bun_sourcemap::Chunk>,
},
Html {
source_index: IndexInt,
Expand Down Expand Up @@ -8091,8 +8090,7 @@ pub mod bv2_impl {
bun_js_printer::PrintResult::Result(r) => r.source_map.as_ref(),
bun_js_printer::PrintResult::Err(_) => None,
},
CompileResult::Css { source_map, .. } => source_map.as_ref(),
CompileResult::Html { .. } => None,
CompileResult::Css { .. } | CompileResult::Html { .. } => None,
}
}
}
Expand Down Expand Up @@ -8149,6 +8147,46 @@ pub mod bv2_impl {
bun_core::scoped_log!(ContentHasher, "HASH_UPDATE: {:?}\n", i);
self.hasher.update(bun_core::slice_as_bytes(i));
}
/// Hashes the path `template` prints with `/` separators. A template without its hash prints no `[hash]`.
pub(crate) fn write_path(
&mut self,
template: &options::PathTemplate,
sanitize_parent_dirs: bool,
) {
struct Path<'a> {
hasher: &'a mut bun_hash::XxHash64Streaming,
len: u64,
}
impl bun_io::Write for Path<'_> {
fn write_all(&mut self, bytes: &[u8]) -> bun_io::Result<()> {
bun_core::scoped_log!(
ContentHasher,
"HASH_UPDATE path: {}\n",
bstr::BStr::new(bytes)
);
self.len += bytes.len() as u64;
let mut remain = bytes;
if bun_paths::SEP != b'/' {
while let Some(i) = strings::index_of_char_usize(remain, bun_paths::SEP) {
self.hasher.update(&remain[..i]);
self.hasher.update(b"/");
remain = &remain[i + 1..];
}
}
self.hasher.update(remain);
Ok(())
}
}
let mut path = Path {
hasher: &mut self.hasher,
len: 0,
};
template
.print(&mut path, sanitize_parent_dirs)
.expect("infallible: a hasher takes any bytes");
let len = path.len;
self.hasher.update(&len.to_ne_bytes());
}
pub(crate) fn digest(&self) -> u64 {
self.hasher.digest()
}
Expand Down
14 changes: 9 additions & 5 deletions src/bundler/linker_context/generateChunksInParallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(
// runtime bunfs references to out-of-root entrypoints resolve.
chunk
.template
.print(&mut rel_path, !c.options.compile_mode.is_executable())
.print(&mut rel_path, c.sanitize_parent_dirs())
.expect("write to Vec<u8>");
path::resolve_path::platform_to_posix_in_place::<u8>(&mut rel_path);

Expand Down Expand Up @@ -764,9 +764,10 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(

match sourcemap_option {
tag @ (SourceMapOption::External | SourceMapOption::Linked) => {
let debug_id = chunks[ci].debug_id();
let output_source_map = chunks[ci]
.output_source_map
.finalize(&code_result.shifts)
.finalize(&code_result.shifts, debug_id)
.expect("Failed to allocate memory for external source map");

if tag == SourceMapOption::Linked {
Expand Down Expand Up @@ -831,9 +832,10 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(
standalone_sourcemaps[ci] = Some(output_source_map);
}
SourceMapOption::Inline => {
let debug_id = chunks[ci].debug_id();
let output_source_map = chunks[ci]
.output_source_map
.finalize(&code_result.shifts)
.finalize(&code_result.shifts, debug_id)
.expect("Failed to allocate memory for inline source map");
let encode_len = bun_base64::encode_len(&output_source_map);

Expand Down Expand Up @@ -1018,9 +1020,10 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(

match chunk.content.sourcemap(c.options.source_maps) {
tag @ (SourceMapOption::External | SourceMapOption::Linked) => {
let debug_id = chunk.debug_id();
let output_source_map = chunk
.output_source_map
.finalize(&code_result.shifts)
.finalize(&code_result.shifts, debug_id)
.expect("Failed to allocate memory for external source map");
let mut source_map_final_rel_path: Vec<u8> =
Vec::with_capacity(chunk.final_rel_path.len() + b".map".len());
Expand Down Expand Up @@ -1068,9 +1071,10 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>(
}));
}
SourceMapOption::Inline => {
let debug_id = chunk.debug_id();
let output_source_map = chunk
.output_source_map
.finalize(&code_result.shifts)
.finalize(&code_result.shifts, debug_id)
.expect("Failed to allocate memory for external source map");
let encode_len = bun_base64::encode_len(&output_source_map);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,12 @@ fn generate_compile_result_for_css_chunk_impl(
return CompileResult::Css {
result: Err(crate::Error::PrintError),
source_index: Index::INVALID.get(),
source_map: None,
};
}
}
CompileResult::Css {
result: Ok(allocating_writer.into_boxed_slice()),
source_index: Index::INVALID.get(),
source_map: None,
}
}
CssImportOrderKind::SourceIndex(idx) => {
Expand Down Expand Up @@ -148,7 +146,6 @@ fn generate_compile_result_for_css_chunk_impl(
return CompileResult::Css {
result: Err(crate::Error::PrintError),
source_index: idx.get(),
source_map: None,
};
}
}
Expand All @@ -166,7 +163,6 @@ fn generate_compile_result_for_css_chunk_impl(
CompileResult::Css {
result: Ok(output),
source_index: idx.get(),
source_map: None,
}
}
}
Expand Down
Loading
Loading