Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
5c0cb31ef2 Implement Bun.build files option for virtual files
- Add files field to JSBundler Config struct to accept Record<string, string  < /dev/null |  Buffer | Blob>
- Parse files option in JSBundler.zig using JSPropertyIterator and Body.Value.fromJS
- Add files field to BundleV2 struct as StringHashMap(bun.webcore.Blob.Any)
- Copy virtual files from config to BundleV2 in BundleThread.zig
- Modify ParseTask.zig getCodeForParseTaskWithoutPlugins to check virtual files before filesystem
- Add comprehensive tests for virtual files with various content types
- Handle memory management with proper cleanup in deinit functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 13:07:09 +00:00
7 changed files with 343 additions and 0 deletions

1
real_entry.js Normal file
View File

@@ -0,0 +1 @@
import { greeting } from './virtual.js'; console.log(greeting);

View File

@@ -59,6 +59,7 @@ pub const JSBundler = struct {
env_behavior: Api.DotEnvBehavior = .disable,
env_prefix: OwnedString = OwnedString.initEmpty(bun.default_allocator),
tsconfig_override: OwnedString = OwnedString.initEmpty(bun.default_allocator),
files: bun.StringHashMap(WebCore.Blob.Any) = bun.StringHashMap(WebCore.Blob.Any).init(bun.default_allocator),
pub const List = bun.StringArrayHashMapUnmanaged(Config);
@@ -75,6 +76,7 @@ pub const JSBundler = struct {
.owned_chunk = OwnedString.initEmpty(allocator),
.owned_asset = OwnedString.initEmpty(allocator),
},
.files = bun.StringHashMap(WebCore.Blob.Any).init(allocator),
};
errdefer this.deinit(allocator);
errdefer if (plugins.*) |plugin| plugin.deinit();
@@ -472,6 +474,27 @@ pub const JSBundler = struct {
this.throw_on_error = flag;
}
if (try config.getOwnObject(globalThis, "files")) |files| {
var files_iter = try JSC.JSPropertyIterator(.{
.skip_empty_name = true,
.include_value = true,
}).init(globalThis, files);
defer files_iter.deinit();
while (try files_iter.next()) |prop| {
const file_value = files_iter.value;
// Convert the JS value to a Body.Value and then to Blob.Any
var body_value = try WebCore.Body.Value.fromJS(globalThis, file_value);
const blob_any = body_value.useAsAnyBlob();
const key = try prop.toOwnedSlice(allocator);
// Store the blob in the files hash map
try this.files.put(key, blob_any);
}
}
return this;
}
@@ -531,6 +554,14 @@ pub const JSBundler = struct {
self.env_prefix.deinit();
self.footer.deinit();
self.tsconfig_override.deinit();
// Cleanup files hash map
var files_iter = self.files.iterator();
while (files_iter.next()) |entry| {
bun.default_allocator.free(entry.key_ptr.*);
entry.value_ptr.detach();
}
self.files.deinit();
}
};

View File

