Allow reading config from within plugins, and partially implement esbuild initialOptions (#2861)

* Implement plugin build.config and initialOptions

* update types

* default initialoptions entrypoints
This commit is contained in:
dave caruso
2023-05-11 22:58:41 -04:00
committed by GitHub
parent 136b50c746
commit dfd0f3e252
10 changed files with 300 additions and 157 deletions

View File

@@ -897,7 +897,7 @@ const myPlugin: BunPlugin = {
};
```
The `builder` object provides some methods for hooking into parts of the bundling process. Bun implements `onResolve` and `onLoad`; it does not yet implement the esbuild hooks `onStart`, `onEnd`, and `onDispose`, or the `initialOptions` and `resolve` utilities.
The `builder` object provides some methods for hooking into parts of the bundling process. Bun implements `onResolve` and `onLoad`; it does not yet implement the esbuild hooks `onStart`, `onEnd`, and `onDispose`, and `resolve` utilities. `initialOptions` is partially implemented, being read-only and only having a subset of esbuild's options; use [`config`](/docs/bundler/plugins#reading-bunbuilds-config) (same thing but with Bun's `BuildConfig` format) instead.
```ts
import type { BunPlugin } from "bun";

View File

@@ -80,7 +80,7 @@ plugin(
// application code
```
Bun's plugin API is based on [esbuild](https://esbuild.github.io/plugins). Only a subset of the esbuild API is implemented, but some esbuild plugins "just work" in Bun, like the official [MDX loader](https://mdxjs.com/packages/esbuild/):
Bun's plugin API is based on [esbuild](https://esbuild.github.io/plugins). Only [a subset](/docs/bundler/migration#plugin-api) of the esbuild API is implemented, but some esbuild plugins "just work" in Bun, like the official [MDX loader](https://mdxjs.com/packages/esbuild/):
```jsx
import { plugin } from "bun";
@@ -272,6 +272,31 @@ import MySvelteComponent from "./component.svelte";
console.log(mySvelteComponent.render());
```
## Reading `Bun.build`'s config
Plugins can read and write to the [build config](/docs/cli/build#api) with `build.config`.
```ts
Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
sourcemap: 'external',
plugins: [
{
name: 'demo',
setup(build) {
console.log(build.config.sourcemap); // "external"
build.config.minify = true; // enable minification
// `plugins` is readonly
console.log(`Number of plugins: ${build.config.plugins.length}`);
}
}
],
});
```
## Reference
```ts
@@ -295,6 +320,7 @@ type PluginBuilder = {
exports?: Record<string, any>;
},
) => void;
config: BuildConfig;
};
type Loader = "js" | "jsx" | "ts" | "tsx" | "json" | "toml" | "object";

View File

