Skip to content

Commit 6c20fdf

Browse files
authored
[heft-rspack-plugin] implement DeferredWatchFileSystem (#5457)
1 parent 52d92b1 commit 6c20fdf

File tree

6 files changed

+289
-2
lines changed

6 files changed

+289
-2
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-webpack5-plugin",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-webpack5-plugin"
10+
}

common/config/subspaces/default/pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

heft-plugins/heft-rspack-plugin/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@
2626
"@rushstack/node-core-library": "workspace:*",
2727
"tapable": "2.3.0",
2828
"@rspack/dev-server": "^1.1.4",
29+
"watchpack": "2.4.0",
2930
"webpack": "~5.98.0"
3031
},
3132
"devDependencies": {
3233
"@rushstack/heft": "workspace:*",
3334
"@rushstack/terminal": "workspace:*",
35+
"@types/watchpack": "2.4.0",
3436
"eslint": "~9.37.0",
3537
"local-node-rig": "workspace:*",
3638
"@rspack/core": "~1.6.0-beta.0"
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import Watchpack, { type WatchOptions } from 'watchpack';
5+
import type { Compiler, RspackPluginInstance, WatchFileSystem } from '@rspack/core';
6+
7+
// InputFileSystem type is defined inline since it's not exported from @rspack/core
8+
// missing re-export here: https://github.com/web-infra-dev/rspack/blob/9542b49ad43f91ecbcb37ff277e0445e67b99967/packages/rspack/src/exports.ts#L133
9+
// type definition here: https://github.com/web-infra-dev/rspack/blob/9542b49ad43f91ecbcb37ff277e0445e67b99967/packages/rspack/src/util/fs.ts#L496
10+
// eslint-disable-next-line @typescript-eslint/naming-convention
11+
export interface InputFileSystem {
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
readFile: (...args: any[]) => void;
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
readlink: (...args: any[]) => void;
16+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+
readdir: (...args: any[]) => void;
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
stat: (...args: any[]) => void;
20+
purge?: (files?: string | string[] | Set<string>) => void;
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
[key: string]: any;
23+
}
24+
25+
export type WatchCallback = Parameters<WatchFileSystem['watch']>[5];
26+
export type WatchUndelayedCallback = Parameters<WatchFileSystem['watch']>[6];
27+
export type Watcher = ReturnType<WatchFileSystem['watch']>;
28+
export type WatcherInfo = ReturnType<Required<Watcher>['getInfo']>;
29+
type FileSystemMap = ReturnType<NonNullable<Watcher['getFileTimeInfoEntries']>>;
30+
31+
interface IWatchState {
32+
changes: Set<string>;
33+
removals: Set<string>;
34+
35+
callback: WatchCallback;
36+
}
37+
38+
interface ITimeEntry {
39+
timestamp: number;
40+
safeTime: number;
41+
}
42+
43+
type IRawFileSystemMap = Map<string, ITimeEntry>;
44+
45+
interface ITimeInfoEntries {
46+
fileTimeInfoEntries: FileSystemMap;
47+
contextTimeInfoEntries: FileSystemMap;
48+
}
49+
50+
export class DeferredWatchFileSystem implements WatchFileSystem {
51+
public readonly inputFileSystem: InputFileSystem;
52+
public readonly watcherOptions: WatchOptions;
53+
public watcher: Watchpack | undefined;
54+
55+
private readonly _onChange: () => void;
56+
private _state: IWatchState | undefined;
57+
58+
public constructor(inputFileSystem: InputFileSystem, onChange: () => void) {
59+
this.inputFileSystem = inputFileSystem;
60+
this.watcherOptions = {
61+
aggregateTimeout: 0
62+
};
63+
this.watcher = new Watchpack(this.watcherOptions);
64+
this._onChange = onChange;
65+
}
66+
67+
public flush(): boolean {
68+
const state: IWatchState | undefined = this._state;
69+
70+
if (!state) {
71+
return false;
72+
}
73+
74+
const { changes, removals, callback } = state;
75+
76+
// Force flush the aggregation callback
77+
const { changes: newChanges, removals: newRemovals } = this.watcher!.getAggregated();
78+
79+
// Rspack (like Webpack 5) treats changes and removals as separate things
80+
if (newRemovals) {
81+
for (const removal of newRemovals) {
82+
changes.delete(removal);
83+
removals.add(removal);
84+
}
85+
}
86+
if (newChanges) {
87+
for (const change of newChanges) {
88+
removals.delete(change);
89+
changes.add(change);
90+
}
91+
}
92+
93+
if (changes.size > 0 || removals.size > 0) {
94+
this._purge(removals, changes);
95+
96+
const { fileTimeInfoEntries, contextTimeInfoEntries } = this._fetchTimeInfo();
97+
98+
callback(null, fileTimeInfoEntries, contextTimeInfoEntries, changes, removals);
99+
100+
changes.clear();
101+
removals.clear();
102+
103+
return true;
104+
}
105+
106+
return false;
107+
}
108+
109+
public watch(
110+
files: Iterable<string>,
111+
directories: Iterable<string>,
112+
missing: Iterable<string>,
113+
startTime: number,
114+
options: WatchOptions,
115+
callback: WatchCallback,
116+
callbackUndelayed: WatchUndelayedCallback
117+
): Watcher {
118+
const oldWatcher: Watchpack | undefined = this.watcher;
119+
this.watcher = new Watchpack(options);
120+
121+
const changes: Set<string> = new Set();
122+
const removals: Set<string> = new Set();
123+
124+
this._state = {
125+
changes,
126+
removals,
127+
128+
callback
129+
};
130+
131+
this.watcher.on('aggregated', (newChanges: Set<string>, newRemovals: Set<string>) => {
132+
for (const change of newChanges) {
133+
removals.delete(change);
134+
changes.add(change);
135+
}
136+
for (const removal of newRemovals) {
137+
changes.delete(removal);
138+
removals.add(removal);
139+
}
140+
141+
this._onChange();
142+
});
143+
144+
this.watcher.watch({
145+
files,
146+
directories,
147+
missing,
148+
startTime
149+
});
150+
151+
if (oldWatcher) {
152+
oldWatcher.close();
153+
}
154+
155+
return {
156+
close: () => {
157+
if (this.watcher) {
158+
this.watcher.close();
159+
this.watcher = undefined;
160+
}
161+
},
162+
pause: () => {
163+
if (this.watcher) {
164+
this.watcher.pause();
165+
}
166+
},
167+
getInfo: () => {
168+
const newRemovals: Set<string> | undefined = this.watcher?.aggregatedRemovals;
169+
const newChanges: Set<string> | undefined = this.watcher?.aggregatedChanges;
170+
this._purge(newRemovals, newChanges);
171+
const { fileTimeInfoEntries, contextTimeInfoEntries } = this._fetchTimeInfo();
172+
return {
173+
changes: newChanges!,
174+
removals: newRemovals!,
175+
fileTimeInfoEntries,
176+
contextTimeInfoEntries
177+
};
178+
},
179+
getContextTimeInfoEntries: () => {
180+
const { contextTimeInfoEntries } = this._fetchTimeInfo();
181+
return contextTimeInfoEntries;
182+
},
183+
getFileTimeInfoEntries: () => {
184+
const { fileTimeInfoEntries } = this._fetchTimeInfo();
185+
return fileTimeInfoEntries;
186+
}
187+
};
188+
}
189+
190+
private _fetchTimeInfo(): ITimeInfoEntries {
191+
const fileTimeInfoEntries: IRawFileSystemMap = new Map();
192+
const contextTimeInfoEntries: IRawFileSystemMap = new Map();
193+
this.watcher?.collectTimeInfoEntries(fileTimeInfoEntries, contextTimeInfoEntries);
194+
return { fileTimeInfoEntries, contextTimeInfoEntries };
195+
}
196+
197+
private _purge(removals: Set<string> | undefined, changes: Set<string> | undefined): void {
198+
const fs: InputFileSystem = this.inputFileSystem;
199+
if (fs.purge) {
200+
if (removals) {
201+
for (const removal of removals) {
202+
fs.purge(removal);
203+
}
204+
}
205+
if (changes) {
206+
for (const change of changes) {
207+
fs.purge(change);
208+
}
209+
}
210+
}
211+
}
212+
}
213+
214+
export class OverrideNodeWatchFSPlugin implements RspackPluginInstance {
215+
public readonly fileSystems: Set<DeferredWatchFileSystem> = new Set();
216+
private readonly _onChange: () => void;
217+
218+
public constructor(onChange: () => void) {
219+
this._onChange = onChange;
220+
}
221+
222+
public apply(compiler: Compiler): void {
223+
const { inputFileSystem } = compiler;
224+
if (!inputFileSystem) {
225+
throw new Error(`compiler.inputFileSystem is not defined`);
226+
}
227+
228+
const watchFileSystem: DeferredWatchFileSystem = new DeferredWatchFileSystem(
229+
inputFileSystem,
230+
this._onChange
231+
);
232+
this.fileSystems.add(watchFileSystem);
233+
compiler.watchFileSystem = watchFileSystem;
234+
}
235+
}

heft-plugins/heft-rspack-plugin/src/RspackPlugin.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type RspackCoreImport
2727
} from './shared';
2828
import { tryLoadRspackConfigurationAsync } from './RspackConfigurationLoader';
29+
import { type DeferredWatchFileSystem, OverrideNodeWatchFSPlugin } from './DeferredWatchFileSystem';
2930

