T-CREATOR

Node.js ファイル監視:fs.watch とイベント駆動設計

Node.js ファイル監視:fs.watch とイベント駆動設計

Node.js での Web アプリケーション開発やサーバーサイド開発において、ファイルの変更をリアルタイムで検知する機能は欠かせません。開発時のホットリロード機能や、設定ファイルの変更検知など、ファイル監視は現代の開発フローに深く根ざした重要な技術です。本記事では、Node.js の標準モジュールであるfs.watchを活用したファイル監視の実装方法と、イベント駆動設計による効率的なアーキテクチャの構築方法について、実践的なコード例とともに詳しく解説いたします。

Node.js におけるファイル監視の重要性

現代の Web 開発では、ファイルの変更を自動的に検知してアプリケーションの動作を更新する仕組みが当たり前となっています。

開発効率の向上

ファイル監視機能により、以下のような開発体験の向上が実現できます。

#機能効果具体例
1ホットリロードコード変更の即時反映Next.js 開発サーバー
2自動テスト実行変更時のテスト自動化Jest watch モード
3設定ファイル監視再起動不要の設定更新webpack.config.js
4静的ファイル処理アセット変更の自動処理Sass/TypeScript コンパイル

このような機能を実現するために、Node.js ではfsモジュールが提供するファイル監視 API を活用できます。

本番環境での活用シーン

開発環境だけでなく、本番環境でもファイル監視は重要な役割を果たします。

typescript// 設定ファイルの動的リロード例
import { watch } from 'fs';
import { readFileSync } from 'fs';

interface AppConfig {
  port: number;
  database: {
    host: string;
    port: number;
  };
}

class ConfigManager {
  private config: AppConfig;
  private configPath: string;

  constructor(configPath: string) {
    this.configPath = configPath;
    this.loadConfig();
    this.setupFileWatching();
  }

  // 設定ファイルの読み込み
  private loadConfig(): void {
    try {
      const configData = readFileSync(
        this.configPath,
        'utf-8'
      );
      this.config = JSON.parse(configData);
      console.log('設定ファイルを読み込みました');
    } catch (error) {
      console.error(
        '設定ファイルの読み込みに失敗しました:',
        error
      );
    }
  }

  // ファイル監視の設定
  private setupFileWatching(): void {
    watch(this.configPath, (eventType) => {
      if (eventType === 'change') {
        console.log(
          '設定ファイルが変更されました。再読み込み中...'
        );
        this.loadConfig();
      }
    });
  }

  public getConfig(): AppConfig {
    return this.config;
  }
}

上記のコードでは、設定ファイルの変更を監視し、変更があった場合に自動的に新しい設定を読み込む仕組みを実装しています。

fs.watch の基本的な使い方

Node.js のfs.watchは、ファイルやディレクトリの変更を監視する強力な API です。基本的な使い方から詳しく見ていきましょう。

基本的な監視の実装

最もシンプルなファイル監視の実装は以下のようになります。

javascriptimport { watch } from 'fs';

// 単一ファイルの監視
const watcher = watch(
  './target-file.txt',
  (eventType, filename) => {
    console.log(`イベントタイプ: ${eventType}`);
    console.log(`ファイル名: ${filename}`);
  }
);

// 監視の停止
// watcher.close();

この基本形では、ファイルに変更があるたびにコールバック関数が実行されます。

イベントタイプの詳細理解

fs.watchでは主に 2 つのイベントタイプが発生します。

#イベントタイプ発生タイミング注意点
1renameファイルの作成・削除・名前変更プラットフォーム依存
2changeファイル内容の変更複数回発生する可能性
typescriptimport { watch, FSWatcher } from 'fs';
import { stat } from 'fs/promises';

interface FileWatchOptions {
  persistent?: boolean;
  recursive?: boolean;
  encoding?: BufferEncoding;
}

class FileWatcher {
  private watcher: FSWatcher | null = null;
  private targetPath: string;

  constructor(targetPath: string) {
    this.targetPath = targetPath;
  }

  // ファイル監視の開始
  public startWatching(
    options: FileWatchOptions = {}
  ): void {
    const defaultOptions = {
      persistent: true,
      recursive: false,
      encoding: 'utf8' as BufferEncoding,
    };

    const watchOptions = { ...defaultOptions, ...options };

    this.watcher = watch(
      this.targetPath,
      watchOptions,
      async (eventType, filename) => {
        console.log(`\n--- ファイル変更イベント ---`);
        console.log(`イベントタイプ: ${eventType}`);
        console.log(`ファイル名: ${filename}`);
        console.log(`監視パス: ${this.targetPath}`);

        await this.handleFileEvent(eventType, filename);
      }
    );

    this.watcher.on('error', (error) => {
      console.error('ファイル監視エラー:', error);
    });

    console.log(
      `ファイル監視を開始しました: ${this.targetPath}`
    );
  }

  // ファイルイベントの処理
  private async handleFileEvent(
    eventType: string,
    filename: string | null
  ): Promise<void> {
    if (!filename) {
      console.log('ファイル名が取得できませんでした');
      return;
    }

    try {
      const filePath = `${this.targetPath}/${filename}`;
      const stats = await stat(filePath);

      if (eventType === 'change') {
        console.log(
          `ファイルが変更されました: ${filename}`
        );
        console.log(`ファイルサイズ: ${stats.size} bytes`);
        console.log(`最終更新: ${stats.mtime}`);
      } else if (eventType === 'rename') {
        console.log(
          `ファイルが作成/削除/名前変更されました: ${filename}`
        );
      }
    } catch (error) {
      // ファイルが削除された場合のエラーハンドリング
      if (
        (error as NodeJS.ErrnoException).code === 'ENOENT'
      ) {
        console.log(
          `ファイルが削除されました: ${filename}`
        );
      } else {
        console.error(
          'ファイル情報の取得に失敗しました:',
          error
        );
      }
    }
  }

