1'use strict';
2
3const {
4  ArrayPrototypePush,
5  SafePromiseAllReturnVoid,
6  Promise,
7  PromisePrototypeThen,
8  SafeMap,
9  SafeSet,
10  StringPrototypeStartsWith,
11  SymbolAsyncIterator,
12} = primordials;
13
14const { EventEmitter } = require('events');
15const assert = require('internal/assert');
16const {
17  AbortError,
18  codes: {
19    ERR_INVALID_ARG_VALUE,
20  },
21} = require('internal/errors');
22const { getValidatedPath } = require('internal/fs/utils');
23const { kFSWatchStart, StatWatcher } = require('internal/fs/watchers');
24const { kEmptyObject } = require('internal/util');
25const { validateBoolean, validateAbortSignal } = require('internal/validators');
26const {
27  basename: pathBasename,
28  join: pathJoin,
29  relative: pathRelative,
30  resolve: pathResolve,
31} = require('path');
32
33let internalSync;
34let internalPromises;
35
36function lazyLoadFsPromises() {
37  internalPromises ??= require('fs/promises');
38  return internalPromises;
39}
40
41function lazyLoadFsSync() {
42  internalSync ??= require('fs');
43  return internalSync;
44}
45let kResistStopPropagation;
46
47async function traverse(dir, files = new SafeMap(), symbolicLinks = new SafeSet()) {
48  const { opendir } = lazyLoadFsPromises();
49
50  const filenames = await opendir(dir);
51  const subdirectories = [];
52
53  for await (const file of filenames) {
54    const f = pathJoin(dir, file.name);
55
56    files.set(f, file);
57
58    // Do not follow symbolic links
59    if (file.isSymbolicLink()) {
60      symbolicLinks.add(f);
61    } else if (file.isDirectory()) {
62      ArrayPrototypePush(subdirectories, traverse(f, files));
63    }
64  }
65
66  await SafePromiseAllReturnVoid(subdirectories);
67
68  return files;
69}
70
71class FSWatcher extends EventEmitter {
72  #options = null;
73  #closed = false;
74  #files = new SafeMap();
75  #symbolicFiles = new SafeSet();
76  #rootPath = pathResolve();
77  #watchingFile = false;
78
79  constructor(options = kEmptyObject) {
80    super();
81
82    assert(typeof options === 'object');
83
84    const { persistent, recursive, signal, encoding } = options;
85
86    // TODO(anonrig): Add non-recursive support to non-native-watcher for IBMi & AIX support.
87    if (recursive != null) {
88      validateBoolean(recursive, 'options.recursive');
89    }
90
91    if (persistent != null) {
92      validateBoolean(persistent, 'options.persistent');
93    }
94
95    if (signal != null) {
96      validateAbortSignal(signal, 'options.signal');
97    }
98
99    if (encoding != null) {
100      // This is required since on macOS and Windows it throws ERR_INVALID_ARG_VALUE
101      if (typeof encoding !== 'string') {
102        throw new ERR_INVALID_ARG_VALUE(encoding, 'options.encoding');
103      }
104    }
105
106    this.#options = { persistent, recursive, signal, encoding };
107  }
108
109  close() {
110    if (this.#closed) {
111      return;
112    }
113
114    const { unwatchFile } = lazyLoadFsSync();
115    this.#closed = true;
116
117    for (const file of this.#files.keys()) {
118      unwatchFile(file);
119    }
120
121    this.#files.clear();
122    this.#symbolicFiles.clear();
123    this.emit('close');
124  }
125
126  #unwatchFiles(file) {
127    const { unwatchFile } = lazyLoadFsSync();
128
129    this.#symbolicFiles.delete(file);
130
131    for (const filename of this.#files.keys()) {
132      if (StringPrototypeStartsWith(filename, file)) {
133        unwatchFile(filename);
134      }
135    }
136  }
137
138  async #watchFolder(folder) {
139    const { opendir } = lazyLoadFsPromises();
140
141    try {
142      const files = await opendir(folder);
143
144      for await (const file of files) {
145        if (this.#closed) {
146          break;
147        }
148
149        const f = pathJoin(folder, file.name);
150
151        if (!this.#files.has(f)) {
152          this.emit('change', 'rename', pathRelative(this.#rootPath, f));
153
154          if (file.isSymbolicLink()) {
155            this.#symbolicFiles.add(f);
156          }
157
158          if (file.isFile()) {
159            this.#watchFile(f);
160          } else {
161            this.#files.set(f, file);
162
163            if (file.isDirectory() && !file.isSymbolicLink()) {
164              await this.#watchFolder(f);
165            }
166          }
167        }
168      }
169    } catch (error) {
170      this.emit('error', error);
171    }
172  }
173
174  #watchFile(file) {
175    if (this.#closed) {
176      return;
177    }
178
179    const { watchFile } = lazyLoadFsSync();
180    const existingStat = this.#files.get(file);
181
182    watchFile(file, {
183      persistent: this.#options.persistent,
184    }, (currentStats, previousStats) => {
185      if (existingStat && !existingStat.isDirectory() &&
186        currentStats.nlink !== 0 && existingStat.mtimeMs === currentStats.mtimeMs) {
187        return;
188      }
189
190      this.#files.set(file, currentStats);
191
192      if (currentStats.birthtimeMs === 0 && previousStats.birthtimeMs !== 0) {
193        // The file is now deleted
194        this.#files.delete(file);
195        this.emit('change', 'rename', pathRelative(this.#rootPath, file));
196        this.#unwatchFiles(file);
197      } else if (file === this.#rootPath && this.#watchingFile) {
198        // This case will only be triggered when watching a file with fs.watch
199        this.emit('change', 'change', pathBasename(file));
200      } else if (this.#symbolicFiles.has(file)) {
201        // Stats from watchFile does not return correct value for currentStats.isSymbolicLink()
202        // Since it is only valid when using fs.lstat(). Therefore, check the existing symbolic files.
203        this.emit('change', 'rename', pathRelative(this.#rootPath, file));
204      } else if (currentStats.isDirectory()) {
205        this.#watchFolder(file);
206      }
207    });
208  }
209
210  [kFSWatchStart](filename) {
211    filename = pathResolve(getValidatedPath(filename));
212
213    try {
214      const file = lazyLoadFsSync().statSync(filename);
215
216      this.#rootPath = filename;
217      this.#closed = false;
218      this.#watchingFile = file.isFile();
219
220      if (file.isDirectory()) {
221        this.#files.set(filename, file);
222
223        PromisePrototypeThen(
224          traverse(filename, this.#files, this.#symbolicFiles),
225          () => {
226            for (const f of this.#files.keys()) {
227              this.#watchFile(f);
228            }
229          },
230        );
231      } else {
232        this.#watchFile(filename);
233      }
234    } catch (error) {
235      if (error.code === 'ENOENT') {
236        error.filename = filename;
237        throw error;
238      }
239    }
240
241  }
242
243  ref() {
244    this.#files.forEach((file) => {
245      if (file instanceof StatWatcher) {
246        file.ref();
247      }
248    });
249  }
250
251  unref() {
252    this.#files.forEach((file) => {
253      if (file instanceof StatWatcher) {
254        file.unref();
255      }
256    });
257  }
258
259  [SymbolAsyncIterator]() {
260    const { signal } = this.#options;
261    const promiseExecutor = signal == null ?
262      (resolve) => {
263        this.once('change', (eventType, filename) => {
264          resolve({ __proto__: null, value: { eventType, filename } });
265        });
266      } : (resolve, reject) => {
267        const onAbort = () => reject(new AbortError(undefined, { cause: signal.reason }));
268        if (signal.aborted) return onAbort();
269        kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
270        signal.addEventListener('abort', onAbort, { __proto__: null, once: true, [kResistStopPropagation]: true });
271        this.once('change', (eventType, filename) => {
272          signal.removeEventListener('abort', onAbort);
273          resolve({ __proto__: null, value: { eventType, filename } });
274        });
275      };
276    return {
277      next: () => (this.#closed ?
278        { __proto__: null, done: true } :
279        new Promise(promiseExecutor)),
280      [SymbolAsyncIterator]() { return this; },
281    };
282  }
283}
284
285module.exports = {
286  FSWatcher,
287  kFSWatchStart,
288};
289