feat: Add build.module() support for Bun.build plugins

Implements virtual module support for Bun.build plugins, allowing plugins to define
modules that can be imported like regular files without creating actual files on disk.

- Added VirtualModuleMap to JSBundlerPlugin for storing virtual modules
- Implemented build.module(specifier, callback) in plugin setup
- Virtual modules are resolved and loaded through the standard plugin pipeline
- Added comprehensive test suite covering various use cases

```js
await Bun.build({
  entrypoints: ["./entry.ts"],
  plugins: [{
    name: "my-plugin",
    setup(build) {
      build.module("my-virtual-module", () => ({
        contents: "export default 'Hello!'",
        loader: "js",
      }));
    }
  }],
});
```

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-09-08 05:14:08 +00:00
parent 594b03c275
commit aabccfc731
6 changed files with 544 additions and 5 deletions

View File

@@ -1162,6 +1162,7 @@ pub const JSBundler = struct {
return JSBundlerPlugin__anyMatches(this, &namespace_string, &path_string, is_onLoad);
}
pub fn matchOnLoad(
this: *Plugin,
path: []const u8,

View File

@@ -57,6 +57,7 @@ JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onResolveAsync);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onBeforeParse);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_generateDeferPromise);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_addVirtualModule);
void BundlerPlugin::NamespaceList::append(JSC::VM& vm, JSC::RegExp* filter, String& namespaceString, unsigned& index)
{
@@ -101,6 +102,13 @@ static bool anyMatchesForNamespace(JSC::VM& vm, BundlerPlugin::NamespaceList& li
}
bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespaceStr, const BunString* path, bool isOnLoad)
{
auto pathString = path->toWTFString(BunString::ZeroCopy);
// Check virtual modules for both onLoad and onResolve
if (this->virtualModules && this->virtualModules->contains(pathString)) {
return true;
}
if (isOnLoad) {
return anyMatchesForNamespace(vm, this->onLoad, namespaceStr, path);
} else {
@@ -108,6 +116,54 @@ bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespac
}
}
bool BundlerPlugin::hasVirtualModule(const String& path) const
{
return virtualModules && virtualModules->contains(path);
}
JSC::JSObject* BundlerPlugin::getVirtualModule(const String& path)
{
if (!virtualModules) {
return nullptr;
}
auto it = virtualModules->find(path);
if (it != virtualModules->end()) {
unsigned index = it->value;
if (index < virtualModulesList.list().size()) {
return virtualModulesList.list()[index].get();
}
}
return nullptr;
}
void BundlerPlugin::addVirtualModule(JSC::VM& vm, JSC::JSCell* owner, const String& path, JSC::JSObject* moduleFunction)
{
if (!virtualModules) {
virtualModules = new VirtualModuleMap();
}
unsigned index = virtualModulesList.list().size();
virtualModulesList.append(vm, owner, moduleFunction);
virtualModules->set(path, index);
}
void BundlerPlugin::tombstone()
{
tombstoned = true;
if (virtualModules) {
delete virtualModules;
virtualModules = nullptr;
}
virtualModulesList.clear();
}
void BundlerPlugin::visitAdditionalChildren(JSC::JSCell* cell, JSC::SlotVisitor& visitor)
{
deferredPromises.visit(visitor);
virtualModulesList.visit(visitor);
}
static const HashTableValue JSBundlerPluginHashTable[] = {
{ "addFilter"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addFilter, 3 } },
{ "addError"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addError, 3 } },
@@ -115,6 +171,7 @@ static const HashTableValue JSBundlerPluginHashTable[] = {
{ "onResolveAsync"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onResolveAsync, 4 } },
{ "onBeforeParse"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onBeforeParse, 4 } },
{ "generateDeferPromise"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_generateDeferPromise, 0 } },
{ "addVirtualModule"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addVirtualModule, 2 } },
};
class JSBundlerPlugin final : public JSC::JSDestructibleObject {
@@ -193,7 +250,7 @@ void JSBundlerPlugin::visitAdditionalChildren(Visitor& visitor)
this->onLoadFunction.visit(visitor);
this->onResolveFunction.visit(visitor);
this->setupFunction.visit(visitor);
this->plugin.deferredPromises.visit(this, visitor);
this->plugin.visitAdditionalChildren(this, visitor);
}
template<typename Visitor>
@@ -467,6 +524,37 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_generateDeferPromise, (JSC::JSG
return encoded_defer_promise;
}
JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addVirtualModule, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
JSBundlerPlugin* thisObject = jsCast<JSBundlerPlugin*>(callFrame->thisValue());
if (thisObject->plugin.tombstoned) {
return JSC::JSValue::encode(JSC::jsUndefined());
}
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSC::JSValue pathValue = callFrame->argument(0);
JSC::JSValue moduleValue = callFrame->argument(1);
if (!pathValue.isString()) {
throwTypeError(globalObject, scope, "Expected first argument to be a string"_s);
return JSC::JSValue::encode({});
}
if (!moduleValue.isObject() || !moduleValue.isCallable()) {
throwTypeError(globalObject, scope, "Expected second argument to be a function"_s);
return JSC::JSValue::encode({});
}
WTF::String path = pathValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
thisObject->plugin.addVirtualModule(vm, thisObject, path, JSC::asObject(moduleValue));
return JSC::JSValue::encode(JSC::jsUndefined());
}
void JSBundlerPlugin::finishCreation(JSC::VM& vm)
{
Base::finishCreation(vm);
@@ -738,4 +826,29 @@ extern "C" JSC::JSGlobalObject* JSBundlerPlugin__globalObject(Bun::JSBundlerPlug
return plugin->m_globalObject;
}
extern "C" bool JSBundlerPlugin__hasVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
return plugin->plugin.hasVirtualModule(pathStr);
}
extern "C" JSC::EncodedJSValue JSBundlerPlugin__getVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
auto* virtualModule = plugin->plugin.getVirtualModule(pathStr);
if (virtualModule) {
return JSC::JSValue::encode(virtualModule);
}
return JSC::JSValue::encode(JSC::jsUndefined());
}
extern "C" void JSBundlerPlugin__addVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path, JSC::EncodedJSValue encodedModuleFunction)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
JSC::JSValue moduleFunction = JSC::JSValue::decode(encodedModuleFunction);
if (moduleFunction.isObject()) {
plugin->plugin.addVirtualModule(plugin->vm(), plugin, pathStr, JSC::asObject(moduleFunction));
}
}
} // namespace Bun