  // 監視の停止
  public stopWatching(): void {
    if (this.watcher) {
      this.watcher.close();
      this.watcher = null;
      console.log('ファイル監視を停止しました');
    }
  }
}

再帰的なディレクトリ監視

ディレクトリ全体とその子ディレクトリを監視したい場合は、recursiveオプションを使用します。

typescriptimport { watch } from 'fs';
import path from 'path';

// ディレクトリの再帰監視(Windows、macOSでサポート)
const directoryWatcher = watch(
  './src',
  { recursive: true },
  (eventType, filename) => {
    if (filename) {
      const fullPath = path.join('./src', filename);
      console.log(`${eventType}: ${fullPath}`);

      // TypeScriptファイルのみを対象とする例
      if (
        path.extname(filename) === '.ts' ||
        path.extname(filename) === '.tsx'
      ) {
        console.log('TypeScriptファイルが変更されました');
        // ここでコンパイル処理などを実行
      }
    }
  }
);

この基本的な監視機能を理解したところで、次はfs.watchfs.watchFileの違いについて詳しく見ていきましょう。

fs.watch と fs.watchFile の違いと使い分け

Node.js にはfs.watchの他にfs.watchFileという API も存在します。それぞれの特徴と適切な使い分けについて解説いたします。

技術的な違いの比較

#項目fs.watchfs.watchFile
1実装方式OS 依存のイベント監視ポーリング方式
2パフォーマンス高性能(リアルタイム)低性能(定期チェック)
3対応プラットフォームWindows, macOS, Linux全プラットフォーム
4監視対象ファイル・ディレクトリファイルのみ
5イベント詳細度高い(eventType)低い(stats のみ)

fs.watchFile の特徴と使用例

fs.watchFileはポーリング方式でファイルの変更を検知します。

typescriptimport { watchFile, unwatchFile, Stats } from 'fs';

interface FileStats {
  current: Stats;
  previous: Stats;
}

// fs.watchFileの基本的な使い方
watchFile(
  './config.json',
  { interval: 1000 },
  (current: Stats, previous: Stats) => {
    console.log('--- fs.watchFile イベント ---');
    console.log(`現在のファイルサイズ: ${current.size}`);
    console.log(`前回のファイルサイズ: ${previous.size}`);
    console.log(`最終更新時刻: ${current.mtime}`);

    // ファイルサイズが変更された場合
    if (current.size !== previous.size) {
      console.log('ファイルサイズが変更されました');
    }

    // ファイルが削除された場合
    if (current.nlink === 0) {
      console.log('ファイルが削除されました');
      unwatchFile('./config.json'); // 監視を停止
    }
  }
);

// クラスベースでの実装例
class PollingFileWatcher {
  private filePath: string;
  private interval: number;
  private isWatching: boolean = false;

  constructor(filePath: string, interval: number = 1000) {
    this.filePath = filePath;
    this.interval = interval;
  }

  // 監視開始
  public startWatching(): void {
    if (this.isWatching) {
      console.log('既に監視中です');
      return;
    }

    watchFile(
      this.filePath,
      { interval: this.interval },
      this.handleFileChange.bind(this)
    );
    this.isWatching = true;
    console.log(
      `ポーリング監視を開始しました: ${this.filePath} (間隔: ${this.interval}ms)`
    );
  }

  // ファイル変更の処理
  private handleFileChange(
    current: Stats,
    previous: Stats
  ): void {
    const changes: string[] = [];

    // 各種変更の検出
    if (current.size !== previous.size) {
      changes.push(
        `サイズ: ${previous.size}${current.size}`
      );
    }

    if (
      current.mtime.getTime() !== previous.mtime.getTime()
    ) {
      changes.push(
        `更新時刻: ${previous.mtime}${current.mtime}`
      );
    }

    if (current.mode !== previous.mode) {
      changes.push(
        `パーミッション: ${previous.mode}${current.mode}`
      );
    }

    if (changes.length > 0) {
      console.log(`\nファイル変更を検出: ${this.filePath}`);
      changes.forEach((change) =>
        console.log(`  - ${change}`)
      );
    }
  }

  // 監視停止
  public stopWatching(): void {
    if (!this.isWatching) {
      console.log('監視は開始されていません');
      return;
    }

    unwatchFile(this.filePath);
    this.isWatching = false;
    console.log(
      `ポーリング監視を停止しました: ${this.filePath}`
    );
  }
}

適切な使い分けの判断基準

どちらの API を使うべきかは、以下の基準で判断できます。

