From aabccfc731f85efddf37fe31fad0e2536560dc46 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 8 Sep 2025 05:14:08 +0000 Subject: [PATCH] feat: Add build.module() support for Bun.build plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/bun.js/api/JSBundler.zig | 1 + src/bun.js/bindings/JSBundlerPlugin.cpp | 115 +++++- src/bun.js/bindings/JSBundlerPlugin.h | 16 +- src/bundler/bundle_v2.zig | 1 + src/js/builtins/BundlerPlugin.ts | 74 +++- .../bundler-plugin-virtual-modules.test.ts | 342 ++++++++++++++++++ 6 files changed, 544 insertions(+), 5 deletions(-) create mode 100644 test/bundler/bundler-plugin-virtual-modules.test.ts diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index acc4336de8..491f2d1b13 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -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, diff --git a/src/bun.js/bindings/JSBundlerPlugin.cpp b/src/bun.js/bindings/JSBundlerPlugin.cpp index 9b129148a1..5f5a50c45f 100644 --- a/src/bun.js/bindings/JSBundlerPlugin.cpp +++ b/src/bun.js/bindings/JSBundlerPlugin.cpp @@ -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(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addFilter, 3 } }, { "addError"_s, static_cast(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(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onResolveAsync, 4 } }, { "onBeforeParse"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onBeforeParse, 4 } }, { "generateDeferPromise"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_generateDeferPromise, 0 } }, + { "addVirtualModule"_s, static_cast(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 @@ -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(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 diff --git a/src/bun.js/bindings/JSBundlerPlugin.h b/src/bun.js/bindings/JSBundlerPlugin.h index 34fb313075..d791609ffa 100644 --- a/src/bun.js/bindings/JSBundlerPlugin.h +++ b/src/bun.js/bindings/JSBundlerPlugin.h @@ -20,6 +20,8 @@ using namespace JSC; class BundlerPlugin final { public: + using VirtualModuleMap = WTF::UncheckedKeyHashMap; + /// 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 deferredPromises = {}; + VirtualModuleMap* virtualModules { nullptr }; + WriteBarrierList virtualModulesList = {}; JSBundlerPluginAddErrorCallback addError; JSBundlerPluginOnLoadAsyncCallback onLoadAsync; diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 8cf22fc9f1..0a7b23115a 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -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; diff --git a/src/js/builtins/BundlerPlugin.ts b/src/js/builtins/BundlerPlugin.ts index 2e262c7854..bb938a3d06 100644 --- a/src/js/builtins/BundlerPlugin.ts +++ b/src/js/builtins/BundlerPlugin.ts @@ -8,6 +8,7 @@ interface BundlerPlugin { onLoad: Map; onResolve: Map; onEndCallbacks: Array<(build: Bun.BuildOutput) => void | Promise> | undefined; + virtualModules: Map { 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; promises: Array> | 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); diff --git a/test/bundler/bundler-plugin-virtual-modules.test.ts b/test/bundler/bundler-plugin-virtual-modules.test.ts new file mode 100644 index 0000000000..a217e14502 --- /dev/null +++ b/test/bundler/bundler-plugin-virtual-modules.test.ts @@ -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 +}); \ No newline at end of file