Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
23be320484 test: Update module() test to verify it's now supported
The test previously expected module() to throw because it wasn't implemented.
Now that build.module() is implemented, update the test to verify it works correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:50 +00:00
Claude Bot
3add6b1756 fix: Clear virtual modules on plugin cleanup to prevent memory leaks
- Clear C++ VirtualModuleMap when plugin is tombstoned
- Clear JavaScript virtualModules Map in runOnEndCallbacks
- Ensures virtual module callbacks are properly garbage collected after build

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:38 +00:00
Claude Bot
7059672379 test: Add additional tests for virtual modules
- Add test to verify onLoad plugins still work alongside virtual modules
- Add test to verify no memory leaks with repeated builds
- Tests ensure virtual modules don't interfere with regular plugin functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:27 +00:00
Claude Bot
aabccfc731 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>
2025-09-08 23:20:06 +00:00
7 changed files with 674 additions and 25 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);
@@ -686,6 +774,13 @@ extern "C" void JSBundlerPlugin__drainDeferred(Bun::JSBundlerPlugin* pluginObjec
extern "C" void JSBundlerPlugin__tombstone(Bun::JSBundlerPlugin* plugin)
{
plugin->plugin.tombstone();
// Clear virtual modules when tombstoning
if (plugin->plugin.virtualModules) {
plugin->plugin.virtualModules->clear();
delete plugin->plugin.virtualModules;
plugin->plugin.virtualModules = nullptr;
}
}
extern "C" JSC::EncodedJSValue JSBundlerPlugin__runOnEndCallbacks(Bun::JSBundlerPlugin* plugin, JSC::EncodedJSValue encodedBuildPromise, JSC::EncodedJSValue encodedBuildResult, JSC::EncodedJSValue encodedRejection)
@@ -738,4 +833,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;
@@ -112,6 +114,12 @@ export function runOnEndCallbacks(
buildResult: Bun.BuildOutput,
buildRejection: AggregateError | undefined,
): Promise<void> | void {
// Clean up virtual modules when build ends
if (this.virtualModules) {
this.virtualModules.clear();
this.virtualModules = undefined;
}
const callbacks = this.onEndCallbacks;
if (!callbacks) return;
const promises: PromiseLike<unknown>[] = [];
@@ -347,8 +355,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 +419,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 +543,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

@@ -402,27 +402,28 @@ describe("Bun.build", () => {
expect(x.logs[0].position).toBeTruthy();
});
test("module() throws error", async () => {
expect(() =>
Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/bundle-ws.ts")],
plugins: [
{
name: "test",
setup: b => {
b.module("ad", () => {
return {
exports: {
hello: "world",
},
loader: "object",
};
});
},
test("module() is now supported", async () => {
// module() is now implemented and should not throw
const result = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/bundle-ws.ts")],
plugins: [
{
name: "test",
setup: b => {
// Verify module() exists and can be called
expect(typeof b.module).toBe("function");
b.module("test-virtual-module", () => {
return {
contents: "export default 'hello world';",
loader: "js",
};
});
},
],
}),
).toThrow();
},
],
});
expect(result.success).toBe(true);
});
test("non-object plugins throw invalid argument errors", () => {

View File

@@ -0,0 +1,438 @@
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
});
test("Bun.build plugin virtual modules - onLoad plugins still work", async () => {
using dir = tempDir("virtual-with-onload", {
"entry.ts": `
import virtual from "my-virtual";
import data from "./real.json";
console.log(virtual, data);
`,
"real.json": `{"original": "data"}`,
});
let onLoadCalled = false;
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: "combined-plugin",
setup(build) {
// Add virtual module
build.module("my-virtual", () => ({
contents: `export default "virtual content";`,
loader: "js",
}));
// Also add regular onLoad plugin for JSON files
build.onLoad({ filter: /\.json$/ }, (args) => {
onLoadCalled = true;
return {
contents: `{"modified": "by onLoad plugin"}`,
loader: "json",
};
});
},
}],
});
expect(result.success).toBe(true);
expect(onLoadCalled).toBe(true);
const output = await result.outputs[0].text();
expect(output).toContain("virtual content");
expect(output).toContain("modified");
expect(output).toContain("by onLoad plugin");
});
test("Bun.build plugin virtual modules - no memory leak on repeated builds", async () => {
using dir = tempDir("virtual-memory", {
"entry.ts": `
import msg from "test-module";
console.log(msg);
`,
});
// Track memory usage with multiple builds
const initialMemory = process.memoryUsage().heapUsed;
const memoryAfterBuilds = [];
// Run multiple builds to check for memory leaks
for (let i = 0; i < 10; i++) {
await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [{
name: `test-plugin-${i}`,
setup(build) {
// Create a large callback to make memory leaks more visible
const largeData = new Array(10000).fill(`data-${i}`);
build.module("test-module", () => ({
contents: `export default "${largeData[0]}";`,
loader: "js",
}));
},
}],
});
// Force GC after each build if available
if (global.gc) {
global.gc();
}
memoryAfterBuilds.push(process.memoryUsage().heapUsed);
}
// Memory usage should stabilize and not continuously grow
// Check that the last few builds don't show significant growth
const lastThreeBuilds = memoryAfterBuilds.slice(-3);
const avgLastThree = lastThreeBuilds.reduce((a, b) => a + b, 0) / 3;
const firstThreeBuilds = memoryAfterBuilds.slice(0, 3);
const avgFirstThree = firstThreeBuilds.reduce((a, b) => a + b, 0) / 3;
// Memory shouldn't grow by more than 50% between first and last builds
// This is a loose check to avoid flakiness
const memoryGrowthRatio = avgLastThree / avgFirstThree;
expect(memoryGrowthRatio).toBeLessThan(1.5);
});