Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions src/bun_core/string/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2021,12 +2021,19 @@ pub use printer::quote_for_json;

pub use crate::ffi::slice_to_nul;

/// Pure path-string helper used by the bundler chunk writer and `css::printer`.
/// Returns `[prefix', suffix']` such that concatenating them produces a
/// reasonably-normalized path (collapses `./` leading and avoids `//`).
/// The two-element-array return shape lets bundler call-sites index it
/// directly.
pub fn cheap_prefix_normalizer<'a>(prefix: &'a [u8], suffix: &'a [u8]) -> [&'a [u8]; 2] {
/// Joins a public path (`prefix`) with an output-relative path (`suffix`) the
/// way esbuild's `joinWithPublicPath` does. Returns `[prefix', sep, suffix']`;
/// concatenate the three parts. A leading `./` on `suffix` is dropped, and
/// exactly one `/` separates a non-empty `prefix` from `suffix`.
///
/// ```text
/// ["", "./out.js"] => "./out.js"
/// ["", "../out.js"] => "../out.js"
/// ["https://example.com/", "/out.js"] => "https://example.com/out.js"
/// ["https://example.com", "./out.js"] => "https://example.com/out.js"
/// ["/foo/", "bar.js"] => "/foo/bar.js"
/// ```
Comment thread
robobun marked this conversation as resolved.
Resolved
pub fn cheap_prefix_normalizer<'a>(prefix: &'a [u8], suffix: &'a [u8]) -> [&'a [u8]; 3] {
if prefix.is_empty() {
let suffix_no_slash = strings::remove_leading_dot_slash(suffix);
return [
Expand All @@ -2035,23 +2042,23 @@ pub fn cheap_prefix_normalizer<'a>(prefix: &'a [u8], suffix: &'a [u8]) -> [&'a [
} else {
b"./"
},
b"",
suffix_no_slash,
];
}

// ["https://example.com/", "/out.js"] => "https://example.com/out.js"
// ["/foo/", "/bar.js"] => "/foo/bar.js"
let win = crate::Environment::IS_WINDOWS;
if strings::ends_with_char(prefix, b'/') || (win && strings::ends_with_char(prefix, b'\\')) {
if strings::starts_with_char(suffix, b'/')
|| (win && strings::starts_with_char(suffix, b'\\'))
{
return [prefix, &suffix[1..]];
}
// It gets really complicated if we try to deal with URLs more than this.
}
let is_sep = |c: u8| c == b'/' || (win && c == b'\\');
Comment thread
robobun marked this conversation as resolved.
Resolved

let suffix = strings::remove_leading_dot_slash(suffix);
let prefix_has_sep = prefix.last().is_some_and(|&c| is_sep(c));
let suffix_has_sep = suffix.first().is_some_and(|&c| is_sep(c));

[prefix, strings::remove_leading_dot_slash(suffix)]
match (prefix_has_sep, suffix_has_sep) {
(true, true) => [prefix, b"", &suffix[1..]],
(false, false) => [prefix, b"/", suffix],
_ => [prefix, b"", suffix],
}
}

// Re-export `wtf::parse_double` at crate root (callers spell it `bun_core::parse_double`).
Expand Down
24 changes: 10 additions & 14 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,10 @@ impl IntermediateOutput {
)
},
);
count += cheap_normalizer[0].len() + cheap_normalizer[1].len();
count += cheap_normalizer
.iter()
.map(|part| part.len())
.sum::<usize>();
}
QueryKind::None => {}
}
Expand Down Expand Up @@ -1064,21 +1067,14 @@ 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]);
for part in cheap_normalizer {
if part.is_empty() {
continue;
}
}

if !cheap_normalizer[1].is_empty() {
remain[..cheap_normalizer[1].len()]
.copy_from_slice(cheap_normalizer[1]);
remain = &mut remain[cheap_normalizer[1].len()..];
remain[..part.len()].copy_from_slice(part);
remain = &mut remain[part.len()..];
if ENABLE_SOURCE_MAP_SHIFTS {
shift.after.advance(cheap_normalizer[1]);
shift.after.advance(part);
}
}

