feat: support bun plugin (#539)

* loose start for bun plugin

* implement bun

* support emitFile()

* fix Bun cases in integration test

* add bun to other test files

* remove bun-types-no-globals from github

* restore bun-types-no-globals from npm @^1.2

* bun does not yet support .onEnd, so for now we shouldn't fake it with brittle workarounds

* add Bun in the documentation

* some missing bun references in docs

* support multiple plugins

* use Bun namespace instead of importing module that won't necessarily exist

* Bun is a cute pink color!

* fix the transform hook

* fix for virtual modules

* tidy up

* setup bun in ci

* revert unplugin require path

* ignore bun in test-out folders

* update tests

* support onEnd

* remove

* implement guessLoader(), bun also now supports onEnd()

* don't eat errors/warnings

* we dont need to outdir for bun in this test

* bun writebundle test

* Update to bun@1.2.22 (supports onEnd and onResolve)

* use onStart()

* define onStart() in mocks

* onStart

* ci: run vitest in Bun so we can run bun's tests

* Bun error message if building outside of Bun

* skip bun specific tests when not running in bun

* refactor

* allow only

* ci: fix typecheck

---------

Co-authored-by: Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
Alistair Smith 2025-11-03 10:30:00 +00:00 committed by GitHub
parent ffc1e55446
commit f674d582bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1124 additions and 46 deletions

View File

@ -15,3 +15,36 @@ permissions: {}
jobs:
unit-test:
uses: sxzz/workflows/.github/workflows/unit-test.yml@v1
with:
typecheck: pnpm run build && pnpm run typecheck
unit-test-bun:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: lts/*
cache: pnpm
- name: Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Test with bun
run: bun run scripts/buildFixtures.ts && bun -b vitest --allowOnly

View File

@ -15,6 +15,7 @@ Currently supports:
- [Rspack](https://www.rspack.dev/)
- [Rolldown](https://rolldown.rs/)
- [Farm](https://www.farmfe.org/)
- [Bun](https://bun.com/)
- And every framework built on top of them.
## Documentations

View File

@ -1,4 +1,4 @@
export const title = 'Unplugin'
export const description = 'Unified plugin system. Support Vite, Rollup, webpack, esbuild, and every frameworks on top of them.'
export const description = 'Unified plugin system. Support Vite, Rollup, webpack, esbuild, Bun, and every frameworks on top of them.'
export const url = 'https://unplugin.unjs.io/'
export const ogImage = `${url}/og.png`

View File

@ -6,7 +6,7 @@
Unplugin
</h1>
<p align="center">
Unified plugin system, Support Vite, Rollup, webpack, esbuild, and more
Unified plugin system, Support Vite, Rollup, webpack, esbuild, Bun, and more
</p>
<p align="center">

View File

@ -18,6 +18,7 @@ lastUpdated: false
- [Rspack](https://www.rspack.dev/)
- [Rolldown](https://rolldown.rs/)
- [Farm](https://www.farmfe.org/)
- [Bun](https://bun.com/)
## Trying It Online
@ -153,6 +154,21 @@ export default defineConfig({
})
```
```ts [Bun]
// bun.config.ts
import Starter from 'unplugin-starter/bun'
await Bun.build({
entrypoints: ['./src/index.ts'],
outdir: './dist',
plugins: [
Starter({
/* options */
}),
],
})
```
```js [Vue-CLI]
// vue.config.js
module.exports = {
@ -193,18 +209,18 @@ export default defineConfig({
## Supported Hooks
| Hook | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown |
| --------------------------------------------------------------------------------- | :-------------: | :--: | :-----: | :-------------: | :-------------: | :--: | :------: |
| [`enforce`](https://vite.dev/guide/api-plugin.html#plugin-ordering) | ❌ <sup>1</sup> | ✅ | ✅ | ❌ <sup>1</sup> | ✅ | ✅ | ✅ |
| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ <sup>5</sup> | ✅ | ✅ |
| ~~`loadInclude`~~<sup>2</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ <sup>3</sup> | ✅ | ✅ | ✅ |
| ~~`transformInclude`~~<sup>2</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ <sup>3</sup> | ✅ | ✅ | ✅ |
| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)<sup>4</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Hook | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun |
| --------------------------------------------------------------------------------- | :-------------: | :--: | :-----: | :-------------: | :-------------: | :--: | :------: | :-------------: |
| [`enforce`](https://vite.dev/guide/api-plugin.html#plugin-ordering) | ❌ <sup>1</sup> | ✅ | ✅ | ❌ <sup>1</sup> | ✅ | ✅ | ✅ | ❌ |
| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ <sup>5</sup> | ✅ | ✅ | ✅ |
| ~~`loadInclude`~~<sup>2</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ <sup>3</sup> | ✅ | ✅ | ✅ | ✅ |
| ~~`transformInclude`~~<sup>2</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ <sup>3</sup> | ✅ | ✅ | ✅ | ✅ |
| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |<sup>6</sup> |
| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)<sup>4</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |<sup>6</sup> |
::: details Notice
@ -215,6 +231,7 @@ export default defineConfig({
3. Although esbuild can handle both JavaScript and CSS and many other file formats, you can only return JavaScript in `load` and `transform` results.
4. Currently, `writeBundle` is only serves as a hook for the timing. It doesn't pass any arguments.
5. Rspack supports `resolveId` with a minimum required version of v1.0.0-alpha.1.
6. Bun's plugin API doesn't have an `onEnd` hook yet, so `buildEnd` and `writeBundle` are not supported.
:::
@ -253,6 +270,7 @@ export const webpackPlugin = unplugin.webpack
export const rspackPlugin = unplugin.rspack
export const esbuildPlugin = unplugin.esbuild
export const farmPlugin = unplugin.farm
export const bunPlugin = unplugin.bun
```
### Filters
@ -289,14 +307,14 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/
## Supported Context
| Context | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown |
| ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :-----: | :----: | :--: | :------: |
| [`this.parse`](https://rollupjs.org/plugin-development/#this-parse) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.addWatchFile`](https://rollupjs.org/plugin-development/#this-addwatchfile) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| [`this.emitFile`](https://rollupjs.org/plugin-development/#this-emitfile)<sup>1</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| [`this.warn`](https://rollupjs.org/plugin-development/#this-warn) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.error`](https://rollupjs.org/plugin-development/#this-error) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Context | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun |
| ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :-----: | :----: | :--: | :------: | :-: |
| [`this.parse`](https://rollupjs.org/plugin-development/#this-parse) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.addWatchFile`](https://rollupjs.org/plugin-development/#this-addwatchfile) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| [`this.emitFile`](https://rollupjs.org/plugin-development/#this-emitfile)<sup>1</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| [`this.warn`](https://rollupjs.org/plugin-development/#this-warn) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`this.error`](https://rollupjs.org/plugin-development/#this-error) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
::: info Notice
@ -309,9 +327,9 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/
### Bundler Supported
| Rollup | Vite | webpack | Rspack | esbuild | Farm | Rolldown |
| :--------------------: | :--: | :-----: | :----: | :-----: | :--: | :------: |
| ✅ `>=3.1`<sup>1</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Rollup | Vite | webpack | Rspack | esbuild | Farm | Rolldown | Bun |
| :--------------------: | :--: | :-----: | :----: | :-----: | :--: | :------: | :-: |
| ✅ `>=3.1`<sup>1</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
::: details Notice
@ -409,6 +427,9 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (
farm: {
// Farm plugin
},
bun: {
// Bun plugin
},
}
}
@ -424,6 +445,7 @@ Each of the function takes the same generic factory argument as `createUnplugin`
```ts
import {
createBunPlugin,
createEsbuildPlugin,
createFarmPlugin,
createRolldownPlugin,
@ -440,4 +462,5 @@ const esbuildPlugin = createEsbuildPlugin(/* factory */)
const webpackPlugin = createWebpackPlugin(/* factory */)
const rspackPlugin = createRspackPlugin(/* factory */)
const farmPlugin = createFarmPlugin(/* factory */)
const bunPlugin = createBunPlugin(/* factory */)
```

View File

@ -5,7 +5,7 @@ sidebar: false
hero:
name: Unplugin
text: The Unified<br>Plugin System
tagline: Supports Vite, Rollup, webpack, esbuild, and every framework built on top of them.
tagline: Supports Vite, Rollup, webpack, esbuild, Bun, and every framework built on top of them.
image:
light: /logo_light.svg
dark: /logo_dark.svg
@ -64,6 +64,12 @@ features:
icon:
src: /features/rolldown.svg
- title: Bun
details: All-in-one JavaScript runtime & toolkit
link: https://bun.com/
icon:
src: /features/bun.svg
- title: More
details: More supported bundlers...
link: /guide/#supported-hooks

View File

@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -59,6 +59,7 @@
"@types/picomatch": "catalog:",
"ansis": "catalog:",
"bumpp": "catalog:",
"bun-types-no-globals": "catalog:",
"esbuild": "catalog:",
"eslint": "catalog:",
"eslint-plugin-format": "catalog:",

15
pnpm-lock.yaml generated
View File

@ -24,6 +24,9 @@ catalogs:
bumpp:
specifier: ^10.3.1
version: 10.3.1
bun-types-no-globals:
specifier: ^1.2.22
version: 1.3.1
eslint:
specifier: ^9.39.0
version: 9.39.0
@ -196,6 +199,9 @@ importers:
bumpp:
specifier: 'catalog:'
version: 10.3.1
bun-types-no-globals:
specifier: 'catalog:'
version: 1.3.1
esbuild:
specifier: ^0.25.12
version: 0.25.12
@ -2107,6 +2113,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
bun-types-no-globals@1.3.1:
resolution: {integrity: sha512-nVhf54PRc8MzKvMK0IXy6TlPGy8Qtk/BY/kk7IUuAxDROYWGRhD+9WF1H0VvzIeB3/AMnuV3az9K7h/4GfSWCw==}
bundle-name@3.0.0:
resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==}
engines: {node: '>=12'}
@ -5319,14 +5328,14 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.27.7
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
'@babel/traverse@7.27.7':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.5
'@babel/parser': 7.27.7
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.5
debug: 4.4.3
@ -7188,6 +7197,8 @@ snapshots:
transitivePeerDependencies:
- magicast
bun-types-no-globals@1.3.1: {}
bundle-name@3.0.0:
dependencies:
run-applescript: 5.0.0

View File

@ -11,6 +11,7 @@ catalog:
'@types/picomatch': ^4.0.2
ansis: ^4.2.0
bumpp: ^10.3.1
bun-types-no-globals: ^1.2.22
esbuild: ^0.25.12
eslint: ^9.39.0
eslint-plugin-format: ^1.0.2

View File

@ -5,6 +5,8 @@ import { join, resolve } from 'node:path'
import process from 'node:process'
import c from 'ansis'
const isBun = !!process.versions.bun
const dir = resolve(import.meta.dirname, '../test/fixtures')
let fixtures = await readdir(dir)
@ -16,6 +18,13 @@ for (const name of fixtures) {
if (existsSync(join(path, 'dist')))
await rm(join(path, 'dist')).catch(() => {})
if (isBun) {
console.log(c.magentaBright.inverse.bold`\n Bun `, name, '\n')
execSync('bun --version', { cwd: path, stdio: 'inherit' })
execSync('bun bun.config.js', { cwd: path, stdio: 'inherit' })
continue // skip other builders in bun environment
}
console.log(c.yellow.inverse.bold`\n Vite `, name, '\n')
execSync('npx vite --version', { cwd: path, stdio: 'inherit' })
execSync('npx vite build', { cwd: path, stdio: 'inherit' })

238
src/bun/index.ts Normal file
View File

@ -0,0 +1,238 @@
import type { BunPlugin, Loader } from 'bun'
import type { TransformResult, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types'
import { isAbsolute } from 'node:path'
import { normalizeObjectHook } from '../utils/filter'
import { toArray } from '../utils/general'
import { createBuildContext, createPluginContext, guessLoader } from './utils'
export function getBunPlugin<UserOptions = Record<string, never>>(
factory: UnpluginFactory<UserOptions>,
): UnpluginInstance<UserOptions>['bun'] {
return (userOptions?: UserOptions): BunPlugin => {
if (typeof Bun === 'undefined') {
throw new ReferenceError('Bun is not supported in this environment')
}
if (!Bun.semver.satisfies(Bun.version, '>=1.2.22')) {
throw new Error('Bun 1.2.22 or higher is required, please upgrade Bun')
}
const meta: UnpluginContextMeta = {
framework: 'bun',
}
const plugins = toArray(factory(userOptions!, meta))
return {
name: (plugins.length === 1 ? plugins[0].name : meta.bunHostName)
?? `unplugin-host:${plugins.map(p => p.name).join(':')}`,
async setup(build) {
const context = createBuildContext(build)
if (plugins.some(plugin => plugin.buildStart)) {
build.onStart(async () => {
for (const plugin of plugins) {
if (plugin.buildStart) {
await plugin.buildStart.call(context)
}
}
})
}
const resolveIdHooks = plugins
.filter(plugin => plugin.resolveId)
.map(plugin => ({
plugin,
...normalizeObjectHook('resolveId', plugin.resolveId!),
}))
const loadHooks = plugins
.filter(plugin => plugin.load)
.map(plugin => ({
plugin,
...normalizeObjectHook('load', plugin.load!),
}))
const transformHooks = plugins
.filter(plugin => plugin.transform || plugin.transformInclude)
.map(plugin => ({
plugin,
...normalizeObjectHook('transform', plugin.transform!),
}))
const virtualModulePlugins = new Set<string>()
for (const plugin of plugins) {
if (plugin.resolveId && plugin.load) {
virtualModulePlugins.add(plugin.name)
}
}
if (resolveIdHooks.length) {
build.onResolve({ filter: /.*/ }, async (args) => {
if (build.config?.external?.includes(args.path)) {
return
}
for (const { plugin, handler, filter } of resolveIdHooks) {
if (!filter(args.path))
continue
const { mixedContext, errors, warnings } = createPluginContext(context)
const isEntry = args.kind === 'entry-point-run' || args.kind === 'entry-point-build'
const result = await handler.call(
mixedContext,
args.path,
isEntry ? undefined : args.importer,
{ isEntry },
)
for (const warning of warnings) {
console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message)
}
if (errors.length > 0) {
const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n')
throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`)
}
if (typeof result === 'string') {
if (!isAbsolute(result)) {
return {
path: result,
namespace: plugin.name,
}
}
return { path: result }
}
else if (typeof result === 'object' && result !== null) {
if (!isAbsolute(result.id)) {
return {
path: result.id,
external: result.external,
namespace: plugin.name,
}
}
return {
path: result.id,
external: result.external,
}
}
}
})
}
async function processLoadTransform(
id: string,
namespace: string,
loader?: Loader,
): Promise<{ contents: string, loader: Loader } | undefined> {
let code: string | undefined
let hasResult = false
const namespaceLoadHooks = namespace === 'file'
? loadHooks
: loadHooks.filter(h => h.plugin.name === namespace)
for (const { plugin, handler, filter } of namespaceLoadHooks) {
if (plugin.loadInclude && !plugin.loadInclude(id))
continue
if (!filter(id))
continue
const { mixedContext, errors, warnings } = createPluginContext(context)
const result = await handler.call(mixedContext, id)
for (const warning of warnings) {
console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message)
}
if (errors.length > 0) {
const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n')
throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`)
}
if (typeof result === 'string') {
code = result
hasResult = true
break
}
else if (typeof result === 'object' && result !== null) {
code = result.code
hasResult = true
break
}
}
if (!hasResult && namespace === 'file' && transformHooks.length > 0) {
code = await Bun.file(id).text()
}
if (code !== undefined) {
const namespaceTransformHooks = namespace === 'file'
? transformHooks
: transformHooks.filter(h => h.plugin.name === namespace)
for (const { plugin, handler, filter } of namespaceTransformHooks) {
if (plugin.transformInclude && !plugin.transformInclude(id))
continue
if (!filter(id, code))
continue
const { mixedContext, errors, warnings } = createPluginContext(context)
const result: TransformResult = await handler.call(mixedContext, code, id)
for (const warning of warnings) {
console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message)
}
if (errors.length > 0) {
const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n')
throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`)
}
if (typeof result === 'string') {
code = result
hasResult = true
}
else if (typeof result === 'object' && result !== null) {
code = result.code
hasResult = true
}
}
}
if (hasResult && code !== undefined) {
return {
contents: code,
loader: loader ?? guessLoader(id),
}
}
}
if (loadHooks.length || transformHooks.length) {
build.onLoad({ filter: /.*/, namespace: 'file' }, async (args) => {
return processLoadTransform(args.path, 'file', args.loader)
})
}
for (const pluginName of virtualModulePlugins) {
build.onLoad({ filter: /.*/, namespace: pluginName }, async (args) => {
return processLoadTransform(args.path, pluginName, args.loader)
})
}
if (plugins.some(plugin => plugin.buildEnd || plugin.writeBundle)) {
build.onEnd(async () => {
for (const plugin of plugins) {
if (plugin.buildEnd) {
await plugin.buildEnd.call(context)
}
if (plugin.writeBundle) {
await plugin.writeBundle()
}
}
})
}
},
}
}
}

89
src/bun/utils.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Loader, PluginBuilder } from 'bun'
import type { UnpluginBuildContext, UnpluginContext, UnpluginMessage } from '../types'
import fs from 'node:fs'
import path from 'node:path'
import * as acorn from 'acorn'
const ExtToLoader: Record<string, Loader> = {
'.js': 'js',
'.mjs': 'js',
'.cjs': 'js',
'.jsx': 'jsx',
'.ts': 'ts',
'.cts': 'ts',
'.mts': 'ts',
'.tsx': 'tsx',
'.css': 'css',
'.less': 'css',
'.stylus': 'css',
'.scss': 'css',
'.sass': 'css',
'.json': 'json',
'.txt': 'text',
}
export function guessLoader(id: string): Loader {
return ExtToLoader[path.extname(id).toLowerCase()] || 'js'
}
export function createBuildContext(build: PluginBuilder): UnpluginBuildContext {
const watchFiles: string[] = []
return {
addWatchFile(file) {
watchFiles.push(file)
},
getWatchFiles() {
return watchFiles
},
emitFile(emittedFile) {
const outFileName = emittedFile.fileName || emittedFile.name
const outdir = build?.config?.outdir
if (outdir && emittedFile.source && outFileName) {
const outPath = path.resolve(outdir, outFileName)
const outDir = path.dirname(outPath)
if (!fs.existsSync(outDir))
fs.mkdirSync(outDir, { recursive: true })
fs.writeFileSync(outPath, emittedFile.source)
}
},
parse(code, opts = {}) {
return acorn.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
})
},
getNativeBuildContext() {
return { framework: 'bun', build }
},
}
}
export function createPluginContext(
buildContext: UnpluginBuildContext,
): {
errors: Array<string | UnpluginMessage>
warnings: Array<string | UnpluginMessage>
mixedContext: UnpluginBuildContext & UnpluginContext
} {
const errors: Array<string | UnpluginMessage> = []
const warnings: Array<string | UnpluginMessage> = []
const mixedContext: UnpluginBuildContext & UnpluginContext = {
...buildContext,
error(error) {
errors.push(error)
},
warn(warning) {
warnings.push(warning)
},
}
return {
errors,
warnings,
mixedContext,
}
}

View File

@ -1,4 +1,5 @@
import type { UnpluginFactory, UnpluginInstance } from './types'
import { getBunPlugin } from './bun'
import { getEsbuildPlugin } from './esbuild'
import { getFarmPlugin } from './farm'
import { getRolldownPlugin } from './rolldown'
@ -36,6 +37,9 @@ export function createUnplugin<UserOptions, Nested extends boolean = boolean>(
get unloader() {
return getUnloaderPlugin(factory)
},
get bun() {
return getBunPlugin(factory)
},
get raw() {
return factory
},
@ -89,3 +93,9 @@ export function createUnloaderPlugin<UserOptions, Nested extends boolean = boole
): UnpluginInstance<UserOptions>['unloader'] {
return getUnloaderPlugin(factory)
}
export function createBunPlugin<UserOptions, Nested extends boolean = boolean>(
factory: UnpluginFactory<UserOptions, Nested>,
): UnpluginInstance<UserOptions>['bun'] {
return getBunPlugin(factory)
}

22
src/globals.d.ts vendored
View File

@ -1,8 +1,14 @@
/**
* Flag that is replaced with a boolean during build time.
* __DEV__ is false in the final library output, and it is
* true when the library is ad-hoc transpiled, ie. during tests.
*
* See "tsdown.config.ts" and "vitest.config.ts" for more info.
*/
declare const __DEV__: boolean
import * as BunModule from 'bun'
declare global {
export import Bun = BunModule
/**
* Flag that is replaced with a boolean during build time.
* __DEV__ is false in the final library output, and it is
* true when the library is ad-hoc transpiled, ie. during tests.
*
* See "tsdown.config.ts" and "vitest.config.ts" for more info.
*/
declare const __DEV__: boolean
}

View File

@ -1,5 +1,7 @@
import type { CompilationContext as FarmCompilationContext, JsPlugin as FarmPlugin } from '@farmfe/core'
import type { Compilation as RspackCompilation, Compiler as RspackCompiler, LoaderContext as RspackLoaderContext, RspackPluginInstance } from '@rspack/core'
import type { Options as AcornOptions } from 'acorn'
import type { BunPlugin, PluginBuilder as BunPluginBuilder } from 'bun'
import type { BuildOptions, Plugin as EsbuildPlugin, Loader, PluginBuild } from 'esbuild'
import type { Plugin as RolldownPlugin } from 'rolldown'
import type { AstNode, EmittedAsset, PluginContextMeta as RollupContextMeta, Plugin as RollupPlugin, SourceMapInput } from 'rollup'
@ -9,6 +11,7 @@ import type { Compilation as WebpackCompilation, Compiler as WebpackCompiler, Lo
import type VirtualModulesPlugin from 'webpack-virtual-modules'
export type {
BunPlugin,
EsbuildPlugin,
RolldownPlugin,
RollupPlugin,
@ -52,12 +55,13 @@ export type NativeBuildContext
| { framework: 'esbuild', build: PluginBuild }
| { framework: 'rspack', compiler: RspackCompiler, compilation: RspackCompilation, loaderContext?: RspackLoaderContext | undefined }
| { framework: 'farm', context: FarmCompilationContext }
| { framework: 'bun', build: BunPluginBuilder }
export interface UnpluginBuildContext {
addWatchFile: (id: string) => void
emitFile: (emittedFile: EmittedAsset) => void
getWatchFiles: () => string[]
parse: (input: string, options?: any) => AstNode
parse: (input: string, options?: Partial<AcornOptions>) => AstNode
getNativeBuildContext?: (() => NativeBuildContext) | undefined
}
@ -142,6 +146,7 @@ export interface UnpluginOptions {
config?: ((options: BuildOptions) => void) | undefined
} | undefined
farm?: Partial<FarmPlugin> | undefined
bun?: Partial<BunPlugin> | undefined
}
export interface ResolvedUnpluginOptions extends UnpluginOptions {
@ -168,6 +173,7 @@ export interface UnpluginInstance<UserOptions, Nested extends boolean = boolean>
esbuild: UnpluginFactoryOutput<UserOptions, EsbuildPlugin>
unloader: UnpluginFactoryOutput<UserOptions, Nested extends true ? Array<UnloaderPlugin> : UnloaderPlugin>
farm: UnpluginFactoryOutput<UserOptions, FarmPlugin>
bun: UnpluginFactoryOutput<UserOptions, BunPlugin>
raw: UnpluginFactory<UserOptions, Nested>
}
@ -180,6 +186,10 @@ export type UnpluginContextMeta = Partial<RollupContextMeta> & ({
framework: 'esbuild'
/** Set the host plugin name of esbuild when returning multiple plugins */
esbuildHostName?: string | undefined
} | {
framework: 'bun'
/** Set the host plugin name of bun when returning multiple plugins */
bunHostName?: string | undefined
} | {
framework: 'rspack'
rspack: { compiler: RspackCompiler }

View File

@ -1,6 +1,7 @@
import fs from 'node:fs/promises'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { onlyBun } from '../../../utils'
const r = (...args: string[]) => resolve(__dirname, '../dist', ...args)
@ -38,4 +39,10 @@ describe('load-called-before-transform', () => {
expect(content).toContain('it is a msg -> through the load hook -> transform-[Injected Farm]')
})
onlyBun('bun', async () => {
const content = await fs.readFile(r('bun/main.js'), 'utf-8')
expect(content).toContain('it is a msg -> through the load hook -> transform-[Injected Bun]')
})
})

8
test/fixtures/load/bun.config.js vendored Normal file
View File

@ -0,0 +1,8 @@
const Bun = require('bun')
const { bun } = require('./unplugin')
await Bun.build({
entrypoints: ['./src/main.js'],
outdir: './dist/bun',
plugins: [bun({ msg: 'Bun' })],
})

View File

@ -1,6 +1,7 @@
import fs from 'node:fs/promises'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { onlyBun } from '../../../utils'
const r = (...args: string[]) => resolve(__dirname, '../dist', ...args)
@ -18,6 +19,7 @@ describe('transform build', () => {
expect(content).toContain('NON-TARGET: __UNPLUGIN__')
expect(content).toContain('TARGET: [Injected Post Rollup]')
// Query imports are external in Rollup
})
it('webpack', async () => {
@ -51,4 +53,12 @@ describe('transform build', () => {
expect(content).toContain('TARGET: [Injected Post Farm]')
expect(content).toContain('QUERY: [Injected Post Farm]')
})
onlyBun('bun', async () => {
const content = await fs.readFile(r('bun/main.js'), 'utf-8')
expect(content).toContain('NON-TARGET: __UNPLUGIN__')
expect(content).toContain('TARGET: [Injected Post Bun]')
// Like Rollup, imports with query params are marked as external in Bun
})
})

8
test/fixtures/transform/bun.config.js vendored Normal file
View File

@ -0,0 +1,8 @@
const Bun = require('bun')
const { bun } = require('./unplugin')
await Bun.build({
entrypoints: ['./src/main.js'],
outdir: './dist/bun',
plugins: [bun({ msg: 'Bun' })],
})

View File

@ -6,8 +6,8 @@ module.exports = createUnplugin((options, meta) => {
{
name: 'transform-fixture-pre',
resolveId(id) {
// Rollup doesn't know how to import module with query string so we ignore the module
if (id.includes('?query-param=query-value') && meta.framework === 'rollup') {
// Rollup and Bun don't know how to import module with query string so we ignore the module
if (id.includes('?query-param=query-value') && (meta.framework === 'rollup' || meta.framework === 'bun')) {
return {
id,
external: true,

View File

@ -1,6 +1,7 @@
import fs from 'node:fs/promises'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { onlyBun } from '../../../utils'
const r = (...args: string[]) => resolve(__dirname, '../dist', ...args)
@ -46,4 +47,11 @@ describe('virtual-module build', () => {
expect(content).toContain('VIRTUAL:ONE')
expect(content).toContain('VIRTUAL:TWO')
})
onlyBun('bun', async () => {
const content = await fs.readFile(r('bun/main.js'), 'utf-8')
expect(content).toContain('VIRTUAL:ONE')
expect(content).toContain('VIRTUAL:TWO')
})
})

View File

@ -0,0 +1,8 @@
const Bun = require('bun')
const { bun } = require('./unplugin')
await Bun.build({
entrypoints: ['./src/main.js'],
outdir: './dist/bun',
plugins: [bun()],
})

View File

@ -0,0 +1,51 @@
import { createUnplugin } from 'unplugin'
import { describe, expect, it } from 'vitest'
describe.skipIf(typeof Bun === 'undefined')('bun plugin', () => {
it('should export bun plugin', () => {
const unplugin = createUnplugin(() => ({
name: 'test-plugin',
}))
expect(unplugin.bun).toBeDefined()
expect(typeof unplugin.bun).toBe('function')
})
it('should create bun plugin with correct name', () => {
const unplugin = createUnplugin(() => ({
name: 'test-plugin',
}))
const bunPlugin = unplugin.bun()
expect(bunPlugin.name).toBe('test-plugin')
expect(bunPlugin.setup).toBeDefined()
expect(typeof bunPlugin.setup).toBe('function')
})
it('should handle options correctly', () => {
interface Options {
value: string
}
const unplugin = createUnplugin<Options>(options => ({
name: 'test-plugin',
buildStart() {
expect(options.value).toBe('test')
},
}))
const bunPlugin = unplugin.bun({ value: 'test' })
expect(bunPlugin).toBeDefined()
})
it('should support multiple plugins with host name', () => {
const unplugin = createUnplugin(() => [
{ name: 'plugin-1' },
{ name: 'plugin-2' },
])
const bunPlugin = unplugin.bun()
expect(bunPlugin.name).toBe('unplugin-host:plugin-1:plugin-2')
expect(bunPlugin.setup).toBeDefined()
})
})

View File

@ -0,0 +1,134 @@
import { createUnplugin } from 'unplugin'
import { describe, expect, it, vi } from 'vitest'
describe.skipIf(typeof Bun === 'undefined')('bun nested plugin support', () => {
it('should call buildStart for all nested plugins', async () => {
const buildStart1 = vi.fn()
const buildStart2 = vi.fn()
const unplugin = createUnplugin(() => [
{
name: 'plugin-1',
buildStart: buildStart1,
},
{
name: 'plugin-2',
buildStart: buildStart2,
},
])
const bunPlugin = unplugin.bun()
const mockBuild: Bun.PluginBuilder = {
onResolve: vi.fn(),
onLoad: vi.fn(),
onStart: vi.fn(callback => callback()),
config: { outdir: './dist' } as Bun.BuildConfig & { plugins: Bun.BunPlugin[] },
} as Partial<Bun.PluginBuilder> as Bun.PluginBuilder
await bunPlugin.setup(mockBuild)
expect(buildStart1).toHaveBeenCalledTimes(1)
expect(buildStart2).toHaveBeenCalledTimes(1)
})
it('should handle resolveId from multiple plugins', async () => {
const resolveId1 = vi.fn().mockResolvedValue('resolved-1')
const resolveId2 = vi.fn().mockResolvedValue('resolved-2')
const unplugin = createUnplugin(() => [
{
name: 'plugin-1',
resolveId: resolveId1,
},
{
name: 'plugin-2',
resolveId: resolveId2,
},
])
const bunPlugin = unplugin.bun()
const onResolveCallback = vi.fn()
const mockBuild = {
onResolve: vi.fn((options, callback) => {
onResolveCallback.mockImplementation(callback)
}),
onLoad: vi.fn(),
onStart: vi.fn(),
config: { outdir: './dist' },
} as never as Bun.PluginBuilder
await bunPlugin.setup(mockBuild)
expect(mockBuild.onResolve).toHaveBeenCalledWith(
{ filter: /.*/ },
expect.any(Function),
)
const result = await onResolveCallback({
path: 'test.js',
importer: 'index.js',
kind: 'import-statement',
})
expect(result).toEqual({ path: 'resolved-1', namespace: 'plugin-1' })
expect(resolveId1).toHaveBeenCalledWith(
'test.js',
'index.js',
{ isEntry: false },
)
expect(resolveId2).not.toHaveBeenCalled()
})
it('should handle transform from multiple plugins', async () => {
const transform1 = vi.fn((code: string) => `${code}\n// transformed by plugin-1`)
const transform2 = vi.fn((code: string) => `${code}\n// transformed by plugin-2`)
const unplugin = createUnplugin(() => [
{
name: 'plugin-1',
transform: transform1,
},
{
name: 'plugin-2',
transform: transform2,
},
])
const bunPlugin = unplugin.bun()
let onLoadCallback: Bun.OnLoadCallback
const mockBuild = {
onResolve: vi.fn(),
onLoad: vi.fn((options, callback) => {
if (!onLoadCallback) {
onLoadCallback = callback
}
}),
onStart: vi.fn(),
config: { outdir: './dist' },
} as never as Bun.PluginBuilder
const originalFile = Bun.file
Bun.file = vi.fn().mockReturnValue({
text: vi.fn().mockResolvedValue('original code'),
})
await bunPlugin.setup(mockBuild)
const result = await onLoadCallback!({
path: 'test.js',
loader: 'js',
} as Bun.OnLoadArgs)
expect(result).toEqual({
contents: 'original code\n// transformed by plugin-1\n// transformed by plugin-2',
loader: 'js',
})
expect(transform1).toHaveBeenCalledWith('original code', 'test.js')
expect(transform2).toHaveBeenCalledWith('original code\n// transformed by plugin-1', 'test.js')
Bun.file = originalFile
})
})

View File

@ -0,0 +1,295 @@
import type { PluginBuilder } from 'bun'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createBuildContext,
createPluginContext,
} from '../../../src/bun/utils'
vi.mock('node:fs')
vi.mock('node:path')
describe('bun utils', () => {
afterEach(() => {
vi.clearAllMocks()
})
describe('createBuildContext', () => {
it('should create build context with all required methods', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
expect(context.addWatchFile).toBeInstanceOf(Function)
expect(context.getWatchFiles).toBeInstanceOf(Function)
expect(context.emitFile).toBeInstanceOf(Function)
expect(context.parse).toBeInstanceOf(Function)
expect(context.getNativeBuildContext).toBeInstanceOf(Function)
})
it('should handle addWatchFile and getWatchFiles', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
expect(context.getWatchFiles()).toEqual([])
context.addWatchFile('file1.js')
context.addWatchFile('file2.js')
expect(context.getWatchFiles()).toEqual(['file1.js', 'file2.js'])
})
it('should emit file with fileName', () => {
const mockExistsSync = vi.mocked(fs.existsSync)
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockResolve = vi.mocked(path.resolve)
const mockDirname = vi.mocked(path.dirname)
mockExistsSync.mockReturnValue(true)
mockResolve.mockReturnValue('/path/to/outdir/output.js')
mockDirname.mockReturnValue('/path/to/outdir')
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
fileName: 'output.js',
source: 'console.log("hello")',
} as any)
expect(mockResolve).toHaveBeenCalledWith('/path/to/outdir', 'output.js')
expect(mockDirname).toHaveBeenCalledWith('/path/to/outdir/output.js')
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/path/to/outdir/output.js',
'console.log("hello")',
)
})
it('should emit file with name when fileName is not provided', () => {
const mockExistsSync = vi.mocked(fs.existsSync)
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockResolve = vi.mocked(path.resolve)
const mockDirname = vi.mocked(path.dirname)
mockExistsSync.mockReturnValue(true)
mockResolve.mockReturnValue('/path/to/outdir/output.js')
mockDirname.mockReturnValue('/path/to/outdir')
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
name: 'output.js',
source: 'console.log("hello")',
} as any)
expect(mockResolve).toHaveBeenCalledWith('/path/to/outdir', 'output.js')
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/path/to/outdir/output.js',
'console.log("hello")',
)
})
it('should create directory if it does not exist when emitting file', () => {
const mockExistsSync = vi.mocked(fs.existsSync)
const mockMkdirSync = vi.mocked(fs.mkdirSync)
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockResolve = vi.mocked(path.resolve)
const mockDirname = vi.mocked(path.dirname)
mockExistsSync.mockReturnValue(false)
mockResolve.mockReturnValue('/path/to/outdir/nested/output.js')
mockDirname.mockReturnValue('/path/to/outdir/nested')
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
fileName: 'nested/output.js',
source: 'console.log("hello")',
} as any)
expect(mockMkdirSync).toHaveBeenCalledWith('/path/to/outdir/nested', { recursive: true })
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/path/to/outdir/nested/output.js',
'console.log("hello")',
)
})
it('should handle Buffer source when emitting file', () => {
const mockExistsSync = vi.mocked(fs.existsSync)
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockResolve = vi.mocked(path.resolve)
const mockDirname = vi.mocked(path.dirname)
mockExistsSync.mockReturnValue(true)
mockResolve.mockReturnValue('/path/to/outdir/output.bin')
mockDirname.mockReturnValue('/path/to/outdir')
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
const bufferSource = Buffer.from('binary data')
context.emitFile({
type: 'asset',
fileName: 'output.bin',
source: bufferSource,
} as any)
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/path/to/outdir/output.bin',
bufferSource,
)
})
it('should not emit file when source is missing', () => {
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
fileName: 'output.js',
} as any)
expect(mockWriteFileSync).not.toHaveBeenCalled()
})
it('should not emit file when both fileName and name are missing', () => {
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
source: 'console.log("hello")',
} as any)
expect(mockWriteFileSync).not.toHaveBeenCalled()
})
it('should not emit file when outdir is not configured', () => {
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
const mockBuild = { config: {} }
const context = createBuildContext(mockBuild as PluginBuilder)
context.emitFile({
type: 'asset',
fileName: 'output.js',
source: 'console.log("hello")',
} as any)
expect(mockWriteFileSync).not.toHaveBeenCalled()
})
it('should parse code with acorn', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
const ast = context.parse('const x = 1')
expect(ast).toBeDefined()
expect(ast.type).toBe('Program')
expect((ast as any).body).toHaveLength(1)
expect((ast as any).body[0].type).toBe('VariableDeclaration')
})
it('should parse code with custom options', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
const ast = context.parse('const x = 1', {
sourceType: 'script',
ecmaVersion: 2015,
})
expect(ast).toBeDefined()
expect(ast.type).toBe('Program')
})
it('should return native build context', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const context = createBuildContext(mockBuild as PluginBuilder)
const nativeContext = context.getNativeBuildContext!()
expect(nativeContext).toEqual({
framework: 'bun',
build: mockBuild,
})
})
})
describe('createPluginContext', () => {
it('should create plugin context with error and warn methods', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const buildContext = createBuildContext(mockBuild as PluginBuilder)
const pluginContext = createPluginContext(buildContext)
expect(pluginContext.errors).toEqual([])
expect(pluginContext.warnings).toEqual([])
expect(pluginContext.mixedContext).toBeDefined()
expect(pluginContext.mixedContext.error).toBeInstanceOf(Function)
expect(pluginContext.mixedContext.warn).toBeInstanceOf(Function)
})
it('should collect errors when error is called', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const buildContext = createBuildContext(mockBuild as PluginBuilder)
const pluginContext = createPluginContext(buildContext)
pluginContext.mixedContext.error('Error message')
expect(pluginContext.errors).toHaveLength(1)
expect(pluginContext.errors[0]).toBe('Error message')
pluginContext.mixedContext.error('Another error')
expect(pluginContext.errors).toHaveLength(2)
expect(pluginContext.errors[1]).toBe('Another error')
})
it('should collect warnings when warn is called', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const buildContext = createBuildContext(mockBuild as PluginBuilder)
const pluginContext = createPluginContext(buildContext)
pluginContext.mixedContext.warn('Warning message')
expect(pluginContext.warnings).toHaveLength(1)
expect(pluginContext.warnings[0]).toBe('Warning message')
pluginContext.mixedContext.warn('Another warning')
expect(pluginContext.warnings).toHaveLength(2)
expect(pluginContext.warnings[1]).toBe('Another warning')
})
it('should include build context methods in mixed context', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const buildContext = createBuildContext(mockBuild as PluginBuilder)
const pluginContext = createPluginContext(buildContext)
expect(pluginContext.mixedContext.addWatchFile).toBeInstanceOf(Function)
expect(pluginContext.mixedContext.getWatchFiles).toBeInstanceOf(Function)
expect(pluginContext.mixedContext.emitFile).toBeInstanceOf(Function)
expect(pluginContext.mixedContext.parse).toBeInstanceOf(Function)
})
it('should handle complex error objects', () => {
const mockBuild = { config: { outdir: '/path/to/outdir' } }
const buildContext = createBuildContext(mockBuild as PluginBuilder)
const pluginContext = createPluginContext(buildContext)
const errorObj = {
message: 'Complex error',
code: 'ERR_001',
stack: 'Error stack trace',
}
pluginContext.mixedContext.error(errorObj)
expect(pluginContext.errors).toHaveLength(1)
expect(pluginContext.errors[0]).toEqual(errorObj)
})
})
})

1
test/unit-tests/filter/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test-out

View File

@ -3,6 +3,7 @@ import type { Mock } from 'vitest'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray } from '../utils'
function createUnpluginWithHooks(
@ -169,4 +170,19 @@ describe('filter', () => {
check(resolveIdHandler, loadHandler, transformHandler)
})
onlyBun('bun', async () => {
const { hook: resolveId, handler: resolveIdHandler } = createIdHook()
const { hook: load, handler: loadHandler } = createIdHook()
const { hook: transform, handler: transformHandler } = createTransformHook()
const plugin = createUnpluginWithHooks(resolveId, load, transform).bun
await build.bun({
entrypoints: [path.resolve(__dirname, 'test-src/entry.js')],
plugins: [plugin()],
outdir: path.resolve(__dirname, 'test-out/bun'),
})
check(resolveIdHandler, loadHandler, transformHandler)
})
})

View File

@ -0,0 +1 @@
test-out

View File

@ -3,6 +3,7 @@ import type { Mock } from 'vitest'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray } from '../utils'
const entryFilePath = path.resolve(__dirname, './test-src/entry.js')
@ -25,7 +26,7 @@ function createUnpluginWithCallback(
// We extract this check because all bundlers should behave the same
function checkHookCalls(
name: 'webpack' | 'rollup' | 'vite' | 'rspack' | 'esbuild',
name: 'webpack' | 'rollup' | 'vite' | 'rspack' | 'esbuild' | 'bun',
resolveIdCallback: Mock,
transformIncludeCallback: Mock,
transformCallback: Mock,
@ -213,4 +214,27 @@ describe('id parameter should be consistent across hooks and plugins', () => {
checkHookCalls('esbuild', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})
onlyBun('bun', async () => {
const mockResolveIdHook = vi.fn(() => undefined)
const mockTransformIncludeHook = vi.fn(() => true)
const mockTransformHook = vi.fn(() => undefined)
const mockLoadHook = vi.fn(() => undefined)
const plugin = createUnpluginWithCallback(
mockResolveIdHook,
mockTransformIncludeHook,
mockTransformHook,
mockLoadHook,
).bun
await build.bun({
entrypoints: [entryFilePath],
plugins: [plugin()],
external: externals,
outdir: path.resolve(__dirname, 'test-out/bun'),
})
checkHookCalls('bun', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})
})

View File

@ -0,0 +1 @@
test-out

View File

@ -2,6 +2,7 @@ import type { VitePlugin } from 'unplugin'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray } from '../utils'
const entryFilePath = path.resolve(__dirname, './test-src/entry.js')
@ -141,4 +142,17 @@ describe('load hook should not be called when resolveId hook returned `external:
checkHookCalls()
})
onlyBun('bun', async () => {
const plugin = createMockedUnplugin().bun
await build.bun({
entrypoints: [entryFilePath],
plugins: [plugin()],
external: externals,
outdir: path.resolve(__dirname, 'test-out/bun'),
})
checkHookCalls()
})
})

