flower-trellis 0.5.3 → 0.5.4
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/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/MANIFEST.json +2 -2
- package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -31
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +99 -210
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -181
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +60 -29
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -31
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +99 -210
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -181
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +60 -29
- package/package.json +3 -3
- package/src/commands/init.js +13 -2
- package/src/commands/plugin.js +8 -4
- package/src/commands/update.js +7 -1
- package/src/lib/banner.js +10 -1
- package/src/lib/command-completion.js +43 -0
|
@@ -1,260 +1,335 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本地日志与浏览器控制服务。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 按 JSONL(每行一条 JSON)格式实时追加写入 session.jsonl。
|
|
7
|
-
*
|
|
8
|
-
* 启动方式:
|
|
9
|
-
* node logger.js
|
|
10
|
-
*
|
|
11
|
-
* 实时查看:
|
|
12
|
-
* tail -f session.jsonl
|
|
4
|
+
* `/log` 接收注入脚本的兼容回退事件;正常路径由 Playwright binding
|
|
5
|
+
* 直接调用 `server.appendEvents`,因此不依赖目标页面访问 localhost。
|
|
13
6
|
*/
|
|
14
7
|
const http = require('http');
|
|
15
8
|
const fs = require('fs');
|
|
16
9
|
const path = require('path');
|
|
10
|
+
const { DEFAULT_HOST, getLoggerOrigin, resolvePort } = require('./config');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LOG_FILE = path.resolve(process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'));
|
|
13
|
+
const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const DASHBOARD_OMITTED_FIELDS = new Set([
|
|
15
|
+
'requestBody',
|
|
16
|
+
'responseBody',
|
|
17
|
+
'requestHeaders',
|
|
18
|
+
'responseHeaders',
|
|
19
|
+
'requestMultipart',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成 Dashboard 使用的轻量事件,原始敏感内容只保留在本地 JSONL。
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} event 原始事件。
|
|
26
|
+
* @param {number} cursor 单调递增游标。
|
|
27
|
+
* @return {Object} 不含大正文和完整头的事件摘要。
|
|
28
|
+
*/
|
|
29
|
+
function summarizeDashboardEvent(event, cursor) {
|
|
30
|
+
const summary = { _cursor: cursor };
|
|
31
|
+
for (const [key, value] of Object.entries(event)) {
|
|
32
|
+
if (!DASHBOARD_OMITTED_FIELDS.has(key)) summary[key] = value;
|
|
33
|
+
}
|
|
34
|
+
if (event.requestHeaders) summary.requestHeaderCount = Object.keys(event.requestHeaders).length;
|
|
35
|
+
if (event.responseHeaders) summary.responseHeaderCount = Object.keys(event.responseHeaders).length;
|
|
36
|
+
if (event.requestBody !== undefined && event.requestBody !== null) summary.requestBodyCaptured = true;
|
|
37
|
+
if (event.responseBody !== undefined && event.responseBody !== null) summary.responseBodyCaptured = true;
|
|
38
|
+
if (event.requestMultipart) summary.requestMultipartPartCount = event.requestMultipart.parts?.length || 0;
|
|
39
|
+
return summary;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 判断请求 Origin 是否允许访问控制或事件读取接口。
|
|
44
|
+
*
|
|
45
|
+
* @param {string|undefined} origin 请求 Origin。
|
|
46
|
+
* @param {string|undefined} host 请求 Host。
|
|
47
|
+
* @return {boolean} 是否允许。
|
|
48
|
+
*/
|
|
49
|
+
function isProtectedOriginAllowed(origin, host) {
|
|
50
|
+
if (!origin) return true;
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(origin);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (parsed.host === host) return true;
|
|
58
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
59
|
+
}
|
|
17
60
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 读取有大小上限的 JSON body。
|
|
63
|
+
*
|
|
64
|
+
* @param {http.IncomingMessage} req HTTP 请求。
|
|
65
|
+
* @return {Promise<Object>} JSON 对象。
|
|
66
|
+
*/
|
|
67
|
+
function readJsonBody(req) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let size = 0;
|
|
71
|
+
let settled = false;
|
|
72
|
+
req.on('data', chunk => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
size += chunk.length;
|
|
75
|
+
if (size > MAX_JSON_BODY_BYTES) {
|
|
76
|
+
settled = true;
|
|
77
|
+
chunks.length = 0;
|
|
78
|
+
reject(Object.assign(new Error('request body too large'), { statusCode: 413, code: 'BODY_TOO_LARGE' }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
});
|
|
83
|
+
req.on('end', () => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
if (chunks.length === 0) return resolve({});
|
|
87
|
+
try {
|
|
88
|
+
return resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
error.statusCode = 400;
|
|
91
|
+
error.code = 'INVALID_JSON';
|
|
92
|
+
return reject(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
req.on('error', error => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
reject(error);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
22
102
|
|
|
23
103
|
/**
|
|
24
|
-
*
|
|
104
|
+
* 写 JSON 响应。
|
|
25
105
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
106
|
+
* @param {http.ServerResponse} res HTTP 响应。
|
|
107
|
+
* @param {number} statusCode 状态码。
|
|
108
|
+
* @param {Object|Array<Object>} payload 响应数据。
|
|
109
|
+
* @return {void}
|
|
110
|
+
*/
|
|
111
|
+
function writeJson(res, statusCode, payload) {
|
|
112
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
113
|
+
res.end(JSON.stringify(payload));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 启动本地日志收集与浏览器控制服务。
|
|
28
118
|
*
|
|
29
|
-
* @param {Object} [options]
|
|
30
|
-
* @param {number} [options.port
|
|
31
|
-
* @param {string} [options.
|
|
32
|
-
* @param {
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @return {http.Server} 已启动的 HTTP server 实例(方便调用方关闭)
|
|
119
|
+
* @param {Object} [options] 配置项。
|
|
120
|
+
* @param {number} [options.port] 监听端口。
|
|
121
|
+
* @param {string} [options.host] 监听地址。
|
|
122
|
+
* @param {string} [options.logFile] JSONL 文件路径。
|
|
123
|
+
* @param {{dispatch:(action:string,body:Object)=>Promise<Object>}} [options.browserController] 浏览器控制器。
|
|
124
|
+
* @return {http.Server & {appendEvents:(events:Array<Object>|Object)=>void}} 已启动且保持 `.close()` 兼容的 server。
|
|
36
125
|
*/
|
|
37
126
|
function startLogger(options = {}) {
|
|
38
|
-
const port = options.port
|
|
127
|
+
const port = resolvePort(options.port);
|
|
128
|
+
const host = options.host || DEFAULT_HOST;
|
|
39
129
|
const logFile = options.logFile || DEFAULT_LOG_FILE;
|
|
40
130
|
const browserController = options.browserController || null;
|
|
131
|
+
const dashboardFile = path.join(__dirname, 'dashboard.html');
|
|
132
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
133
|
+
fs.closeSync(fs.openSync(logFile, 'a'));
|
|
134
|
+
let dashboardEvents = null;
|
|
41
135
|
|
|
42
136
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
137
|
+
* 追加一批事件,每个对象独占一行。
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<Object>|Object} input 单条事件或事件数组。
|
|
140
|
+
* @return {void}
|
|
45
141
|
*/
|
|
46
|
-
function appendEvents(
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
142
|
+
function appendEvents(input) {
|
|
143
|
+
const events = Array.isArray(input) ? input : [input];
|
|
144
|
+
const validEvents = events.filter(event => event && typeof event === 'object' && !Array.isArray(event));
|
|
145
|
+
if (validEvents.length === 0) return;
|
|
146
|
+
const persistedEvents = validEvents.map(event => ({ ...event, serverTime: new Date().toISOString() }));
|
|
147
|
+
const lines = persistedEvents.map(event => JSON.stringify(event)).join('\n') + '\n';
|
|
51
148
|
fs.appendFileSync(logFile, lines);
|
|
149
|
+
if (dashboardEvents) {
|
|
150
|
+
for (const event of persistedEvents) {
|
|
151
|
+
dashboardEvents.push(summarizeDashboardEvent(event, dashboardEvents.length + 1));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
52
154
|
}
|
|
53
155
|
|
|
54
|
-
const dashboardFile = path.join(__dirname, 'dashboard.html');
|
|
55
|
-
|
|
56
156
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @return {
|
|
157
|
+
* 读取全部有效 JSONL 事件。
|
|
158
|
+
*
|
|
159
|
+
* @return {Array<Object>} 事件列表。
|
|
60
160
|
*/
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
161
|
+
function readDashboardEvents() {
|
|
162
|
+
if (dashboardEvents) return dashboardEvents;
|
|
163
|
+
const events = [];
|
|
164
|
+
for (const line of fs.readFileSync(logFile, 'utf8').split('\n')) {
|
|
165
|
+
if (!line.trim()) continue;
|
|
166
|
+
try {
|
|
167
|
+
events.push(summarizeDashboardEvent(JSON.parse(line), events.length + 1));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// 单行损坏不应影响其它可复现事件。
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
dashboardEvents = events;
|
|
173
|
+
return dashboardEvents;
|
|
72
174
|
}
|
|
73
175
|
|
|
74
176
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* @param {
|
|
78
|
-
* @param {http.
|
|
79
|
-
* @param {
|
|
177
|
+
* 为 `/log` 回退或受保护接口设置各自的 CORS 头。
|
|
178
|
+
*
|
|
179
|
+
* @param {http.IncomingMessage} req HTTP 请求。
|
|
180
|
+
* @param {http.ServerResponse} res HTTP 响应。
|
|
181
|
+
* @param {'log'|'protected'|'none'} policy 路由策略。
|
|
182
|
+
* @return {boolean} 当前 Origin 是否允许继续处理。
|
|
80
183
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
184
|
+
function applyOriginPolicy(req, res, policy) {
|
|
185
|
+
const origin = req.headers.origin;
|
|
186
|
+
if (policy === 'none') return true;
|
|
187
|
+
if (policy === 'protected' && !isProtectedOriginAllowed(origin, req.headers.host)) return false;
|
|
188
|
+
if (origin) {
|
|
189
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
190
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
191
|
+
res.setHeader('Vary', 'Origin');
|
|
86
192
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
case 'open': result = await browserController.open(body.url, body); break;
|
|
92
|
-
case 'pages': result = await browserController.listPages(); break;
|
|
93
|
-
case 'close': result = await browserController.close(body.index); break;
|
|
94
|
-
case 'focus': result = await browserController.focus(body.index); break;
|
|
95
|
-
case 'reload': result = await browserController.reload(body.index); break;
|
|
96
|
-
case 'back': result = await browserController.back(body.index); break;
|
|
97
|
-
case 'forward': result = await browserController.forward(body.index); break;
|
|
98
|
-
case 'navigate': result = await browserController.navigate(body.index, body.url); break;
|
|
99
|
-
default:
|
|
100
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
101
|
-
res.end(JSON.stringify({ error: 'unknown control action: ' + action }));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
105
|
-
res.end(JSON.stringify(result || { ok: true }));
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.error('[control]', action, 'failed:', err.message);
|
|
108
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
109
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
193
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
194
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
195
|
+
if (policy === 'log' && req.headers['access-control-request-private-network']) {
|
|
196
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
110
197
|
}
|
|
198
|
+
return true;
|
|
111
199
|
}
|
|
112
200
|
|
|
113
201
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* @
|
|
202
|
+
* 执行 browser controller action 并统一错误结构。
|
|
203
|
+
*
|
|
204
|
+
* @param {string} action 动作名。
|
|
205
|
+
* @param {http.IncomingMessage} req HTTP 请求。
|
|
206
|
+
* @param {http.ServerResponse} res HTTP 响应。
|
|
207
|
+
* @return {Promise<void>} 完成响应后结束。
|
|
117
208
|
*/
|
|
118
|
-
function
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
209
|
+
async function handleControl(action, req, res) {
|
|
210
|
+
if (!browserController) {
|
|
211
|
+
writeJson(res, 503, { error: { code: 'BROWSER_NOT_READY', message: 'browser controller 未配置', details: null } });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const body = req.method === 'POST' ? await readJsonBody(req) : Object.fromEntries(new URL(req.url, getLoggerOrigin({ port })).searchParams);
|
|
216
|
+
const result = await browserController.dispatch(action, body);
|
|
217
|
+
writeJson(res, 200, result === undefined ? { ok: true } : result);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('[control]', action, 'failed:', error.message);
|
|
220
|
+
writeJson(res, error.statusCode || 500, {
|
|
221
|
+
error: {
|
|
222
|
+
code: error.code || 'CONTROL_FAILED',
|
|
223
|
+
message: error.message,
|
|
224
|
+
details: error.details || null,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
125
227
|
}
|
|
126
|
-
return events;
|
|
127
228
|
}
|
|
128
229
|
|
|
129
230
|
const server = http.createServer((req, res) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
140
|
-
res.setHeader('Vary', 'Origin');
|
|
141
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
142
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
143
|
-
// Chrome Private Network Access:从"公网"页面访问"本地网络"资源时浏览器会做预检
|
|
144
|
-
// 即使 dashboard 也在 localhost 上,某些代理/转发场景可能触发,直接放行
|
|
145
|
-
if (req.headers['access-control-request-private-network']) {
|
|
146
|
-
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
231
|
+
const reqUrl = new URL(req.url, getLoggerOrigin({ port }));
|
|
232
|
+
const pathname = reqUrl.pathname;
|
|
233
|
+
const isControl = pathname.startsWith('/control/');
|
|
234
|
+
const isEvents = pathname === '/events';
|
|
235
|
+
const isSession = pathname === '/session';
|
|
236
|
+
const policy = pathname === '/log' ? 'log' : (isControl || isEvents || isSession ? 'protected' : 'none');
|
|
237
|
+
if (!applyOriginPolicy(req, res, policy)) {
|
|
238
|
+
writeJson(res, 403, { error: { code: 'ORIGIN_FORBIDDEN', message: 'cross-origin control access is forbidden', details: null } });
|
|
239
|
+
return;
|
|
147
240
|
}
|
|
148
|
-
|
|
149
|
-
// 预检请求直接放行
|
|
150
241
|
if (req.method === 'OPTIONS') {
|
|
151
|
-
|
|
242
|
+
if (policy === 'none') {
|
|
243
|
+
res.writeHead(404);
|
|
244
|
+
} else {
|
|
245
|
+
res.writeHead(204);
|
|
246
|
+
}
|
|
152
247
|
res.end();
|
|
153
248
|
return;
|
|
154
249
|
}
|
|
155
|
-
|
|
156
|
-
// 解析 URL,做基于路径的路由
|
|
157
|
-
const reqUrl = new URL(req.url, `http://localhost:${port}`);
|
|
158
|
-
const pathname = reqUrl.pathname;
|
|
159
|
-
|
|
160
|
-
// 静音 favicon 噪音
|
|
161
250
|
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
162
251
|
res.writeHead(204);
|
|
163
252
|
res.end();
|
|
164
253
|
return;
|
|
165
254
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
255
|
+
if (isControl) {
|
|
256
|
+
if (!['GET', 'POST'].includes(req.method)) {
|
|
257
|
+
writeJson(res, 405, { error: { code: 'METHOD_NOT_ALLOWED', message: 'control only supports GET or POST', details: null } });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
void handleControl(pathname.slice('/control/'.length), req, res);
|
|
171
261
|
return;
|
|
172
262
|
}
|
|
173
|
-
|
|
174
|
-
// GET /dashboard :返回 HTML 实时面板
|
|
175
263
|
if (req.method === 'GET' && (pathname === '/dashboard' || pathname === '/dashboard.html')) {
|
|
176
264
|
if (!fs.existsSync(dashboardFile)) {
|
|
177
265
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
178
266
|
res.end('dashboard.html 未找到');
|
|
179
267
|
return;
|
|
180
268
|
}
|
|
181
|
-
const html = fs.readFileSync(dashboardFile, 'utf8');
|
|
182
269
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
183
|
-
res.end(
|
|
270
|
+
res.end(fs.readFileSync(dashboardFile, 'utf8'));
|
|
184
271
|
return;
|
|
185
272
|
}
|
|
186
|
-
|
|
187
|
-
// GET /events :返回事件列表 JSON,供 Dashboard 轮询
|
|
188
|
-
// 支持 ?since=<serverTime ISO>(增量拉取) 和 ?limit=N(只要最近 N 条)
|
|
189
273
|
if (req.method === 'GET' && pathname === '/events') {
|
|
190
274
|
const since = reqUrl.searchParams.get('since');
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
275
|
+
const cursor = Number.parseInt(reqUrl.searchParams.get('cursor'), 10);
|
|
276
|
+
const limit = Number.parseInt(reqUrl.searchParams.get('limit'), 10);
|
|
277
|
+
let events = readDashboardEvents();
|
|
278
|
+
if (cursor > 0) events = events.filter(event => event._cursor > cursor);
|
|
279
|
+
if (since) events = events.filter(event => event.serverTime > since);
|
|
194
280
|
if (limit > 0 && events.length > limit) events = events.slice(-limit);
|
|
195
|
-
res
|
|
196
|
-
res.end(JSON.stringify(events));
|
|
281
|
+
writeJson(res, 200, events);
|
|
197
282
|
return;
|
|
198
283
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
284
|
+
if (req.method === 'GET' && pathname === '/session') {
|
|
285
|
+
res.writeHead(200, {
|
|
286
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
287
|
+
'Content-Disposition': `attachment; filename="${path.basename(logFile)}"`,
|
|
288
|
+
'Content-Length': fs.statSync(logFile).size,
|
|
289
|
+
});
|
|
290
|
+
fs.createReadStream(logFile).pipe(res);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (req.method === 'POST' && pathname === '/log') {
|
|
294
|
+
readJsonBody(req).then(payload => {
|
|
295
|
+
appendEvents(payload);
|
|
296
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
297
|
+
res.end('ok');
|
|
298
|
+
}).catch(error => {
|
|
299
|
+
console.error('[logger] bad payload:', error.message);
|
|
300
|
+
writeJson(res, error.statusCode || 400, {
|
|
301
|
+
error: { code: error.code || 'INVALID_PAYLOAD', message: error.message, details: null },
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
|
|
307
|
+
writeJson(res, 200, {
|
|
204
308
|
status: 'ok',
|
|
205
309
|
logFile,
|
|
206
310
|
endpoints: {
|
|
207
|
-
dashboard:
|
|
208
|
-
events:
|
|
209
|
-
|
|
311
|
+
dashboard: `${getLoggerOrigin({ port })}/dashboard`,
|
|
312
|
+
events: `${getLoggerOrigin({ port })}/events`,
|
|
313
|
+
session: `${getLoggerOrigin({ port })}/session`,
|
|
314
|
+
log: `${getLoggerOrigin({ port })}/log`,
|
|
315
|
+
control: `${getLoggerOrigin({ port })}/control/<action>`,
|
|
210
316
|
},
|
|
211
|
-
}));
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// POST /log(或任意 POST 路径):接收事件
|
|
216
|
-
if (req.method === 'POST') {
|
|
217
|
-
let body = '';
|
|
218
|
-
req.on('data', chunk => { body += chunk; });
|
|
219
|
-
req.on('end', () => {
|
|
220
|
-
try {
|
|
221
|
-
// 客户端可能批量发送(数组),也可能单条发送(对象),统一成数组处理
|
|
222
|
-
const parsed = JSON.parse(body);
|
|
223
|
-
const events = Array.isArray(parsed) ? parsed : [parsed];
|
|
224
|
-
appendEvents(events);
|
|
225
|
-
res.writeHead(200);
|
|
226
|
-
res.end('ok');
|
|
227
|
-
} catch (err) {
|
|
228
|
-
// 解析失败时把原始 body 也记下来便于排查
|
|
229
|
-
console.error('[logger] bad payload:', err.message);
|
|
230
|
-
res.writeHead(400);
|
|
231
|
-
res.end(err.message);
|
|
232
|
-
}
|
|
233
317
|
});
|
|
234
318
|
return;
|
|
235
319
|
}
|
|
236
|
-
|
|
237
|
-
res.writeHead(405);
|
|
238
|
-
res.end();
|
|
320
|
+
writeJson(res, 404, { error: { code: 'NOT_FOUND', message: 'route not found', details: null } });
|
|
239
321
|
});
|
|
240
322
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// 本工具是本地开发用,端口高且仅供自用,没有安全风险。
|
|
245
|
-
server.listen(port, '0.0.0.0', () => {
|
|
246
|
-
console.log(`[logger] listening on http://localhost:${port} (also 0.0.0.0:${port})`);
|
|
323
|
+
server.appendEvents = appendEvents;
|
|
324
|
+
server.listen(port, host, () => {
|
|
325
|
+
console.log(`[logger] listening on ${getLoggerOrigin({ port })} (also ${host}:${port})`);
|
|
247
326
|
console.log(`[logger] writing to ${logFile}`);
|
|
248
|
-
console.log(`[logger] dashboard at
|
|
327
|
+
console.log(`[logger] dashboard at ${getLoggerOrigin({ port })}/dashboard`);
|
|
249
328
|
console.log(`[logger] live view: tail -f ${logFile}`);
|
|
250
329
|
});
|
|
251
|
-
|
|
252
330
|
return server;
|
|
253
331
|
}
|
|
254
332
|
|
|
255
|
-
|
|
256
|
-
if (require.main === module) {
|
|
257
|
-
startLogger();
|
|
258
|
-
}
|
|
333
|
+
if (require.main === module) startLogger();
|
|
259
334
|
|
|
260
|
-
module.exports = { startLogger };
|
|
335
|
+
module.exports = { isProtectedOriginAllowed, readJsonBody, startLogger, summarizeDashboardEvent };
|