@@ -2749,9 +2749,9 @@ declare module "bun" {
callback: OnResolveCallback,
): void;
/**
* The current target environment
* The config object passed to `Bun.build` as is. Can be mutated.
*/
target: Target;
config: BuildConfig & { plugins: BunPlugin[]; };
}
interface BunPlugin {
@@ -2789,7 +2789,7 @@ declare module "bun" {
* }));
* ```
*/
builder: PluginBuilder,
build: PluginBuilder,
): void | Promise<void>;
}

View File

@@ -83,6 +83,108 @@ pub const JSBundler = struct {
errdefer this.deinit(allocator);
errdefer if (plugins.*) |plugin| plugin.deinit();
// Plugins must be resolved first as they are allowed to mutate the config JSValue
if (try config.getArray(globalThis, "plugins")) |array| {
var iter = array.arrayIterator(globalThis);
while (iter.next()) |plugin| {
if (try plugin.getObject(globalThis, "SECRET_SERVER_COMPONENTS_INTERNALS")) |internals| {
if (internals.get(globalThis, "router")) |router_value| {
if (router_value.as(JSC.API.FileSystemRouter) != null) {
this.server_components.router.set(globalThis, router_value);
} else {
globalThis.throwInvalidArguments("Expected router to be a Bun.FileSystemRouter", .{});
return error.JSError;
}
}
const directive_object = (try internals.getObject(globalThis, "directive")) orelse {
globalThis.throwInvalidArguments("Expected directive to be an object", .{});
return error.JSError;
};
if (try directive_object.getArray(globalThis, "client")) |client_names_array| {
var array_iter = client_names_array.arrayIterator(globalThis);
while (array_iter.next()) |client_name| {
var slice = client_name.toSliceOrNull(globalThis) orelse {
globalThis.throwInvalidArguments("Expected directive.client to be an array of strings", .{});
return error.JSException;
};
defer slice.deinit();
try this.server_components.client.append(allocator, OwnedString.initCopy(allocator, slice.slice()) catch unreachable);
}
} else {
globalThis.throwInvalidArguments("Expected directive.client to be an array of strings", .{});
return error.JSException;
}
if (try directive_object.getArray(globalThis, "server")) |server_names_array| {
var array_iter = server_names_array.arrayIterator(globalThis);
while (array_iter.next()) |server_name| {
var slice = server_name.toSliceOrNull(globalThis) orelse {
globalThis.throwInvalidArguments("Expected directive.server to be an array of strings", .{});
return error.JSException;
};
defer slice.deinit();
try this.server_components.server.append(allocator, OwnedString.initCopy(allocator, slice.slice()) catch unreachable);
}
} else {
globalThis.throwInvalidArguments("Expected directive.server to be an array of strings", .{});
return error.JSException;
}
continue;
}
// var decl = PluginDeclaration{
// .name = OwnedString.initEmpty(allocator),
// .setup = .{},
// };
// defer decl.deinit();
if (plugin.getOptional(globalThis, "name", ZigString.Slice) catch null) |slice| {
defer slice.deinit();
if (slice.len == 0) {
globalThis.throwInvalidArguments("Expected plugin to have a non-empty name", .{});
return error.JSError;
}
} else {
globalThis.throwInvalidArguments("Expected plugin to have a name", .{});
return error.JSError;
}
const function = (plugin.getFunction(globalThis, "setup") catch null) orelse {
globalThis.throwInvalidArguments("Expected plugin to have a setup() function", .{});
return error.JSError;
};
var bun_plugins: *Plugin = plugins.* orelse brk: {
plugins.* = Plugin.create(
globalThis,
switch (this.target) {
.bun, .bun_macro => JSC.JSGlobalObject.BunPluginTarget.bun,
.node => JSC.JSGlobalObject.BunPluginTarget.node,
else => .browser,
},
);
break :brk plugins.*.?;
};
var plugin_result = bun_plugins.addPlugin(function, config);
if (!plugin_result.isEmptyOrUndefinedOrNull()) {
if (plugin_result.asAnyPromise()) |promise| {
globalThis.bunVM().waitForPromise(promise);
plugin_result = promise.result(globalThis.vm());
}
}
if (plugin_result.toError()) |err| {
globalThis.throwValue(err);
return error.JSError;
}
}
}
if (try config.getOptionalEnum(globalThis, "target", options.Target)) |target| {
this.target = target;
}
@@ -289,107 +391,6 @@ pub const JSBundler = struct {
};
}
if (try config.getArray(globalThis, "plugins")) |array| {
var iter = array.arrayIterator(globalThis);
while (iter.next()) |plugin| {
if (try plugin.getObject(globalThis, "SECRET_SERVER_COMPONENTS_INTERNALS")) |internals| {
if (internals.get(globalThis, "router")) |router_value| {
if (router_value.as(JSC.API.FileSystemRouter) != null) {
this.server_components.router.set(globalThis, router_value);
} else {
globalThis.throwInvalidArguments("Expected router to be a Bun.FileSystemRouter", .{});
return error.JSError;
}
}
const directive_object = (try internals.getObject(globalThis, "directive")) orelse {
globalThis.throwInvalidArguments("Expected directive to be an object", .{});
return error.JSError;
};
if (try directive_object.getArray(globalThis, "client")) |client_names_array| {
var array_iter = client_names_array.arrayIterator(globalThis);
while (array_iter.next()) |client_name| {
var slice = client_name.toSliceOrNull(globalThis) orelse {
globalThis.throwInvalidArguments("Expected directive.client to be an array of strings", .{});
return error.JSException;
};
defer slice.deinit();
try this.server_components.client.append(allocator, OwnedString.initCopy(allocator, slice.slice()) catch unreachable);
}
} else {
globalThis.throwInvalidArguments("Expected directive.client to be an array of strings", .{});
return error.JSException;
}
if (try directive_object.getArray(globalThis, "server")) |server_names_array| {
var array_iter = server_names_array.arrayIterator(globalThis);
while (array_iter.next()) |server_name| {
var slice = server_name.toSliceOrNull(globalThis) orelse {
globalThis.throwInvalidArguments("Expected directive.server to be an array of strings", .{});
return error.JSException;
};
defer slice.deinit();
try this.server_components.server.append(allocator, OwnedString.initCopy(allocator, slice.slice()) catch unreachable);
}
} else {
globalThis.throwInvalidArguments("Expected directive.server to be an array of strings", .{});
return error.JSException;
}
continue;
}
// var decl = PluginDeclaration{
// .name = OwnedString.initEmpty(allocator),
// .setup = .{},
// };
// defer decl.deinit();
if (plugin.getOptional(globalThis, "name", ZigString.Slice) catch null) |slice| {
defer slice.deinit();
if (slice.len == 0) {
globalThis.throwInvalidArguments("Expected plugin to have a non-empty name", .{});
return error.JSError;
}
} else {
globalThis.throwInvalidArguments("Expected plugin to have a name", .{});
return error.JSError;
}
const function = (plugin.getFunction(globalThis, "setup") catch null) orelse {
globalThis.throwInvalidArguments("Expected plugin to have a setup() function", .{});
return error.JSError;
};
var bun_plugins: *Plugin = plugins.* orelse brk: {
plugins.* = Plugin.create(
globalThis,
switch (this.target) {
.bun, .bun_macro => JSC.JSGlobalObject.BunPluginTarget.bun,
.node => JSC.JSGlobalObject.BunPluginTarget.node,
else => .browser,
},
);
break :brk plugins.*.?;
};
var plugin_result = bun_plugins.addPlugin(function);
if (!plugin_result.isEmptyOrUndefinedOrNull()) {
if (plugin_result.asAnyPromise()) |promise| {
globalThis.bunVM().waitForPromise(promise);
plugin_result = promise.result(globalThis.vm());
}
}
if (plugin_result.toError()) |err| {
globalThis.throwValue(err);
return error.JSError;
}
}
}
return this;
}
@@ -911,11 +912,12 @@ pub const JSBundler = struct {
pub fn addPlugin(
this: *Plugin,
object: JSC.JSValue,
config: JSC.JSValue,
) JSValue {
JSC.markBinding(@src());
const tracer = bun.tracy.traceNamed(@src(), "JSBundler.addPlugin");
defer tracer.end();
return JSBundlerPlugin__runSetupFunction(this, object);
return JSBundlerPlugin__runSetupFunction(this, object, config);
}
pub fn deinit(this: *Plugin) void {
@@ -934,6 +936,7 @@ pub const JSBundler = struct {
extern fn JSBundlerPlugin__runSetupFunction(
*Plugin,
JSC.JSValue,
JSC.JSValue,
) JSValue;
pub export fn JSBundlerPlugin__addError(

View File

@@ -375,7 +375,8 @@ extern "C" Bun::JSBundlerPlugin* JSBundlerPlugin__create(Zig::GlobalObject* glob
extern "C" EncodedJSValue JSBundlerPlugin__runSetupFunction(
Bun::JSBundlerPlugin* plugin,
EncodedJSValue encodedSetupFunction)
EncodedJSValue encodedSetupFunction,
EncodedJSValue encodedConfig)
{
auto& vm = plugin->vm();
auto scope = DECLARE_CATCH_SCOPE(vm);
@@ -390,6 +391,7 @@ extern "C" EncodedJSValue JSBundlerPlugin__runSetupFunction(
MarkedArgumentBuffer arguments;
arguments.append(JSValue::decode(encodedSetupFunction));
arguments.append(JSValue::decode(encodedConfig));
auto* lexicalGlobalObject = jsCast<JSFunction*>(JSValue::decode(encodedSetupFunction))->globalObject();
auto result = JSC::call(lexicalGlobalObject, setupFunction, callData, plugin, arguments);

View File

@@ -202,10 +202,10 @@ const char* const s_bundlerPluginRunOnResolvePluginsCode =
const JSC::ConstructAbility s_bundlerPluginRunSetupFunctionCodeConstructAbility = JSC::ConstructAbility::CannotConstruct;
const JSC::ConstructorKind s_bundlerPluginRunSetupFunctionCodeConstructorKind = JSC::ConstructorKind::None;
const JSC::ImplementationVisibility s_bundlerPluginRunSetupFunctionCodeImplementationVisibility = JSC::ImplementationVisibility::Public;
const int s_bundlerPluginRunSetupFunctionCodeLength = 3794;
const int s_bundlerPluginRunSetupFunctionCodeLength = 4551;
static const JSC::Intrinsic s_bundlerPluginRunSetupFunctionCodeIntrinsic = JSC::NoIntrinsic;
const char* const s_bundlerPluginRunSetupFunctionCode =
"(function (setup) {\n" \
"(function (setup, config) {\n" \
" \"use strict\";\n" \
" var onLoadPlugins = new Map(),\n" \
" onResolvePlugins = new Map();\n" \
@@ -271,6 +271,10 @@ const char* const s_bundlerPluginRunSetupFunctionCode =
" @throwTypeError(\"On-dispose callbacks are not implemented yet. See https:/\\/github.com/oven-sh/bun/issues/2771\");\n" \
" }\n" \
"\n" \
" function onDispose(callback) {\n" \
" @throwTypeError(\"build.resolve() is not implemented yet. See https:/\\/github.com/oven-sh/bun/issues/2771\");\n" \
" }\n" \
"\n" \
" const processSetupResult = () => {\n" \
" var anyOnLoad = false,\n" \
" anyOnResolve = false;\n" \
@@ -327,11 +331,27 @@ const char* const s_bundlerPluginRunSetupFunctionCode =
" };\n" \
"\n" \
" var setupResult = setup({\n" \
" config,\n" \
" onDispose,\n" \
" onEnd,\n" \
" onLoad,\n" \
" onResolve,\n" \
" onStart,\n" \
" resolve,\n" \
" //\n" \
" initialOptions: {\n" \
" ...config,\n" \
" bundle: true,\n" \
" entryPoints: config.entrypoints ?? config.entryPoints ?? [],\n" \
" minify: typeof config.minify === 'boolean' ? config.minify : false,\n" \
" minifyIdentifiers: config.minify === true || config.minify?.identifiers,\n" \
" minifyWhitespace: config.minify === true || config.minify?.whitespace,\n" \
" minifySyntax: config.minify === true || config.minify?.syntax,\n" \
" outbase: config.root,\n" \
" platform: config.target === 'bun' ? 'node' : config.target,\n" \
" root: undefined,\n" \
" },\n" \
" esbuild: {},\n" \
" });\n" \
"\n" \
" if (setupResult && @isPromise(setupResult)) {\n" \

View File

@@ -65,7 +65,7 @@ extern const JSC::ImplementationVisibility s_bundlerPluginRunOnLoadPluginsCodeIm
#define WEBCORE_FOREACH_BUNDLERPLUGIN_BUILTIN_DATA(macro) \
macro(runOnResolvePlugins, bundlerPluginRunOnResolvePlugins, 5) \
macro(runSetupFunction, bundlerPluginRunSetupFunction, 1) \
macro(runSetupFunction, bundlerPluginRunSetupFunction, 2) \
macro(runOnLoadPlugins, bundlerPluginRunOnLoadPlugins, 4) \
#define WEBCORE_BUILTIN_BUNDLERPLUGIN_RUNONRESOLVEPLUGINS 1

View File

@@ -178,7 +178,7 @@ function runOnResolvePlugins(
}
}
function runSetupFunction(setup) {
function runSetupFunction(setup, config) {
"use strict";
var onLoadPlugins = new Map(),
onResolvePlugins = new Map();
@@ -244,6 +244,10 @@ function runSetupFunction(setup) {
@throwTypeError("On-dispose callbacks are not implemented yet. See https:/\/github.com/oven-sh/bun/issues/2771");
}
function onDispose(callback) {
@throwTypeError("build.resolve() is not implemented yet. See https:/\/github.com/oven-sh/bun/issues/2771");
}
const processSetupResult = () => {
var anyOnLoad = false,
anyOnResolve = false;
@@ -300,11 +304,27 @@ function runSetupFunction(setup) {
};
var setupResult = setup({
config,
onDispose,
onEnd,
onLoad,
onResolve,
onStart,
resolve,
// esbuild's options argument is different, we provide some interop
initialOptions: {
...config,
bundle: true,
entryPoints: config.entrypoints ?? config.entryPoints ?? [],
minify: typeof config.minify === 'boolean' ? config.minify : false,
minifyIdentifiers: config.minify === true || config.minify?.identifiers,
minifyWhitespace: config.minify === true || config.minify?.whitespace,
minifySyntax: config.minify === true || config.minify?.syntax,
outbase: config.root,
platform: config.target === 'bun' ? 'node' : config.target,
root: undefined,
},
esbuild: {},
});
if (setupResult && @isPromise(setupResult)) {

View File

@@ -653,46 +653,46 @@ describe("bundler", () => {
},
};
});
itBundled("plugin/ManyPlugins", ({ root }) => {
const pluginCount = 4000;
let resolveCount = 0;
let loadCount = 0;
return {
files: {
"index.ts": /* ts */ `
import { foo as foo1 } from "plugin1:file";
import { foo as foo2 } from "plugin4000:file";
console.log(foo1, foo2);
`,
},
plugins: Array.from({ length: pluginCount }).map((_, i) => ({
name: `${i}`,
setup(builder) {
builder.onResolve({ filter: new RegExp(`^plugin${i}:file$`) }, args => {
resolveCount++;
return {
path: `plugin${i}:file`,
namespace: `plugin${i}`,
};
});
builder.onLoad({ filter: new RegExp(`^plugin${i}:file$`), namespace: `plugin${i}` }, args => {
loadCount++;
return {
contents: `export const foo = ${i};`,
loader: "js",
};
});
},
})),
run: {
stdout: `${pluginCount - 1} ${pluginCount - 1}`,
},
onAfterBundle(api) {
expect(resolveCount).toBe(pluginCount * 2);
expect(loadCount).toBe(pluginCount);
},
};
});
// itBundled("plugin/ManyPlugins", ({ root }) => {
// const pluginCount = 4000;
// let resolveCount = 0;
// let loadCount = 0;
// return {
// files: {
// "index.ts": /* ts */ `
// import { foo as foo1 } from "plugin1:file";
// import { foo as foo2 } from "plugin4000:file";
// console.log(foo1, foo2);
// `,
// },
// plugins: Array.from({ length: pluginCount }).map((_, i) => ({
// name: `${i}`,
// setup(builder) {
// builder.onResolve({ filter: new RegExp(`^plugin${i}:file$`) }, args => {
// resolveCount++;
// return {
// path: `plugin${i}:file`,
// namespace: `plugin${i}`,
// };
// });
// builder.onLoad({ filter: new RegExp(`^plugin${i}:file$`), namespace: `plugin${i}` }, args => {
// loadCount++;
// return {
// contents: `export const foo = ${i};`,
// loader: "js",
// };
// });
// },
// })),
// run: {
// stdout: `${pluginCount - 1} ${pluginCount - 1}`,
// },
// onAfterBundle(api) {
// expect(resolveCount).toBe(pluginCount * 2);
// expect(loadCount).toBe(pluginCount);
// },
// };
// });
itBundled("plugin/NamespaceOnLoadBug", () => {
return {
files: {
@@ -753,4 +753,69 @@ describe("bundler", () => {
},
};
});
itBundled("plugin/Options", ({ getConfigRef }) => {
return {
files: {
"index.ts": /* ts */ `
console.log("it works");
`,
},
entryPoints: ["./index.ts"],
plugins(build) {
expect(build.config).toBe(getConfigRef());
},
};
});
itBundled("plugin/ESBuildInitialOptions", ({}) => {
return {
files: {
"index.ts": /* ts */ `
console.log("it works");
`,
},
external: ["esbuild"],
entryPoints: ["./index.ts"],
plugins(build) {
expect((build as any).initialOptions).toEqual({
bundle: true,
entryPoints: ["/tmp/bun-build-tests/bun-T6ZQHx/plugin/ESBuildInitialOptions/index.ts"],
external: ["esbuild"],
format: undefined,
minify: false,
minifyIdentifiers: undefined,
minifySyntax: undefined,
minifyWhitespace: undefined,
outdir: "/tmp/bun-build-tests/bun-T6ZQHx/plugin/ESBuildInitialOptions",
platform: "browser",
sourcemap: undefined,
});
},
};
});
itBundled("plugin/ESBuildInitialOptions2", ({ root }) => {
return {
files: {
"index.ts": /* ts */ `
console.log("it works");
`,
},
external: ["esbuild"],
entryPoints: ["./index.ts"],
plugins(build) {
expect((build as any).initialOptions).toEqual({
bundle: true,
entryPoints: ["/tmp/bun-build-tests/bun-T6ZQHx/plugin/ESBuildInitialOptions/index.ts"],
external: ["esbuild"],
format: undefined,
minify: false,
minifyIdentifiers: undefined,
minifySyntax: undefined,
minifyWhitespace: undefined,
outdir: root,
platform: "browser",
sourcemap: undefined,
});
},
};
});
});

