From 2929faedadf59147af49444436bc75ffc4f0298c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:52:52 +0000 Subject: [PATCH 1/6] bundler: splice ASCII into chunks built for bun and count the ASCII in front of non-ASCII in source map shifts Chunks printed for bun start with "// @bun", which the runtime loads as Latin-1 without re-parsing, so the printer escapes everything in them to ASCII. The paths and HTML import manifests spliced in afterwards were written as raw UTF-8, and the manifest module itself was printed as browser code because generate_server_html_module left its target at the parser default. A non-ASCII file name therefore produced a bundle that failed to load (or resolved mojibake paths). LineColumnOffset::advance skipped the ASCII run in front of every newline or non-ASCII character it found, so a shift recorded after any non-ASCII text on the same line was too small and the mappings around the splice point were off. --- src/bundler/Chunk.rs | 105 +++++++++++++-------- src/bundler/HTMLImportManifest.rs | 106 +++------------------- src/bundler/bundle_v2.rs | 6 +- src/bundler/lib.rs | 1 - src/sourcemap/lib.rs | 5 + test/bundler/bun-build-api.test.ts | 67 ++++++++++++++ test/bundler/bundler_bun.test.ts | 25 +++++ test/bundler/html-import-manifest.test.ts | 91 ++++++++++++++++++- 8 files changed, 273 insertions(+), 133 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 4feb8d95b3d7..3f9400b97005 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -22,7 +22,7 @@ use crate::bun_css; use crate::bun_fs; use crate::Graph::Graph; -use crate::html_import_manifest as HTMLImportManifest; +use crate::HTMLImportManifest; use crate::options::{self, Loader}; use crate::{ AdditionalFile, CompileResult, LinkerContext, LinkerGraph, PartRange, PathTemplate, @@ -520,6 +520,24 @@ impl IntermediateOutput { dst } + /// Writes the path that replaces a chunk/asset placeholder. `ascii_only` + /// (see `code_with_source_map_shifts`) escapes it the way the printer + /// escapes the double-quoted literal it is spliced into. + fn write_spliced_path( + writer: &mut W, + path: &[u8], + ascii_only: bool, + ) -> Result<(), crate::Error> { + if ascii_only { + bun_js_printer::write_pre_quoted_string_inner::<_, { bun_js_printer::Encoding::Utf8 }>( + path, writer, b'"', true, false, + )?; + } else { + writer.write_all(path)?; + } + Ok(()) + } + pub(crate) fn get_size(&self) -> usize { match self { IntermediateOutput::Pieces(pieces) => { @@ -691,6 +709,15 @@ impl IntermediateOutput { &[] }; + // Every placeholder in a JS chunk sits inside a double-quoted string + // literal (import paths, asset paths, the HTML import manifest). A + // chunk printed for bun starts with `// @bun` (see postProcessJSChunk), + // which makes the runtime load it as Latin-1 without re-parsing it, so + // the printer escaped it to ASCII; what we splice in has to be too. + let ascii_only = chunk.content.is_javascript() + && linker_graph.ast.items_target()[chunk.entry_point.source_index() as usize] + .is_bun(); + for piece in pieces.slice() { count += piece.data.len(); @@ -750,15 +777,17 @@ impl IntermediateOutput { } QueryKind::HtmlImport => { - count += bun_core::fmt::count(format_args!( - "{}", - HTMLImportManifest::format_escaped_json( - piece.query.index(), - graph, - chunks, - linker_graph, - ) - )); + let mut counter = bun_io::DiscardingWriter::new(); + HTMLImportManifest::write_escaped_json( + piece.query.index(), + graph, + linker_graph, + chunks, + ascii_only, + &mut counter, + ) + .expect("unreachable"); + count += counter.count; continue; } QueryKind::None => unreachable!(), @@ -777,7 +806,12 @@ impl IntermediateOutput { ) }, ); - count += cheap_normalizer[0].len() + cheap_normalizer[1].len(); + for part in cheap_normalizer { + let mut counter = bun_io::DiscardingWriter::new(); + Self::write_spliced_path(&mut counter, part, ascii_only) + .expect("unreachable"); + count += counter.count; + } } QueryKind::None => {} } @@ -911,17 +945,20 @@ 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 written = { + let mut stream = + bun_io::FixedBufferStream::new_mut(&mut *remain); + HTMLImportManifest::write_escaped_json( + piece.query.index(), + graph, + linker_graph, + chunks, + ascii_only, + &mut stream, + ) + .expect("unreachable"); + stream.pos + }; if ENABLE_SOURCE_MAP_SHIFTS { // The placeholder was an HtmlImport unique key, which has @@ -962,22 +999,18 @@ impl IntermediateOutput { }, ); - if !cheap_normalizer[0].is_empty() { - remain[..cheap_normalizer[0].len()] - .copy_from_slice(cheap_normalizer[0]); - remain = &mut remain[cheap_normalizer[0].len()..]; - if ENABLE_SOURCE_MAP_SHIFTS { - shift.after.advance(cheap_normalizer[0]); - } - } - - if !cheap_normalizer[1].is_empty() { - remain[..cheap_normalizer[1].len()] - .copy_from_slice(cheap_normalizer[1]); - remain = &mut remain[cheap_normalizer[1].len()..]; + for part in cheap_normalizer { + let written = { + let mut stream = + bun_io::FixedBufferStream::new_mut(&mut *remain); + Self::write_spliced_path(&mut stream, part, ascii_only) + .expect("unreachable"); + stream.pos + }; if ENABLE_SOURCE_MAP_SHIFTS { - shift.after.advance(cheap_normalizer[1]); + shift.after.advance(&remain[..written]); } + remain = &mut remain[written..]; } if ENABLE_SOURCE_MAP_SHIFTS { diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 3b8e54eed82d..6079a580f157 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -34,13 +34,12 @@ //! 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::relative_normalized; use bun_resolver::fs::FileSystem; @@ -51,30 +50,6 @@ use crate::options::{Loader, OutputKind}; use crate::options_impl::LoaderExt as _; use crate::{BundleV2, Chunk, LinkerGraph}; -#[derive(Clone, Copy)] -pub struct HTMLImportManifest<'a> { - pub(crate) index: u32, - pub(crate) graph: &'a Graph<'a>, - pub(crate) chunks: &'a [Chunk], - pub(crate) 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(()), - Err(_) => Err(fmt::Error), - } - } -} - fn write_entry_item( writer: &mut W, input: &[u8], @@ -126,47 +101,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. +/// Writes the manifest as the body of the double-quoted string literal the +/// printer emitted around the placeholder (see `generate_server_html_module`). +/// Extremely unfortunate, but necessary: E.String does not accept pre-escaped +/// input, and this happens at the very end. +/// +/// `ascii_only` must match how the surrounding chunk was printed: a chunk that +/// starts with `// @bun` is loaded as Latin-1 without being re-parsed, so +/// non-ASCII in it has to be escaped like the printer escapes everything else +/// there. pub(crate) fn write_escaped_json( index: u32, graph: &Graph, linker_graph: &LinkerGraph<'_>, chunks: &[Chunk], + ascii_only: bool, 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, + bun_js_printer::write_pre_quoted_string_inner::<_, { Encoding::Utf8 }>( + &bytes, writer, b'"', ascii_only, true, )?; 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) - } -} - pub(crate) fn write( index: u32, graph: &Graph, @@ -340,42 +299,3 @@ pub(crate) fn write( 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(()) - } -} diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index f9be9d28d01a..c75c38d08342 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6892,7 +6892,7 @@ pub mod bv2_impl { (&raw mut *transpiler.options.define, transpiler.log) }; - let ast_for_html_entrypoint = JSAst::init( + let mut ast_for_html_entrypoint = JSAst::init( bun_js_parser::new_lazy_export_ast( heap, // SAFETY: `define`/`log` live for `'a` (owned by the Transpiler). @@ -6913,6 +6913,10 @@ pub mod bv2_impl { )? .unwrap(), ); + // The parser defaults this to `Browser`, but the manifest is server + // code: the printer escapes it and `computeChunks` classifies it by + // this target, same as the file that imported the HTML. + ast_for_html_entrypoint.target = target; let fake_input_file = crate::Graph::InputFile { source: empty_html_file_source.clone(), diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 20338efed2b8..a70fee18c957 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -43,7 +43,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/sourcemap/lib.rs b/src/sourcemap/lib.rs index 5aaf6e92f0f4..e5a797cc565e 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -168,6 +168,11 @@ impl LineColumnOffset { debug_assert!(i >= offset); debug_assert!((i as usize) < input.len()); + // `input[offset..i]` is the ASCII run the search skipped over. + this.columns = this + .columns + .add_scalar(i32::try_from(i - offset).expect("int cast")); + let iter = strings::CodepointIterator::init(input); let mut cursor = strings::Cursor { i, diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 19d6444da1b4..5ca91c167bd8 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1075,6 +1075,73 @@ describe.concurrent("sourcemap positions", () => { } }); }); + + // Asset and chunk paths are spliced into the chunk after it was printed, and + // the mappings behind each splice are shifted by the size difference. The + // splice position is found by walking the printed text in front of it, which + // used to lose the ASCII run in front of every non-ASCII character, so a + // non-ASCII string earlier on the line moved the boundary left and shifted + // mappings that sit before the splice. + test("generated columns before and after a spliced asset path on a line with non-ASCII text", async () => { + const lib = [ + `export function first() { return "${Buffer.alloc(80, "a").toString()}"; }`, + `export function second() { return "é"; }`, + ``, + ].join("\n"); + const entry = [ + `import { first, second } from "./lib";`, + `import asset from "./asset.bin";`, + `export function third() { return [first(), second(), asset]; }`, + ``, + ].join("\n"); + const dir = tempDirWithFiles("build-sourcemap-shift-after-non-ascii", { + "lib.ts": lib, + "entry.ts": entry, + "asset.bin": "asset", + }); + + const build = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + sourcemap: "external", + minify: { whitespace: true }, + }); + expect(build.logs).toBeEmpty(); + + const generated = await build.outputs.find(o => o.kind === "entry-point")!.text(); + const map = await build.outputs.find(o => o.kind === "sourcemap")!.json(); + + // lib.ts, the asset path, and entry.ts all share the first generated line, + // in that order. + const splice = generated.indexOf('asset_default="./asset-'); + expect(generated.slice(0, splice)).toContain('"é"'); + expect(generated.slice(0, splice)).not.toContain("\n"); + + const lineColumn = (text: string, index: number) => { + expect(index).not.toBe(-1); + const before = text.slice(0, index); + return { line: before.split("\n").length, column: index - (before.lastIndexOf("\n") + 1) }; + }; + + await SourceMapConsumer.with(map, null, consumer => { + const positions = [ + { token: "function first(", file: "lib.ts", source: lib }, + { token: "function second(", file: "lib.ts", source: lib }, + { token: "function third(", file: "entry.ts", source: entry }, + ].map(({ token, file, source }) => { + const mapped = consumer.generatedPositionFor({ + source: consumer.sources.find(s => s.endsWith(file))!, + ...lineColumn(source, source.indexOf(token)), + }); + return { token, line: mapped.line, column: mapped.column }; + }); + expect(positions).toEqual([ + { token: "function first(", ...lineColumn(generated, generated.indexOf("function first(")) }, + { token: "function second(", ...lineColumn(generated, generated.indexOf("function second(")) }, + { token: "function third(", ...lineColumn(generated, generated.indexOf("function third(")) }, + ]); + }); + }); }); const originalCwd = process.cwd() + ""; diff --git a/test/bundler/bundler_bun.test.ts b/test/bundler/bundler_bun.test.ts index 674da11059bb..6e68defc8a6d 100644 --- a/test/bundler/bundler_bun.test.ts +++ b/test/bundler/bundler_bun.test.ts @@ -137,6 +137,31 @@ error: Hello World`, }, run: { stdout: "" }, }); + // Output for bun starts with `// @bun`, which the runtime loads as Latin-1 + // without re-parsing, so the chunk and asset paths spliced into the printed + // code have to be escaped to ASCII like the rest of the file. Spliced in raw, + // "./é-.js" is read back as "./é-.js". + itBundled("bun/NonAsciiChunkAndAssetPaths", { + target: "bun", + splitting: true, + outdir: "/out", + files: { + "/entry.ts": /* js */ ` + import asset from "./dätä.bin"; + const { value } = await import("./modülé"); + console.log(value); + console.log(await Bun.file(import.meta.dir + "/" + asset).text()); + `, + "/modülé.ts": /* js */ `export const value = "from modülé";`, + "/dätä.bin": `asset contents`, + }, + run: { file: "/out/entry.js", stdout: "from modülé\nasset contents" }, + onAfterBundle(api) { + // The output files keep their names; the references to them are escaped. + api.expectFile("/out/entry.js").toMatch(/_default = "\.\/[\w\\-]+\.bin";/); + api.expectFile("/out/entry.js").toMatch(/import\("\.\/[\w\\-]+\.js"\)/); + }, + }); if (Bun.version.startsWith("1.4") || Bun.version.startsWith("1.3") || Bun.version.startsWith("1.2")) { for (const backend of ["api", "cli"] as const) { itBundled("bun/ExportsConditionsDevelopment" + backend.toUpperCase(), { diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index e18ac20bd8a8..657d9ff3c625 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunRun, tempDir } from "harness"; import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { SourceMapConsumer } from "source-map"; import { itBundled } from "./expectBundled"; @@ -454,6 +454,93 @@ console.log("About manifest:", aboutHtml); }); }); + // A chunk built for bun starts with `// @bun`, which makes the runtime load it + // as Latin-1 without re-parsing it, so everything in it has to be ASCII. The + // printer escapes the code it prints; the manifest module (whose default + // export is named after the HTML file) and the manifest JSON spliced in + // afterwards have to be escaped the same way, or a non-ASCII file name shows + // up as raw UTF-8: the identifier is a syntax error and the paths inside the + // manifest decode as mojibake. + test("html-import/non-ascii-file-name-loads-under-target-bun", async () => { + await using dir = tempDir("html-import-non-ascii", { + "server.ts": `import page from "./sidé.html";\nconsole.log(JSON.stringify(page));\n`, + "sidé.html": ``, + "sidé.ts": `console.log("client");`, + }); + + const build = await Bun.build({ + entrypoints: [join(dir, "server.ts")], + outdir: join(dir, "out"), + target: "bun", + }); + expect(build.logs).toBeEmpty(); + + const server = build.outputs.find(o => basename(o.path) === "server.js")!; + // Only the `/* path */` comments in front of each module may carry the + // file name verbatim; the code itself has to be ASCII. + const code = (await server.text()) + .split("\n") + .filter(line => !line.startsWith("/* ") && !line.startsWith("// ")) + .join("\n"); + expect(code).toContain("__jsonParse("); + expect(code).toMatch(/^[\x00-\x7f]*$/); + + const result = await bunRun(server.path); + expect(result).toSpawn(); + const manifest = JSON.parse(result.stdout); + expect(manifest.index).toBe("./sidé.html"); + expect(manifest.files.map(({ input, path, loader }: any) => ({ input, path, loader }))).toEqual([ + { input: "sidé.html", path: expect.stringMatching(/\.js$/), loader: "js" }, + { input: "sidé.html", path: "./sidé.html", loader: "html" }, + ]); + }); + + // Same idea as source-map-columns-after-manifest, but with a non-ASCII HTML + // file name, which puts non-ASCII into the spliced manifest unless it is + // escaped. The runtime reads the chunk as Latin-1, so the columns in its stack + // traces count bytes; the shift recorded for the manifest only matches that + // when the manifest was spliced in as ASCII. (Full minification so that the + // identifier named after the file does not fail the load first; that is + // covered above.) + test("html-import/non-ascii-file-name-source-map-columns", async () => { + const source = [ + `import page from "./sidé.html";`, + `function boom() {`, + ` throw new Error(page.index);`, + `}`, + `boom();`, + ``, + ].join("\n"); + await using dir = tempDir("html-import-non-ascii-sourcemap", { + "server.ts": source, + "sidé.html": ``, + "sidé.ts": `console.log("client");`, + }); + + const build = await Bun.build({ + entrypoints: [join(dir, "server.ts")], + outdir: join(dir, "out"), + target: "bun", + sourcemap: "inline", + minify: true, + }); + expect(build.logs).toBeEmpty(); + + const result = await bunRun(build.outputs.find(o => basename(o.path) === "server.js")!.path); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("error: ./sidé.html"); + // 1-based line:column of the Error construction inside boom() (`new` is + // minified away, so the frame points at `Error`) and of the boom() call. + const lines = source.split("\n"); + const position = (token: string) => { + const line = lines.findIndex(l => l.includes(token)); + return `${line + 1}:${lines[line].indexOf(token) + 1}`; + }; + const frames = [...result.stderr.matchAll(/server\.ts:(\d+:\d+)/g)].map(m => m[1]); + expect(frames.slice(0, 2)).toEqual([position("Error("), position("boom();")]); + expect(result.exitCode).toBe(1); + }); + // Test that import with {type: 'file'} still works as a file import itBundled("html-import/with-type-file-attribute", { outdir: "out/", From d9b9cee46fa6dd4706e54e5b186fe454ec450000 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:59:09 +0000 Subject: [PATCH 2/6] bundler: escape every path spliced into a JS chunk, and resolve it the same way in both assembly passes The sizing pass used to measure the path before the Windows separator normalization the writing pass applies, which was harmless while both copied the bytes through but over-counted once the path was escaped (each backslash counted twice), leaving NUL bytes at the end of the chunk on Windows. Both passes now resolve the path through one helper. Escaping now applies to every JS chunk, not only to chunks built for bun: the placeholder is always the body of a double-quoted literal, so a quote in an asset name broke the output for every target. ASCII-only escaping stays specific to chunks that carry the bun pragma. The manifest test no longer asserts the manifest's input paths, which are a separate Windows problem. --- src/bundler/Chunk.rs | 158 +++++++++++++--------- test/bundler/bundler_loader.test.ts | 26 ++++ test/bundler/html-import-manifest.test.ts | 6 +- 3 files changed, 126 insertions(+), 64 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 3f9400b97005..d885824c0447 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -459,6 +459,37 @@ fn additional_output_file_index(f: &AdditionalFile) -> usize { } } +/// How the text that replaces a placeholder has to be written into the chunk. +#[derive(Clone, Copy)] +enum SpliceEscape { + /// CSS and HTML chunks: written as-is; their placeholders sit in `url()`s + /// and attributes, not in JS string literals. + Raw, + /// JS chunks: every placeholder (import path, asset path, HTML import + /// manifest) is the body of a double-quoted string literal, so the + /// replacement is escaped the way the printer would have escaped it. + /// `ascii_only` is set for chunks that start with `// @bun` (see + /// postProcessJSChunk): the runtime loads those as Latin-1 without + /// re-parsing them, so the printer keeps them ASCII and the splices must too. + JsString { ascii_only: bool }, +} + +impl SpliceEscape { + fn for_chunk(chunk: &Chunk, linker_graph: &LinkerGraph<'_>) -> Self { + if !chunk.content.is_javascript() { + return Self::Raw; + } + Self::JsString { + ascii_only: linker_graph.ast.items_target()[chunk.entry_point.source_index() as usize] + .is_bun(), + } + } + + fn ascii_only(self) -> bool { + matches!(self, Self::JsString { ascii_only: true }) + } +} + impl IntermediateOutput { pub(crate) fn allocator_for_size(_size: usize) -> &'static DynAlloc { // mimalloc serves large allocations via mmap already, so the global @@ -520,20 +551,57 @@ impl IntermediateOutput { dst } - /// Writes the path that replaces a chunk/asset placeholder. `ascii_only` - /// (see `code_with_source_map_shifts`) escapes it the way the printer - /// escapes the double-quoted literal it is spliced into. + /// The two slices (prefix, path) that replace a chunk/asset placeholder. + /// `code_with_source_map_shifts` calls this once to size its buffer and once + /// to fill it, and `write_spliced_path` escapes what this returns, so both + /// passes have to go through here to count the same bytes. + fn spliced_path_parts<'a>( + file_path: &[u8], + import_prefix: &'a [u8], + from_chunk_dir: &[u8], + use_outdir_relative_path: bool, + file_path_buf: &'a mut [u8], + relative_platform_buf: &'a mut [u8], + ) -> [&'a [u8]; 2] { + // normalize windows paths to '/' + // The source slices are reachable only + // through `&Graph` / `&[Chunk]` here; materialising `&mut` from a + // shared-provenance pointer is UB regardless of whether the write + // happens. Copy into a pooled scratch buffer and normalise that. + let file_path: &'a [u8] = { + let dst = &mut file_path_buf[..file_path.len()]; + dst.copy_from_slice(file_path); + bun_paths::resolve_path::platform_to_posix_in_place::(dst); + dst + }; + cheap_prefix_normalizer( + import_prefix, + if use_outdir_relative_path { + file_path + } else { + bun_paths::resolve_path::relative_platform_buf::( + relative_platform_buf, + from_chunk_dir, + file_path, + ) + }, + ) + } + + /// Writes one of the slices from `spliced_path_parts`. fn write_spliced_path( writer: &mut W, path: &[u8], - ascii_only: bool, + escape: SpliceEscape, ) -> Result<(), crate::Error> { - if ascii_only { - bun_js_printer::write_pre_quoted_string_inner::<_, { bun_js_printer::Encoding::Utf8 }>( - path, writer, b'"', true, false, - )?; - } else { - writer.write_all(path)?; + match escape { + SpliceEscape::Raw => writer.write_all(path)?, + SpliceEscape::JsString { ascii_only } => { + bun_js_printer::write_pre_quoted_string_inner::< + _, + { bun_js_printer::Encoding::Utf8 }, + >(path, writer, b'"', ascii_only, false)? + } } Ok(()) } @@ -709,14 +777,7 @@ impl IntermediateOutput { &[] }; - // Every placeholder in a JS chunk sits inside a double-quoted string - // literal (import paths, asset paths, the HTML import manifest). A - // chunk printed for bun starts with `// @bun` (see postProcessJSChunk), - // which makes the runtime load it as Latin-1 without re-parsing it, so - // the printer escaped it to ASCII; what we splice in has to be too. - let ascii_only = chunk.content.is_javascript() - && linker_graph.ast.items_target()[chunk.entry_point.source_index() as usize] - .is_bun(); + let escape = SpliceEscape::for_chunk(chunk, linker_graph); for piece in pieces.slice() { count += piece.data.len(); @@ -783,7 +844,7 @@ impl IntermediateOutput { graph, linker_graph, chunks, - ascii_only, + escape.ascii_only(), &mut counter, ) .expect("unreachable"); @@ -793,22 +854,16 @@ impl IntermediateOutput { QueryKind::None => unreachable!(), }; - let cheap_normalizer = cheap_prefix_normalizer( + for part in Self::spliced_path_parts( + file_path, import_prefix, - if use_outdir_relative_path { - file_path - } else { - bun_paths::resolve_path::relative_platform_buf::< - bun_paths::platform::Posix, - false, - >( - &mut relative_platform_buf[..], from_chunk_dir, file_path - ) - }, - ); - for part in cheap_normalizer { + from_chunk_dir, + use_outdir_relative_path, + &mut file_path_buf[..], + &mut relative_platform_buf[..], + ) { let mut counter = bun_io::DiscardingWriter::new(); - Self::write_spliced_path(&mut counter, part, ascii_only) + Self::write_spliced_path(&mut counter, part, escape) .expect("unreachable"); count += counter.count; } @@ -953,7 +1008,7 @@ impl IntermediateOutput { graph, linker_graph, chunks, - ascii_only, + escape.ascii_only(), &mut stream, ) .expect("unreachable"); @@ -973,37 +1028,18 @@ impl IntermediateOutput { _ => unreachable!(), }; - // normalize windows paths to '/' - // The source slices are reachable only - // through `&Graph` / `&[Chunk]` here; materialising `&mut` from a - // shared-provenance pointer is UB regardless of whether the write - // happens. Copy into a pooled scratch buffer and normalise that. - let file_path: &[u8] = { - let n = file_path.len(); - let dst = &mut file_path_buf[..n]; - dst.copy_from_slice(file_path); - bun_paths::resolve_path::platform_to_posix_in_place::(dst); - dst - }; - let cheap_normalizer = cheap_prefix_normalizer( + for part in Self::spliced_path_parts( + file_path, import_prefix, - if use_outdir_relative_path { - file_path - } else { - bun_paths::resolve_path::relative_platform_buf::< - bun_paths::platform::Posix, - false, - >( - &mut relative_platform_buf[..], from_chunk_dir, file_path - ) - }, - ); - - for part in cheap_normalizer { + from_chunk_dir, + use_outdir_relative_path, + &mut file_path_buf[..], + &mut relative_platform_buf[..], + ) { let written = { let mut stream = bun_io::FixedBufferStream::new_mut(&mut *remain); - Self::write_spliced_path(&mut stream, part, ascii_only) + Self::write_spliced_path(&mut stream, part, escape) .expect("unreachable"); stream.pos }; diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index 72dfc0e353dd..a3e4c82efaf4 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -1,5 +1,6 @@ import { fileURLToPath, Loader } from "bun"; import { describe, expect } from "bun:test"; +import { isWindows } from "harness"; import fs, { readdirSync } from "node:fs"; import { join } from "path"; import { itBundled } from "./expectBundled"; @@ -454,6 +455,31 @@ describe("bundler", async () => { } }); + // The asset path is spliced into the chunk after printing, in place of a + // placeholder inside a double-quoted literal, so a quote in it has to be + // escaped the way the printer would have escaped it. (Windows file names + // cannot contain a quote.) + describe.skipIf(isWindows)("file loader escapes the asset path", () => { + for (const target of ["bun", "node", "browser"] as const) { + itBundled(`${target}/loader-file-path-with-quote`, { + target, + outdir: "/out", + files: { + "/entry.ts": /* js */ ` + import asset from './q"b.txt' with {type: "file"}; + export default asset; + `, + '/q"b.txt': "asset", + }, + onAfterBundle(api) { + api.expectFile("out/entry.js").toContain('"./q\\"b-'); + const module = require(join(api.outdir, "entry.js")); + api.assertFileExists(join("out", module.default)); + }, + }); + } + }); + // Lazy-export modules (JSON, TOML, CSS modules, ...) used to crash the // printer when bundled with the dev server's module format. // https://github.com/oven-sh/bun/issues/31943 diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 657d9ff3c625..e250ed7e1233 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -489,9 +489,9 @@ console.log("About manifest:", aboutHtml); expect(result).toSpawn(); const manifest = JSON.parse(result.stdout); expect(manifest.index).toBe("./sidé.html"); - expect(manifest.files.map(({ input, path, loader }: any) => ({ input, path, loader }))).toEqual([ - { input: "sidé.html", path: expect.stringMatching(/\.js$/), loader: "js" }, - { input: "sidé.html", path: "./sidé.html", loader: "html" }, + expect(manifest.files.map(({ path, loader }: any) => ({ path, loader }))).toEqual([ + { path: expect.stringMatching(/\.js$/), loader: "js" }, + { path: "./sidé.html", loader: "html" }, ]); }); From 019317c86edb64e7ae71c885736c76869f4dc271 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:03:54 +0000 Subject: [PATCH 3/6] bundler: shorten the comments around the splice escaping --- src/bundler/Chunk.rs | 25 +++++++------------------ src/bundler/HTMLImportManifest.rs | 12 +++--------- src/bundler/bundle_v2.rs | 4 +--- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index d885824c0447..3d7df993dc14 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -459,18 +459,13 @@ fn additional_output_file_index(f: &AdditionalFile) -> usize { } } -/// How the text that replaces a placeholder has to be written into the chunk. +/// How the text replacing a placeholder is written into the chunk. #[derive(Clone, Copy)] enum SpliceEscape { - /// CSS and HTML chunks: written as-is; their placeholders sit in `url()`s - /// and attributes, not in JS string literals. + /// CSS and HTML chunks (`url()`s and attributes). Raw, - /// JS chunks: every placeholder (import path, asset path, HTML import - /// manifest) is the body of a double-quoted string literal, so the - /// replacement is escaped the way the printer would have escaped it. - /// `ascii_only` is set for chunks that start with `// @bun` (see - /// postProcessJSChunk): the runtime loads those as Latin-1 without - /// re-parsing them, so the printer keeps them ASCII and the splices must too. + /// JS chunks: the placeholder is the body of a `"..."` literal. Chunks with + /// the `// @bun` pragma are loaded as Latin-1, so they must also stay ASCII. JsString { ascii_only: bool }, } @@ -551,10 +546,8 @@ impl IntermediateOutput { dst } - /// The two slices (prefix, path) that replace a chunk/asset placeholder. - /// `code_with_source_map_shifts` calls this once to size its buffer and once - /// to fill it, and `write_spliced_path` escapes what this returns, so both - /// passes have to go through here to count the same bytes. + /// The (prefix, path) pair that replaces a chunk/asset placeholder. Shared by + /// the sizing and the writing pass so that both see the same bytes. fn spliced_path_parts<'a>( file_path: &[u8], import_prefix: &'a [u8], @@ -563,11 +556,7 @@ impl IntermediateOutput { file_path_buf: &'a mut [u8], relative_platform_buf: &'a mut [u8], ) -> [&'a [u8]; 2] { - // normalize windows paths to '/' - // The source slices are reachable only - // through `&Graph` / `&[Chunk]` here; materialising `&mut` from a - // shared-provenance pointer is UB regardless of whether the write - // happens. Copy into a pooled scratch buffer and normalise that. + // Normalize Windows separators on a copy; the path belongs to the graph. let file_path: &'a [u8] = { let dst = &mut file_path_buf[..file_path.len()]; dst.copy_from_slice(file_path); diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 6079a580f157..ced376b3ac6b 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -101,15 +101,9 @@ fn write_entry_item( Ok(()) } -/// Writes the manifest as the body of the double-quoted string literal the -/// printer emitted around the placeholder (see `generate_server_html_module`). -/// Extremely unfortunate, but necessary: E.String does not accept pre-escaped -/// input, and this happens at the very end. -/// -/// `ascii_only` must match how the surrounding chunk was printed: a chunk that -/// starts with `// @bun` is loaded as Latin-1 without being re-parsed, so -/// non-ASCII in it has to be escaped like the printer escapes everything else -/// there. +/// Writes the manifest as the body of the `"..."` literal printed around the +/// placeholder (`generate_server_html_module`). `ascii_only` is required for +/// chunks with the `// @bun` pragma, which are loaded as Latin-1. pub(crate) fn write_escaped_json( index: u32, graph: &Graph, diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index c75c38d08342..2f7c7d3a8856 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6913,9 +6913,7 @@ pub mod bv2_impl { )? .unwrap(), ); - // The parser defaults this to `Browser`, but the manifest is server - // code: the printer escapes it and `computeChunks` classifies it by - // this target, same as the file that imported the HTML. + // The parser defaults `target` to browser; this module belongs to the importing side. ast_for_html_entrypoint.target = target; let fake_input_file = crate::Graph::InputFile { From aede9d4ea7e149e3bc35a00fec03755e0987d791 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:34:52 +0000 Subject: [PATCH 4/6] bundler: splice chunk paths into the metafile as JSON string content The metafile resolves its chunk references through the same assembly routine as the chunks, with the first chunk standing in, so the escape mode picked for that chunk (a JS string, ASCII-only for bun) was applied inside JSON. The metafile now asks for JSON escaping explicitly. --- src/bundler/Chunk.rs | 56 ++++++++++++++----- src/bundler/linker_context/MetafileBuilder.rs | 16 +----- test/bundler/metafile.test.ts | 28 +++++++++- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 3d7df993dc14..8e8ac819bbd1 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -459,7 +459,7 @@ fn additional_output_file_index(f: &AdditionalFile) -> usize { } } -/// How the text replacing a placeholder is written into the chunk. +/// How the text replacing a placeholder is written into the output. #[derive(Clone, Copy)] enum SpliceEscape { /// CSS and HTML chunks (`url()`s and attributes). @@ -467,6 +467,8 @@ enum SpliceEscape { /// JS chunks: the placeholder is the body of a `"..."` literal. Chunks with /// the `// @bun` pragma are loaded as Latin-1, so they must also stay ASCII. JsString { ascii_only: bool }, + /// The metafile: the placeholder is the body of a JSON string. + JsonString, } impl SpliceEscape { @@ -583,15 +585,14 @@ impl IntermediateOutput { path: &[u8], escape: SpliceEscape, ) -> Result<(), crate::Error> { - match escape { - SpliceEscape::Raw => writer.write_all(path)?, - SpliceEscape::JsString { ascii_only } => { - bun_js_printer::write_pre_quoted_string_inner::< - _, - { bun_js_printer::Encoding::Utf8 }, - >(path, writer, b'"', ascii_only, false)? - } - } + let (ascii_only, json) = match escape { + SpliceEscape::Raw => return Ok(writer.write_all(path)?), + SpliceEscape::JsString { ascii_only } => (ascii_only, false), + SpliceEscape::JsonString => (false, true), + }; + bun_js_printer::write_pre_quoted_string_inner::<_, { bun_js_printer::Encoding::Utf8 }>( + path, writer, b'"', ascii_only, json, + )?; Ok(()) } @@ -628,6 +629,7 @@ impl IntermediateOutput { enable_source_map_shifts: bool, ) -> Result { let display_size: Option<&mut usize> = display_size.into(); + let escape = SpliceEscape::for_chunk(chunk, linker_graph); // switch (enable_source_map_shifts) { inline else => |b| ... } if enable_source_map_shifts { self.code_with_source_map_shifts::( @@ -640,6 +642,7 @@ impl IntermediateOutput { display_size, force_absolute_path, None, + escape, ) } else { self.code_with_source_map_shifts::( @@ -652,10 +655,35 @@ impl IntermediateOutput { display_size, force_absolute_path, None, + escape, ) } } + /// `code()` for the metafile, whose placeholders sit inside JSON strings. + /// `chunk` only supplies the directory paths are made relative to; the + /// metafile is not a chunk of its own. + pub(crate) fn code_for_metafile( + &mut self, + parse_graph: &Graph, + linker_graph: &LinkerGraph<'_>, + chunk: &Chunk, + chunks: &[Chunk], + ) -> Result { + self.code_with_source_map_shifts::( + None, + parse_graph, + linker_graph, + b"", + chunk, + chunks, + None, + false, + None, + SpliceEscape::JsonString, + ) + } + /// Like `code()` but with standalone HTML support. /// When `standalone_chunk_contents` is provided, chunk piece references are /// resolved to inline code content instead of file paths. Asset references @@ -678,6 +706,7 @@ impl IntermediateOutput { standalone_chunk_contents: &[Option>], ) -> Result { let display_size: Option<&mut usize> = display_size.into(); + let escape = SpliceEscape::for_chunk(chunk, linker_graph); if enable_source_map_shifts { self.code_with_source_map_shifts::( allocator_to_use, @@ -689,6 +718,7 @@ impl IntermediateOutput { display_size, force_absolute_path, Some(standalone_chunk_contents), + escape, ) } else { self.code_with_source_map_shifts::( @@ -701,12 +731,13 @@ impl IntermediateOutput { display_size, force_absolute_path, Some(standalone_chunk_contents), + escape, ) } } #[allow(clippy::too_many_arguments)] - pub(crate) fn code_with_source_map_shifts( + fn code_with_source_map_shifts( &mut self, allocator_to_use: Option<&DynAlloc>, graph: &Graph, @@ -718,6 +749,7 @@ impl IntermediateOutput { display_size: Option<&mut usize>, force_absolute_path: bool, standalone_chunk_contents: Option<&[Option>]>, + escape: SpliceEscape, ) -> Result { // `Graph.input_files` SoA accessors live in `Graph::InputFileColumns`; // `LinkerGraph.files` SoA (`items_entry_point_chunk_index`) lands with @@ -766,8 +798,6 @@ impl IntermediateOutput { &[] }; - let escape = SpliceEscape::for_chunk(chunk, linker_graph); - for piece in pieces.slice() { count += piece.data.len(); diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index 459fe0e50cb7..b71f35519860 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -437,19 +437,9 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re )?; // Get final output with all chunk references resolved. - // `code()` takes the dummy chunk and the full slice as `&`, so pass - // `&chunks[0]` directly — overlapping shared borrows are fine. - let code_result = intermediate.code( - None, - parse_graph, - &c.graph, - b"", // no import prefix for metafile - &chunks[0], - chunks, - None, // no display size - false, // not force absolute path - false, // no source map shifts - )?; + // `code_for_metafile()` takes the dummy chunk and the full slice as `&`, so + // passing `&chunks[0]` directly is fine (overlapping shared borrows). + let code_result = intermediate.code_for_metafile(parse_graph, &c.graph, &chunks[0], chunks)?; Ok(code_result.buffer) } diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 3ca6436b214d..3c4838612456 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { isWindows, tempDir } from "harness"; // Type definitions for metafile structure interface MetafileImport { @@ -458,6 +458,32 @@ describe("bundler metafile", () => { expect(outputPaths).toContain(dynamicImport!.path); }); + // The chunk path is spliced into the metafile after the JSON around it was + // written, so it has to be escaped as JSON string content: the quote must be + // escaped, and the non-ASCII characters must not be turned into the `\xNN` + // escapes a JS chunk built for bun would use. (Windows cannot create the file.) + test.skipIf(isWindows)("metafile escapes resolved chunk paths as JSON", async () => { + using dir = tempDir("metafile-chunk-path-escape", { + "entry.js": `import('./q"modülé.js').then(m => console.log(m.value));`, + 'q"modülé.js': `export const value = 123;`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/entry.js`], + target: "bun", + splitting: true, + naming: { chunk: "[name]-[hash].[ext]" }, + metafile: true, + }); + expect(result.logs).toBeEmpty(); + + const metafile = result.metafile as Metafile; + const [, entry] = Object.entries(metafile.inputs).find(([path]) => path.endsWith("entry.js"))!; + const chunkPath = entry.imports.find(imp => imp.kind === "dynamic-import")!.path; + expect(chunkPath).toMatch(/^\.\/q"modülé-[a-z0-9]+\.js$/); + expect(Object.keys(metafile.outputs)).toContain(chunkPath); + }); + test("metafile includes cssBundle for CSS outputs", async () => { using dir = tempDir("metafile-css-bundle-test", { "entry.js": `import "./styles.css"; console.log("styled");`, From 12f0113bcf224bde1c71556bb8960968fd846769 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:36:54 +0000 Subject: [PATCH 5/6] bundler: trim two comments around the metafile splice --- src/bundler/Chunk.rs | 5 ++--- src/bundler/linker_context/MetafileBuilder.rs | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 8e8ac819bbd1..d2bab04bc6e7 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -660,9 +660,8 @@ impl IntermediateOutput { } } - /// `code()` for the metafile, whose placeholders sit inside JSON strings. - /// `chunk` only supplies the directory paths are made relative to; the - /// metafile is not a chunk of its own. + /// `code()` for the metafile, whose placeholders sit inside JSON strings; + /// `chunk` only supplies the directory that paths are made relative to. pub(crate) fn code_for_metafile( &mut self, parse_graph: &Graph, diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index b71f35519860..989c68eb6d54 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -437,8 +437,6 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re )?; // Get final output with all chunk references resolved. - // `code_for_metafile()` takes the dummy chunk and the full slice as `&`, so - // passing `&chunks[0]` directly is fine (overlapping shared borrows). let code_result = intermediate.code_for_metafile(parse_graph, &c.graph, &chunks[0], chunks)?; Ok(code_result.buffer) From 9590770fb616178b4faeca31db168d9c050403b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:00:11 +0000 Subject: [PATCH 6/6] ci: retrigger