@maiyunnet/kebab 9.20.1 → 9.21.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/doc/kebab-rag.md +472 -33
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/core.d.ts +59 -0
- package/lib/core.js +81 -0
- package/lib/fs.d.ts +12 -1
- package/lib/fs.js +214 -159
- package/package.json +1 -1
- package/sys/master.js +54 -0
- package/sys/monitor/files.d.ts +34 -0
- package/sys/monitor/files.js +260 -0
- package/sys/monitor/watchdog.js +284 -215
- package/sys/monitor.d.ts +28 -3
- package/sys/monitor.js +527 -250
- package/www/example/ctr/test.d.ts +21 -0
- package/www/example/ctr/test.js +250 -2
package/lib/fs.js
CHANGED
|
@@ -4,67 +4,34 @@
|
|
|
4
4
|
* Last: 2020-3-11 22:21:51, 2022-12-29 01:18:25, 2023-12-13 20:50:09
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from 'fs';
|
|
7
|
+
import * as crypto from 'crypto';
|
|
7
8
|
import * as http2 from 'http2';
|
|
9
|
+
import * as stream from 'stream';
|
|
8
10
|
import * as mime from '@litert/mime';
|
|
9
11
|
import * as lText from './text.js';
|
|
10
12
|
import * as lCore from './core.js';
|
|
11
13
|
import * as lZlib from './zlib.js';
|
|
12
14
|
/**
|
|
13
|
-
* ---
|
|
15
|
+
* --- 读取完整文件或一段,区间包含首尾字节 ---
|
|
14
16
|
* @param path 文件路径
|
|
15
17
|
* @param options 编码或选项
|
|
18
|
+
* @returns 文件内容,读取失败返回 null
|
|
16
19
|
*/
|
|
17
20
|
export async function getContent(path, options) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
options ??= {};
|
|
25
|
-
}
|
|
26
|
-
const encoding = options.encoding;
|
|
27
|
-
const start = options.start;
|
|
28
|
-
const end = options.end;
|
|
29
|
-
if ((start !== undefined) || (end !== undefined)) {
|
|
30
|
-
return new Promise(function (resolve) {
|
|
31
|
-
const rs = createReadStream(path, {
|
|
32
|
-
'start': start,
|
|
33
|
-
'end': end
|
|
34
|
-
});
|
|
35
|
-
const data = [];
|
|
36
|
-
rs.on('data', (chunk) => {
|
|
37
|
-
if (!(chunk instanceof Buffer)) {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
data.push(chunk);
|
|
41
|
-
}).on('end', function () {
|
|
42
|
-
const buf = Buffer.concat(data);
|
|
43
|
-
if (encoding) {
|
|
44
|
-
resolve(buf.toString());
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
resolve(buf);
|
|
48
|
-
}
|
|
49
|
-
}).on('error', function () {
|
|
50
|
-
resolve(null);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
try {
|
|
56
|
-
if (encoding) {
|
|
57
|
-
return await fs.promises.readFile(path, {
|
|
58
|
-
'encoding': encoding
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
return await fs.promises.readFile(path);
|
|
63
|
-
}
|
|
21
|
+
const { encoding, start, end } = typeof options === 'string' ? { 'encoding': options } : options ?? {};
|
|
22
|
+
try {
|
|
23
|
+
if (start === undefined && end === undefined) {
|
|
24
|
+
return await fs.promises.readFile(path, { encoding });
|
|
64
25
|
}
|
|
65
|
-
|
|
66
|
-
|
|
26
|
+
const data = [];
|
|
27
|
+
for await (const chunk of createReadStream(path, { start, end })) {
|
|
28
|
+
data.push(chunk);
|
|
67
29
|
}
|
|
30
|
+
const content = Buffer.concat(data);
|
|
31
|
+
return encoding ? content.toString(encoding) : content;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
68
35
|
}
|
|
69
36
|
}
|
|
70
37
|
/**
|
|
@@ -117,22 +84,18 @@ export async function symlink(filePath, linkPath, type) {
|
|
|
117
84
|
* @param path 要删除的文件路径
|
|
118
85
|
*/
|
|
119
86
|
export async function unlink(path) {
|
|
120
|
-
for (let i = 0; i
|
|
87
|
+
for (let i = 0; i < 4; ++i) {
|
|
121
88
|
try {
|
|
122
89
|
await fs.promises.unlink(path);
|
|
123
90
|
return true;
|
|
124
91
|
}
|
|
125
92
|
catch {
|
|
126
|
-
|
|
93
|
+
if (i < 3) {
|
|
94
|
+
await lCore.sleep(250);
|
|
95
|
+
}
|
|
127
96
|
}
|
|
128
97
|
}
|
|
129
|
-
|
|
130
|
-
await fs.promises.unlink(path);
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
98
|
+
return false;
|
|
136
99
|
}
|
|
137
100
|
/**
|
|
138
101
|
* --- 获取对象是否存在,存在则返回 stats 对象,否则返回 null ---
|
|
@@ -152,10 +115,7 @@ export async function stats(path) {
|
|
|
152
115
|
*/
|
|
153
116
|
export async function isDir(path) {
|
|
154
117
|
const pstats = await stats(path);
|
|
155
|
-
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
return pstats;
|
|
118
|
+
return pstats?.isDirectory() ? pstats : false;
|
|
159
119
|
}
|
|
160
120
|
/**
|
|
161
121
|
* --- 判断是否是文件或文件是否存在,是的话返回 stats ---
|
|
@@ -163,10 +123,7 @@ export async function isDir(path) {
|
|
|
163
123
|
*/
|
|
164
124
|
export async function isFile(path) {
|
|
165
125
|
const pstats = await stats(path);
|
|
166
|
-
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
return pstats;
|
|
126
|
+
return pstats?.isFile() ? pstats : false;
|
|
170
127
|
}
|
|
171
128
|
/**
|
|
172
129
|
* --- 深度创建目录,如果最末目录存在,则自动创建成功 ---
|
|
@@ -174,10 +131,6 @@ export async function isFile(path) {
|
|
|
174
131
|
* @param mode 权限
|
|
175
132
|
*/
|
|
176
133
|
export async function mkdir(path, mode = 0o755) {
|
|
177
|
-
if (await isDir(path)) {
|
|
178
|
-
return true;
|
|
179
|
-
}
|
|
180
|
-
// --- 深度创建目录 ---
|
|
181
134
|
try {
|
|
182
135
|
await fs.promises.mkdir(path, {
|
|
183
136
|
'recursive': true,
|
|
@@ -209,30 +162,24 @@ export async function rmdir(path) {
|
|
|
209
162
|
* --- Danger 危险:危险函数,尽量不要使用 ---
|
|
210
163
|
* --- This is a danger function, please don't use it ---
|
|
211
164
|
* --- 删除一个非空目录 ---
|
|
165
|
+
* @param path 目录路径,不递归读取符号链接指向的目录
|
|
166
|
+
* @returns 删除成功或根路径不是目录返回 true,删除失败返回 false
|
|
212
167
|
*/
|
|
213
168
|
export async function rmdirDeep(path) {
|
|
169
|
+
// --- 去掉末尾斜线再用 lstat 检查,避免目录链接被跟随 ---
|
|
170
|
+
if (await isDir(path.replace(/\/+$/, '') || path) === false) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
214
173
|
if (!path.endsWith('/')) {
|
|
215
174
|
path += '/';
|
|
216
175
|
}
|
|
217
176
|
const list = await readDir(path);
|
|
218
177
|
for (const item of list) {
|
|
219
|
-
const
|
|
220
|
-
|
|
178
|
+
const target = path + item.name;
|
|
179
|
+
const result = item.isDirectory() ? await rmdirDeep(target) : await unlink(target);
|
|
180
|
+
if (!result) {
|
|
221
181
|
return false;
|
|
222
182
|
}
|
|
223
|
-
if (stat.isDirectory()) {
|
|
224
|
-
// --- 目录 ---
|
|
225
|
-
const rtn = await rmdirDeep(path + item.name);
|
|
226
|
-
if (!rtn) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
const rtn = await unlink(path + item.name);
|
|
232
|
-
if (!rtn) {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
183
|
}
|
|
237
184
|
return rmdir(path);
|
|
238
185
|
}
|
|
@@ -270,28 +217,12 @@ export async function rename(oldPath, newPath) {
|
|
|
270
217
|
*/
|
|
271
218
|
export async function readDir(path, encoding) {
|
|
272
219
|
try {
|
|
273
|
-
const list =
|
|
274
|
-
const dlist = await fs.promises.readdir(path, {
|
|
220
|
+
const list = await fs.promises.readdir(path, {
|
|
275
221
|
'encoding': encoding,
|
|
276
222
|
'withFileTypes': true
|
|
277
223
|
});
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
list.push(item);
|
|
283
|
-
}
|
|
284
|
-
// --- 将 list 根据先目录后文件排序,如果是同是目录或文件,则以名称排序 ---
|
|
285
|
-
list.sort((a, b) => {
|
|
286
|
-
// --- 目录排在文件前面 ---
|
|
287
|
-
if (a.isDirectory() && !b.isDirectory())
|
|
288
|
-
return -1;
|
|
289
|
-
if (!a.isDirectory() && b.isDirectory())
|
|
290
|
-
return 1;
|
|
291
|
-
// --- 同类型按名称排序 ---
|
|
292
|
-
return a.name.localeCompare(b.name);
|
|
293
|
-
});
|
|
294
|
-
return list;
|
|
224
|
+
// --- 目录优先,同类型按名称排序;readdir 本身不返回 . 和 .. ---
|
|
225
|
+
return list.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
295
226
|
}
|
|
296
227
|
catch {
|
|
297
228
|
return [];
|
|
@@ -319,13 +250,11 @@ export async function copyFolder(from, to, ignore = []) {
|
|
|
319
250
|
if (r === -1) {
|
|
320
251
|
return r;
|
|
321
252
|
}
|
|
322
|
-
|
|
323
|
-
num += r;
|
|
324
|
-
}
|
|
253
|
+
num += r;
|
|
325
254
|
}
|
|
326
255
|
else if (item.isFile()) {
|
|
327
256
|
// --- 先判断本文件是否被排除 ---
|
|
328
|
-
if (
|
|
257
|
+
if (lText.match(item.name, ignore)) {
|
|
329
258
|
continue;
|
|
330
259
|
}
|
|
331
260
|
if (!checkTo) {
|
|
@@ -362,21 +291,7 @@ export async function copyFile(src, dest) {
|
|
|
362
291
|
* @param options 编码或配置
|
|
363
292
|
*/
|
|
364
293
|
export function createReadStream(path, options) {
|
|
365
|
-
|
|
366
|
-
options = {
|
|
367
|
-
'encoding': options
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
options ??= {};
|
|
372
|
-
}
|
|
373
|
-
return fs.createReadStream(path, {
|
|
374
|
-
'flags': options.flags,
|
|
375
|
-
'encoding': options.encoding,
|
|
376
|
-
'autoClose': options.autoClose,
|
|
377
|
-
'start': options.start,
|
|
378
|
-
'end': options.end
|
|
379
|
-
});
|
|
294
|
+
return fs.createReadStream(path, typeof options === 'string' ? { 'encoding': options } : options);
|
|
380
295
|
}
|
|
381
296
|
/**
|
|
382
297
|
* --- 读取文件写入到流,并等待写入完成 ---
|
|
@@ -399,28 +314,102 @@ export function pipe(path, destination, options) {
|
|
|
399
314
|
* @param options 编码或配置
|
|
400
315
|
*/
|
|
401
316
|
export function createWriteStream(path, options) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
317
|
+
return fs.createWriteStream(path, typeof options === 'string' ? { 'encoding': options } : options);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* --- 解析并合并字节区间,限制多段请求的读取开销 ---
|
|
321
|
+
* @param value Range 请求头
|
|
322
|
+
* @param size 文件字节数
|
|
323
|
+
* @returns 区间列表;false 表示全部越界或区间过多;null 表示忽略请求头
|
|
324
|
+
*/
|
|
325
|
+
function parseFileRanges(value, size) {
|
|
326
|
+
if (!/^bytes=/i.test(value) || size === 0 || !Number.isSafeInteger(size)) {
|
|
327
|
+
return null;
|
|
406
328
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
329
|
+
const values = value.slice(6).split(',');
|
|
330
|
+
// --- 防止客户端利用大量小区间放大文件读取与响应开销 ---
|
|
331
|
+
if (value.length > 8192 || values.length > 16) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const total = BigInt(size);
|
|
335
|
+
const ranges = [];
|
|
336
|
+
let count = 0;
|
|
337
|
+
for (const item of values) {
|
|
338
|
+
const part = item.trim();
|
|
339
|
+
if (part === '') {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const match = /^(\d*)-(\d*)$/.exec(part);
|
|
343
|
+
if (!match || match[1] === '' && match[2] === '') {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
++count;
|
|
347
|
+
// --- 先以 BigInt 解析,避免超大偏移被浮点取整后读错字节 ---
|
|
348
|
+
const start = match[1] !== '' ? BigInt(match[1]) : total - BigInt(match[2]);
|
|
349
|
+
const end = match[1] !== '' && match[2] !== '' ? BigInt(match[2]) : total - 1n;
|
|
350
|
+
if (match[1] !== '' && match[2] !== '' && end < start) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (start >= total) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
ranges.push({
|
|
357
|
+
'start': Number(start < 0n ? 0n : start),
|
|
358
|
+
'end': Number(end >= total ? total - 1n : end),
|
|
359
|
+
'order': count
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (count === 0) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
if (ranges.length === 0) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
369
|
+
const merged = [];
|
|
370
|
+
for (const range of ranges) {
|
|
371
|
+
const previous = merged[merged.length - 1];
|
|
372
|
+
if (previous && range.start <= previous.end + 1) {
|
|
373
|
+
previous.end = Math.max(previous.end, range.end);
|
|
374
|
+
previous.order = Math.min(previous.order, range.order);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
merged.push(range);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return merged.sort((a, b) => a.order - b.order);
|
|
417
381
|
}
|
|
418
382
|
/**
|
|
419
|
-
* ---
|
|
383
|
+
* --- 逐段读取 multipart 响应,保持背压且在取消时关闭当前文件流 ---
|
|
384
|
+
* @param path 文件路径
|
|
385
|
+
* @param ranges 文件区间
|
|
386
|
+
* @param headers 每段的 MIME 头
|
|
387
|
+
* @param footer 结束边界
|
|
388
|
+
* @returns multipart 数据流
|
|
389
|
+
*/
|
|
390
|
+
async function* readFileRanges(path, ranges, headers, footer) {
|
|
391
|
+
for (let i = 0; i < ranges.length; ++i) {
|
|
392
|
+
yield headers[i];
|
|
393
|
+
yield* createReadStream(path, ranges[i]);
|
|
394
|
+
yield Buffer.from('\r\n');
|
|
395
|
+
}
|
|
396
|
+
yield footer;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* --- 读取文件并输出到 http 的 response,支持 GET 字节范围与条件请求 ---
|
|
400
|
+
*
|
|
401
|
+
* Range 支持 start-end、start-、-length 及最多 16 段,请求头上限 8 KiB;相邻和重叠区间合并。
|
|
402
|
+
* 有效区间返回 206,全部越界或区间过多返回 416;无效语法、未知单位和空文件忽略 Range。
|
|
403
|
+
* 部分响应按原始文件字节流式输出,不进行动态压缩。If-Range 日期须匹配 Last-Modified,
|
|
404
|
+
* 且文件不启用动态压缩、修改时间早于当前响应所在秒,否则退回完整响应;
|
|
405
|
+
* 文件元数据生成的 ETag 为弱校验值,不接受其作为 If-Range 的强校验值。
|
|
406
|
+
* HEAD 忽略 Range 且不输出响应体。
|
|
407
|
+
*
|
|
420
408
|
* @param path 文件绝对路径
|
|
421
409
|
* @param req http 请求对象
|
|
422
410
|
* @param res http 响应对象
|
|
423
411
|
* @param stat 文件的 stat(如果有)
|
|
412
|
+
* @returns 输出完成或连接终止后无返回值
|
|
424
413
|
*/
|
|
425
414
|
export async function readToResponse(path, req, res, stat) {
|
|
426
415
|
stat ??= await stats(path);
|
|
@@ -428,7 +417,7 @@ export async function readToResponse(path, req, res, stat) {
|
|
|
428
417
|
const content = '<h1>404 Not found</h1><hr>Kebab';
|
|
429
418
|
res.setHeader('content-length', Buffer.byteLength(content));
|
|
430
419
|
lCore.writeHead(res, 404);
|
|
431
|
-
res.end(content);
|
|
420
|
+
res.end(req.method === 'HEAD' ? '' : content);
|
|
432
421
|
return;
|
|
433
422
|
}
|
|
434
423
|
// --- 判断缓存以及 MIME 和编码 ---
|
|
@@ -438,44 +427,110 @@ export async function readToResponse(path, req, res, stat) {
|
|
|
438
427
|
if (mimeData.mime.startsWith('text/') || ['json', 'xml', 'svg', 'js', 'mjs', 'map', 'webmanifest'].includes(mimeData.extension)) {
|
|
439
428
|
charset = '; charset=utf-8';
|
|
440
429
|
}
|
|
430
|
+
const hash = `W/"${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}"`;
|
|
431
|
+
const lastModified = stat.mtime.toUTCString();
|
|
432
|
+
const modifiedTime = Date.parse(lastModified);
|
|
433
|
+
const canCompress = mimeData.compressible && stat.size >= 1024;
|
|
434
|
+
res.setHeader('etag', hash);
|
|
435
|
+
res.setHeader('last-modified', lastModified);
|
|
436
|
+
res.setHeader('accept-ranges', 'bytes');
|
|
437
|
+
if (canCompress) {
|
|
438
|
+
const vary = res.getHeader('vary');
|
|
439
|
+
const values = (Array.isArray(vary) ? vary.join(',') : String(vary ?? '')).split(',').map((item) => item.trim()).filter(Boolean);
|
|
440
|
+
if (!values.some((item) => item === '*' || item.toLowerCase() === 'accept-encoding')) {
|
|
441
|
+
values.push('Accept-Encoding');
|
|
442
|
+
res.setHeader('vary', values.join(', '));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
441
445
|
// --- 这些文件可能需要缓存 ---
|
|
442
446
|
if (['htm', 'html', 'css', 'js', 'mjs', 'xml', 'jpg', 'jpeg', 'svg', 'gif', 'png', 'json'].includes(mimeData.extension)) {
|
|
443
447
|
/** --- 静态文件默认缓存秒数 --- */
|
|
444
448
|
const cacheTTL = 600;
|
|
445
|
-
const hash = `W/"${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}"`;
|
|
446
|
-
const lastModified = stat.mtime.toUTCString();
|
|
447
|
-
res.setHeader('etag', hash);
|
|
448
449
|
res.setHeader('expires', new Date(Date.now() + cacheTTL * 1_000).toUTCString());
|
|
449
450
|
res.setHeader('cache-control', 'public, max-age=' + cacheTTL.toString());
|
|
450
|
-
// --- 判断返回 304 吗 ---
|
|
451
|
-
const noneMatch = req.headers['if-none-match'];
|
|
452
|
-
const modifiedSince = req.headers['if-modified-since'];
|
|
453
|
-
if ((hash === noneMatch) && (lastModified === modifiedSince)) {
|
|
454
|
-
lCore.writeHead(res, 304);
|
|
455
|
-
res.end();
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
res.setHeader('last-modified', lastModified);
|
|
459
451
|
}
|
|
460
452
|
else {
|
|
461
453
|
res.setHeader('cache-control', 'no-cache, must-revalidate');
|
|
462
454
|
}
|
|
463
|
-
// ---
|
|
464
|
-
|
|
455
|
+
// --- 条件请求优先于 Range;弱 ETag 只能用于缓存验证,不能用于 If-Match ---
|
|
456
|
+
const match = req.headers['if-match'];
|
|
457
|
+
const unmodifiedSince = req.headers['if-unmodified-since'];
|
|
458
|
+
if (match !== undefined ? match.trim() !== '*' : unmodifiedSince !== undefined && modifiedTime > Date.parse(unmodifiedSince)) {
|
|
459
|
+
res.setHeader('content-length', 0);
|
|
460
|
+
lCore.writeHead(res, 412);
|
|
461
|
+
res.end();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const noneMatch = req.headers['if-none-match'];
|
|
465
|
+
const modifiedSince = req.headers['if-modified-since'];
|
|
466
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
467
|
+
const unchanged = noneMatch !== undefined ? noneMatch.trim() === '*' || noneMatch.split(',').some((item) => item.trim().replace(/^W\//, '') === hash.slice(2)) :
|
|
468
|
+
isRead && modifiedSince !== undefined && modifiedTime <= Date.parse(modifiedSince);
|
|
469
|
+
if (unchanged) {
|
|
470
|
+
if (!isRead) {
|
|
471
|
+
res.setHeader('content-length', 0);
|
|
472
|
+
}
|
|
473
|
+
lCore.writeHead(res, isRead ? 304 : 412);
|
|
474
|
+
res.end();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const contentType = mimeData.mime + charset;
|
|
478
|
+
res.setHeader('content-type', contentType);
|
|
479
|
+
const rangeHeader = req.headers['range'];
|
|
480
|
+
const ifRange = req.headers['if-range'];
|
|
481
|
+
// --- 日期必须精确匹配且至少早一秒;压缩与原始字节共享的日期不能作强校验,避免跨编码续传 ---
|
|
482
|
+
const rangeMatches = ifRange === undefined || typeof ifRange === 'string' && !ifRange.includes('"') &&
|
|
483
|
+
Date.parse(ifRange) === modifiedTime && modifiedTime <= Date.now() - 1_000 && !canCompress;
|
|
484
|
+
const ranges = req.method === 'GET' && rangeHeader !== undefined &&
|
|
485
|
+
rangeMatches ? parseFileRanges(rangeHeader, stat.size) : null;
|
|
486
|
+
if (ranges === false) {
|
|
487
|
+
res.setHeader('content-range', `bytes */${stat.size}`);
|
|
488
|
+
res.setHeader('content-length', 0);
|
|
489
|
+
lCore.writeHead(res, 416);
|
|
490
|
+
res.end();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
465
493
|
// --- 判断客户端支持的压缩模式 ---
|
|
466
494
|
const encoding = req.headers['accept-encoding'] ?? '';
|
|
467
|
-
if (
|
|
495
|
+
if (ranges === null && canCompress && encoding !== '') {
|
|
468
496
|
// --- 压缩 ---
|
|
469
497
|
const compress = await lZlib.compress(encoding, await getContent(path));
|
|
470
498
|
if (compress) {
|
|
471
499
|
res.setHeader('content-encoding', compress.type);
|
|
472
500
|
res.setHeader('content-length', Buffer.byteLength(compress.buffer));
|
|
473
|
-
|
|
501
|
+
lCore.writeHead(res, 200);
|
|
502
|
+
res.end(req.method === 'HEAD' ? '' : compress.buffer);
|
|
474
503
|
return;
|
|
475
504
|
}
|
|
476
505
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
506
|
+
let headers = [];
|
|
507
|
+
let footer = Buffer.alloc(0);
|
|
508
|
+
if (ranges !== null && ranges.length > 1) {
|
|
509
|
+
const boundary = `kebab-${crypto.randomBytes(16).toString('hex')}`;
|
|
510
|
+
headers = ranges.map((range) => Buffer.from(`--${boundary}\r\nContent-Type: ${contentType}\r\nContent-Range: bytes ${range.start}-${range.end}/${stat.size}\r\n\r\n`));
|
|
511
|
+
footer = Buffer.from(`--${boundary}--\r\n`);
|
|
512
|
+
res.setHeader('content-type', `multipart/byteranges; boundary=${boundary}`);
|
|
513
|
+
res.setHeader('content-length', ranges.reduce((length, range, i) => length + headers[i].length + (range.end - range.start + 1) + 2, footer.length));
|
|
514
|
+
}
|
|
515
|
+
else if (ranges !== null) {
|
|
516
|
+
res.setHeader('content-range', `bytes ${ranges[0].start}-${ranges[0].end}/${stat.size}`);
|
|
517
|
+
res.setHeader('content-length', ranges[0].end - ranges[0].start + 1);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
res.setHeader('content-length', stat.size);
|
|
521
|
+
}
|
|
522
|
+
lCore.writeHead(res, ranges === null ? 200 : 206);
|
|
523
|
+
if (req.method === 'HEAD') {
|
|
524
|
+
res.end();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const source = ranges !== null && ranges.length > 1 ?
|
|
528
|
+
stream.Readable.from(readFileRanges(path, ranges, headers, footer)) : createReadStream(path, ranges?.[0]);
|
|
529
|
+
try {
|
|
530
|
+
await stream.promises.pipeline(source, res instanceof http2.Http2ServerResponse ? (res.stream ?? res) : res);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// --- 已提交响应头后不能再发送错误页;管道会关闭文件流及断开的响应 ---
|
|
534
|
+
res.destroy();
|
|
535
|
+
}
|
|
481
536
|
}
|
package/package.json
CHANGED
package/sys/master.js
CHANGED
|
@@ -8,9 +8,11 @@ import * as os from 'os';
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as http from 'http';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
+
import * as stream from 'stream/promises';
|
|
11
12
|
// --- 库和定义 ---
|
|
12
13
|
import * as kebab from '#kebab/index.js';
|
|
13
14
|
import * as sRoute from '#kebab/sys/route.js';
|
|
15
|
+
import * as sMonitorFiles from '#kebab/sys/monitor/files.js';
|
|
14
16
|
import * as lCore from '#kebab/lib/core.js';
|
|
15
17
|
import * as lFs from '#kebab/lib/fs.js';
|
|
16
18
|
import * as lText from '#kebab/lib/text.js';
|
|
@@ -918,6 +920,58 @@ function createRpcListener() {
|
|
|
918
920
|
}));
|
|
919
921
|
return;
|
|
920
922
|
}
|
|
923
|
+
case 'monitor-events': {
|
|
924
|
+
const result = await sMonitorFiles.getEvents(path.join(kebab.LOG_CWD, 'monitor'), msg.path ?? lTime.format(null, 'Y/m/d'), msg.offset ?? 0, msg.limit ?? 20);
|
|
925
|
+
res.setHeader('cache-control', 'no-store');
|
|
926
|
+
res.end(lText.stringifyJson(result === false ? { 'result': 0 } : { 'result': 1, ...result }));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
case 'monitor-event': {
|
|
930
|
+
const data = await sMonitorFiles.getEvent(path.join(kebab.LOG_CWD, 'monitor'), msg.path);
|
|
931
|
+
res.setHeader('cache-control', 'no-store');
|
|
932
|
+
res.end(lText.stringifyJson(data === false ? { 'result': 0 } : { 'result': 1, data }));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
case 'monitor-file': {
|
|
936
|
+
const file = await sMonitorFiles.getFile(path.join(kebab.LOG_CWD, 'monitor'), msg.path);
|
|
937
|
+
res.setHeader('cache-control', 'no-store');
|
|
938
|
+
if (file === false || msg.preview !== undefined && typeof msg.preview !== 'boolean') {
|
|
939
|
+
res.writeHead(400);
|
|
940
|
+
res.end();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (file === null) {
|
|
944
|
+
res.writeHead(404);
|
|
945
|
+
res.end();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (msg.preview && file.info.preview === null) {
|
|
949
|
+
res.writeHead(file.stat.size > sMonitorFiles.PREVIEW_LIMIT ? 413 : 415);
|
|
950
|
+
res.end();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
let contentType = 'application/octet-stream';
|
|
954
|
+
if (msg.preview) {
|
|
955
|
+
contentType = file.info.preview === 'json' ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
956
|
+
}
|
|
957
|
+
res.setHeader('content-type', contentType);
|
|
958
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
959
|
+
res.setHeader('x-kebab-monitor-file', '1');
|
|
960
|
+
res.setHeader('content-disposition', `attachment; filename="${file.info.name}"`);
|
|
961
|
+
res.setHeader('content-length', file.stat.size);
|
|
962
|
+
if (file.stat.size === 0) {
|
|
963
|
+
res.end();
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// --- 原始字节直接传输;pipeline 在客户端断开时关闭文件流,不整文件读取或压缩 ---
|
|
967
|
+
try {
|
|
968
|
+
await stream.pipeline(fs.createReadStream(file.path, { 'start': 0, 'end': file.stat.size - 1 }), res);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
res.destroy();
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
921
975
|
case 'ls': {
|
|
922
976
|
// --- 获取目录内文件/文件夹列表 ---
|
|
923
977
|
if (typeof msg.path !== 'string') {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Stats } from 'fs';
|
|
2
|
+
import type { IMonitorEvent, IMonitorEventSummary, IMonitorFile } from '#kebab/lib/core.js';
|
|
3
|
+
/** --- 小型 JSON/文本预览上限,Profile 和堆快照不预览 --- */
|
|
4
|
+
export declare const PREVIEW_LIMIT: number;
|
|
5
|
+
/**
|
|
6
|
+
* --- 获取一天的事件摘要,先分页目录再读取记录 ---
|
|
7
|
+
* @param root log/monitor 的绝对路径
|
|
8
|
+
* @param day YYYY/MM/DD
|
|
9
|
+
* @param offset 跳过目录数量
|
|
10
|
+
* @param limit 返回数量,1-100
|
|
11
|
+
* @returns 列表和总数;目录不存在返回空列表,输入或读取错误返回 false
|
|
12
|
+
*/
|
|
13
|
+
export declare function getEvents(root: string, day: unknown, offset?: unknown, limit?: unknown): Promise<{
|
|
14
|
+
'list': IMonitorEventSummary[];
|
|
15
|
+
'total': number;
|
|
16
|
+
} | false>;
|
|
17
|
+
/**
|
|
18
|
+
* --- 获取允许读取的诊断文件及其元数据,文件路径限制在日期/事件目录下 ---
|
|
19
|
+
* @param root log/monitor 的绝对路径
|
|
20
|
+
* @param relative files 中返回的文件相对路径
|
|
21
|
+
* @returns 真实路径、文件状态和元数据;不存在返回 null,非法路径或读取错误返回 false
|
|
22
|
+
*/
|
|
23
|
+
export declare function getFile(root: string, relative: unknown): Promise<{
|
|
24
|
+
'path': string;
|
|
25
|
+
'stat': Stats;
|
|
26
|
+
'info': IMonitorFile;
|
|
27
|
+
} | null | false>;
|
|
28
|
+
/**
|
|
29
|
+
* --- 获取事件原始数据及文件清单,包含 diagnostics 引用的看门狗取证文件 ---
|
|
30
|
+
* @param root log/monitor 的绝对路径
|
|
31
|
+
* @param relative 事件相对路径
|
|
32
|
+
* @returns 事件详情;不存在返回 null,非法路径或读取错误返回 false
|
|
33
|
+
*/
|
|
34
|
+
export declare function getEvent(root: string, relative: unknown): Promise<IMonitorEvent | null | false>;
|