@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/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
- if (typeof options === 'string') {
19
- options = {
20
- 'encoding': options
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
- catch {
66
- return null;
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 <= 2; ++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
- await lCore.sleep(250);
93
+ if (i < 3) {
94
+ await lCore.sleep(250);
95
+ }
127
96
  }
128
97
  }
129
- try {
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
- if (!pstats?.isDirectory()) {
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
- if (!pstats?.isFile()) {
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 stat = await stats(item.name);
220
- if (!stat) {
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
- for (const item of dlist) {
279
- if (item.name === '.' || item.name === '..') {
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
- else {
323
- num += r;
324
- }
253
+ num += r;
325
254
  }
326
255
  else if (item.isFile()) {
327
256
  // --- 先判断本文件是否被排除 ---
328
- if (ignore.length > 0 && lText.match(item.name, ignore)) {
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
- if (typeof options === 'string') {
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
- if (typeof options === 'string') {
403
- options = {
404
- 'encoding': options
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
- else {
408
- options ??= {};
409
- }
410
- return fs.createWriteStream(path, {
411
- 'flags': options.flags,
412
- 'encoding': options.encoding,
413
- 'mode': options.mode,
414
- 'autoClose': options.autoClose,
415
- 'start': options.start
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
- * --- 读取文件并输出到 http 的 response ---
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
- // --- 设置 type ---
464
- res.setHeader('content-type', mimeData.mime + charset);
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 (mimeData.compressible && (stat.size >= 1024)) {
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
- res.end(compress.buffer);
501
+ lCore.writeHead(res, 200);
502
+ res.end(req.method === 'HEAD' ? '' : compress.buffer);
474
503
  return;
475
504
  }
476
505
  }
477
- // --- 不压缩 ---
478
- res.setHeader('content-length', stat.size);
479
- lCore.writeHead(res, 200);
480
- await pipe(path, res instanceof http2.Http2ServerResponse ? (res.stream ?? res) : res);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.20.1",
3
+ "version": "9.21.1",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
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>;