bun_bundler 0.1.0

A Rust-native programmable browser runtime built on Servo and SpiderMonkey
//! HTMLImportManifest generates JSON manifests for HTML imports in Bun's bundler.
//!
//! When you import an HTML file in JavaScript:
//! ```javascript
//! import index from "./index.html";
//! console.log(index);
//! ```
//!
//! Bun transforms this into a call to `__jsonParse()` with a JSON manifest containing
//! metadata about all the files generated from the HTML import:
//!
//! ```javascript
//! var src_default = __jsonParse(
//!   '{"index":"./index.html","files":[{"input":"index.html","path":"./index-f2me3qnf.js","loader":"js","isEntry":true,"headers":{"etag": "eet6gn75","content-type": "text/javascript;charset=utf-8"}},{"input":"index.html","path":"./index.html","loader":"html","isEntry":true,"headers":{"etag": "r9njjakd","content-type": "text/html;charset=utf-8"}},{"input":"index.html","path":"./index-gysa5fmk.css","loader":"css","isEntry":true,"headers":{"etag": "50zb7x61","content-type": "text/css;charset=utf-8"}},{"input":"logo.svg","path":"./logo-kygw735p.svg","loader":"file","isEntry":false,"headers":{"etag": "kygw735p","content-type": "application/octet-stream"}},{"input":"react.svg","path":"./react-ck11dneg.svg","loader":"file","isEntry":false,"headers":{"etag": "ck11dneg","content-type": "application/octet-stream"}}]}'
//! );
//! ```
//!
//! The manifest JSON structure contains:
//! - `index`: The original HTML file path
//! - `files`: Array of all generated files with metadata:
//!   - `input`: Original source file path
//!   - `path`: Generated output file path (with content hash)
//!   - `loader`: File type/loader used (js, css, html, file, etc.)
//!   - `isEntry`: Whether this file is an entry point
//!   - `headers`: HTTP headers including ETag and Content-Type
//!
//! This enables applications to:
//! 1. Know all files generated from an HTML import
//! 2. Get proper MIME types and ETags for serving files
//! 3. Implement proper caching strategies
//! 4. Handle assets referenced by the HTML file
//!
//! The manifest is generated during the linking phase and serialized as a JSON string
//! 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_js_printer::Encoding;
use bun_paths::resolve_path::relative_normalized;
use bun_resolver::fs::FileSystem;

use crate::Graph::Graph;
use crate::chunk::{Content, Flags};
use crate::options::{Loader, OutputKind};
use crate::options_impl::LoaderExt as _;
use crate::{BundleV2, Chunk, LinkerGraph};

// TODO(port): lifetime — LIFETIMES.tsv has no rows for this file; classified as
// BORROW_PARAM (transient formatter struct passed by value).
#[derive(Clone, Copy)]
pub struct HTMLImportManifest<'a> {
    pub index: u32,
    pub graph: &'a Graph<'a>,
    pub chunks: &'a [Chunk],
    pub linker_graph: &'a LinkerGraph<'a>,
}

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(()),
            // We use std.fmt.count for this
            // Zig: error.NoSpaceLeft => unreachable, error.OutOfMemory => return error.OutOfMemory
            Err(_) => Err(fmt::Error),
        }
    }
}

fn write_entry_item<W: Write + ?Sized>(
    writer: &mut W,
    input: &[u8],
    path: &[u8],
    hash: u64,
    loader: Loader,
    kind: OutputKind,
) -> Result<(), bun_core::Error> {
    writer.write_all(b"{")?;

    if !input.is_empty() {
        writer.write_all(b"\"input\":")?;
        bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>(input, writer)?;
        writer.write_all(b",")?;
    }

    writer.write_all(b"\"path\":")?;
    bun_js_printer::write_json_string::<_, { Encoding::Utf8 }>(path, writer)?;

    writer.write_all(b",\"loader\":\"")?;
    // Zig: @tagName(loader) — strum is configured snake_case to match.
    writer.write_all(<&'static str>::from(loader).as_bytes())?;
    writer.write_all(b"\",\"isEntry\":")?;
    writer.write_all(if kind == OutputKind::EntryPoint {
        b"true" as &[u8]
    } else {
        b"false"
    })?;
    writer.write_all(b",\"headers\":{")?;

    if hash > 0 {
        const BASE64_BUF_LEN: usize =
            bun_base64::encode_len_from_size(core::mem::size_of::<u64>()) + 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"\",")?;
    }

    // Valid mime types are valid headers, which do not need to be escaped in JSON.
    let mime = loader.to_mime_type(&[path]);
    writer.write_all(b"\"content-type\":\"")?;
    writer.write_all(&mime.value)?;
    writer.write_all(b"\"")?;

    writer.write_all(b"}}")?;
    Ok(())
}

