i18ntk 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,194 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const SecurityUtils = require('./security');
4
-
5
- function watchDirectory(dir, callback, watchers) {
6
- if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
7
- const watcher = fs.watch(dir, (event, filename) => {
8
- if (filename && filename.endsWith('.json')) {
9
- callback(path.join(dir, filename));
10
- }
11
- });
12
- watchers.push(watcher);
13
-
14
- try {
15
- const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
16
- if (items) {
17
- items.forEach(entry => {
18
- if (entry.isDirectory()) {
19
- watchDirectory(path.join(dir, entry.name), callback, watchers);
20
- }
21
- });
22
- }
23
- } catch (_) {
24
- // Cannot read directory contents
25
- }
26
- }
27
-
28
- function watchLocales(dirs, onChange) {
29
- const directories = Array.isArray(dirs) ? dirs : [dirs];
30
- const watchers = [];
31
- directories.forEach(d => watchDirectory(path.resolve(d), onChange, watchers));
32
- console.log(`Watching for changes in: ${directories.join(', ')}`);
33
- return () => watchers.forEach(w => w.close());
34
- }
35
-
36
- module.exports = watchLocales;
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const EventEmitter = require('events');
4
+ const crypto = require('crypto');
5
+ const SecurityUtils = require('./security');
6
+
7
+ const DEFAULT_DEBOUNCE_MS = 300;
8
+ const DEFAULT_MAX_DIRECTORIES = 50;
9
+
10
+ function sha256File(filePath) {
11
+ try {
12
+ const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
13
+ if (content === null || content === undefined) return null;
14
+ return crypto.createHash('sha256').update(content).digest('hex');
15
+ } catch (_) {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function watchDirectory(dir, emitter, watchers, options = {}) {
21
+ const {
22
+ debounceMs = DEFAULT_DEBOUNCE_MS,
23
+ hashTracking = true,
24
+ watchState = { count: 0, maxDirectories: DEFAULT_MAX_DIRECTORIES }
25
+ } = options;
26
+
27
+ if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
28
+ if (watchState.count >= watchState.maxDirectories) {
29
+ emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
30
+ return;
31
+ }
32
+
33
+ const fileHashes = new Map();
34
+ const debounceTimers = new Map();
35
+
36
+ if (hashTracking) {
37
+ try {
38
+ const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
39
+ if (items) {
40
+ for (const entry of items) {
41
+ if (entry.isFile() && entry.name.endsWith('.json')) {
42
+ const fullPath = path.join(dir, entry.name);
43
+ const h = sha256File(fullPath);
44
+ if (h) fileHashes.set(fullPath, h);
45
+ }
46
+ }
47
+ }
48
+ } catch (_) { /* initial read may fail */ }
49
+ }
50
+
51
+ let watcher;
52
+ try {
53
+ watcher = fs.watch(dir, (event, filename) => {
54
+ if (!filename || !filename.endsWith('.json')) return;
55
+
56
+ const fullPath = path.join(dir, filename);
57
+ const validated = SecurityUtils.validatePath(fullPath, path.dirname(dir));
58
+ if (!validated) return;
59
+
60
+ if (debounceTimers.has(fullPath)) {
61
+ clearTimeout(debounceTimers.get(fullPath));
62
+ }
63
+
64
+ debounceTimers.set(fullPath, setTimeout(() => {
65
+ debounceTimers.delete(fullPath);
66
+
67
+ try {
68
+ if (event === 'rename') {
69
+ if (SecurityUtils.safeExistsSync(fullPath, path.dirname(fullPath))) {
70
+ if (hashTracking) {
71
+ const h = sha256File(fullPath);
72
+ if (h) fileHashes.set(fullPath, h);
73
+ }
74
+ emitter.emit('add', fullPath);
75
+ } else {
76
+ fileHashes.delete(fullPath);
77
+ emitter.emit('unlink', fullPath);
78
+ }
79
+ } else if (event === 'change') {
80
+ if (hashTracking) {
81
+ const newHash = sha256File(fullPath);
82
+ const oldHash = fileHashes.get(fullPath);
83
+ if (newHash && newHash === oldHash) return;
84
+ if (newHash) fileHashes.set(fullPath, newHash);
85
+ }
86
+ emitter.emit('change', fullPath);
87
+ }
88
+ } catch (err) {
89
+ emitter.emit('error', err);
90
+ }
91
+ }, debounceMs));
92
+ });
93
+
94
+ watcher.on('error', (err) => {
95
+ emitter.emit('error', err);
96
+ });
97
+ } catch (err) {
98
+ emitter.emit('error', err);
99
+ return;
100
+ }
101
+
102
+ watchers.push({ watcher, path: dir, debounceTimers });
103
+ watchState.count++;
104
+
105
+ try {
106
+ const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
107
+ if (items) {
108
+ for (const entry of items) {
109
+ if (entry.isDirectory()) {
110
+ watchDirectory(path.join(dir, entry.name), emitter, watchers, options);
111
+ }
112
+ }
113
+ }
114
+ } catch (_) {
115
+ // Cannot read directory contents
116
+ }
117
+ }
118
+
119
+ function watchLocales(dirs, onChange, options = {}) {
120
+ const directories = Array.isArray(dirs) ? dirs : [dirs];
121
+ const emitter = new EventEmitter();
122
+ emitter.on('error', () => {});
123
+ const watchers = [];
124
+
125
+ // Backward-compatible onChange callback
126
+ if (typeof onChange === 'function') {
127
+ emitter.on('change', onChange);
128
+ emitter.on('add', onChange);
129
+ emitter.on('unlink', onChange);
130
+ } else if (typeof onChange === 'object' && onChange !== null && typeof onChange.onChange === 'function') {
131
+ emitter.on('change', onChange.onChange);
132
+ emitter.on('add', onChange.onChange);
133
+ emitter.on('unlink', onChange.onChange);
134
+ }
135
+
136
+ const {
137
+ debounceMs = DEFAULT_DEBOUNCE_MS,
138
+ hashTracking = true,
139
+ maxDirectories = DEFAULT_MAX_DIRECTORIES
140
+ } = (typeof onChange === 'object' && onChange !== null) ? onChange : options;
141
+
142
+ const watchState = { count: 0, maxDirectories };
143
+ for (const d of directories) {
144
+ if (watchState.count >= watchState.maxDirectories) {
145
+ emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
146
+ break;
147
+ }
148
+
149
+ const resolved = path.resolve(d);
150
+ const validated = SecurityUtils.validatePath(resolved, process.cwd());
151
+ if (!validated) {
152
+ emitter.emit('error', new Error(`Path validation failed for: ${d}`));
153
+ continue;
154
+ }
155
+
156
+ const projectRoot = path.resolve(process.cwd());
157
+ const rel = path.relative(projectRoot, validated);
158
+ if (rel.startsWith('..')) {
159
+ emitter.emit('error', new Error(`Directory outside project root: ${d}`));
160
+ continue;
161
+ }
162
+
163
+ watchDirectory(validated, emitter, watchers, { debounceMs, hashTracking, watchState });
164
+ }
165
+
166
+ const stop = () => {
167
+ for (const entry of watchers) {
168
+ if (entry.debounceTimers) {
169
+ for (const timer of entry.debounceTimers.values()) {
170
+ clearTimeout(timer);
171
+ }
172
+ entry.debounceTimers.clear();
173
+ }
174
+ try { entry.watcher.close(); } catch (_) { /* ignore */ }
175
+ }
176
+ watchers.length = 0;
177
+ emitter.removeAllListeners();
178
+ };
179
+
180
+ stop.stop = stop;
181
+ stop.emitter = emitter;
182
+ stop.on = emitter.on.bind(emitter);
183
+ stop.once = emitter.once.bind(emitter);
184
+ stop.off = emitter.off ? emitter.off.bind(emitter) : emitter.removeListener.bind(emitter);
185
+ stop.emit = emitter.emit.bind(emitter);
186
+ stop.removeListener = emitter.removeListener.bind(emitter);
187
+ stop.removeAllListeners = emitter.removeAllListeners.bind(emitter);
188
+ stop.getWatchedPaths = () => watchers.map(w => w.path);
189
+ stop.getDebounceMs = () => debounceMs;
190
+
191
+ return stop;
192
+ }
193
+
194
+ module.exports = watchLocales;