Expand Down
65 changes: 26 additions & 39 deletions src/bundler/linker_context/generateChunksInParallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,11 +555,9 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
} else {
c.options.public_path
};
let normalizer = cheap_prefix_normalizer(public_path, &ch.final_rel_path);
let mut resolved: Vec<u8> = Vec::new();
resolved.extend_from_slice(normalizer[0]);
resolved.extend_from_slice(normalizer[1]);
let _ = unique_key_to_path.put(ch.unique_key, resolved.into_boxed_slice()); // OOM-only Result
let resolved =
strings::concat(&cheap_prefix_normalizer(public_path, &ch.final_rel_path));
let _ = unique_key_to_path.put(ch.unique_key, resolved); // OOM-only Result
}
}

Expand Down Expand Up @@ -743,7 +741,7 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
// file rather than a JS file next to the .map. Point at
// the .map path relative to the HTML chunk's directory.
let mut relative_platform_buf = path::path_buffer_pool::get();
let [a, b]: [&[u8]; 2] = if !c.options.public_path.is_empty() {
let url_parts: [&[u8]; 3] = if !c.options.public_path.is_empty() {
cheap_prefix_normalizer(
c.options.public_path,
&source_map_final_rel_path,
Expand Down Expand Up @@ -782,16 +780,14 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
)
};

let source_map_start = b"//# sourceMappingURL=";
let total_len =
buffer.len() + source_map_start.len() + a.len() + b.len() + b"\n".len();
let mut buf: Vec<u8> = Vec::with_capacity(total_len);
buf.extend_from_slice(&buffer);
buf.extend_from_slice(source_map_start);
buf.extend_from_slice(a);
buf.extend_from_slice(b);
buf.push(b'\n');
buffer = buf.into_boxed_slice();
buffer = strings::concat(&[
&buffer,
b"//# sourceMappingURL=",
url_parts[0],
url_parts[1],
url_parts[2],
b"\n",
]);
}

standalone_sourcemaps[ci] = Some(output_source_map);
Expand Down Expand Up @@ -993,26 +989,20 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
source_map_final_rel_path.extend_from_slice(b".map");

