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