Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
0b5ad9d136 Add modules option to Bun.build for virtual module support
This commit implements the `modules` configuration option for Bun.build,
allowing users to provide virtual modules (in-memory content) that override
specific import specifiers without filesystem I/O.

Features:
- Accepts strings, Blobs, and TypedArrays as module content
- Uses exact string matching for import specifiers
- Virtual modules have highest priority in resolution (before plugins)
- Loader inferred from file extension or defaults to .js
- Proper memory management with contents owned by default_allocator

Implementation:
- Added VirtualModule struct to bundle_v2.zig to store module contents
- Added virtual_modules field to BundleV2 and Config structs
- Implemented modules parsing in JSBundler.zig using JSPropertyIterator
- Added enqueueVirtualModuleParseTask() to create ParseTask without I/O
- Integrated virtual module resolution in runResolutionForParseTask()
- Added proper cleanup in deinit() with move semantics to prevent double-free

Tests:
- Added bundler_modules.test.ts with 4 test cases
- Tests cover string, Blob, and TypedArray inputs
- Tests verify error handling for invalid module values

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 23:25:05 +00:00
Claude Bot
7b12fbe359 WIP: Add modules configuration parsing for Bun.build
This is a work-in-progress implementation of the `modules` option for
Bun.build, which allows users to provide virtual modules (in-memory content)
that override specific import specifiers without filesystem I/O.

Changes in this commit:
- Add VirtualModule struct to BundleV2 with proper Blob handling (by value)
- Add virtual_modules field to BundleV2 and Config structs
- Implement modules parsing in Config.fromJS() to handle strings, Blobs, and TypedArrays
- Add cleanup logic in Config.deinit() with move semantics
- Add basic test scaffolding

Remaining work:
- Transfer virtual_modules from Config to BundleV2
- Implement enqueueVirtualModuleParseTask() function
- Add virtual module resolution in runResolutionForParseTask()
- Implement BundleV2.deinit() cleanup
- Complete and run tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 23:00:45 +00:00
4 changed files with 301 additions and 0 deletions

View File

@@ -45,6 +45,13 @@ pub const JSBundler = struct {
tsconfig_override: OwnedString = OwnedString.initEmpty(bun.default_allocator),
compile: ?CompileOptions = null,
/// Virtual modules provided via the `modules` option
/// Map of import specifier → content (string/blob/typedarray)
virtual_modules: bun.StringHashMapUnmanaged(BundleV2.VirtualModule) = .{},
/// Flag to prevent double-free when virtual_modules is moved to BundleV2
virtual_modules_moved: bool = false,
pub const CompileOptions = struct {
compile_target: CompileTarget = .{},
exec_argv: OwnedString = OwnedString.initEmpty(bun.default_allocator),
@@ -719,6 +726,57 @@ pub const JSBundler = struct {
}
}
// Parse virtual modules
if (try config.getOwnObject(globalThis, "modules")) |modules_obj| {
var modules_iter = try jsc.JSPropertyIterator(.{
.skip_empty_name = true,
.include_value = true,
}).init(globalThis, modules_obj);
defer modules_iter.deinit();
while (try modules_iter.next()) |specifier_prop| {
const specifier = try specifier_prop.toOwnedSlice(bun.default_allocator);
errdefer bun.default_allocator.free(specifier);
const value_js = modules_iter.value;
// Convert JS value to VirtualModule
const virtual_module = brk: {
// Try BlobOrStringOrBuffer
if (try jsc.Node.BlobOrStringOrBuffer.fromJS(
globalThis,
bun.default_allocator,
value_js,
)) |blob_or_str| {
switch (blob_or_str) {
.blob => |blob| {
// Clone blob contents to owned memory
const contents = try bun.default_allocator.dupe(u8, blob.sharedView());
break :brk BundleV2.VirtualModule{
.contents = contents,
};
},
.string_or_buffer => |str_buf| {
// Get slice and clone it
const contents = try bun.default_allocator.dupe(u8, str_buf.slice());
break :brk BundleV2.VirtualModule{
.contents = contents,
};
},
}
} else {
return globalThis.throwInvalidArguments(
"modules[\"{s}\"] must be a string, Blob, or TypedArray",
.{specifier},
);
}
};
// Insert into map
try this.virtual_modules.put(bun.default_allocator, specifier, virtual_module);
}
}
return this;
}
@@ -795,6 +853,18 @@ pub const JSBundler = struct {
self.env_prefix.deinit();
self.footer.deinit();
self.tsconfig_override.deinit();
// Clean up virtual modules if not moved
if (!self.virtual_modules_moved) {
var iter = self.virtual_modules.iterator();
while (iter.next()) |entry| {
// Free the key (specifier)
bun.default_allocator.free(entry.key_ptr.*);
// Free the value (VirtualModule contents)
entry.value_ptr.deinit();
}
self.virtual_modules.deinit(bun.default_allocator);
}
}
};

