import { EventEmitter } from "node:events"; import type { ClassifierOptions } from "./change-classifier.js"; /** * file-watcher.ts — VextFileWatcher 文件监听器(Phase 2A) * * 热重载的入口组件,负责: * * 1. **监听** `src/` 目录、项目根 `preload/`/`public/` 目录和根目录配置文件的变更 * 2. **分类** 变更文件为 `cold`(冷重启)、`soft`(热替换)、`client`(前端重建)或 `ignore`(忽略) * 3. **识别变更类型**(`modify` / `add` / `delete`)— 决定走 Tier 1 还是 Tier 2 编译路径 * 4. **防抖合并** 100ms 窗口内的多个变更为一次 reload 事件 * 5. **Docker 兼容** — inotify 不可用时自动降级为 polling * * 重要设计约束: * - FileWatcher 监听的是 `src/` **源码目录**,不是 `.vext/dev/` 编译产物目录。 * 这避免了 esbuild 编译输出触发二次变更事件的问题。 * - 使用 Node.js 内置 `fs.watch`(零外部依赖),不依赖 chokidar 等第三方库。 * - 防抖窗口默认 0ms(不开启),文件变更立即触发重载;可通过 --debounce 选项开启。 * * 事件: * - `change` — 文件变更事件(FileChangeEvent),防抖合并后发射 * * @module lib/dev/file-watcher * @see 11c-file-watcher.md(完整设计文档) * @see 11-hot-reload.md §3(文件分类规则) * @see IMPLEMENTATION-PLAN.md 任务 2.3 */ /** * VextFileWatcher 构造选项 */ export interface WatcherOptions { /** 项目根目录(绝对路径) */ root: string; /** 防抖间隔(ms),默认 0(不开启防抖,文件变更立即触发重载) */ debounce?: number; /** 使用轮询模式(Docker/网络文件系统降级方案) */ usePolling?: boolean; /** 轮询间隔(ms),仅 usePolling 为 true 时有效,默认 1000ms */ pollInterval?: number; /** * 用户自定义分类选项(从 config.dev 传入) * * 允许用户在配置文件中自定义 coldPatterns / ignorePatterns, * 覆盖内置的文件分类规则。 */ classifierOptions?: ClassifierOptions; } /** * 单个文件的变更信息 * * 用于分级编译决策: * - `modify` → Tier 1(compileSingle,~1-5ms) * - `add` / `delete` → Tier 2(rebuildWithNewEntryPoints,~50-600ms) */ export interface FileChangeInfo { /** 相对于项目根目录的文件路径(使用 / 分隔符,如 "src/routes/user.ts") */ path: string; /** 变更类型:modify=内容修改, add=新增文件, delete=删除文件 */ type: "modify" | "add" | "delete"; } /** * 文件变更事件(防抖合并后发射) * * 一个事件可能包含多个文件的变更(防抖窗口内的所有变更合并为一个事件)。 * * action 字段是所有变更的合并结果: * - 只要有一个 `cold` 分类的文件 → action = 'cold'(触发 Cold Restart) * - 有 server source 变更 → action = 'soft'(触发 Soft Reload) * - 只有 client 变更 → action = 'client'(触发前端重建) */ export interface FileChangeEvent { /** 变更文件列表(含路径和变更类型) */ files: FileChangeInfo[]; /** 合并后的最终动作(有一个 cold 就是 cold) */ action: "soft" | "cold" | "client"; } export declare class VextFileWatcher extends EventEmitter { /** * 活跃的 watcher 列表(fs.watch 实例或 polling 定时器包装) * * stop() 时遍历关闭所有 watcher。 */ private watchers; /** 当前挂载的项目级 preload 目录 watcher(如存在) */ private preloadWatcher; /** 当前挂载的 public 目录 watcher(如存在) */ private publicWatcher; /** * 防抖期间暂存的变更集合 * * key: 相对于项目根目录的文件路径(/ 分隔符) * value: 分类动作 + 变更类型 * * 同一文件在防抖窗口内多次变更时,cold 优先级最高。 */ private pendingChanges; /** * 防抖定时器 * * 每次收到新变更时重置定时器。 * 定时器到期后调用 flush() 合并发射事件。 */ private debounceTimer; /** * 配置选项(已填充默认值) */ private readonly options; /** * v2.2:已知文件路径集合,用于区分 add 和 modify * * 在 start() 时扫描 src/ 初始化,后续根据文件变更事件维护: * - add 事件 → 加入集合 * - delete 事件 → 从集合移除 * * fs.watch 的 'rename' 事件无法直接区分新建和删除, * 需要配合 existsSync + knownFiles 来判断。 */ private knownFiles; constructor(options: WatcherOptions); /** * start — 启动文件监听 * * 流程: * 1. 扫描 src/ 初始化已知文件集合(knownFiles) * 2. 根据 usePolling 选项决定监听模式: * - polling = false → 使用 fs.watch(递归监听 src/ + 单文件监听根配置) * - polling = true → 使用 setInterval 定期扫描 * * 监听目标: * - src/ 目录(递归,所有源码文件变更) * - preload/ 目录(非递归,项目级 preload 文件变更) * - public/ 目录(递归,前端静态资源) * - 根目录配置文件(package.json, tsconfig.json) * - .env 文件(.env, .env.local, .env.production 等) */ start(): Promise; /** * stop — 停止所有 watcher 并清理状态 * * 关闭所有 fs.watch 实例和 polling 定时器, * 清空 pending 变更和已知文件集合。 * * 调用后可通过 start() 重新启动。 */ stop(): void; /** * findEnvFiles — 查找根目录下所有 .env 文件 * * 匹配 .env, .env.local, .env.production, .env.development 等。 * * @param root 项目根目录 * @returns .env 文件名列表 */ private findEnvFiles; /** * detectChangeType — 根据 fs.watch 事件类型和文件系统状态判断变更类型(v2.2) * * fs.watch 的 eventType 语义在不同平台有差异: * * | 平台 | eventType='change' | eventType='rename' | * |---------|-------------------|-----------------------------| * | macOS | 内容修改 | 新建/删除/重命名 | * | Windows | 内容修改 | rename 可能触发两次 | * | Linux | 内容修改 | vim 写文件时可能报 rename | * * 此方法统一处理跨平台差异: * - 'change' → 'modify' * - 'rename' → 检查文件是否存在 + knownFiles 集合来区分 add/modify/delete * * @param eventType fs.watch 回调的 eventType 参数 * @param normalizedPath 相对于项目根的规范化路径(/ 分隔符) * @param absolutePath 文件的绝对路径(用于 existsSync 检查) * @returns 变更类型 */ private detectChangeType; /** * initKnownFiles — 初始化已知文件集合(v2.2) * * 启动时扫描 src/ 目录,记录所有已存在的文件路径。 * 后续 detectChangeType() 通过检查文件是否在 knownFiles 中 * 来区分 add 和 modify。 * * @param root 项目根目录 */ private initKnownFiles; private attachPreloadWatcher; private reconcilePreloadWatcher; private attachPublicWatcher; private reconcilePublicWatcher; /** * onFileChange — 处理文件变更事件 * * 调用 classifyChange() 分类文件变更,忽略 ignore 类型, * 将 cold/soft 类型加入 pending 集合,启动防抖定时器。 * * 合并策略: * - 同一文件在防抖窗口内多次变更 → 保留最高优先级的 action(cold > soft > client) * - 不同文件独立记录 * * @param relativePath 相对于项目根目录的文件路径(/ 分隔符) * @param changeType 变更类型 */ private onFileChange; /** * flush — 将 pending 变更合并为一个 FileChangeEvent 并发射 * * 在防抖定时器到期后调用。 * 将 pendingChanges Map 转换为 FileChangeEvent 并发射 'change' 事件。 * * action 合并规则: * - 只要有一个文件的 action 为 'cold' → 整体 action = 'cold' * - 有 server source 文件 → 整体 action = 'soft' * - 只有 client 文件 → 整体 action = 'client' */ private flush; /** * restartWithPolling — 当 inotify 限制时降级为 polling 模式 * * 关闭所有现有 watcher,切换到 polling 模式重新开始监听。 * 这是 Docker 容器中 inotify 报 ENOSPC 时的降级策略。 */ private restartWithPolling; /** * startPolling — Polling 降级方案 * * 适用于 Docker 挂载卷、网络文件系统等 fs.watch 不可靠的环境。 * * 工作原理: * 1. 初始扫描建立基线(记录所有文件的 mtime) * 2. 每隔 pollInterval 毫秒重新扫描 * 3. 对比前后两轮的文件列表和 mtime: * - 新出现的文件 → add * - mtime 变化的文件 → modify * - 消失的文件 → delete * * v2.2 改进:polling 模式也能正确检测 add/delete * (通过对比前后两轮文件列表的差集)。 * * 也会监听根目录配置文件(package.json, tsconfig.json, .env*)。 */ private startPolling; /** * walkDirectory — 递归遍历目录,返回所有匹配的文件路径 * * 遍历规则: * - 跳过以 `.` 开头的隐藏目录(如 .git, .vext) * - 跳过 node_modules 目录 * - 收集 server 代码与 frontend client 常见资源文件 * * @param dir 要遍历的目录绝对路径 * @returns 匹配的文件绝对路径列表 */ private walkDirectory; /** * listPreloadFiles — 列出项目根 preload/ 目录中的一级文件 * * 仅收集项目级 preload 支持的候选文件类型,且不递归子目录。 */ private listPreloadFiles; private listPublicFiles; }