mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
342
test/bundler/bundler-plugin-virtual-modules.test.ts
Normal file
342
test/bundler/bundler-plugin-virtual-modules.test.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user