From 1eae026cfd43c4604cb092dfa2128ade2e8e621a Mon Sep 17 00:00:00 2001 From: Lukas Taegert-Atkinson Date: Sat, 5 Mar 2022 14:19:30 +0100 Subject: [PATCH] Await watchChange and closeWatcher hooks --- cli/run/watch-cli.ts | 6 ++--- docs/05-plugin-development.md | 6 ++--- docs/build-hooks.mmd | 4 +-- src/Graph.ts | 14 ++++------ src/rollup/types.d.ts | 49 +++++++++++++++++++++++------------ src/watch/watch-proxy.ts | 36 +++++++++++++++++++++++-- src/watch/watch.ts | 15 ++++++----- test/watch/index.js | 5 ++-- 8 files changed, 92 insertions(+), 43 deletions(-) diff --git a/cli/run/watch-cli.ts b/cli/run/watch-cli.ts index cd845424eff..8ad23b4976a 100644 --- a/cli/run/watch-cli.ts +++ b/cli/run/watch-cli.ts @@ -56,7 +56,7 @@ export async function watch(command: Record): Promise { return; } if (watcher) { - watcher.close(); + await watcher.close(); } start(options, warnings); } catch (err: any) { @@ -136,12 +136,12 @@ export async function watch(command: Record): Promise { }); } - function close(code: number | null): void { + async function close(code: number | null): Promise { process.removeListener('uncaughtException', close); // removing a non-existent listener is a no-op process.stdin.removeListener('end', close); - if (watcher) watcher.close(); + if (watcher) await watcher.close(); if (configWatcher) configWatcher.close(); if (code) { diff --git a/docs/05-plugin-development.md b/docs/05-plugin-development.md index 228b0d81807..dc94d3f3051 100644 --- a/docs/05-plugin-development.md +++ b/docs/05-plugin-development.md @@ -96,7 +96,7 @@ Called on each `rollup.rollup` build. This is the recommended hook to use when y #### `closeWatcher` -**Type:** `() => void`
**Kind:** `sync, sequential`
**Previous/Next Hook:** This hook can be triggered at any time both during the build and the output generation phases. If that is the case, the current build will still proceed but no new [`watchChange`](guide/en/#watchchange) events will be triggered ever. +**Type:** `() => void`
**Kind:** `async, parallel`
**Previous/Next Hook:** This hook can be triggered at any time both during the build and the output generation phases. If that is the case, the current build will still proceed but no new [`watchChange`](guide/en/#watchchange) events will be triggered ever. Notifies a plugin when watcher process closes and all open resources should be closed too. This hook cannot be used by output plugins. @@ -302,9 +302,9 @@ You can use [`this.getModuleInfo`](guide/en/#thisgetmoduleinfo) to find out the #### `watchChange` -**Type:** `watchChange: (id: string, change: {event: 'create' | 'update' | 'delete'}) => void`
**Kind:** `sync, sequential`
**Previous/Next Hook:** This hook can be triggered at any time both during the build and the output generation phases. If that is the case, the current build will still proceed but a new build will be scheduled to start once the current build has completed, starting again with [`options`](guide/en/#options). +**Type:** `watchChange: (id: string, change: {event: 'create' | 'update' | 'delete'}) => void`
**Kind:** `async, parallel`
**Previous/Next Hook:** This hook can be triggered at any time both during the build and the output generation phases. If that is the case, the current build will still proceed but a new build will be scheduled to start once the current build has completed, starting again with [`options`](guide/en/#options). -Notifies a plugin whenever rollup has detected a change to a monitored file in `--watch` mode. This hook cannot be used by output plugins. Second argument contains additional details of change event. +Notifies a plugin whenever rollup has detected a change to a monitored file in `--watch` mode. If a promise is returned, Rollup will wait for the promise to resolve before scheduling another build. This hook cannot be used by output plugins. The second argument contains additional details of change event. ### Output Generation Hooks diff --git a/docs/build-hooks.mmd b/docs/build-hooks.mmd index 93da9f1a6b9..889bc5baac4 100644 --- a/docs/build-hooks.mmd +++ b/docs/build-hooks.mmd @@ -32,10 +32,10 @@ flowchart TB transform("transform"):::hook-sequential click transform "/guide/en/#transform" _parent - watchchange("watchChange"):::hook-sequential-sync + watchchange("watchChange"):::hook-parallel click watchchange "/guide/en/#watchchange" _parent - closewatcher("closeWatcher"):::hook-sequential-sync + closewatcher("closeWatcher"):::hook-parallel click closewatcher "/guide/en/#closewatcher" _parent options diff --git a/src/Graph.ts b/src/Graph.ts index ee6fb26951b..2bc50b79764 100644 --- a/src/Graph.ts +++ b/src/Graph.ts @@ -78,15 +78,11 @@ export default class Graph { if (watcher) { this.watchMode = true; - const handleChange: WatchChangeHook = (...args) => - this.pluginDriver.hookSeqSync('watchChange', args); - const handleClose = () => this.pluginDriver.hookSeqSync('closeWatcher', []); - watcher.on('change', handleChange); - watcher.on('close', handleClose); - watcher.once('restart', () => { - watcher.removeListener('change', handleChange); - watcher.removeListener('close', handleClose); - }); + const handleChange = (...args: Parameters) => + this.pluginDriver.hookParallel('watchChange', args); + const handleClose = () => this.pluginDriver.hookParallel('closeWatcher', []); + watcher.onCurrentAwaited('change', handleChange); + watcher.onCurrentAwaited('close', handleClose); } this.pluginDriver = new PluginDriver(this, options, options.plugins, this.pluginCache); this.acornParser = acorn.Parser.extend(...(options.acornInjectPlugins as any)); diff --git a/src/rollup/types.d.ts b/src/rollup/types.d.ts index 55da5d5f34c..6144a7a1c31 100644 --- a/src/rollup/types.d.ts +++ b/src/rollup/types.d.ts @@ -344,7 +344,7 @@ export type WatchChangeHook = ( this: PluginContext, id: string, change: { event: ChangeEvent } -) => void; +) => Promise | void; /** * use this type for plugin annotation @@ -375,7 +375,7 @@ export interface PluginHooks extends OutputPluginHooks { buildEnd: (this: PluginContext, err?: Error) => Promise | void; buildStart: (this: PluginContext, options: NormalizedInputOptions) => Promise | void; closeBundle: (this: PluginContext) => Promise | void; - closeWatcher: (this: PluginContext) => void; + closeWatcher: (this: PluginContext) => Promise | void; load: LoadHook; moduleParsed: ModuleParsedHook; options: ( @@ -440,7 +440,9 @@ export type AsyncPluginHooks = | 'shouldTransformCachedModule' | 'transform' | 'writeBundle' - | 'closeBundle'; + | 'closeBundle' + | 'closeWatcher' + | 'watchChange'; export type PluginValueHooks = 'banner' | 'footer' | 'intro' | 'outro'; @@ -458,13 +460,11 @@ export type FirstPluginHooks = export type SequentialPluginHooks = | 'augmentChunkHash' - | 'closeWatcher' | 'generateBundle' | 'options' | 'outputOptions' | 'renderChunk' - | 'transform' - | 'watchChange'; + | 'transform'; export type ParallelPluginHooks = | 'banner' @@ -477,7 +477,9 @@ export type ParallelPluginHooks = | 'renderError' | 'renderStart' | 'writeBundle' - | 'closeBundle'; + | 'closeBundle' + | 'closeWatcher' + | 'watchChange'; interface OutputPluginValueHooks { banner: AddonHook; @@ -898,6 +900,24 @@ interface TypedEventEmitter any } setMaxListeners(n: number): this; } +export interface RollupAwaitingEmitter any }> + extends TypedEventEmitter { + close(): Promise; + emitAndAwait(event: K, ...args: Parameters): Promise[]>; + /** + * Registers an event listener that will be awaited before Rollup continues + * for events emitted via emitAndAwait. All listeners will be awaited in + * parallel while rejections are tracked via Promise.all. + * Listeners are removed automatically when removeAwaited is called, which + * happens automatically after each run. + */ + onCurrentAwaited( + event: K, + listener: (...args: Parameters) => Promise> + ): this; + removeAwaited(): this; +} + export type RollupWatcherEvent = | { code: 'START' } | { code: 'BUNDLE_START'; input?: InputOption; output: readonly string[] } @@ -911,15 +931,12 @@ export type RollupWatcherEvent = | { code: 'END' } | { code: 'ERROR'; error: RollupError; result: RollupBuild | null }; -export interface RollupWatcher - extends TypedEventEmitter<{ - change: (id: string, change: { event: ChangeEvent }) => void; - close: () => void; - event: (event: RollupWatcherEvent) => void; - restart: () => void; - }> { - close(): void; -} +export type RollupWatcher = RollupAwaitingEmitter<{ + change: (id: string, change: { event: ChangeEvent }) => void; + close: () => void; + event: (event: RollupWatcherEvent) => void; + restart: () => void; +}>; export function watch(config: RollupWatchOptions | RollupWatchOptions[]): RollupWatcher; diff --git a/src/watch/watch-proxy.ts b/src/watch/watch-proxy.ts index 0ca30d39a1b..487a3365708 100644 --- a/src/watch/watch-proxy.ts +++ b/src/watch/watch-proxy.ts @@ -5,7 +5,11 @@ import { errInvalidOption, error } from '../utils/error'; import type { GenericConfigObject } from '../utils/options/options'; import { loadFsEvents } from './fsevents-importer'; -class WatchEmitter extends EventEmitter { +class WatchEmitter any }> extends EventEmitter { + private awaitedHandlers: { + [K in keyof T]?: ((...args: Parameters) => Promise>)[]; + } = {}; + constructor() { super(); // Allows more than 10 bundles to be watched without @@ -13,7 +17,35 @@ class WatchEmitter extends EventEmitter { this.setMaxListeners(Infinity); } - close() {} + // Will be overwritten by Rollup + async close(): Promise {} + + emitAndAwait( + event: K, + ...args: Parameters + ): Promise[]> { + this.emit(event as string, ...(args as any[])); + const handlers = this.awaitedHandlers[event]; + if (!handlers) return Promise.resolve([]); + return Promise.all(handlers.map(handler => handler(...args))); + } + + onCurrentAwaited( + event: K, + listener: (...args: Parameters) => Promise> + ): this { + let handlers = this.awaitedHandlers[event]; + if (!handlers) { + handlers = this.awaitedHandlers[event] = []; + } + handlers.push(listener); + return this; + } + + removeAwaited(): this { + this.awaitedHandlers = {}; + return this; + } } export default function watch(configs: GenericConfigObject[] | GenericConfigObject): RollupWatcher { diff --git a/src/watch/watch.ts b/src/watch/watch.ts index 5ffc4c72ee7..2635a3a4f7b 100644 --- a/src/watch/watch.ts +++ b/src/watch/watch.ts @@ -57,12 +57,12 @@ export class Watcher { process.nextTick(() => this.run()); } - close(): void { + async close(): Promise { if (this.buildTimeout) clearTimeout(this.buildTimeout); for (const task of this.tasks) { task.close(); } - this.emitter.emit('close'); + await this.emitter.emitAndAwait('close'); this.emitter.removeAllListeners(); } @@ -87,14 +87,17 @@ export class Watcher { if (this.buildTimeout) clearTimeout(this.buildTimeout); - this.buildTimeout = setTimeout(() => { + this.buildTimeout = setTimeout(async () => { this.buildTimeout = null; try { - for (const [id, event] of this.invalidatedIds) { - this.emitter.emit('change', id, { event }); - } + await Promise.all( + [...this.invalidatedIds].map(([id, event]) => + this.emitter.emitAndAwait('change', id, { event }) + ) + ); this.invalidatedIds.clear(); this.emitter.emit('restart'); + this.emitter.removeAwaited(); this.run(); } catch (error: any) { this.invalidatedIds.clear(); diff --git a/test/watch/index.js b/test/watch/index.js index cdd2431c6af..b184b621df0 100644 --- a/test/watch/index.js +++ b/test/watch/index.js @@ -604,13 +604,14 @@ describe('rollup.watch', () => { ]); }); - it('recovers from a plugin error in the watchChange hook', async () => { + it('awaits and recovers from a plugin error in the watchChange hook', async () => { let fail = true; await copy('test/watch/samples/basic', 'test/_tmp/input'); watcher = rollup.watch({ input: 'test/_tmp/input/main.js', plugins: { - watchChange(id) { + async watchChange() { + await new Promise(resolve => setTimeout(resolve, 300)); if (fail) { this.error('Failed in watchChange'); }