3031
export interface IRspackPluginOptions {
3132
devConfigurationPath?: string | undefined;
@@ -48,6 +49,7 @@ export default class RspackPlugin implements IHeftTaskPlugin<IRspackPluginOption
4849
private _rspackConfiguration: IRspackConfiguration | undefined | false = false;
4950
private _rspackCompilationDonePromise: Promise<void> | undefined;
5051
private _rspackCompilationDonePromiseResolveFn: (() => void) | undefined;
52+
private _watchFileSystems: Set<DeferredWatchFileSystem> | undefined;
5153

5254
private _warnings: Error[] = [];
5355
private _errors: Error[] = [];
@@ -110,6 +112,20 @@ export default class RspackPlugin implements IHeftTaskPlugin<IRspackPluginOption
110112
options
111113
);
112114

115+
if (rspackConfiguration && requestRun) {
116+
const overrideWatchFSPlugin: OverrideNodeWatchFSPlugin = new OverrideNodeWatchFSPlugin(requestRun);
117+
this._watchFileSystems = overrideWatchFSPlugin.fileSystems;
118+
for (const config of Array.isArray(rspackConfiguration)
119+
? rspackConfiguration
120+
: [rspackConfiguration]) {
121+
if (!config.plugins) {
122+
config.plugins = [overrideWatchFSPlugin];
123+
} else {
124+
config.plugins.unshift(overrideWatchFSPlugin);
125+
}
126+
}
127+
}
128+
113129
this._rspackConfiguration = rspackConfiguration;
114130
}
115131

