Use any JavaScript/TypeScript function as a Bun bundler
onBeforeParse plugin — no
per-project Rust code required.
Bun 1.3.x JS bundler plugins cannot intercept native file types (.tsx, .jsx,
.ts, .js) via onLoad/onResolve. Only compiled native NAPI modules can intercept
those files using the onBeforeParse hook. This package wraps that native hook once, so
you can write your transforms in plain TypeScript.
bun add bun-js-beforeparsePre-built .node binaries are included for:
| Platform | Architecture | Target |
|---|---|---|
| Linux | x64 (glibc) | x86_64-unknown-linux-gnu |
| Linux | x64 (musl) | x86_64-unknown-linux-musl |
| Linux | arm64 (glibc) | aarch64-unknown-linux-gnu |
| Linux | arm64 (musl) | aarch64-unknown-linux-musl |
| macOS | x64 (Intel) | x86_64-apple-darwin |
| macOS | arm64 (Apple Silicon) | aarch64-apple-darwin |
| Windows | x64 (MSVC) | x86_64-pc-windows-msvc |
| Windows | arm64 (MSVC) | aarch64-pc-windows-msvc |
import { jsBridge } from "bun-js-beforeparse";
const server = Bun.serve({
routes: { "/": homepage },
plugins: [
{
name: "my-transform",
setup(build) {
build.onBeforeParse(
{ filter: /\.[jt]sx$/, namespace: "file" },
jsBridge(async (source, path) => {
// Anything here — plain TypeScript, no Rust
return source.replace(/foo/g, "bar");
}),
);
},
},
],
development: { hmr: true },
port: 3000,
});For one-shot Bun.build() calls, release the bridge when done so the process can exit:
import { jsBridge, releaseBridge } from "bun-js-beforeparse";
const bridge = jsBridge(myTransform);
await Bun.build({
entrypoints: ["./src/index.tsx"],
plugins: [{
name: "transform",
setup(build) {
build.onBeforeParse({ filter: /\.[jt]sx$/ }, bridge);
},
}],
});
releaseBridge(bridge); // allows the process to exitWraps a TypeScript transform function for use as a Bun onBeforeParse plugin.
function jsBridge(fn: TransformFn): NativePluginDescriptorfn— Your transform. Receives(source: string, path: string)and must return the (possibly modified) source as astring(sync) orPromise<string>(async). CPU-only async is safe; see the constraint.- Returns the descriptor object
{ napiModule, symbol, external }expected bybuild.onBeforeParse(matcher, HERE).
Releases the TSFN reference so the event loop can exit after a Bun.build() call.
Not needed when using Bun.serve() (the server keeps the event loop alive anyway).
With Weak = true (napi-rs v3) this is a no-op for API compatibility — the TSFN does
not hold the event loop open. Calling it is still safe and has no effect.
function releaseBridge(descriptor: NativePluginDescriptor): voidtype TransformFn = (source: string, path: string) => string | Promise<string>Your transform must not await anything that requires the JS event loop to yield
(e.g. await fetch(...), await Bun.file(...).text()).
Safe: CPU-only async work — Babel transforms, SWC, Oxc, @code-inspector/core.
These resolve through microtasks without yielding, so the blocked worker thread unblocks
as soon as the microtask queue drains.
Unsafe: Anything that needs a new I/O event — fetch, Bun.file().text(),
setTimeout-based delays, anything backed by libuv/tokio callbacks.
Why: The bridge blocks a Bun bundler worker thread via a synchronous Rust channel
(mpsc::sync_channel(0)) while it waits for the JS callback to send back the result.
If the callback needs the event loop to turn over (e.g. awaiting a fetch response), and
the event loop is blocked handling the TSFN callback, you get a deadlock.
Bun worker thread (native) JS main thread
────────────────────────── ──────────────
bun_js_bridge_dispatch() TSFN callback fires
OnBeforeParse::from_raw() call_with_return_value cb
read source bytes (zero-copy) calls user's JS fn(source, path)
create SyncChannel(0) user fn returns a value
tsfn.call_with_return_value( ┌─ String → tx.send(s) directly
payload, Blocking, cb) │─ Promise → .then(s => tx.send(s))
←─── blocks on rx.recv() ────────────┘ .catch(_ => tx.send(""))
handle.set_output_source_code()
Key design decisions:
mpsc::sync_channel(0)— a rendezvous channel.send()blocks untilrecv()picks up, so the worker thread blocks exactly until the JS result is ready.Unknown<'static>TSFN return type — the callback return type is intentionally left loose so the runtime accepts bothString(sync transform) andPromise<String>(async transform). The dispatch hook inspects the value viavalue.get_type()and branches: aStringis sent through the channel directly; aPromiseis cast toPromiseRaw<String>and wired with.then()/.catch()so the resolved value reaches the blocked worker thread after microtask resolution.callee_handled = false— the JS callback is invoked asfn(source, path)directly, with no null error-first arg prepended.jsBridge()passes the user'sTransformFnthrough unchanged, so(source, path)is what you actually receive.Weak = trueTSFN reference — does not hold the event loop open by itself; the process exits naturally once the event loop drains.releaseBridge()is retained for API compatibility but is a no-op in napi-rs v3.External::<Arc<BridgeFn>>::inner_from_raw(ptr)— napi v3External<T>wraps data in aTaggedObject<T>struct, not a bare*mut T. Direct casting would segfault;inner_from_rawnavigates the wrapper correctly.catch_unwindin theextern "C" hook— prevents a Rust panic from crashing the Bun runtime. On panic the original source is returned unchanged.
napi-rs v3 note: the generated
index.d.tstypes the callback as(arg: [string, string]) => unknown, but at runtimeFnArgs<(String, String)>spreads the tuple into two positional JS args —fn(source, path). ThejsBridge()wrapper insulates users from this discrepancy.
Requires: Rust (stable), Bun (1.3+), napi-rs CLI
# Install all toolchains (Rust, Bun, Node)
mise install
# Install npm deps + build + test
mise run setup
mise run check# Debug build (for development)
bun run build:debug
# Release build
bun run buildThe build produces bun-js-beforeparse.<platform>.node in the package root.
The release workflow builds all targets using direct napi build commands:
| Target | Method | Runner |
|---|---|---|
x86_64-unknown-linux-gnu |
--use-napi-cross |
ubuntu-latest |
x86_64-unknown-linux-musl |
-x (cargo-zigbuild + zig) |
ubuntu-latest |
aarch64-unknown-linux-gnu |
--use-napi-cross |
ubuntu-latest |
aarch64-unknown-linux-musl |
-x (cargo-zigbuild + zig) |
ubuntu-latest |
aarch64-apple-darwin |
native | macos-latest |
x86_64-apple-darwin |
native (cross from arm64) | macos-latest |
x86_64-pc-windows-msvc |
native | windows-latest |
aarch64-pc-windows-msvc |
native (cross from x64) | windows-latest |
For local cross-compilation, see the napi-rs docs.
Releases are automated via GitHub Actions. Push a semver tag to trigger a full build across all 8 platforms and publish to npm.
- Set the
NPM_TOKENsecret in your GitHub repo settings (Settings → Secrets → Actions → New repository secret) - Ensure you have npm publish access to the
bun-js-beforeparsepackage
# Bump version in package.json, then:
git tag v0.1.0
git push --tagsThis triggers the release workflow which:
- Builds
.nodebinaries for all 8 platforms in parallel - Runs
napi pre-publishto create per-platform stub packages undernpm/ - Publishes each platform stub to npm (e.g.
@bun-js-beforeparse/linux-x64-gnu) - Publishes the main
bun-js-beforeparsepackage withoptionalDependencies - Creates a GitHub Release with the binaries attached
When a user runs npm install bun-js-beforeparse, npm automatically installs only
the matching platform stub. For example, on Linux x64 it installs @bun-js-beforeparse/linux-x64-gnu.
The main package's optionalDependencies field drives this behavior.
To test the publish without actually publishing:
- Go to Actions → Release → Run workflow
- Check "Dry run"
- The workflow will build and run
npm publish --dry-runfor all packages
Issues and PRs welcome. The Rust source is in src/lib.rs; the TypeScript wrapper is
in js/index.ts.
MIT