Start implementing build.onEnd hook

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-08-22 20:24:28 +00:00
parent 97495a86fe
commit 108eb6efce
7 changed files with 263 additions and 1 deletions

View File

@@ -1077,6 +1077,11 @@ pub const JSBundler = struct {
extern fn JSBundlerPlugin__appendDeferPromise(*Plugin) JSValue;
pub const appendDeferPromise = JSBundlerPlugin__appendDeferPromise;
pub fn runOnEndPlugins(this: *Plugin, build_result: jsc.JSValue) void {
const global_object = this.globalObject();
_ = global_object.runOnEndPlugins(build_result) catch {};
}
pub fn hasAnyMatches(
this: *Plugin,
path: *const Fs.Path,

View File

@@ -860,6 +860,14 @@ extern "C" JSC::EncodedJSValue Bun__runOnLoadPlugins(Zig::GlobalObject* globalOb
return globalObject->onLoadPlugins.run(globalObject, namespaceString, path);
}
extern "C" JSC::EncodedJSValue Bun__runOnEndPlugins(Zig::GlobalObject* globalObject, JSC::EncodedJSValue buildResult)
{
// For now, let's just return undefined to confirm the basic flow works
// The actual implementation will need to find the correct plugin instance
// and call runOnEndPlugins with the build result
return JSC::JSValue::encode(JSC::jsUndefined());
}
namespace Bun {
Structure* createModuleMockStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype)

View File

@@ -674,4 +674,5 @@ extern "C" JSC::JSGlobalObject* JSBundlerPlugin__globalObject(Bun::JSBundlerPlug
return plugin->m_globalObject;
}
} // namespace Bun

View File

@@ -240,6 +240,7 @@ pub const JSGlobalObject = opaque {
};
extern fn Bun__runOnLoadPlugins(*jsc.JSGlobalObject, ?*const bun.String, *const bun.String, BunPluginTarget) JSValue;
extern fn Bun__runOnResolvePlugins(*jsc.JSGlobalObject, ?*const bun.String, *const bun.String, *const String, BunPluginTarget) JSValue;
extern fn Bun__runOnEndPlugins(*jsc.JSGlobalObject, JSValue) JSValue;
pub fn runOnLoadPlugins(this: *JSGlobalObject, namespace_: bun.String, path: bun.String, target: BunPluginTarget) bun.JSError!?JSValue {
jsc.markBinding(@src());
@@ -255,6 +256,11 @@ pub const JSGlobalObject = opaque {
return result;
}
pub fn runOnEndPlugins(this: *JSGlobalObject, build_result: JSValue) bun.JSError!JSValue {
jsc.markBinding(@src());
return try bun.jsc.fromJSHostCall(this, @src(), Bun__runOnEndPlugins, .{ this, build_result });
}
pub fn createErrorInstance(this: *JSGlobalObject, comptime fmt: [:0]const u8, args: anytype) JSValue {
if (comptime std.meta.fieldNames(@TypeOf(args)).len > 0) {
var stack_fallback = std.heap.stackFallback(1024 * 4, this.allocator());

View File

@@ -1867,6 +1867,8 @@ pub const BundleV2 = struct {
return result;
}
fn toJSError(this: *JSBundleCompletionTask, promise: *jsc.JSPromise, globalThis: *jsc.JSGlobalObject) void {
if (this.config.throw_on_error) {
promise.reject(globalThis, this.log.toJSAggregateError(globalThis, bun.String.static("Bundle failed")));
@@ -1887,6 +1889,11 @@ pub const BundleV2 = struct {
return promise.reject(globalThis, err);
},
);
// Run onEnd plugins before resolving - error case
if (this.plugins) |plugins| {
plugins.runOnEndPlugins(root_obj);
}
promise.resolve(globalThis, root_obj);
}
@@ -1985,6 +1992,12 @@ pub const BundleV2 = struct {
return promise.reject(globalThis, err);
},
);
// Run onEnd plugins before resolving - success case
if (this.plugins) |plugins| {
plugins.runOnEndPlugins(root_obj);
}
promise.resolve(globalThis, root_obj);
},
}

View File

@@ -20,6 +20,7 @@ interface BundlerPlugin {
addFilter(filter, namespace, number): void;
generateDeferPromise(id: number): Promise<void>;
promises: Array<Promise<any>> | undefined;
onEndCallbacks: Array<(result: any) => void | Promise<void>> | undefined;
onBeforeParse: (filter: RegExp, namespace: string, addon: unknown, symbol: string, external?: unknown) => void;
$napiDlopenHandle: number;
@@ -222,6 +223,20 @@ export function runSetupFunction(
return this;
}
function onEnd(this: PluginBuilder, callback): PluginBuilder {
if (isBake) {
throw new TypeError("onEnd() is not supported in Bake yet");
}
if (!$isCallable(callback)) {
throw new TypeError("callback must be a function");
}
// Store onEnd callbacks for later execution
self.onEndCallbacks ??= [];
self.onEndCallbacks.push(callback);
return this;
}
const processSetupResult = () => {
var anyOnLoad = false,
anyOnResolve = false;
@@ -290,7 +305,7 @@ export function runSetupFunction(
var setupResult = setup({
config: config,
onDispose: notImplementedIssueFn(2771, "On-dispose callbacks"),
onEnd: notImplementedIssueFn(2771, "On-end callbacks"),
onEnd,
onLoad,
onResolve,
onBeforeParse,
@@ -443,6 +458,68 @@ export function runOnResolvePlugins(this: BundlerPlugin, specifier, inputNamespa
}
}
export function runOnEndPlugins(
this: BundlerPlugin,
buildResult,
) {
const { onEndCallbacks } = this;
if (!onEndCallbacks || onEndCallbacks.length === 0) {
return;
}
// Convert logs to errors/warnings arrays for esbuild compatibility
const logs = buildResult.logs || [];
const errors = [];
const warnings = [];
for (const log of logs) {
if (log.level === "error") {
errors.push({
text: log.message || "",
location: log.position ? {
file: log.position.file || "",
line: log.position.line || 0,
column: log.position.column || 0,
} : null,
notes: [],
detail: undefined,
});
} else if (log.level === "warning") {
warnings.push({
text: log.message || "",
location: log.position ? {
file: log.position.file || "",
line: log.position.line || 0,
column: log.position.column || 0,
} : null,
notes: [],
detail: undefined,
});
}
}
// Create esbuild-compatible result object
const onEndResult = {
errors,
warnings,
};
// Execute onEnd callbacks serially as per esbuild specification
for (const callback of onEndCallbacks) {
try {
const result = callback(onEndResult);
if ($isPromise(result)) {
// For now, we can't easily await promises in the bundler completion
// We'll handle this synchronously
console.warn("onEnd callback returned a promise, but async onEnd is not fully supported yet");
}
} catch (error) {
// Log the error but don't fail the build
console.error("onEnd callback error:", error);
}
}
}
export function runOnLoadPlugins(
this: BundlerPlugin,
internalID,

View File

@@ -896,4 +896,156 @@ describe("bundler", () => {
expect(js).toContain('.wasm"');
},
});
// OnEnd Plugin Tests
itBundled("plugin/OnEnd/Basic", {
files: {
"index.ts": /* ts */ `
export const message = "hello world";
`,
},
plugins(builder) {
let onEndCalled = false;
builder.onEnd(result => {
onEndCalled = true;
console.log("onEnd called with result:", result);
expect(result).toBeDefined();
expect(result.errors).toBeDefined();
expect(result.warnings).toBeDefined();
expect(Array.isArray(result.errors)).toBe(true);
expect(Array.isArray(result.warnings)).toBe(true);
});
},
onAfterBundle(api) {
// This runs after the bundle is complete
// If onEnd was working, this should verify it was called
console.log("Bundle completed - onEnd should have been called by now");
},
});
itBundled("plugin/OnEnd/WithErrors", {
files: {
"index.ts": /* ts */ `
import { missing } from "./nonexistent.js";
export { missing };
`,
},
plugins(builder) {
builder.onEnd(result => {
expect(result.errors.length).toBeGreaterThan(0);
const errorMessage = result.errors[0].text;
expect(errorMessage).toContain("Could not resolve");
});
},
bundleErrors: {
"/index.ts": [`Could not resolve`],
},
});
itBundled("plugin/OnEnd/Multiple", {
files: {
"index.ts": /* ts */ `
export const value = 42;
`,
},
plugins(builder) {
const callOrder: number[] = [];
builder.onEnd(result => {
callOrder.push(1);
expect(result.errors.length).toBe(0);
});
builder.onEnd(result => {
callOrder.push(2);
expect(callOrder).toEqual([1, 2]);
});
builder.onEnd(result => {
callOrder.push(3);
expect(callOrder).toEqual([1, 2, 3]);
});
},
});
itBundled("plugin/OnEnd/Async", {
files: {
"index.ts": /* ts */ `
export const async_value = "test";
`,
},
plugins(builder) {
builder.onEnd(async result => {
await new Promise(resolve => setTimeout(resolve, 10));
expect(result).toBeDefined();
expect(result.errors.length).toBe(0);
});
},
});
itBundled("plugin/OnEnd/ModifyResult", {
files: {
"index.ts": /* ts */ `
export const test = "value";
`,
},
plugins(builder) {
builder.onEnd(result => {
// Add a custom warning to the result
result.warnings.push({
text: "Custom warning from onEnd",
location: null,
notes: [],
detail: undefined,
});
});
},
});
itBundled("plugin/OnEnd/WithOnLoad", {
files: {
"index.ts": /* ts */ `
import { data } from "./data.custom";
console.log(JSON.stringify(data));
export { data };
`,
"data.custom": `{"value": "test"}`,
},
plugins(builder) {
let onLoadCalled = false;
let onEndCalled = false;
builder.onLoad({ filter: /\.custom$/ }, async args => {
onLoadCalled = true;
const text = await Bun.file(args.path).text();
return {
contents: `export const data = ${text};`,
loader: "js",
};
});
builder.onEnd(result => {
onEndCalled = true;
expect(onLoadCalled).toBe(true);
expect(result.errors.length).toBe(0);
});
},
run: {
stdout: `{"value":"test"}`,
},
});
itBundled("plugin/OnEnd/ExceptionHandling", {
files: {
"index.ts": /* ts */ `
export const test = "exception handling";
`,
},
plugins(builder) {
builder.onEnd(result => {
throw new Error("onEnd error");
});
},
// The build should still succeed, but the error should be captured
});
});