Skip to content
This repository was archived by the owner on Jul 10, 2026. It is now read-only.

Repository files navigation

bun-js-beforeparse

Use any JavaScript/TypeScript function as a Bun bundler onBeforeParse plugin — no per-project Rust code required.

The problem this solves

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.

Installation

bun add bun-js-beforeparse

Pre-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

Quick start

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 exit

API

jsBridge(fn)

Wraps a TypeScript transform function for use as a Bun onBeforeParse plugin.

function jsBridge(fn: TransformFn): NativePluginDescriptor
  • fn — Your transform. Receives (source: string, path: string) and must return the (possibly modified) source as a string (sync) or Promise<string> (async). CPU-only async is safe; see the constraint.
  • Returns the descriptor object { napiModule, symbol, external } expected by build.onBeforeParse(matcher, HERE).

releaseBridge(descriptor)

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): void

TransformFn

type TransformFn = (source: string, path: string) => string | Promise<string>

Constraint: no event-loop-bound async

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.

How it works

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 until recv() 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 both String (sync transform) and Promise<String> (async transform). The dispatch hook inspects the value via value.get_type() and branches: a String is sent through the channel directly; a Promise is cast to PromiseRaw<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 as fn(source, path) directly, with no null error-first arg prepended. jsBridge() passes the user's TransformFn through unchanged, so (source, path) is what you actually receive.
  • Weak = true TSFN 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 v3 External<T> wraps data in a TaggedObject<T> struct, not a bare *mut T. Direct casting would segfault; inner_from_raw navigates the wrapper correctly.
  • catch_unwind in the extern "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.ts types the callback as (arg: [string, string]) => unknown, but at runtime FnArgs<(String, String)> spreads the tuple into two positional JS args — fn(source, path). The jsBridge() wrapper insulates users from this discrepancy.

Building from source

Requires: Rust (stable), Bun (1.3+), napi-rs CLI

Quick setup with mise

# Install all toolchains (Rust, Bun, Node)
mise install

# Install npm deps + build + test
mise run setup
mise run check

Manual setup

# Debug build (for development)
bun run build:debug

# Release build
bun run build

The build produces bun-js-beforeparse.<platform>.node in the package root.

Cross-compilation

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.

How to publish

Releases are automated via GitHub Actions. Push a semver tag to trigger a full build across all 8 platforms and publish to npm.

Prerequisites

  1. Set the NPM_TOKEN secret in your GitHub repo settings (Settings → Secrets → Actions → New repository secret)
  2. Ensure you have npm publish access to the bun-js-beforeparse package

Publish a release

# Bump version in package.json, then:
git tag v0.1.0
git push --tags

This triggers the release workflow which:

  1. Builds .node binaries for all 8 platforms in parallel
  2. Runs napi pre-publish to create per-platform stub packages under npm/
  3. Publishes each platform stub to npm (e.g. @bun-js-beforeparse/linux-x64-gnu)
  4. Publishes the main bun-js-beforeparse package with optionalDependencies
  5. Creates a GitHub Release with the binaries attached

How npm installation works

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.

Dry run

To test the publish without actually publishing:

  1. Go to Actions → Release → Run workflow
  2. Check "Dry run"
  3. The workflow will build and run npm publish --dry-run for all packages

Sponsor

Github Sponsor Buy Me a Coffee

Contributing

Issues and PRs welcome. The Rust source is in src/lib.rs; the TypeScript wrapper is in js/index.ts.

License

MIT

About

A NAPI plugin to pass onBeforeParse event to js plugin (Waiting for implementation)

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages