diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index d51f80223766..c9195a2b5399 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 fa1641f878af..0d5f4340ec7e 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,87 @@ 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); +}); + // devTest("onLoad with watchFile", { // framework: minimalFramework, // pluginFile: ` diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 1335e3244c00..acc4dff881f1 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" } };`,