@flun/windows 2.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/CHANGELOG.md +4 -0
- package/LICENSE +19 -0
- package/README.md +361 -0
- package/bin/elevate.vbs +25 -0
- package/bin/winsw/winsw.exe +0 -0
- package/bin/winsw/winsw.exe.config +6 -0
- package/copy-files.js +34 -0
- package/index.d.ts +129 -0
- package/index.js +9 -0
- package/lib/binaries.js +133 -0
- package/lib/cmd.js +54 -0
- package/lib/daemon.js +422 -0
- package/lib/eventlog.js +226 -0
- package/lib/shared.js +34 -0
- package/lib/winsw.js +111 -0
- package/lib/wrapper.js +324 -0
- package/package.json +58 -0
- package/sevWin.js +19 -0
package/lib/wrapper.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程包装器 (Process Wrapper)
|
|
3
|
+
*
|
|
4
|
+
* 一个用于管理和监控子进程的包装器,提供以下功能:
|
|
5
|
+
* 1. 自动重启失败的进程,支持多种重启策略
|
|
6
|
+
* 2. 进程生命周期监控和日志记录
|
|
7
|
+
* 3. 优雅的进程终止处理
|
|
8
|
+
* 4. 重启次数限制和频率控制
|
|
9
|
+
* 5. 支持通过事件日志或控制台输出日志
|
|
10
|
+
*
|
|
11
|
+
* 命令行参数:
|
|
12
|
+
* -f, --file: 要运行的脚本绝对路径(必需)
|
|
13
|
+
* -d, --cwd: 子进程工作目录
|
|
14
|
+
* -l, --log: 日志描述名称(必需)
|
|
15
|
+
* -e, --eventlog: 事件日志容器(APPLICATION或SYSTEM)
|
|
16
|
+
* -m, --maxretries: 最大重启次数(-1表示无限制)
|
|
17
|
+
* -r, --maxrestarts: 时间窗口内最大重启次数
|
|
18
|
+
* -w, --wait: 重启等待时间(秒)
|
|
19
|
+
* -g, --grow: 等待时间增长系数
|
|
20
|
+
* -a, --abortonerror: 错误时是否终止
|
|
21
|
+
* -s, --stopparentfirst: 是否允许优雅退出
|
|
22
|
+
* --scriptoptions: 传递给脚本的选项
|
|
23
|
+
*/
|
|
24
|
+
import { path, fs, fork } from './shared.js';
|
|
25
|
+
import { createServer } from 'net';
|
|
26
|
+
import yargs from 'yargs/yargs';
|
|
27
|
+
import { hideBin } from 'yargs/helpers';
|
|
28
|
+
import { EventLogger } from './eventlog.js';
|
|
29
|
+
|
|
30
|
+
// 时间窗口(秒),默认时间窗口内最大重启次数,默认重启等待时间(秒),默认等待时间增长系数
|
|
31
|
+
const timeWindow = 60, defaultMaxRestarts = 3, defaultWaitTime = 1, defaultGrowth = 0.25,
|
|
32
|
+
booleanChoices = ['y', 'n', 'yes', 'no', 'true', 'false'],
|
|
33
|
+
/**
|
|
34
|
+
* 布尔选项解析函数
|
|
35
|
+
* @param {string} value - 原始输入值
|
|
36
|
+
* @returns {'yes'|'no'} 标准化的布尔值
|
|
37
|
+
*/
|
|
38
|
+
parseBooleanOption = value => {
|
|
39
|
+
const normalized = value.trim().toLowerCase();
|
|
40
|
+
if (normalized === 'true') return 'yes';
|
|
41
|
+
if (normalized === 'false') return 'no';
|
|
42
|
+
return normalized;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 带引号参数解析函数
|
|
47
|
+
* @param {string} value - 可能被引号包裹的字符串
|
|
48
|
+
* @returns {string} 去除外层引号后的字符串
|
|
49
|
+
*/
|
|
50
|
+
parseQuotedOption = value => {
|
|
51
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
52
|
+
return value.slice(1, -1);
|
|
53
|
+
return value;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 检查时间窗口限制函数
|
|
58
|
+
* @returns {boolean} 若当前时间仍在时间窗口内则返回 true,否则 false
|
|
59
|
+
*/
|
|
60
|
+
checkTimeWindowLimit = () => {
|
|
61
|
+
const windowEndTime = timeWindowStart.getTime() + (timeWindow * 1000);
|
|
62
|
+
return Date.now() <= windowEndTime;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// 解析命令行参数
|
|
66
|
+
argv = yargs(hideBin(process.argv))
|
|
67
|
+
.option('file', {
|
|
68
|
+
alias: 'f',
|
|
69
|
+
type: 'string',
|
|
70
|
+
demandOption: true,
|
|
71
|
+
description: '要作为进程运行的脚本的绝对路径',
|
|
72
|
+
coerce: filePath => path.resolve(filePath),
|
|
73
|
+
check: filePath => {
|
|
74
|
+
if (!fs.existsSync(filePath)) throw new Error(`文件 ${filePath} 不存在或无法找到`);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.option('cwd', {
|
|
79
|
+
alias: 'd',
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: '要作为进程运行的脚本的当前工作目录的绝对路径',
|
|
82
|
+
coerce: cwdPath => cwdPath ? path.resolve(cwdPath) : undefined,
|
|
83
|
+
default: undefined,
|
|
84
|
+
check: cwdPath => {
|
|
85
|
+
if (cwdPath && !fs.existsSync(cwdPath)) throw new Error(`工作目录 ${cwdPath} 不存在`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.option('log', {
|
|
90
|
+
alias: 'l',
|
|
91
|
+
type: 'string',
|
|
92
|
+
demandOption: true,
|
|
93
|
+
description: '进程日志的描述性名称',
|
|
94
|
+
coerce: parseQuotedOption
|
|
95
|
+
})
|
|
96
|
+
.option('eventlog', {
|
|
97
|
+
alias: 'e',
|
|
98
|
+
type: 'string',
|
|
99
|
+
default: 'APPLICATION',
|
|
100
|
+
description: '事件日志容器;必须是 APPLICATION 或 SYSTEM',
|
|
101
|
+
choices: ['APPLICATION', 'SYSTEM']
|
|
102
|
+
})
|
|
103
|
+
.option('maxretries', {
|
|
104
|
+
alias: 'm',
|
|
105
|
+
type: 'number',
|
|
106
|
+
default: -1,
|
|
107
|
+
description: '进程自动重启的最大次数(-1表示无限制)'
|
|
108
|
+
})
|
|
109
|
+
.option('maxrestarts', {
|
|
110
|
+
alias: 'r',
|
|
111
|
+
type: 'number',
|
|
112
|
+
default: defaultMaxRestarts,
|
|
113
|
+
description: `在${timeWindow}秒内进程应重启的最大次数,超过则关闭`
|
|
114
|
+
})
|
|
115
|
+
.option('wait', {
|
|
116
|
+
alias: 'w',
|
|
117
|
+
type: 'number',
|
|
118
|
+
default: defaultWaitTime,
|
|
119
|
+
description: '每次重启尝试之间的等待秒数',
|
|
120
|
+
check: waitTime => {
|
|
121
|
+
if (waitTime < 0) throw new Error('等待时间不能为负数');
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.option('grow', {
|
|
126
|
+
alias: 'g',
|
|
127
|
+
type: 'number',
|
|
128
|
+
default: defaultGrowth,
|
|
129
|
+
description: '等待时间增长的增长百分比(0-1之间)',
|
|
130
|
+
check: growth => {
|
|
131
|
+
if (growth < 0 || growth > 1) throw new Error('增长率必须在0到1之间');
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.option('abortonerror', {
|
|
136
|
+
alias: 'a',
|
|
137
|
+
type: 'string',
|
|
138
|
+
default: 'no',
|
|
139
|
+
description: '如果进程因错误失败,是否尝试重启',
|
|
140
|
+
choices: booleanChoices,
|
|
141
|
+
coerce: parseBooleanOption
|
|
142
|
+
})
|
|
143
|
+
.option('stopparentfirst', {
|
|
144
|
+
alias: 's',
|
|
145
|
+
type: 'string',
|
|
146
|
+
default: 'no',
|
|
147
|
+
description: '是否允许脚本使用关闭消息优雅退出',
|
|
148
|
+
choices: booleanChoices,
|
|
149
|
+
coerce: parseBooleanOption
|
|
150
|
+
})
|
|
151
|
+
.option('scriptoptions', {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: '要传递给脚本的选项(空格分隔)',
|
|
154
|
+
default: '',
|
|
155
|
+
coerce: parseQuotedOption
|
|
156
|
+
}).help().parse();
|
|
157
|
+
|
|
158
|
+
// 初始化日志记录器
|
|
159
|
+
const logger = new EventLogger({
|
|
160
|
+
source: argv.log,
|
|
161
|
+
eventlog: argv.eventlog
|
|
162
|
+
}),
|
|
163
|
+
// 进程管理状态
|
|
164
|
+
growthFactor = argv.grow + 1, scriptPath = argv.file;
|
|
165
|
+
|
|
166
|
+
let waitTime = argv.wait * 1000, restartAttempts = 0, timeWindowStart = null, restartsInWindow = 0, childProcess = null,
|
|
167
|
+
isForceKill = false;
|
|
168
|
+
|
|
169
|
+
// 设置工作目录-如果没有指定,使用脚本所在目录
|
|
170
|
+
if (!argv.cwd) argv.cwd = path.dirname(scriptPath), logger.info(`未指定工作目录,使用脚本所在目录: ${argv.cwd}`);
|
|
171
|
+
|
|
172
|
+
// 检查工作目录是否存在
|
|
173
|
+
if (!fs.existsSync(argv.cwd)) logger.warn(`工作目录 ${argv.cwd} 不存在,使用进程当前目录`), argv.cwd = process.cwd();
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
// 创建服务器以保持进程运行
|
|
177
|
+
let keepAliveServer = createServer().listen();
|
|
178
|
+
|
|
179
|
+
keepAliveServer.on('error', err => {
|
|
180
|
+
logger.warn(`保持活动服务器错误: ${err.message}`), keepAliveServer = createServer().listen();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 解析传递给脚本的参数
|
|
185
|
+
* @returns {string[]} 脚本参数数组
|
|
186
|
+
*/
|
|
187
|
+
const prepareScriptArgs = () => {
|
|
188
|
+
if (!argv.scriptoptions || argv.scriptoptions.trim() === '') return [];
|
|
189
|
+
|
|
190
|
+
const args = [], scrOs = argv.scriptoptions;
|
|
191
|
+
let currentArg = '', inQuotes = false, quoteChar = '';
|
|
192
|
+
|
|
193
|
+
for (let i = 0; i < scrOs.length; i++) {
|
|
194
|
+
const char = scrOs[i];
|
|
195
|
+
|
|
196
|
+
if ((char === '"' || char === "'") && (i === 0 || scrOs[i - 1] !== '\\')) {
|
|
197
|
+
if (!inQuotes) inQuotes = true, quoteChar = char;
|
|
198
|
+
else if (char === quoteChar) inQuotes = false;
|
|
199
|
+
else currentArg += char;
|
|
200
|
+
} else if (char === ' ' && !inQuotes) {
|
|
201
|
+
if (currentArg.trim() !== '') args.push(currentArg.trim()), currentArg = '';
|
|
202
|
+
}
|
|
203
|
+
else currentArg += char;
|
|
204
|
+
}
|
|
205
|
+
if (currentArg.trim() !== '') args.push(currentArg.trim());
|
|
206
|
+
return args.filter(arg => arg !== '');
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 监控子进程状态,必要时重启
|
|
211
|
+
* @returns {void}
|
|
212
|
+
*/
|
|
213
|
+
monitorChildProcess = () => {
|
|
214
|
+
// 如果没有活动的子进程
|
|
215
|
+
if (!childProcess || !childProcess.pid) {
|
|
216
|
+
// 检查时间窗口内的重启次数限制
|
|
217
|
+
if (restartsInWindow >= argv.maxrestarts && timeWindowStart && checkTimeWindowLimit())
|
|
218
|
+
logger.error(`在过去 ${timeWindow} 秒内重启了${restartsInWindow}次,请检查脚本`), process.exit(1);
|
|
219
|
+
|
|
220
|
+
// 延迟重启
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
waitTime *= growthFactor, restartAttempts += 1;
|
|
223
|
+
|
|
224
|
+
// 检查总重启次数限制
|
|
225
|
+
if (argv.maxretries >= 0 && restartAttempts > argv.maxretries)
|
|
226
|
+
logger.error(`重启次数过多。${scriptPath} 将不会被重启,已超过最大重启次数 ${argv.maxretries}`), process.exit(1);
|
|
227
|
+
|
|
228
|
+
launchProcess('warn', `在意外退出后 ${waitTime} 毫秒重启: 尝试次数=${restartAttempts}`);
|
|
229
|
+
}, waitTime);
|
|
230
|
+
}
|
|
231
|
+
else restartAttempts = 0, waitTime = argv.wait * 1000; // 重置重启计数器和等待时间
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 启动子进程
|
|
236
|
+
* @param {string} logLevel - 日志级别(info, warn, error等)
|
|
237
|
+
* @param {string} message - 日志消息
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
launchProcess = (logLevel = 'info', message = '') => {
|
|
241
|
+
if (isForceKill) return logger.info('进程已终止');
|
|
242
|
+
if (message) logger[logLevel](message);
|
|
243
|
+
|
|
244
|
+
// 初始化或更新时间窗口
|
|
245
|
+
if (!timeWindowStart) {
|
|
246
|
+
timeWindowStart = new Date();
|
|
247
|
+
|
|
248
|
+
// 设置时间窗口重置
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
timeWindowStart = null, restartsInWindow = 0;
|
|
251
|
+
}, timeWindow * 1000 + 1);
|
|
252
|
+
}
|
|
253
|
+
restartsInWindow += 1;
|
|
254
|
+
|
|
255
|
+
// 准备子进程选项
|
|
256
|
+
const processOptions = {
|
|
257
|
+
env: { ...process.env }, stdio: ['inherit', 'inherit', 'inherit', 'ipc']
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (argv.cwd) processOptions.cwd = argv.cwd;
|
|
261
|
+
if (argv.stopparentfirst === 'yes' || argv.stopparentfirst === 'y') processOptions.detached = true;
|
|
262
|
+
|
|
263
|
+
// 准备脚本参数
|
|
264
|
+
const scriptArgs = prepareScriptArgs();
|
|
265
|
+
logger.info(`启动子进程: ${scriptPath}`), logger.info(`工作目录: ${argv.cwd}`);
|
|
266
|
+
logger.info(`传递给脚本的参数: ${JSON.stringify(scriptArgs)}`);
|
|
267
|
+
|
|
268
|
+
childProcess = fork(scriptPath, scriptArgs, processOptions); // 创建子进程
|
|
269
|
+
// 监听子进程退出事件
|
|
270
|
+
childProcess.on('exit', (code, signal) => {
|
|
271
|
+
const exitMessage = signal ? `${scriptPath} 被信号 ${signal} 终止` : `${scriptPath} 以退出码 ${code} 停止运行`;
|
|
272
|
+
|
|
273
|
+
logger.warn(exitMessage);
|
|
274
|
+
// 检查是否需要因错误而终止
|
|
275
|
+
if (code !== 0 && (argv.abortonerror === 'yes' || argv.abortonerror === 'y'))
|
|
276
|
+
logger.error(`${scriptPath} 以错误代码 ${code} 退出,终止包装器进程`), process.exit(code || 1);
|
|
277
|
+
else if (isForceKill) process.exit(0);
|
|
278
|
+
|
|
279
|
+
childProcess = null, monitorChildProcess(); // 重新监控并可能重启
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
childProcess.on('error', err => logger.error(`子进程错误: ${err.message}`)); // 监听子进程错误事件
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 终止子进程
|
|
287
|
+
* @returns {void}
|
|
288
|
+
*/
|
|
289
|
+
terminateChildProcess = () => {
|
|
290
|
+
isForceKill = true;
|
|
291
|
+
|
|
292
|
+
if (childProcess) {
|
|
293
|
+
if (argv.stopparentfirst === 'yes' || argv.stopparentfirst === 'y') {
|
|
294
|
+
childProcess.send('shutdown'); // 尝试优雅终止
|
|
295
|
+
|
|
296
|
+
// 设置超时强制终止
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
if (childProcess?.killable) childProcess.kill('SIGKILL');
|
|
299
|
+
}, 5000);
|
|
300
|
+
}
|
|
301
|
+
else childProcess.kill('SIGTERM');
|
|
302
|
+
}
|
|
303
|
+
else logger.warn('尝试终止不存在的子进程');
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// 注册进程终止事件处理程序
|
|
307
|
+
process.on('exit', terminateChildProcess);
|
|
308
|
+
process.on('SIGINT', () => {
|
|
309
|
+
logger.info('收到 SIGINT 信号,正在终止...'), terminateChildProcess();
|
|
310
|
+
});
|
|
311
|
+
process.on('SIGTERM', () => {
|
|
312
|
+
logger.info('收到 SIGTERM 信号,正在终止...'), terminateChildProcess();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// 处理未捕获异常
|
|
316
|
+
process.on('uncaughtException', err => {
|
|
317
|
+
logger.error(`未捕获异常: ${err.message}\n${err.stack}`), launchProcess('warn', err.message);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// 处理未处理的Promise拒绝
|
|
321
|
+
process.on('unhandledRejection', reason => logger.error(`未处理的Promise拒绝: ${reason}`));
|
|
322
|
+
|
|
323
|
+
// 启动主进程
|
|
324
|
+
launchProcess('info', `正在启动 ${scriptPath}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flun/windows",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "支持 Windows 服务、事件日志、UAC 以及多种与操作系统交互的辅助方法",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"windows",
|
|
7
|
+
"service",
|
|
8
|
+
"daemon",
|
|
9
|
+
"logging",
|
|
10
|
+
"event",
|
|
11
|
+
"event logging",
|
|
12
|
+
"elevate",
|
|
13
|
+
"sudo",
|
|
14
|
+
"task"
|
|
15
|
+
],
|
|
16
|
+
"author": "flun <cn@flun.top>",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"types": "index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"postinstall": "node copy-files.js 2>&1"
|
|
27
|
+
},
|
|
28
|
+
"main": "index.js",
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"bin",
|
|
32
|
+
"index.d.ts",
|
|
33
|
+
"index.js",
|
|
34
|
+
"sevWin.js",
|
|
35
|
+
"copy-files.js",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"preferGlobal": true,
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"xml": "1.0.1",
|
|
43
|
+
"yargs": "^18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.12.0",
|
|
47
|
+
"npm": ">=10.0.0"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/flunGit/windows.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://www.npmjs.com/package/@flun/windows#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/flunGit/windows/issues"
|
|
56
|
+
},
|
|
57
|
+
"license": "ISC"
|
|
58
|
+
}
|
package/sevWin.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Service } from '@flun/windows'; // 可选参数(EventLogger,elevate, sudo, isAdminUser, kill, list)
|
|
2
|
+
|
|
3
|
+
const serviceName = 'TestApp', scriptPath = 'D:\\test\\dev.js', // 请根据实际路径修改
|
|
4
|
+
svc = new Service({
|
|
5
|
+
name: serviceName,
|
|
6
|
+
description: 'Node.js 开发服务器',
|
|
7
|
+
script: scriptPath,
|
|
8
|
+
nodeOptions: ['--harmony', '--max-old-space-size=4096'],
|
|
9
|
+
env: { name: "NODE_ENV", value: "production" },
|
|
10
|
+
// sudo: { enabled: true }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const installService = () => {
|
|
14
|
+
console.log('🚀 开始安装服务...');
|
|
15
|
+
svc.on('install', () => svc.start());
|
|
16
|
+
svc.on('start', () => console.log('✅ 安装成功,服务已启动!!'));
|
|
17
|
+
svc.install();
|
|
18
|
+
}
|
|
19
|
+
installService();
|