tailwindcss icon indicating copy to clipboard operation
tailwindcss copied to clipboard

Bun plugin

Open zackradisic opened this issue 10 months ago • 5 comments

(Sorry for creating this PR without opening an issue first)

This PR implements a Bun plugin that makes TailwindCSS fast in Bun.

c612d2faa37f2cff03c8620a08bb2def7268851bb183c2fa8e2763246b9243e5

(bundling 2048 html + react + tailwind files)

Overview

This plugins is comprised of two parts:

  • A regular Bun bundler plugin which is the main driver of everything and invokes the native bundler plugin
  • A native bundler plugin which parallelizes the module graph scanning of candidates. Tailwind was part of the motivation for implementing this API.

Native bundler plugin

The native bundler plugin is used to scan the module graph in parallel with the Scanner struct from @tailwindcss/oxide.

The main logic for this code is in the tw_on_before_parse function.

Native bundler plugins run in parallel on Bun's bundler threads and do not need to do UTF-16 <-> UTF-8 string conversions. This speeds up the plugin a lot.

Native bundler plugins are NAPI modules which export additional symbols (since NAPI modules themselves are dynamically loaded libraries which can be dlopen()'d). The bun-native-plugin crate handles the boilerplate for creating one.

I placed the Bun plugin inside the existing crates/node/lib.rs (the @tailwindcss/oxide package). This reduces the need to create more compiled artifacts at the cost of a relatively small binary size change:

# original size
❯ ls -lhS dist/tailwindcss-oxide-darwin-arm64.tgz
-rw-r--r--@ 1 zackradisic  staff   2.1M Feb  4 20:42 dist/tailwindcss-oxide-darwin-arm64.tgz

# new size
❯ ls -lhS dist/tailwindcss-oxide-darwin-arm64.tgz
-rw-r--r--@ 1 zackradisic  staff   2.2M Feb  4 18:42 dist/tailwindcss-oxide-darwin-arm64.tgz

Please let me know if you would like me to split it out into its own separate package if you don't like the binary size change.

Sharing state between the native plugin and JS

The scanned candidates and other state are held inside a NAPI External. The struct in the code that does this is called TailwindContextExternal.

A NAPI External is a NAPI value which can be given to JS and which holds a void* data pointer. This data is inaccessible to JS, but a NAPI module can dereference the data and convert it to NAPI values.

This looks a bit like this on the Rust side:

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

  external
}

And the JS side:

// import napi functions which let us manipulate the external
import { twctxCreate, twctxIsDirty, twctxToJs } from '@tailwindcss/oxide'

// create the state, the returned value
// is a Napi External
const external = twctxCreate()

/* ... other code ... */

let moduleGraphCandidates = new Map<string, Set<string>>()
function getSharedCandidates() {
  // check if there are changes
  if (twctxIsDirty(external)) {
    // convert the state into js values
    let rawCandidates: Array<{ id: string; candidates: string[] }> = twctxToJs(external)
    for (let { id, candidates } of rawCandidates) {
      moduleGraphCandidates.set(id, new Set(candidates))
    }
  }
  return moduleGraphCandidates
}

napi-rs version bump

The napi-rs crate was updated to version 2.16.15 so we can use the External::inner_from_raw() function to turn an External's *mut c_void pointer back into TailwindContextExternal.

JS plugin

The JS plugin @tailwindcss-bun/src/index.ts uses logic copied over from the vite plugin implementation but modified to work with Bun's plugin API.

It invokes the native bundler plugin using the .onBeforeParse plugin API function:

// Called on every file which matches the filter
// before it is parsed by Bun
build.onBeforeParse(
  // filter which files the native plugin apply to
  { filter: NON_CSS_ROOT_FILE_RE },

  // pass the napi module, the symbol which points to the plugin main function,
  // and the external which holds the tailwind state
  { napiModule: addon, symbol: 'tw_on_before_parse', external },
)

One thing to note is that Bun's bundler currently does not have an API that is analogous to .addWatchedFile(), so there is currently no way to add additional files to the module graph.

Testing

I added a integrations/bun/index.test.ts file, please let me know if you would like more tests

zackradisic avatar Feb 06 '25 04:02 zackradisic

Hey! Really excited for an official Bun plugin! We’re happy to collaborate and work on this together but to set the expectations right it will realistically take a few weeks before someone from our team can focus on this.

One reason for that is that we’re currently working on some changes that will break the existing Oxide <> Node APIs that are necessary fix some critical bugs. These changes will then impact the API design for the updated Oxide bindings necessary for the Bun plugin as well.

I’m sorry that we can’t hop onto this right away but we have to prioritize some other important fixes for the v4 release right now, I hope you understand. We’re pretty stoked about this feature though and hope to get to it soon!

philipp-spiess avatar Feb 07 '25 16:02 philipp-spiess

Any update on this?

Nanome203 avatar Apr 24 '25 17:04 Nanome203

would love to see this make it through! using tailwind with bun is a much better experience than vite, yet the former is not even listed in the installation docs, and the latter is the default suggestion 😭

marcospgp avatar May 06 '25 15:05 marcospgp

bump

Nanome203 avatar Oct 13 '25 19:10 Nanome203

bump again! first class support of bun would be great :heart: thx @zackradisic for working on this!

choucavalier avatar Oct 28 '25 21:10 choucavalier