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.
Files changed (50) hide show
  1. package/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
  2. package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
  3. package/enhancements/MANIFEST.json +2 -2
  4. package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -32
  5. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
  6. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
  7. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
  8. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
  9. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +103 -212
  10. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -179
  11. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
  12. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
  13. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
  14. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
  15. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
  16. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
  17. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
  18. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  19. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
  20. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
  21. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
  22. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
  23. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +107 -40
  24. package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -32
  25. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
  26. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
  27. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
  28. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
  29. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +103 -212
  30. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -179
  31. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
  32. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
  33. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
  34. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
  35. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
  36. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
  37. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
  38. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  39. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
  40. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
  41. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
  42. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
  43. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +107 -40
  44. package/package.json +3 -3
  45. package/src/builtin-plugins/skill-garden/content-adapter.js +97 -4
  46. package/src/commands/init.js +13 -2
  47. package/src/commands/plugin.js +8 -4
  48. package/src/commands/update.js +7 -1
  49. package/src/lib/banner.js +10 -1
  50. package/src/lib/command-completion.js +43 -0
@@ -1,258 +1,335 @@
1
1
  /**
2
- * 本地日志收集服务
2
+ * 本地日志与浏览器控制服务。
3
3
  *
4
- * 职责:
5
- * 接收浏览器注入脚本通过 fetch / sendBeacon 发来的事件,
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
- const DEFAULT_PORT = 7777;
19
- const DEFAULT_LOG_FILE = path.join(__dirname, 'session.jsonl');
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
- * 既可作为独立脚本运行(node logger.js),
25
- * 也可被 launch.js 通过 require 调用,从而实现"一条命令同时起日志和浏览器"。
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=7777] 监听端口
29
- * @param {string} [options.logFile] 日志文件绝对路径,默认 ./session.jsonl
30
- * @param {Object} [options.browserController] 浏览器控制器,传入后开启 /control/* 接口
31
- * 让 Dashboard 直接控制浏览器(打开 URL、切 tab、关闭、刷新等)。
32
- * 需要提供方法:open/listPages/close/focus/reload/back/forward/navigate
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 || DEFAULT_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
- * @param {Array<Object>} events 事件数组,每个事件会单独成行
137
+ * 追加一批事件,每个对象独占一行。
138
+ *
139
+ * @param {Array<Object>|Object} input 单条事件或事件数组。
140
+ * @return {void}
43
141
  */
44
- function appendEvents(events) {
45
- // 给每条事件补一个服务端时间戳,用于和客户端时间对齐排查时钟漂移
46
- const lines = events
47
- .map(e => JSON.stringify({ ...e, serverTime: new Date().toISOString() }))
48
- .join('\n') + '\n';
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
- * 读取 POST 请求的 JSON body
56
- * @param {http.IncomingMessage} req
57
- * @return {Promise<Object>}
157
+ * 读取全部有效 JSONL 事件。
158
+ *
159
+ * @return {Array<Object>} 事件列表。
58
160
  */
59
- function readJsonBody(req) {
60
- return new Promise((resolve, reject) => {
61
- let body = '';
62
- req.on('data', c => { body += c; });
63
- req.on('end', () => {
64
- if (!body) return resolve({});
65
- try { resolve(JSON.parse(body)); }
66
- catch (e) { reject(e); }
67
- });
68
- req.on('error', reject);
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
- * 处理 /control/* 路由:把请求转发给 browserController
74
- * 这是 Dashboard 控制浏览器的唯一通道
75
- * @param {string} action 动作名(去掉 /control/ 前缀后的部分)
76
- * @param {http.IncomingMessage} req
77
- * @param {http.ServerResponse} res
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
- async function handleControl(action, req, res) {
80
- if (!browserController) {
81
- res.writeHead(503, { 'Content-Type': 'application/json' });
82
- res.end(JSON.stringify({ error: 'browser controller 未配置(独立运行 logger 不支持控制)' }));
83
- return;
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
- try {
86
- const body = req.method === 'POST' ? await readJsonBody(req) : {};
87
- let result;
88
- switch (action) {
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
- * @return {Array<Object>}
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 readAllEvents() {
117
- if (!fs.existsSync(logFile)) return [];
118
- const lines = fs.readFileSync(logFile, 'utf8').split('\n');
119
- const events = [];
120
- for (const line of lines) {
121
- if (!line.trim()) continue;
122
- try { events.push(JSON.parse(line)); } catch (e) { /* skip malformed */ }
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
- // CORS:注入脚本会跑在任意目标网站,必须支持跨域
129
- //
130
- // 关键:不能用通配符 '*',因为 sendBeacon / 某些带 cookie 的 fetch
131
- // 会以 credentials='include' 发请求,浏览器规定此场景下
132
- // Access-Control-Allow-Origin 必须是具体 origin,且必须配合 Allow-Credentials=true。
133
- // 解决方案:把请求方的 Origin 原样回显,并允许携带 credentials。
134
- // Vary: Origin 告知缓存层"响应内容随 Origin 变化",避免缓存串号。
135
- const reqOrigin = req.headers.origin || '*';
136
- res.setHeader('Access-Control-Allow-Origin', reqOrigin);
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
- res.writeHead(204);
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
- // /control/* :浏览器控制接口(POST 操作 / GET 查询)
166
- if (pathname.startsWith('/control/')) {
167
- const action = pathname.replace('/control/', '');
168
- handleControl(action, req, res);
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(html);
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 limit = parseInt(reqUrl.searchParams.get('limit'), 10);
190
- let events = readAllEvents();
191
- if (since) events = events.filter(e => e.serverTime > since);
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.writeHead(200, { 'Content-Type': 'application/json' });
194
- res.end(JSON.stringify(events));
281
+ writeJson(res, 200, events);
195
282
  return;
196
283
  }
197
-
198
- // GET / 或 /health:健康检查 + 入口链接
199
- if (req.method === 'GET') {
200
- res.writeHead(200, { 'Content-Type': 'application/json' });
201
- res.end(JSON.stringify({
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: `http://localhost:${port}/dashboard`,
206
- events: `http://localhost:${port}/events`,
207
- log: `http://localhost:${port}/log (POST)`,
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
- // 监听 0.0.0.0 而非 127.0.0.1:
240
- // WSL2 的 NAT 模式下,绑定 127.0.0.1 时 Windows 主机访问会走奇怪路径,
241
- // 可能导致同源判断失败 / CORS 报错。绑 0.0.0.0 让 Windows 主机能直接访问。
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 http://localhost:${port}/dashboard`);
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
- // 作为独立脚本运行时直接启动;被 require 时只暴露函数
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 };