All files / cli/src/commands watch.ts

100% Statements 28/28
100% Branches 8/8
100% Functions 12/12
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103                                    1x                       12x 4x 2x       12x 1x   2x 2x     2x 2x     4x 4x 4x 2x   2x   1x   1x     1x       1x   7x                           1x   1x     1x   1x         1x   1x 5x     1x          
import { makePromiseKit } from '@endo/promise-kit';
import type { PromiseKit } from '@endo/promise-kit';
import { Logger } from '@metamask/logger';
import { watch } from 'chokidar';
import type { FSWatcher, MatchFunction } from 'chokidar';
import { unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
 
import { bundleFile as rawBundleFile } from './bundle.ts';
import { resolveBundlePath } from '../path.ts';
 
type CloseWatcher = () => Promise<void>;
 
type WatchDirReturn = {
  ready: Promise<CloseWatcher>;
  error: Promise<never>;
};
 
export const makeWatchEvents = (
  watcher: FSWatcher,
  readyResolve: PromiseKit<CloseWatcher>['resolve'],
  throwError: PromiseKit<never>['reject'],
  logger: Logger,
): {
  ready: () => void;
  add: (path: string) => void;
  change: (path: string) => void;
  unlink: (path: string) => void;
  error: (error: Error) => void;
} => {
  const bundleFile = (path: string): void => {
    rawBundleFile(path, { logger, targetPath: resolveBundlePath(path) }).catch(
      (error) => logger.error(`Failed to bundle file:`, error),
    );
  };
 
  return {
    ready: () => readyResolve(watcher.close.bind(watcher)),
    add: (path) => {
      logger.info(`Source file added:`, path);
      bundleFile(path);
    },
    change: (path) => {
      logger.info(`Source file changed:`, path);
      bundleFile(path);
    },
    unlink: (path) => {
      logger.info('Source file removed:', path);
      const bundlePath = resolveBundlePath(path);
      unlink(bundlePath)
        .then(() => logger.info(`Removed:`, bundlePath))
        .catch((reason: unknown) => {
          if (reason instanceof Error && reason.message.match(/ENOENT/u)) {
            // If associated bundle does not exist, do nothing.
            return;
          }
          throwError(reason);
        });
    },
    error: (error: Error) => throwError(error),
  };
};
 
export const shouldIgnore: MatchFunction = (file, stats): boolean =>
  // Ignore files and directories in `node_modules`.
  file.includes('node_modules') ||
  // Watch non-files, but ignore files that are not JavaScript.
  ((stats?.isFile() ?? false) && !file.endsWith('.js'));
 
/**
 * Start a watcher that bundles `.js` files in the target dir.
 *
 * @param dir - The directory to watch.
 * @param logger - The logger to use.
 * @returns A {@link WatchDirReturn} object with `ready` and `error` properties which are promises.
 *  The `ready` promise resolves to an awaitable method to close the watcher.
 *  The `error` promise never resolves, but rejects when any of the watcher's behaviors encounters an irrecoverable error.
 */
export function watchDir(dir: string, logger: Logger): WatchDirReturn {
  const resolvedDir = resolve(dir);
 
  const { resolve: readyResolve, promise: readyPromise } =
    makePromiseKit<CloseWatcher>();
 
  const { reject: throwError, promise: errorPromise } = makePromiseKit<never>();
 
  let watcher = watch(resolvedDir, {
    ignoreInitial: false,
    ignored: shouldIgnore,
  });
 
  const events = makeWatchEvents(watcher, readyResolve, throwError, logger);
 
  for (const key of Object.keys(events)) {
    watcher = watcher.on(key, events[key as keyof typeof events] as never);
  }
 
  return {
    ready: readyPromise,
    error: errorPromise,
  };
}