1
test/unit-tests/resolve-id/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test-out

View File

@ -3,6 +3,7 @@ import type { Mock } from 'vitest'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray } from '../utils'
function createUnpluginWithCallback(resolveIdCallback: UnpluginOptions['resolveId']) {
@ -138,4 +139,17 @@ describe('resolveId hook', () => {
checkResolveIdHook(mockResolveIdHook)
})
onlyBun('bun', async () => {
const mockResolveIdHook = createResolveIdHook()
const plugin = createUnpluginWithCallback(mockResolveIdHook).bun
await build.bun({
entrypoints: [path.resolve(__dirname, 'test-src/entry.js')],
plugins: [plugin()],
outdir: path.resolve(__dirname, 'test-out/bun'), // Bun requires outdir
})
checkResolveIdHook(mockResolveIdHook)
})
})

View File

@ -13,6 +13,11 @@ export const rolldownBuild: typeof rolldown.build = rolldown.build
export const esbuildBuild: typeof esbuild.build = esbuild.build
export const webpackBuild: typeof webpack.webpack = webpack.webpack || (webpack as any).default || webpack
export const rspackBuild: typeof rspack.rspack = rspack.rspack
export const bunBuild: typeof Bun.build = typeof Bun !== 'undefined'
? Bun.build
: () => {
throw new ReferenceError('Bun.build does not exist in this environment. Please run your app with the Bun runtime.')
}
export const webpackVersion: string = ((webpack as any).default || webpack).version
@ -23,6 +28,7 @@ export const build: {
rolldown: typeof rolldownBuild
vite: typeof viteBuild
esbuild: typeof esbuildBuild
bun: typeof bunBuild
} = {
webpack: webpackBuild,
rspack: rspackBuild,
@ -39,4 +45,5 @@ export const build: {
}))
},
esbuild: esbuildBuild,
bun: bunBuild,
}

