diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 4feb8d95b3d7..d2bab04bc6e7 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, @@ -459,6 +459,34 @@ fn additional_output_file_index(f: &AdditionalFile) -> usize { } } +/// How the text replacing a placeholder is written into the output. +#[derive(Clone, Copy)] +enum SpliceEscape { + /// CSS and HTML chunks (`url()`s and attributes). + Raw, + /// 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 { + 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,6 +548,54 @@ impl IntermediateOutput { dst } + /// 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], + 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 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); + 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], + escape: SpliceEscape, + ) -> Result<(), crate::Error> { + 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(()) + } + pub(crate) fn get_size(&self) -> usize { match self { IntermediateOutput::Pieces(pieces) => { @@ -553,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::( @@ -565,6 +642,7 @@ impl IntermediateOutput { display_size, force_absolute_path, None, + escape, ) } else { self.code_with_source_map_shifts::( @@ -577,10 +655,34 @@ impl IntermediateOutput { display_size, force_absolute_path, None, + escape, ) } } + /// `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, + 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 @@ -603,6 +705,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, @@ -614,6 +717,7 @@ impl IntermediateOutput { display_size, force_absolute_path, Some(standalone_chunk_contents), + escape, ) } else { self.code_with_source_map_shifts::( @@ -626,12 +730,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, @@ -643,6 +748,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 @@ -750,34 +856,35 @@ 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, + escape.ascii_only(), + &mut counter, + ) + .expect("unreachable"); + count += counter.count; continue; } 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 - ) - }, - ); - count += cheap_normalizer[0].len() + cheap_normalizer[1].len(); + 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, escape) + .expect("unreachable"); + count += counter.count; + } } QueryKind::None => {} } @@ -911,17 +1018,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, + escape.ascii_only(), + &mut stream, + ) + .expect("unreachable"); + stream.pos + }; if ENABLE_SOURCE_MAP_SHIFTS { // The placeholder was an HtmlImport unique key, which has @@ -936,48 +1046,25 @@ 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 - ) - }, - ); - - 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()..]; + 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, escape) + .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..ced376b3ac6b 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,25 @@ 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 `"..."` 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, 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 +293,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..2f7c7d3a8856 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,8 @@ pub mod bv2_impl { )? .unwrap(), ); + // 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 { 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/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index 459fe0e50cb7..989c68eb6d54 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -437,19 +437,7 @@ 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 - )?; + let code_result = intermediate.code_for_metafile(parse_graph, &c.graph, &chunks[0], chunks)?; Ok(code_result.buffer) } 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/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 e18ac20bd8a8..e250ed7e1233 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(({ path, loader }: any) => ({ path, loader }))).toEqual([ + { path: expect.stringMatching(/\.js$/), loader: "js" }, + { 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/", 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");`,