UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

190 lines (165 loc) 6.04 kB
const fs = require('fs'); const path = require('path'); const EventEmitter = require('events'); const crypto = require('crypto'); const SecurityUtils = require('./security'); const DEFAULT_DEBOUNCE_MS = 300; const DEFAULT_MAX_DIRECTORIES = 50; function sha256File(filePath) { try { const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath)); if (content === null || content === undefined) return null; return crypto.createHash('sha256').update(content).digest('hex'); } catch (_) { return null; } } function watchDirectory(dir, emitter, watchers, options = {}) { const { debounceMs = DEFAULT_DEBOUNCE_MS, hashTracking = true, watchState = { count: 0, maxDirectories: DEFAULT_MAX_DIRECTORIES } } = options; if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return; if (watchState.count >= watchState.maxDirectories) { emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`)); return; } const fileHashes = new Map(); const debounceTimers = new Map(); if (hashTracking) { try { const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true }); if (items) { for (const entry of items) { if (entry.isFile() && entry.name.endsWith('.json')) { const fullPath = path.join(dir, entry.name); const h = sha256File(fullPath); if (h) fileHashes.set(fullPath, h); } } } } catch (_) { /* initial read may fail */ } } let watcher; try { watcher = fs.watch(dir, (event, filename) => { if (!filename || !filename.endsWith('.json')) return; const fullPath = path.join(dir, filename); const validated = SecurityUtils.validatePath(fullPath, path.dirname(dir)); if (!validated) return; if (debounceTimers.has(fullPath)) { clearTimeout(debounceTimers.get(fullPath)); } debounceTimers.set(fullPath, setTimeout(() => { debounceTimers.delete(fullPath); try { if (event === 'rename') { if (SecurityUtils.safeExistsSync(fullPath, path.dirname(fullPath))) { if (hashTracking) { const h = sha256File(fullPath); if (h) fileHashes.set(fullPath, h); } emitter.emit('add', fullPath); } else { fileHashes.delete(fullPath); emitter.emit('unlink', fullPath); } } else if (event === 'change') { if (hashTracking) { const newHash = sha256File(fullPath); const oldHash = fileHashes.get(fullPath); if (newHash && newHash === oldHash) return; if (newHash) fileHashes.set(fullPath, newHash); } emitter.emit('change', fullPath); } } catch (err) { emitter.emit('error', err); } }, debounceMs)); }); watcher.on('error', (err) => { emitter.emit('error', err); }); } catch (err) { emitter.emit('error', err); return; } watchers.push({ watcher, path: dir, debounceTimers }); watchState.count++; try { const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true }); if (items) { for (const entry of items) { if (entry.isDirectory()) { watchDirectory(path.join(dir, entry.name), emitter, watchers, options); } } } } catch (_) { // Cannot read directory contents } } function watchLocales(dirs, onChange, options = {}) { const directories = Array.isArray(dirs) ? dirs : [dirs]; const emitter = new EventEmitter(); emitter.on('error', (err) => { process.stderr.write(`[i18ntk] File watcher error: ${err?.message || err}\n`); }); const watchers = []; // Backward-compatible onChange callback if (typeof onChange === 'function') { emitter.on('change', onChange); emitter.on('add', onChange); emitter.on('unlink', onChange); } else if (typeof onChange === 'object' && onChange !== null && typeof onChange.onChange === 'function') { emitter.on('change', onChange.onChange); emitter.on('add', onChange.onChange); emitter.on('unlink', onChange.onChange); } const { debounceMs = DEFAULT_DEBOUNCE_MS, hashTracking = true, maxDirectories = DEFAULT_MAX_DIRECTORIES } = (typeof onChange === 'object' && onChange !== null) ? onChange : options; const watchState = { count: 0, maxDirectories }; for (const d of directories) { if (watchState.count >= watchState.maxDirectories) { emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`)); break; } const resolved = path.resolve(d); const validated = SecurityUtils.validatePath(resolved, path.dirname(resolved)); if (!validated) { emitter.emit('error', new Error(`Path validation failed for: ${d}`)); continue; } watchDirectory(validated, emitter, watchers, { debounceMs, hashTracking, watchState }); } const stop = () => { for (const entry of watchers) { if (entry.debounceTimers) { for (const timer of entry.debounceTimers.values()) { clearTimeout(timer); } entry.debounceTimers.clear(); } try { entry.watcher.close(); } catch (_) { /* ignore */ } } watchers.length = 0; emitter.removeAllListeners(); }; stop.stop = stop; stop.emitter = emitter; stop.on = emitter.on.bind(emitter); stop.once = emitter.once.bind(emitter); stop.off = emitter.off ? emitter.off.bind(emitter) : emitter.removeListener.bind(emitter); stop.emit = emitter.emit.bind(emitter); stop.removeListener = emitter.removeListener.bind(emitter); stop.removeAllListeners = emitter.removeAllListeners.bind(emitter); stop.getWatchedPaths = () => watchers.map(w => w.path); stop.getDebounceMs = () => debounceMs; return stop; } module.exports = watchLocales;