From 428fb852cda90ff8c1f0809e6e4a8dd3b332fd39 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:17:59 +0000 Subject: [PATCH 1/3] bake: require app.plugins and framework.plugins to be arrays parse_plugin_array iterated whatever value it was given, so a non-array plugins option was either accepted and ignored (a number, an array-like object) or rejected with a message about its first element (a string, a single plugin object). Reject non-arrays up front with the same error Bun.build uses, and treat app.plugins: null as not provided, the way framework.plugins and Bun.build already do. --- src/runtime/bake/bake_body.rs | 6 +- test/bake/dev/plugins.test.ts | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index d51f8022376..c9195a2b539 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -244,7 +244,7 @@ impl UserOptions { } }; - if let Some(plugin_array) = config.get(global, "plugins")? { + if let Some(plugin_array) = get_optional_value(config, global, b"plugins")? { bundler_options.parse_plugin_array(plugin_array, global)?; } @@ -312,6 +312,10 @@ impl SplitBundlerOptions { plugin_array: JSValue, global: &JSGlobalObject, ) -> JsResult<()> { + if !plugin_array.is_array() { + return Err(global.throw_invalid_arguments(format_args!("plugins must be an array"))); + } + // Create the Plugin and assign it to `opts.plugin` BEFORE iterating, // so `plugins: []` still leaves `self.plugin = Some(_)`. let plugin: NonNull = match self.plugin { diff --git a/test/bake/dev/plugins.test.ts b/test/bake/dev/plugins.test.ts index fa1641f878a..0ada04be313 100644 --- a/test/bake/dev/plugins.test.ts +++ b/test/bake/dev/plugins.test.ts @@ -1,4 +1,6 @@ // Plugin tests concern plugins in development mode. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { devTest, minimalFramework } from "../bake-harness"; // Note: more in depth testing of plugins is done in test/bundler/bundler_plugin.test.ts @@ -110,6 +112,117 @@ devTest("onResolve + onLoad virtual file", { ]); }, }); + +// `app.plugins` and `framework.plugins` share one parser. It used to iterate whatever +// it was handed: a non-array was either accepted and ignored (123, an array-like) or +// rejected for its first "element" ("abc", a single plugin object passed without the +// surrounding array). +test.concurrent("app.plugins and framework.plugins must be arrays", async () => { + using dir = tempDir("bake-plugins-not-array", { + "server.ts": `export function render() { return new Response("unused"); }`, + "check.ts": ` + const framework = { + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }], + }; + let setupCalls = 0; + const plugin = { name: "counted", setup() { setupCalls++; } }; + const values = { + "string": "abc", + "single plugin object": plugin, + "number": 123, + "array-like": { length: 0 }, + "array": [plugin], + "empty array": [], + "null": null, + }; + const sites = { + "app.plugins": plugins => ({ framework, plugins }), + "framework.plugins": plugins => ({ framework: { ...framework, plugins } }), + }; + + const results = {}; + for (const [site, app] of Object.entries(sites)) { + for (const [name, plugins] of Object.entries(values)) { + try { + const server = Bun.serve({ + port: 0, + development: true, + app: app(plugins), + fetch: () => new Response(""), + }); + server.stop(true); + results[site + " = " + name] = "accepted"; + } catch (e) { + results[site + " = " + name] = e.message; + } + } + } + results.setupCalls = setupCalls; + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "check.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "app.plugins = string": "plugins must be an array", + "app.plugins = single plugin object": "plugins must be an array", + "app.plugins = number": "plugins must be an array", + "app.plugins = array-like": "plugins must be an array", + "app.plugins = array": "accepted", + "app.plugins = empty array": "accepted", + "app.plugins = null": "accepted", + "framework.plugins = string": "plugins must be an array", + "framework.plugins = single plugin object": "plugins must be an array", + "framework.plugins = number": "plugins must be an array", + "framework.plugins = array-like": "plugins must be an array", + "framework.plugins = array": "accepted", + "framework.plugins = empty array": "accepted", + "framework.plugins = null": "accepted", + // once per site, from the two "array" cases + setupCalls: 2, + }); + expect(exitCode).toBe(0); +}); + +// `bun build --app` reads the same options object and used to build with the +// invalid value ignored. +test.concurrent("bun build --app rejects a non-array plugins option", async () => { + using dir = tempDir("bake-build-plugins-not-array", { + "server.ts": `export function render() { return new Response("unused"); }`, + "bun.app.ts": ` + export default { + app: { + framework: { + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }], + }, + plugins: 123, + }, + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--app"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("TypeError: plugins must be an array"); + expect(exitCode).toBe(1); +}); + // devTest("onLoad with watchFile", { // framework: minimalFramework, // pluginFile: ` From 7f3a8246e205ea4b8258af0c334ba5b9e3936286 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:40:07 +0000 Subject: [PATCH 2/3] bake: drop the bun build --app plugins test Every bun build --app run trips a pre-existing unchecked-exception assertion in BakeGetDefaultExportFromModule under BUN_JSC_validateExceptionChecks, which CI enables for this file. The command parses its options with the same UserOptions::from_js the remaining Bun.serve test exercises. --- test/bake/dev/plugins.test.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/test/bake/dev/plugins.test.ts b/test/bake/dev/plugins.test.ts index 0ada04be313..0d5f4340ec7 100644 --- a/test/bake/dev/plugins.test.ts +++ b/test/bake/dev/plugins.test.ts @@ -193,36 +193,6 @@ test.concurrent("app.plugins and framework.plugins must be arrays", async () => expect(exitCode).toBe(0); }); -// `bun build --app` reads the same options object and used to build with the -// invalid value ignored. -test.concurrent("bun build --app rejects a non-array plugins option", async () => { - using dir = tempDir("bake-build-plugins-not-array", { - "server.ts": `export function render() { return new Response("unused"); }`, - "bun.app.ts": ` - export default { - app: { - framework: { - fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }], - }, - plugins: 123, - }, - }; - `, - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "build", "--app"], - env: bunEnv, - cwd: String(dir), - stdout: "ignore", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - - expect(stderr).toContain("TypeError: plugins must be an array"); - expect(exitCode).toBe(1); -}); - // devTest("onLoad with watchFile", { // framework: minimalFramework, // pluginFile: ` From 43a3a6c6fa8f7528276c99b0686cb6a23d9d506b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:07:06 +0000 Subject: [PATCH 3/3] bake: cover bun build --app with a non-array plugins option production.test.ts is exempt from exception-check validation, so the pre-existing unchecked exception in BakeGetDefaultExportFromModule does not abort the build there. --- test/bake/dev/production.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 1335e3244c0..acc4dff881f 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "fs"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import path from "path"; import { tempDirWithBakeDeps } from "../bake-harness"; @@ -329,6 +329,29 @@ export default function GettingStarted() { } }); + // Same options parser as `Bun.serve({ app })` (dev/plugins.test.ts covers the value + // matrix); this used to build with the option silently ignored. + test("rejects a non-array plugins option", async () => { + using dir = tempDir("bake-production-plugins-not-array", { + "server.ts": `export function render() { return new Response("unused"); }`, + "bun.app.ts": ` + export default { + app: { + framework: { + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }], + }, + plugins: 123, + }, + }; + `, + }); + + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app`.cwd(String(dir)).env(bunEnv).throws(false); + + expect(stderr.toString()).toContain("TypeError: plugins must be an array"); + expect(exitCode).toBe(1); + }); + test("client-side component with default import should work", async () => { const dir = await tempDirWithBakeDeps("bake-production-client-import", { "src/index.tsx": `export default { app: { framework: "react" } };`,