Skip to content

Commit

Permalink
feat: esbuild support for addWatchFile and getWatchFiles (#345)
Browse files Browse the repository at this point in the history
Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
3 people committed Dec 26, 2023
1 parent 670eb37 commit 2f65939
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 47 deletions.
18 changes: 10 additions & 8 deletions README.md
Expand Up @@ -44,16 +44,18 @@ 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> || ✅<sup>7</sup> ||
| [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile)<sup>5</sup> ||| ✅<sup>6</sup> | | ||
| [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) ||| ✅<sup>6</sup> || ✅<sup>7</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 Webpack, [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisgetwatchfiles), [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile), and [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) are not supported within `resolveId` hooks.
7. 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
69 changes: 48 additions & 21 deletions src/esbuild/index.ts
@@ -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.esbuild?.config)
plugin.esbuild.config.call(context, initialOptions)
Expand All @@ -53,39 +52,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 @@ -102,7 +115,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 @@ -113,7 +133,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 @@ -137,7 +157,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
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() {
throw new Error('unplugin/esbuild: addWatchFile outside supported hooks (resolveId, load, transform)')
},
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
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>
watchChange?: (this: UnpluginBuildContext, id: string, change: { event: 'create' | 'update' | 'delete' }) => void

// Output Generation Hooks
Expand Down
8 changes: 7 additions & 1 deletion src/webpack/context.ts
Expand Up @@ -6,7 +6,7 @@ import type { Compilation } from 'webpack'
import { Parser } from 'acorn'
import type { UnpluginBuildContext } from '../types'

export function createContext(compilation: Compilation): UnpluginBuildContext {
export function createContext(compilation?: Compilation): UnpluginBuildContext {
return {
parse(code: string, opts: any = {}) {
return Parser.parse(code, {
Expand All @@ -17,13 +17,17 @@ export function createContext(compilation: Compilation): UnpluginBuildContext {
})
},
addWatchFile(id) {
if (!compilation)
throw new Error('unplugin/webpack: addWatchFile outside supported hooks (buildStart, buildEnd, load, transform, watchChange)');
(compilation.fileDependencies ?? compilation.compilationDependencies).add(
resolve(process.cwd(), id),
)
},
emitFile(emittedFile) {
const outFileName = emittedFile.fileName || emittedFile.name
if (emittedFile.source && outFileName) {
if (!compilation)
throw new Error('unplugin/webpack: emitFile outside supported hooks (buildStart, buildEnd, load, transform, watchChange)')
compilation.emitAsset(
outFileName,
sources
Expand All @@ -41,6 +45,8 @@ export function createContext(compilation: Compilation): UnpluginBuildContext {
}
},
getWatchFiles() {
if (!compilation)
throw new Error('unplugin/webpack: getWatchFiles outside supported hooks (buildStart, buildEnd, load, transform, watchChange)')
return Array.from(
compilation.fileDependencies ?? compilation.compilationDependencies,
)
Expand Down
23 changes: 19 additions & 4 deletions src/webpack/index.ts
Expand Up @@ -2,8 +2,8 @@ import fs from 'fs'
import { resolve } from 'path'
import process from 'process'
import VirtualModulesPlugin from 'webpack-virtual-modules'
import type { ResolvePluginInstance } from 'webpack'
import type { ResolvedUnpluginOptions, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, WebpackCompiler } from '../types'
import type { ResolvePluginInstance, Resolver } from 'webpack'
import type { ResolvedUnpluginOptions, UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, WebpackCompiler } from '../types'
import { normalizeAbsolutePath, shouldLoad, toArray, transformUse } from '../utils'
import { createContext } from './context'

Expand Down Expand Up @@ -69,7 +69,7 @@ export function getWebpackPlugin<UserOptions = Record<string, never>>(
plugin.__vfs = vfs

const resolverPlugin: ResolvePluginInstance = {
apply(resolver) {
apply(resolver: Resolver) {
const target = resolver.ensureHook('resolve')

resolver
Expand All @@ -89,8 +89,23 @@ export function getWebpackPlugin<UserOptions = Record<string, never>>(
const isEntry = requestContext.issuer === ''

// call hook
const resolveIdResult = await plugin.resolveId!(id, importer, { isEntry })
const context = createContext()
let error: Error | undefined
const pluginContext: UnpluginContext = {
error(msg: string | Error) {
if (error == null)
error = typeof msg === 'string' ? new Error(msg) : msg
else
console.error(`unplugin/webpack: multiple errors returned from resolveId hook: ${msg}`)
},
warn(msg) {
console.warn(`unplugin/webpack: warning from resolveId hook: ${msg}`)
},
}
const resolveIdResult = await plugin.resolveId!.call!({ ...context, ...pluginContext }, id, importer, { isEntry })

if (error != null)
return callback(error)
if (resolveIdResult == null)
return callback()

Expand Down
2 changes: 1 addition & 1 deletion src/webpack/loaders/load.ts
Expand Up @@ -21,7 +21,7 @@ export default async function load(this: LoaderContext<any>, source: string, map
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))

const res = await plugin.load.call(
Object.assign(this._compilation && createContext(this._compilation) as any, context),
{ ...this._compilation && createContext(this._compilation) as any, ...context },
normalizeAbsolutePath(id),
)

Expand Down
6 changes: 5 additions & 1 deletion src/webpack/loaders/transform.ts
Expand Up @@ -23,7 +23,11 @@ export default async function transform(this: LoaderContext<{ unpluginName: stri
error: error => this.emitError(typeof error === 'string' ? new Error(error) : error),
warn: error => this.emitWarning(typeof error === 'string' ? new Error(error) : error),
}
const res = await plugin.transform.call(Object.assign(this._compilation && createContext(this._compilation) as any, context), source, this.resource)
const res = await plugin.transform.call(
{ ...this._compilation && createContext(this._compilation) as any, ...context },
source,
this.resource,
)

if (res == null)
callback(null, source, map)
Expand Down

0 comments on commit 2f65939

Please sign in to comment.