// Extremely unfortunate, but necessary due to E.String not accepting pre-escaped input and this happening at the very end.
pub fn write_escaped_json<W: Write + ?Sized>(
    index: u32,
    graph: &Graph,
    linker_graph: &LinkerGraph<'_>,
    chunks: &[Chunk],
    writer: &mut W,
) -> Result<(), bun_core::Error> {
    // PERF(port): was stack-fallback (std.heap.stackFallback(4096)).
    let mut bytes: Vec<u8> = 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`].
/// Mirrors Zig's `std.fmt.Alt(HTMLImportManifest, escapedJSONFormatter)`.
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(()),
            // We use std.fmt.count for this
            // Zig: error.WriteFailed => unreachable, error.OutOfMemory => return error.WriteFailed
            Err(_) => Err(fmt::Error),
        }
    }
}

impl<'a> HTMLImportManifest<'a> {
    pub fn format_escaped_json(self) -> EscapedJson<'a> {
        EscapedJson(self)
    }
}

pub fn write<W: Write + ?Sized>(
    index: u32,
    graph: &Graph,
    linker_graph: &LinkerGraph<'_>,
    chunks: &[Chunk],
    writer: &mut W,
) -> Result<(), bun_core::Error> {
    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();
    // SAFETY: graph points to BundleV2.graph.
    let bv2: &BundleV2<'_> = unsafe {
        &*bun_core::from_field_ptr!(BundleV2<'static>, graph, std::ptr::from_ref::<Graph>(graph))
    };
    let options = &bv2.transpiler().options;
    let mut entry_point_bits = AutoBitSet::init_empty(graph.entry_points.len())?;

    let root_dir: &[u8] = if !options.root_dir.is_empty() {
        &options.root_dir[..]
    } else {
        // SAFETY: FileSystem singleton is initialized before bundling.
        FileSystem::get().top_level_dir
    };

    writer.write_all(b"{")?;

    let inject_compiler_filesystem_prefix = options.compile;
    // Use the server-side public path here.
    let public_path: &[u8] = &options.public_path;
    let mut temp_buffer: Vec<u8> = Vec::new();

    for ch in chunks.iter() {
        if ch.entry_point.source_index() == browser_source_index && ch.entry_point.is_entry_point()
        {
            entry_point_bits.set(ch.entry_point.entry_point_id() as usize);

            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,
                    )?;
                }
                writer.write_all(b",")?;
            }
        }
    }

    // Start the files array

    writer.write_all(b"\"files\":[")?;

    let mut first = true;

    let additional_output_files = graph.additional_output_files.as_slice();
    let file_entry_bits: &[AutoBitSet] = linker_graph.files.items_entry_bits();
    let mut already_visited_output_file = AutoBitSet::init_empty(additional_output_files.len())?;

    // Write all chunks that have files associated with this entry point.
    // Also include browser chunks from server builds (lazy-loaded chunks from dynamic imports).
    // When there's only one HTML import, all browser chunks belong to that manifest.
    // When there are multiple HTML imports, only include chunks that intersect with this entry's bits.
    let has_single_html_import = graph.html_imports.html_source_indices.len() == 1;
    for ch in chunks.iter() {
        if ch.entry_bits().has_intersection(&entry_point_bits)
            || (has_single_html_import
                && ch.flags.contains(Flags::IS_BROWSER_CHUNK_FROM_SERVER_BUILD))
        {
            if !first {
                writer.write_all(b",")?;
            }
            first = false;

            let input: &[u8] = if !ch.entry_point.is_entry_point() {
                b""
            } else {
                let path_for_key = relative_normalized::<bun_paths::platform::Posix, false>(
                    root_dir,
                    sources[ch.entry_point.source_index() as usize].path.text,
                );
                strings::remove_leading_dot_slash(path_for_key)
            };

            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,
                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.unwrap_or(ch.isolated_hash),
                ch.content.loader(),
                if ch.entry_point.is_entry_point() {
                    OutputKind::EntryPoint
                } else {
                    OutputKind::Chunk
                },
            )?;
        }
    }

    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 = relative_normalized::<bun_paths::platform::Posix, false>(
                    root_dir,
                    sources[source_index.get() as usize].path.text,
                );
                let path_for_key = strings::remove_leading_dot_slash(path_for_key);

                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,
                    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 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(())
    }
}

// ported from: src/bundler/HTMLImportManifest.zig