in packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts [741:878]
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<IWatcher> {
const ioHelper = asIoHelper(this.ioHost, 'watch');
await using assembly = await assemblyFromSource(ioHelper, cx, false);
const rootDir = options.watchDir ?? process.cwd();
if (options.include === undefined && options.exclude === undefined) {
throw new ToolkitError(
"Cannot use the 'watch' command without specifying at least one directory to monitor. " +
'Make sure to add a "watch" key to your cdk.json',
);
}
// For the "include" subkey under the "watch" key, the behavior is:
// 1. No "watch" setting? We error out.
// 2. "watch" setting without an "include" key? We default to observing "./**".
// 3. "watch" setting with an empty "include" key? We default to observing "./**".
// 4. Non-empty "include" key? Just use the "include" key.
const watchIncludes = patternsArrayForWatch(options.include, {
rootDir,
returnRootDirIfEmpty: true,
});
// For the "exclude" subkey under the "watch" key,
// the behavior is to add some default excludes in addition to the ones specified by the user:
// 1. The CDK output directory.
// 2. Any file whose name starts with a dot.
// 3. Any directory's content whose name starts with a dot.
// 4. Any node_modules and its content (even if it's not a JS/TS project, you might be using a local aws-cli package)
const outdir = assembly.directory;
const watchExcludes = patternsArrayForWatch(options.exclude, {
rootDir,
returnRootDirIfEmpty: false,
});
// only exclude the outdir if it is under the rootDir
const relativeOutDir = path.relative(rootDir, outdir);
if (Boolean(relativeOutDir && !relativeOutDir.startsWith('..' + path.sep) && !path.isAbsolute(relativeOutDir))) {
watchExcludes.push(`${relativeOutDir}/**`);
}
watchExcludes.push('**/.*', '**/.*/**', '**/node_modules/**');
// Print some debug information on computed settings
await ioHelper.notify(IO.CDK_TOOLKIT_I5310.msg([
`root directory used for 'watch' is: ${rootDir}`,
`'include' patterns for 'watch': ${JSON.stringify(watchIncludes)}`,
`'exclude' patterns for 'watch': ${JSON.stringify(watchExcludes)}`,
].join('\n'), {
watchDir: rootDir,
includes: watchIncludes,
excludes: watchExcludes,
}));
// Since 'cdk deploy' is a relatively slow operation for a 'watch' process,
// introduce a concurrency latch that tracks the state.
// This way, if file change events arrive when a 'cdk deploy' is still executing,
// we will batch them, and trigger another 'cdk deploy' after the current one finishes,
// making sure 'cdk deploy's always execute one at a time.
// Here's a diagram showing the state transitions:
// -------------- -------- file changed -------------- file changed -------------- file changed
// | | ready event | | ------------------> | | ------------------> | | --------------|
// | pre-ready | -------------> | open | | deploying | | queued | |
// | | | | <------------------ | | <------------------ | | <-------------|
// -------------- -------- 'cdk deploy' done -------------- 'cdk deploy' done --------------
type LatchState = 'pre-ready' | 'open' | 'deploying' | 'queued';
let latch: LatchState = 'pre-ready';
const cloudWatchLogMonitor = options.traceLogs ? new CloudWatchLogEventMonitor({ ioHelper }) : undefined;
const deployAndWatch = async () => {
latch = 'deploying' as LatchState;
await cloudWatchLogMonitor?.deactivate();
await this.invokeDeployFromWatch(assembly, options, cloudWatchLogMonitor);
// If latch is still 'deploying' after the 'await', that's fine,
// but if it's 'queued', that means we need to deploy again
while (latch === 'queued') {
// TypeScript doesn't realize latch can change between 'awaits',
// and thinks the above 'while' condition is always 'false' without the cast
latch = 'deploying';
await ioHelper.notify(IO.CDK_TOOLKIT_I5315.msg("Detected file changes during deployment. Invoking 'cdk deploy' again"));
await this.invokeDeployFromWatch(assembly, options, cloudWatchLogMonitor);
}
latch = 'open';
await cloudWatchLogMonitor?.activate();
};
const watcher = chokidar
.watch(watchIncludes, {
ignored: watchExcludes,
cwd: rootDir,
})
.on('ready', async () => {
latch = 'open';
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg("'watch' received the 'ready' event. From now on, all file changes will trigger a deployment"));
await ioHelper.notify(IO.CDK_TOOLKIT_I5314.msg("Triggering initial 'cdk deploy'"));
await deployAndWatch();
})
.on('all', async (event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', filePath: string) => {
const watchEvent = {
event,
path: filePath,
};
if (latch === 'pre-ready') {
await ioHelper.notify(IO.CDK_TOOLKIT_I5311.msg(`'watch' is observing ${event === 'addDir' ? 'directory' : 'the file'} '${filePath}' for changes`, watchEvent));
} else if (latch === 'open') {
await ioHelper.notify(IO.CDK_TOOLKIT_I5312.msg(`Detected change to '${filePath}' (type: ${event}). Triggering 'cdk deploy'`, watchEvent));
await deployAndWatch();
} else {
// this means latch is either 'deploying' or 'queued'
latch = 'queued';
await ioHelper.notify(IO.CDK_TOOLKIT_I5313.msg(
`Detected change to '${filePath}' (type: ${event}) while 'cdk deploy' is still running. Will queue for another deployment after this one finishes'`,
watchEvent,
));
}
});
const stoppedPromise = promiseWithResolvers<void>();
return {
async dispose() {
await watcher.close();
// Prevents Node from staying alive. There is no 'end' event that the watcher emits
// that we can know it's definitely done, so best we can do is tell it to stop watching,
// stop keeping Node alive, and then pretend that's everything we needed to do.
watcher.unref();
stoppedPromise.resolve();
return stoppedPromise.promise;
},
async waitForEnd() {
return stoppedPromise.promise;
},
async [Symbol.asyncDispose]() {
return this.dispose();
},
} satisfies IWatcher;
}