Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
47 changes: 47 additions & 0 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,53 @@ pub struct CodeResult {
pub(crate) shifts: Vec<source_map::SourceMapShifts>,
}

/// Appends `//# sourceMappingURL=<prefix><rel_path>\n`, percent-encoded so that no byte of either part ends the comment.
pub(crate) fn append_source_mapping_url_comment(out: &mut Vec<u8>, prefix: &[u8], rel_path: &[u8]) {
const START: &[u8] = b"//# sourceMappingURL=";
out.reserve(START.len() + prefix.len() + rel_path.len() + b"\n".len());
out.extend_from_slice(START);
// `prefix` is publicPath, a URL already: printable ASCII stays, so `?`, `#`, `[::1]` and `%XX` keep their meaning.
percent_encode(out, prefix, |byte| byte.is_ascii_graphic());
percent_encode(out, rel_path, is_url_path_byte);
out.push(b'\n');
}

/// Returns `code` followed by the comment, in an allocation of the exact size.
pub(crate) fn with_source_mapping_url_comment(
code: &[u8],
prefix: &[u8],
rel_path: &[u8],
) -> Box<[u8]> {
let mut comment: Vec<u8> = Vec::new();
append_source_mapping_url_comment(&mut comment, prefix, rel_path);

let mut buf: Vec<u8> = Vec::with_capacity(code.len() + comment.len());
buf.extend_from_slice(code);
buf.extend_from_slice(&comment);
buf.into_boxed_slice()
}

fn percent_encode(out: &mut Vec<u8>, bytes: &[u8], keep: impl Fn(u8) -> bool) {
for &byte in bytes {
if keep(byte) {
out.push(byte);
} else {
let [high, low] = bun_core::fmt::hex2_upper(byte);
out.extend_from_slice(&[b'%', high, low]);
}
}
}

/// The set Go's `URL.EscapedPath` (esbuild) keeps, minus `:`: a first path segment with a colon parses as a scheme.
fn is_url_path_byte(byte: u8) -> bool {
matches!(
byte,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
| b'-' | b'.' | b'_' | b'~'
| b'$' | b'&' | b'+' | b',' | b'/' | b';' | b'=' | b'@'
)
}

/// What the paths `code()` writes over a chunk's references to other outputs
/// are relative to. A public path makes them outdir-relative either way.
#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down
29 changes: 4 additions & 25 deletions src/bundler/linker_context/generateChunksInParallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::Chunk;
use crate::Index;
use crate::analyze_transpiled_module;
use crate::cheap_prefix_normalizer;
use crate::chunk::{ReferencePathStyle, SourceMapShiftTracking};
use crate::chunk::{ReferencePathStyle, SourceMapShiftTracking, with_source_mapping_url_comment};
use crate::options;
use crate::options::Loader;

Expand Down Expand Up @@ -809,16 +809,7 @@ 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 = with_source_mapping_url_comment(&buffer, a, b);
}

standalone_sourcemaps[ci] = Some(output_source_map);
Expand Down Expand Up @@ -1026,20 +1017,8 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
[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 =
with_source_mapping_url_comment(&code_result.buffer, a, b);
}

sourcemap_output_file =
Expand Down
19 changes: 5 additions & 14 deletions src/bundler/linker_context/writeOutputFilesToDisk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use bun_paths as paths;
use bun_wyhash::hash;

use crate::LinkerContext;
use crate::chunk::{Content, Flags as ChunkFlags, ReferencePathStyle, SourceMapShiftTracking};
use crate::chunk::{
Content, Flags as ChunkFlags, ReferencePathStyle, SourceMapShiftTracking,
with_source_mapping_url_comment,
};
use crate::linker_context::output_file_list_builder::OutputFileList;
use crate::linker_context_mod::debug;
use crate::options::{self, Loader, OutputFile, SourceMapOption};
Expand Down Expand Up @@ -308,19 +311,7 @@ pub(crate) fn write_output_files_to_disk(
[b"" as &[u8], 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 = with_source_mapping_url_comment(&code_result.buffer, a, b);
}

match bun_sys::File::write_file(
Expand Down
58 changes: 58 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,64 @@ describe.concurrent("sourcemap positions", () => {
});
});

