@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0
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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 响应工具(Node 后端的 `server.py:_respond()` / `_redirect()` 对应物)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么统一走这里:Python 侧所有响应都由 `_respond()` 出,因此**每个**响应都带
|
|
5
|
+
* Content-Type、Content-Length、Cache-Control: no-store(前端与 e2e 脚本已按此预期写)。
|
|
6
|
+
* Node 侧若各端点自己 writeHead,这三条一定会漏 —— 对拍时就是成片的假差异。
|
|
7
|
+
*
|
|
8
|
+
* JSON 正文自 2026-10-11(M1 评审 P3-1)起用 `pyJsonDumps` 序列化:与 Python
|
|
9
|
+
* `json.dumps(obj, ensure_ascii=False)` **逐字节相同**(含 `", "` / `": "` 分隔符与中文原样)。
|
|
10
|
+
* 为什么值得:整套改造的哲学是字节级对齐(连数据层的 JSON 列都为此刻了 `pyjson.js`),
|
|
11
|
+
* 而 M3 起要一次补几十个读端点——口径现在就定死,省得铺开后再回头改。
|
|
12
|
+
* `pyJsonDumps` 对非普通 JSON 值(undefined/函数/Date/Map…)抛 TypeError:那种值本就是
|
|
13
|
+
* 编码错误,这里兜底退回 `JSON.stringify` 并留一行日志,绝不让一个字段把响应打成 500。
|
|
14
|
+
*/
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
import { pyJsonDumps } from './pyjson.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Python `int(text)` 的严格版(只认「两侧 ASCII 空白 + 可选正负号 + ASCII 数字(可带下划线分隔)」)。
|
|
21
|
+
* 解析失败**抛错**——这正是 `_read_json` 里 `int(self.headers.get("Content-Length", 0) or 0)`
|
|
22
|
+
* 在收到非数字长度时的行为(ValueError 逃出 do_POST ⇒ 该连接无响应即断)。
|
|
23
|
+
* Unicode 数字/空白等边角与 `query.js:pyIntStrict` 同一处理口径(前端不产生,见 M3 记录)。
|
|
24
|
+
*/
|
|
25
|
+
function pyIntStrict(text) {
|
|
26
|
+
const body = String(text).replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, '');
|
|
27
|
+
if (!/^[+-]?\d(?:_?\d)*$/.test(body)) {
|
|
28
|
+
throw new Error(`invalid literal for int() with base 10: ${JSON.stringify(String(text))}`);
|
|
29
|
+
}
|
|
30
|
+
return Number(body.replaceAll('_', ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 读请求体(Python `server.py:_read_json`(5256) 的对应物)——**每个写端点都必须走它**。
|
|
35
|
+
*
|
|
36
|
+
* 逐条对齐 Python(任一条不同就是 bug,实例级对拍会照出来):
|
|
37
|
+
* 1. `Content-Length` 缺失或 ≤0 ⇒ 空对象,**不读流**;
|
|
38
|
+
* 2. 长度头存在但不是十进制整数 ⇒ `int()` 抛错(调用方不补响应,连接直接断——Python 同形);
|
|
39
|
+
* 3. 读满 `length` 字节后 `JSON.parse`:解析失败(含 UTF-8 解码失败)⇒ 空对象;
|
|
40
|
+
* 4. 合法 JSON 但**不是普通对象**(数组/字符串/数字/布尔/null)⇒ 空对象。Python 注释写明理由:
|
|
41
|
+
* 各接口对 `data.get(...)` 无类型防护,非 dict 会抛 AttributeError 断连;
|
|
42
|
+
* 5. 客户端提前断开导致字节不足 ⇒ 按已读内容解析(Python `rfile.read(n)` 返回短读,同形)。
|
|
43
|
+
*
|
|
44
|
+
* **必须 await**:不 await 时解析异常会变成 unhandledRejection 把整个后端带走(M3 的教训:
|
|
45
|
+
* Node 侧没有 socketserver 那种「只断一条连接」的天然兜底,这是 `dispatchSafely` 存在的理由)。
|
|
46
|
+
*
|
|
47
|
+
* 关于「读体的时机」:Python 在 `do_POST` 里**固定一处**读体(1027→1048),顺序是
|
|
48
|
+
* 鉴权 → 强制改密闸 → logout/change_password(**不读体**)→ 读体 → 各端点。
|
|
49
|
+
* Node 侧因此**不许在 server.js 里无条件预读**:由 `ctx.readBody()` 记忆化,各 handler
|
|
50
|
+
* 在 Python 读体的同一位置调用;未命中路径由 server.js 在回 404 之前补读一次(同 Python)。
|
|
51
|
+
*
|
|
52
|
+
* @param {object} req node:http IncomingMessage
|
|
53
|
+
* @returns {Promise<object>} 普通对象(解析不出就是空对象)
|
|
54
|
+
*/
|
|
55
|
+
export function readJsonBody(req) {
|
|
56
|
+
const header = req?.headers?.['content-length'];
|
|
57
|
+
const text = header === undefined || header === null || header === '' ? '0' : String(header);
|
|
58
|
+
let length;
|
|
59
|
+
try {
|
|
60
|
+
length = pyIntStrict(text); // 非数字 ⇒ 抛(见 ②)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// **同步抛改成 rejected promise**:契约是「本函数永远返回 promise」,这样
|
|
63
|
+
// `ctx.readBody()` 的记忆化对失败路径也成立(同步抛会让它每次调用都重新抛、
|
|
64
|
+
// 且 `p1 === p2` 这条不变量失效)。
|
|
65
|
+
return Promise.reject(error);
|
|
66
|
+
}
|
|
67
|
+
if (!(length > 0)) return Promise.resolve({});
|
|
68
|
+
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let received = 0;
|
|
72
|
+
let settled = false;
|
|
73
|
+
const finish = () => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
// UTF-8 解码必须**严格**:Python `rfile.read(n).decode("utf-8")` 遇非法字节抛
|
|
77
|
+
// UnicodeDecodeError ⇒ 被 `except (ValueError, UnicodeDecodeError)` 吃成空对象。
|
|
78
|
+
// Node 的 `buf.toString('utf8')` 会把非法字节**替换成 U+FFFD 而不报错**——那样
|
|
79
|
+
// `{"a":"<非法字节>"}` 会解析成功并返回带替换符的对象,与 Python 的「空对象」分叉。
|
|
80
|
+
let text2;
|
|
81
|
+
try {
|
|
82
|
+
text2 = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
83
|
+
} catch {
|
|
84
|
+
resolve({});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let value = null;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(text2);
|
|
90
|
+
} catch {
|
|
91
|
+
value = null;
|
|
92
|
+
}
|
|
93
|
+
// ④ 非普通对象(含 null/数组)一律空对象;`typeof null === 'object'` 要显式挡掉
|
|
94
|
+
const plain = value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
95
|
+
resolve(plain ? value : {});
|
|
96
|
+
};
|
|
97
|
+
req.on('data', (chunk) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
let buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
100
|
+
// **只取前 `length` 字节**(= Python `rfile.read(length)`):多出来的属于下一个请求
|
|
101
|
+
// (pipelines/keep-alive),不能混进本次正文。单测「只读 Content-Length 字节」钉住它。
|
|
102
|
+
const left = length - received;
|
|
103
|
+
if (buf.length > left) buf = buf.subarray(0, left);
|
|
104
|
+
chunks.push(buf);
|
|
105
|
+
received += buf.length;
|
|
106
|
+
if (received >= length) finish();
|
|
107
|
+
});
|
|
108
|
+
req.on('end', finish);
|
|
109
|
+
req.on('close', finish); // 提前断开:按已读内容解析(见 ⑤)
|
|
110
|
+
req.on('error', (error) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
reject(error);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** JSON 正文序列化:优先与 Python 逐字节一致,编码不了才退回 JS 原生(带日志) */
|
|
119
|
+
function jsonBody(value) {
|
|
120
|
+
try {
|
|
121
|
+
return pyJsonDumps(value);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`touchstone-node: JSON 正文无法按 Python 口径序列化(退回 JSON.stringify): `
|
|
124
|
+
+ `${(error && error.message) || error}`);
|
|
125
|
+
return JSON.stringify(value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 发一个完整响应(与 Python `_respond` 逐项等价)。
|
|
131
|
+
* @param {object} res node:http 的 ServerResponse(测试里用同形状的桩)
|
|
132
|
+
* @param {number} status 状态码
|
|
133
|
+
* @param {Buffer|string} body 正文(字符串按 UTF-8 计长度)
|
|
134
|
+
* @param {string} contentType
|
|
135
|
+
* @param {object} [extraHeaders]
|
|
136
|
+
*/
|
|
137
|
+
export function respond(res, status, body, contentType, extraHeaders) {
|
|
138
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
139
|
+
const headers = {
|
|
140
|
+
'Content-Type': contentType,
|
|
141
|
+
'Content-Length': String(buf.length),
|
|
142
|
+
'Cache-Control': 'no-store',
|
|
143
|
+
};
|
|
144
|
+
for (const [name, value] of Object.entries(extraHeaders || {})) headers[name] = value;
|
|
145
|
+
res.writeHead(status, headers);
|
|
146
|
+
try {
|
|
147
|
+
res.end(buf);
|
|
148
|
+
} catch {
|
|
149
|
+
// 客户端提前断开(刷新/关页)属常态,与 Python 侧吞 BrokenPipe 同口径
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** JSON 响应(Content-Type 固定 application/json; charset=utf-8,与 Python 侧同;正文为 Python 口径) */
|
|
154
|
+
export function respondJson(res, status, value, extraHeaders) {
|
|
155
|
+
respond(res, status, jsonBody(value), 'application/json; charset=utf-8', extraHeaders);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 错误响应 —— **紧凑** JSON:`{"error":"..."}`(冒号后**没有**空格)。
|
|
160
|
+
*
|
|
161
|
+
* 为什么不能直接用 `respondJson`:Python 侧的错误体全部是**硬编码字节串**
|
|
162
|
+
* (`b'{"error":"not found"}'`、`b'{"error":"task not found"}'`…),走的是字面量而不是
|
|
163
|
+
* `json.dumps`;而 `json.dumps` 的缺省分隔符是 `(', ', ': ')` ⇒ `{"error": "..."}` 多一个空格。
|
|
164
|
+
* 两者在同一份源码里并存,所以 Node 侧必须分两个函数:**错误体紧凑、数据体带空格**。
|
|
165
|
+
* 这条系统性差异由 M3 的任务族实现者实测抓出(15 处全中),否则整族错误响应都会差 1 字节。
|
|
166
|
+
*/
|
|
167
|
+
export function respondError(res, status, message, extraHeaders) {
|
|
168
|
+
// message 仍走 pyJsonDumps(字符串转义与 Python 逐字节一致),外层括号与冒号手工拼
|
|
169
|
+
respond(res, status, `{"error":${jsonBody(String(message))}}`,
|
|
170
|
+
'application/json; charset=utf-8', extraHeaders);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 错误响应 —— **带空格**的 JSON:`{"error": "..."}`。
|
|
175
|
+
*
|
|
176
|
+
* 为什么错误体要有两个函数:Python 侧的错误正文**并存两种形态,取决于写点**(M3 项目族
|
|
177
|
+
* 实现者逐字节实测确认):
|
|
178
|
+
* - 硬编码字节串(`b'{"error":"not found"}'`,112 处)⇒ 紧凑,用 `respondError`;
|
|
179
|
+
* - `json.dumps({"error": ...})`(49 处,如 `_api_fs_browse`:2350、`_api_project_file`:2392/2402、
|
|
180
|
+
* `_send_project_file_raw`:2425/2430、项目名/归档/prefs 的 400…)⇒ 缺省分隔符带空格,用本函数。
|
|
181
|
+
* 两类都「正确」,差别只在 Python 源码怎么写的;Node 侧必须按端点选对,否则每个点差 1 字节。
|
|
182
|
+
* **判断方法**:去 `server.py` 看该处 `_respond` 的第二个参数是字节串字面量还是 `json.dumps(...)`。
|
|
183
|
+
*/
|
|
184
|
+
export function respondErrorSpaced(res, status, message, extraHeaders) {
|
|
185
|
+
respondJson(res, status, { error: String(message) }, extraHeaders);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { jsonBody };
|
|
189
|
+
|
|
190
|
+
/** 302 重定向(与 Python `_redirect` 一致:只有 Location 与 Content-Length: 0) */
|
|
191
|
+
export function sendRedirect(res, location) {
|
|
192
|
+
res.writeHead(302, { Location: location, 'Content-Length': '0' });
|
|
193
|
+
res.end();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 发一个磁盘文件(Python `_send_file`):读得到 → 200 + 调用方给的 Content-Type;
|
|
198
|
+
* 读不到(`OSError`)→ **404 `not found` + text/plain**(注意不是 JSON、也不是空体)。
|
|
199
|
+
*
|
|
200
|
+
* 为什么单独抽出来:M3 有三个端点走它(看板卡片附件 `/board/media/<fid>`、
|
|
201
|
+
* 会话图片、项目文件 raw),三处都要同一条「读不到就 404 文本」语义;Python 侧是
|
|
202
|
+
* 同一个 `_send_file`,Node 侧分头写必然有人写成 JSON 404。
|
|
203
|
+
*/
|
|
204
|
+
export async function sendFile(res, filePath, contentType) {
|
|
205
|
+
try {
|
|
206
|
+
const data = await readFile(filePath);
|
|
207
|
+
respond(res, 200, data, contentType);
|
|
208
|
+
} catch {
|
|
209
|
+
respond(res, 404, 'not found', 'text/plain; charset=utf-8');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Python `BaseHTTPRequestHandler.send_error(501)` 的**逐字节**复刻(Node 版没有 do_* 的
|
|
215
|
+
* 方法走它:HEAD / PUT / OPTIONS / TRACE …)。
|
|
216
|
+
*
|
|
217
|
+
* 为什么要复刻而不是「回个 501 就完事」:Python 侧的 501 不是 JSON,而是 `send_error`
|
|
218
|
+
* 生成的一张 HTML 错误页,Content-Type 是 `text/html;charset=utf-8`(**分号后没有空格**)。
|
|
219
|
+
* 对拍比的是「状态码 + Content-Type + 正文哈希」,随手写成 `text/plain` + 一句
|
|
220
|
+
* `unsupported method` 就会在方法语义上留下一类永久差异,逼着报告里挂一条白名单。
|
|
221
|
+
* 复刻成本十几行,收益是这类差异彻底消失——**白名单只留真正无法消除的**。
|
|
222
|
+
*
|
|
223
|
+
* 两个细节照抄:HEAD 请求**不写正文**(Python `send_error` 里 `if self.command != 'HEAD'`),
|
|
224
|
+
* 正文里的方法名是请求方法原文(如 `Unsupported method ('OPTIONS')`)。
|
|
225
|
+
* 模板与文案取自真机实测(2026-10-11,Python 3.12.14,见 M3 记录)。
|
|
226
|
+
*/
|
|
227
|
+
export function respondUnsupportedMethod(res, method) {
|
|
228
|
+
const body = `<!DOCTYPE HTML>
|
|
229
|
+
<html lang="en">
|
|
230
|
+
<head>
|
|
231
|
+
<meta charset="utf-8">
|
|
232
|
+
<title>Error response</title>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<h1>Error response</h1>
|
|
236
|
+
<p>Error code: 501</p>
|
|
237
|
+
<p>Message: Unsupported method ('${method}').</p>
|
|
238
|
+
<p>Error code explanation: 501 - Server does not support this operation.</p>
|
|
239
|
+
</body>
|
|
240
|
+
</html>
|
|
241
|
+
`;
|
|
242
|
+
const buf = Buffer.from(String(method).toUpperCase() === 'HEAD' ? '' : body, 'utf8');
|
|
243
|
+
res.writeHead(501, {
|
|
244
|
+
'Content-Type': 'text/html;charset=utf-8',
|
|
245
|
+
'Content-Length': String(buf.length),
|
|
246
|
+
Connection: 'close',
|
|
247
|
+
});
|
|
248
|
+
try {
|
|
249
|
+
res.end(buf);
|
|
250
|
+
} catch {
|
|
251
|
+
// 对端已断开:忽略(与其他响应写口同口径)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 打开一条 SSE 流并返回写口(Python 侧三条事件流的公共头部 + 写帧形态)。
|
|
257
|
+
*
|
|
258
|
+
* 头部逐条对齐 Python:`Content-Type: text/event-stream; charset=utf-8` +
|
|
259
|
+
* `Cache-Control: no-store`;`X-Accel-Buffering: no` 只有看板那两条流带
|
|
260
|
+
* (`server.py:5052/5124`),`/api/stream` 与任务会话流不带 —— 这里用参数区分,
|
|
261
|
+
* 免得「顺手统一」把头部差异带进对拍。
|
|
262
|
+
*
|
|
263
|
+
* 写口三件事都**逐字节**照 Python:`event:` 行 + `data:` 行 + 空行;注释帧
|
|
264
|
+
* (keepalive)只有一行 `: xxx` + 空行;`id:` 行仅任务会话流的 entries 帧用。
|
|
265
|
+
* 写失败(客户端断开)一律吞掉并让调用方收尾 —— SSE 断连不是业务错误。
|
|
266
|
+
*/
|
|
267
|
+
export function openSse(res, { accelBuffering = false } = {}) {
|
|
268
|
+
const headers = {
|
|
269
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
270
|
+
'Cache-Control': 'no-store',
|
|
271
|
+
};
|
|
272
|
+
if (accelBuffering) headers['X-Accel-Buffering'] = 'no';
|
|
273
|
+
res.writeHead(200, headers);
|
|
274
|
+
res.flushHeaders?.();
|
|
275
|
+
const writeRaw = (text) => {
|
|
276
|
+
try {
|
|
277
|
+
res.write(text);
|
|
278
|
+
return true;
|
|
279
|
+
} catch {
|
|
280
|
+
return false; // 对端已断开:调用方按循环退出处理
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
/** 一条命名事件(`event: <name>\ndata: <json>\n\n`),payload 按 Python 口径序列化 */
|
|
285
|
+
event(name, payload) {
|
|
286
|
+
return writeRaw(`event: ${name}\ndata: ${jsonBody(payload)}\n\n`);
|
|
287
|
+
},
|
|
288
|
+
/** 带 id 的命名事件(任务会话流 entries 帧用:`id: <n>\n`) */
|
|
289
|
+
eventWithId(name, id, payload) {
|
|
290
|
+
return writeRaw(`id: ${id}\nevent: ${name}\ndata: ${jsonBody(payload)}\n\n`);
|
|
291
|
+
},
|
|
292
|
+
/** 裸 data 行(`/api/stream` 的旧形态:只有 data、没有 event 名) */
|
|
293
|
+
data(payload) {
|
|
294
|
+
return writeRaw(`data: ${jsonBody(payload)}\n\n`);
|
|
295
|
+
},
|
|
296
|
+
/** 注释帧(keepalive),如 `: keepalive` / `: hb` */
|
|
297
|
+
comment(text) {
|
|
298
|
+
return writeRaw(`: ${text}\n\n`);
|
|
299
|
+
},
|
|
300
|
+
/** 未编码的原始文本(`_stream_note` 那种整段预编码载荷用) */
|
|
301
|
+
raw(text) {
|
|
302
|
+
return writeRaw(text);
|
|
303
|
+
},
|
|
304
|
+
end() {
|
|
305
|
+
try {
|
|
306
|
+
res.end();
|
|
307
|
+
} catch {
|
|
308
|
+
// 已经断开:忽略
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|