Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
acbaf3c752 fix(bundler): normalize paths to posix in FileMap top_level_dir fallback
On Windows, both the specifier and top_level_dir use backslashes, but
FileMap keys use forward slashes. Normalize both to posix before
prefix-matching and map lookup so the fallback works cross-platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-02 13:32:25 +00:00
Claude Bot
933d5cbee0 fix(bundler): validate path segment boundary when stripping top_level_dir prefix
Address review feedback: ensure we only strip top_level_dir when it matches
at a path segment boundary (separator character), preventing false positives
like "/work" matching "/workspace/...". Also extract makeResult helper to
reduce duplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-02 12:41:26 +00:00
Claude Bot
c00371e2af fix(bundler): resolve virtual files from HTML <script src> with absolute paths
When `Bun.build()` is used with a virtual HTML entrypoint (via `files` map)
that references other virtual files through absolute `<script src="/...">` paths,
the HTMLScanner rewrites those paths by joining them with `top_level_dir` (CWD).
This causes FileMap lookups to fail because the map keys use the original paths.

Add a fallback in `FileMap.resolve` that strips the `top_level_dir` prefix from
absolute specifiers and retries the lookup with the original path.

Closes #27687

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-02 12:41:26 +00:00
2 changed files with 163 additions and 18 deletions

View File

@@ -66,12 +66,7 @@ pub const JSBundler = struct {
// Must use getKey to return the map's owned key, not the parameter
if (comptime !bun.Environment.isWindows) {
if (self.map.getKey(specifier)) |key| {
return _resolver.Result{
.path_pair = .{
.primary = Fs.Path.initWithNamespace(key, "file"),
},
.module_type = .unknown,
};
return makeResult(key);
}
} else {
const buf = bun.path_buffer_pool.get();
@@ -79,12 +74,55 @@ pub const JSBundler = struct {
const normalized_specifier = bun.path.pathToPosixBuf(u8, specifier, buf);
if (self.map.getKey(normalized_specifier)) |key| {
return _resolver.Result{
.path_pair = .{
.primary = Fs.Path.initWithNamespace(key, "file"),
},
.module_type = .unknown,
};
return makeResult(key);
}
}
// For absolute specifiers, check if the specifier was produced by the HTML
// scanner's top_level_dir join (e.g., "/virtual/foo.tsx" -> "<CWD>/virtual/foo.tsx").
// If so, try stripping the top_level_dir prefix and looking up the original path.
//
// On Windows, both the specifier and top_level_dir may use backslashes, so we
// normalize both to posix before prefix-matching and map lookup.
if (specifier.len > 0 and isAbsolutePath(specifier)) {
const top_level_dir = Fs.FileSystem.instance.top_level_dir;
if (top_level_dir.len > 0) {
// Normalize both specifier and top_level_dir to posix for consistent matching.
const norm_spec_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(norm_spec_buf);
const norm_tld_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(norm_tld_buf);
const norm_specifier = bun.path.pathToPosixBuf(u8, specifier, norm_spec_buf);
const norm_top_level_dir = bun.path.pathToPosixBuf(u8, top_level_dir, norm_tld_buf);
if (bun.strings.hasPrefix(norm_specifier, norm_top_level_dir)) {
const after_prefix = norm_specifier[norm_top_level_dir.len..];
if (after_prefix.len == 0) {
// specifier == top_level_dir exactly, nothing to look up
} else if (after_prefix[0] == '/') {
// top_level_dir ended without separator, remainder starts with one.
// e.g. top_level_dir="/workspace/bun", specifier="/workspace/bun/virtual/foo.tsx"
// -> after_prefix="/virtual/foo.tsx" (already a valid absolute path)
if (self.map.getKey(after_prefix)) |key| {
return makeResult(key);
}
} else if (norm_top_level_dir[norm_top_level_dir.len - 1] == '/') {
// top_level_dir ended with separator (e.g. "/workspace/bun/"),
// so after_prefix is "virtual/foo.tsx" — prepend "/" to reconstruct
// the original absolute path "/virtual/foo.tsx".
const orig_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(orig_buf);
orig_buf[0] = '/';
@memcpy(orig_buf[1..][0..after_prefix.len], after_prefix);
const original_path = orig_buf[0 .. after_prefix.len + 1];
if (self.map.getKey(original_path)) |key| {
return makeResult(key);
}
}
// else: partial segment match (e.g. "/work" matched "/workspace/..."),
// not a valid top_level_dir prefix — skip this fallback.
}
}
}
@@ -133,12 +171,7 @@ pub const JSBundler = struct {
const joined = buf[0..joined_len];
// Must use getKey to return the map's owned key, not the temporary buffer
if (self.map.getKey(joined)) |key| {
return _resolver.Result{
.path_pair = .{
.primary = Fs.Path.initWithNamespace(key, "file"),
},
.module_type = .unknown,
};
return makeResult(key);
}
}
@@ -162,6 +195,15 @@ pub const JSBundler = struct {
return false;
}
fn makeResult(key: []const u8) _resolver.Result {
return _resolver.Result{
.path_pair = .{
.primary = Fs.Path.initWithNamespace(key, "file"),
},
.module_type = .unknown,
};
}
/// Parse the files option from JavaScript.
/// Expected format: Record<string, string | Blob | File | TypedArray | ArrayBuffer>
/// Uses async parsing for cross-thread safety since bundler runs on a separate thread.

View File

@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";
describe("issue #27687 - virtual HTML entrypoint with absolute script src", () => {
test("resolves virtual script referenced via absolute path from virtual HTML", async () => {
const result = await Bun.build({
entrypoints: ["/virtual/index.html"],
target: "browser",
format: "esm",
minify: false,
files: {
"/virtual/index.html": `
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body>
<div id="root"></div>
<script type="module" src="/virtual/_hydrate.tsx"></script>
</body>
</html>`,
"/virtual/_hydrate.tsx": `console.log("Hydration entry loaded");`,
},
});
if (!result.success) {
console.error(result.logs);
}
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(2);
const htmlOutput = result.outputs.find(o => o.type?.startsWith("text/html"));
expect(htmlOutput).toBeDefined();
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
expect(jsOutput).toBeDefined();
const jsContent = await jsOutput!.text();
expect(jsContent).toContain("Hydration entry loaded");
});
test("resolves virtual script with absolute path from different virtual directory", async () => {
const result = await Bun.build({
entrypoints: ["/app/index.html"],
target: "browser",
format: "esm",
minify: false,
files: {
"/app/index.html": `
<!DOCTYPE html>
<html>
<body>
<script type="module" src="/shared/utils.js"></script>
</body>
</html>`,
"/shared/utils.js": `export const msg = "cross-directory import works";
console.log(msg);`,
},
});
if (!result.success) {
console.error(result.logs);
}
expect(result.success).toBe(true);
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
expect(jsOutput).toBeDefined();
const jsContent = await jsOutput!.text();
expect(jsContent).toContain("cross-directory import works");
});
test("resolves virtual script with root-level absolute path from virtual HTML", async () => {
const result = await Bun.build({
entrypoints: ["/index.html"],
target: "browser",
format: "esm",
minify: false,
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<body>
<script type="module" src="/app.js"></script>
</body>
</html>`,
"/app.js": `console.log("root level script");`,
},
});
if (!result.success) {
console.error(result.logs);
}
expect(result.success).toBe(true);
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
expect(jsOutput).toBeDefined();
const jsContent = await jsOutput!.text();
expect(jsContent).toContain("root level script");
});
});