typescript// 使い分けの判断ロジック
class FileWatcherSelector {
  public static selectWatcher(requirements: {
    realTimeRequired: boolean;
    crossPlatform: boolean;
    resourceUsage: 'low' | 'normal' | 'high';
    networkDrive: boolean;
  }) {
    // ネットワークドライブの場合
    if (requirements.networkDrive) {
      console.log(
        '推奨: fs.watchFile(ネットワークドライブのため)'
      );
      return 'watchFile';
    }

    // リアルタイム性が重要で、リソース使用量が許容できる場合
    if (
      requirements.realTimeRequired &&
      requirements.resourceUsage !== 'low'
    ) {
      console.log(
        '推奨: fs.watch(リアルタイム監視のため)'
      );
      return 'watch';
    }

    // クロスプラットフォーム対応が必須の場合
    if (requirements.crossPlatform) {
      console.log(
        '推奨: fs.watchFile(プラットフォーム互換性のため)'
      );
      return 'watchFile';
    }

    // リソース使用量を抑えたい場合
    if (requirements.resourceUsage === 'low') {
      console.log(
        '推奨: fs.watchFile(リソース使用量が少ない)'
      );
      return 'watchFile';
    }

    console.log('推奨: fs.watch(一般的な用途)');
    return 'watch';
  }
}

// 使用例
const requirements = {
  realTimeRequired: true,
  crossPlatform: false,
  resourceUsage: 'normal' as const,
  networkDrive: false,
};

const selectedWatcher =
  FileWatcherSelector.selectWatcher(requirements);

このように、要件に応じて適切なファイル監視 API を選択することで、効率的なアプリケーションを構築できます。

イベント駆動設計の基本概念

イベント駆動設計(Event-Driven Design)は、システム内でイベントの発生とその処理を中心とした設計パターンです。ファイル監視においても、この設計思想を活用することで、拡張性と保守性の高いアーキテクチャを構築できます。

イベント駆動設計の核となる概念

イベント駆動設計では以下の要素が重要な役割を果たします。

#要素役割具体例
1Event(イベント)何が起こったかの情報ファイル変更、削除、作成
2Event Emitter(発行者)イベントを発行する主体ファイル監視システム
3Event Listener(購読者)イベントを受信し処理する主体ビルドシステム、ログシステム
4Event Loop(イベントループ)イベントの配信と処理を管理Node.js のイベントループ

Node.js の EventEmitter を活用した実装

Node.js の EventEmitter クラスを継承して、カスタムファイル監視システムを構築してみましょう。

typescriptimport { EventEmitter } from 'events';
import { watch, FSWatcher, Stats } from 'fs';
import { stat } from 'fs/promises';
import path from 'path';

// ファイル変更イベントの型定義
interface FileChangeEvent {
  type: 'create' | 'update' | 'delete' | 'rename';
  path: string;
  filename: string;
  timestamp: Date;
  size?: number;
}

// エラーイベントの型定義
interface FileWatchError {
  code: string;
  message: string;
  path: string;
  timestamp: Date;
}

class EventDrivenFileWatcher extends EventEmitter {
  private watchers: Map<string, FSWatcher> = new Map();
  private isActive: boolean = false;

  constructor() {
    super();
    // 最大リスナー数を設定(メモリリーク警告を回避)
    this.setMaxListeners(50);
  }

  // ディレクトリの監視開始
  public async watchDirectory(
    directoryPath: string
  ): Promise<void> {
    try {
      const absolutePath = path.resolve(directoryPath);

      if (this.watchers.has(absolutePath)) {
        this.emit(
          'warning',
          `既に監視中です: ${absolutePath}`
        );
        return;
      }

      const watcher = watch(
        absolutePath,
        { recursive: true },
        async (eventType, filename) => {
          if (filename) {
            await this.processFileEvent(
              eventType,
              filename,
              absolutePath
            );
          }
        }
      );

      // エラーハンドリング
      watcher.on('error', (error) => {
        const errorEvent: FileWatchError = {
          code:
            (error as NodeJS.ErrnoException).code ||
            'UNKNOWN',
          message: error.message,
          path: absolutePath,
          timestamp: new Date(),
        };
        this.emit('error', errorEvent);
      });

      this.watchers.set(absolutePath, watcher);
      this.isActive = true;

      this.emit('watchStart', {
        path: absolutePath,
        timestamp: new Date(),
      });
    } catch (error) {
      const errorEvent: FileWatchError = {
        code:
          (error as NodeJS.ErrnoException).code ||
          'UNKNOWN',
        message: (error as Error).message,
        path: directoryPath,
        timestamp: new Date(),
      };
      this.emit('error', errorEvent);
    }
  }

  // ファイルイベントの処理
  private async processFileEvent(
    eventType: string,
    filename: string,
    basePath: string
  ): Promise<void> {
    const fullPath = path.join(basePath, filename);
    const timestamp = new Date();

    try {
      if (eventType === 'change') {
        // ファイルの更新イベント
        const stats = await stat(fullPath);
        const changeEvent: FileChangeEvent = {
          type: 'update',
          path: fullPath,
          filename,
          timestamp,
          size: stats.size,
        };
        this.emit('fileUpdate', changeEvent);
      } else if (eventType === 'rename') {
        // ファイルの作成/削除/名前変更イベント
        try {
          const stats = await stat(fullPath);
          // ファイルが存在する場合は作成イベント
          const createEvent: FileChangeEvent = {
            type: 'create',
            path: fullPath,
            filename,
            timestamp,
            size: stats.size,
          };
          this.emit('fileCreate', createEvent);
        } catch (error) {
          // ファイルが存在しない場合は削除イベント
          if (
            (error as NodeJS.ErrnoException).code ===
            'ENOENT'
          ) {
            const deleteEvent: FileChangeEvent = {
              type: 'delete',
              path: fullPath,
              filename,
              timestamp,
            };
            this.emit('fileDelete', deleteEvent);
          }
        }
      }
    } catch (error) {
      const errorEvent: FileWatchError = {
        code:
          (error as NodeJS.ErrnoException).code ||
          'UNKNOWN',
        message: (error as Error).message,
        path: fullPath,
        timestamp,
      };
      this.emit('error', errorEvent);
    }
  }

