Skip to content

plugin: accept ArrayBuffer/SharedArrayBuffer for onLoad contents - #33798

Open
robobun wants to merge 1 commit into
mainfrom
farm/895f8b16/onload-contents-arraybuffer
Open

robobun wants to merge 1 commit into
mainfrom
farm/895f8b16/onload-contents-arraybuffer

Conversation

@robobun

@robobun robobun commented Jul 8, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const bytes = new TextEncoder().encode("export default 'OK'");
const ab = new ArrayBuffer(bytes.byteLength); new Uint8Array(ab).set(bytes);

Bun.plugin({ name: "p", setup(b) {
  b.onResolve({ filter: /.*/, namespace: "x" }, a => ({ path: a.path, namespace: "y" }));
  b.onLoad({ filter: /.*/, namespace: "y" }, () => ({ contents: ab, loader: "js" }));
} });
await import("x:m");
// error: Expected "contents" to be a string or an ArrayBufferView

Bun.build with the same plugin fails the same way (onLoad plugins must return an object with "contents" as a string or Uint8Array). A Uint8Array view over the exact same ArrayBuffer is accepted, so the data is fine; only the type check disagrees with the published OnLoadResultSourceCode.contents type, which is string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer.

This bites anyone passing the result of await file.arrayBuffer() or fetch(...).arrayBuffer() straight into contents: it type-checks against bun.d.ts and then fails at runtime.

Cause

  • Runtime (src/jsc/bindings/ModuleLoader.cpp): handleOnLoadResultNotPromise only downcasts contents to JSC::JSArrayBufferView.
  • Bundler (src/js/builtins/BundlerPlugin.ts): the validator only checks typeof contents === "string" || $isTypedArrayView(contents) before calling onLoadAsync. The downstream StringOrBuffer::from_js_to_owned_slice (via JSC__JSValue__asArrayBuffer) already handles ArrayBufferType, so only the JS-side gate was in the way.

Fix

  • ModuleLoader.cpp: add a JSC::JSArrayBuffer branch that reads impl()->data() / byteLength(), matching the pattern in JSBuffer.cpp and bindings.cpp. JSArrayBuffer covers both ArrayBuffer and SharedArrayBuffer.
  • BundlerPlugin.ts: when contents is neither a string nor a typed-array view, fall back to require("node:util/types").isAnyArrayBuffer(contents) before rejecting. The require() is only reached on the uncommon non-string/non-view path.

Both error messages now list the accepted types.

Verification

bun bd test test/js/bun/plugin/plugins.test.ts -t "onLoad contents accepts BufferSource"
bun bd test test/bundler/bun-build-api.test.ts -t "onLoad contents accepts BufferSource"

Each suite has an ArrayBuffer case, a SharedArrayBuffer case, a Uint8Array-over-SharedArrayBuffer control, and a negative case that asserts plain objects are still rejected. The ArrayBuffer / SharedArrayBuffer cases fail under USE_SYSTEM_BUN=1 and pass with this change.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 7 billable files and costs up to $1.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 15 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: f07ab04f-2e6e-4ab2-a24a-e74b3ac56c07
📥 Commits

Reviewing files that changed from the base of the PR and between aa83076 and 67bbd7e.

📒 Files selected for processing (7)
  • src/js/builtins/BundlerPlugin.ts
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ModuleLoader.h
  • test/bundler/bun-build-api.test.ts
  • test/js/bun/plugin/plugin-buffer-contents-fixture.ts
  • test/js/bun/plugin/plugin-buffer-contents-macro.ts
  • test/js/bun/plugin/plugins.test.ts
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:45 AM PT - Oct 3rd, 2026

❌ @robobun, your commit 67bbd7e has 1 failures in Build #123261 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33798

That installs a local version of the PR into your bun-33798 executable, so you can run:

bun-33798 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, well-tested widening of the onLoad contents type gate to match the published .d.ts.

Extended reasoning...

Overview

This PR fixes a runtime/type-declaration mismatch: OnLoadResultSourceCode.contents is typed as string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, but both the runtime plugin path (ModuleLoader.cpp) and the bundler plugin path (BundlerPlugin.ts) only accepted string/ArrayBufferView. The fix adds a JSC::JSArrayBuffer downcast branch in C++ (mirroring the existing pattern at bindings.cpp:3159) and relaxes the JS-side gate via isAnyArrayBuffer() on the fallthrough path. Both error messages are updated to list the accepted types. Tests cover ArrayBuffer, SharedArrayBuffer, Uint8Array-over-SAB (control), and a negative case for both Bun.plugin and Bun.build.

