bake: require app.plugins and framework.plugins to be arrays - #38902
bake: require app.plugins and framework.plugins to be arrays#38902robobun wants to merge 3 commits into
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
💤 Files with no reviewable changes (1)
WalkthroughThe bake runtime now skips nullish plugin values and rejects non-array values. Tests cover both plugin configuration sites, plugin setup behavior, and the corresponding build-time error. ChangesPlugin validation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced on the released
With this branch every one of those reports Tests, both failing on the released binary and passing with this branch:
|
There was a problem hiding this comment.
LGTM — adds the missing is_array() guard in the shared parse_plugin_array helper and aligns app.plugins null-handling with framework.plugins.
What was reviewed:
is_array()uses the sameJSType::Array | DerivedArraycheck asJSValue::get_array(JSBundler's path), so the message and semantics matchBun.build.- Switching
app.pluginsfromget()toget_optional_value()is required becauseget()returnsSome(null)forplugins: null, which would now fail the new check; verified againstJSValue::getat src/jsc/JSValue.rs:1129. - The guard runs before
Plugin::create, so the error path allocates nothing thatUserOptions::dropwould need to release. - Tests cover both call sites, both entry points (
Bun.serveandbun build --app), and the accepted cases ([],null,[plugin]) alongside the rejected ones.
Extended reasoning...
Overview
Two-line runtime change in src/runtime/bake/bake_body.rs: (1) parse_plugin_array now rejects a non-array value with plugins must be an array before creating the plugin object or iterating; (2) the app.plugins read switches from config.get() to the local get_optional_value() shim so null is treated as unset. Two new subprocess tests in test/bake/dev/plugins.test.ts exercise seven values against both app.plugins and framework.plugins in one process, plus the bun build --app CLI path.
Security risks
None. This tightens input validation on a user-provided options object; nothing new is parsed, allocated, or executed on the added path — it only rejects earlier. No auth, crypto, or filesystem surface touched.
Level of scrutiny
Low-to-medium. Bake is experimental (behind a feature flag and prints a warning), and the change is a defensive guard placed at the shared helper both call sites route through — the recommended layer per REVIEW.md. I confirmed JSValue::is_array() is the same predicate get_array uses (is_cell() && js_type().is_array()), so behavior matches Bun.build's existing plugins validation, and confirmed JSValue::get() returns Some(null) (only filters undefined/missing), which is why the get_optional_value swap is needed to keep plugins: null accepted.
Other factors
The PR description explicitly justifies not using get_array (it filters empty arrays, but plugins: [] must still create the plugin set for the DevServer bunfig-fallback check) — that reasoning checks out against the code. The guard is placed before Plugin::create, so no GC-protected handle leaks on the error path. Tests are hermetic (tempDir, port: 0, subprocess with drained pipes, test.concurrent), assert exact error messages and the positive contract (setupCalls: 2), and were verified by the author to fail on the released binary. print_warning() is only called from bun build --app (not Bun.serve), and the CLI test uses toContain on stderr, so the experimental-warning banner does not interfere with either assertion.
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.
There was a problem hiding this comment.
LGTM — small, well-scoped validation fix with matching test coverage.
What was reviewed:
is_array()is an infallible JSType check (no JS execution / exception scope needed); error message matchesBun.build's existing wording.- Switching
app.pluginstoget_optional_valuekeepsplugins: nullaccepted (would otherwise start throwing) and now matches theframework.pluginssite. - Confirmed
get_arrayfilters empty arrays, so the directis_array()check is deliberate to preserveplugins: []creating the plugin set. - Test covers both sites × 7 value shapes in one subprocess, drains pipes concurrently, asserts
setup()still runs for real arrays.
Extended reasoning...
Overview
Two-file change: src/runtime/bake/bake_body.rs gains a 3-line guard at the top of SplitBundlerOptions::parse_plugin_array rejecting non-arrays with plugins must be an array, and UserOptions::from_js reads app.plugins via the existing get_optional_value helper (filtering null/undefined) instead of raw config.get. test/bake/dev/plugins.test.ts gains one test.concurrent case that runs 7 value shapes (string, single plugin object, number, array-like, array, empty array, null) through both app.plugins and framework.plugins in a single spawned process and asserts the full result matrix plus that setup() fired exactly twice.
Security risks
None. This tightens input validation on a config option; is_array() is a pure JSType check that runs no user JS, so there's no new coercion surface or reentrancy.
Level of scrutiny
Low. The guard is placed in the shared helper both call sites already flow through, mirrors the check Bun.build performs for the same option name, and the get_optional_value swap brings app.plugins in line with how framework.plugins was already read. I verified the PR's rationale for not using get_array (it drops empty arrays, and plugins: [] must still create the plugin set for the DevServer's bunfig-fallback decision).
Other factors
The one inline finding is a pre-existing JSBundlerPlugin leak on the framework.plugins: [valid] + app.plugins: <invalid> error path — the leak class predates this PR (already reachable via app.plugins: "abc" and several other inputs) and the fix belongs on SplitBundlerOptions's drop, which is orthogonal. The new test follows the harness conventions (tempDir, bunEnv, port: 0, concurrent pipe drain, stderr/stdout asserted before exitCode) and the PR description documents that it fails on the released binary. No outstanding reviewer comments.
production.test.ts is exempt from exception-check validation, so the pre-existing unchecked exception in BakeGetDefaultExportFromModule does not abort the build there.
There was a problem hiding this comment.
LGTM — the four-line guard in parse_plugin_array plus the get_optional_value swap are the minimal, correct fix, and the pre-existing leak note from the earlier pass is resolved (#37837).
What was reviewed:
is_array()guard placed in the shared helper so bothapp.pluginsandframework.plugins(and bothBun.serve/bun build --appentry points) are covered; message matchesBun.build's.get_optional_valueswap keepsapp.plugins: nullaccepted (would otherwise regress) and matches theframework.pluginssite.- New
plugins.test.tscase: hermetic subprocess,port: 0, drains pipes concurrently, exact-value assertions across 7 values × 2 sites, verifiessetup()still runs;production.test.tscase sits alongside existingbun build --apptests using the sameBun.$/bunEnvshape.
Extended reasoning...
Overview
Two-line behavioral change in src/runtime/bake/bake_body.rs: parse_plugin_array now rejects non-array input with plugins must be an array before creating the JSBundlerPlugin or iterating, and UserOptions::from_js reads app.plugins via get_optional_value (filters null/undefined) instead of raw .get(). Two test files gain coverage: test/bake/dev/plugins.test.ts adds a subprocess test exercising 7 value shapes across both app.plugins and framework.plugins, and test/bake/dev/production.test.ts adds a bun build --app test with plugins: 123.
Security risks
None. This tightens input validation on a config option; no new parsing of untrusted data, no auth/crypto/permissions surface. The only observable behavior change is that previously-silent bad config now throws a TypeError at startup.
Level of scrutiny
Low-to-medium. The Rust change is a four-line guard placed at the shared helper (the correct layer per REVIEW.md — fixes both call sites at once), reusing the exact is_array() check and message string Bun.build already uses for the same option. The get_optional_value swap is defensive: without it, app.plugins: null (accepted today) would newly throw, so it preserves existing behavior. get_array was correctly avoided because it also filters empty arrays, and plugins: [] must still create the plugin set (DevServer uses its presence to gate the bunfig fallback). Bake is experimental (per the print_warning in the same file), further lowering the risk of the stricter validation.
Other factors
- My earlier pass flagged the pre-existing
SplitBundlerOptionserror-path leak as non-blocking; the author confirmed #37837 fixes that class via RAII, resolved the thread, and updated the PR description accordingly. This PR neither introduces nor worsens the mechanism (and actually narrows one trigger:framework.plugins: "abc"now throws before creating the Plugin instead of after). - Since that review, commit 43a3a6c added the
bun build --apptest inproduction.test.ts. It follows the exact shape of the neighboring tests in that file (Bun.$,.env(bunEnv),.throws(false), stderr-then-exitCode assertion order) and errors out at options parsing before any bundling runs, so it does not share the full-build code path the description flagged for the ASAN exception-check lane. - The
plugins.test.tstest is well-constructed per REVIEW.md:tempDir+using,bunEnv/bunExe,port: 0, concurrent pipe draining, exacttoEqualon a JSON result object,setupCalls: 2proves valid arrays still register plugins, andtest.concurrentsince it's an independent subprocess. - No bugs surfaced by the bug-hunting system this run.
Problem
Bun.serve({ app: { plugins: <non-array> } }), the same key insideapp.framework, andbun build --appwith either one never check thatpluginsis an array.plugins: 123,plugins: true,plugins: { length: 0 }or a function are accepted and silently do nothing.plugins: "abc"orplugins: { name, setup }(one plugin passed without the surrounding array) throwExpected plugin to be an object, which describes the first "element" of the value, not the mistake.src/runtime/bake/bake_body.rs:247forapp.plugins,:1097forframework.plugins) hand the value straight toSplitBundlerOptions::parse_plugin_array, which callsarray_iterator()on it (:329).JSArrayIteratorworks on any value: it readslengthand indexes, so a string iterates its characters and a value without a usablelengthiterates nothing.{ name, setup }case only throws today becauseJSValue::get_lengthreturns 2^51-1 for objects with nolengthproperty (the C++ side returns infinity, the Rust side checks forf64::MAX); once that is fixed a single plugin object would be silently ignored too.bake.zigparsePluginArray), so this is long-standing rather than a port regression.Fix
parse_plugin_arrayrejects a non-array withplugins must be an arraybefore doing anything else. Both call sites go through it, soapp.plugins,framework.plugins,Bun.serveandbun build --appare all covered by the one check.is_array()test are the onesBun.buildalready uses for itspluginsoption (JSValue::get_array,src/runtime/api/JSBundler.rs:488), so the same mistake now reports the same error in both APIs.get_arrayitself is not used here because it also returnsNonefor an empty array, andplugins: []has to keep creating the (empty) plugin set: the dev server uses its presence to decide whether to fall back to the bunfig[serve.static]plugins (src/runtime/bake/DevServer.rs:1978).JSBundlerPluginof its own. A plugin set already created by a validframework.pluginsis still leaked when a later option (including this newapp.pluginserror) fails:from_jserror paths have never released it, and bake: release the app plugins cell when the dev server is torn down #37837 fixes that class by making the handle release itself on drop.app.pluginsis now read with the sameget_optional_valuehelperframework.pluginsuses, soplugins: nullmeans "not provided" at both sites, as it already does forBun.build. Without this,app.plugins: null(accepted today) would start throwing.test/bake/dev/plugins.test.ts,app.plugins and framework.plugins must be arrays: one process runs the same seven values through both sites viaBun.serve. A string, a single plugin object, a number and an array-like all getplugins must be an array;[plugin],[]andnullare still accepted, andsetup()still runs for the real arrays. It fails on the releasedbunwith the accepted/misleading results listed above and passes with this build; the existing tests in the file still pass.bun build --appreaches the sameUserOptions::from_js(src/runtime/bake/production.rs:395) and is covered bytest/bake/dev/production.test.ts,production > rejects a non-array plugins option(abun.app.tswithplugins: 123): the release build bundles with the option ignored, this build printsTypeError: plugins must be an arrayand exits 1.production.test.tsrather than next to the matrix because everybun build --apprun trips a pre-existing unchecked-exception assertion inBakeSourceProvider.cpp(BakeGetDefaultExportFromModule) under the exception-check validation the ASAN lane enables, andproduction.test.tsis the bake file listed intest/no-validate-exceptions.txt. The assertion is independent of this change and is being fixed separately. Run the test alone with-t "rejects a non-array plugins option": the file's other tests build a React app and exceed the default per-test timeout on a debug build.bundlerOptions.{server,client,ssr}being objects (bake: validate bundlerOptions values are objects before property access #30125) andfileSystemRouterTypes[i]being objects (bake: validate fileSystemRouterTypes array elements are objects #30401), already have their own open PRs. Note for sequencing: bake: release the app plugins cell when the dev server is torn down #37837 rewrites the body ofparse_plugin_arraydirectly below the inserted check, so whichever of the two lands second needs a trivial rebase.Background
appoption ofBun.serveand thebun build --appcommand. Both parse the user's options object withUserOptions::from_jsinbake_body.rs;frameworkcan be the string"react"or an object parsed byFramework::from_js, and both the outer object and the framework object accept apluginsarray of Bun bundler plugins.JSBundlerPluginobject (created on the firstpluginskey seen, even an empty one), which every bundle run by the dev server or the production build consults.JSArrayIteratoris Bun's helper for walking a JS array from native code. It does not require an actual array: it reads alengthand indexes, so callers are expected to validate the value first;JSValue::get_arrayis the helper that does that for object properties.