diff --git a/common/changes/@rushstack/heft-webpack5-plugin/bmiddha-rspack-watchpack_2025-11-19-00-23.json b/common/changes/@rushstack/heft-webpack5-plugin/bmiddha-rspack-watchpack_2025-11-19-00-23.json new file mode 100644 index 00000000000..e62ded6bf1b --- /dev/null +++ b/common/changes/@rushstack/heft-webpack5-plugin/bmiddha-rspack-watchpack_2025-11-19-00-23.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-webpack5-plugin", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/heft-webpack5-plugin" +} \ No newline at end of file diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index efe863b0bed..9b3644ccffe 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3081,6 +3081,9 @@ importers: tapable: specifier: 2.3.0 version: 2.3.0 + watchpack: + specifier: 2.4.0 + version: 2.4.0 webpack: specifier: ~5.98.0 version: 5.98.0 @@ -3094,6 +3097,9 @@ importers: '@rushstack/terminal': specifier: workspace:* version: link:../../libraries/terminal + '@types/watchpack': + specifier: 2.4.0 + version: 2.4.0 eslint: specifier: ~9.37.0 version: 9.37.0(supports-color@8.1.1) diff --git a/heft-plugins/heft-rspack-plugin/package.json b/heft-plugins/heft-rspack-plugin/package.json index 87a601f1aeb..f748783673a 100644 --- a/heft-plugins/heft-rspack-plugin/package.json +++ b/heft-plugins/heft-rspack-plugin/package.json @@ -26,11 +26,13 @@ "@rushstack/node-core-library": "workspace:*", "tapable": "2.3.0", "@rspack/dev-server": "^1.1.4", + "watchpack": "2.4.0", "webpack": "~5.98.0" }, "devDependencies": { "@rushstack/heft": "workspace:*", "@rushstack/terminal": "workspace:*", + "@types/watchpack": "2.4.0", "eslint": "~9.37.0", "local-node-rig": "workspace:*", "@rspack/core": "~1.6.0-beta.0" diff --git a/heft-plugins/heft-rspack-plugin/src/DeferredWatchFileSystem.ts b/heft-plugins/heft-rspack-plugin/src/DeferredWatchFileSystem.ts new file mode 100644 index 00000000000..1b7a437c21d --- /dev/null +++ b/heft-plugins/heft-rspack-plugin/src/DeferredWatchFileSystem.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import Watchpack, { type WatchOptions } from 'watchpack'; +import type { Compiler, RspackPluginInstance, WatchFileSystem } from '@rspack/core'; + +// InputFileSystem type is defined inline since it's not exported from @rspack/core +// missing re-export here: https://github.com/web-infra-dev/rspack/blob/9542b49ad43f91ecbcb37ff277e0445e67b99967/packages/rspack/src/exports.ts#L133 +// type definition here: https://github.com/web-infra-dev/rspack/blob/9542b49ad43f91ecbcb37ff277e0445e67b99967/packages/rspack/src/util/fs.ts#L496 +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface InputFileSystem { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readFile: (...args: any[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readlink: (...args: any[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readdir: (...args: any[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stat: (...args: any[]) => void; + purge?: (files?: string | string[] | Set) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export type WatchCallback = Parameters[5]; +export type WatchUndelayedCallback = Parameters[6]; +export type Watcher = ReturnType; +export type WatcherInfo = ReturnType['getInfo']>; +type FileSystemMap = ReturnType>; + +interface IWatchState { + changes: Set; + removals: Set; + + callback: WatchCallback; +} + +interface ITimeEntry { + timestamp: number; + safeTime: number; +} + +type IRawFileSystemMap = Map; + +interface ITimeInfoEntries { + fileTimeInfoEntries: FileSystemMap; + contextTimeInfoEntries: FileSystemMap; +} + +export class DeferredWatchFileSystem implements WatchFileSystem { + public readonly inputFileSystem: InputFileSystem; + public readonly watcherOptions: WatchOptions; + public watcher: Watchpack | undefined; + + private readonly _onChange: () => void; + private _state: IWatchState | undefined; + + public constructor(inputFileSystem: InputFileSystem, onChange: () => void) { + this.inputFileSystem = inputFileSystem; + this.watcherOptions = { + aggregateTimeout: 0 + }; + this.watcher = new Watchpack(this.watcherOptions); + this._onChange = onChange; + } + + public flush(): boolean { + const state: IWatchState | undefined = this._state; + + if (!state) { + return false; + } + + const { changes, removals, callback } = state; + + // Force flush the aggregation callback + const { changes: newChanges, removals: newRemovals } = this.watcher!.getAggregated(); + + // Rspack (like Webpack 5) treats changes and removals as separate things + if (newRemovals) { + for (const removal of newRemovals) { + changes.delete(removal); + removals.add(removal); + } + } + if (newChanges) { + for (const change of newChanges) { + removals.delete(change); + changes.add(change); + } + } + + if (changes.size > 0 || removals.size > 0) { + this._purge(removals, changes); + + const { fileTimeInfoEntries, contextTimeInfoEntries } = this._fetchTimeInfo(); + + callback(null, fileTimeInfoEntries, contextTimeInfoEntries, changes, removals); + + changes.clear(); + removals.clear(); + + return true; + } + + return false; + } + + public watch( + files: Iterable, + directories: Iterable, + missing: Iterable, + startTime: number, + options: WatchOptions, + callback: WatchCallback, + callbackUndelayed: WatchUndelayedCallback + ): Watcher { + const oldWatcher: Watchpack | undefined = this.watcher; + this.watcher = new Watchpack(options); + + const changes: Set = new Set(); + const removals: Set = new Set(); + + this._state = { + changes, + removals, + + callback + }; + + this.watcher.on('aggregated', (newChanges: Set, newRemovals: Set) => { + for (const change of newChanges) { + removals.delete(change); + changes.add(change); + } + for (const removal of newRemovals) { + changes.delete(removal); + removals.add(removal); + } + + this._onChange(); + }); + + this.watcher.watch({ + files, + directories, + missing, + startTime + }); + + if (oldWatcher) { + oldWatcher.close(); + } + + return { + close: () => { + if (this.watcher) { + this.watcher.close(); + this.watcher = undefined; + } + }, + pause: () => { + if (this.watcher) { + this.watcher.pause(); + } + }, + getInfo: () => { + const newRemovals: Set | undefined = this.watcher?.aggregatedRemovals; + const newChanges: Set | undefined = this.watcher?.aggregatedChanges; + this._purge(newRemovals, newChanges); + const { fileTimeInfoEntries, contextTimeInfoEntries } = this._fetchTimeInfo(); + return { + changes: newChanges!, + removals: newRemovals!, + fileTimeInfoEntries, + contextTimeInfoEntries + }; + }, + getContextTimeInfoEntries: () => { + const { contextTimeInfoEntries } = this._fetchTimeInfo(); + return contextTimeInfoEntries; + }, + getFileTimeInfoEntries: () => { + const { fileTimeInfoEntries } = this._fetchTimeInfo(); + return fileTimeInfoEntries; + } + }; + } + + private _fetchTimeInfo(): ITimeInfoEntries { + const fileTimeInfoEntries: IRawFileSystemMap = new Map(); + const contextTimeInfoEntries: IRawFileSystemMap = new Map(); + this.watcher?.collectTimeInfoEntries(fileTimeInfoEntries, contextTimeInfoEntries); + return { fileTimeInfoEntries, contextTimeInfoEntries }; + } + + private _purge(removals: Set | undefined, changes: Set | undefined): void { + const fs: InputFileSystem = this.inputFileSystem; + if (fs.purge) { + if (removals) { + for (const removal of removals) { + fs.purge(removal); + } + } + if (changes) { + for (const change of changes) { + fs.purge(change); + } + } + } + } +} + +export class OverrideNodeWatchFSPlugin implements RspackPluginInstance { + public readonly fileSystems: Set = new Set(); + private readonly _onChange: () => void; + + public constructor(onChange: () => void) { + this._onChange = onChange; + } + + public apply(compiler: Compiler): void { + const { inputFileSystem } = compiler; + if (!inputFileSystem) { + throw new Error(`compiler.inputFileSystem is not defined`); + } + + const watchFileSystem: DeferredWatchFileSystem = new DeferredWatchFileSystem( + inputFileSystem, + this._onChange + ); + this.fileSystems.add(watchFileSystem); + compiler.watchFileSystem = watchFileSystem; + } +} diff --git a/heft-plugins/heft-rspack-plugin/src/RspackPlugin.ts b/heft-plugins/heft-rspack-plugin/src/RspackPlugin.ts index 4349b82a2be..67546a3e345 100644 --- a/heft-plugins/heft-rspack-plugin/src/RspackPlugin.ts +++ b/heft-plugins/heft-rspack-plugin/src/RspackPlugin.ts @@ -26,6 +26,7 @@ import { type RspackCoreImport } from './shared'; import { tryLoadRspackConfigurationAsync } from './RspackConfigurationLoader'; +import { type DeferredWatchFileSystem, OverrideNodeWatchFSPlugin } from './DeferredWatchFileSystem'; export interface IRspackPluginOptions { devConfigurationPath?: string | undefined; @@ -48,6 +49,7 @@ export default class RspackPlugin implements IHeftTaskPlugin | undefined; private _rspackCompilationDonePromiseResolveFn: (() => void) | undefined; + private _watchFileSystems: Set | undefined; private _warnings: Error[] = []; private _errors: Error[] = []; @@ -110,6 +112,20 @@ export default class RspackPlugin implements IHeftTaskPlugin | undefined = this._rspackCompilationDonePromise; + let isInitial: boolean = false; + if (!this._rspackCompiler) { + isInitial = true; this._validateEnvironmentVariable(taskSession); if (!taskSession.parameters.watch) { // Should never happen, but just in case @@ -392,10 +411,25 @@ export default class RspackPlugin implements IHeftTaskPlugin