public async watch()

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;
  }