index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Tobias Koppers @sokra
  4. */
  5. "use strict";
  6. const { EventEmitter } = require("events");
  7. const LinkResolver = require("./LinkResolver");
  8. const getWatcherManager = require("./getWatcherManager");
  9. const globToRegExp = require("./util/globToRegExp");
  10. const watchEventSource = require("./watchEventSource");
  11. /** @typedef {import("./getWatcherManager").WatcherManager} WatcherManager */
  12. /** @typedef {import("./DirectoryWatcher")} DirectoryWatcher */
  13. /** @typedef {import("./DirectoryWatcher").DirectoryWatcherEvents} DirectoryWatcherEvents */
  14. /** @typedef {import("./DirectoryWatcher").FileWatcherEvents} FileWatcherEvents */
  15. // eslint-disable-next-line jsdoc/reject-any-type
  16. /** @typedef {Record<string, (...args: any[]) => any>} EventMap */
  17. /**
  18. * @template {EventMap} T
  19. * @typedef {import("./DirectoryWatcher").Watcher<T>} Watcher
  20. */
  21. /** @typedef {(item: string) => boolean} IgnoredFunction */
  22. /** @typedef {string[] | RegExp | string | IgnoredFunction} Ignored */
  23. /**
  24. * @typedef {object} WatcherOptions
  25. * @property {boolean=} followSymlinks true when need to resolve symlinks and watch symlink and real file, otherwise false
  26. * @property {Ignored=} ignored ignore some files from watching (glob pattern or regexp)
  27. * @property {number | boolean=} poll true when need to enable polling mode for watching, otherwise false
  28. */
  29. /** @typedef {WatcherOptions & { aggregateTimeout?: number }} WatchOptions */
  30. /**
  31. * @typedef {object} NormalizedWatchOptions
  32. * @property {boolean} followSymlinks true when need to resolve symlinks and watch symlink and real file, otherwise false
  33. * @property {IgnoredFunction} ignored ignore some files from watching (glob pattern or regexp)
  34. * @property {number | boolean=} poll true when need to enable polling mode for watching, otherwise false
  35. */
  36. /** @typedef {`scan (${string})` | "change" | "rename" | `watch ${string}` | `directory-removed ${string}`} EventType */
  37. /** @typedef {{ safeTime: number, timestamp: number, accuracy: number }} Entry */
  38. /** @typedef {{ safeTime: number }} OnlySafeTimeEntry */
  39. // eslint-disable-next-line jsdoc/ts-no-empty-object-type
  40. /** @typedef {{ }} ExistenceOnlyTimeEntry */
  41. /** @typedef {Map<string, Entry | OnlySafeTimeEntry | ExistenceOnlyTimeEntry | null>} TimeInfoEntries */
  42. /** @typedef {Set<string>} Changes */
  43. /** @typedef {Set<string>} Removals */
  44. /** @typedef {{ changes: Changes, removals: Removals }} Aggregated */
  45. /** @typedef {{ files?: Iterable<string>, directories?: Iterable<string>, missing?: Iterable<string>, startTime?: number }} WatchMethodOptions */
  46. /** @typedef {Record<string, number>} Times */
  47. /**
  48. * @param {MapIterator<WatchpackFileWatcher> | MapIterator<WatchpackDirectoryWatcher>} watchers watchers
  49. * @param {Set<DirectoryWatcher>} set set
  50. */
  51. function addWatchersToSet(watchers, set) {
  52. for (const ww of watchers) {
  53. // Set.add is already idempotent, so skip the redundant has() probe.
  54. set.add(ww.watcher.directoryWatcher);
  55. }
  56. }
  57. /**
  58. * @param {string} ignored ignored
  59. * @returns {string | undefined} resolved global to regexp
  60. */
  61. const stringToRegexp = (ignored) => {
  62. if (ignored.length === 0) {
  63. return;
  64. }
  65. return `^${globToRegExp(ignored)}(?:$|\\/)`;
  66. };
  67. /**
  68. * Normalizes path separators for regex testing. `String.prototype.replace`
  69. * always allocates a new string, even when the pattern finds nothing; for
  70. * POSIX paths (the common case) that allocation is pure overhead. Check for
  71. * a backslash with `indexOf` first so we skip the copy on paths that are
  72. * already normalized.
  73. * @param {string} item item
  74. * @returns {string} item with backslashes normalized to forward slashes
  75. */
  76. const normalizeSeparators = (item) =>
  77. item.includes("\\") ? item.replace(/\\/g, "/") : item;
  78. /**
  79. * @param {Ignored=} ignored ignored
  80. * @returns {(item: string) => boolean} ignored to function
  81. */
  82. const ignoredToFunction = (ignored) => {
  83. if (Array.isArray(ignored)) {
  84. const stringRegexps =
  85. /** @type {string[]} */
  86. (ignored.map((i) => stringToRegexp(i)).filter(Boolean));
  87. if (stringRegexps.length === 0) {
  88. return () => false;
  89. }
  90. const regexp =
  91. stringRegexps.length === 1
  92. ? new RegExp(stringRegexps[0])
  93. : new RegExp(stringRegexps.join("|"));
  94. return (item) => regexp.test(normalizeSeparators(item));
  95. } else if (typeof ignored === "string") {
  96. const stringRegexp = stringToRegexp(ignored);
  97. if (!stringRegexp) {
  98. return () => false;
  99. }
  100. const regexp = new RegExp(stringRegexp);
  101. return (item) => regexp.test(normalizeSeparators(item));
  102. } else if (ignored instanceof RegExp) {
  103. return (item) => ignored.test(normalizeSeparators(item));
  104. } else if (typeof ignored === "function") {
  105. return ignored;
  106. } else if (ignored) {
  107. throw new Error(`Invalid option for 'ignored': ${ignored}`);
  108. } else {
  109. return () => false;
  110. }
  111. };
  112. /**
  113. * @param {WatchOptions} options options
  114. * @returns {NormalizedWatchOptions} normalized options
  115. */
  116. const normalizeOptions = (options) => ({
  117. followSymlinks: Boolean(options.followSymlinks),
  118. ignored: ignoredToFunction(options.ignored),
  119. poll: options.poll,
  120. });
  121. const normalizeCache = new WeakMap();
  122. /**
  123. * @param {WatchOptions} options options
  124. * @returns {NormalizedWatchOptions} normalized options
  125. */
  126. const cachedNormalizeOptions = (options) => {
  127. const cacheEntry = normalizeCache.get(options);
  128. if (cacheEntry !== undefined) return cacheEntry;
  129. const normalized = normalizeOptions(options);
  130. normalizeCache.set(options, normalized);
  131. return normalized;
  132. };
  133. class WatchpackFileWatcher {
  134. /**
  135. * @param {Watchpack} watchpack watchpack
  136. * @param {Watcher<FileWatcherEvents>} watcher watcher
  137. * @param {string | string[]} files files
  138. */
  139. constructor(watchpack, watcher, files) {
  140. /** @type {string[]} */
  141. this.files = Array.isArray(files) ? files : [files];
  142. this.watcher = watcher;
  143. watcher.on("initial-missing", (type) => {
  144. for (const file of this.files) {
  145. if (!watchpack._missing.has(file)) {
  146. watchpack._onRemove(file, file, type);
  147. }
  148. }
  149. });
  150. watcher.on("change", (mtime, type, _initial) => {
  151. for (const file of this.files) {
  152. watchpack._onChange(file, mtime, file, type);
  153. }
  154. });
  155. watcher.on("remove", (type) => {
  156. for (const file of this.files) {
  157. watchpack._onRemove(file, file, type);
  158. }
  159. });
  160. }
  161. /**
  162. * @param {string | string[]} files files
  163. */
  164. update(files) {
  165. if (!Array.isArray(files)) {
  166. if (this.files.length !== 1) {
  167. this.files = [files];
  168. } else if (this.files[0] !== files) {
  169. this.files[0] = files;
  170. }
  171. } else {
  172. this.files = files;
  173. }
  174. }
  175. close() {
  176. this.watcher.close();
  177. }
  178. }
  179. class WatchpackDirectoryWatcher {
  180. /**
  181. * @param {Watchpack} watchpack watchpack
  182. * @param {Watcher<DirectoryWatcherEvents>} watcher watcher
  183. * @param {string} directories directories
  184. */
  185. constructor(watchpack, watcher, directories) {
  186. /** @type {string[]} */
  187. this.directories = Array.isArray(directories) ? directories : [directories];
  188. this.watcher = watcher;
  189. watcher.on("initial-missing", (type) => {
  190. for (const item of this.directories) {
  191. watchpack._onRemove(item, item, type);
  192. }
  193. });
  194. watcher.on("change", (file, mtime, type, _initial) => {
  195. for (const item of this.directories) {
  196. watchpack._onChange(item, mtime, file, type);
  197. }
  198. });
  199. watcher.on("remove", (type) => {
  200. for (const item of this.directories) {
  201. watchpack._onRemove(item, item, type);
  202. }
  203. });
  204. }
  205. /**
  206. * @param {string | string[]} directories directories
  207. */
  208. update(directories) {
  209. if (!Array.isArray(directories)) {
  210. if (this.directories.length !== 1) {
  211. this.directories = [directories];
  212. } else if (this.directories[0] !== directories) {
  213. this.directories[0] = directories;
  214. }
  215. } else {
  216. this.directories = directories;
  217. }
  218. }
  219. close() {
  220. this.watcher.close();
  221. }
  222. }
  223. /**
  224. * @typedef {object} WatchpackEvents
  225. * @property {(file: string, mtime: number, type: EventType) => void} change change event
  226. * @property {(file: string, type: EventType) => void} remove remove event
  227. * @property {(changes: Changes, removals: Removals) => void} aggregated aggregated event
  228. */
  229. /**
  230. * @extends {EventEmitter<{ [K in keyof WatchpackEvents]: Parameters<WatchpackEvents[K]> }>}
  231. */
  232. class Watchpack extends EventEmitter {
  233. /**
  234. * @param {WatchOptions=} options options
  235. */
  236. constructor(options = {}) {
  237. super();
  238. if (!options) options = {};
  239. /** @type {WatchOptions} */
  240. this.options = options;
  241. this.aggregateTimeout =
  242. typeof options.aggregateTimeout === "number"
  243. ? options.aggregateTimeout
  244. : 200;
  245. /** @type {NormalizedWatchOptions} */
  246. this.watcherOptions = cachedNormalizeOptions(options);
  247. /** @type {WatcherManager} */
  248. this.watcherManager = getWatcherManager(this.watcherOptions);
  249. /** @type {Map<string, WatchpackFileWatcher>} */
  250. this.fileWatchers = new Map();
  251. /** @type {Map<string, WatchpackDirectoryWatcher>} */
  252. this.directoryWatchers = new Map();
  253. /** @type {Set<string>} */
  254. this._missing = new Set();
  255. this.startTime = undefined;
  256. this.paused = false;
  257. /** @type {Changes} */
  258. this.aggregatedChanges = new Set();
  259. /** @type {Removals} */
  260. this.aggregatedRemovals = new Set();
  261. /** @type {undefined | NodeJS.Timeout} */
  262. this.aggregateTimer = undefined;
  263. this._onTimeout = this._onTimeout.bind(this);
  264. }
  265. /**
  266. * @overload
  267. * @param {Iterable<string>} arg1 files
  268. * @param {Iterable<string>} arg2 directories
  269. * @param {number=} arg3 startTime
  270. * @returns {void}
  271. */
  272. /**
  273. * @overload
  274. * @param {WatchMethodOptions} arg1 watch options
  275. * @returns {void}
  276. */
  277. /**
  278. * @param {Iterable<string> | WatchMethodOptions} arg1 files
  279. * @param {Iterable<string>=} arg2 directories
  280. * @param {number=} arg3 startTime
  281. * @returns {void}
  282. */
  283. watch(arg1, arg2, arg3) {
  284. /** @type {Iterable<string> | undefined} */
  285. let files;
  286. /** @type {Iterable<string> | undefined} */
  287. let directories;
  288. /** @type {Iterable<string> | undefined} */
  289. let missing;
  290. /** @type {number | undefined} */
  291. let startTime;
  292. if (!arg2) {
  293. ({
  294. files = [],
  295. directories = [],
  296. missing = [],
  297. startTime,
  298. } = /** @type {WatchMethodOptions} */ (arg1));
  299. } else {
  300. files = /** @type {Iterable<string>} */ (arg1);
  301. directories = /** @type {Iterable<string>} */ (arg2);
  302. missing = [];
  303. startTime = /** @type {number} */ (arg3);
  304. }
  305. this.paused = false;
  306. const { fileWatchers, directoryWatchers } = this;
  307. const { ignored } = this.watcherOptions;
  308. /**
  309. * @param {string} path path
  310. * @returns {boolean} true when need to filter, otherwise false
  311. */
  312. const filter = (path) => !ignored(path);
  313. /**
  314. * @template K, V
  315. * @param {Map<K, V | V[]>} map map
  316. * @param {K} key key
  317. * @param {V} item item
  318. */
  319. const addToMap = (map, key, item) => {
  320. const list = map.get(key);
  321. if (list === undefined) {
  322. map.set(key, item);
  323. } else if (Array.isArray(list)) {
  324. list.push(item);
  325. } else {
  326. map.set(key, [list, item]);
  327. }
  328. };
  329. const fileWatchersNeeded = new Map();
  330. const directoryWatchersNeeded = new Map();
  331. /** @type {Set<string>} */
  332. const missingFiles = new Set();
  333. if (this.watcherOptions.followSymlinks) {
  334. const resolver = new LinkResolver();
  335. for (const file of files) {
  336. if (filter(file)) {
  337. for (const innerFile of resolver.resolve(file)) {
  338. if (file === innerFile || filter(innerFile)) {
  339. addToMap(fileWatchersNeeded, innerFile, file);
  340. }
  341. }
  342. }
  343. }
  344. for (const file of missing) {
  345. if (filter(file)) {
  346. for (const innerFile of resolver.resolve(file)) {
  347. if (file === innerFile || filter(innerFile)) {
  348. missingFiles.add(file);
  349. addToMap(fileWatchersNeeded, innerFile, file);
  350. }
  351. }
  352. }
  353. }
  354. for (const dir of directories) {
  355. if (filter(dir)) {
  356. let first = true;
  357. for (const innerItem of resolver.resolve(dir)) {
  358. if (filter(innerItem)) {
  359. addToMap(
  360. first ? directoryWatchersNeeded : fileWatchersNeeded,
  361. innerItem,
  362. dir,
  363. );
  364. }
  365. first = false;
  366. }
  367. }
  368. }
  369. } else {
  370. for (const file of files) {
  371. if (filter(file)) {
  372. addToMap(fileWatchersNeeded, file, file);
  373. }
  374. }
  375. for (const file of missing) {
  376. if (filter(file)) {
  377. missingFiles.add(file);
  378. addToMap(fileWatchersNeeded, file, file);
  379. }
  380. }
  381. for (const dir of directories) {
  382. if (filter(dir)) {
  383. addToMap(directoryWatchersNeeded, dir, dir);
  384. }
  385. }
  386. }
  387. // Close unneeded old watchers
  388. // and update existing watchers
  389. for (const [key, w] of fileWatchers) {
  390. const needed = fileWatchersNeeded.get(key);
  391. if (needed === undefined) {
  392. w.close();
  393. fileWatchers.delete(key);
  394. } else {
  395. w.update(needed);
  396. fileWatchersNeeded.delete(key);
  397. }
  398. }
  399. for (const [key, w] of directoryWatchers) {
  400. const needed = directoryWatchersNeeded.get(key);
  401. if (needed === undefined) {
  402. w.close();
  403. directoryWatchers.delete(key);
  404. } else {
  405. w.update(needed);
  406. directoryWatchersNeeded.delete(key);
  407. }
  408. }
  409. // Create new watchers and install handlers on these watchers
  410. watchEventSource.batch(() => {
  411. for (const [key, files] of fileWatchersNeeded) {
  412. const watcher = this.watcherManager.watchFile(key, startTime);
  413. if (watcher) {
  414. fileWatchers.set(key, new WatchpackFileWatcher(this, watcher, files));
  415. }
  416. }
  417. for (const [key, directories] of directoryWatchersNeeded) {
  418. const watcher = this.watcherManager.watchDirectory(key, startTime);
  419. if (watcher) {
  420. directoryWatchers.set(
  421. key,
  422. new WatchpackDirectoryWatcher(this, watcher, directories),
  423. );
  424. }
  425. }
  426. });
  427. this._missing = missingFiles;
  428. this.startTime = startTime;
  429. }
  430. close() {
  431. this.paused = true;
  432. if (this.aggregateTimer) clearTimeout(this.aggregateTimer);
  433. for (const w of this.fileWatchers.values()) w.close();
  434. for (const w of this.directoryWatchers.values()) w.close();
  435. this.fileWatchers.clear();
  436. this.directoryWatchers.clear();
  437. }
  438. pause() {
  439. this.paused = true;
  440. if (this.aggregateTimer) clearTimeout(this.aggregateTimer);
  441. }
  442. /**
  443. * @returns {Record<string, number>} times
  444. */
  445. getTimes() {
  446. /** @type {Set<DirectoryWatcher>} */
  447. const directoryWatchers = new Set();
  448. addWatchersToSet(this.fileWatchers.values(), directoryWatchers);
  449. addWatchersToSet(this.directoryWatchers.values(), directoryWatchers);
  450. /** @type {Record<string, number>} */
  451. const obj = Object.create(null);
  452. for (const w of directoryWatchers) {
  453. // getTimes() returns a prototype-less object, so for...in is safe
  454. // and avoids the throwaway array that Object.keys would allocate.
  455. const times = w.getTimes();
  456. for (const file in times) obj[file] = times[file];
  457. }
  458. return obj;
  459. }
  460. /**
  461. * @returns {TimeInfoEntries} time info entries
  462. */
  463. getTimeInfoEntries() {
  464. /** @type {TimeInfoEntries} */
  465. const map = new Map();
  466. this.collectTimeInfoEntries(map, map);
  467. return map;
  468. }
  469. /**
  470. * @param {TimeInfoEntries} fileTimestamps file timestamps
  471. * @param {TimeInfoEntries} directoryTimestamps directory timestamps
  472. */
  473. collectTimeInfoEntries(fileTimestamps, directoryTimestamps) {
  474. /** @type {Set<DirectoryWatcher>} */
  475. const allWatchers = new Set();
  476. addWatchersToSet(this.fileWatchers.values(), allWatchers);
  477. addWatchersToSet(this.directoryWatchers.values(), allWatchers);
  478. for (const w of allWatchers) {
  479. w.collectTimeInfoEntries(fileTimestamps, directoryTimestamps);
  480. }
  481. }
  482. /**
  483. * @returns {Aggregated} aggregated info
  484. */
  485. getAggregated() {
  486. if (this.aggregateTimer) {
  487. clearTimeout(this.aggregateTimer);
  488. this.aggregateTimer = undefined;
  489. }
  490. const changes = this.aggregatedChanges;
  491. const removals = this.aggregatedRemovals;
  492. this.aggregatedChanges = new Set();
  493. this.aggregatedRemovals = new Set();
  494. return { changes, removals };
  495. }
  496. /**
  497. * @param {string} item item
  498. * @param {number} mtime mtime
  499. * @param {string} file file
  500. * @param {EventType} type type
  501. */
  502. _onChange(item, mtime, file, type) {
  503. file = file || item;
  504. if (!this.paused) {
  505. this.emit("change", file, mtime, type);
  506. if (this.aggregateTimer) clearTimeout(this.aggregateTimer);
  507. this.aggregateTimer = setTimeout(this._onTimeout, this.aggregateTimeout);
  508. }
  509. this.aggregatedRemovals.delete(item);
  510. this.aggregatedChanges.add(item);
  511. }
  512. /**
  513. * @param {string} item item
  514. * @param {string} file file
  515. * @param {EventType} type type
  516. */
  517. _onRemove(item, file, type) {
  518. file = file || item;
  519. if (!this.paused) {
  520. this.emit("remove", file, type);
  521. if (this.aggregateTimer) clearTimeout(this.aggregateTimer);
  522. this.aggregateTimer = setTimeout(this._onTimeout, this.aggregateTimeout);
  523. }
  524. this.aggregatedChanges.delete(item);
  525. this.aggregatedRemovals.add(item);
  526. }
  527. _onTimeout() {
  528. this.aggregateTimer = undefined;
  529. const changes = this.aggregatedChanges;
  530. const removals = this.aggregatedRemovals;
  531. this.aggregatedChanges = new Set();
  532. this.aggregatedRemovals = new Set();
  533. this.emit("aggregated", changes, removals);
  534. }
  535. }
  536. /**
  537. * @template A
  538. * @template B
  539. * @param {A} obj input a
  540. * @param {B} exports input b
  541. * @returns {A & B} merged
  542. */
  543. const mergeExports = (obj, exports) => {
  544. const descriptors = Object.getOwnPropertyDescriptors(exports);
  545. Object.defineProperties(obj, descriptors);
  546. return /** @type {A & B} */ (Object.freeze(obj));
  547. };
  548. /** @typedef {typeof Watchpack & { util: { readonly globToRegExp: typeof globToRegExp } }} WatchpackExports */
  549. module.exports = /** @type {WatchpackExports} */ (
  550. mergeExports(Watchpack, {
  551. util: {
  552. get globToRegExp() {
  553. return globToRegExp;
  554. },
  555. },
  556. })
  557. );