  // 監視の停止
  public stopWatching(directoryPath?: string): void {
    if (directoryPath) {
      const absolutePath = path.resolve(directoryPath);
      const watcher = this.watchers.get(absolutePath);
      if (watcher) {
        watcher.close();
        this.watchers.delete(absolutePath);
        this.emit('watchStop', {
          path: absolutePath,
          timestamp: new Date(),
        });
      }
    } else {
      // 全ての監視を停止
      this.watchers.forEach((watcher, path) => {
        watcher.close();
        this.emit('watchStop', {
          path,
          timestamp: new Date(),
        });
      });
      this.watchers.clear();
    }

    this.isActive = this.watchers.size > 0;
  }

  // 監視状態の確認
  public getWatchingPaths(): string[] {
    return Array.from(this.watchers.keys());
  }

  public isWatching(): boolean {
    return this.isActive;
  }
}

イベントリスナーの実装例

作成したファイル監視システムを使用する側の実装例です。

typescript// ビルドシステムのイベントリスナー
class BuildSystem {
  private fileWatcher: EventDrivenFileWatcher;
  private buildQueue: Set<string> = new Set();
  private buildTimeout: NodeJS.Timeout | null = null;

  constructor(fileWatcher: EventDrivenFileWatcher) {
    this.fileWatcher = fileWatcher;
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    // TypeScriptファイルの更新を監視
    this.fileWatcher.on(
      'fileUpdate',
      (event: FileChangeEvent) => {
        if (this.isTypeScriptFile(event.filename)) {
          console.log(
            `TypeScriptファイルが更新されました: ${event.filename}`
          );
          this.queueBuild(event.path);
        }
      }
    );

    // 新しいファイルの作成を監視
    this.fileWatcher.on(
      'fileCreate',
      (event: FileChangeEvent) => {
        if (this.isTypeScriptFile(event.filename)) {
          console.log(
            `新しいTypeScriptファイルが作成されました: ${event.filename}`
          );
          this.queueBuild(event.path);
        }
      }
    );

    // ファイル削除時の処理
    this.fileWatcher.on(
      'fileDelete',
      (event: FileChangeEvent) => {
        if (this.isTypeScriptFile(event.filename)) {
          console.log(
            `TypeScriptファイルが削除されました: ${event.filename}`
          );
          this.handleFileDelete(event.path);
        }
      }
    );

    // エラーハンドリング
    this.fileWatcher.on(
      'error',
      (error: FileWatchError) => {
        console.error(
          `ファイル監視エラー [${error.code}]: ${error.message}`
        );
      }
    );
  }

  private isTypeScriptFile(filename: string): boolean {
    return /\.(ts|tsx)$/.test(filename);
  }

  private queueBuild(filePath: string): void {
    this.buildQueue.add(filePath);

    // デバウンス処理:連続した変更を一つのビルドにまとめる
    if (this.buildTimeout) {
      clearTimeout(this.buildTimeout);
    }

    this.buildTimeout = setTimeout(() => {
      this.executeBuild();
    }, 300); // 300ms の遅延
  }

  private async executeBuild(): Promise<void> {
    if (this.buildQueue.size === 0) return;

    const filesToBuild = Array.from(this.buildQueue);
    this.buildQueue.clear();

    console.log(`\n--- ビルド開始 ---`);
    console.log(`対象ファイル数: ${filesToBuild.length}`);

    try {
      // ここで実際のビルド処理を実行
      await this.performBuild(filesToBuild);
      console.log('ビルドが完了しました');
    } catch (error) {
      console.error('ビルドエラー:', error);
    }
  }

  private async performBuild(
    files: string[]
  ): Promise<void> {
    // 実際のビルド処理(例:TypeScriptコンパイル)
    console.log('TypeScriptをコンパイル中...');
    // await execAsync('tsc --build');

    // ここではシミュレーション
    await new Promise((resolve) =>
      setTimeout(resolve, 1000)
    );
  }

  private handleFileDelete(filePath: string): void {
    // 削除されたファイルに対応する出力ファイルも削除
    const outputPath = filePath.replace(
      /\.(ts|tsx)$/,
      '.js'
    );
    console.log(
      `対応する出力ファイルを削除します: ${outputPath}`
    );
  }
}

fs.watch を活用したイベント駆動アーキテクチャの構築

実践的なファイル監視システムを構築するため、複数のコンポーネントが協調動作するアーキテクチャを設計してみましょう。

アーキテクチャの全体設計

typescript// メインのアプリケーションクラス
class FileWatchApplication {
  private fileWatcher: EventDrivenFileWatcher;
  private buildSystem: BuildSystem;
  private logSystem: LogSystem;
  private notificationSystem: NotificationSystem;

  constructor() {
    this.fileWatcher = new EventDrivenFileWatcher();
    this.buildSystem = new BuildSystem(this.fileWatcher);
    this.logSystem = new LogSystem(this.fileWatcher);
    this.notificationSystem = new NotificationSystem(
      this.fileWatcher
    );
  }

