blob: 5c84badc720963e29b05c2478315d76606dfb841 [file] [log] [blame]
Leo Repp58b9f112021-11-22 11:57:47 +01001'use strict';
2
3const fs = require('fs');
4const sysPath = require('path');
5const { promisify } = require('util');
6
7let fsevents;
8try {
9 fsevents = require('fsevents');
10} catch (error) {
11 if (process.env.CHOKIDAR_PRINT_FSEVENTS_REQUIRE_ERROR) console.error(error);
12}
13
14if (fsevents) {
15 // TODO: real check
16 const mtch = process.version.match(/v(\d+)\.(\d+)/);
17 if (mtch && mtch[1] && mtch[2]) {
18 const maj = Number.parseInt(mtch[1], 10);
19 const min = Number.parseInt(mtch[2], 10);
20 if (maj === 8 && min < 16) {
21 fsevents = undefined;
22 }
23 }
24}
25
26const {
27 EV_ADD,
28 EV_CHANGE,
29 EV_ADD_DIR,
30 EV_UNLINK,
31 EV_ERROR,
32 STR_DATA,
33 STR_END,
34 FSEVENT_CREATED,
35 FSEVENT_MODIFIED,
36 FSEVENT_DELETED,
37 FSEVENT_MOVED,
38 // FSEVENT_CLONED,
39 FSEVENT_UNKNOWN,
40 FSEVENT_TYPE_FILE,
41 FSEVENT_TYPE_DIRECTORY,
42 FSEVENT_TYPE_SYMLINK,
43
44 ROOT_GLOBSTAR,
45 DIR_SUFFIX,
46 DOT_SLASH,
47 FUNCTION_TYPE,
48 EMPTY_FN,
49 IDENTITY_FN
50} = require('./constants');
51
52const Depth = (value) => isNaN(value) ? {} : {depth: value};
53
54const stat = promisify(fs.stat);
55const lstat = promisify(fs.lstat);
56const realpath = promisify(fs.realpath);
57
58const statMethods = { stat, lstat };
59
60/**
61 * @typedef {String} Path
62 */
63
64/**
65 * @typedef {Object} FsEventsWatchContainer
66 * @property {Set<Function>} listeners
67 * @property {Function} rawEmitter
68 * @property {{stop: Function}} watcher
69 */
70
71// fsevents instance helper functions
72/**
73 * Object to hold per-process fsevents instances (may be shared across chokidar FSWatcher instances)
74 * @type {Map<Path,FsEventsWatchContainer>}
75 */
76const FSEventsWatchers = new Map();
77
78// Threshold of duplicate path prefixes at which to start
79// consolidating going forward
80const consolidateThreshhold = 10;
81
82const wrongEventFlags = new Set([
83 69888, 70400, 71424, 72704, 73472, 131328, 131840, 262912
84]);
85
86/**
87 * Instantiates the fsevents interface
88 * @param {Path} path path to be watched
89 * @param {Function} callback called when fsevents is bound and ready
90 * @returns {{stop: Function}} new fsevents instance
91 */
92const createFSEventsInstance = (path, callback) => {
93 const stop = fsevents.watch(path, callback);
94 return {stop};
95};
96
97/**
98 * Instantiates the fsevents interface or binds listeners to an existing one covering
99 * the same file tree.
100 * @param {Path} path - to be watched
101 * @param {Path} realPath - real path for symlinks
102 * @param {Function} listener - called when fsevents emits events
103 * @param {Function} rawEmitter - passes data to listeners of the 'raw' event
104 * @returns {Function} closer
105 */
106function setFSEventsListener(path, realPath, listener, rawEmitter) {
107 let watchPath = sysPath.extname(path) ? sysPath.dirname(path) : path;
108 const parentPath = sysPath.dirname(watchPath);
109 let cont = FSEventsWatchers.get(watchPath);
110
111 // If we've accumulated a substantial number of paths that
112 // could have been consolidated by watching one directory
113 // above the current one, create a watcher on the parent
114 // path instead, so that we do consolidate going forward.
115 if (couldConsolidate(parentPath)) {
116 watchPath = parentPath;
117 }
118
119 const resolvedPath = sysPath.resolve(path);
120 const hasSymlink = resolvedPath !== realPath;
121
122 const filteredListener = (fullPath, flags, info) => {
123 if (hasSymlink) fullPath = fullPath.replace(realPath, resolvedPath);
124 if (
125 fullPath === resolvedPath ||
126 !fullPath.indexOf(resolvedPath + sysPath.sep)
127 ) listener(fullPath, flags, info);
128 };
129
130 // check if there is already a watcher on a parent path
131 // modifies `watchPath` to the parent path when it finds a match
132 let watchedParent = false;
133 for (const watchedPath of FSEventsWatchers.keys()) {
134 if (realPath.indexOf(sysPath.resolve(watchedPath) + sysPath.sep) === 0) {
135 watchPath = watchedPath;
136 cont = FSEventsWatchers.get(watchPath);
137 watchedParent = true;
138 break;
139 }
140 }
141
142 if (cont || watchedParent) {
143 cont.listeners.add(filteredListener);
144 } else {
145 cont = {
146 listeners: new Set([filteredListener]),
147 rawEmitter,
148 watcher: createFSEventsInstance(watchPath, (fullPath, flags) => {
149 if (!cont.listeners.size) return;
150 const info = fsevents.getInfo(fullPath, flags);
151 cont.listeners.forEach(list => {
152 list(fullPath, flags, info);
153 });
154
155 cont.rawEmitter(info.event, fullPath, info);
156 })
157 };
158 FSEventsWatchers.set(watchPath, cont);
159 }
160
161 // removes this instance's listeners and closes the underlying fsevents
162 // instance if there are no more listeners left
163 return () => {
164 const lst = cont.listeners;
165
166 lst.delete(filteredListener);
167 if (!lst.size) {
168 FSEventsWatchers.delete(watchPath);
169 if (cont.watcher) return cont.watcher.stop().then(() => {
170 cont.rawEmitter = cont.watcher = undefined;
171 Object.freeze(cont);
172 });
173 }
174 };
175}
176
177// Decide whether or not we should start a new higher-level
178// parent watcher
179const couldConsolidate = (path) => {
180 let count = 0;
181 for (const watchPath of FSEventsWatchers.keys()) {
182 if (watchPath.indexOf(path) === 0) {
183 count++;
184 if (count >= consolidateThreshhold) {
185 return true;
186 }
187 }
188 }
189
190 return false;
191};
192
193// returns boolean indicating whether fsevents can be used
194const canUse = () => fsevents && FSEventsWatchers.size < 128;
195
196// determines subdirectory traversal levels from root to path
197const calcDepth = (path, root) => {
198 let i = 0;
199 while (!path.indexOf(root) && (path = sysPath.dirname(path)) !== root) i++;
200 return i;
201};
202
203// returns boolean indicating whether the fsevents' event info has the same type
204// as the one returned by fs.stat
205const sameTypes = (info, stats) => (
206 info.type === FSEVENT_TYPE_DIRECTORY && stats.isDirectory() ||
207 info.type === FSEVENT_TYPE_SYMLINK && stats.isSymbolicLink() ||
208 info.type === FSEVENT_TYPE_FILE && stats.isFile()
209)
210
211/**
212 * @mixin
213 */
214class FsEventsHandler {
215
216/**
217 * @param {import('../index').FSWatcher} fsw
218 */
219constructor(fsw) {
220 this.fsw = fsw;
221}
222checkIgnored(path, stats) {
223 const ipaths = this.fsw._ignoredPaths;
224 if (this.fsw._isIgnored(path, stats)) {
225 ipaths.add(path);
226 if (stats && stats.isDirectory()) {
227 ipaths.add(path + ROOT_GLOBSTAR);
228 }
229 return true;
230 }
231
232 ipaths.delete(path);
233 ipaths.delete(path + ROOT_GLOBSTAR);
234}
235
236addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts) {
237 const event = watchedDir.has(item) ? EV_CHANGE : EV_ADD;
238 this.handleEvent(event, path, fullPath, realPath, parent, watchedDir, item, info, opts);
239}
240
241async checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts) {
242 try {
243 const stats = await stat(path)
244 if (this.fsw.closed) return;
245 if (sameTypes(info, stats)) {
246 this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts);
247 } else {
248 this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts);
249 }
250 } catch (error) {
251 if (error.code === 'EACCES') {
252 this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts);
253 } else {
254 this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts);
255 }
256 }
257}
258
259handleEvent(event, path, fullPath, realPath, parent, watchedDir, item, info, opts) {
260 if (this.fsw.closed || this.checkIgnored(path)) return;
261
262 if (event === EV_UNLINK) {
263 const isDirectory = info.type === FSEVENT_TYPE_DIRECTORY
264 // suppress unlink events on never before seen files
265 if (isDirectory || watchedDir.has(item)) {
266 this.fsw._remove(parent, item, isDirectory);
267 }
268 } else {
269 if (event === EV_ADD) {
270 // track new directories
271 if (info.type === FSEVENT_TYPE_DIRECTORY) this.fsw._getWatchedDir(path);
272
273 if (info.type === FSEVENT_TYPE_SYMLINK && opts.followSymlinks) {
274 // push symlinks back to the top of the stack to get handled
275 const curDepth = opts.depth === undefined ?
276 undefined : calcDepth(fullPath, realPath) + 1;
277 return this._addToFsEvents(path, false, true, curDepth);
278 }
279
280 // track new paths
281 // (other than symlinks being followed, which will be tracked soon)
282 this.fsw._getWatchedDir(parent).add(item);
283 }
284 /**
285 * @type {'add'|'addDir'|'unlink'|'unlinkDir'}
286 */
287 const eventName = info.type === FSEVENT_TYPE_DIRECTORY ? event + DIR_SUFFIX : event;
288 this.fsw._emit(eventName, path);
289 if (eventName === EV_ADD_DIR) this._addToFsEvents(path, false, true);
290 }
291}
292
293/**
294 * Handle symlinks encountered during directory scan
295 * @param {String} watchPath - file/dir path to be watched with fsevents
296 * @param {String} realPath - real path (in case of symlinks)
297 * @param {Function} transform - path transformer
298 * @param {Function} globFilter - path filter in case a glob pattern was provided
299 * @returns {Function} closer for the watcher instance
300*/
301_watchWithFsEvents(watchPath, realPath, transform, globFilter) {
302 if (this.fsw.closed || this.fsw._isIgnored(watchPath)) return;
303 const opts = this.fsw.options;
304 const watchCallback = async (fullPath, flags, info) => {
305 if (this.fsw.closed) return;
306 if (
307 opts.depth !== undefined &&
308 calcDepth(fullPath, realPath) > opts.depth
309 ) return;
310 const path = transform(sysPath.join(
311 watchPath, sysPath.relative(watchPath, fullPath)
312 ));
313 if (globFilter && !globFilter(path)) return;
314 // ensure directories are tracked
315 const parent = sysPath.dirname(path);
316 const item = sysPath.basename(path);
317 const watchedDir = this.fsw._getWatchedDir(
318 info.type === FSEVENT_TYPE_DIRECTORY ? path : parent
319 );
320
321 // correct for wrong events emitted
322 if (wrongEventFlags.has(flags) || info.event === FSEVENT_UNKNOWN) {
323 if (typeof opts.ignored === FUNCTION_TYPE) {
324 let stats;
325 try {
326 stats = await stat(path);
327 } catch (error) {}
328 if (this.fsw.closed) return;
329 if (this.checkIgnored(path, stats)) return;
330 if (sameTypes(info, stats)) {
331 this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts);
332 } else {
333 this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts);
334 }
335 } else {
336 this.checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts);
337 }
338 } else {
339 switch (info.event) {
340 case FSEVENT_CREATED:
341 case FSEVENT_MODIFIED:
342 return this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts);
343 case FSEVENT_DELETED:
344 case FSEVENT_MOVED:
345 return this.checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts);
346 }
347 }
348 };
349
350 const closer = setFSEventsListener(
351 watchPath,
352 realPath,
353 watchCallback,
354 this.fsw._emitRaw
355 );
356
357 this.fsw._emitReady();
358 return closer;
359}
360
361/**
362 * Handle symlinks encountered during directory scan
363 * @param {String} linkPath path to symlink
364 * @param {String} fullPath absolute path to the symlink
365 * @param {Function} transform pre-existing path transformer
366 * @param {Number} curDepth level of subdirectories traversed to where symlink is
367 * @returns {Promise<void>}
368 */
369async _handleFsEventsSymlink(linkPath, fullPath, transform, curDepth) {
370 // don't follow the same symlink more than once
371 if (this.fsw.closed || this.fsw._symlinkPaths.has(fullPath)) return;
372
373 this.fsw._symlinkPaths.set(fullPath, true);
374 this.fsw._incrReadyCount();
375
376 try {
377 const linkTarget = await realpath(linkPath);
378 if (this.fsw.closed) return;
379 if (this.fsw._isIgnored(linkTarget)) {
380 return this.fsw._emitReady();
381 }
382
383 this.fsw._incrReadyCount();
384
385 // add the linkTarget for watching with a wrapper for transform
386 // that causes emitted paths to incorporate the link's path
387 this._addToFsEvents(linkTarget || linkPath, (path) => {
388 let aliasedPath = linkPath;
389 if (linkTarget && linkTarget !== DOT_SLASH) {
390 aliasedPath = path.replace(linkTarget, linkPath);
391 } else if (path !== DOT_SLASH) {
392 aliasedPath = sysPath.join(linkPath, path);
393 }
394 return transform(aliasedPath);
395 }, false, curDepth);
396 } catch(error) {
397 if (this.fsw._handleError(error)) {
398 return this.fsw._emitReady();
399 }
400 }
401}
402
403/**
404 *
405 * @param {Path} newPath
406 * @param {fs.Stats} stats
407 */
408emitAdd(newPath, stats, processPath, opts, forceAdd) {
409 const pp = processPath(newPath);
410 const isDir = stats.isDirectory();
411 const dirObj = this.fsw._getWatchedDir(sysPath.dirname(pp));
412 const base = sysPath.basename(pp);
413
414 // ensure empty dirs get tracked
415 if (isDir) this.fsw._getWatchedDir(pp);
416 if (dirObj.has(base)) return;
417 dirObj.add(base);
418
419 if (!opts.ignoreInitial || forceAdd === true) {
420 this.fsw._emit(isDir ? EV_ADD_DIR : EV_ADD, pp, stats);
421 }
422}
423
424initWatch(realPath, path, wh, processPath) {
425 if (this.fsw.closed) return;
426 const closer = this._watchWithFsEvents(
427 wh.watchPath,
428 sysPath.resolve(realPath || wh.watchPath),
429 processPath,
430 wh.globFilter
431 );
432 this.fsw._addPathCloser(path, closer);
433}
434
435/**
436 * Handle added path with fsevents
437 * @param {String} path file/dir path or glob pattern
438 * @param {Function|Boolean=} transform converts working path to what the user expects
439 * @param {Boolean=} forceAdd ensure add is emitted
440 * @param {Number=} priorDepth Level of subdirectories already traversed.
441 * @returns {Promise<void>}
442 */
443async _addToFsEvents(path, transform, forceAdd, priorDepth) {
444 if (this.fsw.closed) {
445 return;
446 }
447 const opts = this.fsw.options;
448 const processPath = typeof transform === FUNCTION_TYPE ? transform : IDENTITY_FN;
449
450 const wh = this.fsw._getWatchHelpers(path);
451
452 // evaluate what is at the path we're being asked to watch
453 try {
454 const stats = await statMethods[wh.statMethod](wh.watchPath);
455 if (this.fsw.closed) return;
456 if (this.fsw._isIgnored(wh.watchPath, stats)) {
457 throw null;
458 }
459 if (stats.isDirectory()) {
460 // emit addDir unless this is a glob parent
461 if (!wh.globFilter) this.emitAdd(processPath(path), stats, processPath, opts, forceAdd);
462
463 // don't recurse further if it would exceed depth setting
464 if (priorDepth && priorDepth > opts.depth) return;
465
466 // scan the contents of the dir
467 this.fsw._readdirp(wh.watchPath, {
468 fileFilter: entry => wh.filterPath(entry),
469 directoryFilter: entry => wh.filterDir(entry),
470 ...Depth(opts.depth - (priorDepth || 0))
471 }).on(STR_DATA, (entry) => {
472 // need to check filterPath on dirs b/c filterDir is less restrictive
473 if (this.fsw.closed) {
474 return;
475 }
476 if (entry.stats.isDirectory() && !wh.filterPath(entry)) return;
477
478 const joinedPath = sysPath.join(wh.watchPath, entry.path);
479 const {fullPath} = entry;
480
481 if (wh.followSymlinks && entry.stats.isSymbolicLink()) {
482 // preserve the current depth here since it can't be derived from
483 // real paths past the symlink
484 const curDepth = opts.depth === undefined ?
485 undefined : calcDepth(joinedPath, sysPath.resolve(wh.watchPath)) + 1;
486
487 this._handleFsEventsSymlink(joinedPath, fullPath, processPath, curDepth);
488 } else {
489 this.emitAdd(joinedPath, entry.stats, processPath, opts, forceAdd);
490 }
491 }).on(EV_ERROR, EMPTY_FN).on(STR_END, () => {
492 this.fsw._emitReady();
493 });
494 } else {
495 this.emitAdd(wh.watchPath, stats, processPath, opts, forceAdd);
496 this.fsw._emitReady();
497 }
498 } catch (error) {
499 if (!error || this.fsw._handleError(error)) {
500 // TODO: Strange thing: "should not choke on an ignored watch path" will be failed without 2 ready calls -__-
501 this.fsw._emitReady();
502 this.fsw._emitReady();
503 }
504 }
505
506 if (opts.persistent && forceAdd !== true) {
507 if (typeof transform === FUNCTION_TYPE) {
508 // realpath has already been resolved
509 this.initWatch(undefined, path, wh, processPath);
510 } else {
511 let realPath;
512 try {
513 realPath = await realpath(wh.watchPath);
514 } catch (e) {}
515 this.initWatch(realPath, path, wh, processPath);
516 }
517 }
518}
519
520}
521
522module.exports = FsEventsHandler;
523module.exports.canUse = canUse;