mm_watcher 0.0.0-stage → 1.0.1
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.
- package/example/demo.js +87 -0
- package/index.js +16 -0
- package/lib/fs_handle.js +50 -0
- package/lib/ignore_matcher.js +92 -0
- package/lib/path_util.js +15 -0
- package/lib/symbols.js +8 -0
- package/lib/watcher.js +118 -0
- package/lib/watcher_hub.js +242 -0
- package/package.json +42 -5
- package/README.md +0 -3
package/example/demo.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { Watcher } = require('../index');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 等待指定毫秒
|
|
10
|
+
* @param {number} ms - 等待时间
|
|
11
|
+
* @returns {Promise<void>} 到期后 resolve
|
|
12
|
+
*/
|
|
13
|
+
function delay(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 演示目录监听:忽略目录、忽略文件、新增/删除事件
|
|
19
|
+
* @returns {Promise<void>} 演示完成
|
|
20
|
+
*/
|
|
21
|
+
async function demoDirWatch() {
|
|
22
|
+
const dir = path.join(os.tmpdir(), 'mm_watcher_demo_dir');
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true });
|
|
26
|
+
|
|
27
|
+
const changes = [];
|
|
28
|
+
const watcher = new Watcher({ target: dir, ignore: ['node_modules', '*.tmp'] });
|
|
29
|
+
const ok = watcher.start((list) => changes.push(...list));
|
|
30
|
+
console.log('目录监听:', ok, '| 目标:', dir);
|
|
31
|
+
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'a.json'), '{}');
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'cache.tmp'), 'x');
|
|
34
|
+
fs.writeFileSync(path.join(dir, 'node_modules/b.json'), '{}');
|
|
35
|
+
await delay(300);
|
|
36
|
+
console.log('变更事件:', changes);
|
|
37
|
+
|
|
38
|
+
fs.rmSync(path.join(dir, 'a.json'));
|
|
39
|
+
changes.length = 0;
|
|
40
|
+
await delay(300);
|
|
41
|
+
fs.writeFileSync(path.join(dir, 'a.json'), '{}');
|
|
42
|
+
await delay(300);
|
|
43
|
+
console.log('删除后重建:', changes.map((one) => one.type + ' ' + one.path));
|
|
44
|
+
|
|
45
|
+
watcher.dispose();
|
|
46
|
+
console.log('解绑后状态:', watcher.active);
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 演示单文件监听:修改与删除事件
|
|
52
|
+
* @returns {Promise<void>} 演示完成
|
|
53
|
+
*/
|
|
54
|
+
async function demoFileWatch() {
|
|
55
|
+
const dir = path.join(os.tmpdir(), 'mm_watcher_demo_file');
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
const file = path.join(dir, 'config.json');
|
|
59
|
+
fs.writeFileSync(file, '{}');
|
|
60
|
+
|
|
61
|
+
const changes = [];
|
|
62
|
+
const watcher = new Watcher({ target: file });
|
|
63
|
+
const ok = watcher.start((list) => changes.push(...list));
|
|
64
|
+
console.log('单文件监听:', ok, '| 目标:', file);
|
|
65
|
+
|
|
66
|
+
fs.writeFileSync(file, '{"v":1}');
|
|
67
|
+
await delay(300);
|
|
68
|
+
console.log('修改事件:', changes.map((one) => one.type + ' ' + one.path));
|
|
69
|
+
|
|
70
|
+
changes.length = 0;
|
|
71
|
+
fs.rmSync(file);
|
|
72
|
+
await delay(300);
|
|
73
|
+
console.log('删除事件:', changes.map((one) => one.type + ' ' + one.path));
|
|
74
|
+
|
|
75
|
+
watcher.dispose();
|
|
76
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
await demoDirWatch();
|
|
81
|
+
await demoFileWatch();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main().catch((error) => {
|
|
85
|
+
console.error(error);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const Watcher = require('./lib/watcher');
|
|
2
|
+
const Admin = require('mm_corejs').Admin;
|
|
3
|
+
|
|
4
|
+
const admin = new Admin();
|
|
5
|
+
admin.setClass(Watcher);
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
Watcher,
|
|
9
|
+
admin,
|
|
10
|
+
watcherAdmin(scope, config) {
|
|
11
|
+
if (!scope) {
|
|
12
|
+
scope = 'os';
|
|
13
|
+
}
|
|
14
|
+
return admin.get(scope, config);
|
|
15
|
+
}
|
|
16
|
+
};
|
package/lib/fs_handle.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 文件系统监听句柄
|
|
7
|
+
* @description 包装 fs.watch:目录递归或单文件监听,打开失败返回 false,关闭与 unref 均容错
|
|
8
|
+
*/
|
|
9
|
+
class FsHandle {
|
|
10
|
+
/** 底层 watcher,未打开时为 null */
|
|
11
|
+
#watcher = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 打开监听
|
|
15
|
+
* @param {string} target 监听目标绝对路径
|
|
16
|
+
* @param {{ recursive: boolean }} options 监听选项
|
|
17
|
+
* @param {(event_type: string, filename: string|null) => void} on_event 事件回调
|
|
18
|
+
* @param {() => void} on_error 错误回调
|
|
19
|
+
* @returns {boolean} 打开成功返回 true
|
|
20
|
+
*/
|
|
21
|
+
open(target, options, on_event, on_error) {
|
|
22
|
+
let watcher = null;
|
|
23
|
+
try {
|
|
24
|
+
watcher = fs.watch(target, options, on_event);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
29
|
+
watcher.on('error', on_error);
|
|
30
|
+
this.#watcher = watcher;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 关闭监听,已关闭或关闭抛错时静默忽略
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
close() {
|
|
39
|
+
if (!this.#watcher) return;
|
|
40
|
+
try {
|
|
41
|
+
this.#watcher.close();
|
|
42
|
+
} catch {
|
|
43
|
+
// 监听器已关闭时忽略
|
|
44
|
+
}
|
|
45
|
+
this.#watcher = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 导出类 */
|
|
50
|
+
module.exports = FsHandle;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 忽略匹配器
|
|
7
|
+
* @description 模式逐项编译:以 '.' 开头按扩展名匹配文件;含 * 或 ? 按通配符匹配目录名与文件名;否则精确名匹配目录名与文件名
|
|
8
|
+
*/
|
|
9
|
+
class IgnoreMatcher {
|
|
10
|
+
/** 精确名集合 */
|
|
11
|
+
#exact = new Set();
|
|
12
|
+
/** 通配符编译后的正则列表 */
|
|
13
|
+
#globs = [];
|
|
14
|
+
/** 扩展名集合(含点、小写) */
|
|
15
|
+
#exts = new Set();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 创建忽略匹配器
|
|
19
|
+
* @param {Array<string>} patterns 忽略模式数组
|
|
20
|
+
*/
|
|
21
|
+
constructor(patterns) {
|
|
22
|
+
if (!Array.isArray(patterns)) return;
|
|
23
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
24
|
+
this._compile(patterns[i]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 编译单个模式
|
|
30
|
+
* @param {string} pattern 原始模式
|
|
31
|
+
* @returns {void}
|
|
32
|
+
* @private
|
|
33
|
+
*/
|
|
34
|
+
_compile(pattern) {
|
|
35
|
+
const s = String(pattern).trim();
|
|
36
|
+
if (!s) return;
|
|
37
|
+
if (s.startsWith('.')) {
|
|
38
|
+
this.#exts.add(s.toLowerCase());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (s.includes('*') || s.includes('?')) {
|
|
42
|
+
this.#globs.push(this._globToRegExp(s));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.#exact.add(s);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 通配符转正则:* 匹配任意字符序列,? 匹配单个字符,其余特殊字符转义
|
|
50
|
+
* @param {string} pattern 通配符模式
|
|
51
|
+
* @returns {RegExp} 锚定正则
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
_globToRegExp(pattern) {
|
|
55
|
+
let source = '';
|
|
56
|
+
for (const ch of pattern) {
|
|
57
|
+
if (ch === '*') source += '.*';
|
|
58
|
+
else if (ch === '?') source += '.';
|
|
59
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
}
|
|
61
|
+
return new RegExp('^' + source + '$');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 判断目录名是否命中忽略
|
|
66
|
+
* @param {string} segment 目录段名
|
|
67
|
+
* @returns {boolean} 命中返回 true
|
|
68
|
+
*/
|
|
69
|
+
isIgnoredDir(segment) {
|
|
70
|
+
if (this.#exact.has(segment)) return true;
|
|
71
|
+
for (let i = 0; i < this.#globs.length; i++) {
|
|
72
|
+
if (this.#globs[i].test(segment)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 判断文件名是否命中忽略(精确名、通配符或扩展名)
|
|
79
|
+
* @param {string} basename 文件名
|
|
80
|
+
* @returns {boolean} 命中返回 true
|
|
81
|
+
*/
|
|
82
|
+
isIgnoredFile(basename) {
|
|
83
|
+
if (this.#exact.has(basename)) return true;
|
|
84
|
+
for (let i = 0; i < this.#globs.length; i++) {
|
|
85
|
+
if (this.#globs[i].test(basename)) return true;
|
|
86
|
+
}
|
|
87
|
+
return this.#exts.has(path.extname(basename).toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* 导出类 */
|
|
92
|
+
module.exports = IgnoreMatcher;
|
package/lib/path_util.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 路径规范化:统一分隔符、去 "./" 前缀与首尾斜杠
|
|
5
|
+
* @param {string} p 原始路径
|
|
6
|
+
* @returns {string} 规范化后的路径
|
|
7
|
+
*/
|
|
8
|
+
function normalizeKey(p) {
|
|
9
|
+
return String(p)
|
|
10
|
+
.replace(/\\/g, '/')
|
|
11
|
+
.replace(/^\.\//, '')
|
|
12
|
+
.replace(/^\/+|\/+$/g, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { normalizeKey };
|
package/lib/symbols.js
ADDED
package/lib/watcher.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const hub = require('./watcher_hub');
|
|
4
|
+
const { DISPATCH, DEACTIVATE } = require('./symbols');
|
|
5
|
+
|
|
6
|
+
/* 默认防抖窗口(毫秒) */
|
|
7
|
+
const DEFAULT_DEBOUNCE = 100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 文件监听器
|
|
11
|
+
* @description 监听目录(递归)或单个文件,事件经防抖与忽略过滤后以变更列表回调;
|
|
12
|
+
* 相同目标与忽略配置的多个监听器共享底层句柄,监听不可用时静默降级
|
|
13
|
+
*/
|
|
14
|
+
class Watcher {
|
|
15
|
+
/** 监听目标绝对路径 */
|
|
16
|
+
#target;
|
|
17
|
+
/** 共享句柄的监听键 */
|
|
18
|
+
#key;
|
|
19
|
+
/** 忽略模式数组 */
|
|
20
|
+
#ignore;
|
|
21
|
+
/** 防抖窗口(毫秒) */
|
|
22
|
+
#debounce;
|
|
23
|
+
/** 出错停用时的外部回调 */
|
|
24
|
+
#onError;
|
|
25
|
+
/** 变更回调 */
|
|
26
|
+
#callback = null;
|
|
27
|
+
/** 是否处于监听状态 */
|
|
28
|
+
#active = false;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 创建监听器
|
|
32
|
+
* @param {{ target: string, ignore?: Array<string>, debounce?: number, onError?: () => void }} [config] 监听配置,target 为目录或文件绝对路径
|
|
33
|
+
*/
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.#target = String(config.target).replace(/\\/g, '/');
|
|
36
|
+
this.#ignore = Array.isArray(config.ignore) ? [...config.ignore] : [];
|
|
37
|
+
this.#debounce =
|
|
38
|
+
Number.isFinite(config.debounce) && config.debounce >= 0
|
|
39
|
+
? config.debounce
|
|
40
|
+
: DEFAULT_DEBOUNCE;
|
|
41
|
+
this.#onError = typeof config.onError === 'function' ? config.onError : null;
|
|
42
|
+
this.#key = [this.#target, this.#ignore.join(',')].join('\u0001');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 开启监听
|
|
47
|
+
* @param {(changes: Array<{ type: string, path: string }>) => void} [callback] 变更回调,type 为 change/unlink/refresh
|
|
48
|
+
* @returns {boolean} 监听是否可用
|
|
49
|
+
*/
|
|
50
|
+
start(callback) {
|
|
51
|
+
if (this.#active) return true;
|
|
52
|
+
if (!hub.add(this)) return false;
|
|
53
|
+
this.#callback = typeof callback === 'function' ? callback : null;
|
|
54
|
+
this.#active = true;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 关闭监听并清空变更回调
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*/
|
|
62
|
+
dispose() {
|
|
63
|
+
if (!this.#active) return;
|
|
64
|
+
this.#active = false;
|
|
65
|
+
this.#callback = null;
|
|
66
|
+
hub.del(this);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 当前是否处于监听状态 */
|
|
70
|
+
get active() { return this.#active; }
|
|
71
|
+
/** 共享句柄的监听键 */
|
|
72
|
+
get key() { return this.#key; }
|
|
73
|
+
/** 监听目标绝对路径 */
|
|
74
|
+
get target() { return this.#target; }
|
|
75
|
+
/** 忽略模式副本 */
|
|
76
|
+
get ignorePatterns() { return [...this.#ignore]; }
|
|
77
|
+
/** 防抖窗口(毫秒) */
|
|
78
|
+
get debounce() { return this.#debounce; }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 接收中枢分发的变更列表
|
|
82
|
+
* @param {Array<{ type: string, path: string }>} changes 变更列表
|
|
83
|
+
* @returns {void}
|
|
84
|
+
*/
|
|
85
|
+
[DISPATCH](changes) {
|
|
86
|
+
if (!this.#active || !changes.length) return;
|
|
87
|
+
if (!this.#callback) return;
|
|
88
|
+
const list = changes.map((one) => ({ ...one }));
|
|
89
|
+
try {
|
|
90
|
+
this.#callback(list);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('[mm_watcher] 监听回调执行失败:', error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 底层监听出错后停用本监听器并通知外部
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
[DEACTIVATE]() {
|
|
101
|
+
this.#active = false;
|
|
102
|
+
this.#callback = null;
|
|
103
|
+
if (this.#onError) {
|
|
104
|
+
this.#onError();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 关闭全部监听句柄
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
static closeAll() {
|
|
113
|
+
hub.closeAll();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* 导出类 */
|
|
118
|
+
module.exports = Watcher;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { normalizeKey } = require('./path_util');
|
|
6
|
+
const IgnoreMatcher = require('./ignore_matcher');
|
|
7
|
+
const FsHandle = require('./fs_handle');
|
|
8
|
+
const { DISPATCH, DEACTIVATE } = require('./symbols');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 监听中枢
|
|
12
|
+
* @description 按 目标+忽略配置 共享底层监听句柄,防抖窗口内合并事件,
|
|
13
|
+
* 逐路径 stat 分类为 change/unlink/refresh 并过滤忽略项,最后分发给各监听器
|
|
14
|
+
*/
|
|
15
|
+
class WatcherHub {
|
|
16
|
+
/** 监听键 -> { key, target, is_dir, debounce, matcher, watchers, timer, pending, handle } */
|
|
17
|
+
#entries = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 登记监听器,同键共享句柄
|
|
21
|
+
* @param {object} watcher 监听器实例
|
|
22
|
+
* @returns {boolean} 句柄可用返回 true
|
|
23
|
+
*/
|
|
24
|
+
add(watcher) {
|
|
25
|
+
const key = watcher.key;
|
|
26
|
+
const exists = this.#entries.get(key);
|
|
27
|
+
if (exists) {
|
|
28
|
+
exists.watchers.add(watcher);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return this._openEntry(watcher);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 注销监听器,最后一个注销时关闭句柄
|
|
36
|
+
* @param {object} watcher 监听器实例
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
del(watcher) {
|
|
40
|
+
const entry = this.#entries.get(watcher.key);
|
|
41
|
+
if (!entry) return;
|
|
42
|
+
entry.watchers.delete(watcher);
|
|
43
|
+
if (entry.watchers.size === 0) {
|
|
44
|
+
this._closeEntry(entry);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 关闭全部监听句柄并停用全部监听器
|
|
50
|
+
* @returns {void}
|
|
51
|
+
*/
|
|
52
|
+
closeAll() {
|
|
53
|
+
const keys = [...this.#entries.keys()];
|
|
54
|
+
for (let i = 0; i < keys.length; i++) {
|
|
55
|
+
this._teardown(keys[i]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 关闭条目并停用其全部监听器
|
|
61
|
+
* @param {string} key 监听键
|
|
62
|
+
* @returns {void}
|
|
63
|
+
* @private
|
|
64
|
+
*/
|
|
65
|
+
_teardown(key) {
|
|
66
|
+
const entry = this.#entries.get(key);
|
|
67
|
+
if (!entry) return;
|
|
68
|
+
const watchers = [...entry.watchers];
|
|
69
|
+
this._closeEntry(entry);
|
|
70
|
+
for (const watcher of watchers) {
|
|
71
|
+
watcher[DEACTIVATE]();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 为新监听器打开共享句柄
|
|
77
|
+
* @param {object} watcher 监听器实例
|
|
78
|
+
* @returns {boolean} 打开成功返回 true
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_openEntry(watcher) {
|
|
82
|
+
const stat = this._stat(watcher.target);
|
|
83
|
+
if (!stat) return false;
|
|
84
|
+
const is_dir = stat.isDirectory();
|
|
85
|
+
const entry = {
|
|
86
|
+
key: watcher.key,
|
|
87
|
+
target: watcher.target,
|
|
88
|
+
is_dir,
|
|
89
|
+
debounce: watcher.debounce,
|
|
90
|
+
matcher: new IgnoreMatcher(watcher.ignorePatterns),
|
|
91
|
+
watchers: new Set([watcher]),
|
|
92
|
+
timer: null,
|
|
93
|
+
pending: new Set(),
|
|
94
|
+
handle: null,
|
|
95
|
+
};
|
|
96
|
+
this.#entries.set(entry.key, entry);
|
|
97
|
+
|
|
98
|
+
const handle = new FsHandle();
|
|
99
|
+
const ok = handle.open(
|
|
100
|
+
watcher.target,
|
|
101
|
+
{ recursive: is_dir },
|
|
102
|
+
(event_type, filename) => this._onEvent(entry.key, filename),
|
|
103
|
+
() => this._onError(entry.key)
|
|
104
|
+
);
|
|
105
|
+
if (!ok) {
|
|
106
|
+
this.#entries.delete(entry.key);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
entry.handle = handle;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 关闭并移除监听条目
|
|
115
|
+
* @param {object} entry 监听条目
|
|
116
|
+
* @returns {void}
|
|
117
|
+
* @private
|
|
118
|
+
*/
|
|
119
|
+
_closeEntry(entry) {
|
|
120
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
121
|
+
entry.handle.close();
|
|
122
|
+
this.#entries.delete(entry.key);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 读取目标状态
|
|
127
|
+
* @param {string} target 目标绝对路径
|
|
128
|
+
* @returns {object|null} 状态对象,读取失败返回 null
|
|
129
|
+
* @private
|
|
130
|
+
*/
|
|
131
|
+
_stat(target) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(target);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 底层监听出错:关闭条目并停用全部监听器
|
|
141
|
+
* @param {string} key 监听键
|
|
142
|
+
* @returns {void}
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_onError(key) {
|
|
146
|
+
this._teardown(key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 记录文件系统事件,按防抖窗口合并后处理
|
|
151
|
+
* @param {string} key 监听键
|
|
152
|
+
* @param {string|null} filename 事件对应的相对路径,为空表示无法定位
|
|
153
|
+
* @returns {void}
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_onEvent(key, filename) {
|
|
157
|
+
const entry = this.#entries.get(key);
|
|
158
|
+
if (!entry) return;
|
|
159
|
+
|
|
160
|
+
entry.pending.add(filename ? normalizeKey(filename) : '');
|
|
161
|
+
if (entry.timer) return;
|
|
162
|
+
|
|
163
|
+
entry.timer = setTimeout(() => {
|
|
164
|
+
entry.timer = null;
|
|
165
|
+
this._flush(key);
|
|
166
|
+
}, entry.debounce);
|
|
167
|
+
|
|
168
|
+
if (typeof entry.timer.unref === 'function') {
|
|
169
|
+
entry.timer.unref();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 处理防抖窗口内累积的事件并分发
|
|
175
|
+
* @param {string} key 监听键
|
|
176
|
+
* @returns {void}
|
|
177
|
+
* @private
|
|
178
|
+
*/
|
|
179
|
+
_flush(key) {
|
|
180
|
+
const entry = this.#entries.get(key);
|
|
181
|
+
if (!entry || !entry.pending.size) return;
|
|
182
|
+
|
|
183
|
+
const list = [...entry.pending];
|
|
184
|
+
entry.pending.clear();
|
|
185
|
+
|
|
186
|
+
const changes = [];
|
|
187
|
+
for (let i = 0; i < list.length; i++) {
|
|
188
|
+
const event = this._classify(entry, list[i]);
|
|
189
|
+
if (!event) continue;
|
|
190
|
+
changes.push(event);
|
|
191
|
+
if (event.type === 'refresh') break;
|
|
192
|
+
}
|
|
193
|
+
if (!changes.length) return;
|
|
194
|
+
|
|
195
|
+
for (const watcher of entry.watchers) {
|
|
196
|
+
watcher[DISPATCH](changes);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 将单个路径事件分类为监听变更
|
|
202
|
+
* @description 无法定位的路径与目录事件分类为 refresh;忽略项返回 null;
|
|
203
|
+
* 目录目标按相对路径判状态,单文件目标按文件本身判状态
|
|
204
|
+
* @param {object} entry 监听条目
|
|
205
|
+
* @param {string} rel_path 规范化后的相对路径,空串表示无法定位
|
|
206
|
+
* @returns {{ type: string, path: string }|null} 变更事件,忽略时返回 null
|
|
207
|
+
* @private
|
|
208
|
+
*/
|
|
209
|
+
_classify(entry, rel_path) {
|
|
210
|
+
if (!rel_path) return { type: 'refresh', path: '' };
|
|
211
|
+
|
|
212
|
+
if (!entry.is_dir) {
|
|
213
|
+
return this._toEvent(this._stat(entry.target), rel_path);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const parts = rel_path.split('/');
|
|
217
|
+
const basename = parts[parts.length - 1];
|
|
218
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
219
|
+
if (entry.matcher.isIgnoredDir(parts[i])) return null;
|
|
220
|
+
}
|
|
221
|
+
if (entry.matcher.isIgnoredFile(basename)) return null;
|
|
222
|
+
|
|
223
|
+
return this._toEvent(this._stat(path.join(entry.target, rel_path)), rel_path);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 按路径状态生成变更事件
|
|
228
|
+
* @param {object|null} stat 状态对象,不存在为 null
|
|
229
|
+
* @param {string} rel_path 相对路径
|
|
230
|
+
* @returns {{ type: string, path: string }|null} 变更事件,特殊文件返回 null
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
_toEvent(stat, rel_path) {
|
|
234
|
+
if (!stat) return { type: 'unlink', path: rel_path };
|
|
235
|
+
if (stat.isDirectory()) return { type: 'refresh', path: rel_path };
|
|
236
|
+
if (!stat.isFile()) return null;
|
|
237
|
+
return { type: 'change', path: rel_path };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* 导出单例 */
|
|
242
|
+
module.exports = new WatcherHub();
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "mm_watcher",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "文件监听器,支持监听目录(递归)与单个文件,事件防抖合并、忽略目录与文件(精确名/扩展名/通配符),同目标多监听者共享底层句柄。",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://gitee.com/qiuwenwu91/mm_watcher.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"mm_mod",
|
|
11
|
+
"mm_infra",
|
|
12
|
+
"mm_common",
|
|
13
|
+
"超级美眉",
|
|
14
|
+
"文件监听",
|
|
15
|
+
"目录监听",
|
|
16
|
+
"watcher"
|
|
17
|
+
],
|
|
18
|
+
"layer": "infra",
|
|
19
|
+
"group": "common",
|
|
20
|
+
"author": "邱文武",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"main": "index.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"lib",
|
|
26
|
+
"example"
|
|
27
|
+
],
|
|
28
|
+
"directories": {
|
|
29
|
+
"example": "example",
|
|
30
|
+
"lib": "lib"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "jest --coverage",
|
|
34
|
+
"demo": "node example/demo.js",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"jest": "^30.5.2"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"mm_corejs": "^1.1.5"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/README.md
DELETED