Security risks

None. This widens an input-type acceptance gate to include buffer types that the downstream consumer (JSC__JSValue__asArrayBuffer / ZigString construction) already handles. No new parsing of untrusted data, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. The C++ change is a 5-line branch that exactly mirrors an established pattern elsewhere in the bindings (null-check impl(), then read data()/byteLength()); a null impl() correctly falls through to the existing error path. The TS builtin change only adds a lazy require on the uncommon non-string/non-view path, consistent with other lazy requires in the same file. No CODEOWNERS apply to these files.

Other factors

Test coverage is thorough: positive cases for both new types plus a control and a negative assertion on the exact error text, exercised through both entry points (runtime import and Bun.build). The PR description confirms the new tests fail under USE_SYSTEM_BUN=1. The bug hunting system found no issues.

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status on build 70707: 283 jobs passed. The three red lanes are unrelated infrastructure:

  • test/js/sql/postgres-binary-array-bounds.test.ts on Windows 2019 x64: ERR_POSTGRES_CONNECTION_REFUSED (postgres service unavailable on the runner)
  • test/regression/issue/26030.test.ts on Alpine 3.23 x64-baseline: Failed to start service mysql_plain (via coordinator): application not healthy after 1m0s (MySQL container health check timeout)
  • test/js/bun/webview/webview.test.ts on darwin 14 aarch64: WebView host process killed by signal 5 (WebView subprocess SIGTRAP)

The new tests in test/js/bun/plugin/plugins.test.ts and test/bundler/bun-build-api.test.ts passed on every lane. Ready for review.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

A note for the rebase. #42405 changes the JSArrayBufferView branch of handleOnLoadResultNotPromise. It copies the bytes into result.sourceTextCopy before the transpile. A worker can write a SharedArrayBuffer during the transpile (panic: index out of bounds in the lexer), and a macro can overwrite or detach any other buffer.

The JSArrayBuffer branch that this PR adds borrows buffer->data(). It has the same problem, most directly for a bare SharedArrayBuffer. It needs the same copy: result.sourceTextCopy.tryAppend(buffer->span()), then point sourceText.string at the vector.

…le a copy of buffer contents

Bun.plugin onLoad and build.module accept a bare ArrayBuffer or
SharedArrayBuffer as contents, as the published type says. Bun.build
onLoad accepts them too.

handleVirtualModuleResult, the one caller of Bun__transpileVirtualModule,
copies the bytes of a typed array, DataView, ArrayBuffer or
SharedArrayBuffer into a stack-local Vector before the transpile. Macros
and onResolve plugins run JS during the transpile and another thread can
write a SharedArrayBuffer, so the parser cannot read those bytes in
place. A string still lends its characters. handleOnLoadResultNotPromise
only checks the type of contents, so OnLoadResult holds no pointer.

The Bun.build check of contents uses the $inheritsArrayBuffer intrinsic.
@robobun
robobun force-pushed the farm/895f8b16/onload-contents-arraybuffer branch from 0ae37a9 to 67bbd7e Compare October 3, 2026 09:03
Comment on lines +221 to +223
// Points `source` at the text of an onLoad `contents` for one transpile. False: an exception is pending.
// A string lends its characters. Buffer bytes go into `copy`: macros and onResolve plugins run JS during
// the transpile and another thread can write a SharedArrayBuffer, so the parser cannot read them in place.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +231 to +238

auto bytes = bufferContentsBytes(contents);
ASSERT(bytes);
if (bytes->size() > Bun::maxVectorSize<uint8_t>() || !copy.tryAppend(*bytes)) [[unlikely]] {
throwOutOfMemoryError(globalObject, scope);
return false;
}
source = EncodedSlice { copy.span().data(), copy.size() };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 pre-existing, widened: plugin authors who hand Bun.plugin onLoad a UTF-8 encoded buffer with non-ASCII text (the await file.arrayBuffer() case this PR enables) get mojibake in string literals, while the same buffer in Bun.build is correct. sourceTextForTranspile builds EncodedSlice { copy.span().data(), copy.size() } with no encoding tag (ModuleLoader.cpp:238), and the Rust consumer treats an untagged 8-bit slice as Latin-1 and transcodes any byte >= 0x80 (src/bun_core/string/mod.rs:1488-1492). Fix: mark buffer-sourced slices as UTF-8 (the TAG_UTF8_BIT at src/bun_core/string/mod.rs:1173) or pass raw bytes so the runtime path matches Bun.build, which copies the bytes unchanged via from_js_to_owned_slice (src/runtime/node/types.rs:368-373).

