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: esbuild support for addWatchFile and getWatchFiles #345

Merged
merged 13 commits into from
Dec 26, 2023
70 changes: 46 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,17 @@ Currently supports:

###### Supported

| Hook | Rollup | Vite | Webpack 4 | Webpack 5 | esbuild | Rspack |
| -------------------------------------------------------------------------- | :----: | :--: | :-------: | :-------: | :-----: | :----: |
| [`this.parse`](https://rollupjs.org/guide/en/#thisparse) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisaddwatchfile) | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ |
| [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile)<sup>5</sup> | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ |
| [`this.warn`](https://rollupjs.org/guide/en/#thiswarn) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.error`](https://rollupjs.org/guide/en/#thiserror) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| Hook | Rollup | Vite | Webpack 4 | Webpack 5 | esbuild | Rspack |
| -------------------------------------------------------------------------- | :----: | :--: | :-------: | :-------: | :------------: | :----: |
| [`this.parse`](https://rollupjs.org/guide/en/#thisparse) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisaddwatchfile) | βœ… | βœ… | βœ… | βœ… | βœ…<sup>6</sup> | ❌ |
| [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile)<sup>5</sup> | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) | βœ… | βœ… | βœ… | βœ… | βœ…<sup>6</sup> | ❌ |
| [`this.warn`](https://rollupjs.org/guide/en/#thiswarn) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |
| [`this.error`](https://rollupjs.org/guide/en/#thiserror) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… |

5. Currently, [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile) only supports the `EmittedAsset` variant.
6. Currently, in esbuild, [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisgetwatchfiles) and [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) are supported only within `resolveId`, `load`, and `transform` hooks; and [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) returns an array of only the files explicitly watched via [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisaddwatchfile) during the same resolve step (`resolveId` hook) or load step (`load` and `transform` hooks).

## Usage

Expand Down Expand Up @@ -129,7 +130,9 @@ import UnpluginFeature from './unplugin-feature'

export default {
plugins: [
UnpluginFeature.vite({ /* options */ }),
UnpluginFeature.vite({
/* options */
}),
],
}
```
Expand All @@ -142,7 +145,9 @@ import UnpluginFeature from './unplugin-feature'

export default {
plugins: [
UnpluginFeature.rollup({ /* options */ }),
UnpluginFeature.rollup({
/* options */
}),
],
}
```
Expand All @@ -153,7 +158,9 @@ export default {
// webpack.config.js
module.exports = {
plugins: [
require('./unplugin-feature').webpack({ /* options */ }),
require('./unplugin-feature').webpack({
/* options */
}),
],
}
```
Expand All @@ -166,19 +173,22 @@ import { build } from 'esbuild'

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


###### Rspack

```ts
// rspack.config.js
module.exports = {
plugins: [
require('./unplugin-feature').rspack({ /* options */ }),
require('./unplugin-feature').rspack({
/* options */
}),
],
}
```
Expand All @@ -194,8 +204,12 @@ export const unplugin = createUnplugin((options: UserOptions, meta) => {
return {
// Common unplugin hooks
name: 'unplugin-prefixed-name',
transformInclude(id) { /* ... */ },
transform(code) { /* ... */ },
transformInclude(id) {
/* ... */
},
transform(code) {
/* ... */
},

// Framework specific hooks
vite: {
Expand All @@ -218,11 +232,9 @@ export const unplugin = createUnplugin((options: UserOptions, meta) => {
// Change the filter of onResolve and onLoad
// onResolveFilter?: RegExp,
// onLoadFilter?: RegExp,

// Tell esbuild how to interpret the contents. By default unplugin tries to guess the loader
// from file extension (eg: .js -> "js", .jsx -> 'jsx')
// loader?: (Loader | (code: string, id: string) => Loader)

// Or you can completely replace the setup logic
// setup?: EsbuildPlugin.setup,
},
Expand All @@ -241,14 +253,24 @@ import {
createRollupPlugin,
createRspackPlugin,
createVitePlugin,
createWebpackPlugin
createWebpackPlugin,
} from 'unplugin'

const vitePlugin = createVitePlugin({ /* options */ })
const rollupPlugin = createRollupPlugin({ /* options */ })
const esbuildPlugin = createEsbuildPlugin({ /* options */ })
const webpackPlugin = createWebpackPlugin({ /* options */ })
const rspackPlugin = createRspackPlugin({ /* options */ })
const vitePlugin = createVitePlugin({
/* options */
})
const rollupPlugin = createRollupPlugin({
/* options */
})
const esbuildPlugin = createEsbuildPlugin({
/* options */
})
const webpackPlugin = createWebpackPlugin({
/* options */
})
const rspackPlugin = createRspackPlugin({
/* options */
})
edemaine marked this conversation as resolved.
Show resolved Hide resolved
```

## Conventions
Expand Down
69 changes: 48 additions & 21 deletions src/esbuild/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import fs from 'fs'
import path from 'path'
import type { PartialMessage } from 'esbuild'
import type { SourceMap } from 'rollup'
import type { RawSourceMap } from '@ampproject/remapping'
import type { EsbuildPlugin, UnpluginBuildContext, UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, UnpluginOptions } from '../types'
import { combineSourcemaps, createEsbuildContext, guessLoader, processCodeWithSourceMap, toArray, unwrapLoader } from './utils'
import type { EsbuildPlugin, UnpluginBuildContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, UnpluginOptions } from '../types'
import { combineSourcemaps, createBuildContext, createPluginContext, guessLoader, processCodeWithSourceMap, toArray, unwrapLoader } from './utils'

let i = 0

Expand All @@ -27,7 +26,7 @@ export function getEsbuildPlugin<UserOptions = Record<string, never>>(
const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/
const loader = plugin.esbuild?.loader ?? guessLoader

const context: UnpluginBuildContext = createEsbuildContext(initialOptions)
const context: UnpluginBuildContext = createBuildContext(initialOptions)

if (plugin.buildStart)
onStart(() => plugin.buildStart!.call(context))
Expand All @@ -50,39 +49,53 @@ export function getEsbuildPlugin<UserOptions = Record<string, never>>(
return undefined
}

const { errors, warnings, mixedContext } = createPluginContext(context)

const isEntry = args.kind === 'entry-point'
const result = await plugin.resolveId!(
const result = await plugin.resolveId!.call(
mixedContext,
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.`
// We explicitly have this if statement here for consistency with the integration of other bundlers.
// Here, `args.importer` is just an empty string on entry files whereas the equivalent 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 (typeof result === 'string') {
return {
path: result,
namespace: plugin.name,
errors,
warnings,
watchFiles: mixedContext.getWatchFiles(),
}
}
else if (typeof result === 'object' && result !== null) {
return {
path: result.id,
external: result.external,
namespace: plugin.name,
errors,
warnings,
watchFiles: mixedContext.getWatchFiles(),
}
}
})
}

if (plugin.load || plugin.transform) {
onLoad({ filter: onLoadFilter }, async (args) => {
const id = args.path + args.suffix

const errors: PartialMessage[] = []
const warnings: PartialMessage[] = []
const pluginContext: UnpluginContext = {
error(message) { errors.push({ text: String(message) }) },
warn(message) { warnings.push({ text: String(message) }) },
}
const { errors, warnings, mixedContext } = createPluginContext(context)

// 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 && (!plugin.loadInclude || plugin.loadInclude(id))) {
const result = await plugin.load.call(Object.assign(context, pluginContext), id)
const result = await plugin.load.call(mixedContext, id)
if (typeof result === 'string') {
code = result
}
Expand All @@ -99,7 +112,14 @@ export function getEsbuildPlugin<UserOptions = Record<string, never>>(
if (map)
code = processCodeWithSourceMap(map, code)

return { contents: code, errors, warnings, loader: unwrapLoader(loader, code, args.path), resolveDir }
return {
contents: code,
errors,
warnings,
watchFiles: mixedContext.getWatchFiles(),
loader: unwrapLoader(loader, code, args.path),
resolveDir,
}
}

if (!plugin.transformInclude || plugin.transformInclude(id)) {
Expand All @@ -110,7 +130,7 @@ export function getEsbuildPlugin<UserOptions = Record<string, never>>(
code = await fs.promises.readFile(args.path, 'utf8')
}

const result = await plugin.transform.call(Object.assign(context, pluginContext), code, id)
const result = await plugin.transform.call(mixedContext, code, id)
if (typeof result === 'string') {
code = result
}
Expand All @@ -134,7 +154,14 @@ export function getEsbuildPlugin<UserOptions = Record<string, never>>(
if (code) {
if (map)
code = processCodeWithSourceMap(map, code)
return { contents: code, errors, warnings, loader: unwrapLoader(loader, code, args.path), resolveDir }
return {
contents: code,
errors,
warnings,
watchFiles: mixedContext.getWatchFiles(),
loader: unwrapLoader(loader, code, args.path),
resolveDir,
}
}
})
}
Expand Down
34 changes: 30 additions & 4 deletions src/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { Buffer } from 'buffer'
import remapping from '@ampproject/remapping'
import { Parser } from 'acorn'
import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping'
import type { BuildOptions, Loader } from 'esbuild'
import type { BuildOptions, Loader, PartialMessage } from 'esbuild'
import type { SourceMap } from 'rollup'
import type { UnpluginBuildContext } from '../types'
import type { UnpluginBuildContext, UnpluginContext } from '../types'

export * from '../utils'

Expand Down Expand Up @@ -110,7 +110,9 @@ export function combineSourcemaps(
return map as EncodedSourceMap
}

export function createEsbuildContext(initialOptions: BuildOptions): UnpluginBuildContext {
export function createBuildContext(initialOptions: BuildOptions): UnpluginBuildContext {
const watchFiles: string[] = []

return {
parse(code: string, opts: any = {}) {
return Parser.parse(code, {
Expand All @@ -121,6 +123,7 @@ export function createEsbuildContext(initialOptions: BuildOptions): UnpluginBuil
})
},
addWatchFile() {
console.warn('unplugin/esbuild: addWatchFile is no-op outside supported hooks (resolveId, load, transform)')
edemaine marked this conversation as resolved.
Show resolved Hide resolved
},
emitFile(emittedFile) {
// Ensure output directory exists for this.emitFile
Expand All @@ -132,9 +135,32 @@ export function createEsbuildContext(initialOptions: BuildOptions): UnpluginBuil
fs.writeFileSync(path.resolve(initialOptions.outdir, outFileName), emittedFile.source)
},
getWatchFiles() {
return []
return watchFiles
},
}
}

export function createPluginContext(context: UnpluginBuildContext) {
const errors: PartialMessage[] = []
const warnings: PartialMessage[] = []
const pluginContext: UnpluginContext = {
error(message) { errors.push({ text: String(message) }) },
warn(message) { warnings.push({ text: String(message) }) },
}

const mixedContext: UnpluginContext & UnpluginBuildContext = {
...context,
...pluginContext,
addWatchFile(id: string) {
context.getWatchFiles().push(id)
},
}

return {
errors,
warnings,
mixedContext,
}
}

export function processCodeWithSourceMap(map: SourceMap | null | undefined, code: string) {
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface UnpluginOptions {
buildEnd?: (this: UnpluginBuildContext) => Promise<void> | void
transform?: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable<TransformResult>
load?: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable<TransformResult>
resolveId?: (id: string, importer: string | undefined, options: { isEntry: boolean }) => Thenable<string | ExternalIdResult | null | undefined>
resolveId?: (this: UnpluginBuildContext & UnpluginContext, id: string, importer: string | undefined, options: { isEntry: boolean }) => Thenable<string | ExternalIdResult | null | undefined>
Copy link
Member

Choose a reason for hiding this comment

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

Adding this would indicate that it works for every bundler. Would love to have a simple test for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a simple test for this in resolveId (and perhaps we should in transform and load too).

And good that we tested: Webpack doesn't do this. Does anyone know whether Resolvers in Webpack can access a compilation object, so we can call createContext? I didn't see an easy way... One option would be to throw an error in these situations.

Copy link
Contributor Author

@edemaine edemaine Oct 20, 2023

Choose a reason for hiding this comment

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

I've done a bunch of searching and it seems like loaders and resolvers are treated very differently in Webpack, and only loaders have all the APIs we need. I'm unclear whether a loader can be used as a resolver. But at least for now, I've added the same consistent interface to resolveId (so types work and function calls don't crash from lack of functions), but most of the API functions throw errors. (Some do work; I could implement error, warn, and parse still.)

Also documented the limitations here (which are fewer than before β€” previously resolveId offered no this API in Webpack or esbuild).

watchChange?: (this: UnpluginBuildContext, id: string, change: { event: 'create' | 'update' | 'delete' }) => void

// Output Generation Hooks
Expand Down