diff --git a/package.json b/package.json index 4314c9c7..d987311b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "@types/node": "^20.6.2", "@types/webpack-sources": "^3.2.0", "bumpp": "^9.2.0", + "bun": "^1.0.0", + "bun-types": "^1.0.1", "conventional-changelog-cli": "^3.0.0", "esbuild": "^0.19.3", "eslint": "^8.49.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 210c5a9b..d59845f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,12 @@ devDependencies: bumpp: specifier: ^9.2.0 version: 9.2.0 + bun: + specifier: ^1.0.0 + version: 1.0.0 + bun-types: + specifier: ^1.0.1 + version: 1.0.1 conventional-changelog-cli: specifier: ^3.0.0 version: 3.0.0 @@ -791,6 +797,54 @@ packages: fastq: 1.13.0 dev: true + /@oven/bun-darwin-aarch64@1.0.0: + resolution: {integrity: sha512-aFwqiriquRp2+LOZfLP1sUMRO9ENRGEZuKdhGyd4MeHY9gHZTdPWLqY7ULv91G8jcIrBLGwik6LM28ntuEWodw==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@oven/bun-darwin-x64-baseline@1.0.0: + resolution: {integrity: sha512-3TzDx34X+PnJJ6vBVhbJ0kNQBC3eeii1RJgHWxuOjLbtP3Jri8KEBoqsbIMGZz7E0hGnr/qzM+WDWf/QcOE3wg==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@oven/bun-darwin-x64@1.0.0: + resolution: {integrity: sha512-DrsSsN94iZPRZmreixDhudoekmDhayd5YF3QEfW8x6orkkHkGhso65AbIn2ZFl0dciuDOV9T7mx2vY03UWwgHA==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@oven/bun-linux-aarch64@1.0.0: + resolution: {integrity: sha512-sVnmDDenV6yx0ob3r06NI8+QvKYxjwGU8b2WGgz4sDnO5n5yt75vEAWOCyXQcfLFfaLgobsdqVCwUP1zRK/w9g==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oven/bun-linux-x64-baseline@1.0.0: + resolution: {integrity: sha512-McMbKzIaKbQfEcaVC1RYlLmjtrwDZQrmK2mcPyvefb8FOO8Uexk3VyjuNTSJkmJXgX/rqMHB4jGa8YQHxyHBBQ==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oven/bun-linux-x64@1.0.0: + resolution: {integrity: sha512-mTSy9cerd2dlNgUDQ8fUVmB8fMyshr0KEEwT6hFsPi0ujaT4TVs7JRxbVftsAEb9/j825t3TxXS2O7NC36Di+Q==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@pmmmwh/react-refresh-webpack-plugin@0.5.10(react-refresh@0.14.0)(webpack-dev-server@4.13.1)(webpack@5.76.0): resolution: {integrity: sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==} engines: {node: '>= 10.13'} @@ -2006,6 +2060,25 @@ packages: - supports-color dev: true + /bun-types@1.0.1: + resolution: {integrity: sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw==} + dev: true + + /bun@1.0.0: + resolution: {integrity: sha512-LNqtzM5lg/dHfwORsOEqMNFQUIiYA+txJ8uBUjrgDCHzOPjOzpMQt4ifLEDGj50498WiCXiTdGZfC/BsTa6Pmg==} + cpu: [arm64, x64] + os: [darwin, linux] + hasBin: true + requiresBuild: true + optionalDependencies: + '@oven/bun-darwin-aarch64': 1.0.0 + '@oven/bun-darwin-x64': 1.0.0 + '@oven/bun-darwin-x64-baseline': 1.0.0 + '@oven/bun-linux-aarch64': 1.0.0 + '@oven/bun-linux-x64': 1.0.0 + '@oven/bun-linux-x64-baseline': 1.0.0 + dev: true + /bundle-require@4.0.1(esbuild@0.18.11): resolution: {integrity: sha512-9NQkRHlNdNpDBGmLpngF3EFDcwodhMUuLz9PaWYciVcQF9SE4LFjM2DB/xV1Li5JiuDMv7ZUWuC3rGbqR0MAXQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} diff --git a/src/bun/index.ts b/src/bun/index.ts new file mode 100644 index 00000000..9b88c821 --- /dev/null +++ b/src/bun/index.ts @@ -0,0 +1,134 @@ +import fs from 'fs' +import type { SourceMap } from 'rollup' +import type { RawSourceMap } from '@ampproject/remapping' +import type { BunPlugin, UnpluginBuildContext, UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, UnpluginOptions } from '../types' +import { combineSourcemaps, createBunContext, guessLoader, processCodeWithSourceMap, toArray } from './utils' + +let i = 0 + +export function getBunPlugin>( + factory: UnpluginFactory, +): UnpluginInstance['bun'] { + return (userOptions?: UserOptions): BunPlugin => { + const meta: UnpluginContextMeta = { + framework: 'bun', + } + const plugins = toArray(factory(userOptions!, meta)) + + const setup = (plugin: UnpluginOptions): BunPlugin['setup'] => + plugin.bun?.setup + ?? ((build) => { + meta.build = build + const { onResolve, onLoad, config: initialOptions } = build + + const onResolveFilter = plugin.esbuild?.onResolveFilter ?? /.*/ + const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/ + + const context: UnpluginBuildContext = createBunContext(initialOptions) + + if (plugin.resolveId) { + onResolve({ filter: onResolveFilter }, async (args) => { + if (initialOptions.external?.includes(args.path)) { + // We don't want to call the `resolveId` hook for external modules, since rollup doesn't do + // that and we want to have consistent behaviour across bundlers + return undefined + } + + const isEntry = args.kind === 'entry-point' + const result = await plugin.resolveId!( + args.path, + // We explicitly have this if statement here for consistency with the integration of other bundelers. + // Here, `args.importer` is just an empty string on entry files whereas the euqivalent on other bundlers is `undefined.` + isEntry ? undefined : args.importer, + { isEntry }, + ) + if (typeof result === 'string') + return { path: result, namespace: plugin.name } + else if (typeof result === 'object' && result !== null) + return { path: result.id, external: result.external, namespace: plugin.name } + }) + } + + if (plugin.load || plugin.transform) { + onLoad({ filter: onLoadFilter }, async (args) => { + const id = args.path + + let code: string | undefined, map: SourceMap | null | undefined + + if (plugin.load && (!plugin.loadInclude || plugin.loadInclude(id))) { + const result = await plugin.load.call(context as UnpluginBuildContext & UnpluginContext, id) + if (typeof result === 'string') { + code = result + } + else if (typeof result === 'object' && result !== null) { + code = result.code + map = result.map as any + } + } + + if (!plugin.transform) { + if (code === undefined) { + return { + contents: '', + } + } + + if (map) + code = processCodeWithSourceMap(map, code) + + return { contents: code, loader: guessLoader(args.path) } + } + + if (!plugin.transformInclude || plugin.transformInclude(id)) { + if (!code) { + // caution: 'utf8' assumes the input file is not in binary. + // if you want your plugin handle binary files, make sure to + // `plugin.load()` them first. + code = await fs.promises.readFile(args.path, 'utf8') + } + + const result = await plugin.transform.call(context as UnpluginBuildContext & UnpluginContext, code, id) + if (typeof result === 'string') { + code = result + } + else if (typeof result === 'object' && result !== null) { + code = result.code + // if we already got sourcemap from `load()`, + // combine the two sourcemaps + if (map && result.map) { + map = combineSourcemaps(args.path, [ + result.map as RawSourceMap, + map as RawSourceMap, + ]) as SourceMap + } + else { + // otherwise, we always keep the last one, even if it's empty + map = result.map as any + } + } + } + + if (code) { + if (map) + code = processCodeWithSourceMap(map, code) + return { contents: code, loader: guessLoader(args.path) } + } + + return { + contents: '', + } + }) + } + }) + + const setupMultiplePlugins = (): BunPlugin['setup'] => + (build) => { + for (const plugin of plugins) + setup(plugin)(build) + } + + return plugins.length === 1 + ? { name: plugins[0].name, setup: setup(plugins[0]) } + : { name: meta.bunHostName ?? `unplugin-host-${i++}`, setup: setupMultiplePlugins() } + } +} diff --git a/src/bun/utils.ts b/src/bun/utils.ts new file mode 100644 index 00000000..7df8c781 --- /dev/null +++ b/src/bun/utils.ts @@ -0,0 +1,135 @@ +import fs from 'fs' +import path from 'path' +import { Buffer } from 'buffer' +import remapping from '@ampproject/remapping' +import { Parser } from 'acorn' +import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping' +import type { Loader, PluginBuilder } from 'bun' +import type { SourceMap } from 'rollup' +import type { UnpluginBuildContext } from '../types' + +export * from '../utils' + +const ExtToLoader: Record = { + '.js': 'js', + '.mjs': 'js', + '.cjs': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.cts': 'ts', + '.mts': 'ts', + '.tsx': 'tsx', + '.json': 'json', + '.txt': 'text', + '.toml': 'toml', + '.node': 'napi', +} + +export function guessLoader(id: string): Loader { + return ExtToLoader[path.extname(id).toLowerCase()] || 'file' +} + +// `load` and `transform` may return a sourcemap without toString and toUrl, +// but esbuild needs them, we fix the two methods +export function fixSourceMap(map: EncodedSourceMap): SourceMap { + if (!('toString' in map)) { + Object.defineProperty(map, 'toString', { + enumerable: false, + value: function toString() { + return JSON.stringify(this) + }, + }) + } + if (!('toUrl' in map)) { + Object.defineProperty(map, 'toUrl', { + enumerable: false, + value: function toUrl() { + return `data:application/json;charset=utf-8;base64,${Buffer.from(this.toString()).toString('base64')}` + }, + }) + } + return map as SourceMap +} + +// taken from https://github.com/vitejs/vite/blob/71868579058512b51991718655e089a78b99d39c/packages/vite/src/node/utils.ts#L525 +const nullSourceMap: EncodedSourceMap = { + names: [], + sources: [], + mappings: '', + version: 3, +} +export function combineSourcemaps( + filename: string, + sourcemapList: Array, +): EncodedSourceMap { + sourcemapList = sourcemapList.filter(m => m.sources) + + if ( + sourcemapList.length === 0 + || sourcemapList.every(m => m.sources.length === 0) + ) + return { ...nullSourceMap } + + // We don't declare type here so we can convert/fake/map as EncodedSourceMap + let map // : SourceMap + let mapIndex = 1 + const useArrayInterface + = sourcemapList.slice(0, -1).find(m => m.sources.length !== 1) === undefined + if (useArrayInterface) { + map = remapping(sourcemapList, () => null, true) + } + else { + map = remapping( + sourcemapList[0], + (sourcefile) => { + if (sourcefile === filename && sourcemapList[mapIndex]) + return sourcemapList[mapIndex++] + else + return { ...nullSourceMap } + }, + true, + ) + } + if (!map.file) + delete map.file + + return map as EncodedSourceMap +} + +export function createBunContext(initialOptions: PluginBuilder['config']): UnpluginBuildContext { + return { + parse(code: string, opts: any = {}) { + return Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }) + }, + addWatchFile() { + }, + emitFile(emittedFile) { + // Ensure output directory exists for this.emitFile + if (initialOptions.outdir && !fs.existsSync(initialOptions.outdir)) + fs.mkdirSync(initialOptions.outdir, { recursive: true }) + + const outFileName = emittedFile.fileName || emittedFile.name + if (initialOptions.outdir && emittedFile.source && outFileName) + fs.writeFileSync(path.resolve(initialOptions.outdir, outFileName), emittedFile.source) + }, + getWatchFiles() { + return [] + }, + } +} + +export function processCodeWithSourceMap(map: SourceMap | null | undefined, code: string) { + if (map) { + if (!map.sourcesContent || map.sourcesContent.length === 0) + map.sourcesContent = [code] + + map = fixSourceMap(map as EncodedSourceMap) + code += `\n//# sourceMappingURL=${map.toUrl()}` + } + return code +} diff --git a/src/define.ts b/src/define.ts index 4b7e73cf..c6d73be8 100644 --- a/src/define.ts +++ b/src/define.ts @@ -1,3 +1,4 @@ +import { getBunPlugin } from './bun' import { getEsbuildPlugin } from './esbuild' import { getRollupPlugin } from './rollup' import { getRspackPlugin } from './rspack' @@ -25,6 +26,10 @@ export function createUnplugin( get rspack() { return getRspackPlugin(factory) }, + /** @experimental do not use it in production */ + get bun() { + return getBunPlugin(factory) + }, get raw() { return factory }, @@ -37,6 +42,12 @@ export function createEsbuildPlugin( + factory: UnpluginFactory, +) { + return getBunPlugin(factory) +} + export function createRollupPlugin( factory: UnpluginFactory, ) { diff --git a/src/types.ts b/src/types.ts index b3a3bea6..d1ff0408 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,11 @@ import type { Plugin as VitePlugin } from 'vite' import type { Plugin as EsbuildPlugin, Loader, PluginBuild } from 'esbuild' import type { Compiler as RspackCompiler, RspackPluginInstance } from '@rspack/core' import type VirtualModulesPlugin from 'webpack-virtual-modules' +import type { BunPlugin, PluginBuilder } from 'bun' export { EsbuildPlugin, + BunPlugin, RollupPlugin, VitePlugin, WebpackPluginInstance, @@ -77,6 +79,11 @@ export interface UnpluginOptions { setup?: EsbuildPlugin['setup'] loader?: Loader | ((code: string, id: string) => Loader) } + bun?: { + onResolveFilter?: RegExp + onLoadFilter?: RegExp + setup?: BunPlugin['setup'] + } } export interface ResolvedUnpluginOptions extends UnpluginOptions { @@ -100,6 +107,7 @@ export interface UnpluginInstance webpack: UnpluginFactoryOutput rspack: UnpluginFactoryOutput esbuild: UnpluginFactoryOutput + bun: UnpluginFactoryOutput raw: UnpluginFactory } @@ -115,6 +123,11 @@ export type UnpluginContextMeta = Partial & ({ build?: PluginBuild /** Set the host plugin name of esbuild when returning multiple plugins */ esbuildHostName?: string +} | { + framework: 'bun' + build?: PluginBuilder + /** Set the host plugin name of esbuild when returning multiple plugins */ + bunHostName?: string } | { framework: 'rspack' rspack: { diff --git a/test/unit-tests/id-consistency/id-consistency.test.ts b/test/unit-tests/id-consistency/id-consistency.test.ts index 05b4df77..c9c90da9 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 { afterEach, describe, expect, it, vi } from 'vitest' import type { UnpluginOptions, VitePlugin } from 'unplugin' import { createUnplugin } from 'unplugin' +import Bun from 'bun' 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, @@ -207,4 +208,28 @@ describe('id parameter should be consistent accross hooks and plugins', () => { checkHookCalls('esbuild', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook) }) + + it('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 Bun.build({ + entrypoints: [entryFilePath], + plugins: [plugin()], + // @ts-expect-error - documentation mentions outdir can be false, but types don't allow it + outdir: false, + external: externals, + }) + + checkHookCalls('bun', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook) + }) }) diff --git a/test/unit-tests/write-bundle/write-bundle.test.ts b/test/unit-tests/write-bundle/write-bundle.test.ts index 0bacb9f2..665bdbbc 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 { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { UnpluginOptions, VitePlugin } from 'unplugin' import { createUnplugin } from 'unplugin' import type { RspackOptions } from '@rspack/core' +import Bun from 'bun' import { build, toArray, webpackVersion } from '../utils' function createUnpluginWithCallback(writeBundleCallback: UnpluginOptions['writeBundle']) { @@ -168,4 +169,21 @@ describe('writeBundle hook', () => { checkWriteBundleHook(mockResolveIdHook) }) + + it('build', async () => { + expect.assertions(3) + const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/bun'))) + const plugin = createUnpluginWithCallback(mockResolveIdHook).bun + + await Bun.build({ + entrypoints: [path.resolve(__dirname, 'test-src/entry.js')], + plugins: [plugin()], + // @ts-expect-error - documentation mentions outfile can be passed, but types don't allow it + outfile: path.resolve(__dirname, 'test-out/bun/output.js'), + format: 'esm', + sourcemap: 'inline', + }) + + checkWriteBundleHook(mockResolveIdHook) + }) }) diff --git a/tsconfig.json b/tsconfig.json index 34c5b8ad..34a8cdca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "declaration": true, "resolveJsonModule": true, "types": [ - "node" + "node", + "bun-types" ], "paths": { "unplugin": [ diff --git a/tsup.config.ts b/tsup.config.ts index 8fafdd81..dad137d9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -19,6 +19,7 @@ export const tsup: Options = { 'webpack', 'rollup', 'esbuild', + 'bun', ], define: { __DEV__: 'false',