Skip to content

Bun plugin #16295

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,86 @@ jobs:
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

bun-tests:
strategy:
fail-fast: false
matrix:
bun-version: [1.2.2]
runner:
- name: Windows
os: windows-latest

- name: Linux
os: ubuntu-latest

- name: macOS
os: macos-14

# Exclude windows and macos from being built on feature branches
on-main-branch:
- ${{ github.ref == 'refs/heads/main' }}
exclude:
- on-main-branch: false
runner:
name: Windows
- on-main-branch: false
runner:
name: macOS

runs-on: ${{ matrix.runner.os }}
timeout-minutes: 30

name: Bun / ${{ matrix.runner.name }}

steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: ${{ matrix.bun-version }}

- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-

# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./target/
./crates/node/*.node
./crates/node/index.js
./crates/node/index.d.ts
key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }}

- name: Install dependencies
run: pnpm install

- name: Build
run: pnpm run build
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link'

- name: Test
run: pnpm vitest --root=./integrations bun
34 changes: 32 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ crate-type = ["cdylib"]

[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.16.11", default-features = false, features = ["napi4"] }
napi = { version = "2.16.15", default-features = false, features = ["napi4"] }
napi-derive = "2.16.12"
tailwindcss-oxide = { path = "../oxide" }
rayon = "1.5.3"
bun-native-plugin = { version = "0.2.0" }
fxhash = { package = "rustc-hash", version = "2.0.0" }

[build-dependencies]
napi-build = "2.0.1"
16 changes: 16 additions & 0 deletions crates/node/export_native_binding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* This code modifies the `index.js` JS glue code generated by napi-rs to export the `require()`'d napi module.
* This is needed for the native bun plugin implementation.
*/
const fs = require('fs')
const path = require('path')

const indexPath = path.join(__dirname, 'index.js')
const exportLine = '\nmodule.exports.nativeBinding = nativeBinding;\n'

fs.appendFileSync(indexPath, exportLine)

const indexDtsPath = path.join(__dirname, 'index.d.ts')
const exportDtsLine = '\nexport declare const nativeBinding: unknown;\n'

fs.appendFileSync(indexDtsPath, exportDtsLine)
4 changes: 2 additions & 2 deletions crates/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
},
"scripts": {
"artifacts": "napi artifacts",
"build": "napi build --platform --release --no-const-enum",
"build": "napi build --platform --release --no-const-enum && node export_native_binding.js",
"dev": "cargo watch --quiet --shell 'npm run build'",
"build:debug": "napi build --platform --no-const-enum",
"build:debug": "napi build --platform --no-const-enum && node export_native_binding.js",
"version": "napi version"
},
"optionalDependencies": {
Expand Down
144 changes: 144 additions & 0 deletions crates/node/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
use std::ops::Deref;
use std::sync::{atomic::AtomicBool, Mutex};

use bun_native_plugin::{anyhow, bun, define_bun_plugin, Error, Result};
use fxhash::{FxHashMap, FxHashSet};
use napi::{
bindgen_prelude::{Array, External},
Env, Result as NapiResult,
};
use utf16::IndexConverter;

#[macro_use]
Expand Down Expand Up @@ -148,3 +157,138 @@ impl Scanner {
.collect()
}
}

/// State which contains the scanned candidates from the module graph.
///
/// This is turned into a Napi External so the JS plugin can hold it and
/// eventually request the candidates to be turned in to JS.
///
/// The state inside this struct must be threadsafe as it could be accessed from
/// the JS thread as well as the other bundler threads.
#[derive(Default)]
pub struct TailwindContextExternal {
/// Candidates scanned from the module graph.
module_graph_candidates: Mutex<FxHashMap<String, FxHashSet<String>>>,
/// Atomic flag to indicate whether the state has been changed.
dirty: AtomicBool,
}

define_bun_plugin!("tailwindcss");

/// Create the TailwindContextExternal and return it to JS wrapped in a Napi External.
#[no_mangle]
#[napi]
pub fn twctx_create() -> External<TailwindContextExternal> {
let external = External::new(TailwindContextExternal {
module_graph_candidates: Default::default(),
dirty: AtomicBool::new(false),
});

external
}

#[napi(object)]
struct CandidatesEntry {
pub id: String,
pub candidates: Vec<String>,
}

/// Convert the scanned candidates into a JS array of objects so the JS code can
/// use it.
#[no_mangle]
#[napi]
pub fn twctx_to_js(env: Env, ctx: External<TailwindContextExternal>) -> NapiResult<Array> {
let candidates = ctx.module_graph_candidates.lock().map_err(|_| {
napi::Error::new(
napi::Status::WouldDeadlock,
"Failed to acquire lock on candidates: another thread panicked while holding the lock.",
)
})?;

let len: u32 = candidates.len().try_into().map_err(|_| {
napi::Error::new(
napi::Status::InvalidArg,
format!("Too many candidates: {}", candidates.len()),
)
})?;

let mut arr = env.create_array(len)?;

// TODO: Creating objects and copying/convertings strings is slow in NAPI.
// We could use a more efficient approach:
// 1. Flat array of de-duped candidate strings
// 2. Flat array of int32 (or smaller) indices into the candidate array as well as lengths
// 3. A flat array of ids
//
// However, it is unclear how much of a performance difference this makes as this array will
// get turned into a set inside the corresponding JS code.
for (i, (id, candidates)) in candidates.iter().enumerate() {
let mut obj = env.create_object()?;
obj.set("id", id)?;
obj.set("candidates", candidates.iter().collect::<Vec<_>>())?;
arr.set(i as u32, obj)?;
}

Ok(arr)
}

/// This function can be called from JS to check if the state has been changed and to
/// then call `twctx_to_js` to convert the candidates into JS values.
#[no_mangle]
#[napi]
pub fn twctx_is_dirty(_env: Env, ctx: External<TailwindContextExternal>) -> NapiResult<bool> {
Ok(ctx.dirty.load(std::sync::atomic::Ordering::Acquire))
}

/// This is the main native bundler plugin function.
///
/// It is executed for every file that matches the regex (see the `.onBeforeParse` call in `@tailwindcss-bun/src/index.ts`).
///
/// This function is essentially given as input the source code to the file before it is parsed by Bun. It uses this to
/// scan it for potential candidates.
///
/// Care must be taken to ensure that this code is threadsafe as it could be executing concurrrently on multiple of Bun's bundler
/// threads.
#[bun]
pub fn tw_on_before_parse(handle: &mut OnBeforeParse) -> Result<()> {
let source_code = handle.input_source_code()?;

let mut scanner = tailwindcss_oxide::Scanner::new(None);

let candidates = scanner.scan_content(vec![tailwindcss_oxide::ChangedContent {
content: Some(source_code.to_string()),
file: None,
}]);

// If we found candidates, update our state
if !candidates.is_empty() {
let tw_ctx: &TailwindContextExternal = unsafe {
handle
.external(External::inner_from_raw)
.and_then(|tw| tw.ok_or(Error::Unknown))?
};

let mut graph_candidates = tw_ctx.module_graph_candidates.lock().map_err(|_| {
anyhow::Error::msg(
"Failed to acquire lock on candidates: another thread panicked while holding the lock",
)
})?;

tw_ctx
.dirty
.store(true, std::sync::atomic::Ordering::Release);

let path = handle.path()?;

if let Some(graph_candidates) = graph_candidates.get_mut(path.deref()) {
graph_candidates.extend(candidates.into_iter());
} else {
graph_candidates.insert(path.to_string(), candidates.into_iter().collect());
}
}

let loader = handle.output_loader();
handle.set_output_loader(loader);

Ok(())
}
Loading