// The comment is a URL on a `//` line. A line terminator written raw ends the
// line, and the rest of the file name or publicPath runs as code when the
// bundle is loaded.
describe.concurrent("sourceMappingURL comment", () => {
// Builds once to disk and once in memory: the two paths append the comment
// separately. Returns everything from the comment to the end of each output.
async function linkedComments(entry: string, publicPath?: string) {
const comments: string[] = [];
for (const outdir of [join(path.dirname(entry), "out"), undefined]) {
const build = await Bun.build({ entrypoints: [entry], outdir, sourcemap: "linked", publicPath });
expect(build.outputs.map(o => o.kind).sort()).toEqual(["entry-point", "sourcemap"]);
const code = await build.outputs.find(o => o.kind === "entry-point")!.text();
comments.push(code.slice(code.indexOf("//# sourceMappingURL=")));
}
return { onDisk: comments[0], inMemory: comments[1] };
}

describe.each([
["LF", "\n", "%0A"],
["CR", "\r", "%0D"],
["U+2028", "\u2028", "%E2%80%A8"],
["U+2029", "\u2029", "%E2%80%A9"],
])("%s in an entry file name", (_, terminator, encoded) => {
// A Windows file name cannot contain a control character.
test.skipIf(isWindows && terminator < " ")("is percent-encoded", async () => {
const name = `page${terminator}globalThis.INJECTED=1,0`;
using dir = tempDir("sourcemap-url-file-name", { [`${name}.js`]: "export default 1;\n" });

const expected = `//# sourceMappingURL=page${encoded}globalThis.INJECTED=1,0.js.map\n`;
expect(await linkedComments(join(String(dir), `${name}.js`))).toEqual({ onDisk: expected, inMemory: expected });
// A consumer that decodes the URL finds the map next to the bundle.
const url = expected.slice("//# sourceMappingURL=".length, -1);
expect(await Bun.file(join(String(dir), "out", decodeURIComponent(url))).json()).toMatchObject({ version: 3 });
});
});

test("line terminators in publicPath are percent-encoded", async () => {
using dir = tempDir("sourcemap-url-public-path", { "plain.js": "export default 1;\n" });

const expected = "//# sourceMappingURL=https://cdn.example.com/a%0Ab%0Dc%E2%80%A8d%E2%80%A9e/plain.js.map\n";
expect(
await linkedComments(join(String(dir), "plain.js"), "https://cdn.example.com/a\nb\rc\u2028d\u2029e/"),
).toEqual({ onDisk: expected, inMemory: expected });
});

test("a file name is encoded as a URL path and publicPath keeps its URL syntax", async () => {
// In a file name `#`, `%` and a space are literal. In publicPath, `[::1]`
// is a host and `%20` is already an escape.
using dir = tempDir("sourcemap-url-syntax", { "a b#c%20d.js": "export default 1;\n" });

const expected = "//# sourceMappingURL=http://[::1]:3000/my%20assets/a%20b%23c%2520d.js.map\n";
expect(await linkedComments(join(String(dir), "a b#c%20d.js"), "http://[::1]:3000/my%20assets/")).toEqual({
onDisk: expected,
inMemory: expected,
});
});
});

const originalCwd = process.cwd() + "";