  // アプリケーションの開始
  public async start(watchPaths: string[]): Promise<void> {
    try {
      console.log(
        'ファイル監視アプリケーションを開始します'
      );

      // 各監視パスを設定
      for (const watchPath of watchPaths) {
        await this.fileWatcher.watchDirectory(watchPath);
      }

      console.log(`監視対象パス: ${watchPaths.join(', ')}`);
      console.log(
        '監視を開始しました。Ctrl+C で終了します。'
      );
    } catch (error) {
      console.error(
        'アプリケーションの開始に失敗しました:',
        error
      );
      throw error;
    }
  }

  // アプリケーションの終了
  public stop(): void {
    console.log('\nアプリケーションを終了しています...');
    this.fileWatcher.stopWatching();
    console.log(
      'ファイル監視アプリケーションが終了しました'
    );
  }
}

// ログシステム
class LogSystem {
  private fileWatcher: EventDrivenFileWatcher;
  private logFile: string;

  constructor(fileWatcher: EventDrivenFileWatcher) {
    this.fileWatcher = fileWatcher;
    this.logFile = './file-changes.log';
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    // 全てのファイルイベントをログに記録
    this.fileWatcher.on('fileUpdate', (event) =>
      this.logEvent('UPDATE', event)
    );
    this.fileWatcher.on('fileCreate', (event) =>
      this.logEvent('CREATE', event)
    );
    this.fileWatcher.on('fileDelete', (event) =>
      this.logEvent('DELETE', event)
    );
    this.fileWatcher.on('error', (error) =>
      this.logError(error)
    );
  }

  private logEvent(
    action: string,
    event: FileChangeEvent
  ): void {
    const logEntry = `[${event.timestamp.toISOString()}] ${action}: ${
      event.path
    } (${event.size || 0} bytes)`;
    console.log(logEntry);
    // ここで実際のファイルへの書き込みを行う
  }

  private logError(error: FileWatchError): void {
    const logEntry = `[${error.timestamp.toISOString()}] ERROR [${
      error.code
    }]: ${error.message} (${error.path})`;
    console.error(logEntry);
  }
}

// 通知システム
class NotificationSystem {
  private fileWatcher: EventDrivenFileWatcher;

  constructor(fileWatcher: EventDrivenFileWatcher) {
    this.fileWatcher = fileWatcher;
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    // 重要なファイルの変更を通知
    this.fileWatcher.on('fileUpdate', (event) => {
      if (this.isImportantFile(event.filename)) {
        this.sendNotification(
          `重要なファイルが更新されました: ${event.filename}`
        );
      }
    });

    // エラー発生時の通知
    this.fileWatcher.on('error', (error) => {
      this.sendNotification(
        `ファイル監視エラーが発生しました: ${error.message}`
      );
    });
  }

  private isImportantFile(filename: string): boolean {
    const importantPatterns = [
      /package\.json$/,
      /tsconfig\.json$/,
      /\.env$/,
      /webpack\.config\./,
    ];

    return importantPatterns.some((pattern) =>
      pattern.test(filename)
    );
  }

  private sendNotification(message: string): void {
    console.log(`🔔 通知: ${message}`);
    // ここで実際の通知システム(Slack、メールなど)との連携を行う
  }
}

実践的な活用例:ホットリロード機能の実装

開発効率を大幅に向上させるホットリロード機能を、ファイル監視とイベント駆動設計を活用して実装してみましょう。

Express.js サーバーのホットリロード

typescriptimport express from 'express';
import { Server } from 'http';
import {
  EventDrivenFileWatcher,
  FileChangeEvent,
} from './file-watcher';

class HotReloadServer {
  private app: express.Application;
  private server: Server | null = null;
  private fileWatcher: EventDrivenFileWatcher;
  private port: number;
  private isRestarting: boolean = false;

  constructor(port: number = 3000) {
    this.port = port;
    this.app = express();
    this.fileWatcher = new EventDrivenFileWatcher();
    this.setupServer();
    this.setupFileWatching();
  }

  private setupServer(): void {
    // 静的ファイルの配信
    this.app.use(express.static('public'));

    // APIエンドポイントの例
    this.app.get('/api/health', (req, res) => {
      res.json({
        status: 'ok',
        timestamp: new Date().toISOString(),
        uptime: process.uptime(),
      });
    });

    // サーバーサイドレンダリングの例
    this.app.get('/', (req, res) => {
      res.send(`
        <!DOCTYPE html>
        <html>
        <head>
          <title>Hot Reload Demo</title>
          <script>
            // WebSocketでホットリロード機能を実装
            const ws = new WebSocket('ws://localhost:3001');
            ws.onmessage = function(event) {
              if (event.data === 'reload') {
                window.location.reload();
              }
            };
          </script>
        </head>
        <body>
          <h1>Hot Reload Demo</h1>
          <p>最終更新: ${new Date().toLocaleString()}</p>
          <p>ファイルを変更するとページが自動的にリロードされます</p>
        </body>
        </html>
      `);
    });
  }

  private setupFileWatching(): void {
    // サーバーファイルの変更を監視
    this.fileWatcher.on(
      'fileUpdate',
      (event: FileChangeEvent) => {
        if (this.shouldTriggerReload(event.filename)) {
          console.log(
            `🔄 ファイル変更を検出: ${event.filename}`
          );
          this.scheduleRestart();
        }
      }
    );

    // 新しいファイルの作成も監視
    this.fileWatcher.on(
      'fileCreate',
      (event: FileChangeEvent) => {
        if (this.shouldTriggerReload(event.filename)) {
          console.log(
            `📁 新しいファイルを検出: ${event.filename}`
          );
          this.scheduleRestart();
        }
      }
    );

    // エラーハンドリング
    this.fileWatcher.on('error', (error) => {
      console.error(
        `❌ ファイル監視エラー: ${error.message}`
      );
    });
  }

