flower-trellis 0.5.2 → 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 -32
- 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 +103 -212
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -179
- 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/runtime-layout.test.js +96 -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 +107 -40
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -32
- 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 +103 -212
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -179
- 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/runtime-layout.test.js +96 -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 +107 -40
- package/package.json +3 -3
- package/src/builtin-plugins/skill-garden/content-adapter.js +97 -4
- 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,258 +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
|
-
|
|
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
|
+
}
|
|
20
102
|
|
|
21
103
|
/**
|
|
22
|
-
*
|
|
104
|
+
* 写 JSON 响应。
|
|
23
105
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
+
* 启动本地日志收集与浏览器控制服务。
|
|
26
118
|
*
|
|
27
|
-
* @param {Object} [options]
|
|
28
|
-
* @param {number} [options.port
|
|
29
|
-
* @param {string} [options.
|
|
30
|
-
* @param {
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @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。
|
|
34
125
|
*/
|
|
35
126
|
function startLogger(options = {}) {
|
|
36
|
-
const port = options.port
|
|
127
|
+
const port = resolvePort(options.port);
|
|
128
|
+
const host = options.host || DEFAULT_HOST;
|
|
37
129
|
const logFile = options.logFile || DEFAULT_LOG_FILE;
|
|
38
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;
|
|
39
135
|
|
|
40
136
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
137
|
+
* 追加一批事件,每个对象独占一行。
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<Object>|Object} input 单条事件或事件数组。
|
|
140
|
+
* @return {void}
|
|
43
141
|
*/
|
|
44
|
-
function appendEvents(
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
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';
|
|
49
148
|
fs.appendFileSync(logFile, lines);
|
|
149
|
+
if (dashboardEvents) {
|
|
150
|
+
for (const event of persistedEvents) {
|
|
151
|
+
dashboardEvents.push(summarizeDashboardEvent(event, dashboardEvents.length + 1));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
50
154
|
}
|
|
51
155
|
|
|
52
|
-
const dashboardFile = path.join(__dirname, 'dashboard.html');
|
|
53
|
-
|
|
54
156
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @return {
|
|
157
|
+
* 读取全部有效 JSONL 事件。
|
|
158
|
+
*
|
|
159
|
+
* @return {Array<Object>} 事件列表。
|
|
58
160
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
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;
|
|
70
174
|
}
|
|
71
175
|
|
|
72
176
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* @param {
|
|
76
|
-
* @param {http.
|
|
77
|
-
* @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 是否允许继续处理。
|
|
78
183
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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');
|
|
84
192
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
case 'open': result = await browserController.open(body.url, body); break;
|
|
90
|
-
case 'pages': result = await browserController.listPages(); break;
|
|
91
|
-
case 'close': result = await browserController.close(body.index); break;
|
|
92
|
-
case 'focus': result = await browserController.focus(body.index); break;
|
|
93
|
-
case 'reload': result = await browserController.reload(body.index); break;
|
|
94
|
-
case 'back': result = await browserController.back(body.index); break;
|
|
95
|
-
case 'forward': result = await browserController.forward(body.index); break;
|
|
96
|
-
case 'navigate': result = await browserController.navigate(body.index, body.url); break;
|
|
97
|
-
default:
|
|
98
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
99
|
-
res.end(JSON.stringify({ error: 'unknown control action: ' + action }));
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
103
|
-
res.end(JSON.stringify(result || { ok: true }));
|
|
104
|
-
} catch (err) {
|
|
105
|
-
console.error('[control]', action, 'failed:', err.message);
|
|
106
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
107
|
-
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');
|
|
108
197
|
}
|
|
198
|
+
return true;
|
|
109
199
|
}
|
|
110
200
|
|
|
111
201
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @
|
|
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>} 完成响应后结束。
|
|
115
208
|
*/
|
|
116
|
-
function
|
|
117
|
-
if (!
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
});
|
|
123
227
|
}
|
|
124
|
-
return events;
|
|
125
228
|
}
|
|
126
229
|
|
|
127
230
|
const server = http.createServer((req, res) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
138
|
-
res.setHeader('Vary', 'Origin');
|
|
139
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
140
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
141
|
-
// Chrome Private Network Access:从"公网"页面访问"本地网络"资源时浏览器会做预检
|
|
142
|
-
// 即使 dashboard 也在 localhost 上,某些代理/转发场景可能触发,直接放行
|
|
143
|
-
if (req.headers['access-control-request-private-network']) {
|
|
144
|
-
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;
|
|
145
240
|
}
|
|
146
|
-
|
|
147
|
-
// 预检请求直接放行
|
|
148
241
|
if (req.method === 'OPTIONS') {
|
|
149
|
-
|
|
242
|
+
if (policy === 'none') {
|
|
243
|
+
res.writeHead(404);
|
|
244
|
+
} else {
|
|
245
|
+
res.writeHead(204);
|
|
246
|
+
}
|
|
150
247
|
res.end();
|
|
151
248
|
return;
|
|
152
249
|
}
|
|
153
|
-
|
|
154
|
-
// 解析 URL,做基于路径的路由
|
|
155
|
-
const reqUrl = new URL(req.url, `http://localhost:${port}`);
|
|
156
|
-
const pathname = reqUrl.pathname;
|
|
157
|
-
|
|
158
|
-
// 静音 favicon 噪音
|
|
159
250
|
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
160
251
|
res.writeHead(204);
|
|
161
252
|
res.end();
|
|
162
253
|
return;
|
|
163
254
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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);
|
|
169
261
|
return;
|
|
170
262
|
}
|
|
171
|
-
|
|
172
|
-
// GET /dashboard :返回 HTML 实时面板
|
|
173
263
|
if (req.method === 'GET' && (pathname === '/dashboard' || pathname === '/dashboard.html')) {
|
|
174
264
|
if (!fs.existsSync(dashboardFile)) {
|
|
175
265
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
176
266
|
res.end('dashboard.html 未找到');
|
|
177
267
|
return;
|
|
178
268
|
}
|
|
179
|
-
const html = fs.readFileSync(dashboardFile, 'utf8');
|
|
180
269
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
181
|
-
res.end(
|
|
270
|
+
res.end(fs.readFileSync(dashboardFile, 'utf8'));
|
|
182
271
|
return;
|
|
183
272
|
}
|
|
184
|
-
|
|
185
|
-
// GET /events :返回事件列表 JSON,供 Dashboard 轮询
|
|
186
|
-
// 支持 ?since=<serverTime ISO>(增量拉取) 和 ?limit=N(只要最近 N 条)
|
|
187
273
|
if (req.method === 'GET' && pathname === '/events') {
|
|
188
274
|
const since = reqUrl.searchParams.get('since');
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
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);
|
|
192
280
|
if (limit > 0 && events.length > limit) events = events.slice(-limit);
|
|
193
|
-
res
|
|
194
|
-
res.end(JSON.stringify(events));
|
|
281
|
+
writeJson(res, 200, events);
|
|
195
282
|
return;
|
|
196
283
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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, {
|
|
202
308
|
status: 'ok',
|
|
203
309
|
logFile,
|
|
204
310
|
endpoints: {
|
|
205
|
-
dashboard:
|
|
206
|
-
events:
|
|
207
|
-
|
|
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>`,
|
|
208
316
|
},
|
|
209
|
-
}));
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// POST /log(或任意 POST 路径):接收事件
|
|
214
|
-
if (req.method === 'POST') {
|
|
215
|
-
let body = '';
|
|
216
|
-
req.on('data', chunk => { body += chunk; });
|
|
217
|
-
req.on('end', () => {
|
|
218
|
-
try {
|
|
219
|
-
// 客户端可能批量发送(数组),也可能单条发送(对象),统一成数组处理
|
|
220
|
-
const parsed = JSON.parse(body);
|
|
221
|
-
const events = Array.isArray(parsed) ? parsed : [parsed];
|
|
222
|
-
appendEvents(events);
|
|
223
|
-
res.writeHead(200);
|
|
224
|
-
res.end('ok');
|
|
225
|
-
} catch (err) {
|
|
226
|
-
// 解析失败时把原始 body 也记下来便于排查
|
|
227
|
-
console.error('[logger] bad payload:', err.message);
|
|
228
|
-
res.writeHead(400);
|
|
229
|
-
res.end(err.message);
|
|
230
|
-
}
|
|
231
317
|
});
|
|
232
318
|
return;
|
|
233
319
|
}
|
|
234
|
-
|
|
235
|
-
res.writeHead(405);
|
|
236
|
-
res.end();
|
|
320
|
+
writeJson(res, 404, { error: { code: 'NOT_FOUND', message: 'route not found', details: null } });
|
|
237
321
|
});
|
|
238
322
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// 本工具是本地开发用,端口高且仅供自用,没有安全风险。
|
|
243
|
-
server.listen(port, '0.0.0.0', () => {
|
|
244
|
-
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})`);
|
|
245
326
|
console.log(`[logger] writing to ${logFile}`);
|
|
246
|
-
console.log(`[logger] dashboard at
|
|
327
|
+
console.log(`[logger] dashboard at ${getLoggerOrigin({ port })}/dashboard`);
|
|
247
328
|
console.log(`[logger] live view: tail -f ${logFile}`);
|
|
248
329
|
});
|
|
249
|
-
|
|
250
330
|
return server;
|
|
251
331
|
}
|
|
252
332
|
|
|
253
|
-
|
|
254
|
-
if (require.main === module) {
|
|
255
|
-
startLogger();
|
|
256
|
-
}
|
|
333
|
+
if (require.main === module) startLogger();
|
|
257
334
|
|
|
258
|
-
module.exports = { startLogger };
|
|
335
|
+
module.exports = { isProtectedOriginAllowed, readJsonBody, startLogger, summarizeDashboardEvent };
|