mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
feat(build): plugins can be factory functions
This commit is contained in:
18
packages/bun-types/bun.d.ts
vendored
18
packages/bun-types/bun.d.ts
vendored
@@ -2633,7 +2633,7 @@ declare module "bun" {
|
||||
}; // | string;
|
||||
root?: string; // project root
|
||||
splitting?: boolean; // default true, enable code splitting
|
||||
plugins?: BunPlugin[];
|
||||
plugins?: BunPluginFactory[];
|
||||
// manifest?: boolean; // whether to return manifest
|
||||
external?: string[];
|
||||
packages?: "bundle" | "external";
|
||||
@@ -5371,6 +5371,8 @@ declare module "bun" {
|
||||
/** https://bun.sh/docs/bundler/loaders */
|
||||
type Loader = "js" | "jsx" | "ts" | "tsx" | "json" | "toml" | "file" | "napi" | "wasm" | "text" | "css" | "html";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
interface PluginConstraints {
|
||||
/**
|
||||
* Only apply the plugin when the import specifier matches this regular expression
|
||||
@@ -5466,8 +5468,8 @@ declare module "bun" {
|
||||
}
|
||||
|
||||
type OnLoadResult = OnLoadResultSourceCode | OnLoadResultObject | undefined | void;
|
||||
type OnLoadCallback = (args: OnLoadArgs) => OnLoadResult | Promise<OnLoadResult>;
|
||||
type OnStartCallback = () => void | Promise<void>;
|
||||
type OnLoadCallback = (args: OnLoadArgs) => MaybePromise<OnLoadResult>;
|
||||
type OnStartCallback = () => MaybePromise<void>;
|
||||
|
||||
interface OnResolveArgs {
|
||||
/**
|
||||
@@ -5511,9 +5513,7 @@ declare module "bun" {
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
type OnResolveCallback = (
|
||||
args: OnResolveArgs,
|
||||
) => OnResolveResult | Promise<OnResolveResult | undefined | null> | undefined | null;
|
||||
type OnResolveCallback = (args: OnResolveArgs) => MaybePromise<OnResolveResult | undefined | null>;
|
||||
|
||||
type FFIFunctionCallable = Function & {
|
||||
// Making a nominally typed function so that the user must get it from dlopen
|
||||
@@ -5613,7 +5613,7 @@ declare module "bun" {
|
||||
*
|
||||
* @returns `this` for method chaining
|
||||
*/
|
||||
module(specifier: string, callback: () => OnLoadResult | Promise<OnLoadResult>): this;
|
||||
module(specifier: string, callback: () => MaybePromise<OnLoadResult>): this;
|
||||
}
|
||||
|
||||
interface BunPlugin {
|
||||
@@ -5655,6 +5655,8 @@ declare module "bun" {
|
||||
): void | Promise<void>;
|
||||
}
|
||||
|
||||
type BunPluginFactory<P extends BunPlugin = BunPlugin> = P | (() => P);
|
||||
|
||||
/**
|
||||
* Extend Bun's module resolution and loading behavior
|
||||
*
|
||||
@@ -5694,7 +5696,7 @@ declare module "bun" {
|
||||
* ```
|
||||
*/
|
||||
interface BunRegisterPlugin {
|
||||
<T extends BunPlugin>(options: T): ReturnType<T["setup"]>;
|
||||
<T extends BunPlugin>(options: BunPluginFactory<T>): ReturnType<T["setup"]>;
|
||||
|
||||
/**
|
||||
* Deactivate all plugins
|
||||
|
||||
@@ -260,31 +260,32 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionAppendOnResolvePluginBrowser, (JSC::JSGlobalO
|
||||
return jsFunctionAppendOnResolvePluginGlobal(globalObject, callframe, BunPluginTargetBrowser);
|
||||
}
|
||||
|
||||
/// `Bun.plugin()`
|
||||
static inline JSC::EncodedJSValue setupBunPlugin(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callframe, BunPluginTarget target)
|
||||
/// `Bun.plugin(options)`
|
||||
static inline JSC::JSValue setupBunPlugin(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSObject* options, BunPluginTarget target)
|
||||
{
|
||||
auto& vm = JSC::getVM(globalObject);
|
||||
ASSERT(options);
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
if (callframe->argumentCount() < 1) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs at least one argument (an object)"_s);
|
||||
return {};
|
||||
|
||||
if (options->isCallable()) {
|
||||
JSC::MarkedArgumentBuffer argList;
|
||||
auto callData = getCallData(options);
|
||||
JSC::JSValue ret = JSC::call(globalObject, JSValue(options), callData, JSValue(globalObject), argList);
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
options = ret.getObject();
|
||||
if (!options) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs an object as first argument"_s);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
JSC::JSObject* obj = callframe->uncheckedArgument(0).getObject();
|
||||
if (!obj) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs an object as first argument"_s);
|
||||
return {};
|
||||
}
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
JSC::JSValue setupFunctionValue = obj->getIfPropertyExists(globalObject, Identifier::fromString(vm, "setup"_s));
|
||||
JSC::JSValue setupFunctionValue = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "setup"_s));
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
if (!setupFunctionValue || setupFunctionValue.isUndefinedOrNull() || !setupFunctionValue.isCell() || !setupFunctionValue.isCallable()) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs a setup() function"_s);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (JSValue targetValue = obj->getIfPropertyExists(globalObject, Identifier::fromString(vm, "target"_s))) {
|
||||
if (JSValue targetValue = options->getIfPropertyExists(globalObject, Identifier::fromString(vm, "target"_s))) {
|
||||
if (auto* targetJSString = targetValue.toStringOrNull(globalObject)) {
|
||||
String targetString = targetJSString->value(globalObject);
|
||||
if (!(targetString == "node"_s || targetString == "bun"_s || targetString == "browser"_s)) {
|
||||
@@ -336,10 +337,37 @@ static inline JSC::EncodedJSValue setupBunPlugin(JSC::JSGlobalObject* globalObje
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
if (auto* promise = JSC::jsDynamicCast<JSC::JSPromise*>(result)) {
|
||||
RELEASE_AND_RETURN(throwScope, JSValue::encode(promise));
|
||||
RELEASE_AND_RETURN(throwScope, promise);
|
||||
}
|
||||
|
||||
RELEASE_AND_RETURN(throwScope, JSValue::encode(jsUndefined()));
|
||||
RELEASE_AND_RETURN(throwScope, jsUndefined());
|
||||
}
|
||||
|
||||
/// `Bun.plugin(optionsFactory)`
|
||||
static inline JSC::JSValue setupBunPlugin(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSFunction* makeOptions, BunPluginTarget target)
|
||||
{
|
||||
ASSERT(makeOptions);
|
||||
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
JSC::MarkedArgumentBuffer argList;
|
||||
|
||||
auto callData = getCallData(makeOptions);
|
||||
JSC::JSValue ret = JSC::call(globalObject, JSValue(makeOptions), callData, JSValue(globalObject), argList);
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
JSC::JSObject* options = ret.getObject();
|
||||
if (!options) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin factory must return an object."_s);
|
||||
return {};
|
||||
}
|
||||
|
||||
// TODO: support async plugin factories. Emit a better error message than
|
||||
// just "setup() function is missing".
|
||||
if (auto* promise = JSC::jsDynamicCast<JSC::JSPromise*>(options)) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin() does not support async plugin factories yet."_s);
|
||||
return {};
|
||||
}
|
||||
RELEASE_AND_RETURN(throwScope, Bun::setupBunPlugin(vm, globalObject, options, target));
|
||||
}
|
||||
|
||||
void BunPlugin::Group::append(JSC::VM& vm, JSC::RegExp* filter, JSC::JSObject* func)
|
||||
@@ -941,7 +969,35 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPluginClear, (JSC::JSGlobalObject * global
|
||||
return JSC::JSValue::encode(JSC::jsUndefined());
|
||||
}
|
||||
|
||||
/// `Bun.plugin(options: BunPlugin | () => BunPlugin)`
|
||||
BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPlugin, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe))
|
||||
{
|
||||
return Bun::setupBunPlugin(globalObject, callframe, BunPluginTargetBun);
|
||||
auto& vm = JSC::getVM(globalObject);
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
if (callframe->argumentCount() < 1) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs at least one argument (an object)"_s);
|
||||
return {};
|
||||
}
|
||||
|
||||
JSC::JSObject* obj = callframe->uncheckedArgument(0).getObject();
|
||||
if (!obj) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin needs an object or function as first argument"_s);
|
||||
return {};
|
||||
}
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
JSC::JSValue result;
|
||||
|
||||
if (auto* function = JSC::jsDynamicCast<JSC::JSFunction*>(obj)) {
|
||||
if (function->isConstructor() || !function->isCallable()) {
|
||||
JSC::throwTypeError(globalObject, throwScope, "plugin factories cannot be classes. Please use an arrow or named function instead."_s);
|
||||
return {};
|
||||
}
|
||||
result = Bun::setupBunPlugin(vm, globalObject, jsCast<JSC::JSFunction*>(obj), BunPluginTargetBun);
|
||||
} else {
|
||||
result = Bun::setupBunPlugin(vm, globalObject, obj, BunPluginTargetBun);
|
||||
}
|
||||
RETURN_IF_EXCEPTION(throwScope, {});
|
||||
|
||||
RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(result));
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ devTest("onResolve", {
|
||||
await dev.fetch("/").equals("value: 1");
|
||||
},
|
||||
});
|
||||
|
||||
devTest("onLoad", {
|
||||
framework: minimalFramework,
|
||||
pluginFile: `
|
||||
@@ -66,6 +67,7 @@ devTest("onLoad", {
|
||||
await dev.fetch("/").equals("value: 1");
|
||||
},
|
||||
});
|
||||
|
||||
devTest("onResolve + onLoad virtual file", {
|
||||
framework: minimalFramework,
|
||||
pluginFile: `
|
||||
@@ -110,6 +112,55 @@ devTest("onResolve + onLoad virtual file", {
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
describe("plugin options", () => {
|
||||
const files = {
|
||||
"trigger.ts": `
|
||||
throw new Error('should not be loaded');
|
||||
`,
|
||||
"routes/index.ts": `
|
||||
import { value } from '../trigger.ts';
|
||||
|
||||
export default function (req, meta) {
|
||||
return new Response('value: ' + value);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
devTest("onLoad", {
|
||||
framework: minimalFramework,
|
||||
pluginFile: `
|
||||
import * as path from 'path';
|
||||
export default [
|
||||
{
|
||||
name: 'a',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /trigger/ }, (args) => {
|
||||
return { contents: 'export const value = 1;', loader: 'ts' };
|
||||
});
|
||||
},
|
||||
}
|
||||
];
|
||||
`,
|
||||
files: {
|
||||
"trigger.ts": `
|
||||
throw new Error('should not be loaded');
|
||||
`,
|
||||
"routes/index.ts": `
|
||||
import { value } from '../trigger.ts';
|
||||
|
||||
export default function (req, meta) {
|
||||
return new Response('value: ' + value);
|
||||
}
|
||||
`,
|
||||
},
|
||||
async test(dev) {
|
||||
await dev.fetch("/").equals("value: 1");
|
||||
await dev.fetch("/").equals("value: 1");
|
||||
await dev.fetch("/").equals("value: 1");
|
||||
},
|
||||
});
|
||||
});
|
||||
// devTest("onLoad with watchFile", {
|
||||
// framework: minimalFramework,
|
||||
// pluginFile: `
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BunPlugin } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { readFileSync, writeFileSync, rmSync } from "fs";
|
||||
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
||||
import path, { join } from "path";
|
||||
import assert from "assert";
|
||||
@@ -611,6 +612,69 @@ describe("Bun.build", () => {
|
||||
const html = build.outputs.find(o => o.type === "text/html;charset=utf-8");
|
||||
expect(await html?.text()).toContain("<meta name='injected-by-plugin' content='true'>");
|
||||
});
|
||||
|
||||
describe("plugin options", () => {
|
||||
let fixture: string;
|
||||
let index: string;
|
||||
|
||||
function coffeePlugin(): BunPlugin {
|
||||
return {
|
||||
name: "coffee",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.coffee$/ }, () => {
|
||||
return {
|
||||
contents: "module.exports = 'world'",
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
fixture = tempDirWithFiles("build-plugins-factory", {
|
||||
"index.ts": `
|
||||
import foo from "./foo.coffee";
|
||||
console.log(foo)
|
||||
`,
|
||||
"foo.coffee": `
|
||||
module.exports = "hello"
|
||||
`,
|
||||
});
|
||||
|
||||
index = join(fixture, "index.ts");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("can be a BunPlugin object", async () => {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [index],
|
||||
plugins: [coffeePlugin()],
|
||||
});
|
||||
|
||||
expect(build.success).toBeTrue();
|
||||
});
|
||||
|
||||
it("can be a function that returns a BunPlugin object", async () => {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [index],
|
||||
plugins: [coffeePlugin],
|
||||
});
|
||||
expect(build.success).toBeTrue();
|
||||
});
|
||||
|
||||
it("cannot be async (for now)", async () => {
|
||||
expect(async () => {
|
||||
await Bun.build({
|
||||
entrypoints: [index],
|
||||
plugins: [async () => coffeePlugin()],
|
||||
});
|
||||
}).toThrow(/does not support async plugin/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("onEnd Plugin does not crash", async () => {
|
||||
|
||||
@@ -186,6 +186,21 @@ plugin({
|
||||
},
|
||||
});
|
||||
|
||||
plugin(() => ({
|
||||
name: "plugin created by function",
|
||||
setup(builder) {
|
||||
builder
|
||||
.onResolve({ filter: /.*/, namespace: "factory-sync" }, ({ path }) => ({
|
||||
namespace: "factory-sync",
|
||||
path,
|
||||
}))
|
||||
.onLoad({ filter: /.*/, namespace: "factory-sync" }, ({ path }) => ({
|
||||
contents: `// ${path}\n\nexport default 42;`,
|
||||
loader: "js",
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
// This is to test that it works when imported from a separate file
|
||||
import "../../third_party/svelte";
|
||||
import "./module-plugins";
|
||||
@@ -510,3 +525,37 @@ it("import(...) without __esModule", async () => {
|
||||
const { default: mod } = await import("my-virtual-module-with-default");
|
||||
expect(mod).toBe("world");
|
||||
});
|
||||
|
||||
describe("plugin factories", () => {
|
||||
it("can be synchronous", async () => {
|
||||
const result = await import("factory-sync:my-file");
|
||||
expect(result.default).toBe(42);
|
||||
});
|
||||
|
||||
it("must be callable", () => {
|
||||
class Foo {
|
||||
static setup() {}
|
||||
}
|
||||
expect(() => plugin(Foo)).toThrow(/factories cannot be classes/);
|
||||
});
|
||||
|
||||
it("cannot be asynchronous (yet)", async () => {
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
plugin(async () => ({
|
||||
name: "plugin created by async function",
|
||||
setup(builder) {
|
||||
builder
|
||||
.onResolve({ filter: /.*/, namespace: "factory-async" }, ({ path }) => ({
|
||||
namespace: "factory-async",
|
||||
path,
|
||||
}))
|
||||
.onLoad({ filter: /.*/, namespace: "factory-async" }, ({ path }) => ({
|
||||
contents: `// ${path}\n\nexport default 42;`,
|
||||
loader: "js",
|
||||
}));
|
||||
},
|
||||
}));
|
||||
}).toThrow(/does not support async plugin factories/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user