@@ -133,6 +133,15 @@ pub fn BundleThread(CompletionStruct: type) type {
else => @compileError("Unknown completion struct: " ++ CompletionStruct),
};
completion.transpiler = this;
// Copy virtual files from config to BundleV2
if (CompletionStruct == BundleV2.JSBundleCompletionTask) {
var files_iter = completion.config.files.iterator();
while (files_iter.next()) |entry| {
const key = try allocator.dupe(u8, entry.key_ptr.*);
try this.files.put(key, entry.value_ptr.*);
}
}
defer {
this.graph.pool.reset();

View File

@@ -644,6 +644,44 @@ fn getCodeForParseTaskWithoutPlugins(
};
}
// Check virtual files before reading from filesystem
// Try exact path first, then normalized versions
const virtual_blob = blk: {
// Try exact path
if (task.ctx.files.get(file_path.text)) |blob| break :blk blob;
// Try with leading dot slash
var with_dot_buf: bun.PathBuffer = undefined;
if (!strings.hasPrefixComptime(file_path.text, "./")) {
const with_dot = std.fmt.bufPrint(&with_dot_buf, "./{s}", .{file_path.text}) catch file_path.text;
if (task.ctx.files.get(with_dot)) |blob| break :blk blob;
}
// Try without leading dot slash
const without_dot = if (strings.hasPrefixComptime(file_path.text, "./"))
file_path.text[2..]
else
file_path.text;
if (task.ctx.files.get(without_dot)) |blob| break :blk blob;
break :blk null;
};
if (virtual_blob) |blob_any| {
const slice = blob_any.slice();
// Duplicate the contents using the same allocator strategy
const virtual_contents = if (loader.shouldCopyForBundling())
try bun.default_allocator.dupe(u8, slice)
else
try allocator.dupe(u8, slice);
break :brk .{
.contents = virtual_contents,
.fd = bun.invalid_fd
};
}
break :brk resolver.caches.fs.readFileWithAllocator(
// TODO: this allocator may be wrong for native plugins
if (loader.shouldCopyForBundling())

View File

@@ -117,6 +117,8 @@ pub const BundleV2 = struct {
plugins: ?*JSC.API.JSBundler.Plugin,
completion: ?*JSBundleCompletionTask,
source_code_length: usize,
/// Virtual files provided via the files option in Bun.build
files: bun.StringHashMap(bun.webcore.Blob.Any),
/// There is a race condition where an onResolve plugin may schedule a task on the bundle thread before it's parsing task completes
resolve_tasks_waiting_for_import_source_index: std.AutoArrayHashMapUnmanaged(Index.Int, BabyList(struct { to_source_index: Index, import_record_index: u32 })) = .{},
@@ -812,6 +814,7 @@ pub const BundleV2 = struct {
.plugins = null,
.completion = null,
.source_code_length = 0,
.files = bun.StringHashMap(bun.webcore.Blob.Any).init(allocator),
.thread_lock = bun.DebugThreadLock.initLocked(),
};
if (bake_options) |bo| {
@@ -2232,6 +2235,13 @@ pub const BundleV2 = struct {
}
this.free_list.clearAndFree();
// Cleanup virtual files
var files_iter = this.files.iterator();
while (files_iter.next()) |entry| {
entry.value_ptr.detach();
}
this.files.deinit();
}
pub fn runFromJSInNewThread(

View File

@@ -956,4 +956,238 @@ export { greeting };`,
process.chdir(originalCwd);
}
});
describe("files option", () => {
test("should support virtual files with string content", async () => {
const result = await Bun.build({
entrypoints: ["./main.js"],
files: {
"./main.js": "export default 'Hello from virtual file!';",
"./utils.js": "export const helper = () => 'helper function';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello from virtual file!");
});
test("should support virtual files with Buffer content", async () => {
const buffer = Buffer.from("export const value = 42;", "utf-8");
const result = await Bun.build({
entrypoints: ["./index.js"],
files: {
"./index.js": buffer,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("42");
});
test("should support virtual files with Blob content", async () => {
const blob = new Blob(["export const name = 'blob-content';"], { type: "text/javascript" });
const result = await Bun.build({
entrypoints: ["./blob-entry.js"],
files: {
"./blob-entry.js": blob,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("blob-content");
});
test("should support imports between virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./entry.js"],
files: {
"./entry.js": "import { greeting } from './lib.js'; export default greeting;",
"./lib.js": "export const greeting = 'Hello World';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello World");
});
test("should prioritize virtual files over filesystem files", async () => {
const dir = tempDirWithFiles("virtual-override", {
"real-file.js": "export const source = 'filesystem';",
});
const result = await Bun.build({
entrypoints: ["./entry.js"],
files: {
"./entry.js": "import { source } from './real-file.js'; export default source;",
[`${dir}/real-file.js`]: "export const source = 'virtual';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("virtual");
});
test("should handle CSS virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./styles.css"],
files: {
"./styles.css": "@import './base.css'; .main { color: red; }",
"./base.css": ".base { margin: 0; }",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("margin: 0");
expect(output).toContain("color: red");
});
test("should handle TypeScript virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./main.ts"],
files: {
"./main.ts": `
interface User {
name: string;
age: number;
}
const user: User = { name: "John", age: 30 };
export default user.name;
`,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("John");
});
test("should handle JSX virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./component.jsx"],
files: {
"./component.jsx": `
export default function Hello() {
return <div>Hello JSX!</div>;
}
`,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello JSX!");
});
test("should handle JSON virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./index.js"],
files: {
"./index.js": "import config from './config.json'; export default config.name;",
"./config.json": JSON.stringify({ name: "virtual-config", version: "1.0.0" }),
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("virtual-config");
});
test("should handle relative path imports in virtual files", async () => {
const result = await Bun.build({
entrypoints: ["./src/main.js"],
files: {
"./src/main.js": "import { util } from '../lib/utils.js'; export default util;",
"./lib/utils.js": "export const util = 'utility function';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("utility function");
});
test("should support mixed virtual and real files", async () => {
const dir = tempDirWithFiles("mixed-files", {
"real.js": "export const realValue = 'from-filesystem';",
});
const result = await Bun.build({
entrypoints: ["./main.js"],
files: {
"./main.js": `
import { realValue } from '${join(dir, "real.js")}';
import { virtualValue } from './virtual.js';
export default { real: realValue, virtual: virtualValue };
`,
"./virtual.js": "export const virtualValue = 'from-memory';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("from-filesystem");
expect(output).toContain("from-memory");
});
test("should handle error in virtual files gracefully", async () => {
const result = await Bun.build({
entrypoints: ["./broken.js"],
files: {
"./broken.js": "invalid syntax !!",
},
});
expect(result.success).toBe(false);
expect(result.logs).toBeDefined();
});
test("should support absolute paths in files", async () => {
const result = await Bun.build({
entrypoints: ["/absolute/entry.js"],
files: {
"/absolute/entry.js": "export default 'absolute path works';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("absolute path works");
});
test("should handle empty files object", async () => {
const dir = tempDirWithFiles("empty-files", {
"index.js": "export default 'real file';",
});
const result = await Bun.build({
entrypoints: [join(dir, "index.js")],
files: {},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("real file");
});
});
});

20
test_files_feature.js Normal file
View File

@@ -0,0 +1,20 @@
// Test the files option implementation
// Create a real entry point that imports a virtual file
import { writeFileSync } from "fs";
writeFileSync("./real_entry.js", "import { greeting } from './virtual.js'; console.log(greeting);");
const result = await Bun.build({
entrypoints: ["./real_entry.js"],
files: {
"./virtual.js": "export const greeting = 'Hello from virtual file!';",
},
});
console.log("Success:", result.success);
console.log("Outputs:", result.outputs.length);
if (result.success) {
console.log("Output content:", await result.outputs[0].text());
} else {
console.log("Logs:", result.logs);
}