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..3b0a92829802 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -34,52 +34,171 @@ //! 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. +pub(crate) struct Template { + /// Every byte except the listed chunks' paths and etags. + json: Vec, + /// Where those go, 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, 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); + 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]; + // 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))?; + } + } } + 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, - input: &[u8], - path: &[u8], +/// 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, +} + +/// 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)] +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], + entry: Entry<'_>, loader: Loader, kind: OutputKind, ) -> Result<(), crate::Error> { @@ -92,7 +211,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 +229,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 +248,30 @@ 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. The chunks have no names yet, so their paths and etags become references. +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(); @@ -217,7 +297,6 @@ pub(crate) fn write_with_assets( // 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() @@ -227,21 +306,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",")?; } } @@ -272,51 +341,101 @@ pub(crate) fn write_with_assets( writer.write_all(b"\"files\":[")?; - 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())?; + // 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) { - if !first { - writer.write_all(b",")?; + if !chunks_to_write.is_set(chunk_index) { + continue; + } + let kind = match ch.content { + Content::Javascript(_) if ch.is_entry_point_file(browser_source_index) => { + Kind::PageScript } - 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, - ) - }; + Content::Html => Kind::Page, + Content::Javascript(_) => Kind::Script, + Content::Css(_) => Kind::Stylesheet, + }; + 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); + } + listed.push(Listed { + kind, + input: start..inputs.len(), + hash: ch.isolated_hash, + index: chunk_index as u32, + }); + } + 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) + { + 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, + }); + } + } + } + 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 (position, file) in listed.iter().enumerate() { + if position > 0 { + writer.write_all(b",")?; + } + 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(&ch.final_rel_path)); + .extend_from_slice(strings::remove_leading_dot_slash(&output_file.dest_path)); &temp_buffer[..] } else { - &ch.final_rel_path + &output_file.dest_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::Asset { + path, + hash: output_file.hash.value, + }, + output_file.loader, + output_file.output_kind, + )?; + } else { + 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 @@ -327,108 +446,20 @@ pub(crate) fn write_with_assets( } } - for (i, output_file) in additional_output_files.iter().enumerate() { - // Only print the file once. - if already_visited_output_file.is_set(i) { - continue; - } - - if let Some(source_index) = output_file.source_index.unwrap() { - if source_index.get() == server_source_index { - continue; - } - let bits: &AutoBitSet = &file_entry_bits[source_index.get() as usize]; - - 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, - path_for_key, - path, - output_file.hash.value, - output_file.loader, - output_file.output_kind, - )?; - } - } - } - 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. -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/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index f51818fb9c88..4acabedce6b0 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2583,11 +2583,52 @@ impl<'a> LinkerContext<'a> { pub(crate) fn final_chunk_hashes( &self, chunks: &[Chunk], - ) -> Result, AllocError> { + ) -> Result< + ( + Vec, + Vec>, + ), + 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(); + // 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 +2674,12 @@ impl<'a> LinkerContext<'a> { out.push(chunk_index); } } - crate::chunk::QueryKind::None | crate::chunk::QueryKind::HtmlImport => {} + crate::chunk::QueryKind::HtmlImport => { + if printers.last() != Some(&chunk_index) { + printers.push(chunk_index); + } + } + crate::chunk::QueryKind::None => {} } } } @@ -2648,40 +2694,61 @@ 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]) { + close(&mut reach, &edges)?; + + 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[printer].to_ne_bytes()); + for piece in pieces.slice() { + if piece.query.kind() != crate::chunk::QueryKind::HtmlImport { continue; } - let other = reach[j].clone()?; - reach[i].set_union(&other); - changed = true; + 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); + } + } + } } + own[printer] = hash.digest(); } - if !changed { - break; + // 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); + } } + // A wider hash of a listed chunk changes what the manifest prints. + close(&mut reach, &edges)?; } - let closure: Vec = reach - .iter() - .enumerate() - .map(|(i, bits)| { - let mut hash = ContentHasher::default(); - hash.write(&own[i].to_ne_bytes()); - let mut iter = bits.iterator::(); - while let Some(j) = iter.next() { - if j != i { - hash.write(&own[j].to_ne_bytes()); - } - } - hash.digest() - }) - .collect(); - use bun_core::fmt::ContentHash; let min_len: Vec = chunks .iter() @@ -2710,7 +2777,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..ed2eca308e58 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,15 @@ interface Manifest { files: ManifestFile[]; } +/** The manifests that a chunk prints, in print order. Minified, the `__jsonParse` that takes each one has another name. */ +function parseManifests(code: string): Manifest[] { + 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. */ 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 +604,588 @@ 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; + minify?: boolean; + chunkNaming?: string; + assetNaming?: string; + plugins?: BunPlugin[]; + } + + /** Every output of one build, in chunk index order. */ + async function buildInOrder( + dir: string, + { entrypoints = ["server.js"], splitting, minify, chunkNaming, 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]", chunk: chunkNaming, 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(), + })), + ); + // 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([]); + // No two outputs of one build have one hash and two contents. + expect(mismatches(outputs, outputs).oneNameTwoContents).toEqual([]); + 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. */ + 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]+$)/, ""); + + /** 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 => parseManifests(output.text).length > 0); + 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; + /** 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. */ + 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); + + 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])]), + ); + expect(mismatches(second, edited)).toEqual(none); + } + + const cases: (Omit & { changes: string; renames: string[]; keeps?: 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"], + }, + { + // 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, 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. + 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 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 => remaining.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) => + ``; + 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");`, + "a.png": "a", + "b.png": "b", + "logo.png": "logo", + }; + + 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; + }); + }, + }); + + 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, + }; + // 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": ``, + "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; }`, + }; + + // 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 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: "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"], + }, + { + 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; }` }, + 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); + + 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")] }), + ]; + 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";`, + }); + /** 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); + 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")]; + expect(second).toEqual({ byIndex: !first.byIndex, listed: first.listed }); + }); + + // With `[hash1]` a hash prints one character, and more when two chunks + // 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(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 before = await build(String(dir), options); + 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 listedChanged = !Bun.deepEquals(listedPaths(manifest(after)), listedPaths(manifest(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 + // 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 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. + 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, + }); + }); + + // `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": ``, + "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