From 580937e37260fe1d09f4966d6b63ca7e825c1c4f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:21:43 +0000 Subject: [PATCH 01/12] bundler: emit each HTML import manifest when chunks are hashed, so [hash] covers it A chunk that prints an HTML import manifest kept its [hash] name and its BuildArtifact.hash while the manifest inside it changed. final_chunk_hashes added nothing for an HtmlImport piece. The manifest is now emitted once per page when the chunks are hashed, as a template: the JSON bytes that are final at that point, and a reference for each listed chunk's path and etag. final_chunk_hashes hashes the bytes, writes the isolated hash of the chunk that each reference names, and adds an edge for each reference. When the chunks have their names, the template is rendered once. The printer copies that rendering in both of its passes, and --compile --aot reads the same JSON. A chunk that prints a manifest reaches the chunks of several pages, and their order is not the same in every build of one tree. Such a chunk, and each chunk that reaches it, digests the rest of its closure as a sum, so its name does not depend on that order. --- src/bundler/Chunk.rs | 25 +- src/bundler/Graph.rs | 12 + src/bundler/HTMLImportManifest.rs | 347 ++++++------- src/bundler/LinkerContext.rs | 69 ++- src/bundler/aot/page_bundles.rs | 21 +- src/bundler/lib.rs | 1 - .../generateChunksInParallel.rs | 8 +- test/bundler/html-import-manifest.test.ts | 468 +++++++++++++++++- 8 files changed, 731 insertions(+), 220 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index e5ec3cb1d1dd..1b0776b7ee7a 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -24,7 +24,6 @@ use crate::bun_fs; use crate::Graph::Graph; #[cfg(debug_assertions)] use crate::LinkerContext; -use crate::html_import_manifest as HTMLImportManifest; use crate::options::{self, Loader}; use crate::{ AdditionalFile, CompileResult, LinkerGraph, PartRange, PathTemplate, cheap_prefix_normalizer, @@ -778,15 +777,7 @@ impl IntermediateOutput { } QueryKind::HtmlImport => { - count += bun_core::fmt::count(format_args!( - "{}", - HTMLImportManifest::format_escaped_json( - piece.query.index(), - graph, - chunks, - linker_graph, - ) - )); + count += graph.html_imports.printed_manifest(index).len(); continue; } QueryKind::None | QueryKind::ChunkId => unreachable!(), @@ -960,17 +951,9 @@ impl IntermediateOutput { } QueryKind::HtmlImport => { - let mut cursor: &mut [u8] = remain; - let before_len = cursor.len(); - HTMLImportManifest::write_escaped_json( - piece.query.index(), - graph, - linker_graph, - chunks, - &mut cursor, - ) - .expect("unreachable"); - let written = before_len - cursor.len(); + let manifest = graph.html_imports.printed_manifest(index); + let written = manifest.len(); + remain[..written].copy_from_slice(manifest); if ENABLE_SOURCE_MAP_SHIFTS { // The placeholder was an HtmlImport unique key, which has diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 4a944ff156f2..73124c363afb 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -99,6 +99,18 @@ pub struct HtmlImports { pub(crate) server_source_indices: Vec, /// Source index of the HTML file being imported pub(crate) html_source_indices: Vec, + /// The manifest of each page that a chunk prints. Set once the chunks are named. + pub(crate) manifests: Vec>, +} + +impl HtmlImports { + /// What a chunk prints for the page at `index`. + pub(crate) fn printed_manifest(&self, index: usize) -> &[u8] { + &self.manifests[index] + .as_ref() + .expect("a chunk is named before it is printed") + .escaped + } } pub struct InputFile<'a> { diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 1cf92b1c7cae..0201b3abcbe7 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -34,52 +34,154 @@ //! that gets embedded directly into the JavaScript output. use crate::mal_prelude::*; -use core::fmt; use bun_ast::Source; use bun_collections::AutoBitSet; use bun_collections::VecExt; use bun_core::strings; -use bun_io::{FmtAdapter, Write}; +use bun_io::Write; use bun_js_printer::Encoding; use bun_paths::resolve_path::{platform, platform_to_posix_in_place, relative_normalized}; use bun_resolver::fs::FileSystem; use crate::Graph::Graph; use crate::chunk::Content; -use crate::options::{Loader, OutputFile, OutputKind}; +use crate::options::{Loader, OutputKind}; use crate::options_impl::LoaderExt as _; use crate::{BundleV2, Chunk, LinkerGraph}; +/// A page's manifest before the chunks are named: `json` is every byte except the +/// listed chunks' paths and etags, and `refs` says where those go. +pub(crate) struct Template { + json: Vec, + /// In `at` order. + refs: Vec, + /// `--compile`: the server-side public path, printed before each chunk's path. + public_path: Option>, +} + +struct ChunkRef { + /// The offset in `json` the value is printed at. + at: u32, + chunk: u32, + value: ChunkValue, +} + +impl ChunkRef { + /// A value of `chunk` that is printed where `json` ends now. + fn at_end_of(json: &[u8], chunk: u32, value: ChunkValue) -> ChunkRef { + ChunkRef { + at: json.len() as u32, + chunk, + value, + } + } +} + +/// What a manifest prints of a listed chunk. #[derive(Clone, Copy)] -pub struct HTMLImportManifest<'a> { - pub(crate) index: u32, - pub(crate) graph: &'a Graph<'a>, - pub(crate) chunks: &'a [Chunk<'a>], - pub(crate) linker_graph: &'a LinkerGraph<'a>, +pub(crate) enum ChunkValue { + /// Its final path, as a JSON string. + Path, + /// The `"etag"` header of its final hash. + Etag, } -impl<'a> fmt::Display for HTMLImportManifest<'a> { - fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut adapter = FmtAdapter::new(writer); - match write( - self.index, - self.graph, - self.linker_graph, - self.chunks, - &mut adapter, - ) { - Ok(()) => Ok(()), - Err(_) => Err(fmt::Error), +/// One part of a manifest. +pub(crate) enum Part<'a> { + /// Bytes that are final before the chunks are named. + Literal(&'a [u8]), + /// A value of the chunk at this index. + Chunk(u32, ChunkValue), +} + +impl Template { + /// The manifest in print order. `final_chunk_hashes` and `resolve` both read it + /// from here: what one prints, the other hashed or followed as an edge. + pub(crate) fn parts(&self) -> impl Iterator> { + let mut literal_start = 0; + let tail = self.refs.last().map_or(0, |last| last.at as usize); + self.refs + .iter() + .flat_map(move |chunk_ref| { + let literal = &self.json[literal_start..chunk_ref.at as usize]; + literal_start = chunk_ref.at as usize; + [ + Part::Literal(literal), + Part::Chunk(chunk_ref.chunk, chunk_ref.value), + ] + }) + .chain(core::iter::once(Part::Literal(&self.json[tail..]))) + } + + fn resolve(&self, chunks: &[Chunk], writer: &mut Vec) -> Result<(), crate::Error> { + let mut prefixed: Vec = Vec::new(); + for part in self.parts() { + match part { + Part::Literal(bytes) => writer.write_all(bytes)?, + Part::Chunk(chunk, ChunkValue::Path) => { + let mut path: &[u8] = &chunks[chunk as usize].final_rel_path; + if let Some(public_path) = &self.public_path { + prefixed.clear(); + prefixed.extend_from_slice(public_path); + prefixed.extend_from_slice(strings::remove_leading_dot_slash(path)); + path = &prefixed; + } + bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>(path, writer)?; + } + Part::Chunk(chunk, ChunkValue::Etag) => { + let chunk = &chunks[chunk as usize]; + // The HTML chunk's body embeds the hashed paths of its JS/CSS + // chunks, so its etag must change when those do. `isolated_hash` + // by design excludes those substitutions; the placeholder hash + // folds them in (`final_chunk_hashes`). + let hash = chunk.template.placeholder.hash; + write_etag(writer, hash.map_or(chunk.isolated_hash, |h| h.value))?; + } + } } + Ok(()) + } + + /// The chunks are named: prints the manifest. + pub(crate) fn render(self, chunks: &[Chunk]) -> Result { + // Each is at least as long as what it is made from. + let mut json: Vec = Vec::with_capacity(self.json.len()); + self.resolve(chunks, &mut json)?; + // Extremely unfortunate, but necessary due to E.String not accepting pre-escaped input and this happening at the very end. + let mut escaped: Vec = Vec::with_capacity(json.len()); + bun_js_printer::write_pre_quoted_string::<_, b'"', false, true, { Encoding::Utf8 }>( + &json, + &mut escaped, + )?; + Ok(Manifest { json, escaped }) } } -fn write_entry_item( - writer: &mut W, +/// A page's manifest once the chunks are named. +pub(crate) struct Manifest { + /// What `--compile --aot` embeds as data. + pub(crate) json: Vec, + /// `json` as the contents of a double-quoted JavaScript string, which is what a chunk prints. + pub(crate) escaped: Vec, +} + +/// Where an entry's path and etag come from. +#[derive(Clone, Copy)] +enum Entry<'a> { + /// From the chunk at this index, once the chunks are named. + Chunk(u32), + Asset { + path: &'a [u8], + hash: u64, + }, +} + +fn write_entry_item( + writer: &mut Vec, + refs: &mut Vec, input: &[u8], - path: &[u8], - hash: u64, + entry: Entry<'_>, loader: Loader, kind: OutputKind, ) -> Result<(), crate::Error> { @@ -92,7 +194,12 @@ fn write_entry_item( } writer.write_all(b"\"path\":")?; - bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>(path, writer)?; + match entry { + Entry::Chunk(chunk) => refs.push(ChunkRef::at_end_of(writer, chunk, ChunkValue::Path)), + Entry::Asset { path, .. } => { + bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>(path, writer)? + } + } writer.write_all(b",\"loader\":\"")?; // strum is configured snake_case, so this prints the lowercase tag name. @@ -105,19 +212,17 @@ fn write_entry_item( })?; writer.write_all(b",\"headers\":{")?; - if hash > 0 { - const BASE64_BUF_LEN: usize = - bun_base64::encode_len_from_size(core::mem::size_of::()) + 2; - let mut base64_buf = [0u8; BASE64_BUF_LEN]; - let n = bun_base64::encode_url_safe(&mut base64_buf, &hash.to_ne_bytes()); - let base64 = &base64_buf[..n]; - writer.write_all(b"\"etag\":\"")?; - writer.write_all(base64)?; - writer.write_all(b"\",")?; + match entry { + Entry::Chunk(chunk) => refs.push(ChunkRef::at_end_of(writer, chunk, ChunkValue::Etag)), + Entry::Asset { hash, .. } => write_etag(writer, hash)?, } // Valid mime types are valid headers, which do not need to be escaped in JSON. - let mime = loader.to_mime_type(&[path]); + let mime = match entry { + // A chunk's loader decides its content type. + Entry::Chunk(_) => loader.to_mime_type(&[]), + Entry::Asset { path, .. } => loader.to_mime_type(&[path]), + }; writer.write_all(b"\"content-type\":\"")?; writer.write_all(&mime.value)?; writer.write_all(b"\"")?; @@ -126,72 +231,31 @@ fn write_entry_item( Ok(()) } -// Extremely unfortunate, but necessary due to E.String not accepting pre-escaped input and this happening at the very end. -pub(crate) fn write_escaped_json( - index: u32, - graph: &Graph, - linker_graph: &LinkerGraph<'_>, - chunks: &[Chunk], - writer: &mut W, -) -> Result<(), crate::Error> { - let mut bytes: Vec = Vec::new(); - write(index, graph, linker_graph, chunks, &mut bytes)?; - bun_js_printer::write_pre_quoted_string::<_, b'"', false, true, { Encoding::Utf8 }>( - &bytes, writer, - )?; - Ok(()) -} - -/// Newtype wrapper produced by [`HTMLImportManifest::format_escaped_json`]. -pub struct EscapedJson<'a>(pub HTMLImportManifest<'a>); - -impl<'a> fmt::Display for EscapedJson<'a> { - fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut adapter = FmtAdapter::new(writer); - match write_escaped_json( - self.0.index, - self.0.graph, - self.0.linker_graph, - self.0.chunks, - &mut adapter, - ) { - Ok(()) => Ok(()), - Err(_) => Err(fmt::Error), - } - } -} - -impl<'a> HTMLImportManifest<'a> { - pub(crate) fn format_escaped_json(self) -> EscapedJson<'a> { - EscapedJson(self) +fn write_etag(writer: &mut Vec, hash: u64) -> Result<(), crate::Error> { + if hash > 0 { + const BASE64_BUF_LEN: usize = + bun_base64::encode_len_from_size(core::mem::size_of::()) + 2; + let mut base64_buf = [0u8; BASE64_BUF_LEN]; + let n = bun_base64::encode_url_safe(&mut base64_buf, &hash.to_ne_bytes()); + let base64 = &base64_buf[..n]; + writer.write_all(b"\"etag\":\"")?; + writer.write_all(base64)?; + writer.write_all(b"\",")?; } + Ok(()) } -pub(crate) fn write( - index: u32, - graph: &Graph, - linker_graph: &LinkerGraph<'_>, - chunks: &[Chunk], - writer: &mut W, -) -> Result<(), crate::Error> { - write_with_assets( - index, - graph, - linker_graph, - chunks, - &graph.additional_output_files, - writer, - ) -} - -pub(crate) fn write_with_assets( +/// Walks what page `index` lists. This runs when the chunks are hashed, so what a +/// chunk's final hash decides is left as a reference to that chunk. +pub(crate) fn emit( index: u32, graph: &Graph, linker_graph: &LinkerGraph<'_>, chunks: &[Chunk], - assets: &[OutputFile], - writer: &mut W, -) -> Result<(), crate::Error> { +) -> Result { + let mut json: Vec = Vec::new(); + let mut refs: Vec = Vec::new(); + let writer = &mut json; let browser_source_index = graph.html_imports.html_source_indices.slice()[index as usize]; let server_source_index = graph.html_imports.server_source_indices.slice()[index as usize]; let sources: &[Source] = graph.input_files.items_source(); @@ -227,21 +291,11 @@ pub(crate) fn write_with_assets( if matches!(ch.content, Content::Html) { writer.write_all(b"\"index\":")?; - if inject_compiler_filesystem_prefix { - temp_buffer.clear(); - temp_buffer.extend_from_slice(public_path); - temp_buffer - .extend_from_slice(strings::remove_leading_dot_slash(&ch.final_rel_path)); - bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>( - &temp_buffer, - writer, - )?; - } else { - bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>( - &ch.final_rel_path[..], - writer, - )?; - } + refs.push(ChunkRef::at_end_of( + writer, + chunk_index as u32, + ChunkValue::Path, + )); writer.write_all(b",")?; } } @@ -274,7 +328,7 @@ pub(crate) fn write_with_assets( let mut first = true; - let additional_output_files = assets; + let additional_output_files = &graph.additional_output_files; let file_entry_bits: &[AutoBitSet] = linker_graph.files.items_entry_bits(); let mut already_visited_output_file = AutoBitSet::init_empty(additional_output_files.len())?; @@ -295,28 +349,11 @@ pub(crate) fn write_with_assets( ) }; - let path: &[u8] = if inject_compiler_filesystem_prefix { - temp_buffer.clear(); - temp_buffer.extend_from_slice(public_path); - temp_buffer - .extend_from_slice(strings::remove_leading_dot_slash(&ch.final_rel_path)); - &temp_buffer[..] - } else { - &ch.final_rel_path - }; - write_entry_item( writer, + &mut refs, input, - path, - // The HTML chunk's body embeds the hashed paths of its JS/CSS - // chunks, so its etag must change when those do. `isolated_hash` - // by design excludes those substitutions; the placeholder hash - // folds them in via `appendIsolatedHashesForImportedChunks`. - ch.template - .placeholder - .hash - .map_or(ch.isolated_hash, |h| h.value), + Entry::Chunk(chunk_index as u32), ch.content.loader(), if ch.entry_point.is_entry_point() { OutputKind::EntryPoint @@ -365,9 +402,12 @@ pub(crate) fn write_with_assets( write_entry_item( writer, + &mut refs, path_for_key, - path, - output_file.hash.value, + Entry::Asset { + path, + hash: output_file.hash.value, + }, output_file.loader, output_file.output_kind, )?; @@ -376,46 +416,11 @@ pub(crate) fn write_with_assets( } writer.write_all(b"]}")?; - Ok(()) -} - -pub mod html_import_manifest { - use crate::Graph::Graph; - use crate::{LinkerGraph, chunk::Chunk}; - - pub use super::{EscapedJson, HTMLImportManifest}; - - #[inline] - pub(crate) fn format_escaped_json<'a>( - index: u32, - graph: &'a Graph, - chunks: &'a [Chunk], - linker_graph: &'a LinkerGraph, - ) -> EscapedJson<'a> { - super::HTMLImportManifest { - index, - graph, - chunks, - linker_graph, - } - .format_escaped_json() - } - - pub(crate) fn write_escaped_json( - index: u32, - graph: &Graph, - linker_graph: &LinkerGraph<'_>, - chunks: &[Chunk], - w: &mut &mut [u8], - ) -> Result<(), core::fmt::Error> { - let taken = core::mem::take(w); - let mut fbs = bun_io::FixedBufferStream::new_mut(taken); - super::write_escaped_json(index, graph, linker_graph, chunks, &mut fbs) - .map_err(|_| core::fmt::Error)?; - let bun_io::FixedBufferStream { buffer, pos } = fbs; - *w = &mut buffer[pos..]; - Ok(()) - } + Ok(Template { + json, + refs, + public_path: inject_compiler_filesystem_prefix.then(|| Box::from(public_path)), + }) } /// The manifest's `input` / asset-key form of a source path: root-relative, `/`-separated. diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index f51818fb9c88..6e7930066b13 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2580,14 +2580,31 @@ impl<'a> LinkerContext<'a> { /// widen until they differ, and every chunk that reaches a widened chunk /// (its output embeds that chunk's path or id) has the widths folded into /// its own hash so that its name changes with its bytes. + /// + /// A chunk that prints a page's manifest hashes the manifest's literal bytes + /// and, at each listed chunk, that chunk's isolated hash, and it reaches + /// every listed chunk. Such a chunk reaches the chunks of several pages, + /// whose order is not the same in every build of one tree, so it and the + /// chunks that reach it digest the rest of their closure as a sum. The + /// manifests are emitted here, once per page, and returned for the caller + /// to render once the chunks are named. pub(crate) fn final_chunk_hashes( &self, chunks: &[Chunk], - ) -> Result, AllocError> { + ) -> Result< + ( + Vec, + Vec>, + ), + BunError, + > { let n = chunks.len(); + let mut manifests: Vec> = Vec::new(); + // The chunks that print a manifest. + let mut printers: Vec = Vec::new(); let mut own: Vec = Vec::with_capacity(n); let mut edges: Vec> = Vec::with_capacity(n); - for chunk in chunks { + for (chunk_index, chunk) in chunks.iter().enumerate() { let mut hash = ContentHasher::default(); let mut out: Vec = chunk .cross_chunk_imports @@ -2633,7 +2650,39 @@ impl<'a> LinkerContext<'a> { out.push(chunk_index); } } - crate::chunk::QueryKind::None | crate::chunk::QueryKind::HtmlImport => {} + crate::chunk::QueryKind::HtmlImport => { + use crate::HTMLImportManifest::Part; + let parse_graph = self.parse_graph(); + let index = piece.query.index(); + if manifests.is_empty() { + let pages = parse_graph.html_imports.server_source_indices.len(); + manifests.resize_with(pages, || None); + } + let manifest = match &mut manifests[index as usize] { + Some(manifest) => manifest, + unset => unset.insert(crate::HTMLImportManifest::emit( + index, + parse_graph, + &self.graph, + chunks, + )?), + }; + for part in manifest.parts() { + match part { + Part::Literal(bytes) => hash.write(bytes), + Part::Chunk(listed, _) => { + // Two listed chunks can trade places and keep the closure. + let listed_hash = chunks[listed as usize].isolated_hash; + hash.write(&listed_hash.to_ne_bytes()); + out.push(listed); + } + } + } + if printers.last() != Some(&chunk_index) { + printers.push(chunk_index); + } + } + crate::chunk::QueryKind::None => {} } } } @@ -2672,12 +2721,22 @@ impl<'a> LinkerContext<'a> { .map(|(i, bits)| { let mut hash = ContentHasher::default(); hash.write(&own[i].to_ne_bytes()); + let reaches_manifest = printers.iter().any(|&printer| bits.is_set(printer)); + let mut rest: u64 = 0; let mut iter = bits.iterator::(); while let Some(j) = iter.next() { - if j != i { + if j == i { + continue; + } + if reaches_manifest { + rest = rest.wrapping_add(own[j]); + } else { hash.write(&own[j].to_ne_bytes()); } } + if reaches_manifest { + hash.write(&rest.to_ne_bytes()); + } hash.digest() }) .collect(); @@ -2710,7 +2769,7 @@ impl<'a> LinkerContext<'a> { } } } - Ok(names) + Ok((names, manifests)) } // Sort cross-chunk exports by chunk name for determinism diff --git a/src/bundler/aot/page_bundles.rs b/src/bundler/aot/page_bundles.rs index a3dacae724d8..95fb0b6a6127 100644 --- a/src/bundler/aot/page_bundles.rs +++ b/src/bundler/aot/page_bundles.rs @@ -18,7 +18,7 @@ use super::services::{BuildContext, with_ast_scope}; use crate::bundle_v2::bv2_impl::generate_unique_key; use crate::mal_prelude::*; use crate::options::{CompileMode, Loader, ModuleType, OutputFile, OutputKind, Side, Target}; -use crate::{BundleV2, Chunk, Index, Transpiler}; +use crate::{BundleV2, Index, Transpiler}; const ENTRY_NAMESPACE: &[u8] = b"bun-aot-page"; const ENTRY_FILE_NAME: &[u8] = b"page-entry.js"; @@ -212,7 +212,7 @@ fn run<'h>( &mut chunks, )?; - let manifests = write_page_manifests(bundle, &chunks, &output_files, pages)?; + let manifests = write_page_manifests(bundle, pages)?; Ok(PageOutputs { manifests, output_files, @@ -221,8 +221,6 @@ fn run<'h>( fn write_page_manifests( bundle: &BundleV2<'_>, - chunks: &[Chunk<'_>], - output_files: &[OutputFile], pages: &[&[u8]], ) -> Result>, crate::Error> { let server_source_indices = &bundle.graph.html_imports.server_source_indices; @@ -247,17 +245,10 @@ fn write_page_manifests( continue; }; - // `generate_chunks_in_parallel` moved the copied files out of the graph and into `output_files`. - let mut manifest: Vec = Vec::new(); - crate::HTMLImportManifest::write_with_assets( - index as u32, - &bundle.graph, - &bundle.linker.graph, - chunks, - output_files, - &mut manifest, - )?; - manifests[positions_by_path[found].1] = Some(manifest); + // The entry chunk prints every page, so `generate_chunks_in_parallel` rendered each one. + if let Some(Some(manifest)) = bundle.graph.html_imports.manifests.get(index) { + manifests[positions_by_path[found].1] = Some(manifest.json.clone()); + } } let bundled_count = manifests.iter().flatten().count(); diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 48736df04c60..3b985c52e7ee 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -92,7 +92,6 @@ pub(crate) use bun_ast::{Part, Ref}; pub use bun_js_printer::MangledProps; pub use options_impl::PathTemplate; -pub use HTMLImportManifest::html_import_manifest; pub use bun_core::cheap_prefix_normalizer; pub use bundle_v2::{ CompileResult, CompileResultForSourceMap, ContentHasher, EventLoop, ImportTracker, PartRange, diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 87be060506b5..bca0cf2d218e 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -376,7 +376,7 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>( // Compute the final hashes of each chunk, then use those to create the final // paths of each chunk. - let hashes = c.final_chunk_hashes(chunks)?; + let (hashes, manifests) = c.final_chunk_hashes(chunks)?; for index in 0..chunks.len() { let chunk = &mut chunks[index]; chunk.template.placeholder.hash = Some(hashes[index]); @@ -516,6 +516,12 @@ pub(crate) fn generate_chunks_in_parallel<'a, const IS_DEV_SERVER: bool>( if has_unusable_output_path { return Err(crate::Error::BuildFailed); } + + // The listed chunks' paths were the last thing the manifests waited for. + c.parse_graph_mut().html_imports.manifests = manifests + .into_iter() + .map(|manifest| manifest.map(|manifest| manifest.render(chunks)).transpose()) + .collect::>()?; } // After final_rel_path is computed for all chunks, fix up module_info diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 2698ff6936ef..c3c3253922c7 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1,7 +1,8 @@ +import type { BunPlugin } from "bun"; import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; -import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; import { SourceMapConsumer } from "source-map"; import { type BundlerTestBundleAPI, itBundled } from "./expectBundled"; @@ -18,11 +19,14 @@ interface Manifest { files: ManifestFile[]; } +/** The manifests that a chunk prints, in print order. */ +function parseManifests(code: string): Manifest[] { + return [...code.matchAll(/__jsonParse\("(.+?)"\)/gs)].map(match => JSON.parse(JSON.parse('"' + match[1] + '"'))); +} + /** The manifests embedded in a bundled server entry point, in import order. */ function readManifests(api: BundlerTestBundleAPI, file: string): Manifest[] { - const manifests = [...api.readFile(file).matchAll(/__jsonParse\("(.+?)"\)/gs)].map(match => - JSON.parse(JSON.parse('"' + match[1] + '"')), - ); + const manifests = parseManifests(api.readFile(file)); for (const manifest of manifests) { for (const { path } of manifest.files) { api.assertFileExists(join("out", path)); @@ -599,6 +603,458 @@ console.log("About manifest:", aboutHtml); expect(htmlA.headers.etag).not.toBe(htmlB.headers.etag); }); + // A chunk that prints a manifest is named by a hash of its contents, so its + // `[hash]` and its `BuildArtifact.hash` have to change when the manifest + // does, and only then. Otherwise a deploy that treats hashed names as + // content addresses keeps a server file that lists client files the new + // build did not write, or uploads one that did not change. + describe("html-import/hash-covers-manifest", () => { + const files = { + "server.js": `import page from "./index.html"; console.log(page);`, + "other.js": `import page from "./index.html"; console.log("other", page);`, + "index.html": ``, + "client.js": `import("./lazy.js"); console.log("client");`, + "style.css": `body { color: red; }`, + "logo.png": "logo", + "lazy.js": `import "./lazy.css"; import icon from "./icon.png"; console.log(icon);`, + "lazy.css": `p { color: green; }`, + "icon.png": "icon", + }; + + interface Output { + path: string; + hash: string | null; + text: string; + } + + interface Options { + entrypoints?: string[]; + splitting?: boolean; + assetNaming?: string; + plugins?: BunPlugin[]; + } + + /** Every output of one build, sorted by path. */ + async function build(dir: string, { entrypoints = ["server.js"], splitting, assetNaming, plugins }: Options = {}) { + const result = await Bun.build({ + entrypoints: entrypoints.map(entrypoint => join(dir, entrypoint)), + outdir: join(dir, "out"), + target: "bun", + splitting, + naming: { entry: "[name]-[hash].[ext]", asset: assetNaming }, + plugins, + }); + expect(result.logs).toBeEmpty(); + const outputs = await Promise.all( + result.outputs.map( + async (output): Promise => ({ + path: basename(output.path), + hash: output.hash, + text: await output.text(), + }), + ), + ); + return outputs.sort((a, b) => (a.path < b.path ? -1 : 1)); + } + + /** An output's name without its hash. */ + const label = (output: Output) => output.path.replace(/-[^-.]+(?=\.[a-z]+$)/, ""); + + /** The outputs that print a manifest, then the outputs that import one of those by name. */ + function printers(outputs: Output[]) { + const printing = outputs.filter(output => output.text.includes("__jsonParse(")); + return [...printing, ...outputs.filter(output => printing.some(printer => output.text.includes(printer.path)))]; + } + + /** What differs between two builds, for each output that prints a manifest or imports one that does. */ + function changes(before: Output[], after: Output[]) { + const later = new Map(printers(after).map(output => [label(output), output])); + return Object.fromEntries( + printers(before).map(output => { + const next = later.get(label(output))!; + return [ + label(output), + { text: output.text !== next.text, path: output.path !== next.path, hash: output.hash !== next.hash }, + ]; + }), + ); + } + const renamed = { text: true, path: true, hash: true }; + const kept = { text: false, path: false, hash: false }; + + /** + * A hashed path and a `BuildArtifact.hash` each stand for one content. + * Lists the outputs of `before` that break this against `after`. + */ + function mismatches(before: Output[], after: Output[]) { + const sameName = (a: Output, b: Output) => + a.hash !== null && (a.hash === b.hash || (a.path === b.path && a.path.includes(a.hash))); + return { + oneNameTwoContents: before.filter(a => after.some(b => sameName(a, b) && a.text !== b.text)).map(label), + oneContentTwoNames: before + .filter(a => after.some(b => a.text === b.text && (a.path !== b.path || a.hash !== b.hash))) + .map(label), + }; + } + const none = { oneNameTwoContents: [], oneContentTwoNames: [] }; + + interface Edit extends Options { + files: Record; + edit: [file: string, contents: string]; + } + + /** The edit renames `renames` and keeps `keeps`. No other name and content go apart, with or without the edit. */ + async function expectRenames({ files, edit, ...options }: Edit, renames: string[], keeps: string[] = []) { + using dir = tempDir("html-import-hash", files); + const first = await build(String(dir), options); + const second = await build(String(dir), options); + expect(second).toEqual(first); + + writeFileSync(join(String(dir), edit[0]), edit[1]); + const edited = await build(String(dir), options); + expect(changes(second, edited)).toEqual( + Object.fromEntries([...renames.map(output => [output, renamed]), ...keeps.map(output => [output, kept])]), + ); + expect(mismatches(second, edited)).toEqual(none); + } + + const cases: (Omit & { changes: string; renames: string[] })[] = [ + { + changes: "client JS", + edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + renames: ["server.js"], + }, + { changes: "CSS", edit: ["style.css", `body { color: blue; }`], renames: ["server.js"] }, + { changes: "an asset", edit: ["logo.png", "changed"], renames: ["server.js"] }, + { + // The asset keeps its path. Only its etag in the manifest changes. + changes: "an asset with an unhashed name", + edit: ["logo.png", "changed"], + assetNaming: "[name].[ext]", + renames: ["server.js"], + }, + { + // Only the page's own path and etag change in the manifest. + changes: "the page's own markup", + edit: ["index.html", files["index.html"].replace("", "changed")], + renames: ["server.js"], + }, + { + changes: "the stylesheet of a lazy chunk", + edit: ["lazy.css", `p { color: purple; }`], + splitting: true, + renames: ["server.js"], + }, + { + changes: "the asset of a lazy chunk, with an unhashed name", + edit: ["icon.png", "changed"], + splitting: true, + assetNaming: "[name].[ext]", + renames: ["server.js"], + }, + { + // Both entry points print the manifest of the one page. + changes: "client JS under two entry points", + edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + entrypoints: ["server.js", "other.js"], + renames: ["other.js", "server.js"], + }, + { + // The manifest moves to a chunk that both entry points import. + changes: "client JS under two entry points that share a chunk", + edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + entrypoints: ["server.js", "other.js"], + splitting: true, + renames: ["chunk.js", "other.js", "server.js"], + }, + ]; + + test.each(cases)("when $changes changes", async ({ renames, ...options }) => { + await expectRenames({ files, ...options }, renames); + }); + + const page = (name: string) => + ``; + const twoPages = { + "a.html": page("a"), + "b.html": page("b"), + "a.css": `.a { color: red; }`, + "b.css": `.b { color: blue; }`, + "a.client.js": `console.log("a");`, + "b.client.js": `console.log("b");`, + }; + + test("when one of two entry points prints the page that changes", async () => { + const files = { + "sa.js": `import a from "./a.html"; console.log(a);`, + "sb.js": `import b from "./b.html"; console.log(b);`, + ...twoPages, + }; + const options: Edit = { + files, + edit: ["b.client.js", `console.log("changed");`], + entrypoints: ["sa.js", "sb.js"], + }; + await expectRenames(options, ["sb.js"], ["sa.js"]); + }); + + // The chunk prints the manifest of the second page only. + test.each([ + { changes: "the page it prints", file: "b.client.js", renames: ["server.js"], keeps: [] }, + { changes: "the page it does not print", file: "a.client.js", renames: [], keeps: ["server.js"] }, + ])("when a chunk imports two pages and $changes changes", async ({ file, renames, keeps }) => { + const files = { + "server.js": `import a from "./a.html"; import b from "./b.html"; console.log(b);`, + ...twoPages, + }; + await expectRenames({ files, edit: [file, `console.log("changed");`] }, renames, keeps); + }); + + /** Holds the files `held` until the scan has found every file that it can find without them. */ + const findLast = (...held: string[]): BunPlugin => ({ + name: "find-last", + setup(builder) { + builder.onLoad({ filter: /\.js$/ }, async ({ path, defer }) => { + if (held.includes(basename(path))) await defer(); + return undefined; + }); + }, + }); + + // The scan finds two pages in either order, and the order of their chunks + // follows it. The chunk that prints both manifests has the same bytes + // either way, so it has one name. + test.each([ + { shape: "one entry point", options: {}, renames: ["server.js"] }, + { + shape: "two entry points that share a chunk", + options: { entrypoints: ["server.js", "other.js"], splitting: true }, + renames: ["chunk.js", "other.js", "server.js"], + }, + ])("when the scan finds two pages in another order, with $shape", async ({ options, renames }) => { + using dir = tempDir("html-import-hash-order", { + "server.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log(a, b);`, + "other.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log("other", a, b);`, + "routes/a.js": `import a from "../a.html"; export { a };`, + "routes/b.js": `import b from "../b.html"; export { b };`, + ...twoPages, + }); + const aLast = await build(String(dir), { ...options, plugins: [findLast("a.js")] }); + const bLast = await build(String(dir), { ...options, plugins: [findLast("b.js")] }); + expect(bLast).toEqual(aLast); + + writeFileSync(join(String(dir), "b.css"), `.b { color: green; }`); + const edited = await build(String(dir), { ...options, plugins: [findLast("a.js")] }); + expect(changes(bLast, edited)).toEqual(Object.fromEntries(renames.map(output => [output, renamed]))); + expect(mismatches(bLast, edited)).toEqual(none); + }); + + // Both manifests list the chunks of `x.js` and `y.js`, which no entry point + // owns, so their entries in `files` differ only in path and etag. The two + // routes of page `b` trade what they import. That trades the places of the + // two chunks, and page `a` lists them with no other change. + test("when two listed chunks trade places", async () => { + const route = (name: string, shared: string) => + `import { ${shared} } from "./${shared}.js"; console.log("${name}", ${shared});`; + using dir = tempDir("html-import-hash-places", { + "x.js": `export const x = "x";`, + "y.js": `export const y = "y";`, + ...Object.fromEntries( + ["a", "b"].flatMap(name => [ + [`server.${name}.js`, `import page from "./${name}.html"; console.log(page);`], + [ + `${name}.html`, + ``, + ], + [`${name}.client.js`, `import("./${name}.route1.js"); import("./${name}.route2.js");`], + [`${name}.route1.js`, route(name, "x")], + [`${name}.route2.js`, route(name, "y")], + ]), + ), + }); + /** The chunk that prints the manifest of page `a`, and the paths of its listed chunks that have no `input`. */ + async function observed() { + // The routes of the page that the scan finds last decide the order of the two chunks. + const options = { + entrypoints: ["server.a.js", "server.b.js"], + splitting: true, + plugins: [findLast("server.b.js")], + }; + const printer = (await build(String(dir), options)).find(output => output.path.startsWith("server.a-"))!; + const [manifest] = parseManifests(printer.text); + return { printer, shared: manifest.files.filter(file => file.input === undefined).map(file => file.path) }; + } + + const before = await observed(); + writeFileSync(join(String(dir), "b.route1.js"), route("b", "y")); + writeFileSync(join(String(dir), "b.route2.js"), route("b", "x")); + const after = await observed(); + + expect(before.shared).toHaveLength(2); + expect(after.shared).toEqual(before.shared.toReversed()); + expect({ + text: before.printer.text !== after.printer.text, + path: before.printer.path !== after.printer.path, + hash: before.printer.hash !== after.printer.hash, + }).toEqual(renamed); + }); + + // Under `[hash1]` a chunk's name gets a second character when another + // chunk's hash starts alike. The page links its own stylesheet, and only the + // manifest names the one of the lazy chunk, so that one's wider name changes + // the manifest and the contents of no other chunk. + test("when only the printed width of a listed chunk's hash changes", async () => { + const candidates = Array.from({ length: 64 }, (_, i) => i); + using dir = tempDir("html-import-hash-width", { + "server.js": files["server.js"], + "index.html": ``, + "client.js": `import "./page.css"; import("./lazy.js"); console.log("client");`, + "page.css": `body { color: red; }`, + "lazy.js": `import "./lazy.css"; console.log("lazy");`, + ...Object.fromEntries(candidates.map(i => [`x${i}.js`, `console.log("x${i}");`])), + }); + + /** Builds with a second entry point that imports the given candidates, each of which becomes a chunk. */ + async function buildWith(imported: number[], salt: number) { + writeFileSync(join(String(dir), "lazy.css"), `p { order: ${salt}; }`); + writeFileSync(join(String(dir), "other.js"), imported.map(i => `import("./x${i}.js");`).join("")); + const result = await Bun.build({ + entrypoints: [join(String(dir), "server.js"), join(String(dir), "other.js")], + outdir: join(String(dir), "out"), + target: "bun", + splitting: true, + naming: { entry: "[name]-[hash].[ext]", chunk: "c[hash1].[ext]" }, + }); + expect(result.logs).toBeEmpty(); + const outputs = await Promise.all( + result.outputs.map(async output => ({ + path: basename(output.path), + hash: output.hash, + text: await output.text(), + })), + ); + const server = outputs.find(output => output.path.startsWith("server-"))!; + const [manifest] = parseManifests(server.text); + const stylesheet = manifest.files.find(file => file.loader === "css" && file.input === "lazy.js")!; + const name = basename(stylesheet.path); + stylesheet.path = ""; + return { outputs, server, stylesheet: name, rest: JSON.stringify(manifest) }; + } + + // A chunk that imports nothing has the same hash in every build, so one + // build tells which character each candidate's hash starts with. + const all = await buildWith(candidates, 0); + const starts = candidates.map(i => all.outputs.find(output => output.text.includes(`"x${i}"`))!.path[1]); + + // Two builds whose manifests differ only in the width of the stylesheet's + // hash. Each imports one candidate, so both have the same set of chunks. + let pair: Record<"before" | "after", Awaited>> | undefined; + for (let salt = 0; salt < 32 && !pair; salt++) { + const before = await buildWith([salt], salt); + const alike = starts.indexOf(before.stylesheet[1]); + if (alike === -1) continue; + const after = await buildWith([alike], salt); + if (after.rest === before.rest && after.stylesheet.length > before.stylesheet.length) pair = { before, after }; + } + expect(pair).toBeDefined(); + expect({ + text: pair!.before.server.text !== pair!.after.server.text, + path: pair!.before.server.path !== pair!.after.server.path, + hash: pair!.before.server.hash !== pair!.after.server.hash, + }).toEqual(renamed); + }, 30_000); + + // `bun build` writes the chunks to disk itself. + test.each(["bun", "node"])("when client JS changes, from the CLI with --target=%s", async target => { + using dir = tempDir("html-import-hash-cli", files); + + async function buildTo(outdir: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "./server.js", + `--target=${target}`, + "--outdir", + outdir, + "--entry-naming", + "[name]-[hash].[ext]", + ], + cwd: String(dir), + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const path = readdirSync(join(String(dir), outdir)).find(file => file.startsWith("server-"))!; + return { path, text: readFileSync(join(String(dir), outdir, path), "utf8") }; + } + + const before = await buildTo("before"); + writeFileSync(join(String(dir), "client.js"), `import("./lazy.js"); console.log("changed");`); + const after = await buildTo("after"); + expect({ text: before.text !== after.text, path: before.path !== after.path }).toEqual({ + text: true, + path: true, + }); + }); + + // A chunk's final hash covers the set of chunks it reaches, not which + // chunk each of its references names. `lazy.js` goes from `a.js` to `b.js` + // here and the page reaches both either way, so the lazy chunk is renamed + // and the manifest lists its new path, while the server chunk keeps its + // name and hash: `{ "server.js": { text: true, path: false, hash: false } }`. + // The page's own script, which imports the lazy chunk by name, is stale the same way. + test.todo("when a lazy chunk imports another chunk that the page already reaches", async () => { + using dir = tempDir("html-import-hash-retarget", { + "server.js": files["server.js"], + "index.html": ``, + "client.js": `import("./a.js"); import("./b.js"); import("./lazy.js"); console.log("client");`, + "a.js": `console.log("a");`, + "b.js": `console.log("b");`, + "lazy.js": `import("./a.js"); console.log("lazy");`, + }); + const before = await build(String(dir), { splitting: true }); + writeFileSync(join(String(dir), "lazy.js"), `import("./b.js"); console.log("lazy");`); + expect(changes(before, await build(String(dir), { splitting: true }))).toEqual({ "server.js": renamed }); + }); + }); + + // A listed chunk's path is printed once the chunks are named, as a JSON + // string inside a JavaScript string. Both escape `"`, which a file name on + // Windows cannot hold. + test.skipIf(isWindows)("html-import/manifest-paths-that-need-escapes", async () => { + using dir = tempDir("html-import-escapes", { + "server.js": `import page from "./index.html"; console.log(page);`, + "index.html": ``, + "client.js": `import("./lazy.js"); console.log("client");`, + "style.css": `body { color: red; }`, + "logo.png": "logo", + "lazy.js": `console.log("lazy");`, + }); + + const result = await Bun.build({ + entrypoints: [join(String(dir), "server.js")], + outdir: join(String(dir), "out"), + target: "bun", + splitting: true, + naming: { entry: `[name]"-[hash].[ext]`, chunk: `chunk"-[hash].[ext]`, asset: `[name]"-[hash].[ext]` }, + }); + expect(result.logs).toBeEmpty(); + + const written = result.outputs.map(output => "./" + basename(output.path)); + const server = result.outputs.find(output => basename(output.path).startsWith("server"))!; + const [manifest] = parseManifests(await server.text()); + expect({ + index: manifest.index, + files: manifest.files.map(file => file.path).sort(), + }).toEqual({ + index: written.find(path => path.endsWith(".html"))!, + files: written.filter(path => !path.startsWith("./server")).sort(), + }); + expect(written.filter(path => !path.includes(`"-`))).toEqual([]); + }); + // The manifest JSON is spliced into the chunk in place of a 25-byte // placeholder after the source map was generated, so the map has to be // shifted by the size difference. With whitespace minified, the rest of the From 190f701be93e7befcac335f8dbc6181f7fa383eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:05:19 +0000 Subject: [PATCH 02/12] test: format html-import-manifest.test.ts --- test/bundler/html-import-manifest.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index c3c3253922c7..a1affae49dce 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -646,13 +646,11 @@ console.log("About manifest:", aboutHtml); }); expect(result.logs).toBeEmpty(); const outputs = await Promise.all( - result.outputs.map( - async (output): Promise => ({ - path: basename(output.path), - hash: output.hash, - text: await output.text(), - }), - ), + result.outputs.map(async (output): Promise => ({ + path: basename(output.path), + hash: output.hash, + text: await output.text(), + })), ); return outputs.sort((a, b) => (a.path < b.path ? -1 : 1)); } From 2f90709e0efd6be1213f372d6b7c12d2a2e66941 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:37:44 +0000 Subject: [PATCH 03/12] bundler: list a manifest's stylesheets and assets by source path The order of the CSS chunks and of the copied files follows the order in which the scan finds their files, so two builds of one tree could print one manifest in two orders. The chunk that prints it then had two bodies, and with the manifest in its hash, two names. Also removes the test for a change of only the printed width of a listed hash. It needed a search over builds for a one-character collision. --- src/bundler/HTMLImportManifest.rs | 168 ++++++++++++---------- test/bundler/html-import-manifest.test.ts | 144 ++++++++----------- 2 files changed, 151 insertions(+), 161 deletions(-) diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 0201b3abcbe7..2171b01d9952 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -330,89 +330,109 @@ pub(crate) fn emit( let additional_output_files = &graph.additional_output_files; let file_entry_bits: &[AutoBitSet] = linker_graph.files.items_entry_bits(); - let mut already_visited_output_file = AutoBitSet::init_empty(additional_output_files.len())?; - for (chunk_index, ch) in chunks.iter().enumerate() { - if chunks_to_write.is_set(chunk_index) { - if !first { - writer.write_all(b",")?; - } - first = false; - - let input: &[u8] = if !ch.entry_point.is_entry_point() { - b"" - } else { - source_path_relative_to_root( - &mut input_buffer, - root_dir, - sources[ch.entry_point.source_index() as usize].path.text, - ) - }; - - write_entry_item( - writer, - &mut refs, - input, - Entry::Chunk(chunk_index as u32), - ch.content.loader(), - if ch.entry_point.is_entry_point() { - OutputKind::EntryPoint - } else { - OutputKind::Chunk - }, - )?; - } + // One tree has to give one listing. The order of the CSS chunks and the order of + // the copied files follow the order in which the scan found their files, so the + // stylesheets and the assets are listed by source path. + let mut listed: Vec = (0..chunks.len() as u32) + .filter(|&chunk_index| chunks_to_write.is_set(chunk_index as usize)) + .collect(); + let is_stylesheet = + |chunk_index: u32| matches!(chunks[chunk_index as usize].content, Content::Css(_)); + let mut stylesheets: Vec = listed + .iter() + .copied() + .filter(|&i| is_stylesheet(i)) + .collect(); + stylesheets.sort_unstable_by_key(|&chunk_index| { + let ch = &chunks[chunk_index as usize]; + ( + sources[ch.entry_point.source_index() as usize].path.text, + ch.isolated_hash, + ) + }); + for (slot, stylesheet) in listed + .iter_mut() + .filter(|i| is_stylesheet(**i)) + .zip(stylesheets) + { + *slot = stylesheet; } - for (i, output_file) in additional_output_files.iter().enumerate() { - // Only print the file once. - if already_visited_output_file.is_set(i) { - continue; + for chunk_index in listed { + let ch = &chunks[chunk_index as usize]; + if !first { + writer.write_all(b",")?; } + first = false; + + let input: &[u8] = if !ch.entry_point.is_entry_point() { + b"" + } else { + source_path_relative_to_root( + &mut input_buffer, + root_dir, + sources[ch.entry_point.source_index() as usize].path.text, + ) + }; + + write_entry_item( + writer, + &mut refs, + input, + Entry::Chunk(chunk_index), + ch.content.loader(), + if ch.entry_point.is_entry_point() { + OutputKind::EntryPoint + } else { + OutputKind::Chunk + }, + )?; + } + let mut assets: Vec<(&[u8], &crate::options::OutputFile)> = Vec::new(); + for output_file in additional_output_files.iter() { if let Some(source_index) = output_file.source_index.unwrap() { - if source_index.get() == server_source_index { - continue; + if source_index.get() != server_source_index + && file_entry_bits[source_index.get() as usize].has_intersection(&entry_point_bits) + { + assets.push((sources[source_index.get() as usize].path.text, output_file)); } - let bits: &AutoBitSet = &file_entry_bits[source_index.get() as usize]; + } + } + assets.sort_unstable_by_key(|&(source_path, output_file)| { + (source_path, &output_file.dest_path[..]) + }); - if bits.has_intersection(&entry_point_bits) { - already_visited_output_file.set(i); - if !first { - writer.write_all(b",")?; - } - first = false; - - let path_for_key = source_path_relative_to_root( - &mut input_buffer, - root_dir, - sources[source_index.get() as usize].path.text, - ); - - let path: &[u8] = if inject_compiler_filesystem_prefix { - temp_buffer.clear(); - temp_buffer.extend_from_slice(public_path); - temp_buffer.extend_from_slice(strings::remove_leading_dot_slash( - &output_file.dest_path, - )); - &temp_buffer[..] - } else { - &output_file.dest_path[..] - }; - - write_entry_item( - writer, - &mut refs, - path_for_key, - Entry::Asset { - path, - hash: output_file.hash.value, - }, - output_file.loader, - output_file.output_kind, - )?; - } + for (source_path, output_file) in assets { + if !first { + writer.write_all(b",")?; } + first = false; + + let path_for_key = source_path_relative_to_root(&mut input_buffer, root_dir, source_path); + + let path: &[u8] = if inject_compiler_filesystem_prefix { + temp_buffer.clear(); + temp_buffer.extend_from_slice(public_path); + temp_buffer + .extend_from_slice(strings::remove_leading_dot_slash(&output_file.dest_path)); + &temp_buffer[..] + } else { + &output_file.dest_path[..] + }; + + write_entry_item( + writer, + &mut refs, + path_for_key, + Entry::Asset { + path, + hash: output_file.hash.value, + }, + output_file.loader, + output_file.output_kind, + )?; } writer.write_all(b"]}")?; diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index a1affae49dce..e515b7314f7d 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -771,8 +771,9 @@ console.log("About manifest:", aboutHtml); await expectRenames({ files, ...options }, renames); }); + // Each page has its own stylesheet, script and image, and both show one logo. const page = (name: string) => - ``; + ``; const twoPages = { "a.html": page("a"), "b.html": page("b"), @@ -780,6 +781,9 @@ console.log("About manifest:", aboutHtml); "b.css": `.b { color: blue; }`, "a.client.js": `console.log("a");`, "b.client.js": `console.log("b");`, + "a.png": "a", + "b.png": "b", + "logo.png": "logo", }; test("when one of two entry points prints the page that changes", async () => { @@ -819,32 +823,63 @@ console.log("About manifest:", aboutHtml); }, }); - // The scan finds two pages in either order, and the order of their chunks - // follows it. The chunk that prints both manifests has the same bytes - // either way, so it has one name. + const routes = { + "server.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log(a, b);`, + "other.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log("other", a, b);`, + "routes/a.js": `import a from "../a.html"; export { a };`, + "routes/b.js": `import b from "../b.html"; export { b };`, + ...twoPages, + }; + const lazyStylesheets = { + "server.js": files["server.js"], + "index.html": ``, + "client.js": `import("./lazy0.js"); import("./lazy1.js"); console.log("client");`, + "lazy0.js": `import "./lazy0.css"; console.log("lazy 0");`, + "lazy1.js": `import "./lazy1.css"; console.log("lazy 1");`, + "lazy0.css": `.lazy0 { color: red; }`, + "lazy1.css": `.lazy1 { color: blue; }`, + }; + + // The order in which the scan finds files decides the order of the pages, + // of their CSS chunks and of the files they copy. A chunk that prints a + // manifest has the same bytes either way, so it has one name. test.each([ - { shape: "one entry point", options: {}, renames: ["server.js"] }, { - shape: "two entry points that share a chunk", + shape: "two pages under one entry point", + files: routes, + held: ["a.js", "b.js"], + options: {}, + edit: ["b.css", `.b { color: green; }`], + renames: ["server.js"], + }, + { + shape: "two pages under two entry points that share a chunk", + files: routes, + held: ["a.js", "b.js"], options: { entrypoints: ["server.js", "other.js"], splitting: true }, + edit: ["b.css", `.b { color: green; }`], renames: ["chunk.js", "other.js", "server.js"], }, - ])("when the scan finds two pages in another order, with $shape", async ({ options, renames }) => { - using dir = tempDir("html-import-hash-order", { - "server.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log(a, b);`, - "other.js": `import { a } from "./routes/a.js"; import { b } from "./routes/b.js"; console.log("other", a, b);`, - "routes/a.js": `import a from "../a.html"; export { a };`, - "routes/b.js": `import b from "../b.html"; export { b };`, - ...twoPages, - }); - const aLast = await build(String(dir), { ...options, plugins: [findLast("a.js")] }); - const bLast = await build(String(dir), { ...options, plugins: [findLast("b.js")] }); - expect(bLast).toEqual(aLast); - - writeFileSync(join(String(dir), "b.css"), `.b { color: green; }`); - const edited = await build(String(dir), { ...options, plugins: [findLast("a.js")] }); - expect(changes(bLast, edited)).toEqual(Object.fromEntries(renames.map(output => [output, renamed]))); - expect(mismatches(bLast, edited)).toEqual(none); + { + shape: "two lazy stylesheets of one page", + files: lazyStylesheets, + held: ["lazy0.js", "lazy1.js"], + options: { splitting: true }, + edit: ["lazy1.css", `.lazy1 { color: green; }`], + renames: ["server.js"], + }, + ])("when the scan finds files in another order: $shape", async ({ files, held, options, edit, renames }) => { + using dir = tempDir("html-import-hash-order", files); + const [first, second] = [ + await build(String(dir), { ...options, plugins: [findLast(held[0])] }), + await build(String(dir), { ...options, plugins: [findLast(held[1])] }), + ]; + expect(second).toEqual(first); + + writeFileSync(join(String(dir), edit[0]), edit[1]); + const edited = await build(String(dir), { ...options, plugins: [findLast(held[0])] }); + expect(changes(second, edited)).toEqual(Object.fromEntries(renames.map(output => [output, renamed]))); + expect(mismatches(second, edited)).toEqual(none); }); // Both manifests list the chunks of `x.js` and `y.js`, which no entry point @@ -897,71 +932,6 @@ console.log("About manifest:", aboutHtml); }).toEqual(renamed); }); - // Under `[hash1]` a chunk's name gets a second character when another - // chunk's hash starts alike. The page links its own stylesheet, and only the - // manifest names the one of the lazy chunk, so that one's wider name changes - // the manifest and the contents of no other chunk. - test("when only the printed width of a listed chunk's hash changes", async () => { - const candidates = Array.from({ length: 64 }, (_, i) => i); - using dir = tempDir("html-import-hash-width", { - "server.js": files["server.js"], - "index.html": ``, - "client.js": `import "./page.css"; import("./lazy.js"); console.log("client");`, - "page.css": `body { color: red; }`, - "lazy.js": `import "./lazy.css"; console.log("lazy");`, - ...Object.fromEntries(candidates.map(i => [`x${i}.js`, `console.log("x${i}");`])), - }); - - /** Builds with a second entry point that imports the given candidates, each of which becomes a chunk. */ - async function buildWith(imported: number[], salt: number) { - writeFileSync(join(String(dir), "lazy.css"), `p { order: ${salt}; }`); - writeFileSync(join(String(dir), "other.js"), imported.map(i => `import("./x${i}.js");`).join("")); - const result = await Bun.build({ - entrypoints: [join(String(dir), "server.js"), join(String(dir), "other.js")], - outdir: join(String(dir), "out"), - target: "bun", - splitting: true, - naming: { entry: "[name]-[hash].[ext]", chunk: "c[hash1].[ext]" }, - }); - expect(result.logs).toBeEmpty(); - const outputs = await Promise.all( - result.outputs.map(async output => ({ - path: basename(output.path), - hash: output.hash, - text: await output.text(), - })), - ); - const server = outputs.find(output => output.path.startsWith("server-"))!; - const [manifest] = parseManifests(server.text); - const stylesheet = manifest.files.find(file => file.loader === "css" && file.input === "lazy.js")!; - const name = basename(stylesheet.path); - stylesheet.path = ""; - return { outputs, server, stylesheet: name, rest: JSON.stringify(manifest) }; - } - - // A chunk that imports nothing has the same hash in every build, so one - // build tells which character each candidate's hash starts with. - const all = await buildWith(candidates, 0); - const starts = candidates.map(i => all.outputs.find(output => output.text.includes(`"x${i}"`))!.path[1]); - - // Two builds whose manifests differ only in the width of the stylesheet's - // hash. Each imports one candidate, so both have the same set of chunks. - let pair: Record<"before" | "after", Awaited>> | undefined; - for (let salt = 0; salt < 32 && !pair; salt++) { - const before = await buildWith([salt], salt); - const alike = starts.indexOf(before.stylesheet[1]); - if (alike === -1) continue; - const after = await buildWith([alike], salt); - if (after.rest === before.rest && after.stylesheet.length > before.stylesheet.length) pair = { before, after }; - } - expect(pair).toBeDefined(); - expect({ - text: pair!.before.server.text !== pair!.after.server.text, - path: pair!.before.server.path !== pair!.after.server.path, - hash: pair!.before.server.hash !== pair!.after.server.hash, - }).toEqual(renamed); - }, 30_000); - // `bun build` writes the chunks to disk itself. test.each(["bun", "node"])("when client JS changes, from the CLI with --target=%s", async target => { using dir = tempDir("html-import-hash-cli", files); From ec8f8a739d4a46097152bc5d14440d647ec893d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:36 +0000 Subject: [PATCH 04/12] bundler: order a manifest's files without chunk index Chunk index and the order of copied files follow the order in which the scan finds files. The listing is now ordered by kind, source path and isolated hash, so one tree gives one manifest. With that order two listed chunks cannot trade places, so the hash no longer writes the isolated hash of each listed chunk. Comments are one line each. --- src/bundler/HTMLImportManifest.rs | 86 +++++++++++------------ src/bundler/LinkerContext.rs | 16 +---- test/bundler/html-import-manifest.test.ts | 50 ------------- 3 files changed, 44 insertions(+), 108 deletions(-) diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 2171b01d9952..14a357602e2a 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -50,11 +50,11 @@ use crate::options::{Loader, OutputKind}; use crate::options_impl::LoaderExt as _; use crate::{BundleV2, Chunk, LinkerGraph}; -/// A page's manifest before the chunks are named: `json` is every byte except the -/// listed chunks' paths and etags, and `refs` says where those go. +/// A page's manifest before the chunks are named. pub(crate) struct Template { + /// Every byte except the listed chunks' paths and etags. json: Vec, - /// In `at` order. + /// Where those go, in `at` order. refs: Vec, /// `--compile`: the server-side public path, printed before each chunk's path. public_path: Option>, @@ -96,8 +96,7 @@ pub(crate) enum Part<'a> { } impl Template { - /// The manifest in print order. `final_chunk_hashes` and `resolve` both read it - /// from here: what one prints, the other hashed or followed as an edge. + /// The manifest in print order, for `resolve` to print and for `final_chunk_hashes` to hash. pub(crate) fn parts(&self) -> impl Iterator> { let mut literal_start = 0; let tail = self.refs.last().map_or(0, |last| last.at as usize); @@ -131,10 +130,7 @@ impl Template { } Part::Chunk(chunk, ChunkValue::Etag) => { let chunk = &chunks[chunk as usize]; - // The HTML chunk's body embeds the hashed paths of its JS/CSS - // chunks, so its etag must change when those do. `isolated_hash` - // by design excludes those substitutions; the placeholder hash - // folds them in (`final_chunk_hashes`). + // Not `isolated_hash`: that leaves out the paths of the chunks this one names. let hash = chunk.template.placeholder.hash; write_etag(writer, hash.map_or(chunk.isolated_hash, |h| h.value))?; } @@ -166,6 +162,9 @@ pub(crate) struct Manifest { pub(crate) escaped: Vec, } +/// What orders a listing: a rank, the file's source path, its isolated hash, and then its index. +type ListingKey<'a> = (u8, &'a [u8], u64, u32); + /// Where an entry's path and etag come from. #[derive(Clone, Copy)] enum Entry<'a> { @@ -245,8 +244,7 @@ fn write_etag(writer: &mut Vec, hash: u64) -> Result<(), crate::Error> { Ok(()) } -/// Walks what page `index` lists. This runs when the chunks are hashed, so what a -/// chunk's final hash decides is left as a reference to that chunk. +/// Walks what page `index` lists. The chunks have no names yet, so their paths and etags become references. pub(crate) fn emit( index: u32, graph: &Graph, @@ -331,35 +329,29 @@ pub(crate) fn emit( let additional_output_files = &graph.additional_output_files; let file_entry_bits: &[AutoBitSet] = linker_graph.files.items_entry_bits(); - // One tree has to give one listing. The order of the CSS chunks and the order of - // the copied files follow the order in which the scan found their files, so the - // stylesheets and the assets are listed by source path. - let mut listed: Vec = (0..chunks.len() as u32) - .filter(|&chunk_index| chunks_to_write.is_set(chunk_index as usize)) - .collect(); - let is_stylesheet = - |chunk_index: u32| matches!(chunks[chunk_index as usize].content, Content::Css(_)); - let mut stylesheets: Vec = listed - .iter() - .copied() - .filter(|&i| is_stylesheet(i)) - .collect(); - stylesheets.sort_unstable_by_key(|&chunk_index| { - let ch = &chunks[chunk_index as usize]; - ( - sources[ch.entry_point.source_index() as usize].path.text, - ch.isolated_hash, - ) - }); - for (slot, stylesheet) in listed - .iter_mut() - .filter(|i| is_stylesheet(**i)) - .zip(stylesheets) - { - *slot = stylesheet; + // Chunk index and the order of copied files follow the scan order, so neither orders the listing. + let mut listed: Vec> = Vec::new(); + for (chunk_index, ch) in chunks.iter().enumerate() { + if !chunks_to_write.is_set(chunk_index) { + continue; + } + let is_page = ch.is_entry_point_file(browser_source_index); + let rank = match ch.content { + Content::Javascript(_) if is_page => 0, + Content::Html => 1, + Content::Javascript(_) => 2, + Content::Css(_) => 3, + }; + let source_path: &[u8] = if ch.entry_point.is_entry_point() { + sources[ch.entry_point.source_index() as usize].path.text + } else { + b"" + }; + listed.push((rank, source_path, ch.isolated_hash, chunk_index as u32)); } + listed.sort_unstable(); - for chunk_index in listed { + for (_, _, _, chunk_index) in listed { let ch = &chunks[chunk_index as usize]; if !first { writer.write_all(b",")?; @@ -390,21 +382,25 @@ pub(crate) fn emit( )?; } - let mut assets: Vec<(&[u8], &crate::options::OutputFile)> = Vec::new(); - for output_file in additional_output_files.iter() { + let mut assets: Vec> = Vec::new(); + for (i, output_file) in additional_output_files.iter().enumerate() { if let Some(source_index) = output_file.source_index.unwrap() { if source_index.get() != server_source_index && file_entry_bits[source_index.get() as usize].has_intersection(&entry_point_bits) { - assets.push((sources[source_index.get() as usize].path.text, output_file)); + assets.push(( + 0, + sources[source_index.get() as usize].path.text, + 0, + i as u32, + )); } } } - assets.sort_unstable_by_key(|&(source_path, output_file)| { - (source_path, &output_file.dest_path[..]) - }); + assets.sort_unstable(); - for (source_path, output_file) in assets { + for (_, source_path, _, i) in assets { + let output_file = &additional_output_files[i as usize]; if !first { writer.write_all(b",")?; } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 6e7930066b13..33c1b48bb55f 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2581,13 +2581,7 @@ impl<'a> LinkerContext<'a> { /// (its output embeds that chunk's path or id) has the widths folded into /// its own hash so that its name changes with its bytes. /// - /// A chunk that prints a page's manifest hashes the manifest's literal bytes - /// and, at each listed chunk, that chunk's isolated hash, and it reaches - /// every listed chunk. Such a chunk reaches the chunks of several pages, - /// whose order is not the same in every build of one tree, so it and the - /// chunks that reach it digest the rest of their closure as a sum. The - /// manifests are emitted here, once per page, and returned for the caller - /// to render once the chunks are named. + /// Also returns the manifest of each page that a chunk prints, for the caller to render. pub(crate) fn final_chunk_hashes( &self, chunks: &[Chunk], @@ -2670,12 +2664,7 @@ impl<'a> LinkerContext<'a> { for part in manifest.parts() { match part { Part::Literal(bytes) => hash.write(bytes), - Part::Chunk(listed, _) => { - // Two listed chunks can trade places and keep the closure. - let listed_hash = chunks[listed as usize].isolated_hash; - hash.write(&listed_hash.to_ne_bytes()); - out.push(listed); - } + Part::Chunk(listed, _) => out.push(listed), } } if printers.last() != Some(&chunk_index) { @@ -2721,6 +2710,7 @@ impl<'a> LinkerContext<'a> { .map(|(i, bits)| { let mut hash = ContentHasher::default(); hash.write(&own[i].to_ne_bytes()); + // A manifest reaches the chunks of several pages, and their order differs between builds. let reaches_manifest = printers.iter().any(|&printer| bits.is_set(printer)); let mut rest: u64 = 0; let mut iter = bits.iterator::(); diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index e515b7314f7d..df1bfdb83769 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -882,56 +882,6 @@ console.log("About manifest:", aboutHtml); expect(mismatches(second, edited)).toEqual(none); }); - // Both manifests list the chunks of `x.js` and `y.js`, which no entry point - // owns, so their entries in `files` differ only in path and etag. The two - // routes of page `b` trade what they import. That trades the places of the - // two chunks, and page `a` lists them with no other change. - test("when two listed chunks trade places", async () => { - const route = (name: string, shared: string) => - `import { ${shared} } from "./${shared}.js"; console.log("${name}", ${shared});`; - using dir = tempDir("html-import-hash-places", { - "x.js": `export const x = "x";`, - "y.js": `export const y = "y";`, - ...Object.fromEntries( - ["a", "b"].flatMap(name => [ - [`server.${name}.js`, `import page from "./${name}.html"; console.log(page);`], - [ - `${name}.html`, - ``, - ], - [`${name}.client.js`, `import("./${name}.route1.js"); import("./${name}.route2.js");`], - [`${name}.route1.js`, route(name, "x")], - [`${name}.route2.js`, route(name, "y")], - ]), - ), - }); - /** The chunk that prints the manifest of page `a`, and the paths of its listed chunks that have no `input`. */ - async function observed() { - // The routes of the page that the scan finds last decide the order of the two chunks. - const options = { - entrypoints: ["server.a.js", "server.b.js"], - splitting: true, - plugins: [findLast("server.b.js")], - }; - const printer = (await build(String(dir), options)).find(output => output.path.startsWith("server.a-"))!; - const [manifest] = parseManifests(printer.text); - return { printer, shared: manifest.files.filter(file => file.input === undefined).map(file => file.path) }; - } - - const before = await observed(); - writeFileSync(join(String(dir), "b.route1.js"), route("b", "y")); - writeFileSync(join(String(dir), "b.route2.js"), route("b", "x")); - const after = await observed(); - - expect(before.shared).toHaveLength(2); - expect(after.shared).toEqual(before.shared.toReversed()); - expect({ - text: before.printer.text !== after.printer.text, - path: before.printer.path !== after.printer.path, - hash: before.printer.hash !== after.printer.hash, - }).toEqual(renamed); - }); - // `bun build` writes the chunks to disk itself. test.each(["bun", "node"])("when client JS changes, from the CLI with --target=%s", async target => { using dir = tempDir("html-import-hash-cli", files); From 7350f5484774ae0e8a4a206899d7e5c2d321dc6b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:14:49 +0000 Subject: [PATCH 05/12] bundler: hash a listed chunk's final hash where the manifest prints it A chunk that prints an HTML import manifest folds each manifest into its own hash in print order, once the closure hashes exist: the literal bytes, and at each path and etag the closure hash of the listed chunk. Each chunk that reaches it digests its closure again. The closure digest is the one from before for every chunk, with no sum. The listed chunks join the reach sets after that, for the widening loop. --- src/bundler/LinkerContext.rs | 142 ++++++++++++---------- test/bundler/html-import-manifest.test.ts | 113 +++++++++++++---- 2 files changed, 166 insertions(+), 89 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 33c1b48bb55f..4acabedce6b0 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2580,8 +2580,6 @@ impl<'a> LinkerContext<'a> { /// widen until they differ, and every chunk that reaches a widened chunk /// (its output embeds that chunk's path or id) has the widths folded into /// its own hash so that its name changes with its bytes. - /// - /// Also returns the manifest of each page that a chunk prints, for the caller to render. pub(crate) fn final_chunk_hashes( &self, chunks: &[Chunk], @@ -2592,8 +2590,40 @@ impl<'a> LinkerContext<'a> { ), BunError, > { + /// Grows each chunk's set to every chunk it reaches through `edges`. + fn close(reach: &mut [AutoBitSet], edges: &[Vec]) -> Result<(), AllocError> { + loop { + let mut changed = false; + for i in 0..reach.len() { + for &j in &edges[i] { + let j = j as usize; + if j == i || reach[j].subset_of(&reach[i]) { + continue; + } + let other = reach[j].clone()?; + reach[i].set_union(&other); + changed = true; + } + } + if !changed { + return Ok(()); + } + } + } + /// Chunk `i`'s own hash, then the rest of its closure in chunk order. + fn closure_hash(own: &[u64], reach: &AutoBitSet, i: usize) -> u64 { + let mut hash = ContentHasher::default(); + hash.write(&own[i].to_ne_bytes()); + let mut iter = reach.iterator::(); + while let Some(j) = iter.next() { + if j != i { + hash.write(&own[j].to_ne_bytes()); + } + } + hash.digest() + } + let n = chunks.len(); - let mut manifests: Vec> = Vec::new(); // The chunks that print a manifest. let mut printers: Vec = Vec::new(); let mut own: Vec = Vec::with_capacity(n); @@ -2645,28 +2675,6 @@ impl<'a> LinkerContext<'a> { } } crate::chunk::QueryKind::HtmlImport => { - use crate::HTMLImportManifest::Part; - let parse_graph = self.parse_graph(); - let index = piece.query.index(); - if manifests.is_empty() { - let pages = parse_graph.html_imports.server_source_indices.len(); - manifests.resize_with(pages, || None); - } - let manifest = match &mut manifests[index as usize] { - Some(manifest) => manifest, - unset => unset.insert(crate::HTMLImportManifest::emit( - index, - parse_graph, - &self.graph, - chunks, - )?), - }; - for part in manifest.parts() { - match part { - Part::Literal(bytes) => hash.write(bytes), - Part::Chunk(listed, _) => out.push(listed), - } - } if printers.last() != Some(&chunk_index) { printers.push(chunk_index); } @@ -2686,50 +2694,60 @@ impl<'a> LinkerContext<'a> { bits.set(i); reach.push(bits); } - loop { - let mut changed = false; - for i in 0..n { - for &j in &edges[i] { - let j = j as usize; - if j == i || reach[j].subset_of(&reach[i]) { - continue; - } - let other = reach[j].clone()?; - reach[i].set_union(&other); - changed = true; - } - } - if !changed { - break; - } - } + close(&mut reach, &edges)?; - let closure: Vec = reach - .iter() - .enumerate() - .map(|(i, bits)| { + let mut closure: Vec = (0..n).map(|i| closure_hash(&own, &reach[i], i)).collect(); + + let mut manifests: Vec> = Vec::new(); + if !printers.is_empty() { + use crate::HTMLImportManifest::Part; + let parse_graph = self.parse_graph(); + let pages = parse_graph.html_imports.server_source_indices.len(); + manifests.resize_with(pages, || None); + // A printer's own hash also covers its manifests as they print, with a listed chunk's hash for its path and etag. + for &printer in &printers { + let crate::chunk::IntermediateOutput::Pieces(pieces) = + &chunks[printer].intermediate_output + else { + continue; + }; let mut hash = ContentHasher::default(); - hash.write(&own[i].to_ne_bytes()); - // A manifest reaches the chunks of several pages, and their order differs between builds. - let reaches_manifest = printers.iter().any(|&printer| bits.is_set(printer)); - let mut rest: u64 = 0; - let mut iter = bits.iterator::(); - while let Some(j) = iter.next() { - if j == i { + hash.write(&own[printer].to_ne_bytes()); + for piece in pieces.slice() { + if piece.query.kind() != crate::chunk::QueryKind::HtmlImport { continue; } - if reaches_manifest { - rest = rest.wrapping_add(own[j]); - } else { - hash.write(&own[j].to_ne_bytes()); + let index = piece.query.index(); + let manifest = match &mut manifests[index as usize] { + Some(manifest) => manifest, + unset => unset.insert(crate::HTMLImportManifest::emit( + index, + parse_graph, + &self.graph, + chunks, + )?), + }; + for part in manifest.parts() { + match part { + Part::Literal(bytes) => hash.write(bytes), + Part::Chunk(listed, _) => { + hash.write(&closure[listed as usize].to_ne_bytes()); + edges[printer].push(listed); + } + } } } - if reaches_manifest { - hash.write(&rest.to_ne_bytes()); + own[printer] = hash.digest(); + } + // A chunk that reaches a printer prints the printer's path. + for i in 0..n { + if printers.iter().any(|&printer| reach[i].is_set(printer)) { + closure[i] = closure_hash(&own, &reach[i], i); } - hash.digest() - }) - .collect(); + } + // A wider hash of a listed chunk changes what the manifest prints. + close(&mut reach, &edges)?; + } use bun_core::fmt::ContentHash; let min_len: Vec = chunks diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index df1bfdb83769..2dc83724c893 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -630,17 +630,22 @@ console.log("About manifest:", aboutHtml); interface Options { entrypoints?: string[]; splitting?: boolean; + minify?: boolean; assetNaming?: string; plugins?: BunPlugin[]; } /** Every output of one build, sorted by path. */ - async function build(dir: string, { entrypoints = ["server.js"], splitting, assetNaming, plugins }: Options = {}) { + async function build( + dir: string, + { entrypoints = ["server.js"], splitting, minify, assetNaming, plugins }: Options = {}, + ) { const result = await Bun.build({ entrypoints: entrypoints.map(entrypoint => join(dir, entrypoint)), outdir: join(dir, "out"), target: "bun", splitting, + minify, naming: { entry: "[name]-[hash].[ext]", asset: assetNaming }, plugins, }); @@ -660,7 +665,8 @@ console.log("About manifest:", aboutHtml); /** The outputs that print a manifest, then the outputs that import one of those by name. */ function printers(outputs: Output[]) { - const printing = outputs.filter(output => output.text.includes("__jsonParse(")); + // The manifest's JSON in a string literal. Minified, `__jsonParse` has another name. + const printing = outputs.filter(output => output.text.includes(String.raw`\"files\":[`)); return [...printing, ...outputs.filter(output => printing.some(printer => output.text.includes(printer.path)))]; } @@ -698,7 +704,12 @@ console.log("About manifest:", aboutHtml); interface Edit extends Options { files: Record; - edit: [file: string, contents: string]; + /** The new contents of each file that changes. */ + edit: Record; + } + + function write(dir: string, edit: Record) { + for (const [file, contents] of Object.entries(edit)) writeFileSync(join(dir, file), contents); } /** The edit renames `renames` and keeps `keeps`. No other name and content go apart, with or without the edit. */ @@ -708,7 +719,7 @@ console.log("About manifest:", aboutHtml); const second = await build(String(dir), options); expect(second).toEqual(first); - writeFileSync(join(String(dir), edit[0]), edit[1]); + write(String(dir), edit); const edited = await build(String(dir), options); expect(changes(second, edited)).toEqual( Object.fromEntries([...renames.map(output => [output, renamed]), ...keeps.map(output => [output, kept])]), @@ -719,33 +730,33 @@ console.log("About manifest:", aboutHtml); const cases: (Omit & { changes: string; renames: string[] })[] = [ { changes: "client JS", - edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + edit: { "client.js": `import("./lazy.js"); console.log("changed");` }, renames: ["server.js"], }, - { changes: "CSS", edit: ["style.css", `body { color: blue; }`], renames: ["server.js"] }, - { changes: "an asset", edit: ["logo.png", "changed"], renames: ["server.js"] }, + { changes: "CSS", edit: { "style.css": `body { color: blue; }` }, renames: ["server.js"] }, + { changes: "an asset", edit: { "logo.png": "changed" }, renames: ["server.js"] }, { // The asset keeps its path. Only its etag in the manifest changes. changes: "an asset with an unhashed name", - edit: ["logo.png", "changed"], + edit: { "logo.png": "changed" }, assetNaming: "[name].[ext]", renames: ["server.js"], }, { // Only the page's own path and etag change in the manifest. changes: "the page's own markup", - edit: ["index.html", files["index.html"].replace("", "changed")], + edit: { "index.html": files["index.html"].replace("", "changed") }, renames: ["server.js"], }, { changes: "the stylesheet of a lazy chunk", - edit: ["lazy.css", `p { color: purple; }`], + edit: { "lazy.css": `p { color: purple; }` }, splitting: true, renames: ["server.js"], }, { changes: "the asset of a lazy chunk, with an unhashed name", - edit: ["icon.png", "changed"], + edit: { "icon.png": "changed" }, splitting: true, assetNaming: "[name].[ext]", renames: ["server.js"], @@ -753,14 +764,14 @@ console.log("About manifest:", aboutHtml); { // Both entry points print the manifest of the one page. changes: "client JS under two entry points", - edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + edit: { "client.js": `import("./lazy.js"); console.log("changed");` }, entrypoints: ["server.js", "other.js"], renames: ["other.js", "server.js"], }, { // The manifest moves to a chunk that both entry points import. changes: "client JS under two entry points that share a chunk", - edit: ["client.js", `import("./lazy.js"); console.log("changed");`], + edit: { "client.js": `import("./lazy.js"); console.log("changed");` }, entrypoints: ["server.js", "other.js"], splitting: true, renames: ["chunk.js", "other.js", "server.js"], @@ -794,7 +805,7 @@ console.log("About manifest:", aboutHtml); }; const options: Edit = { files, - edit: ["b.client.js", `console.log("changed");`], + edit: { "b.client.js": `console.log("changed");` }, entrypoints: ["sa.js", "sb.js"], }; await expectRenames(options, ["sb.js"], ["sa.js"]); @@ -809,7 +820,7 @@ console.log("About manifest:", aboutHtml); "server.js": `import a from "./a.html"; import b from "./b.html"; console.log(b);`, ...twoPages, }; - await expectRenames({ files, edit: [file, `console.log("changed");`] }, renames, keeps); + await expectRenames({ files, edit: { [file]: `console.log("changed");` } }, renames, keeps); }); /** Holds the files `held` until the scan has found every file that it can find without them. */ @@ -830,6 +841,13 @@ console.log("About manifest:", aboutHtml); "routes/b.js": `import b from "../b.html"; export { b };`, ...twoPages, }; + // Both pages load one lazy chunk. + const sharedLazyChunk = { + ...routes, + "a.client.js": `import("./shared.js"); console.log("a");`, + "b.client.js": `import("./shared.js"); console.log("b");`, + "shared.js": `console.log("shared");`, + }; const lazyStylesheets = { "server.js": files["server.js"], "index.html": ``, @@ -840,8 +858,27 @@ console.log("About manifest:", aboutHtml); "lazy1.css": `.lazy1 { color: blue; }`, }; + // Minified, a stylesheet's chunk is its rules alone. The two chunks keep + // their names and contents, and each is now listed where the other was. + test.each([ + { + owners: "pages", + files: routes, + edit: { "a.css": twoPages["b.css"], "b.css": twoPages["a.css"] }, + splitting: false, + }, + { + owners: "lazy chunks of one page", + files: lazyStylesheets, + edit: { "lazy0.css": lazyStylesheets["lazy1.css"], "lazy1.css": lazyStylesheets["lazy0.css"] }, + splitting: true, + }, + ])("when two $owners trade their stylesheets", async ({ owners, ...options }) => { + await expectRenames({ ...options, minify: true }, ["server.js"]); + }); + // The order in which the scan finds files decides the order of the pages, - // of their CSS chunks and of the files they copy. A chunk that prints a + // of their chunks and of the files they copy. A chunk that prints a // manifest has the same bytes either way, so it has one name. test.each([ { @@ -849,7 +886,7 @@ console.log("About manifest:", aboutHtml); files: routes, held: ["a.js", "b.js"], options: {}, - edit: ["b.css", `.b { color: green; }`], + edit: { "b.css": `.b { color: green; }` }, renames: ["server.js"], }, { @@ -857,15 +894,23 @@ console.log("About manifest:", aboutHtml); files: routes, held: ["a.js", "b.js"], options: { entrypoints: ["server.js", "other.js"], splitting: true }, - edit: ["b.css", `.b { color: green; }`], + edit: { "b.css": `.b { color: green; }` }, renames: ["chunk.js", "other.js", "server.js"], }, + { + shape: "two pages that load one lazy chunk", + files: sharedLazyChunk, + held: ["a.js", "b.js"], + options: { splitting: true }, + edit: { "shared.js": `console.log("changed");` }, + renames: ["server.js"], + }, { shape: "two lazy stylesheets of one page", files: lazyStylesheets, held: ["lazy0.js", "lazy1.js"], options: { splitting: true }, - edit: ["lazy1.css", `.lazy1 { color: green; }`], + edit: { "lazy1.css": `.lazy1 { color: green; }` }, renames: ["server.js"], }, ])("when the scan finds files in another order: $shape", async ({ files, held, options, edit, renames }) => { @@ -876,12 +921,30 @@ console.log("About manifest:", aboutHtml); ]; expect(second).toEqual(first); - writeFileSync(join(String(dir), edit[0]), edit[1]); + write(String(dir), edit); const edited = await build(String(dir), { ...options, plugins: [findLast(held[0])] }); expect(changes(second, edited)).toEqual(Object.fromEntries(renames.map(output => [output, renamed]))); expect(mismatches(second, edited)).toEqual(none); }); + // Each page's script reaches two lazy chunks, and the scan order decides + // which has the lower index. That names the script, so the two builds + // print two manifests. + test("when the scan order renames a listed chunk", async () => { + using dir = tempDir("html-import-hash-listed-order", { + ...sharedLazyChunk, + "a.client.js": `import("./a.lazy.js"); import("./shared.js"); console.log("a");`, + "b.client.js": `import("./b.lazy.js"); import("./shared.js"); console.log("b");`, + "a.lazy.js": `console.log("lazy a");`, + "b.lazy.js": `console.log("lazy b");`, + }); + const [first, second] = [ + await build(String(dir), { splitting: true, plugins: [findLast("a.js")] }), + await build(String(dir), { splitting: true, plugins: [findLast("b.js")] }), + ]; + expect(mismatches(first, second).oneNameTwoContents).toEqual([]); + }); + // `bun build` writes the chunks to disk itself. test.each(["bun", "node"])("when client JS changes, from the CLI with --target=%s", async target => { using dir = tempDir("html-import-hash-cli", files); @@ -917,13 +980,9 @@ console.log("About manifest:", aboutHtml); }); }); - // A chunk's final hash covers the set of chunks it reaches, not which - // chunk each of its references names. `lazy.js` goes from `a.js` to `b.js` - // here and the page reaches both either way, so the lazy chunk is renamed - // and the manifest lists its new path, while the server chunk keeps its - // name and hash: `{ "server.js": { text: true, path: false, hash: false } }`. - // The page's own script, which imports the lazy chunk by name, is stale the same way. - test.todo("when a lazy chunk imports another chunk that the page already reaches", async () => { + // `lazy.js` goes from `a.js` to `b.js`, and the page reaches both either + // way. Only the name of the lazy chunk changes, and the manifest lists it. + test("when a lazy chunk imports another chunk that the page already reaches", async () => { using dir = tempDir("html-import-hash-retarget", { "server.js": files["server.js"], "index.html": ``, From c823cbbbfbdb1f50d36c87e31954c61cfd38efe6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:46:21 +0000 Subject: [PATCH 06/12] bundler: order a manifest's files by kind and printed input The listing sorted by a source's absolute native path, so a Windows build and a POSIX build could list one tree in two orders: `lazy/x.js` is before `lazy0.js` only with `/`. The key is now the printed `input`, which is relative to the root and has `/` separators. Chunks and assets share one list, and a hash of the contents breaks a tie for both. Tests: the listing order, an entry point two imports above the chunk that prints the manifest, two chunks with no input under two scan orders, and a change of only the widths of the listed hashes. The scan order test now checks that a listed chunk has two names. --- src/bundler/HTMLImportManifest.rs | 202 ++++++++++++---------- test/bundler/html-import-manifest.test.ts | 125 ++++++++++++- 2 files changed, 229 insertions(+), 98 deletions(-) diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 14a357602e2a..3b0a92829802 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -162,8 +162,26 @@ pub(crate) struct Manifest { pub(crate) escaped: Vec, } -/// What orders a listing: a rank, the file's source path, its isolated hash, and then its index. -type ListingKey<'a> = (u8, &'a [u8], u64, u32); +/// What a manifest lists, in the order it lists them. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + PageScript, + Page, + Script, + Stylesheet, + Asset, +} + +/// A file that a manifest lists, before it is written. +struct Listed { + kind: Kind, + /// Its `input`: a range of the buffer that holds the `input` of every listed file. + input: core::ops::Range, + /// Of its contents. Orders the files that have one `input`, or none. + hash: u64, + /// Of the chunk, or of the asset in `additional_output_files`. + index: u32, +} /// Where an entry's path and etag come from. #[derive(Clone, Copy)] @@ -279,7 +297,6 @@ pub(crate) fn emit( // Use the server-side public path here. let public_path: &[u8] = &options.public_path; let mut temp_buffer: Vec = Vec::new(); - let mut input_buffer: Vec = Vec::new(); for (chunk_index, ch) in chunks.iter().enumerate() { if ch.entry_point.source_index() == browser_source_index && ch.entry_point.is_entry_point() @@ -324,111 +341,109 @@ pub(crate) fn emit( writer.write_all(b"\"files\":[")?; - let mut first = true; - let additional_output_files = &graph.additional_output_files; let file_entry_bits: &[AutoBitSet] = linker_graph.files.items_entry_bits(); - // Chunk index and the order of copied files follow the scan order, so neither orders the listing. - let mut listed: Vec> = Vec::new(); + // Chunk index and the order of the assets follow the scan order, so the printed `input` orders the listing. + let mut inputs: Vec = Vec::new(); + let mut listed: Vec = Vec::new(); for (chunk_index, ch) in chunks.iter().enumerate() { if !chunks_to_write.is_set(chunk_index) { continue; } - let is_page = ch.is_entry_point_file(browser_source_index); - let rank = match ch.content { - Content::Javascript(_) if is_page => 0, - Content::Html => 1, - Content::Javascript(_) => 2, - Content::Css(_) => 3, - }; - let source_path: &[u8] = if ch.entry_point.is_entry_point() { - sources[ch.entry_point.source_index() as usize].path.text - } else { - b"" + let kind = match ch.content { + Content::Javascript(_) if ch.is_entry_point_file(browser_source_index) => { + Kind::PageScript + } + Content::Html => Kind::Page, + Content::Javascript(_) => Kind::Script, + Content::Css(_) => Kind::Stylesheet, }; - listed.push((rank, source_path, ch.isolated_hash, chunk_index as u32)); - } - listed.sort_unstable(); - - for (_, _, _, chunk_index) in listed { - let ch = &chunks[chunk_index as usize]; - if !first { - writer.write_all(b",")?; + let start = inputs.len(); + if ch.entry_point.is_entry_point() { + let source = &sources[ch.entry_point.source_index() as usize]; + append_input(&mut inputs, root_dir, source.path.text); } - first = false; - - let input: &[u8] = if !ch.entry_point.is_entry_point() { - b"" - } else { - source_path_relative_to_root( - &mut input_buffer, - root_dir, - sources[ch.entry_point.source_index() as usize].path.text, - ) - }; - - write_entry_item( - writer, - &mut refs, - input, - Entry::Chunk(chunk_index), - ch.content.loader(), - if ch.entry_point.is_entry_point() { - OutputKind::EntryPoint - } else { - OutputKind::Chunk - }, - )?; + listed.push(Listed { + kind, + input: start..inputs.len(), + hash: ch.isolated_hash, + index: chunk_index as u32, + }); } - - let mut assets: Vec> = Vec::new(); for (i, output_file) in additional_output_files.iter().enumerate() { if let Some(source_index) = output_file.source_index.unwrap() { if source_index.get() != server_source_index && file_entry_bits[source_index.get() as usize].has_intersection(&entry_point_bits) { - assets.push(( - 0, - sources[source_index.get() as usize].path.text, - 0, - i as u32, - )); + let start = inputs.len(); + let source = &sources[source_index.get() as usize]; + append_input(&mut inputs, root_dir, source.path.text); + listed.push(Listed { + kind: Kind::Asset, + input: start..inputs.len(), + hash: output_file.hash.value, + index: i as u32, + }); } } } - assets.sort_unstable(); + listed.sort_unstable_by(|a, b| { + let key = |file: &Listed| { + ( + file.kind, + &inputs[file.input.clone()], + file.hash, + file.index, + ) + }; + key(a).cmp(&key(b)) + }); - for (_, source_path, _, i) in assets { - let output_file = &additional_output_files[i as usize]; - if !first { + for (position, file) in listed.iter().enumerate() { + if position > 0 { writer.write_all(b",")?; } - first = false; - - let path_for_key = source_path_relative_to_root(&mut input_buffer, root_dir, source_path); - - let path: &[u8] = if inject_compiler_filesystem_prefix { - temp_buffer.clear(); - temp_buffer.extend_from_slice(public_path); - temp_buffer - .extend_from_slice(strings::remove_leading_dot_slash(&output_file.dest_path)); - &temp_buffer[..] + let input = &inputs[file.input.clone()]; + + if file.kind == Kind::Asset { + let output_file = &additional_output_files[file.index as usize]; + let path: &[u8] = if inject_compiler_filesystem_prefix { + temp_buffer.clear(); + temp_buffer.extend_from_slice(public_path); + temp_buffer + .extend_from_slice(strings::remove_leading_dot_slash(&output_file.dest_path)); + &temp_buffer[..] + } else { + &output_file.dest_path[..] + }; + + write_entry_item( + writer, + &mut refs, + input, + Entry::Asset { + path, + hash: output_file.hash.value, + }, + output_file.loader, + output_file.output_kind, + )?; } else { - &output_file.dest_path[..] - }; - - write_entry_item( - writer, - &mut refs, - path_for_key, - Entry::Asset { - path, - hash: output_file.hash.value, - }, - output_file.loader, - output_file.output_kind, - )?; + let ch = &chunks[file.index as usize]; + write_entry_item( + writer, + &mut refs, + input, + Entry::Chunk(file.index), + ch.content.loader(), + if ch.entry_point.is_entry_point() { + OutputKind::EntryPoint + } else { + OutputKind::Chunk + }, + )?; + } } writer.write_all(b"]}")?; @@ -439,17 +454,12 @@ pub(crate) fn emit( }) } -/// The manifest's `input` / asset-key form of a source path: root-relative, `/`-separated. -fn source_path_relative_to_root<'b>( - buf: &'b mut Vec, - root_dir: &[u8], - path: &[u8], -) -> &'b [u8] { - buf.clear(); - buf.extend_from_slice(strings::remove_leading_dot_slash(relative_normalized::< +/// Appends the manifest's `input` / asset-key form of a source path: root-relative, `/`-separated. +fn append_input(inputs: &mut Vec, root_dir: &[u8], path: &[u8]) { + let start = inputs.len(); + inputs.extend_from_slice(strings::remove_leading_dot_slash(relative_normalized::< platform::Auto, false, >(root_dir, path))); - platform_to_posix_in_place(&mut buf[..]); - buf + platform_to_posix_in_place(&mut inputs[start..]); } diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 2dc83724c893..769a015ad74c 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -631,6 +631,7 @@ console.log("About manifest:", aboutHtml); entrypoints?: string[]; splitting?: boolean; minify?: boolean; + chunkNaming?: string; assetNaming?: string; plugins?: BunPlugin[]; } @@ -638,7 +639,7 @@ console.log("About manifest:", aboutHtml); /** Every output of one build, sorted by path. */ async function build( dir: string, - { entrypoints = ["server.js"], splitting, minify, assetNaming, plugins }: Options = {}, + { entrypoints = ["server.js"], splitting, minify, chunkNaming, assetNaming, plugins }: Options = {}, ) { const result = await Bun.build({ entrypoints: entrypoints.map(entrypoint => join(dir, entrypoint)), @@ -646,7 +647,7 @@ console.log("About manifest:", aboutHtml); target: "bun", splitting, minify, - naming: { entry: "[name]-[hash].[ext]", asset: assetNaming }, + naming: { entry: "[name]-[hash].[ext]", chunk: chunkNaming, asset: assetNaming }, plugins, }); expect(result.logs).toBeEmpty(); @@ -657,9 +658,16 @@ console.log("About manifest:", aboutHtml); text: await output.text(), })), ); + // Every file that a manifest lists is a file of this build. + const written = new Set(outputs.map(output => "./" + output.path)); + const listed = outputs.flatMap(output => parseManifests(output.text)).flatMap(listedPaths); + expect(listed.filter(path => !written.has(path))).toEqual([]); return outputs.sort((a, b) => (a.path < b.path ? -1 : 1)); } + /** The page and the files that a manifest lists, in print order. */ + const listedPaths = (manifest: Manifest) => [manifest.index, ...manifest.files.map(file => file.path)]; + /** An output's name without its hash. */ const label = (output: Output) => output.path.replace(/-[^-.]+(?=\.[a-z]+$)/, ""); @@ -782,6 +790,30 @@ console.log("About manifest:", aboutHtml); await expectRenames({ files, ...options }, renames); }); + // `server.js` and `other.js` import a chunk, and that chunk imports the chunk that prints the manifest. + test("when client JS changes two imports below an entry point", async () => { + using dir = tempDir("html-import-hash-two-levels", { + ...files, + "page.js": `import page from "./index.html"; export { page };`, + "mid.js": `import { page } from "./page.js"; export const mid = ["mid", page];`, + "server.js": `import { mid } from "./mid.js"; console.log(mid);`, + "other.js": `import { mid } from "./mid.js"; console.log("other", mid);`, + "third.js": `import { page } from "./page.js"; console.log("third", page);`, + }); + const options = { entrypoints: ["server.js", "other.js", "third.js"], splitting: true }; + const before = await build(String(dir), options); + write(String(dir), { "client.js": `import("./lazy.js"); console.log("changed");` }); + const after = await build(String(dir), options); + + const kept = new Set(after.map(output => output.path)); + const serverSide = before.filter( + output => /^(server|other|third)-/.test(output.path) || printers(before).includes(output), + ); + expect(serverSide.map(label).sort()).toEqual(["chunk.js", "chunk.js", "other.js", "server.js", "third.js"]); + expect(serverSide.filter(output => kept.has(output.path)).map(label)).toEqual([]); + expect(mismatches(before, after)).toEqual(none); + }); + // Each page has its own stylesheet, script and image, and both show one logo. const page = (name: string) => ``; @@ -942,9 +974,98 @@ console.log("About manifest:", aboutHtml); await build(String(dir), { splitting: true, plugins: [findLast("a.js")] }), await build(String(dir), { splitting: true, plugins: [findLast("b.js")] }), ]; + const listed = (outputs: Output[]) => parseManifests(printers(outputs)[0].text).map(listedPaths); + expect(listed(second)).not.toEqual(listed(first)); expect(mismatches(first, second).oneNameTwoContents).toEqual([]); }); + // No entry point owns the chunks of `x.js` and `y.js`, so a manifest gives + // them no `input`. The scan order decides which has the lower index. + test("when the scan order trades the indexes of two chunks with no input", async () => { + const route = (name: string, shared: string) => + `import { ${shared} } from "./${shared}.js"; console.log("${name}", ${shared});`; + using dir = tempDir("html-import-hash-no-input", { + ...routes, + "a.client.js": `import("./a.route1.js"); import("./a.route2.js");`, + "b.client.js": `import("./b.route1.js"); import("./b.route2.js");`, + "a.route1.js": route("a", "x"), + "a.route2.js": route("a", "y"), + "b.route1.js": route("b", "y"), + "b.route2.js": route("b", "x"), + "x.js": `export const x = "x";`, + "y.js": `export const y = "y";`, + }); + /** The text of each chunk that the first manifest lists with no `input`, in print order. */ + async function withoutInput(held: string) { + const outputs = await build(String(dir), { splitting: true, plugins: [findLast(held)] }); + const [manifest] = parseManifests(printers(outputs)[0].text); + return manifest.files + .filter(file => file.input === undefined) + .map(file => outputs.find(output => "./" + output.path === file.path)!.text); + } + const [first, second] = [await withoutInput("a.js"), await withoutInput("b.js")]; + expect(first).toHaveLength(2); + expect(second).toEqual(first); + }); + + // With `[hash1]` a hash prints one character, and more when two chunks + // would print the same one. The page reaches none of the 128 chunks that + // the second build adds, so they change the names of its 32 lazy chunks + // only through the widths of their hashes. + test("when other chunks make the hashes of listed chunks wider", async () => { + const lazy = Array.from({ length: 32 }, (_, i) => `lazy${i}.js`); + const others = Array.from({ length: 128 }, (_, i) => `x${i}.js`); + using dir = tempDir("html-import-hash-width", { + "server.js": files["server.js"], + "index.html": ``, + "client.js": lazy.map(file => `import("./${file}");`).join(""), + "other.js": `console.log("other");`, + ...Object.fromEntries([...lazy, ...others].map(file => [file, `console.log("${file}");`])), + }); + const options = { entrypoints: ["server.js", "other.js"], splitting: true, chunkNaming: "c[hash1].[ext]" }; + const narrow = await build(String(dir), options); + write(String(dir), { "other.js": others.map(file => `import("./${file}");`).join("") }); + const wide = await build(String(dir), options); + + const manifest = (outputs: Output[]) => parseManifests(printers(outputs)[0].text)[0]; + const inputs = (outputs: Output[]) => manifest(outputs).files.map(file => [file.loader, file.input]); + expect(inputs(wide)).toEqual(inputs(narrow)); + expect(listedPaths(manifest(wide))).not.toEqual(listedPaths(manifest(narrow))); + expect(changes(narrow, wide)).toEqual({ "server.js": renamed }); + }); + + // Not by chunk index, which follows the scan order. Not by a native path + // either: `lazy/x.js` is before `lazy0.js` only with `/`. + test("the manifest lists files by kind, then by input", async () => { + using dir = tempDir("html-import-hash-listing", { + "server.js": files["server.js"], + "index.html": ``, + "client.js": `import("./lazy0.js"); import("./lazy/x.js"); console.log("client");`, + "lazy0.js": `import "./lazy0.css"; console.log("lazy 0");`, + "lazy/x.js": `import "./x.css"; console.log("x");`, + "lazy0.css": `.lazy0 { color: red; }`, + "lazy/x.css": `.x { color: blue; }`, + "style.css": `body { color: green; }`, + "b.png": "b", + "a0.png": "a0", + "a/c.png": "c", + }); + const [server] = printers(await build(String(dir), { splitting: true })); + const [manifest] = parseManifests(server.text); + expect(manifest.files.map(file => [file.loader, file.input])).toEqual([ + ["js", "index.html"], + ["html", "index.html"], + ["js", "lazy/x.js"], + ["js", "lazy0.js"], + ["css", "index.html"], + ["css", "lazy/x.js"], + ["css", "lazy0.js"], + ["file", "a/c.png"], + ["file", "a0.png"], + ["file", "b.png"], + ]); + }); + // `bun build` writes the chunks to disk itself. test.each(["bun", "node"])("when client JS changes, from the CLI with --target=%s", async target => { using dir = tempDir("html-import-hash-cli", files); From f035452b552399bcb86e53d7d0a3573c528b759c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:06:35 +0000 Subject: [PATCH 07/12] test: check that the two chunks with no input trade their indexes --- test/bundler/html-import-manifest.test.ts | 29 +++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 769a015ad74c..e2650afee047 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -636,8 +636,8 @@ console.log("About manifest:", aboutHtml); plugins?: BunPlugin[]; } - /** Every output of one build, sorted by path. */ - async function build( + /** Every output of one build, in chunk index order. */ + async function buildInOrder( dir: string, { entrypoints = ["server.js"], splitting, minify, chunkNaming, assetNaming, plugins }: Options = {}, ) { @@ -662,7 +662,12 @@ console.log("About manifest:", aboutHtml); const written = new Set(outputs.map(output => "./" + output.path)); const listed = outputs.flatMap(output => parseManifests(output.text)).flatMap(listedPaths); expect(listed.filter(path => !written.has(path))).toEqual([]); - return outputs.sort((a, b) => (a.path < b.path ? -1 : 1)); + return outputs; + } + + /** Every output of one build, sorted by path. */ + async function build(dir: string, options?: Options) { + return (await buildInOrder(dir, options)).sort((a, b) => (a.path < b.path ? -1 : 1)); } /** The page and the files that a manifest lists, in print order. */ @@ -995,17 +1000,17 @@ console.log("About manifest:", aboutHtml); "x.js": `export const x = "x";`, "y.js": `export const y = "y";`, }); - /** The text of each chunk that the first manifest lists with no `input`, in print order. */ - async function withoutInput(held: string) { - const outputs = await build(String(dir), { splitting: true, plugins: [findLast(held)] }); + /** Is the chunk of `x.js` before the chunk of `y.js`, by chunk index and in the first manifest? */ + async function xBeforeY(held: string) { + const outputs = await buildInOrder(String(dir), { splitting: true, plugins: [findLast(held)] }); const [manifest] = parseManifests(printers(outputs)[0].text); - return manifest.files - .filter(file => file.input === undefined) - .map(file => outputs.find(output => "./" + output.path === file.path)!.text); + const index = (name: string) => outputs.findIndex(output => output.text.includes(`var ${name} = "${name}"`)); + const listed = (name: string) => + manifest.files.findIndex(file => file.path === "./" + outputs[index(name)].path); + return { byIndex: index("x") < index("y"), listed: listed("x") < listed("y") }; } - const [first, second] = [await withoutInput("a.js"), await withoutInput("b.js")]; - expect(first).toHaveLength(2); - expect(second).toEqual(first); + const [first, second] = [await xBeforeY("a.js"), await xBeforeY("b.js")]; + expect(second).toEqual({ byIndex: !first.byIndex, listed: first.listed }); }); // With `[hash1]` a hash prints one character, and more when two chunks From 3faea64b4b68d6c82771c344bb5ed96b0571ccd3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:25:59 +0000 Subject: [PATCH 08/12] test: change only the widths of the listed hashes on one graph The two builds of the width test had different graphs: the second one added 128 chunks. That moved the page's chunks to other indexes, so the page's own hash changed for a reason other than a width, and the test passed with the second reach fixpoint removed. Both builds now have the same graph. The 32 chunks that the page does not reach get new contents between the builds, which gives the page's 64 lazy chunks other widths and the same hashes. With the second fixpoint removed, this test fails and the other 36 pass. --- test/bundler/html-import-manifest.test.ts | 37 ++++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index e2650afee047..afac3f8898e3 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1014,29 +1014,30 @@ console.log("About manifest:", aboutHtml); }); // With `[hash1]` a hash prints one character, and more when two chunks - // would print the same one. The page reaches none of the 128 chunks that - // the second build adds, so they change the names of its 32 lazy chunks - // only through the widths of their hashes. - test("when other chunks make the hashes of listed chunks wider", async () => { - const lazy = Array.from({ length: 32 }, (_, i) => `lazy${i}.js`); - const others = Array.from({ length: 128 }, (_, i) => `x${i}.js`); + // would print the same one. The page reaches none of the 32 chunks of + // `other.js`. Their new contents give them new hashes, so the 64 lazy + // chunks of the page keep their hashes and print them at other widths. + test("when only the widths of the listed hashes change", async () => { + const lazy = Array.from({ length: 64 }, (_, i) => `lazy${i}.js`); + const others = Array.from({ length: 32 }, (_, i) => `x${i}.js`); + const logs = (names: string[], tag: string) => + Object.fromEntries(names.map(name => [name, `console.log("${tag}", "${name}");`])); using dir = tempDir("html-import-hash-width", { "server.js": files["server.js"], "index.html": ``, - "client.js": lazy.map(file => `import("./${file}");`).join(""), - "other.js": `console.log("other");`, - ...Object.fromEntries([...lazy, ...others].map(file => [file, `console.log("${file}");`])), + "client.js": lazy.map(name => `import("./${name}");`).join(""), + "other.js": others.map(name => `import("./${name}");`).join(""), + ...logs(lazy, "lazy"), + ...logs(others, "before"), }); const options = { entrypoints: ["server.js", "other.js"], splitting: true, chunkNaming: "c[hash1].[ext]" }; - const narrow = await build(String(dir), options); - write(String(dir), { "other.js": others.map(file => `import("./${file}");`).join("") }); - const wide = await build(String(dir), options); - - const manifest = (outputs: Output[]) => parseManifests(printers(outputs)[0].text)[0]; - const inputs = (outputs: Output[]) => manifest(outputs).files.map(file => [file.loader, file.input]); - expect(inputs(wide)).toEqual(inputs(narrow)); - expect(listedPaths(manifest(wide))).not.toEqual(listedPaths(manifest(narrow))); - expect(changes(narrow, wide)).toEqual({ "server.js": renamed }); + const before = await build(String(dir), options); + write(String(dir), logs(others, "after")); + const after = await build(String(dir), options); + + const listed = (outputs: Output[]) => parseManifests(printers(outputs)[0].text).map(listedPaths); + expect(listed(after)).not.toEqual(listed(before)); + expect(changes(before, after)).toEqual({ "server.js": renamed }); }); // Not by chunk index, which follows the scan order. Not by a native path From cabe3bbcdbe85419677cfe9abbe9e7fde6d392eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:37:10 +0000 Subject: [PATCH 09/12] test: make three manifest checks hold for every build - The width test expects a new name exactly when a listed path changed. The widths follow the hashes of each run, so no path is sure to change. - The test for chunks with no input fails when a manifest does not list them. - parseManifests finds a manifest by its JSON, not by the name of __jsonParse, so the check that each listed file is written also covers the minified builds. --- test/bundler/html-import-manifest.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index afac3f8898e3..1a165152d950 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -19,9 +19,10 @@ interface Manifest { files: ManifestFile[]; } -/** The manifests that a chunk prints, in print order. */ +/** The manifests that a chunk prints, in print order. Minified, the `__jsonParse` that takes each one has another name. */ function parseManifests(code: string): Manifest[] { - return [...code.matchAll(/__jsonParse\("(.+?)"\)/gs)].map(match => JSON.parse(JSON.parse('"' + match[1] + '"'))); + const manifest = /\("(\{\\"(?:index|files)\\":(?:[^"\\]|\\.)*)"\)/g; + return [...code.matchAll(manifest)].map(match => JSON.parse(JSON.parse('"' + match[1] + '"'))); } /** The manifests embedded in a bundled server entry point, in import order. */ @@ -678,8 +679,7 @@ console.log("About manifest:", aboutHtml); /** The outputs that print a manifest, then the outputs that import one of those by name. */ function printers(outputs: Output[]) { - // The manifest's JSON in a string literal. Minified, `__jsonParse` has another name. - const printing = outputs.filter(output => output.text.includes(String.raw`\"files\":[`)); + const printing = outputs.filter(output => parseManifests(output.text).length > 0); return [...printing, ...outputs.filter(output => printing.some(printer => output.text.includes(printer.path)))]; } @@ -810,12 +810,12 @@ console.log("About manifest:", aboutHtml); write(String(dir), { "client.js": `import("./lazy.js"); console.log("changed");` }); const after = await build(String(dir), options); - const kept = new Set(after.map(output => output.path)); + const remaining = new Set(after.map(output => output.path)); const serverSide = before.filter( output => /^(server|other|third)-/.test(output.path) || printers(before).includes(output), ); expect(serverSide.map(label).sort()).toEqual(["chunk.js", "chunk.js", "other.js", "server.js", "third.js"]); - expect(serverSide.filter(output => kept.has(output.path)).map(label)).toEqual([]); + expect(serverSide.filter(output => remaining.has(output.path)).map(label)).toEqual([]); expect(mismatches(before, after)).toEqual(none); }); @@ -1007,6 +1007,7 @@ console.log("About manifest:", aboutHtml); const index = (name: string) => outputs.findIndex(output => output.text.includes(`var ${name} = "${name}"`)); const listed = (name: string) => manifest.files.findIndex(file => file.path === "./" + outputs[index(name)].path); + expect([listed("x"), listed("y")]).not.toContain(-1); return { byIndex: index("x") < index("y"), listed: listed("x") < listed("y") }; } const [first, second] = [await xBeforeY("a.js"), await xBeforeY("b.js")]; @@ -1035,9 +1036,10 @@ console.log("About manifest:", aboutHtml); write(String(dir), logs(others, "after")); const after = await build(String(dir), options); + // The widths follow the hashes of each run, so no single listed path is sure to change. const listed = (outputs: Output[]) => parseManifests(printers(outputs)[0].text).map(listedPaths); - expect(listed(after)).not.toEqual(listed(before)); - expect(changes(before, after)).toEqual({ "server.js": renamed }); + const listedChanged = !Bun.deepEquals(listed(after), listed(before)); + expect(changes(before, after)).toEqual({ "server.js": listedChanged ? renamed : kept }); }); // Not by chunk index, which follows the scan order. Not by a native path From 311dc2b82f0a6d8f07d4780847c4aa9c41360aad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:39:50 +0000 Subject: [PATCH 10/12] test: check which files the manifest lists in the width test --- test/bundler/html-import-manifest.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 1a165152d950..058bd0581da3 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1036,9 +1036,15 @@ console.log("About manifest:", aboutHtml); write(String(dir), logs(others, "after")); const after = await build(String(dir), options); + // Both manifests list the page's own files and no chunk of `other.js`, so only the paths can differ. + const manifest = (outputs: Output[]) => parseManifests(printers(outputs)[0].text)[0]; + const inputs = (outputs: Output[]) => manifest(outputs).files.map(file => `${file.loader} ${file.input}`); + const pageFiles = ["js index.html", "html index.html", ...lazy.map(name => `js ${name}`)].sort(); + expect(inputs(before).sort()).toEqual(pageFiles); + expect(inputs(after).sort()).toEqual(pageFiles); + // The widths follow the hashes of each run, so no single listed path is sure to change. - const listed = (outputs: Output[]) => parseManifests(printers(outputs)[0].text).map(listedPaths); - const listedChanged = !Bun.deepEquals(listed(after), listed(before)); + const listedChanged = !Bun.deepEquals(listedPaths(manifest(after)), listedPaths(manifest(before))); expect(changes(before, after)).toEqual({ "server.js": listedChanged ? renamed : kept }); }); From 80dcfb9f06183ae153e08eff78d85ffe3f905c37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:51:36 +0000 Subject: [PATCH 11/12] test: check that each listed path names the file of its input --- test/bundler/html-import-manifest.test.ts | 37 ++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 058bd0581da3..a6e042bf7230 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1064,20 +1064,29 @@ console.log("About manifest:", aboutHtml); "a0.png": "a0", "a/c.png": "c", }); - const [server] = printers(await build(String(dir), { splitting: true })); - const [manifest] = parseManifests(server.text); - expect(manifest.files.map(file => [file.loader, file.input])).toEqual([ - ["js", "index.html"], - ["html", "index.html"], - ["js", "lazy/x.js"], - ["js", "lazy0.js"], - ["css", "index.html"], - ["css", "lazy/x.js"], - ["css", "lazy0.js"], - ["file", "a/c.png"], - ["file", "a0.png"], - ["file", "b.png"], - ]); + const outputs = await build(String(dir), { splitting: true }); + const [manifest] = parseManifests(printers(outputs)[0].text); + // Each entry's loader and input, and a part of the file that its path must name. + const expected = [ + ["js", "index.html", `console.log("client")`], + ["html", "index.html", ``], + ["js", "lazy/x.js", `console.log("x")`], + ["js", "lazy0.js", `console.log("lazy 0")`], + ["css", "index.html", `color: green`], + ["css", "lazy/x.js", `.x {`], + ["css", "lazy0.js", `.lazy0 {`], + ["file", "a/c.png", "c"], + ["file", "a0.png", "a0"], + ["file", "b.png", "b"], + ]; + expect(manifest.files.map(file => [file.loader, file.input])).toEqual( + expected.map(([loader, input]) => [loader, input]), + ); + const text = (path: string) => outputs.find(output => "./" + output.path === path)!.text; + const named = manifest.files.map((file, i) => + file.loader === "file" ? text(file.path) === expected[i][2] : text(file.path).includes(expected[i][2]), + ); + expect(named).toEqual(expected.map(() => true)); }); // `bun build` writes the chunks to disk itself. From eea0482dc55ddae9dbf0900c9cf70cdd2581123f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:28:51 +0000 Subject: [PATCH 12/12] test: keep a printing chunk's own code in its name With the printing chunk's own hash taken out of the manifest fold, every test passed: an edit of only server.js kept the name of server.js, and two entry points that print one page got one hash for two texts. - A new row edits only the server code of one of two entry points. That entry point gets a new name and the other one keeps its name. - Every build now checks that no two of its outputs have one hash and two contents. --- test/bundler/html-import-manifest.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index a6e042bf7230..ed2eca308e58 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -663,6 +663,8 @@ console.log("About manifest:", aboutHtml); const written = new Set(outputs.map(output => "./" + output.path)); const listed = outputs.flatMap(output => parseManifests(output.text)).flatMap(listedPaths); expect(listed.filter(path => !written.has(path))).toEqual([]); + // No two outputs of one build have one hash and two contents. + expect(mismatches(outputs, outputs).oneNameTwoContents).toEqual([]); return outputs; } @@ -740,7 +742,7 @@ console.log("About manifest:", aboutHtml); expect(mismatches(second, edited)).toEqual(none); } - const cases: (Omit & { changes: string; renames: string[] })[] = [ + const cases: (Omit & { changes: string; renames: string[]; keeps?: string[] })[] = [ { changes: "client JS", edit: { "client.js": `import("./lazy.js"); console.log("changed");` }, @@ -789,10 +791,18 @@ console.log("About manifest:", aboutHtml); splitting: true, renames: ["chunk.js", "other.js", "server.js"], }, + { + // Only this entry point's own code changes. Both print the same manifest. + changes: "the server code of one of two entry points", + edit: { "server.js": `import page from "./index.html"; console.log("changed", page);` }, + entrypoints: ["server.js", "other.js"], + renames: ["server.js"], + keeps: ["other.js"], + }, ]; - test.each(cases)("when $changes changes", async ({ renames, ...options }) => { - await expectRenames({ files, ...options }, renames); + test.each(cases)("when $changes changes", async ({ renames, keeps, ...options }) => { + await expectRenames({ files, ...options }, renames, keeps); }); // `server.js` and `other.js` import a chunk, and that chunk imports the chunk that prints the manifest.