From 67f233ed04a26fc68db9ea7131adccabe635dfa7 Mon Sep 17 00:00:00 2001 From: Don Isaac Date: Tue, 25 Feb 2025 14:53:03 -0800 Subject: [PATCH] feat(build): plugins can be factory functions --- packages/bun-types/bun.d.ts | 18 +++--- src/bun.js/bindings/BunPlugin.cpp | 92 ++++++++++++++++++++++++------ test/bake/dev/dev-plugins.test.ts | 51 +++++++++++++++++ test/bundler/bun-build-api.test.ts | 66 ++++++++++++++++++++- test/js/bun/plugin/plugins.test.ts | 49 ++++++++++++++++ 5 files changed, 249 insertions(+), 27 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 91ead34066..c379338724 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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 | Promise; + 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; - type OnStartCallback = () => void | Promise; + type OnLoadCallback = (args: OnLoadArgs) => MaybePromise; + type OnStartCallback = () => MaybePromise; interface OnResolveArgs { /** @@ -5511,9 +5513,7 @@ declare module "bun" { external?: boolean; } - type OnResolveCallback = ( - args: OnResolveArgs, - ) => OnResolveResult | Promise | undefined | null; + type OnResolveCallback = (args: OnResolveArgs) => MaybePromise; 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): this; + module(specifier: string, callback: () => MaybePromise): this; } interface BunPlugin { @@ -5655,6 +5655,8 @@ declare module "bun" { ): void | Promise; } + type BunPluginFactory

= P | (() => P); + /** * Extend Bun's module resolution and loading behavior * @@ -5694,7 +5696,7 @@ declare module "bun" { * ``` */ interface BunRegisterPlugin { - (options: T): ReturnType; + (options: BunPluginFactory): ReturnType; /** * Deactivate all plugins diff --git a/src/bun.js/bindings/BunPlugin.cpp b/src/bun.js/bindings/BunPlugin.cpp index f39bddf3b9..b9858a9160 100644 --- a/src/bun.js/bindings/BunPlugin.cpp +++ b/src/bun.js/bindings/BunPlugin.cpp @@ -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(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(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(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(obj), BunPluginTargetBun); + } else { + result = Bun::setupBunPlugin(vm, globalObject, obj, BunPluginTargetBun); + } + RETURN_IF_EXCEPTION(throwScope, {}); + + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(result)); } diff --git a/test/bake/dev/dev-plugins.test.ts b/test/bake/dev/dev-plugins.test.ts index 318771bd0a..dd3093dfa7 100644 --- a/test/bake/dev/dev-plugins.test.ts +++ b/test/bake/dev/dev-plugins.test.ts @@ -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: ` diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 5150dcab75..581a573409 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -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(""); }); + + 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 () => { diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index efca70591c..0edd550670 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -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/); + }); +});