@xcanwin/manyoyo 7.0.23 → 7.0.24

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.
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ // serve 日志一行的格式(写入侧见 bin/manyoyo.js 的 createServeLogger):
6
+ // [<本地时间>] [pid:<pid>] [<LEVEL>] <message>[ <extra JSON>]
7
+ const SERVE_LOG_LINE_PATTERN = /^\[([^\]]+)\] \[pid:(\d+)\] \[([A-Z]+)\] (.*)$/;
8
+ const LOG_DATE_TAG_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
9
+
10
+ const DEFAULT_LIMIT = 50;
11
+ const MAX_LIMIT = 500;
12
+ const DEFAULT_CHUNK_SIZE = 64 * 1024;
13
+ // 过滤条件很窄(比如只看 error)时可能扫很久都凑不满 limit,给一个扫描上限兜底,
14
+ // 避免一次请求把整份日志读完、把事件循环堵住
15
+ const MAX_SCAN_BYTES = 4 * 1024 * 1024;
16
+ const NEWLINE = 0x0a;
17
+
18
+ function isValidLogDateTag(value) {
19
+ return typeof value === 'string' && LOG_DATE_TAG_PATTERN.test(value);
20
+ }
21
+
22
+ // message 与 extra 之间没有分隔符,只能从右边找第一个能当 JSON 解析的 "{...}"。
23
+ // 解析不出来就整体当 message,不丢内容
24
+ function splitMessageAndExtra(rest) {
25
+ const text = String(rest || '');
26
+ const braceIndex = text.indexOf(' {');
27
+ if (braceIndex === -1) {
28
+ return { message: text.trim(), extra: {} };
29
+ }
30
+ const message = text.slice(0, braceIndex).trim();
31
+ const tail = text.slice(braceIndex + 1);
32
+ try {
33
+ const parsed = JSON.parse(tail);
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
35
+ return { message, extra: parsed };
36
+ }
37
+ } catch (e) {
38
+ // 落到下面的 raw 分支
39
+ }
40
+ return { message, extra: { raw: tail } };
41
+ }
42
+
43
+ function parseServeLogLine(line) {
44
+ const matched = String(line || '').match(SERVE_LOG_LINE_PATTERN);
45
+ if (!matched) {
46
+ return null;
47
+ }
48
+ const { message, extra } = splitMessageAndExtra(matched[4]);
49
+ return {
50
+ ts: matched[1],
51
+ pid: Number(matched[2]),
52
+ level: matched[3],
53
+ message,
54
+ extra
55
+ };
56
+ }
57
+
58
+ function normalizeLimit(limit) {
59
+ const parsed = Number(limit);
60
+ if (!Number.isFinite(parsed) || parsed <= 0) {
61
+ return DEFAULT_LIMIT;
62
+ }
63
+ return Math.min(Math.floor(parsed), MAX_LIMIT);
64
+ }
65
+
66
+ function buildMatcher(options) {
67
+ const levels = Array.isArray(options.levels) && options.levels.length
68
+ ? new Set(options.levels.map(level => String(level).toUpperCase()))
69
+ : null;
70
+ const keyword = options.keyword ? String(options.keyword).toLowerCase() : '';
71
+ const session = options.session ? String(options.session) : '';
72
+ return (entry, rawLine) => {
73
+ if (levels && !levels.has(entry.level)) {
74
+ return false;
75
+ }
76
+ if (session && entry.extra.session !== session) {
77
+ return false;
78
+ }
79
+ if (keyword && !rawLine.toLowerCase().includes(keyword)) {
80
+ return false;
81
+ }
82
+ return true;
83
+ };
84
+ }
85
+
86
+ // 从文件末尾往前分块读,凑够 limit 条就停——日志文件会长到几十 MB,
87
+ // 整份 readFileSync + split('\n') 是同步阻塞,会把 serve 的事件循环卡住
88
+ // (见 CLAUDE.md 的日志约束)。用 Buffer 按换行字节切分,中文跨块也不会被截断。
89
+ // endOffset 是翻页游标:传上一页返回的 nextEndOffset 就能继续往更早翻
90
+ function readServeLogEntries(filePath, options = {}) {
91
+ const limit = normalizeLimit(options.limit);
92
+ const chunkSize = Number.isFinite(options.chunkSize) && options.chunkSize > 0
93
+ ? Math.floor(options.chunkSize)
94
+ : DEFAULT_CHUNK_SIZE;
95
+ const empty = { entries: [], nextEndOffset: null, scannedBytes: 0, limit };
96
+
97
+ let stat;
98
+ try {
99
+ stat = fs.statSync(filePath);
100
+ } catch (e) {
101
+ return empty;
102
+ }
103
+ if (!stat.isFile()) {
104
+ return empty;
105
+ }
106
+
107
+ const size = stat.size;
108
+ const requestedEnd = Number(options.endOffset);
109
+ let end = Number.isFinite(requestedEnd) && requestedEnd >= 0 ? Math.min(requestedEnd, size) : size;
110
+ if (end <= 0) {
111
+ return empty;
112
+ }
113
+
114
+ const matches = buildMatcher(options);
115
+ const entries = [];
116
+ let scannedBytes = 0;
117
+ let pos = end;
118
+ // pending 里的字节在文件中的起始偏移就是 pos;开头那一行可能不完整,
119
+ // 要留到下一轮(读到更早的块)再拼
120
+ let pending = Buffer.alloc(0);
121
+ let unconsumedEnd = end;
122
+ let reachedLimit = false;
123
+
124
+ const fd = fs.openSync(filePath, 'r');
125
+ try {
126
+ while (pos > 0 && !reachedLimit && scannedBytes < MAX_SCAN_BYTES) {
127
+ const readSize = Math.min(chunkSize, pos);
128
+ pos -= readSize;
129
+ scannedBytes += readSize;
130
+ const buf = Buffer.alloc(readSize);
131
+ fs.readSync(fd, buf, 0, readSize, pos);
132
+ pending = pending.length ? Buffer.concat([buf, pending]) : buf;
133
+
134
+ let lineEnd = pending.length;
135
+ let newlineIndex = pending.lastIndexOf(NEWLINE, lineEnd - 1);
136
+ while (newlineIndex >= 0) {
137
+ const rawLine = pending.subarray(newlineIndex + 1, lineEnd).toString('utf-8');
138
+ lineEnd = newlineIndex;
139
+ const entry = parseServeLogLine(rawLine);
140
+ if (entry && matches(entry, rawLine)) {
141
+ entries.push(entry);
142
+ if (entries.length >= limit) {
143
+ reachedLimit = true;
144
+ break;
145
+ }
146
+ }
147
+ newlineIndex = pending.lastIndexOf(NEWLINE, lineEnd - 1);
148
+ }
149
+ pending = pending.subarray(0, lineEnd);
150
+ unconsumedEnd = pos + lineEnd;
151
+ }
152
+
153
+ // 文件开头那一行前面没有换行符,单独收尾
154
+ if (!reachedLimit && pos === 0 && pending.length) {
155
+ const rawLine = pending.toString('utf-8');
156
+ const entry = parseServeLogLine(rawLine);
157
+ if (entry && matches(entry, rawLine)) {
158
+ entries.push(entry);
159
+ }
160
+ unconsumedEnd = 0;
161
+ }
162
+ } finally {
163
+ fs.closeSync(fd);
164
+ }
165
+
166
+ return {
167
+ entries,
168
+ nextEndOffset: unconsumedEnd > 0 ? unconsumedEnd : null,
169
+ scannedBytes,
170
+ limit
171
+ };
172
+ }
173
+
174
+ module.exports = {
175
+ isValidLogDateTag,
176
+ parseServeLogLine,
177
+ readServeLogEntries
178
+ };