View File

@ -4,6 +4,7 @@ import * as fs from 'node:fs'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray } from '../utils'
function createUnpluginWithCallbacks(resolveIdCallback: UnpluginOptions['resolveId'], loadCallback: UnpluginOptions['load']) {
@ -158,4 +159,18 @@ describe('virtual ids', () => {
checkResolveIdHook(mockResolveIdHook)
checkLoadHook(mockLoadHook)
})
onlyBun('bun', async () => {
const mockResolveIdHook = createResolveIdHook()
const mockLoadHook = createLoadHook()
const plugin = createUnpluginWithCallbacks(mockResolveIdHook, mockLoadHook).bun
await build.bun({
entrypoints: [path.resolve(__dirname, 'test-src/entry.js')],
plugins: [plugin()],
})
checkResolveIdHook(mockResolveIdHook)
checkLoadHook(mockLoadHook)
})
})

View File

@ -5,6 +5,7 @@ import * as fs from 'node:fs'
import * as path from 'node:path'
import { createUnplugin } from 'unplugin'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { onlyBun } from '../../utils'
import { build, toArray, webpackVersion } from '../utils'
function createUnpluginWithCallback(writeBundleCallback: UnpluginOptions['writeBundle']) {
@ -166,4 +167,20 @@ describe('writeBundle hook', () => {
checkWriteBundleHook(mockResolveIdHook)
})
onlyBun('bun', async () => {
expect.assertions(3)
const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/bun')))
const plugin = createUnpluginWithCallback(mockResolveIdHook).bun
await build.bun({
entrypoints: [path.resolve(__dirname, 'test-src/entry.js')],
plugins: [plugin()],
outdir: path.resolve(__dirname, 'test-out/bun'),
naming: 'output.[ext]',
sourcemap: 'external',
})
checkWriteBundleHook(mockResolveIdHook)
})
})

3
test/utils.ts Normal file
View File

@ -0,0 +1,3 @@
import { it } from 'vitest'
export const onlyBun: typeof it.only | typeof it.skip = typeof Bun !== 'undefined' ? it.only : it.skip

View File

@ -5,14 +5,10 @@
"moduleDetection": "force",
"module": "preserve",
"moduleResolution": "bundler",
"paths": {
"unplugin": [
"./src/index.ts"
]
},
"resolveJsonModule": true,
"types": [
"node"
"node",
"bun-types-no-globals"
],
"strict": true,
"noUnusedLocals": true,