  private shouldTriggerReload(filename: string): boolean {
    // リロードをトリガーするファイルパターン
    const reloadPatterns = [
      /\.(js|ts|jsx|tsx)$/, // JavaScript/TypeScript
      /\.(html|css|scss)$/, // フロントエンド
      /\.json$/, // 設定ファイル
      /\.env$/, // 環境変数
    ];

    // 除外するパターン
    const excludePatterns = [
      /node_modules/,
      /\.git/,
      /dist/,
      /build/,
      /\.log$/,
    ];

    // 除外パターンに一致する場合は無視
    if (
      excludePatterns.some((pattern) =>
        pattern.test(filename)
      )
    ) {
      return false;
    }

    // リロードパターンに一致するかチェック
    return reloadPatterns.some((pattern) =>
      pattern.test(filename)
    );
  }

  private scheduleRestart(): void {
    if (this.isRestarting) {
      return; // 既に再起動処理中
    }

    this.isRestarting = true;

    // デバウンス処理:短時間の連続変更をまとめる
    setTimeout(() => {
      this.restart();
    }, 500);
  }

  private restart(): void {
    console.log('🔄 サーバーを再起動しています...');

    if (this.server) {
      this.server.close(() => {
        this.startServer();
        this.isRestarting = false;
      });
    } else {
      this.startServer();
      this.isRestarting = false;
    }
  }

  // サーバーの開始
  public async start(): Promise<void> {
    try {
      // ファイル監視を開始
      await this.fileWatcher.watchDirectory('./src');
      await this.fileWatcher.watchDirectory('./public');

      this.startServer();

      console.log(
        `🚀 ホットリロードサーバーが開始されました`
      );
      console.log(`   URL: http://localhost:${this.port}`);
      console.log(
        `   監視中のディレクトリ: ./src, ./public`
      );
    } catch (error) {
      console.error(
        '❌ サーバーの開始に失敗しました:',
        error
      );
      throw error;
    }
  }

  private startServer(): void {
    this.server = this.app.listen(this.port, () => {
      console.log(
        `✅ サーバーがポート ${
          this.port
        } で起動しました (${new Date().toLocaleTimeString()})`
      );
    });

    this.server.on(
      'error',
      (error: NodeJS.ErrnoException) => {
        if (error.code === 'EADDRINUSE') {
          console.error(
            `❌ ポート ${this.port} は既に使用されています`
          );
        } else {
          console.error('❌ サーバーエラー:', error);
        }
      }
    );
  }

  // サーバーの停止
  public stop(): void {
    console.log('🛑 サーバーを停止しています...');

    this.fileWatcher.stopWatching();

    if (this.server) {
      this.server.close(() => {
        console.log('✅ サーバーが停止されました');
      });
    }
  }
}

// 使用例
const hotReloadServer = new HotReloadServer(3000);

// プロセス終了時のクリーンアップ
process.on('SIGINT', () => {
  hotReloadServer.stop();
  process.exit(0);
});

// サーバー開始
hotReloadServer.start().catch(console.error);

パフォーマンス最適化とベストプラクティス

ファイル監視システムを本番環境で安定稼動させるための最適化手法をご紹介します。

デバウンシング(Debouncing)の実装

ファイル変更イベントが短時間に複数回発生する問題を解決します。

typescriptclass DebouncedFileWatcher extends EventDrivenFileWatcher {
  private debounceTimers: Map<string, NodeJS.Timeout> =
    new Map();
  private debounceDelay: number;

  constructor(debounceDelay: number = 300) {
    super();
    this.debounceDelay = debounceDelay;
  }

  protected processFileEvent(
    eventType: string,
    filename: string,
    basePath: string
  ): Promise<void> {
    const fullPath = path.join(basePath, filename);
    const timerId = this.debounceTimers.get(fullPath);

    // 既存のタイマーをクリア
    if (timerId) {
      clearTimeout(timerId);
    }

    // 新しいタイマーを設定
    const newTimer = setTimeout(async () => {
      await super.processFileEvent(
        eventType,
        filename,
        basePath
      );
      this.debounceTimers.delete(fullPath);
    }, this.debounceDelay);

    this.debounceTimers.set(fullPath, newTimer);
    return Promise.resolve();
  }

  public stopWatching(directoryPath?: string): void {
    // タイマーをクリーンアップ
    this.debounceTimers.forEach((timer) =>
      clearTimeout(timer)
    );
    this.debounceTimers.clear();

    super.stopWatching(directoryPath);
  }
}

フィルタリングによる効率化

不要なファイルを監視対象から除外してパフォーマンスを向上させます。

typescriptinterface WatchFilter {
  include?: RegExp[];
  exclude?: RegExp[];
  maxFileSize?: number; // bytes
}

class FilteredFileWatcher extends DebouncedFileWatcher {
  private filter: WatchFilter;

  constructor(
    filter: WatchFilter,
    debounceDelay: number = 300
  ) {
    super(debounceDelay);
    this.filter = filter;
  }

  protected async processFileEvent(
    eventType: string,
    filename: string,
    basePath: string
  ): Promise<void> {
    const fullPath = path.join(basePath, filename);

    // フィルタリング処理
    if (
      !(await this.shouldProcessFile(fullPath, filename))
    ) {
      return; // フィルタに引っかかった場合は処理をスキップ
    }

    return super.processFileEvent(
      eventType,
      filename,
      basePath
    );
  }