Why this was flagged

A runtime plugin returns { contents: new TextEncoder().encode("export default 'héllo'"), loader: "js" } and the module is imported through Bun.plugin. handleVirtualModuleResult calls sourceTextForTranspile (src/jsc/bindings/ModuleLoader.cpp:426), which copies the bytes and wraps them as EncodedSlice { copy.span().data(), copy.size() } at ModuleLoader.cpp:238 with no UTF-8 tag bit set. Bun__transpileVirtualModule calls source_code.to_utf8() (src/runtime/jsc_hooks.rs:4445); EncodedSlice::to_utf8 at src/bun_core/string/mod.rs:1488 sees !self.is_utf8(), treats the bytes as Latin-1 and transcodes every byte >= 0x80 via to_utf8_from_latin1, so the two UTF-8 bytes of é become two characters (é). The module loads without error with wrong string values. The Bun.build path copies raw bytes (src/runtime/node/types.rs:368-373) and the bundler reads them as UTF-8, so the same plugin gives correct output there. The base already did this for TypedArray/DataView contents; this PR adds ArrayBuffer and SharedArrayBuffer, so the population hitting it grows.

Verification: ModuleLoader.cpp:238 wraps the copied bytes in an EncodedSlice with no tag bits. src/runtime/jsc_hooks.rs:4445 calls source_code.to_utf8(), and src/bun_core/string/mod.rs:1478-1492 transcodes untagged bytes >= 0x80 as Latin-1, so é becomes é. Bun.build copies the buffer verbatim (src/runtime/node/types.rs:368-373). The base already had this for views; this PR adds ArrayBuffer.


if (!(typeof contents === "string") && !$isTypedArrayView(contents)) {
throw new TypeError('onLoad plugins must return an object with "contents" as a string or Uint8Array');
if (!(typeof contents === "string") && !$isTypedArrayView(contents) && !$inheritsArrayBuffer(contents)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Pre-existing, left by this partial fix: Bun.build plugin authors who return a DataView as onLoad contents still get a TypeError, while the runtime Bun.plugin path accepts it. At src/js/builtins/BundlerPlugin.ts:576 the gate is $isTypedArrayView || $inheritsArrayBuffer, and $isTypedArrayView excludes DataView (see the note at src/js/node/fs.ts:300). Fix: make the Bun.build gate accept every value the published contents type and the runtime path accept, including DataView, with a tamper-resistant check (an intrinsic or the native util.types predicate captured at module load, not looked up per call), and word the TypeError to match. The native side already handles DataViewType (bindings.cpp:3539), so only the JS gate is in the way.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A Bun.build plugin's onLoad returns { contents: new DataView(buffer), loader: "js" }, which type-checks against bun.d.ts:6297 (contents: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer). runOnLoadPlugins reaches src/js/builtins/BundlerPlugin.ts:576: typeof is not "string", $isTypedArrayView(DataView) is false because JSC's typed-view check excludes DataViewType (src/js/node/fs.ts:300-301 documents this), and $inheritsArrayBuffer is false for a view. The build fails with 'onLoad plugins must return an object with "contents" as a string, TypedArray, ArrayBuffer, or SharedArrayBuffer'. The runtime path accepts the same value: src/jsc/bindings/ModuleLoader.cpp:212 dynamicDowncastJSC::JSArrayBufferView matches JSDataView. The base branch rejected DataView the same way; the PR widened the gate only for ArrayBuffer/SharedArrayBuffer, leaving the two entry points disagreeing on DataView.

Verification: Pre-existing: the base gate already rejected DataView by the same route. Trigger: a Bun.build plugin's onLoad returns { contents: new DataView(buf), loader }. src/js/builtins/BundlerPlugin.ts:576 throws because $isTypedArrayView excludes DataViewType (src/js/node/fs.ts:300) and $inheritsArrayBuffer is false for a view, while the runtime path in src/jsc/bindings/ModuleLoader.cpp accepts DataView.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant