All files / cli/src/dev-server file-watcher.js

87.5% Statements 35/40
61.11% Branches 11/18
100% Functions 7/7
96.96% Lines 32/33

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 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                                                                    3x               3x 3x     6x 6x 6x 6x 2x 1x       6x   6x 6x                                     9x   9x               9x     6x 6x 6x 6x 6x 6x                 6x     9x 9x 9x   9x 9x     9x   9x   9x 9x 9x        
/**
 * File Watcher for the dev server.
 *
 * Wraps chokidar with a small projection that maps absolute file paths
 * to HMR-protocol payloads (filePath + webPath + updateType) and a per-
 * file debounce so rapid editor saves coalesce into one HMR message.
 *
 * @module @coherent.js/cli/dev-server/file-watcher
 */
 
import chokidar from 'chokidar';
import { relative, sep } from 'node:path';
 
/**
 * @typedef {Object} FileChangeEvent
 * @property {string} filePath - Absolute filesystem path of the changed file.
 * @property {string} webPath - URL-relative path (POSIX separators, leading slash).
 * @property {'component'|'style'|'asset'} updateType - Coarse classification used by the HMR client to pick a strategy.
 */
 
/**
 * @typedef {Object} FileWatcherOptions
 * @property {string} root - Absolute path to the project root being watched.
 * @property {(change: FileChangeEvent) => void} onChange - Called per debounced change.
 * @property {(err: Error) => void} [onError] - Called on chokidar errors.
 * @property {number} [debounceMs=50] - Coalesce rapid writes to the same file within this window.
 * @property {Array<string|RegExp>} [ignored] - Additional ignore patterns (merged with defaults).
 */
 
/**
 * @typedef {Object} FileWatcher
 * @property {() => Promise<void>} close - Stop watching and release resources.
 */
 
const DEFAULT_IGNORES = [
  /(^|[/\\])\../,             // dotfiles + dotted dirs (.git, .DS_Store, etc.)
  /(^|[/\\])node_modules([/\\]|$)/,
  /(^|[/\\])dist([/\\]|$)/,
  /(^|[/\\])coverage([/\\]|$)/,
  /(^|[/\\])\.cache([/\\]|$)/,
];
 
const COMPONENT_EXTS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']);
const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
 
function classify(path) {
  const dot = path.lastIndexOf('.');
  Iif (dot < 0) return 'asset';
  const ext = path.slice(dot).toLowerCase();
  if (COMPONENT_EXTS.has(ext)) return 'component';
  if (STYLE_EXTS.has(ext)) return 'style';
  return 'asset';
}
 
function toWebPath(root, absPath) {
  const rel = relative(root, absPath);
  // Normalize separators to forward slashes for the URL
  const posix = sep === '\\' ? rel.split(sep).join('/') : rel;
  return posix.startsWith('/') ? posix : `/${posix}`;
}
 
/**
 * Create a debounced chokidar-backed file watcher.
 *
 * Resolves once chokidar has emitted `ready` so callers know the
 * initial scan is finished and subsequent writes are real edits.
 *
 * @param {FileWatcherOptions} options
 * @returns {Promise<FileWatcher>}
 */
export async function createFileWatcher(options) {
  const {
    root,
    onChange,
    onError,
    debounceMs = 50,
    ignored = [],
  } = options;
 
  const watcher = chokidar.watch(root, {
    ignored: [...DEFAULT_IGNORES, ...ignored],
    ignoreInitial: true,
    persistent: true,
    awaitWriteFinish: false,
  });
 
  /** @type {Map<string, NodeJS.Timeout>} */
  const pending = new Map();
 
  function schedule(filePath) {
    const prev = pending.get(filePath);
    Iif (prev) clearTimeout(prev);
    const timer = setTimeout(() => {
      pending.delete(filePath);
      try {
        onChange({
          filePath,
          webPath: toWebPath(root, filePath),
          updateType: classify(filePath),
        });
      } catch (err) {
        if (onError) onError(err);
      }
    }, debounceMs);
    pending.set(filePath, timer);
  }
 
  watcher.on('add', schedule);
  watcher.on('change', schedule);
  watcher.on('unlink', schedule);
 
  Eif (onError) {
    watcher.on('error', onError);
  }
 
  await new Promise((resolve) => watcher.once('ready', resolve));
 
  return {
    async close() {
      for (const timer of pending.values()) clearTimeout(timer);
      pending.clear();
      await watcher.close();
    },
  };
}