  private async shouldProcessFile(
    fullPath: string,
    filename: string
  ): Promise<boolean> {
    // 除外パターンのチェック
    if (this.filter.exclude) {
      const isExcluded = this.filter.exclude.some(
        (pattern) => pattern.test(filename)
      );
      if (isExcluded) {
        return false;
      }
    }

    // 包含パターンのチェック
    if (this.filter.include) {
      const isIncluded = this.filter.include.some(
        (pattern) => pattern.test(filename)
      );
      if (!isIncluded) {
        return false;
      }
    }

    // ファイルサイズのチェック
    if (this.filter.maxFileSize) {
      try {
        const stats = await stat(fullPath);
        if (stats.size > this.filter.maxFileSize) {
          console.log(
            `⚠️  ファイルサイズが上限を超過: ${filename} (${stats.size} bytes)`
          );
          return false;
        }
      } catch (error) {
        // ファイルが削除された場合などは処理を続行
        return true;
      }
    }

    return true;
  }
}

// 使用例
const filter: WatchFilter = {
  include: [
    /\.(ts|tsx|js|jsx)$/, // TypeScript/JavaScript
    /\.(css|scss|sass)$/, // スタイルシート
    /\.json$/, // 設定ファイル
  ],
  exclude: [
    /node_modules/,
    /\.git/,
    /dist/,
    /build/,
    /\.DS_Store$/,
    /\.tmp$/,
  ],
  maxFileSize: 10 * 1024 * 1024, // 10MB
};

const filteredWatcher = new FilteredFileWatcher(
  filter,
  500
);

メモリ使用量の最適化

長時間稼動するアプリケーションでのメモリリーク対策です。

typescriptclass OptimizedFileWatcher extends FilteredFileWatcher {
  private eventHistory: Map<string, Date> = new Map();
  private maxHistorySize: number = 1000;
  private cleanupInterval: NodeJS.Timer;

  constructor(
    filter: WatchFilter,
    debounceDelay: number = 300
  ) {
    super(filter, debounceDelay);

    // 定期的なメモリクリーンアップ
    this.cleanupInterval = setInterval(() => {
      this.cleanupHistory();
    }, 60000); // 1分ごと
  }

  protected async processFileEvent(
    eventType: string,
    filename: string,
    basePath: string
  ): Promise<void> {
    const fullPath = path.join(basePath, filename);

    // イベント履歴を記録
    this.eventHistory.set(fullPath, new Date());

    // 履歴サイズが上限を超えた場合は古いエントリを削除
    if (this.eventHistory.size > this.maxHistorySize) {
      this.cleanupHistory();
    }

    return super.processFileEvent(
      eventType,
      filename,
      basePath
    );
  }

  private cleanupHistory(): void {
    const now = new Date();
    const maxAge = 10 * 60 * 1000; // 10分

    let removedCount = 0;
    for (const [
      path,
      timestamp,
    ] of this.eventHistory.entries()) {
      if (now.getTime() - timestamp.getTime() > maxAge) {
        this.eventHistory.delete(path);
        removedCount++;
      }
    }

    if (removedCount > 0) {
      console.log(
        `🧹 イベント履歴をクリーンアップしました: ${removedCount} エントリを削除`
      );
    }
  }

  public stopWatching(directoryPath?: string): void {
    // クリーンアップタイマーを停止
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }

    // 履歴をクリア
    this.eventHistory.clear();

    super.stopWatching(directoryPath);
  }

  // メモリ使用量の監視
  public getMemoryUsage(): {
    eventHistory: number;
    watchers: number;
  } {
    return {
      eventHistory: this.eventHistory.size,
      watchers: this.getWatchingPaths().length,
    };
  }
}

トラブルシューティングと注意点

ファイル監視システムの運用で遭遇しやすい問題とその対処方法をご紹介します。

よくあるエラーとその対処法

1. EMFILE(ファイルディスクリプタ不足)

typescript// エラーコード例
// Error: EMFILE: too many open files, watch '/path/to/file'

class SafeFileWatcher extends OptimizedFileWatcher {
  private maxWatchers: number = 100;
  private currentWatcherCount: number = 0;

  public async watchDirectory(
    directoryPath: string
  ): Promise<void> {
    if (this.currentWatcherCount >= this.maxWatchers) {
      throw new Error(
        `監視可能なディレクトリ数の上限に達しました (${this.maxWatchers})`
      );
    }

    try {
      await super.watchDirectory(directoryPath);
      this.currentWatcherCount++;
    } catch (error) {
      if (
        (error as NodeJS.ErrnoException).code === 'EMFILE'
      ) {
        console.error(
          '❌ ファイルディスクリプタが不足しています'
        );
        console.log('💡 解決方法:');
        console.log(
          '   1. ulimit -n 4096 でディスクリプタ上限を増やす'
        );
        console.log('   2. 監視対象ディレクトリを減らす');
        console.log(
          '   3. より細かいフィルタリングを適用する'
        );
      }
      throw error;
    }
  }

  public stopWatching(directoryPath?: string): void {
    const beforeCount = this.currentWatcherCount;
    super.stopWatching(directoryPath);

    if (directoryPath) {
      this.currentWatcherCount = Math.max(
        0,
        this.currentWatcherCount - 1
      );
    } else {
      this.currentWatcherCount = 0;
    }

    console.log(
      `監視停止: ${beforeCount}${this.currentWatcherCount}`
    );
  }
}