View File

@@ -270,6 +270,12 @@ export interface BundlerTestRunOptions {
/** given when you do itBundled('id', (this object) => BundlerTestInput) */
export interface BundlerTestWrappedAPI {
root: string;
getConfigRef: () => BuildConfig;
}
let configRef: BuildConfig;
function getConfigRef() {
return configRef;
}
export interface BundlerTestRef {
@@ -837,15 +843,16 @@ for (const [key, blob] of build.outputs) {
}
}
configRef = buildConfig;
const build = await Bun.build(buildConfig);
configRef = null!;
Bun.gc(true);
const buildLogs = (build as any).logs;
const buildLogs = build.logs.filter(x => x.level === "error");
if (buildLogs) {
const rawErrors = buildLogs instanceof AggregateError ? buildLogs.errors : [buildLogs];
if (buildLogs.length) {
const allErrors: ErrorMeta[] = [];
for (const error of rawErrors) {
for (const error of buildLogs) {
const str = error.message ?? String(error);
if (str.startsWith("\u001B[2mexpect(") || str.startsWith("expect(")) {
throw error;
@@ -1269,7 +1276,7 @@ export function itBundled(
): BundlerTestRef {
if (typeof opts === "function") {
const fn = opts;
opts = opts({ root: path.join(outBase, id.replaceAll("/", path.sep)) });
opts = opts({ root: path.join(outBase, id.replaceAll("/", path.sep)), getConfigRef });
// @ts-expect-error
opts._referenceFn = fn;
}