if tag == SourceMapOption::Linked {
let [a, b]: [&[u8]; 2] = if public_path.len() > 0 {
let url_parts: [&[u8]; 3] = if public_path.len() > 0 {
cheap_prefix_normalizer(public_path, &source_map_final_rel_path)
} else {
[b"", path::basename(&source_map_final_rel_path)]
[b"", b"", path::basename(&source_map_final_rel_path)]
};

let source_map_start = b"//# sourceMappingURL=";
let total_len = code_result.buffer.len()
+ source_map_start.len()
+ a.len()
+ b.len()
+ b"\n".len();
let mut buf: Vec<u8> = Vec::with_capacity(total_len);
buf.extend_from_slice(&code_result.buffer);
buf.extend_from_slice(source_map_start);
buf.extend_from_slice(a);
buf.extend_from_slice(b);
buf.push(b'\n');

code_result.buffer = buf.into_boxed_slice();
code_result.buffer = strings::concat(&[
&code_result.buffer,
b"//# sourceMappingURL=",
url_parts[0],
url_parts[1],
url_parts[2],
b"\n",
]);
}

sourcemap_output_file =
Expand Down Expand Up @@ -1079,13 +1069,10 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
// with module_info path fixup.
// For non-compile builds, use the normal .jsc extension.
let source_provider_url = if c.options.compile_mode.is_executable() {
let normalizer =
cheap_prefix_normalizer(public_path, &chunk.final_rel_path);
BunString::create_format(format_args!(
"{}{}",
bstr::BStr::new(normalizer[0]),
bstr::BStr::new(normalizer[1])
))
BunString::clone_utf8(&strings::concat(&cheap_prefix_normalizer(
public_path,
&chunk.final_rel_path,
)))
} else {
BunString::create_format(format_args!(
"{}{}",
Expand Down
25 changes: 10 additions & 15 deletions src/bundler/linker_context/writeOutputFilesToDisk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,25 +302,20 @@ pub(crate) fn write_output_files_to_disk(
let source_map_final_rel_path = strings::concat(&[&chunk.final_rel_path, b".map"]);

if tag == SourceMapOption::Linked {
let [a, b] = if !public_path.is_empty() {
let url_parts: [&[u8]; 3] = if !public_path.is_empty() {
cheap_prefix_normalizer(public_path, &source_map_final_rel_path)
} else {
[b"" as &[u8], paths::basename(&source_map_final_rel_path)]
[b"", b"", paths::basename(&source_map_final_rel_path)]
};

let source_map_start = b"//# sourceMappingURL=";
let total_len = code_result.buffer.len()
+ source_map_start.len()
+ a.len()
+ b.len()
+ b"\n".len();
let mut buf: Vec<u8> = Vec::with_capacity(total_len);
buf.extend_from_slice(&code_result.buffer);
buf.extend_from_slice(source_map_start);
buf.extend_from_slice(a);
buf.extend_from_slice(b);
buf.push(b'\n');
code_result.buffer = buf.into_boxed_slice();
code_result.buffer = strings::concat(&[
&code_result.buffer,
b"//# sourceMappingURL=",
url_parts[0],
url_parts[1],
url_parts[2],
b"\n",
]);
}

match bun_sys::File::write_file(
Expand Down
76 changes: 75 additions & 1 deletion test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,81 @@ describe("bundler", () => {
},
outdir: "/out",
publicPath: "/www",
run: {},
onAfterBundle(api) {
api.expectFile("/out/entry.js").toMatch(/"\/www\/hello-[a-z0-9]+\.file"/);
},
});
// A publicPath without a trailing slash gets exactly one "/" before the
// output-relative path in every emitter: static and dynamic cross-chunk
// imports, HTML tags, CSS url(), file-loader strings, and sourceMappingURL.
itBundled("edgecase/PublicPathWithoutTrailingSlash", {
files: {
"/src/index.html": /* html */ `
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./app.ts"></script>
</head>
<body><img src="./logo.svg" /></body>
</html>
`,
"/src/styles.css": /* css */ `body { background: url(./icon.svg); }`,
"/src/app.ts": `console.log("app");`,
"/src/pages/a/entry.ts": /* ts */ `
import { shared } from "../../shared";
import logo from "../../logo.svg";
import("../../lazy").then(m => console.log(m.lazy));
console.log(shared(), logo);
`,
"/src/lazy.ts": `export const lazy = "lazy";`,
"/src/pages/b/entry.ts": /* ts */ `
import { shared } from "../../shared";
console.log(shared());
`,
"/src/shared.ts": `export const shared = () => "shared";`,
// at least 128 KB, else the CSS printer inlines it as a data: URL
"/src/icon.svg": `<svg id="icon">${Buffer.alloc(128 * 1024, "x")}</svg>`,
"/src/logo.svg": `<svg id="logo" />`,
},
entryPoints: ["/src/index.html", "/src/pages/a/entry.ts", "/src/pages/b/entry.ts"],
outputPaths: ["/out/index.html", "/out/pages/a/entry.js", "/out/pages/b/entry.js"],
root: "/src",
outdir: "/out",
splitting: true,
sourceMap: "linked",
publicPath: "https://cdn.example/app",
chunkNaming: "chunk-[hash].[ext]",
loader: { ".svg": "file" },
onAfterBundle(api) {
const prefix = "https://cdn.example/app/";
const expectUnderPrefix = (urls: string[]) => {
expect(urls).not.toBeEmpty();
for (const url of urls) {
expect(url).toStartWith(prefix);
api.assertFileExists(join("/out", url.slice(prefix.length)));
}
};

const entry = api.readFile("/out/pages/a/entry.js");
expect(entry).toMatch(/from "https:\/\/cdn\.example\/app\/chunk-[a-z0-9]+\.js"/);
expect(entry).toMatch(/import\("https:\/\/cdn\.example\/app\/(pages\/a\/)?[a-z0-9-]+\.js"\)/);
expect(entry).toContain(`//# sourceMappingURL=${prefix}pages/a/entry.js.map\n`);

const jsUrls = readdirSync(api.outdir, { recursive: true })
.filter(file => file.endsWith(".js"))
.flatMap(file => cdnUrls(api.readFile(join("/out", file))));
expectUnderPrefix(jsUrls);
expect(jsUrls).toContainEqual(expect.stringMatching(/^https:\/\/cdn\.example\/app\/logo-[a-z0-9]+\.svg$/));

const htmlUrls = [...api.readFile("/out/index.html").matchAll(/(?:src|href)="([^"]+)"/g)].map(m => m[1]);
expectUnderPrefix(htmlUrls);
expect(htmlUrls.map(url => url.slice(prefix.length).split(".").pop()).sort()).toEqual(["css", "js", "svg"]);

const css = readdirSync(api.outdir).find(file => file.endsWith(".css"))!;
const cssUrls = [...api.readFile(join("/out", css)).matchAll(/url\("?([^")]+)"?\)/g)].map(m => m[1]);
expectUnderPrefix(cssUrls);
},
});
itBundled("edgecase/PublicPathNestedChunkReferences", {
files: {
Expand Down
Loading
Loading