2. ENOENT(ファイルが存在しない)

typescript// エラーコード例
// Error: ENOENT: no such file or directory, watch '/nonexistent/path'

class RobustFileWatcher extends SafeFileWatcher {
  public async watchDirectory(
    directoryPath: string
  ): Promise<void> {
    try {
      // ディレクトリの存在確認
      const stats = await stat(directoryPath);
      if (!stats.isDirectory()) {
        throw new Error(
          `指定されたパスはディレクトリではありません: ${directoryPath}`
        );
      }

      await super.watchDirectory(directoryPath);
    } catch (error) {
      const err = error as NodeJS.ErrnoException;

      switch (err.code) {
        case 'ENOENT':
          console.error(
            `❌ ディレクトリが存在しません: ${directoryPath}`
          );
          console.log(
            '💡 ディレクトリを作成するか、正しいパスを指定してください'
          );
          break;

        case 'EACCES':
          console.error(
            `❌ ディレクトリへのアクセス権限がありません: ${directoryPath}`
          );
          console.log(
            '💡 適切な権限を設定するか、sudo で実行してください'
          );
          break;

        case 'ENOTDIR':
          console.error(
            `❌ 指定されたパスはディレクトリではありません: ${directoryPath}`
          );
          break;

        default:
          console.error(
            `❌ 予期しないエラー [${err.code}]: ${err.message}`
          );
      }

      throw error;
    }
  }
}

3. パフォーマンスの問題

typescript// 監視状況の診断ツール
class FileWatcherDiagnostics {
  private watcher: RobustFileWatcher;
  private eventCounts: Map<string, number> = new Map();
  private startTime: Date = new Date();

  constructor(watcher: RobustFileWatcher) {
    this.watcher = watcher;
    this.setupDiagnostics();
  }

  private setupDiagnostics(): void {
    const events = [
      'fileUpdate',
      'fileCreate',
      'fileDelete',
      'error',
    ];

    events.forEach((eventName) => {
      this.watcher.on(eventName, () => {
        const current =
          this.eventCounts.get(eventName) || 0;
        this.eventCounts.set(eventName, current + 1);
      });
    });
  }

  public generateReport(): void {
    const uptime = Date.now() - this.startTime.getTime();
    const uptimeMinutes =
      Math.round((uptime / 1000 / 60) * 100) / 100;

    console.log('\n📊 ファイル監視診断レポート');
    console.log('================================');
    console.log(`稼働時間: ${uptimeMinutes} 分`);
    console.log(
      `監視中のパス: ${
        this.watcher.getWatchingPaths().length
      } 個`
    );

    const memUsage = this.watcher.getMemoryUsage();
    console.log(
      `イベント履歴: ${memUsage.eventHistory} エントリ`
    );

    console.log('\nイベント統計:');
    for (const [
      eventName,
      count,
    ] of this.eventCounts.entries()) {
      const rate =
        Math.round((count / uptimeMinutes) * 100) / 100;
      console.log(
        `  ${eventName}: ${count} 回 (${rate}/分)`
      );
    }

    // パフォーマンスの警告
    const totalEvents = Array.from(
      this.eventCounts.values()
    ).reduce((a, b) => a + b, 0);
    const eventRate = totalEvents / uptimeMinutes;

    if (eventRate > 100) {
      console.log('\n⚠️  高頻度のイベントが発生しています');
      console.log(
        '   💡 フィルタリングの見直しを検討してください'
      );
    }

    if (memUsage.eventHistory > 500) {
      console.log(
        '\n⚠️  イベント履歴が多く蓄積されています'
      );
      console.log(
        '   💡 クリーンアップ間隔の調整を検討してください'
      );
    }
  }
}

まとめ

本記事では、Node.js のfs.watchを活用したファイル監視システムの構築方法と、イベント駆動設計による効率的なアーキテクチャについて詳しく解説いたしました。

学習した重要なポイント

#項目重要度実装のポイント
1fs.watch の基本機能⭐⭐⭐イベントタイプとファイル名の適切な処理
2fs.watch と fs.watchFile の使い分け⭐⭐⭐要件に応じた適切な API 選択
3EventEmitter によるイベント駆動設計⭐⭐⭐疎結合なシステム設計の実現
4デバウンシングとフィルタリング⭐⭐パフォーマンス最適化の実装
5エラーハンドリングとトラブルシューティング⭐⭐安定稼動のための実装

実際のプロダクトでの活用

ファイル監視システムは以下のような場面で実際に活用されています。

  • 開発ツール: Next.js、webpack、Vite などのホットリロード機能
  • CI/CD パイプライン: コード変更の自動検知とビルドトリガー
  • コンテンツ管理: 静的サイトジェネレーターでのファイル変更検知
  • サーバー監視: ログファイルやセキュリティファイルの変更監視

今後の発展

ファイル監視技術は今後も進化を続けており、以下のような発展が期待されます。

  • WebAssembly: より高性能なファイル監視処理の実現
  • クラウド統合: AWS CloudWatch Events や Azure Event Grid との連携
  • マイクロサービス: 分散システムでのファイル変更イベントの伝播
  • AI/ML: ファイル変更パターンの学習による予測的なビルド最適化

このような基礎技術をしっかりと理解しておくことで、より高度なシステム開発に取り組む際の強固な基盤となるでしょう。ぜひ実際のプロジェクトで活用して、開発効率の向上を体感してみてください。

関連リンク