Skip to content

Commit

Permalink
fix #2140, fix #2205: add the onDispose api
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jan 17, 2023
1 parent 16fca6c commit a9c6b7f
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 22 deletions.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## Unreleased

* Add `onDispose` to the plugin API ([#2140](https://github.com/evanw/esbuild/issues/2140), [#2205](https://github.com/evanw/esbuild/issues/2205))

If your plugin wants to perform some cleanup after it's no longer going to be used, you can now use the `onDispose` API to register a callback for cleanup-related tasks. For example, if a plugin starts a long-running child process then it may want to terminate that process when the plugin is discarded. Previously there was no way to do this. Here's an example:

```js
let examplePlugin = {
name: 'example',
setup(build) {
build.onDispose(() => {
console.log('This plugin is no longer used')
})
},
}
```

These `onDispose` callbacks will be called after every `build()` call regardless of whether the build failed or not as well as after the first `dispose()` call on a given build context.

## 0.17.1

* Make it possible to cancel a build ([#2725](https://github.com/evanw/esbuild/issues/2725))
Expand Down
27 changes: 22 additions & 5 deletions lib/shared/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ function buildOrContextImpl(
result => {
if (!result.ok) return handleError(result.error, result.pluginName)
try {
buildOrContextContinue(result.requestPlugins, result.runOnEndCallbacks)
buildOrContextContinue(result.requestPlugins, result.runOnEndCallbacks, result.scheduleOnDisposeCallbacks)
} catch (e) {
handleError(e, '')
}
Expand All @@ -899,14 +899,14 @@ function buildOrContextImpl(
}

try {
buildOrContextContinue(null, (result, done) => done([], []))
buildOrContextContinue(null, (result, done) => done([], []), () => { })
} catch (e) {
handleError(e, '')
}

// "buildOrContext" cannot be written using async/await due to "buildSync"
// and must be written in continuation-passing style instead
function buildOrContextContinue(requestPlugins: protocol.BuildPlugin[] | null, runOnEndCallbacks: RunOnEndCallbacks) {
function buildOrContextContinue(requestPlugins: protocol.BuildPlugin[] | null, runOnEndCallbacks: RunOnEndCallbacks, scheduleOnDisposeCallbacks: () => void) {
const writeDefault = streamIn.hasFS
const {
entries,
Expand Down Expand Up @@ -985,7 +985,10 @@ function buildOrContextImpl(
sendRequest<protocol.BuildRequest, protocol.BuildResponse>(refs, request, (error, response) => {
if (error) return callback(new Error(error), null)
if (!isContext) {
return buildResponseToResult(response!, callback)
return buildResponseToResult(response!, (err, res) => {
scheduleOnDisposeCallbacks()
return callback(err, res)
})
}

// Construct a context object
Expand Down Expand Up @@ -1123,6 +1126,7 @@ function buildOrContextImpl(
}
sendRequest<protocol.DisposeRequest, null>(refs, request, () => {
resolve(); // We don't care about errors here
scheduleOnDisposeCallbacks()

// Only remove the reference here when we know the Go code has seen
// this "dispose" call. We don't want to remove any registered
Expand Down Expand Up @@ -1154,7 +1158,7 @@ let handlePlugins = async (
plugins: types.Plugin[],
details: ObjectStash,
): Promise<
| { ok: true, requestPlugins: protocol.BuildPlugin[], runOnEndCallbacks: RunOnEndCallbacks }
| { ok: true, requestPlugins: protocol.BuildPlugin[], runOnEndCallbacks: RunOnEndCallbacks, scheduleOnDisposeCallbacks: () => void }
| { ok: false, error: any, pluginName: string }
> => {
let onStartCallbacks: {
Expand Down Expand Up @@ -1189,6 +1193,7 @@ let handlePlugins = async (
},
} = {}

let onDisposeCallbacks: (() => void)[] = []
let nextCallbackID = 0
let i = 0
let requestPlugins: protocol.BuildPlugin[] = []
Expand Down Expand Up @@ -1304,6 +1309,10 @@ let handlePlugins = async (
plugin.onLoad.push({ id, filter: filter.source, namespace: namespace || '' })
},

onDispose(callback) {
onDisposeCallbacks.push(callback)
},

esbuild: streamIn.esbuild,
})

Expand Down Expand Up @@ -1495,11 +1504,19 @@ let handlePlugins = async (
}
}

let scheduleOnDisposeCallbacks = (): void => {
// Run each "onDispose" callback with its own call stack
for (const cb of onDisposeCallbacks) {
setTimeout(() => cb(), 0)
}
}

isSetupDone = true
return {
ok: true,
requestPlugins,
runOnEndCallbacks,
scheduleOnDisposeCallbacks,
}
}

Expand Down
3 changes: 3 additions & 0 deletions lib/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ export interface PluginBuild {
onLoad(options: OnLoadOptions, callback: (args: OnLoadArgs) =>
(OnLoadResult | null | undefined | Promise<OnLoadResult | null | undefined>)): void

/** Documentation: https://esbuild.github.io/plugins/#on-dispose */
onDispose(callback: () => void): void

// This is a full copy of the esbuild library in case you need it
esbuild: {
context: typeof context,
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ type PluginBuild struct {

// Documentation: https://esbuild.github.io/plugins/#on-load
OnLoad func(options OnLoadOptions, callback func(OnLoadArgs) (OnLoadResult, error))

// Documentation: https://esbuild.github.io/plugins/#on-dispose
OnDispose func(callback func())
}

// Documentation: https://esbuild.github.io/plugins/#resolve-options
Expand Down
47 changes: 30 additions & 17 deletions pkg/api/api_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,7 @@ func contextImpl(buildOpts BuildOptions) (*internalContext, []Message) {
// directory doesn't change, since breaking that invariant would break the
// validation that we just did above.
caches := cache.MakeCacheSet()
onEndCallbacks, finalizeBuildOptions := loadPlugins(&buildOpts, realFS, log, caches)
onEndCallbacks, onDisposeCallbacks, finalizeBuildOptions := loadPlugins(&buildOpts, realFS, log, caches)
options, entryPoints := validateBuildOptions(buildOpts, log, realFS)
finalizeBuildOptions(&options)
if buildOpts.AbsWorkingDir != absWorkingDir {
Expand All @@ -893,14 +893,15 @@ func contextImpl(buildOpts BuildOptions) (*internalContext, []Message) {
}

args := rebuildArgs{
caches: caches,
onEndCallbacks: onEndCallbacks,
logOptions: logOptions,
entryPoints: entryPoints,
options: options,
mangleCache: buildOpts.MangleCache,
absWorkingDir: absWorkingDir,
write: buildOpts.Write,
caches: caches,
onEndCallbacks: onEndCallbacks,
onDisposeCallbacks: onDisposeCallbacks,
logOptions: logOptions,
entryPoints: entryPoints,
options: options,
mangleCache: buildOpts.MangleCache,
absWorkingDir: absWorkingDir,
write: buildOpts.Write,
}

return &internalContext{
Expand Down Expand Up @@ -1116,6 +1117,11 @@ func (ctx *internalContext) Dispose() {
if build != nil {
build.waitGroup.Wait()
}

// Run each "OnDispose" callback on its own goroutine
for _, fn := range ctx.args.onDisposeCallbacks {
go fn()
}
}

func prettyPrintByteCount(n int) string {
Expand Down Expand Up @@ -1368,14 +1374,15 @@ type onEndCallback struct {
}

type rebuildArgs struct {
caches *cache.CacheSet
onEndCallbacks []onEndCallback
logOptions logger.OutputOptions
entryPoints []bundler.EntryPoint
options config.Options
mangleCache map[string]interface{}
absWorkingDir string
write bool
caches *cache.CacheSet
onEndCallbacks []onEndCallback
onDisposeCallbacks []func()
logOptions logger.OutputOptions
entryPoints []bundler.EntryPoint
options config.Options
mangleCache map[string]interface{}
absWorkingDir string
write bool
}

type rebuildState struct {
Expand Down Expand Up @@ -1966,6 +1973,7 @@ func (impl *pluginImpl) validatePathsArray(pathsIn []string, name string) (paths

func loadPlugins(initialOptions *BuildOptions, fs fs.FS, log logger.Log, caches *cache.CacheSet) (
onEndCallbacks []onEndCallback,
onDisposeCallbacks []func(),
finalizeBuildOptions func(*config.Options),
) {
// Clone the plugin array to guard against mutation during iteration
Expand Down Expand Up @@ -2067,11 +2075,16 @@ func loadPlugins(initialOptions *BuildOptions, fs fs.FS, log logger.Log, caches
})
}

onDispose := func(fn func()) {
onDisposeCallbacks = append(onDisposeCallbacks, fn)
}

item.Setup(PluginBuild{
InitialOptions: initialOptions,
Resolve: resolve,
OnStart: impl.onStart,
OnEnd: onEnd,
OnDispose: onDispose,
OnResolve: impl.onResolve,
OnLoad: impl.onLoad,
})
Expand Down
92 changes: 92 additions & 0 deletions scripts/plugin-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -3053,6 +3053,98 @@ let syncTests = {
await ctx.dispose()
}
},

async pluginOnDisposeAfterSuccessfulBuild({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `1+2`)

let onDisposeCalled
let onDisposePromise = new Promise(resolve => onDisposeCalled = resolve)
await esbuild.build({
entryPoints: [input],
write: false,
plugins: [{
name: 'x', setup(build) {
build.onDispose(onDisposeCalled)
}
}]
})
await onDisposePromise
},

async pluginOnDisposeAfterFailedBuild({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')

let onDisposeCalled
let onDisposePromise = new Promise(resolve => onDisposeCalled = resolve)
try {
await esbuild.build({
entryPoints: [input],
write: false,
logLevel: 'silent',
plugins: [{
name: 'x', setup(build) {
build.onDispose(onDisposeCalled)
}
}]
})
throw new Error('Expected an error to be thrown')
} catch (err) {
if (!err.errors || err.errors.length !== 1)
throw err
}
await onDisposePromise
},

async pluginOnDisposeWithUnusedContext({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `1+2`)

let onDisposeCalled
let onDisposePromise = new Promise(resolve => onDisposeCalled = resolve)
let ctx = await esbuild.context({
entryPoints: [input],
write: false,
plugins: [{
name: 'x', setup(build) {
build.onDispose(onDisposeCalled)
}
}]
})
await ctx.dispose()
await onDisposePromise
},

async pluginOnDisposeWithRebuild({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `1+2`)

let onDisposeCalled
let onDisposeWasCalled = false
let onDisposePromise = new Promise(resolve => {
onDisposeCalled = () => {
onDisposeWasCalled = true
resolve()
}
})
let ctx = await esbuild.context({
entryPoints: [input],
write: false,
plugins: [{
name: 'x', setup(build) {
build.onDispose(onDisposeCalled)
}
}]
})

let result = await ctx.rebuild()
assert.strictEqual(result.outputFiles.length, 1)
assert.strictEqual(onDisposeWasCalled, false)

await ctx.dispose()
await onDisposePromise
assert.strictEqual(onDisposeWasCalled, true)
},
}

async function main() {
Expand Down

0 comments on commit a9c6b7f

Please sign in to comment.