Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: support bun plugin #333

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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions pnpm-lock.yaml

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

134 changes: 134 additions & 0 deletions src/bun/index.ts
Original file line number Diff line number Diff line change
@@ -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<UserOptions = Record<string, never>>(
factory: UnpluginFactory<UserOptions>,
): UnpluginInstance<UserOptions>['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 ?? /.*/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const onResolveFilter = plugin.esbuild?.onResolveFilter ?? /.*/
const onResolveFilter = plugin.bun?.onResolveFilter ?? /.*/

const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/
const onLoadFilter = plugin.bun?.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() }
}
}
135 changes: 135 additions & 0 deletions src/bun/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, Loader> = {
'.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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// but esbuild needs them, we fix the two methods
// but bun 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<DecodedSourceMap | EncodedSourceMap>,
): 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
}
Loading
Loading