describe("tsconfig option", () => {
Expand Down
24 changes: 22 additions & 2 deletions test/bundler/standalone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,15 +537,15 @@ console.log(greet("world"));`,
"style.css": `body { color: red; }`,
};

async function buildWithSourcemap(dir: string, outdir: string, sourcemap: string) {
async function buildWithSourcemap(dir: string, outdir: string, sourcemap: string, entry = "index.html") {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
"--compile",
"--target=browser",
`--sourcemap=${sourcemap}`,
`${dir}/index.html`,
`${dir}/${entry}`,
"--outdir",
outdir,
],
Expand Down Expand Up @@ -583,6 +583,26 @@ console.log(greet("world"));`,
expect(map.mappings.length).toBeGreaterThan(0);
});

// The script and its map are named after the document. A line break written
// raw ends the `//` comment, and the rest of the name runs in the page.
// A Windows file name cannot contain LF.
test.skipIf(isWindows)("CLI --sourcemap=linked percent-encodes a line break in the document's name", async () => {
const { "index.html": document, ...rest } = fixture;
using dir = tempDir("compile-browser-sourcemap-line-break", { "in\ndex.html": document, ...rest });
const outdir = `${dir}/dist`;
await buildWithSourcemap(String(dir), outdir, "linked", "in\ndex.html");

const files = Array.from(new Bun.Glob("**/*").scanSync({ cwd: outdir })).sort();
expect(files).toHaveLength(2);
const mapFile = files.find(f => f.endsWith(".js.map"))!;
expect(mapFile).toMatch(/^in\ndex-[0-9a-z]+\.js\.map$/);

const html = await Bun.file(`${outdir}/in\ndex.html`).text();
expect(html.slice(html.indexOf("//# sourceMappingURL="))).toStartWith(
`//# sourceMappingURL=./${mapFile.replace("\n", "%0A")}\n</script>`,
);
});

test("CLI --sourcemap=inline embeds a data: URL sourcemap in the inline script", async () => {
using dir = tempDir("compile-browser-sourcemap-inline", fixture);
const outdir = `${dir}/dist`;
Expand Down
47 changes: 47 additions & 0 deletions test/internal/source-lints/source-mapping-url-writers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Glob } from "bun";
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";

// `//# sourceMappingURL=<url>` is a URL on a `//` line. A writer that copies a
// file name or `publicPath` after it raw lets a line break in that string (LF,
// CR, U+2028, U+2029) end the comment, and the rest of the string runs as code
// when the output is loaded. append_source_mapping_url_comment in
// src/bundler/Chunk.rs percent-encodes both parts, and every writer of a
// linked map calls it (or with_source_mapping_url_comment, next to it).
//
// A string literal that stops at the `=` means the URL comes from somewhere
// else, so each file that has one is listed here with what follows the `=`.
// A `sourceMappingURL=data:...;base64,` literal is not matched: base64 cannot
// hold a line break.
const knownSites: Record<string, string> = {
"src/bundler/Chunk.rs": "the encoder itself",
"src/runtime/bake/dev_server/incremental_graph.rs": "a fixed route prefix and a hex script id",
"src/sourcemap/lib.rs": "nothing: the needle that finds the comment in a loaded file",
};

const literalThatStopsAtTheUrl = /\/\/# sourceMappingURL="/;

test("a `//# sourceMappingURL=` writer goes through the encoder in src/bundler/Chunk.rs", async () => {
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
const found = new Set<string>();
let scanned = 0;

for await (const rel of new Glob("src/**/*.rs").scan({ cwd: repoRoot })) {
scanned++;
const source = readFileSync(path.join(repoRoot, rel), "utf8");
if (!source.includes("sourceMappingURL=")) continue;
for (const line of source.split("\n")) {
if (line.trimStart().startsWith("//")) continue;
if (literalThatStopsAtTheUrl.test(line)) found.add(rel.replaceAll("\\", "/"));
}
}

// Guards against the scan passing vacuously.
expect(scanned).toBeGreaterThan(1000);

// A new file here appends its own URL after the literal. Call the encoder
// instead. Only add the file to knownSites if what it appends cannot contain
// a byte outside printable ASCII.
expect([...found].sort()).toEqual(Object.keys(knownSites).sort());
});
Loading