fs.watch: наблюдение за ФС и live reload

fs.watch подписывает listener на изменения файла или каталога. Используется для кэширования, hot reload модулей, live deploy без остановки сервиса.

Что это

Не callback, а listener — навешивается один раз, срабатывает многократно при каждом изменении. Знает только два типа событий: 'rename' и 'change'. Об удалении напрямую не сообщает — приходит как 'rename', нужно дополнительно проверять fs.stat/fs.access.

API

const fs = require('node:fs');

const watcher = fs.watch('config.json', (event, filename) => {
  // event: 'rename' | 'change'
  console.log(event, filename);
});

// Каталог (recursive на macOS/Windows)
fs.watch('src', { recursive: true }, (event, filename) => { /* ... */ });

watcher.close();

// fs/promises вариант (Node 19+)
const { watch } = require('node:fs/promises');
for await (const e of watch('src', { recursive: true })) {
  console.log(e.eventType, e.filename);
}

Подводные камни

  • Дублирование событий: текстовые редакторы сохраняют atomic-save (пишут во временный файл → rename), приходит 2-3 события вместо одного. Решение: throttle/debounce обёртка
  • Удаление файла: приходит как 'rename'. Чтобы отличить — после события вызвать fs.access и проверить ENOENT
  • watch не рекурсивен на Linux по умолчанию — нужно ставить watcher на каждый подкаталог вручную или библиотеку chokidar
  • Дескрипторы: каждый watch держит файловый дескриптор, при много-watch это ограничение ОС

Live reload через watch + require.cache

Паттерн: меняем серверный код на лету, сервис не перезапускается.

function reloadModule(modulePath) {
  // 1. Получить абсолютный путь — это ключ в require.cache
  const fullPath = require.resolve(modulePath);
  // 2. Удалить из кэша
  delete require.cache[fullPath];
  // 3. require снова — подгрузится свежая версия
  try {
    return require(fullPath);
  } catch (err) {
    // syntax error в новом файле — не уронить процесс
    console.error('Reload failed:', err);
  }
}

fs.watch('./lib', { recursive: true }, (event, filename) => {
  const mod = reloadModule(`./lib/${filename}`);
  if (mod) routes = mod.routes; // подменили коллекцию роутов разом
});

Опасности live reload в production

  • Файлы приходят не разом: при деплое через rsync/git/npm файлы появляются по одному. Если подгружать каждое изменение — поймаешь промежуточное состояние
  • Решение: накапливать изменения в буфере, применять разом по команде (атомарное переключение версии)
  • Сосуществование версий в памяти: старая лямбда не вызвала callback → ещё живёт; новая уже загружена. Обе обращаются к общим структурам — могут конфликтовать
  • require.cache для ESM не работает: с import/export нет прямого аналога, нужны loaders

🎓 Источники

  • 🎓 Наблюдение за файловой системой в Node.js · 2018-11-28

    • Тезисы:
      • watch — это listener, не callback: ставится один раз, срабатывает многократно
      • Редактор даёт 2 события на одно сохранение (atomic save через rename во временный файл)
      • watch знает только rename и change, удаление приходит как rename
      • Кеш файлов в Map: ключ — относительный путь, значение — Buffer; cacheFolder через readdir + cacheFile
      • Можно держать в кэше уже сжатые данные (gzip заранее) и писать буфер прямо в сокет
      • Live reload без остановки сервиса: для деплоя нужно атомарное переключение, иначе ловишь промежуточное состояние
    • Цитата:

      «Если будет live reload, то мониторинг ФС при деплое опасен. Кто-то деплоит rsync, FTP, git clone — не все файлы за один раз приходят на сервер.»

  • 🎓 Архив 2018 — Часть 10 Наблюдение за файловой системой и динамическая подгрузка · 2020-01-08

    • Тезисы:
      • require.resolve(path) даёт абсолютный путь — это ключ в require.cache. Без удаления второй require берёт из кэша
      • require.cache[fullPath] хранит структуру модуля: loaded, children, parent, exports
      • require.extensions['.js'/'.node'] — обработчики расширений (.node для нативных плагинов)
      • Альтернатива require — vm.createScript + vm.createContext: безопасная замена eval с песочницей
      • Каждый файл Node оборачивается в функцию, аргументами идут module, exports, require, поэтому require — не глобальный
    • Цитата:

      «У require есть resolve, которая нам из имени файла дает по какому он ключу лежит в кэше Require. Мы его можем из кэша Require удалить.»

См. также