View File

@@ -134,6 +134,12 @@ pub fn BundleThread(CompletionStruct: type) type {
};
completion.transpiler = this;
// Transfer virtual modules from config to BundleV2
if (CompletionStruct == BundleV2.JSBundleCompletionTask) {
this.virtual_modules = completion.config.virtual_modules;
completion.config.virtual_modules_moved = true;
}
defer {
ast_memory_allocator.pop();
this.deinitWithoutFreeingArena();

View File

@@ -106,6 +106,18 @@ fn fmtEscapedNamespace(slice: []const u8, comptime fmt: []const u8, _: std.fmt.F
}
pub const BundleV2 = struct {
/// Represents a user-provided virtual module from the `modules` config option
pub const VirtualModule = struct {
/// The module's source code or binary content
/// This memory is owned by the VirtualModule and will be freed on deinit
/// Contents are always duplicated from the original source
contents: []const u8,
pub fn deinit(this: *VirtualModule) void {
// Always free contents - they're owned by default_allocator
bun.default_allocator.free(this.contents);
}
};
transpiler: *Transpiler,
/// When Server Component is enabled, this is used for the client bundles
/// and `transpiler` is used for the server bundles.
@@ -124,6 +136,11 @@ pub const BundleV2 = struct {
/// 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 })) = .{},
/// Map of import specifiers to virtual module contents
/// Populated from Bun.build({ modules: {...} })
/// Keys are owned by this map, values are VirtualModule structs
virtual_modules: bun.StringHashMapUnmanaged(VirtualModule) = .{},
/// Allocations not tracked by a threadlocal heap
free_list: std.ArrayList([]const u8) = std.ArrayList([]const u8).init(bun.default_allocator),
@@ -1391,6 +1408,79 @@ pub const BundleV2 = struct {
return source_index.get();
}
/// Enqueues a ParseTask for a virtual module (from `modules` config)
/// Similar to enqueueParseTask but skips filesystem I/O
pub fn enqueueVirtualModuleParseTask(
this: *BundleV2,
specifier: []const u8,
virtual_module: *const VirtualModule,
known_target: options.Target,
) OOM!Index.Int {
const source_index = Index.init(@as(u32, @intCast(this.graph.ast.len)));
// Determine loader from file extension or default to .js
const loader = brk: {
// Check for file extension in specifier
if (std.mem.lastIndexOfScalar(u8, specifier, '.')) |dot_index| {
const ext = specifier[dot_index..];
if (options.Loader.fromString(ext)) |loader_from_ext| {
break :brk loader_from_ext;
}
}
// Default to JavaScript
break :brk options.Loader.js;
};
// Use the specifier as-is for the path
const path_text = try this.allocator().dupe(u8, specifier);
// Create source with the exact specifier as path
const source = Logger.Source{
.path = Fs.Path.init(path_text),
.contents = virtual_module.contents,
.index = source_index,
};
// Add placeholder AST
this.graph.ast.append(this.allocator(), JSAst.empty) catch unreachable;
// Add input file
this.graph.input_files.append(this.allocator(), .{
.source = source,
.loader = loader,
.side_effects = loader.sideEffects(),
}) catch |err| bun.handleOom(err);
// Create ParseTask with pre-loaded contents
var task = bun.handleOom(this.allocator().create(ParseTask));
task.* = .{
.ctx = this,
.path = source.path,
.contents_or_fd = .{ .contents = virtual_module.contents },
.side_effects = loader.sideEffects(),
.jsx = this.transpilerForTarget(known_target).options.jsx,
.source_index = source_index,
.module_type = .unknown,
.emit_decorator_metadata = false,
.package_version = "",
.loader = loader,
.tree_shaking = this.linker.options.tree_shaking,
.known_target = known_target,
};
task.task.node.next = null;
task.io_task.node.next = null;
this.incrementScanCounter();
// Check for onLoad plugins (even virtual modules can be intercepted)
if (!this.enqueueOnLoadPluginIfNeeded(task)) {
// No plugin matched - schedule parsing
this.graph.pool.schedule(task);
}
return source_index.get();
}
/// Enqueue a ServerComponentParseTask.
/// `source_without_index` is copied and assigned a new source index. That index is returned.
pub fn enqueueServerComponentGeneratedFile(
@@ -2589,6 +2679,18 @@ pub const BundleV2 = struct {
}
this.graph.pool.deinit();
// Clean up virtual modules
{
var iter = this.virtual_modules.iterator();
while (iter.next()) |entry| {
// Free the key (specifier)
bun.default_allocator.free(entry.key_ptr.*);
// Free the value (VirtualModule contents)
entry.value_ptr.deinit();
}
this.virtual_modules.deinit(bun.default_allocator);
}
for (this.free_list.items) |free| {
bun.default_allocator.free(free);
}
@@ -3110,6 +3212,42 @@ pub const BundleV2 = struct {
continue;
}
// Check virtual modules first (highest priority)
if (this.virtual_modules.get(import_record.path.text)) |virtual_module| {
// This import resolves to a virtual module!
const target = ast.target;
const path_map = this.pathToSourceIndexMap(target);
// Check if we've already enqueued this virtual module
const entry = path_map.getOrPut(
this.allocator(),
import_record.path.text,
) catch |err| {
last_error = err;
continue :outer;
};
if (entry.found_existing) {
// Already parsed/parsing - just link to it
import_record.source_index = Index.init(entry.value_ptr.*);
} else {
// First time seeing this virtual module - enqueue parse task
const virtual_source_index = this.enqueueVirtualModuleParseTask(
import_record.path.text,
&virtual_module,
target,
) catch |err| {
last_error = err;
continue :outer;
};
entry.value_ptr.* = virtual_source_index;
import_record.source_index = Index.init(virtual_source_index);
}
// Mark as resolved - skip further resolution
continue;
}
if (this.framework) |fw| if (fw.server_components != null) {
switch (ast.target.isServerSide()) {
inline else => |is_server| {

View File

@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
import { join } from "path";
describe("Bun.build modules option", () => {
test("should accept modules option with string", async () => {
using tmp = tempDir("bundler-modules-basic", {
"entry.js": `
import { msg } from "virtual:msg";
console.log(msg);
`,
});
const result = await Bun.build({
entrypoints: [join(tmp, "entry.js")],
modules: {
"virtual:msg": "export const msg = 'Hello from virtual!';",
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const code = await result.outputs[0].text();
expect(code).toContain("Hello from virtual");
});
test("should accept modules option with Blob", async () => {
using tmp = tempDir("bundler-modules-blob", {
"entry.js": `
import data from "virtual:data";
console.log(data);
`,
});
const blob = new Blob(["export default { key: 'value' };"]);
const result = await Bun.build({
entrypoints: [join(tmp, "entry.js")],
modules: {
"virtual:data": blob,
},
});
expect(result.success).toBe(true);
const code = await result.outputs[0].text();
expect(code).toContain("key");
});
test("should accept modules option with Uint8Array", async () => {
using tmp = tempDir("bundler-modules-uint8", {
"entry.js": `
import data from "virtual:data";
console.log(data);
`,
});
const encoder = new TextEncoder();
const arr = encoder.encode("export default 42;");
const result = await Bun.build({
entrypoints: [join(tmp, "entry.js")],
modules: {
"virtual:data": arr,
},
});
expect(result.success).toBe(true);
const code = await result.outputs[0].text();
expect(code).toContain("42");
});
test("should reject invalid module value", async () => {
using tmp = tempDir("bundler-modules-invalid", {
"entry.js": `console.log("hi");`,
});
await expect(async () => {
await Bun.build({
entrypoints: [join(tmp, "entry.js")],
modules: {
"bad": 123 as any,
},
});
}).toThrow(/must be a string, Blob, or TypedArray/);
});
});