diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7452d0e..fc58435 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/README.md b/README.md
index 2f7157f..5d3d800 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/.vitepress/constant.ts b/docs/.vitepress/constant.ts
index 6087adb..44771ab 100644
--- a/docs/.vitepress/constant.ts
+++ b/docs/.vitepress/constant.ts
@@ -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`
diff --git a/docs/README.md b/docs/README.md
index 09eaac1..b652793 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,7 +6,7 @@
Unplugin
-Unified plugin system, Support Vite, Rollup, webpack, esbuild, and more
+Unified plugin system, Support Vite, Rollup, webpack, esbuild, Bun, and more
diff --git a/docs/guide/index.md b/docs/guide/index.md
index 60d27fc..6b0fcc5 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -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) | ❌ 1 | ✅ | ✅ | ❌ 1 | ✅ | ✅ | ✅ |
-| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ 5 | ✅ | ✅ |
-| ~~`loadInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ |
-| ~~`transformInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ |
-| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
-| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)4 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Hook | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun |
+| --------------------------------------------------------------------------------- | :-------------: | :--: | :-----: | :-------------: | :-------------: | :--: | :------: | :-------------: |
+| [`enforce`](https://vite.dev/guide/api-plugin.html#plugin-ordering) | ❌ 1 | ✅ | ✅ | ❌ 1 | ✅ | ✅ | ✅ | ❌ |
+| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ 5 | ✅ | ✅ | ✅ |
+| ~~`loadInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | ✅ |
+| ~~`transformInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | ✅ |
+| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
+| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 6 |
+| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)4 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 6 |
::: 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)1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| [`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)1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| [`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`1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Rollup | Vite | webpack | Rspack | esbuild | Farm | Rolldown | Bun |
+| :--------------------: | :--: | :-----: | :----: | :-----: | :--: | :------: | :-: |
+| ✅ `>=3.1`1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
::: details Notice
@@ -409,6 +427,9 @@ export const unpluginFactory: UnpluginFactory = (
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 */)
```
diff --git a/docs/index.md b/docs/index.md
index 3fe6144..663cbcf 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,7 +5,7 @@ sidebar: false
hero:
name: Unplugin
text: The Unified
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
diff --git a/docs/public/features/bun.svg b/docs/public/features/bun.svg
new file mode 100644
index 0000000..7ef1500
--- /dev/null
+++ b/docs/public/features/bun.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/package.json b/package.json
index eef977e..cace4a8 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"@types/picomatch": "catalog:",
"ansis": "catalog:",
"bumpp": "catalog:",
+ "bun-types-no-globals": "catalog:",
"esbuild": "catalog:",
"eslint": "catalog:",
"eslint-plugin-format": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 154fa11..b8318bd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b897399..74c4df7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -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
diff --git a/scripts/buildFixtures.ts b/scripts/buildFixtures.ts
index 1323783..73017b6 100644
--- a/scripts/buildFixtures.ts
+++ b/scripts/buildFixtures.ts
@@ -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' })
diff --git a/src/bun/index.ts b/src/bun/index.ts
new file mode 100644
index 0000000..6cafde9
--- /dev/null
+++ b/src/bun/index.ts
@@ -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>(
+ factory: UnpluginFactory,
+): UnpluginInstance['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()
+ 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()
+ }
+ }
+ })
+ }
+ },
+ }
+ }
+}
diff --git a/src/bun/utils.ts b/src/bun/utils.ts
new file mode 100644
index 0000000..2d117fb
--- /dev/null
+++ b/src/bun/utils.ts
@@ -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 = {
+ '.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
+ warnings: Array
+ mixedContext: UnpluginBuildContext & UnpluginContext
+} {
+ const errors: Array = []
+ const warnings: Array = []
+
+ const mixedContext: UnpluginBuildContext & UnpluginContext = {
+ ...buildContext,
+ error(error) {
+ errors.push(error)
+ },
+ warn(warning) {
+ warnings.push(warning)
+ },
+ }
+
+ return {
+ errors,
+ warnings,
+ mixedContext,
+ }
+}
diff --git a/src/define.ts b/src/define.ts
index 15f1330..540b661 100644
--- a/src/define.ts
+++ b/src/define.ts
@@ -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(
get unloader() {
return getUnloaderPlugin(factory)
},
+ get bun() {
+ return getBunPlugin(factory)
+ },
get raw() {
return factory
},
@@ -89,3 +93,9 @@ export function createUnloaderPlugin['unloader'] {
return getUnloaderPlugin(factory)
}
+
+export function createBunPlugin(
+ factory: UnpluginFactory,
+): UnpluginInstance['bun'] {
+ return getBunPlugin(factory)
+}
diff --git a/src/globals.d.ts b/src/globals.d.ts
index f11e5cf..d8a1544 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -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
+}
diff --git a/src/types.ts b/src/types.ts
index 245e187..784200c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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) => AstNode
getNativeBuildContext?: (() => NativeBuildContext) | undefined
}
@@ -142,6 +146,7 @@ export interface UnpluginOptions {
config?: ((options: BuildOptions) => void) | undefined
} | undefined
farm?: Partial | undefined
+ bun?: Partial | undefined
}
export interface ResolvedUnpluginOptions extends UnpluginOptions {
@@ -168,6 +173,7 @@ export interface UnpluginInstance
esbuild: UnpluginFactoryOutput
unloader: UnpluginFactoryOutput : UnloaderPlugin>
farm: UnpluginFactoryOutput
+ bun: UnpluginFactoryOutput
raw: UnpluginFactory
}
@@ -180,6 +186,10 @@ export type UnpluginContextMeta = Partial & ({
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 }
diff --git a/test/fixtures/load/__test__/build.test.ts b/test/fixtures/load/__test__/build.test.ts
index e9e6427..504287d 100644
--- a/test/fixtures/load/__test__/build.test.ts
+++ b/test/fixtures/load/__test__/build.test.ts
@@ -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]')
+ })
})
diff --git a/test/fixtures/load/bun.config.js b/test/fixtures/load/bun.config.js
new file mode 100644
index 0000000..e37211f
--- /dev/null
+++ b/test/fixtures/load/bun.config.js
@@ -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' })],
+})
diff --git a/test/fixtures/transform/__test__/build.test.ts b/test/fixtures/transform/__test__/build.test.ts
index bd27983..fb3618a 100644
--- a/test/fixtures/transform/__test__/build.test.ts
+++ b/test/fixtures/transform/__test__/build.test.ts
@@ -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
+ })
})
diff --git a/test/fixtures/transform/bun.config.js b/test/fixtures/transform/bun.config.js
new file mode 100644
index 0000000..e37211f
--- /dev/null
+++ b/test/fixtures/transform/bun.config.js
@@ -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' })],
+})
diff --git a/test/fixtures/transform/unplugin.js b/test/fixtures/transform/unplugin.js
index a014dac..66d4a57 100644
--- a/test/fixtures/transform/unplugin.js
+++ b/test/fixtures/transform/unplugin.js
@@ -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,
diff --git a/test/fixtures/virtual-module/__test__/build.test.ts b/test/fixtures/virtual-module/__test__/build.test.ts
index fbc06b5..2d09bbe 100644
--- a/test/fixtures/virtual-module/__test__/build.test.ts
+++ b/test/fixtures/virtual-module/__test__/build.test.ts
@@ -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')
+ })
})
diff --git a/test/fixtures/virtual-module/bun.config.js b/test/fixtures/virtual-module/bun.config.js
new file mode 100644
index 0000000..7a29641
--- /dev/null
+++ b/test/fixtures/virtual-module/bun.config.js
@@ -0,0 +1,8 @@
+const Bun = require('bun')
+const { bun } = require('./unplugin')
+
+await Bun.build({
+ entrypoints: ['./src/main.js'],
+ outdir: './dist/bun',
+ plugins: [bun()],
+})
diff --git a/test/unit-tests/bun/index.test.ts b/test/unit-tests/bun/index.test.ts
new file mode 100644
index 0000000..374f67a
--- /dev/null
+++ b/test/unit-tests/bun/index.test.ts
@@ -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 => ({
+ 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()
+ })
+})
diff --git a/test/unit-tests/bun/nested.test.ts b/test/unit-tests/bun/nested.test.ts
new file mode 100644
index 0000000..7458a51
--- /dev/null
+++ b/test/unit-tests/bun/nested.test.ts
@@ -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 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
+ })
+})
diff --git a/test/unit-tests/bun/utils.test.ts b/test/unit-tests/bun/utils.test.ts
new file mode 100644
index 0000000..ed14c45
--- /dev/null
+++ b/test/unit-tests/bun/utils.test.ts
@@ -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)
+ })
+ })
+})
diff --git a/test/unit-tests/filter/.gitignore b/test/unit-tests/filter/.gitignore
new file mode 100644
index 0000000..44cb941
--- /dev/null
+++ b/test/unit-tests/filter/.gitignore
@@ -0,0 +1 @@
+test-out
diff --git a/test/unit-tests/filter/filter.test.ts b/test/unit-tests/filter/filter.test.ts
index 0281079..7977271 100644
--- a/test/unit-tests/filter/filter.test.ts
+++ b/test/unit-tests/filter/filter.test.ts
@@ -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)
+ })
})
diff --git a/test/unit-tests/id-consistency/.gitignore b/test/unit-tests/id-consistency/.gitignore
new file mode 100644
index 0000000..44cb941
--- /dev/null
+++ b/test/unit-tests/id-consistency/.gitignore
@@ -0,0 +1 @@
+test-out
diff --git a/test/unit-tests/id-consistency/id-consistency.test.ts b/test/unit-tests/id-consistency/id-consistency.test.ts
index 0b5e7af..07d1bf4 100644
--- a/test/unit-tests/id-consistency/id-consistency.test.ts
+++ b/test/unit-tests/id-consistency/id-consistency.test.ts
@@ -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)
+ })
})
diff --git a/test/unit-tests/resolve-id-external/.gitignore b/test/unit-tests/resolve-id-external/.gitignore
new file mode 100644
index 0000000..44cb941
--- /dev/null
+++ b/test/unit-tests/resolve-id-external/.gitignore
@@ -0,0 +1 @@
+test-out
diff --git a/test/unit-tests/resolve-id-external/resolve-id-external.test.ts b/test/unit-tests/resolve-id-external/resolve-id-external.test.ts
index 2c4a44d..4c727cb 100644
--- a/test/unit-tests/resolve-id-external/resolve-id-external.test.ts
+++ b/test/unit-tests/resolve-id-external/resolve-id-external.test.ts
@@ -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()
+ })
})
diff --git a/test/unit-tests/resolve-id/.gitignore b/test/unit-tests/resolve-id/.gitignore
new file mode 100644
index 0000000..44cb941
--- /dev/null
+++ b/test/unit-tests/resolve-id/.gitignore
@@ -0,0 +1 @@
+test-out
diff --git a/test/unit-tests/resolve-id/resolve-id.test.ts b/test/unit-tests/resolve-id/resolve-id.test.ts
index 6bd7def..818f260 100644
--- a/test/unit-tests/resolve-id/resolve-id.test.ts
+++ b/test/unit-tests/resolve-id/resolve-id.test.ts
@@ -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)
+ })
})
diff --git a/test/unit-tests/utils.ts b/test/unit-tests/utils.ts
index dfa6e26..f29c166 100644
--- a/test/unit-tests/utils.ts
+++ b/test/unit-tests/utils.ts
@@ -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,
}
diff --git a/test/unit-tests/virtual-id/virtual-id.test.ts b/test/unit-tests/virtual-id/virtual-id.test.ts
index 1b6367c..ee3485c 100644
--- a/test/unit-tests/virtual-id/virtual-id.test.ts
+++ b/test/unit-tests/virtual-id/virtual-id.test.ts
@@ -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)
+ })
})
diff --git a/test/unit-tests/write-bundle/write-bundle.test.ts b/test/unit-tests/write-bundle/write-bundle.test.ts
index 5157f04..8efc093 100644
--- a/test/unit-tests/write-bundle/write-bundle.test.ts
+++ b/test/unit-tests/write-bundle/write-bundle.test.ts
@@ -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)
+ })
})
diff --git a/test/utils.ts b/test/utils.ts
new file mode 100644
index 0000000..58d8de5
--- /dev/null
+++ b/test/utils.ts
@@ -0,0 +1,3 @@
+import { it } from 'vitest'
+
+export const onlyBun: typeof it.only | typeof it.skip = typeof Bun !== 'undefined' ? it.only : it.skip
diff --git a/tsconfig.json b/tsconfig.json
index 07caa1d..cc6a6ea 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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,