Compare commits

...

15 Commits

Author SHA1 Message Date
autofix-ci[bot]
b60b3cbef1 [autofix.ci] apply automated fixes 2025-09-13 04:48:26 +00:00
Claude Bot
57358d3062 feat: improve virtual module support in bundler plugins
- Support async factories that return Promises
- Support Uint8Array/typed arrays for module contents
- Support loader: 'object' with JSON serialization (parity with onLoad)
- Fix registration order to ensure native state updates before JS map
- Fix namespace check for virtual modules (only match file/empty namespace)
- Fix virtual module resolution to work without onResolve filters
- Change VirtualModuleMap from pointer to value type for better memory management

All tests passing for bundler-plugin-virtual-modules.test.ts and bun-build-api.test.ts

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 04:45:42 +00:00
Claude Bot
8557ca1b87 Merge branch 'main' into claude/add-virtual-modules-support 2025-09-13 04:25:47 +00:00
autofix-ci[bot]
a07758190a [autofix.ci] apply automated fixes 2025-09-10 02:09:24 +00:00
Claude Bot
2185da0931 Address remaining PR review comments
- Use 'file' namespace consistently instead of empty string for virtual modules
- Add idempotency check to prevent duplicate virtual module registration
- Add test for duplicate registration prevention
- Fix Map method usage (use .has/.get/.set instead of //)

Native cleanup (tombstone) happens automatically when the plugin is destroyed,
so no manual tombstone call is needed from JavaScript.

All tests pass successfully.
2025-09-10 02:06:41 +00:00
autofix-ci[bot]
2127a3cb6c [autofix.ci] apply automated fixes 2025-09-10 01:07:26 +00:00
Claude Bot
49be23280a Fix module() API test to properly import virtual module 2025-09-10 01:04:55 +00:00
Claude Bot
52c138ef15 Address PR review comments
- Add test for virtual module as entrypoint (requested by alii)
- Include module name in error messages for better debugging (requested by alii)
- Check outputs in bun-build-api test to verify virtual module content (requested by alii)
- Use intrinsic argument validation (, ) for consistency
- Guard against undefined onResolve map to prevent crashes

All tests continue to pass with these improvements.
2025-09-10 01:03:07 +00:00
Claude Bot
c605abc61c Fix remaining compilation errors
- Make BundlerPlugin::visitAdditionalChildren templated to support AbstractSlotVisitor
- Move template implementation to header file for proper instantiation
- All tests now pass successfully
2025-09-09 21:54:05 +00:00
Claude Bot
5f612dd2ba Fix compilation errors and address PR review feedback
- Add missing root.h include at the beginning of JSBundlerPlugin.cpp
- Fix WriteBarrierList usage in visitAdditionalChildren (pass cell parameter)
- Remove invalid clear() call on WriteBarrierList in tombstone()
- Fix namespace comment in JSBundlerPlugin.h
- Default virtual module loader to 'js' instead of defaultLoader
- Make test filter more specific to avoid unintended matches
- Use regex for error matching in tests to be more robust
2025-09-09 20:52:43 +00:00
autofix-ci[bot]
674759309e [autofix.ci] apply automated fixes 2025-09-08 23:29:51 +00:00
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
6 changed files with 806 additions and 26 deletions

View File

@@ -1,3 +1,4 @@
#include "root.h"
#include "JSBundlerPlugin.h"
#include "BunProcess.h"
@@ -57,6 +58,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 +103,17 @@ 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 (only in "file" namespace)
if (!this->virtualModules.isEmpty()) {
// Virtual modules only work in the "file" namespace or empty namespace (defaults to "file")
bool isFileNamespace = !namespaceStr || namespaceStr->isEmpty() || namespaceStr->toWTFString(BunString::ZeroCopy) == "file"_s;
if (isFileNamespace && this->virtualModules.contains(pathString)) {
return true;
}
}
if (isOnLoad) {
return anyMatchesForNamespace(vm, this->onLoad, namespaceStr, path);
} else {
@@ -108,6 +121,43 @@ bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespac
}
}
bool BundlerPlugin::hasVirtualModule(const String& path) const
{
return !virtualModules.isEmpty() && virtualModules.contains(path);
}
JSC::JSObject* BundlerPlugin::getVirtualModule(const String& path)
{
if (virtualModules.isEmpty()) {
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)
{
unsigned index = virtualModulesList.list().size();
virtualModulesList.append(vm, owner, moduleFunction);
virtualModules.set(path, index);
}
void BundlerPlugin::tombstone()
{
tombstoned = true;
virtualModules.clear();
// virtualModulesList will be cleaned up by destructor
}
// Template implementation moved to header file
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 +165,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 +244,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 +518,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 +820,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,16 @@ 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();
template<typename Visitor>
void visitAdditionalChildren(JSC::JSCell* cell, Visitor& visitor)
{
deferredPromises.visit(cell, visitor);
virtualModulesList.visit(cell, visitor);
}
BundlerPlugin(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
: addError(addError)
@@ -130,12 +141,18 @@ public:
this->config = config;
}
~BundlerPlugin()
{
}
NamespaceList onLoad = {};
NamespaceList onResolve = {};
NativePluginList onBeforeParse = {};
BunPluginTarget target { BunPluginTargetBrowser };
WriteBarrierList<JSC::JSPromise> deferredPromises = {};
VirtualModuleMap virtualModules {};
WriteBarrierList<JSC::JSObject> virtualModulesList = {};
JSBundlerPluginAddErrorCallback addError;
JSBundlerPluginOnLoadAsyncCallback onLoadAsync;
@@ -144,4 +161,4 @@ public:
bool tombstoned { false };
};
} // namespace Zig
} // namespace Bun

