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 esbuild #46

Merged
merged 12 commits into from
Dec 28, 2021
45 changes: 34 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@ Currently supports:
- [Vite](https://vitejs.dev/)
- [Rollup](https://rollupjs.org/)
- [Webpack](https://webpack.js.org/)
- [esbuild](https://esbuild.github.io/)

## Hooks

`unplugin` extends the excellent [Rollup plugin API](https://rollupjs.org/guide/en/#plugins-overview) as the unified plugin interface and provides a compatible layer base on the build tools used with.

###### Supported

| Hook | Rollup | Vite | Webpack 4 | Webpack 5 |
| ---- | :----: | :--: | :-------: | :-------: |
| [`buildStart`](https://rollupjs.org/guide/en/#buildstart) | ✅ | ✅ | ✅ | ✅ |
| [`buildEnd`](https://rollupjs.org/guide/en/#buildend) | ✅ | ✅ | ✅ | ✅ |
| `transformInclude`* | ✅ | ✅ | ✅ | ✅ |
| [`transform`](https://rollupjs.org/guide/en/#transformers) | ✅ | ✅ | ✅ | ✅ |
| [`enforce`](https://rollupjs.org/guide/en/#enforce) | ❌\*\* | ✅ | ✅ | ✅ |
| [`resolveId`](https://rollupjs.org/guide/en/#resolveid) | ✅ | ✅ | ✅ | ✅ |
| [`load`](https://rollupjs.org/guide/en/#load) | ✅ | ✅ | ✅ | ✅ |
| Hook | Rollup | Vite | Webpack 4 | Webpack 5 | esbuild |
| ---- | :----: | :--: | :-------: | :-------: | :-----: |
| [`buildStart`](https://rollupjs.org/guide/en/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`buildEnd`](https://rollupjs.org/guide/en/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ |
| `transformInclude`* | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`transform`](https://rollupjs.org/guide/en/#transformers) | ✅ | ✅ | ✅ | ✅ | ✅\*\*\* |
| [`enforce`](https://rollupjs.org/guide/en/#enforce) | ❌\*\* | ✅ | ✅ | ✅ | ❌\*\* |
| [`resolveId`](https://rollupjs.org/guide/en/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`load`](https://rollupjs.org/guide/en/#load) | ✅ | ✅ | ✅ | ✅ | ✅\*\*\* |

- *: Webpack's id filter is outside of loader logic; an additional hook is needed for better perf on Webpack. In Rollup and Vite, this hook has been polyfilled to match the behaviors. See for following usage examples.
- **: Rollup does not support using `enforce` to control the order of plugins. Users need to maintain the order manually.
- **: Rollup and esbuild do not support using `enforce` to control the order of plugins. Users need to maintain the order manually.
- ***: Although esbuild can handle both JavaScript and CSS and many other file formats, you can only return JavaScript in `load` and `transform` results.

## Usage

Expand All @@ -52,6 +54,7 @@ export const unplugin = createUnplugin((options: UserOptions) => {
export const vitePlugin = unplugin.vite
export const rollupPlugin = unplugin.rollup
export const webpackPlugin = unplugin.webpack
export const esbuildPlugin = unplugin.esbuild
```

### Plugin Installation
Expand Down Expand Up @@ -93,14 +96,27 @@ module.exports = {
}
```

###### esbuild

```ts
// esbuild.config.js
import { build } from 'esbuild'

build({
plugins: [
require('./my-unplugin').esbuild({ /* options */ })
]
})
```

### Framework-specific Logic

While `unplugin` provides compatible layers for some hooks, the functionality of it is limited to the common subset of the build's plugins capability. For more advanced framework-specific usages, `unplugin` provides an escape hatch for that.

```ts
export const unplugin = createUnplugin((options: UserOptions, meta) => {

console.log(meta.framework) // 'vite' | 'rollup' | 'webpack'
console.log(meta.framework) // 'vite' | 'rollup' | 'webpack' | 'esbuild'

return {
// common unplugin hooks
Expand All @@ -120,6 +136,13 @@ export const unplugin = createUnplugin((options: UserOptions, meta) => {
},
webpack(compiler) {
// configure Webpack compiler
},
esbuild: {
// change the filter of onResolve and onLoad
onResolveFilter?: RegExp
onLoadFilter?: RegExp
// or you can completely replace the setup logic
setup?: EsbuildPlugin['setup']
}
}
})
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@
"webpack-virtual-modules": "^0.4.3"
},
"devDependencies": {
"@ampproject/remapping": "^1.0.2",
"@nuxtjs/eslint-config-typescript": "^7.0.2",
"@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13",
"@types/jest": "^27.0.2",
"@types/node": "^16.11.4",
"chalk": "^4.1.2",
"enhanced-resolve": "^5.8.3",
"esbuild": "^0.14.8",
"eslint": "^8.1.0",
"fast-glob": "^3.2.7",
"fs-extra": "^10.0.0",
Expand All @@ -54,11 +56,15 @@
"webpack-cli": "^4.9.1"
},
"peerDependencies": {
"esbuild": ">=0.13",
"rollup": "^2.50.0",
"vite": "^2.3.0",
"webpack": "4 || 5"
},
"peerDependenciesMeta": {
"esbuild": {
"optional": true
},
"rollup": {
"optional": true
},
Expand Down
2 changes: 2 additions & 0 deletions scripts/buildFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ async function run () {
execSync('npx rollup -c', { cwd: path, stdio: 'inherit' })
console.log(c.blue.inverse.bold`\n Webpack `, name, '\n')
execSync('npx webpack', { cwd: path, stdio: 'inherit' })
console.log(c.yellow.inverse.bold`\n Esbuild `, name, '\n')
execSync('node esbuild.config.js', { cwd: path, stdio: 'inherit' })
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/define.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getEsbuildPlugin } from './esbuild'
import { getRollupPlugin } from './rollup'
import { UnpluginInstance, UnpluginFactory } from './types'
import { getVitePlugin } from './vite'
Expand All @@ -7,6 +8,9 @@ export function createUnplugin<UserOptions = {}> (
factory: UnpluginFactory<UserOptions>
): UnpluginInstance<UserOptions> {
return {
get esbuild () {
return getEsbuildPlugin(factory)
},
get rollup () {
return getRollupPlugin(factory)
},
Expand Down
126 changes: 126 additions & 0 deletions src/esbuild/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import fs from 'fs'
import path from 'path'
import type { PartialMessage } from 'esbuild'
import type { SourceMap } from 'rollup'
import type { RawSourceMap } from '@ampproject/remapping/dist/types/types'
import type { UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types'
import { combineSourcemaps, fixSourceMap, guessLoader } from './utils'

export function getEsbuildPlugin <UserOptions = {}> (
factory: UnpluginFactory<UserOptions>
): UnpluginInstance<UserOptions>['esbuild'] {
return (userOptions?: UserOptions) => {
const meta: UnpluginContextMeta = {
framework: 'esbuild'
}
const plugin = factory(userOptions, meta)

return {
name: plugin.name,
setup:
plugin.esbuild?.setup ??
function setup ({ onStart, onEnd, onResolve, onLoad }) {
const onResolveFilter = plugin.esbuild?.onResolveFilter ?? /.*/
const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/

if (plugin.buildStart) {
onStart(plugin.buildStart)
}

if (plugin.buildEnd) {
onEnd(plugin.buildEnd)
}

if (plugin.resolveId) {
onResolve({ filter: onResolveFilter }, async (args) => {
const result = await plugin.resolveId!(args.path, args.importer)
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 errors: PartialMessage[] = []
const warnings: PartialMessage[] = []
const pluginContext: UnpluginContext = {
error (message) { errors.push({ text: String(message) }) },
warn (message) { warnings.push({ text: String(message) }) }
}
// because we use `namespace` to simulate virtual modules,
// it is required to forward `resolveDir` for esbuild to find dependencies.
const resolveDir = path.dirname(args.path)

let code: string | undefined, map: SourceMap | null | undefined

if (plugin.load) {
const result = await plugin.load.call(pluginContext, args.path)
if (typeof result === 'string') {
code = result
} else if (typeof result === 'object' && result !== null) {
code = result.code
map = result.map
}
}

if (!plugin.transform) {
if (code === undefined) {
return null
}
if (map) {
// fix missing sourcesContent, esbuild depends on it
if (!map.sourcesContent || map.sourcesContent.length === 0) {
map.sourcesContent = [code]
}
map = fixSourceMap(map as RawSourceMap)
code += `\n//# sourceMappingURL=${map.toUrl()}`
}
return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir }
}

if (!plugin.transformInclude || plugin.transformInclude(args.path)) {
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(pluginContext, code, args.path)
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
}
}
}

if (code) {
if (map) {
if (!map.sourcesContent || map.sourcesContent.length === 0) {
map.sourcesContent = [code]
}
map = fixSourceMap(map as RawSourceMap)
code += `\n//# sourceMappingURL=${map.toUrl()}`
}
return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir }
}
})
}
}
}
}
}
89 changes: 89 additions & 0 deletions src/esbuild/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { extname } from 'path'
import remapping from '@ampproject/remapping'
import type {
DecodedSourceMap,
RawSourceMap
} from '@ampproject/remapping/dist/types/types'
import type { Loader } from 'esbuild'
import type { SourceMap } from 'rollup'

const ExtToLoader: Record<string, Loader> = {
'.js': 'js',
'.mjs': 'js',
'.cjs': 'js',
'.jsx': 'jsx',
'.ts': 'ts',
'.cts': 'ts',
'.mts': 'ts',
'.tsx': 'tsx',
'.css': 'css',
'.json': 'json',
'.txt': 'text'
}

export function guessLoader (id: string): Loader {
return ExtToLoader[extname(id).toLowerCase()] || 'js'
}

// `load` and `transform` may return a sourcemap without toString and toUrl,
// but esbuild needs them, we fix the two methods
export function fixSourceMap (map: RawSourceMap): SourceMap {
Object.defineProperty(map, 'toString', {
enumerable: false,
value: function toString () {
return JSON.stringify(this)
}
})
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: RawSourceMap = {
names: [],
sources: [],
mappings: '',
version: 3
}
export function combineSourcemaps (
filename: string,
sourcemapList: Array<DecodedSourceMap | RawSourceMap>
): RawSourceMap {
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 RawSourceMap
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],
function loader (sourcefile) {
if (sourcefile === filename && sourcemapList[mapIndex]) {
return sourcemapList[mapIndex++]
} else {
return { ...nullSourceMap }
}
},
true
)
}
if (!map.file) {
delete map.file
}

return map as RawSourceMap
}
Loading