autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
337 lines (336 loc) • 12.6 kB
JavaScript
/**
* ConfigWatcher — 构建配置文件热更新监听器
*
* 监听自研构建系统的配置文件变更,执行增量重解析并通知下游服务。
*
* 核心策略:
* - debounce 3s(配置文件常有连续保存)
* - MD5 hash 差量检测避免无效重解析
* - 60s 最大频率保护(防 git checkout 等批量变更风暴)
* - 增量解析:单文件变更只影响对应模块
* - 通过 SignalBus 触发 PanoramaService 缓存失效
* - 通过 RealtimeService 推送 Dashboard WebSocket 事件
*
* @module ConfigWatcher
*/
import { createHash } from 'node:crypto';
import { watch } from 'node:fs';
import { glob, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { shutdown } from '#shared/shutdown.js';
/* ═══ Types ═══════════════════════════════════════════════ */
/** 配置文件系统类型 → 需要监听的文件 glob 列表 */
const WATCH_PATTERNS = {
easybox: ['Boxfile', 'Boxfile.local', 'Boxfile.overlay', 'LocalModule/**/*.boxspec'],
tuist: ['Project.swift', 'Tuist/**/*.swift'],
xcodegen: ['project.yml', 'project.yaml'],
};
/* ═══ ConfigWatcher ═══════════════════════════════════════ */
export class ConfigWatcher {
#projectRoot;
#systemId;
#signalBus;
#onChange;
#debounceMs;
#fullRebuildIntervalMs;
/** fs.watch 实例列表 */
#watchers = [];
/** 已知文件 → hash 映射 */
#fileHashes = new Map();
/** debounce 定时器 */
#debounceTimer = null;
/** 待处理的变更文件路径(debounce 窗口内合并) */
#pendingChanges = new Set();
/** 上次全量重建时间 */
#lastFullRebuild = 0;
/** 是否已关闭 */
#disposed = false;
constructor(options) {
this.#projectRoot = options.projectRoot;
this.#systemId = options.systemId;
this.#signalBus = options.signalBus ?? null;
this.#onChange = options.onChange ?? null;
this.#debounceMs = options.debounceMs ?? 3000;
this.#fullRebuildIntervalMs = options.fullRebuildIntervalMs ?? 60_000;
}
/* ─── Public API ────────────────────────────────── */
/**
* 启动文件监听。异步解析初始文件 hash 并注册 fs.watch。
* 自动注册 shutdown hook 以清理资源。
*/
async start() {
if (this.#disposed) {
return;
}
const patterns = WATCH_PATTERNS[this.#systemId] ?? [];
if (patterns.length === 0) {
return;
}
// 1. 遍历 glob patterns 收集初始文件
const resolvedFiles = await this.#resolveWatchFiles(patterns);
// 2. 计算初始 hash
for (const file of resolvedFiles) {
const absPath = join(this.#projectRoot, file.relativePath);
try {
const content = await readFile(absPath, 'utf-8');
file.hash = computeHash(content);
this.#fileHashes.set(file.relativePath, file);
}
catch {
/* 文件可能已删除,跳过 */
}
}
// 3. 对每个 pattern 设置 fs.watch(目录级)
this.#setupWatchers(patterns);
// 4. 注册 shutdown hook
shutdown.register(() => this.dispose(), `ConfigWatcher(${this.#systemId})`);
}
/**
* 停止监听,释放所有 fs.watch 资源
*/
dispose() {
if (this.#disposed) {
return;
}
this.#disposed = true;
if (this.#debounceTimer) {
clearTimeout(this.#debounceTimer);
this.#debounceTimer = null;
}
for (const watcher of this.#watchers) {
try {
watcher.close();
}
catch {
/* 忽略关闭错误 */
}
}
this.#watchers.length = 0;
this.#pendingChanges.clear();
this.#fileHashes.clear();
}
/** 是否正在监听 */
get active() {
return !this.#disposed && this.#watchers.length > 0;
}
/** 监听的文件数 */
get watchedFileCount() {
return this.#fileHashes.size;
}
/* ─── Internal ──────────────────────────────────── */
/**
* 解析 glob patterns → WatchedFile 列表
*/
async #resolveWatchFiles(patterns) {
const files = [];
for (const pattern of patterns) {
const scope = inferChangeScope(pattern, this.#systemId);
try {
// Node 22 has native glob in fs/promises
const matches = glob(pattern, { cwd: this.#projectRoot });
for await (const match of matches) {
files.push({
relativePath: match,
hash: '',
scope,
moduleName: scope === 'module' ? extractModuleName(match, this.#systemId) : undefined,
});
}
}
catch {
/* pattern 不匹配或目录不存在 */
}
}
return files;
}
/**
* 设置 fs.watch 监听器
*/
#setupWatchers(patterns) {
// 收集要监听的目录(去重)
const watchDirs = new Set();
watchDirs.add(this.#projectRoot); // 根目录(Boxfile, project.yml 等)
for (const pattern of patterns) {
// 对含 ** 的 pattern,监听其父目录
const slashIdx = pattern.indexOf('/');
if (slashIdx > 0) {
const dir = pattern.substring(0, slashIdx);
const absDir = join(this.#projectRoot, dir);
watchDirs.add(absDir);
}
}
for (const dir of watchDirs) {
try {
const watcher = watch(dir, { recursive: true }, (_eventType, filename) => {
if (!filename || this.#disposed) {
return;
}
const relPath = dir === this.#projectRoot ? filename : join(relative(this.#projectRoot, dir), filename);
// 过滤非配置文件
if (this.#isRelevantFile(relPath, patterns)) {
this.#scheduleCheck(relPath);
}
});
this.#watchers.push(watcher);
// 处理 watcher 错误(macOS 上目录被删除等)
watcher.on('error', () => {
/* 静默忽略,下次变更无法检测可通过手动 rescan 恢复 */
});
}
catch {
/* 目录不存在或权限不足 */
}
}
}
/**
* 检查文件路径是否匹配 watch patterns
*/
#isRelevantFile(relPath, patterns) {
for (const pattern of patterns) {
// 简单匹配:精确文件名 / 扩展名 / 子目录包含
if (!pattern.includes('*') && !pattern.includes('/')) {
// 精确文件名匹配
if (relPath === pattern) {
return true;
}
}
else if (pattern.includes('**')) {
// glob 通配 — 提取目录前缀和扩展名
const parts = pattern.split('/');
const dirPrefix = parts[0];
const extMatch = pattern.match(/\*(\.\w+)$/);
if (relPath.startsWith(`${dirPrefix}/`) && extMatch && relPath.endsWith(extMatch[1])) {
return true;
}
}
}
return false;
}
/**
* debounce 调度:合并多次变更后统一处理
*/
#scheduleCheck(relPath) {
this.#pendingChanges.add(relPath);
if (this.#debounceTimer) {
clearTimeout(this.#debounceTimer);
}
this.#debounceTimer = setTimeout(() => {
this.#debounceTimer = null;
void this.#processChanges();
}, this.#debounceMs);
}
/**
* 处理 debounce 窗口内累积的变更
*/
async #processChanges() {
if (this.#disposed || this.#pendingChanges.size === 0) {
return;
}
// 频率保护:60s 内最多一次全量重建
const now = Date.now();
const hasFullScope = [...this.#pendingChanges].some((p) => {
const watched = this.#fileHashes.get(p);
return !watched || watched.scope === 'full';
});
if (hasFullScope && now - this.#lastFullRebuild < this.#fullRebuildIntervalMs) {
// 跳过此轮,等待下次
this.#pendingChanges.clear();
return;
}
const changedPaths = [...this.#pendingChanges];
this.#pendingChanges.clear();
// 逐个检查 hash 变化
const actualChanges = [];
for (const relPath of changedPaths) {
const absPath = join(this.#projectRoot, relPath);
try {
const content = await readFile(absPath, 'utf-8');
const newHash = computeHash(content);
const existing = this.#fileHashes.get(relPath);
if (existing && existing.hash === newHash) {
continue; // hash 未变,跳过
}
// 更新 hash
const scope = existing?.scope ?? inferChangeScope(relPath, this.#systemId);
const moduleName = scope === 'module'
? (existing?.moduleName ?? extractModuleName(relPath, this.#systemId))
: undefined;
this.#fileHashes.set(relPath, { relativePath: relPath, hash: newHash, scope, moduleName });
actualChanges.push({ path: relPath, scope, moduleName });
}
catch {
// 文件被删除 — 也算变更
if (this.#fileHashes.has(relPath)) {
const existing = this.#fileHashes.get(relPath);
this.#fileHashes.delete(relPath);
actualChanges.push({
path: relPath,
scope: existing.scope,
moduleName: existing.moduleName,
});
}
}
}
if (actualChanges.length === 0) {
return; // 所有文件 hash 未变
}
if (hasFullScope) {
this.#lastFullRebuild = now;
}
// 发射信号 → PanoramaService 缓存失效
if (this.#signalBus) {
this.#signalBus.send('lifecycle', 'ConfigWatcher', 1.0, {
metadata: {
event: 'config_changed',
systemId: this.#systemId,
changedCount: actualChanges.length,
},
});
}
// 回调通知(RealtimeService 等)
const event = {
changedFiles: actualChanges,
systemId: this.#systemId,
timestamp: now,
};
this.#onChange?.(event);
}
}
/* ═══ Helpers ═════════════════════════════════════════════ */
function computeHash(content) {
return createHash('md5').update(content).digest('hex');
}
/**
* 根据文件 pattern 推断变更影响范围
*/
function inferChangeScope(pattern, systemId) {
if (systemId === 'easybox') {
if (pattern.includes('.boxspec')) {
return 'module';
}
if (pattern.includes('.local') || pattern.includes('.overlay')) {
return 'overlay';
}
return 'full';
}
if (systemId === 'tuist') {
if (pattern.includes('Tuist/')) {
return 'full';
}
return 'full';
}
// xcodegen / 其他
return 'full';
}
/**
* 从文件路径提取模块名
* e.g. "LocalModule/BDPictures/BDPictures.boxspec" → "BDPictures"
*/
function extractModuleName(relPath, _systemId) {
// 通用:取 spec 文件所在目录名
const parts = relPath.split('/');
if (parts.length >= 2) {
return parts[parts.length - 2];
}
return undefined;
}