View File

@@ -20,6 +20,8 @@ using namespace JSC;
class BundlerPlugin final {
public:
using VirtualModuleMap = WTF::UncheckedKeyHashMap<String, unsigned>;
/// In native plugins, the regular expression could be called concurrently on multiple threads.
/// Therefore, we need a mutex to synchronize access.
class FilterRegExp {
@@ -119,7 +121,11 @@ public:
public:
bool anyMatchesCrossThread(JSC::VM&, const BunString* namespaceStr, const BunString* path, bool isOnLoad);
void tombstone() { tombstoned = true; }
bool hasVirtualModule(const String& path) const;
JSC::JSObject* getVirtualModule(const String& path);
void addVirtualModule(JSC::VM& vm, JSC::JSCell* owner, const String& path, JSC::JSObject* moduleFunction);
void tombstone();
void visitAdditionalChildren(JSC::JSCell*, JSC::SlotVisitor&);
BundlerPlugin(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
: addError(addError)
@@ -130,12 +136,20 @@ public:
this->config = config;
}
~BundlerPlugin() {
if (virtualModules) {
delete virtualModules;
}
}
NamespaceList onLoad = {};
NamespaceList onResolve = {};
NativePluginList onBeforeParse = {};
BunPluginTarget target { BunPluginTargetBrowser };
WriteBarrierList<JSC::JSPromise> deferredPromises = {};
VirtualModuleMap* virtualModules { nullptr };
WriteBarrierList<JSC::JSObject> virtualModulesList = {};
JSBundlerPluginAddErrorCallback addError;
JSBundlerPluginOnLoadAsyncCallback onLoadAsync;

View File

@@ -2889,6 +2889,7 @@ pub const BundleV2 = struct {
original_target: options.Target,
) bool {
if (this.plugins) |plugins| {
// anyMatches will check for virtual modules too
if (plugins.hasAnyMatches(&import_record.path, false)) {
// This is where onResolve plugins are enqueued
var resolve: *jsc.API.JSBundler.Resolve = bun.default_allocator.create(jsc.API.JSBundler.Resolve) catch unreachable;

View File

@@ -8,6 +8,7 @@ interface BundlerPlugin {
onLoad: Map<string, [RegExp, OnLoadCallback][]>;
onResolve: Map<string, [RegExp, OnResolveCallback][]>;
onEndCallbacks: Array<(build: Bun.BuildOutput) => void | Promise<void>> | undefined;
virtualModules: Map<string, () => { contents: string; loader?: string }> | undefined;
/** Binding to `JSBundlerPlugin__onLoadAsync` */
onLoadAsync(
internalID,
@@ -19,6 +20,7 @@ interface BundlerPlugin {
/** Binding to `JSBundlerPlugin__addError` */
addError(internalID: number, error: any, which: number): void;
addFilter(filter, namespace, number): void;
addVirtualModule(path: string, moduleFunction: () => any): void;
generateDeferPromise(id: number): Promise<void>;
promises: Array<Promise<any>> | undefined;
@@ -347,8 +349,24 @@ export function runSetupFunction(
onBeforeParse,
onStart,
resolve: notImplementedIssueFn(2771, "build.resolve()"),
module: () => {
throw new TypeError("module() is not supported in Bun.build() yet. Only via Bun.plugin() at runtime");
module: (specifier: string, callback: () => { contents: string; loader?: string }) => {
if (typeof specifier !== "string") {
throw new TypeError("module() specifier must be a string");
}
if (typeof callback !== "function") {
throw new TypeError("module() callback must be a function");
}
// Store the virtual module
if (!self.virtualModules) {
self.virtualModules = new Map();
}
self.virtualModules.$set(specifier, callback);
// Register the virtual module with the C++ side
self.addVirtualModule(specifier, callback);
return this;
},
addPreload: () => {
throw new TypeError("addPreload() is not supported in Bun.build() yet.");
@@ -395,7 +413,15 @@ export function runOnResolvePlugins(this: BundlerPlugin, specifier, inputNamespa
const kind = $ImportKindIdToLabel[kindId];
var promiseResult: any = (async (inputPath, inputNamespace, importer, kind) => {
var { onResolve, onLoad } = this;
var { onResolve, onLoad, virtualModules } = this;
// Check for virtual modules first
if (virtualModules && virtualModules.$has(inputPath)) {
// Return the virtual module with file namespace (empty string means file)
this.onResolveAsync(internalID, inputPath, "", false);
return null;
}
var results = onResolve.$get(inputNamespace);
if (!results) {
this.onResolveAsync(internalID, null, null, null);
@@ -511,6 +537,48 @@ export function runOnLoadPlugins(
const generateDefer = () => this.generateDeferPromise(internalID);
var promiseResult = (async (internalID, path, namespace, isServerSide, defaultLoader, generateDefer) => {
// Check for virtual modules first (file namespace and in virtualModules map)
if (this.virtualModules && this.virtualModules.$has(path) && (namespace === "file" || namespace === "")) {
const virtualModuleCallback = this.virtualModules.$get(path);
if (virtualModuleCallback) {
let result;
try {
result = virtualModuleCallback();
} catch (e) {
// If the callback throws, report it as an error
this.addError(internalID, e, 1);
return null;
}
try {
if (!result || !$isObject(result)) {
throw new TypeError('Virtual module must return an object with "contents" property');
}
var { contents, loader = defaultLoader } = result;
if (!(typeof contents === "string")) {
throw new TypeError('Virtual module must return an object with "contents" as a string');
}
if (!(typeof loader === "string")) {
throw new TypeError('Virtual module "loader" must be a string if provided');
}
const chosenLoader = LOADERS_MAP[loader];
if (chosenLoader === undefined) {
throw new TypeError(`Loader ${loader} is not supported.`);
}
this.onLoadAsync(internalID, contents, chosenLoader);
return null;
} catch (e) {
this.addError(internalID, e, 1);
return null;
}
}
}
var results = this.onLoad.$get(namespace);
if (!results) {
this.onLoadAsync(internalID, null, null);

View File

@@ -0,0 +1,342 @@
import { test, expect } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import * as path from "node:path";
test("Bun.build plugin virtual modules - basic", async () => {
using dir = tempDir("virtual-basic", {
"entry.ts": `
import message from "my-virtual-module";
console.log(message);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "virtual-module-plugin",
setup(build) {
build.module("my-virtual-module", () => ({
contents: `export default "Hello from virtual module!"`,
loader: "js",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello from virtual module!");
});
test("Bun.build plugin virtual modules - multiple modules", async () => {
using dir = tempDir("virtual-multiple", {
"entry.ts": `
import { greeting } from "virtual-greeting";
import { name } from "virtual-name";
console.log(greeting + " " + name);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "multi-virtual-plugin",
setup(build) {
build.module("virtual-greeting", () => ({
contents: `export const greeting = "Hello";`,
loader: "js",
}));
build.module("virtual-name", () => ({
contents: `export const name = "World";`,
loader: "js",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello");
expect(output).toContain("World");
});
test("Bun.build plugin virtual modules - TypeScript", async () => {
using dir = tempDir("virtual-typescript", {
"entry.ts": `
import { calculate } from "virtual-math";
console.log(calculate(5, 10));
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "typescript-virtual-plugin",
setup(build) {
build.module("virtual-math", () => ({
contents: `
export function calculate(a: number, b: number): number {
return a + b;
}
`,
loader: "ts",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("calculate(5, 10)"); // Function call is in output
});
test("Bun.build plugin virtual modules - JSON", async () => {
using dir = tempDir("virtual-json", {
"entry.ts": `
import config from "virtual-config";
console.log(config.version);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "json-virtual-plugin",
setup(build) {
build.module("virtual-config", () => ({
contents: JSON.stringify({ version: "1.2.3", enabled: true }),
loader: "json",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("1.2.3");
});
test("Bun.build plugin virtual modules - with onLoad and onResolve", async () => {
using dir = tempDir("virtual-mixed", {
"entry.ts": `
import virtual from "my-virtual";
import modified from "./real.js";
console.log(virtual, modified);
`,
"real.js": `export default "original";`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "mixed-plugin",
setup(build) {
// Virtual module
build.module("my-virtual", () => ({
contents: `export default "virtual content";`,
loader: "js",
}));
// Regular onLoad plugin
build.onLoad({ filter: /\.js$/ }, () => ({
contents: `export default "modified";`,
loader: "js",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("virtual content");
expect(output).toContain("modified");
});
test("Bun.build plugin virtual modules - dynamic content", async () => {
using dir = tempDir("virtual-dynamic", {
"entry.ts": `
import timestamp from "virtual-timestamp";
console.log(timestamp);
`,
});
const buildTime = Date.now();
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "dynamic-virtual-plugin",
setup(build) {
build.module("virtual-timestamp", () => ({
contents: `export default ${buildTime};`,
loader: "js",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain(buildTime.toString());
});
test("Bun.build plugin virtual modules - nested imports", async () => {
using dir = tempDir("virtual-nested", {
"entry.ts": `
import { main } from "virtual-main";
console.log(main());
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "nested-virtual-plugin",
setup(build) {
build.module("virtual-main", () => ({
contents: `
import { helper } from "virtual-helper";
export function main() {
return helper() + " from main";
}
`,
loader: "js",
}));
build.module("virtual-helper", () => ({
contents: `
export function helper() {
return "Hello";
}
`,
loader: "js",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("helper() + \" from main\""); // Check for the function composition
});
test("Bun.build plugin virtual modules - multiple plugins", async () => {
using dir = tempDir("virtual-multi-plugin", {
"entry.ts": `
import first from "virtual-first";
import second from "virtual-second";
console.log(first, second);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "first-plugin",
setup(build) {
build.module("virtual-first", () => ({
contents: `export default "from first plugin";`,
loader: "js",
}));
},
},
{
name: "second-plugin",
setup(build) {
build.module("virtual-second", () => ({
contents: `export default "from second plugin";`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("from first plugin");
expect(output).toContain("from second plugin");
});
test("Bun.build plugin virtual modules - error handling", async () => {
using dir = tempDir("virtual-error", {
"entry.ts": `
import data from "virtual-error";
console.log(data);
`,
});
// Plugin errors are thrown as "Bundle failed"
await expect(
Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "error-plugin",
setup(build) {
build.module("virtual-error", () => {
throw new Error("Failed to generate virtual module");
});
},
}],
})
).rejects.toThrow("Bundle failed");
});
test("Bun.build plugin virtual modules - CSS", async () => {
using dir = tempDir("virtual-css", {
"entry.ts": `
import styles from "virtual-styles";
console.log(styles);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "css-virtual-plugin",
setup(build) {
build.module("virtual-styles", () => ({
contents: `
.container {
display: flex;
justify-content: center;
align-items: center;
}
`,
loader: "css",
}));
},
}],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(2); // JS and CSS output
});