View File

@@ -2894,6 +2894,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,33 @@ 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 $ERR_INVALID_ARG_TYPE("specifier", "string", specifier);
}
if (!$isCallable(callback)) {
throw $ERR_INVALID_ARG_TYPE("callback", "function", callback);
}
// Store the virtual module
if (!self.virtualModules) {
self.virtualModules = new Map();
}
// Check for duplicate registration
if (self.virtualModules.has(specifier)) {
const prev = self.virtualModules.get(specifier);
if (prev !== callback) {
throw new TypeError(`Virtual module "${specifier}" is already registered`);
}
return this; // idempotent - same callback already registered
}
// Register with native first; update JS map only on success
self.addVirtualModule(specifier, callback);
self.virtualModules.set(specifier, callback);
return this;
},
addPreload: () => {
throw new TypeError("addPreload() is not supported in Bun.build() yet.");
@@ -395,8 +428,21 @@ 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 (in file namespace or empty namespace)
if (virtualModules && (!inputNamespace || inputNamespace === "file") && virtualModules.has(inputPath)) {
this.onResolveAsync(internalID, inputPath, "file", false);
return null;
}
if (!onResolve) {
this.onResolveAsync(internalID, null, null, null);
return null;
}
var results = onResolve.$get(inputNamespace);
if (!results) {
this.onResolveAsync(internalID, null, null, null);
return null;
@@ -511,6 +557,74 @@ 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") {
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 {
// Unwrap/await promises like onLoad
while (
result &&
$isPromise(result) &&
($getPromiseInternalField(result, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
) {
result = $getPromiseInternalField(result, $promiseFieldReactionsOrResult);
}
if (result && $isPromise(result)) {
result = await result;
}
if (!result || !$isObject(result)) {
throw new TypeError(`Virtual module "${path}" must return an object with "contents" property`);
}
var { contents, loader = "js" } = result as any;
if ((loader as any) === "object") {
if (!("exports" in result)) {
throw new TypeError('Virtual module returning loader: "object" must have "exports" property');
}
try {
contents = JSON.stringify(result.exports);
loader = "json";
} catch (e) {
throw new TypeError(
'Virtual module must return a JSON-serializable object when using loader: "object": ' + e,
);
}
}
if (!(typeof contents === "string") && !$isTypedArrayView(contents)) {
throw new TypeError(
`Virtual module "${path}" must return an object with "contents" as a string or Uint8Array`,
);
}
if (!(typeof loader === "string")) {
throw new TypeError(`Virtual module "${path}" "loader" must be a string if provided`);
}
const chosenLoader = LOADERS_MAP[loader];
if (chosenLoader === undefined) {
throw new TypeError(`Virtual module "${path}": Loader ${loader} is not supported.`);
}
this.onLoadAsync(internalID, contents as any, 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,41 @@ 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 () => {
const dir = tempDirWithFiles("module-api-test", {
"entry.js": `
import msg from "test-virtual-module";
console.log(msg);
`,
});
// module() is now implemented and should not throw
const result = await Bun.build({
entrypoints: [join(String(dir), "entry.js")],
outdir: String(dir),
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 from virtual module';",
loader: "js",
};
});
},
],
}),
).toThrow();
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
// Check that the virtual module content is in the output
const output = await result.outputs[0].text();
expect(output).toContain("Hello from virtual module");
});
test("non-object plugins throw invalid argument errors", () => {

View File

@@ -0,0 +1,527 @@
import { expect, test } from "bun:test";
import { 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: /\/real\.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/i);
});
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 - duplicate registration throws", async () => {
using dir = tempDir("virtual-duplicate", {
"entry.js": `console.log("test");`,
});
await expect(
Bun.build({
entrypoints: [path.join(String(dir), "entry.js")],
outdir: String(dir),
plugins: [
{
name: "duplicate-test",
setup(build) {
// First registration
build.module("duplicate-module", () => ({
contents: `export default "first";`,
loader: "js",
}));
// Duplicate registration with different callback should throw
expect(() => {
build.module("duplicate-module", () => ({
contents: `export default "second";`,
loader: "js",
}));
}).toThrow('Virtual module "duplicate-module" is already registered');
// Same callback should be idempotent (not throw)
const sameCallback = () => ({
contents: `export default "first";`,
loader: "js",
});
build.module("another-module", sameCallback);
build.module("another-module", sameCallback); // Should not throw
},
},
],
}),
).resolves.toHaveProperty("success", true);
});
test("Bun.build plugin virtual modules - virtual module as entrypoint", async () => {
using dir = tempDir("virtual-entrypoint", {});
const result = await Bun.build({
entrypoints: ["virtual-entry"],
outdir: String(dir),
plugins: [
{
name: "in-memory-entrypoint",
setup(build) {
build.module("virtual-entry", () => ({
contents: `console.log("Hello from virtual entrypoint");`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await Bun.file(result.outputs[0].path).text();
expect(output).toContain("Hello from virtual entrypoint");
});
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);
});