@@ -210,7 +226,10 @@ export default class RspackPlugin implements IHeftTaskPlugin<IRspackPluginOption
210226
// the compilation completes.
211227
let rspackCompilationDonePromise: Promise<void> | undefined = this._rspackCompilationDonePromise;
212228

229+
let isInitial: boolean = false;
230+
213231
if (!this._rspackCompiler) {
232+
isInitial = true;
214233
this._validateEnvironmentVariable(taskSession);
215234
if (!taskSession.parameters.watch) {
216235
// Should never happen, but just in case
@@ -392,10 +411,25 @@ export default class RspackPlugin implements IHeftTaskPlugin<IRspackPluginOption
392411
}
393412
}
394413

414+
let hasChanges: boolean = true;
415+
if (!isInitial && this._watchFileSystems) {
416+
hasChanges = false;
417+
for (const watchFileSystem of this._watchFileSystems) {
418+
hasChanges = watchFileSystem.flush() || hasChanges;
419+
}
420+
}
421+
395422
// Resume the compilation, wait for the compilation to complete, then suspend the watchers until the
396423
// next iteration. Even if there are no changes, the promise should resolve since resuming from a
397424
// suspended state invalidates the state of the watcher.
398-
await rspackCompilationDonePromise;
425+
if (hasChanges) {
426+
taskSession.logger.terminal.writeLine('Running incremental Rspack compilation');
427+
await rspackCompilationDonePromise;
428+
} else {
429+
taskSession.logger.terminal.writeLine(
430+
'Rspack has not detected changes. Listing previous diagnostics.'
431+
);
432+
}
399433

400434
this._emitErrors(taskSession.logger);
401435
}

heft-plugins/heft-webpack5-plugin/src/Webpack5Plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export default class Webpack5Plugin implements IHeftTaskPlugin<IWebpackPluginOpt
232232
this._validateEnvironmentVariable(taskSession);
233233
if (!taskSession.parameters.watch) {
234234
// Should never happen, but just in case
235-
throw new InternalError('Cannot run Rspack in watch mode when watch mode is not enabled');
235+
throw new InternalError('Cannot run Webpack in watch mode when watch mode is not enabled');
236236
}
237237

238238
// Load the config and compiler, and return if there is no config found

0 commit comments

Comments
 (0)