dsh-expert-group 0.1.1

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 (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Host 关注点模块:RPC transport 接线与传输加固。
3
+ *
4
+ * expand 阶段只接管"传输能力探测 + Connection 注册生命周期"这两个此前隐含在
5
+ * apply 里的步骤;业务 dispatch、HTTP envelope 与 legacy route 仍留在 index.js,
6
+ * 任务 06 再收缩。模块不读取全局单例,所有依赖由调用方注入。
7
+ *
8
+ * 除接线外,本模块还是两条传输共用的加固 seam:请求体的按类型字节预算与安全
9
+ * 响应收尾都从这里导出。legacy exact route、自持 channel 路由与 Connection 通道
10
+ * 接线后共用同一预算选择、同一结构化错误 code 与同一"最多写一次响应"的收敛规则。
11
+ *
12
+ * @module dsh-expert-group/host/rpc-transport
13
+ */
14
+ import { StringDecoder } from "node:string_decoder";
15
+
16
+ function describeError(error) {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+
20
+ /**
21
+ * 探测宿主是否提供可用的 Connection RPC 能力。
22
+ *
23
+ * 只有"注入上下文确实带 connection 属性"时才进入判定;属性存在但 API 缺失或
24
+ * 读取抛错都返回 unsupported,调用方必须 fail-closed,不能回退到未经认证的 legacy。
25
+ *
26
+ * @param {object} connectionCtx - ctx.inject(["connection"]) 回调收到的上下文。
27
+ * @returns {{status:"absent"|"unsupported"|"available", rpc?:object, reason?:string}}
28
+ */
29
+ export function probeConnectionRpc(connectionCtx) {
30
+ const hasConnection = connectionCtx !== null && connectionCtx !== undefined
31
+ && (Object.prototype.hasOwnProperty.call(connectionCtx, "connection") || "connection" in Object(connectionCtx));
32
+ if (!hasConnection) return { status: "absent", reason: "connection service is not injected" };
33
+ let rpc;
34
+ try {
35
+ rpc = connectionCtx?.connection?.rpc;
36
+ } catch (error) {
37
+ return { status: "unsupported", reason: "connection rpc unavailable: " + describeError(error) };
38
+ }
39
+ if (typeof rpc?.handle !== "function") {
40
+ return { status: "unsupported", reason: "connection rpc handle API unavailable" };
41
+ }
42
+ return { status: "available", rpc };
43
+ }
44
+
45
+ /**
46
+ * 创建一次 Connection 注册的生命周期记录。
47
+ *
48
+ * 释放优先跟随 Connection 服务生命周期(connectionCtx.effect);宿主未提供该
49
+ * 能力时回落到插件自身 fiber(fallbackEffect),保证卸载一定释放 handle。
50
+ * release 幂等:重复释放只生效一次,返回值可观察。
51
+ *
52
+ * @param {object} options
53
+ * @param {object} options.connectionCtx
54
+ * @param {Function} [options.fallbackEffect] - 插件自身 ctx.effect。
55
+ * @param {Function} options.release - 实际释放 handle 的回调。
56
+ * @param {string} [options.label] - effect 标签。
57
+ */
58
+ export function createConnectionRegistration({ connectionCtx, fallbackEffect, release, label } = {}) {
59
+ if (typeof release !== "function") throw new TypeError("createConnectionRegistration 需要 release 回调");
60
+ const effectLabel = typeof label === "string" && label.length > 0 ? label : "dsh-expert-group: connection rpc channel";
61
+ let released = false;
62
+ const runRelease = () => {
63
+ if (released) return false;
64
+ released = true;
65
+ release();
66
+ return true;
67
+ };
68
+ return Object.freeze({
69
+ get released() { return released; },
70
+ effectLabel,
71
+ /** 挂到可用 effect 作用域;返回 "connection" | "plugin"。 */
72
+ attach() {
73
+ if (released) throw new Error("connection registration 已释放,不能再次 attach");
74
+ if (typeof connectionCtx?.effect === "function") {
75
+ connectionCtx.effect(() => runRelease, effectLabel);
76
+ return "connection";
77
+ }
78
+ if (typeof fallbackEffect === "function") {
79
+ fallbackEffect(() => runRelease, effectLabel);
80
+ return "plugin";
81
+ }
82
+ throw new Error("没有可用的 effect 作用域,无法保证 Connection 注册被释放");
83
+ },
84
+ release: runRelease,
85
+ });
86
+ }
87
+
88
+ // ── 传输加固:请求体预算、早停与响应收尾 ─────────────────────────────────
89
+ /**
90
+ * 普通 JSON RPC 请求体上限(16KiB)。
91
+ *
92
+ * 与历史 `readBody` 默认值一致;导出为单一来源后,legacy 读 body、Connection
93
+ * payload 字节检查与自持 channel 路由不必各自硬编码同一个数字。
94
+ */
95
+ export const RPC_JSON_BODY_LIMIT = 16384;
96
+
97
+ /**
98
+ * image-route 请求体上限(32MiB)。
99
+ *
100
+ * 图片以 base64 传输,必须保留更大预算;但只有请求被判定为图片路由时才使用,
101
+ * 普通 JSON 请求不得借此放大读取窗口。
102
+ */
103
+ export const RPC_IMAGE_BODY_LIMIT = 32 * 1024 * 1024;
104
+
105
+ /**
106
+ * settings-commit 请求体上限(8MiB)。
107
+ *
108
+ * 设置保存是"整份候选"提交:六个角色的自定义提示词各自可达 32,000 个 grapheme,
109
+ * 因此候选体积必须按 **UTF-8 字节**而不是按「grapheme 数 × 固定字节数」估算——
110
+ * grapheme 与字节不是一回事:
111
+ * - 一个可见字符可能是 1 字节(ASCII)到 4 字节(CJK/emoji 基础码位);
112
+ * - 组合簇(例如 ZWJ 家庭 emoji,单个可见字符由 7 个 code point 组成)在 UTF-8 下
113
+ * 约 25 字节,**组合簇的字节数没有固定上界**(用户完全可以输入更长的 ZWJ 序列)。
114
+ *
115
+ * 实测:六角色各 32,000 个中文 grapheme 约 0.58MB,六角色各 32,000 个家庭 ZWJ
116
+ * emoji 约 4.8MB。8MiB 覆盖这两个常见形态并留出余量,同时仍远小于图片预算,因此
117
+ * 普通请求不会因为这一档而放大读取窗口;真正极端的组合簇(例如每个可见字符数百
118
+ * 字节)仍会被这一档拒绝为结构化 413,而不是被静默截断。
119
+ */
120
+ export const RPC_SETTINGS_COMMIT_BODY_LIMIT = 8 * 1024 * 1024;
121
+
122
+ /**
123
+ * 超限错误与声明探测共用的有界前缀长度。
124
+ *
125
+ * 插件客户端把 payload.action 放在请求体首位,合法声明必然落在窗口内;窗口有界则
126
+ * 保证普通请求不会为了探测而多读字节,也不会被正文深处的同名文本放大读取预算。
127
+ * 前缀同时用于让自持 channel 路由恢复 rpcId,因此它必须覆盖最大的 action 声明长度
128
+ * (settings-commit 比 image-route 更长)加上 JSON 空白余量。
129
+ */
130
+ const BOUNDED_PREFIX_LIMIT = RPC_JSON_BODY_LIMIT + 8192;
131
+
132
+ /**
133
+ * 请求体前缀里的 action 声明:只放大 JSON 空白,action 值本身是小写字面量加连字符。
134
+ *
135
+ * 只取**第一个**出现的字面量:客户端(含旧版 bundle)总把 payload.action 放在请求体
136
+ * 首位——图片路由是 `{action,sessionId,text,images}`,自持 channel 是
137
+ * `{type,rpcId,method,payload:{action,...}}`——因此第一个匹配就是本次请求的动作声明。
138
+ * 这一点是加固必需:用户正文里出现的同名文本(例如消息内容里写了
139
+ * `"action":"settings-commit"`)绝不能改写传输预算判定。
140
+ */
141
+ const RPC_ACTION_DECLARATION = /"action"\s*:\s*"([a-z0-9-]*)"/u;
142
+
143
+ /** 在响应对象上登记一次性 error 吸收器,避免重复登记同一个响应。 */
144
+ const responsesWithAbsorber = new WeakSet();
145
+
146
+ /** 已经成功收尾过的响应;即使替身没有 headersSent 也不能二次写。 */
147
+ const finalizedResponses = new WeakSet();
148
+
149
+ /**
150
+ * 请求体超限错误。
151
+ *
152
+ * code/status 与既有 mapRpcError 的输出一致(413 payload-too-large),因此 legacy
153
+ * HTTP 可以继续使用同一个结构化错误信封;自持 channel 路由可以用 error.prefix
154
+ * 恢复 rpcId 后回 server-response,两条传输的错误 code 仍然一致。
155
+ */
156
+ export class RpcBodyTooLargeError extends Error {
157
+ /**
158
+ * @param {object} [options]
159
+ * @param {number} [options.limit] - 触发拒绝的预算(字节)。
160
+ * @param {string} [options.prefix] - 已读取的有界请求体前缀。
161
+ * @param {string} [options.message]
162
+ */
163
+ constructor({ limit = RPC_JSON_BODY_LIMIT, prefix = "", message = "请求体超过允许上限" } = {}) {
164
+ super(message);
165
+ this.name = "RpcBodyTooLargeError";
166
+ this.code = "payload-too-large";
167
+ this.status = 413;
168
+ this.limit = limit;
169
+ this.prefix = prefix;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 识别请求体超限错误。
175
+ *
176
+ * 同时接受 name/code 判定:index.js 目前持有自己的 PayloadTooLargeError,接线时
177
+ * 只需把该错误替换成本模块的类(或共用 code),不需要改 mapRpcError 的形状。
178
+ *
179
+ * @param {unknown} error
180
+ * @returns {boolean}
181
+ */
182
+ export function isRpcBodyTooLargeError(error) {
183
+ return error instanceof RpcBodyTooLargeError
184
+ || (error?.code === "payload-too-large" && error?.status === 413);
185
+ }
186
+
187
+ /**
188
+ * 构造一次请求取消错误。
189
+ *
190
+ * 客户端在读完 body 前断开时以 AbortError/ABORT_ERR 收敛,调用方的 mapRpcError
191
+ * 据此映射 499 gateway/cancelled,而不是把 socket 错误当成 500 internal。
192
+ *
193
+ * @param {string} [message]
194
+ * @returns {Error}
195
+ */
196
+ export function createRpcAbortError(message = "请求已取消") {
197
+ const error = new Error(message);
198
+ error.name = "AbortError";
199
+ error.code = "ABORT_ERR";
200
+ return error;
201
+ }
202
+
203
+ /**
204
+ * 从一份(可能被截断的)请求体前缀里读出本次请求声明的 action。
205
+ *
206
+ * 只做文本探测:真正的 action 契约校验仍在业务 dispatch 内部。误判最坏只改变
207
+ * 读取窗口大小,不会绕过认证、ownership 或任何业务校验。
208
+ *
209
+ * @param {unknown} text
210
+ * @returns {string|undefined} 声明的小写 action;前缀里没有声明时返回 undefined。
211
+ */
212
+ export function declaredRpcActionFromText(text) {
213
+ if (typeof text !== "string") return void 0;
214
+ const match = RPC_ACTION_DECLARATION.exec(text);
215
+ return match === null ? void 0 : match[1];
216
+ }
217
+
218
+ /**
219
+ * 判断一份(可能被截断的)请求体前缀是否声明了图片路由。
220
+ *
221
+ * @param {unknown} text
222
+ * @returns {boolean}
223
+ */
224
+ export function isImageRpcBodyDeclared(text) {
225
+ return declaredRpcActionFromText(text) === "image-route";
226
+ }
227
+
228
+ /**
229
+ * 判断一份(可能被截断的)请求体前缀是否声明了 settings-commit。
230
+ *
231
+ * @param {unknown} text
232
+ * @returns {boolean}
233
+ */
234
+ export function isSettingsCommitRpcBodyDeclared(text) {
235
+ return declaredRpcActionFromText(text) === "settings-commit";
236
+ }
237
+
238
+ /**
239
+ * 按请求前缀声明的 action 选择读取预算。
240
+ *
241
+ * 三种预算互不重叠:普通 16KiB、settings-commit 8MiB、image-route 32MiB。
242
+ * **已解析的 action 对预算优先**:只有 image-route 能使用图片预算,settings-commit
243
+ * 与其它 action 各自收敛到自己的档位。options.imageRequest 是 legacy 专用头那样的
244
+ * 传输补丁,只在前缀**尚未声明任何 action** 时作为回退(保持旧客户端"只带头"的历史
245
+ * 行为),绝不能把一个已声明的 settings-commit 提升到 32MiB。
246
+ *
247
+ * @param {unknown} prefix - 有界请求体前缀。
248
+ * @param {{imageRequest?:boolean}} [options] - 传输层图片标记(legacy 专用头)。
249
+ * @returns {number} 字节预算。
250
+ */
251
+ export function rpcBodyLimitForDeclaredText(prefix, { imageRequest = false } = {}) {
252
+ const action = declaredRpcActionFromText(prefix);
253
+ if (action !== void 0) return rpcBodyLimitFor({ action });
254
+ return imageRequest === true ? RPC_IMAGE_BODY_LIMIT : RPC_JSON_BODY_LIMIT;
255
+ }
256
+
257
+ /**
258
+ * 选择一次请求的读取预算。
259
+ *
260
+ * 预算由**已解析的 action** 决定,imageRequest(legacy 专用头)只是 action 尚未解析
261
+ * 出来时的回退:
262
+ * - image-route 是唯一被允许使用图片预算的动作;
263
+ * - settings-commit 提交整份设置候选(六个角色各可达 32,000 grapheme 的提示词覆盖),
264
+ * 体积按 UTF-8 字节计量且组合簇没有固定上界,因此有独立档位;超限仍是结构化 413,
265
+ * 绝不截断;
266
+ * - 其它 action(含未知/非字符串声明)一律最小预算,图片头不得放大读取窗口。
267
+ *
268
+ * @param {object} [options]
269
+ * @param {boolean} [options.imageRequest] - 传输是否已声明图片请求(legacy 专用头)。
270
+ * @param {unknown} [options.action] - 已解码 payload 的 action(Connection 由 payload 推导)。
271
+ * @returns {number} 字节预算。
272
+ */
273
+ export function rpcBodyLimitFor({ imageRequest = false, action } = {}) {
274
+ if (action !== void 0 && action !== null) {
275
+ if (action === "image-route") return RPC_IMAGE_BODY_LIMIT;
276
+ if (action === "settings-commit") return RPC_SETTINGS_COMMIT_BODY_LIMIT;
277
+ return RPC_JSON_BODY_LIMIT;
278
+ }
279
+ return imageRequest === true ? RPC_IMAGE_BODY_LIMIT : RPC_JSON_BODY_LIMIT;
280
+ }
281
+
282
+ /** 超限后 drain 剩余字节:不使用 destroy,让连接可以安全复用。 */
283
+ function drainRequest(req) {
284
+ req.on("data", () => {});
285
+ req.on("end", () => {});
286
+ req.on("error", () => {});
287
+ req.resume?.();
288
+ }
289
+
290
+ /** 已解码 body(Connection intercept)也走同一预算,避免绕过旧 HTTP 限制。 */
291
+ function readProvidedBody(value, declaredImage) {
292
+ let raw;
293
+ try {
294
+ raw = typeof value === "string" ? value : JSON.stringify(value ?? {});
295
+ } catch (error) {
296
+ // 不可序列化的 payload 属于客户端输入问题,交给调用方按既有错误路径映射。
297
+ return Promise.reject(error);
298
+ }
299
+ const prefix = raw.slice(0, BOUNDED_PREFIX_LIMIT);
300
+ // 已解码 payload 的 action 就写在前缀里,因此预算选择与流式读取同源:已解析的
301
+ // action 优先于传输图片标记(settings-commit 不会被图片头提升到 32MiB)。
302
+ const limit = rpcBodyLimitForDeclaredText(prefix, { imageRequest: declaredImage });
303
+ if (Buffer.byteLength(raw) > limit) return Promise.reject(new RpcBodyTooLargeError({ limit, prefix }));
304
+ return Promise.resolve(raw);
305
+ }
306
+
307
+ /**
308
+ * 流式读取请求体,按预算早停。
309
+ *
310
+ * 普通 JSON 请求在超过 16KiB 时立即停止读取并拒绝,不再为普通请求分配图片级别的
311
+ * 32MiB 缓冲区;每读到一个 chunk 都按累积的有界前缀重算预算:声明了更大的 action
312
+ * (settings-commit 8MiB / image-route 32MiB)就升档,声明了更小的 action 就降档
313
+ * (伪造/过期的图片专用头不能让一次 settings 提交按 32MiB 读完)。超限与断开都会
314
+ * drain 剩余字节,避免 keep-alive socket 残留未读数据。
315
+ *
316
+ * 解码边界:req.setEncoding 只是可选调用(Connection payload 替身与宿主注入的请求
317
+ * 形状都没有它),因此 data 事件不一定已经是字符串。原始字节分片一律交给
318
+ * StringDecoder 跨 chunk 保留不完整序列,end 时冲刷尾部残字节——逐 chunk 的
319
+ * String(chunk) 会在多字节字符的字节中间插入 U+FFFD,破坏 JSON 文本与 rpcId 回显。
320
+ */
321
+ function readStreamedBody(req, declaredImage) {
322
+ return new Promise((resolve, reject) => {
323
+ const chunks = [];
324
+ let total = 0;
325
+ // 流式 utf8 解码器:只对 Buffer/TypedArray 形态的 chunk 生效,字符串 chunk 走原语义。
326
+ const decoder = new StringDecoder("utf8");
327
+ // 首个 data 事件之前的回退预算;每个 chunk 都会按有界前缀重算(见 onData)。
328
+ let limit = declaredImage ? RPC_IMAGE_BODY_LIMIT : RPC_JSON_BODY_LIMIT;
329
+ let settled = false;
330
+ // 有界前缀:小请求常常一次 data 就超限,超限的那一块也必须进入前缀,否则调用方
331
+ // 无法回显 rpcId。前缀长度固定上限,绝不随请求体增长。
332
+ let head = "";
333
+ const collectHead = (text) => {
334
+ const room = BOUNDED_PREFIX_LIMIT - head.length;
335
+ if (room > 0) head += text.slice(0, room);
336
+ };
337
+
338
+ const onData = (chunk) => {
339
+ if (settled) return;
340
+ let text;
341
+ let bytes;
342
+ if (typeof chunk === "string") {
343
+ // 宿主 setEncoding 生效:chunk 已按 utf8 解码。解码器里若有未冲刷的残字节
344
+ // (两种形态混流时的防御分支,真实流不会混用),先补回文本保证顺序,其字节数
345
+ // 在原始分片到达时已经计过账,这里只统计字符串本身的字节数。
346
+ text = decoder.end() + chunk;
347
+ bytes = Buffer.byteLength(chunk);
348
+ } else if (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {
349
+ // 无 setEncoding:原始字节。byteLength 直接取分片长度,与解码结果无关,
350
+ // 因此不完整字符被解码器扣下时预算仍然按真实收到的字节增长。
351
+ text = decoder.write(chunk);
352
+ bytes = chunk.byteLength;
353
+ } else {
354
+ // 非常规 chunk(自定义 toString 的替身):退回历史语义,不让解码器抛出
355
+ // TypeError 把一次 RPC 变成传输层 500。
356
+ text = String(chunk);
357
+ bytes = Buffer.byteLength(text);
358
+ }
359
+ // 前缀先于判定累积:有界前缀里的 action 声明是权威预算来源,它既能升档
360
+ // (16KiB → settings-commit 8MiB / image-route 32MiB),也能在伪造或过期的
361
+ // 图片专用头下**降档**(32MiB → settings-commit 8MiB);前缀长度固定上限,
362
+ // 绝不随请求体增长。
363
+ collectHead(text);
364
+ limit = rpcBodyLimitForDeclaredText(head, { imageRequest: declaredImage });
365
+ total += bytes;
366
+ if (total > limit) {
367
+ drainRequest(req);
368
+ finish(reject, new RpcBodyTooLargeError({ limit, prefix: head }));
369
+ return;
370
+ }
371
+ chunks.push(text);
372
+ };
373
+ // end 时先冲刷解码器尾部残字节,再拼接受限的分片文本,保证 body 与整段解码一致。
374
+ const onEnd = () => finish(resolve, chunks.join("") + decoder.end());
375
+ const onError = (error) => finish(reject, error);
376
+ const onAborted = () => {
377
+ const error = createRpcAbortError();
378
+ // body 尚未完成时仍保留有界前缀,调用方可以回显 client-request 的 rpcId。
379
+ error.prefix = head;
380
+ finish(reject, error);
381
+ };
382
+ const onClose = () => {
383
+ // close 也会在正常 body 读完后触发;只有未完整读取才算客户端提前断开。
384
+ if (req.complete === true) return;
385
+ const error = createRpcAbortError();
386
+ error.prefix = head;
387
+ finish(reject, error);
388
+ };
389
+ const cleanup = () => {
390
+ req.removeListener?.("data", onData);
391
+ req.removeListener?.("end", onEnd);
392
+ req.removeListener?.("error", onError);
393
+ req.removeListener?.("aborted", onAborted);
394
+ req.removeListener?.("close", onClose);
395
+ };
396
+ function finish(settle, value) {
397
+ if (settled) return;
398
+ settled = true;
399
+ cleanup();
400
+ settle(value);
401
+ }
402
+
403
+ // content-length 已超过图片上限时任何请求类型都不可能合法:立即拒绝并 drain,
404
+ // 不为一个必然失败的请求分配缓冲区。
405
+ const contentLength = Number(req.headers?.["content-length"]);
406
+ if (Number.isFinite(contentLength) && contentLength > RPC_IMAGE_BODY_LIMIT) {
407
+ drainRequest(req);
408
+ finish(reject, new RpcBodyTooLargeError({ limit: RPC_IMAGE_BODY_LIMIT, prefix: "" }));
409
+ return;
410
+ }
411
+ if (req.aborted === true) {
412
+ finish(reject, createRpcAbortError());
413
+ return;
414
+ }
415
+ req.on("data", onData);
416
+ req.on("end", onEnd);
417
+ req.on("error", onError);
418
+ req.on("aborted", onAborted);
419
+ req.on("close", onClose);
420
+ // 请求对象提供 setEncoding 时按 utf8 取字符串分片(历史 readBody 语义);缺失时
421
+ // 分片保持 Buffer,由上面 onData 里的 StringDecoder 负责跨 chunk 的字符边界。
422
+ req.setEncoding?.("utf8");
423
+ });
424
+ }
425
+
426
+ /**
427
+ * 读取一次 RPC 请求体,并按请求类型选择字节预算。
428
+ *
429
+ * 加固点(相对历史 readBody):
430
+ * 1. 普通 JSON 请求超过 16KiB 立即停止读取并拒绝,不再为普通请求分配 32MiB 级别
431
+ * 的缓冲区;只有前缀声明了 image-route 才升级到图片预算。
432
+ * 2. 超限与断开都会 drain 剩余字节,连接可以安全复用。
433
+ * 3. 客户端在读完前断开时以 AbortError(ABORT_ERR)拒绝,调用方映射 499 而不是 500。
434
+ *
435
+ * @param {object} req - Node IncomingMessage 形状;带已解码 `body` 的 Connection
436
+ * payload 替身同样受同一预算约束。
437
+ * @param {object} [options]
438
+ * @param {boolean} [options.imageRequest] - 传输是否已确定这是图片请求。
439
+ * @returns {Promise<string>} 原始请求体文本。
440
+ * @throws {RpcBodyTooLargeError} 超过所选预算。
441
+ */
442
+ export function readRpcRequestBody(req, options = {}) {
443
+ const declaredImage = (options ?? {}).imageRequest === true;
444
+ if (req !== null && typeof req === "object" && Object.prototype.hasOwnProperty.call(req, "body")) {
445
+ return readProvidedBody(req.body, declaredImage);
446
+ }
447
+ if (req === null || typeof req !== "object" || typeof req.on !== "function") {
448
+ return Promise.reject(new TypeError("readRpcRequestBody 需要可读的请求对象"));
449
+ }
450
+ return readStreamedBody(req, declaredImage);
451
+ }
452
+
453
+ /** 在响应对象上登记一次性 error 吸收器:断开后的写入错误不能冒泡成未处理异常。 */
454
+ function absorbResponseErrors(res) {
455
+ if (res === null || typeof res !== "object" || typeof res.on !== "function") return;
456
+ if (responsesWithAbsorber.has(res)) return;
457
+ responsesWithAbsorber.add(res);
458
+ res.on("error", () => {});
459
+ }
460
+
461
+ /**
462
+ * 响应或其底层 socket 是否已销毁。
463
+ *
464
+ * 客户端断开后 socket 先被销毁、响应对象可能还没标记 end;此时写入只会产生被丢弃
465
+ * 的字节与一次 error 事件,因此收尾必须直接判定为不可写。
466
+ */
467
+ function isResponseDestroyed(res) {
468
+ if (res === null || typeof res !== "object") return true;
469
+ if (res.destroyed === true) return true;
470
+ let socket;
471
+ try {
472
+ socket = res.socket;
473
+ } catch {
474
+ // 读取 socket 失败不改变可写性判定,交由后续写入的 try/catch 收敛。
475
+ return false;
476
+ }
477
+ return socket !== null && typeof socket === "object" && socket.destroyed === true;
478
+ }
479
+
480
+ /**
481
+ * 响应是否已经收尾:headers 已发出、已 end、或连接已销毁,此时绝不能再写 header。
482
+ *
483
+ * @param {unknown} res
484
+ * @returns {boolean} true 表示不能再写;非对象响应同样视为不可写。
485
+ */
486
+ export function isRpcResponseSettled(res) {
487
+ if (res === null || typeof res !== "object") return true;
488
+ return res.headersSent === true
489
+ || res.writableEnded === true
490
+ || res.writableFinished === true
491
+ || res.finished === true
492
+ || isResponseDestroyed(res);
493
+ }
494
+
495
+ /**
496
+ * 安全收尾一个 RPC HTTP 响应:最多写一次 header 与 body,任何写入异常都被吸收。
497
+ *
498
+ * 返回可观察结果而不是抛出。调用方(legacy exact route、自持 channel 路由、fence
499
+ * 拒绝)在客户端已断开、响应已结束或 socket 已销毁时不能二次写 header,也不能把
500
+ * ERR_HTTP_HEADERS_SENT / ERR_STREAM_WRITE_AFTER_END / EPIPE 变成未处理的 rejection。
501
+ *
502
+ * @param {object} res - ServerResponse 形状。
503
+ * @param {number} status - HTTP 状态码。
504
+ * @param {string|object} body - 字符串按 text/plain 原样写,其它按 JSON 信封写。
505
+ * @param {object} [options]
506
+ * @param {object} [options.headers] - 追加/覆盖的响应头。
507
+ * @returns {{written:boolean, reason:"written"|"already-settled"|"destroyed"|"serialize-failed"|"write-failed"}}
508
+ */
509
+ export function finalizeRpcResponse(res, status, body, { headers } = {}) {
510
+ const isText = typeof body === "string";
511
+ if (isRpcResponseSettled(res) || finalizedResponses.has(res)) {
512
+ return { written: false, reason: isResponseDestroyed(res) ? "destroyed" : "already-settled" };
513
+ }
514
+ absorbResponseErrors(res);
515
+ let payload;
516
+ try {
517
+ payload = isText ? body : JSON.stringify(body);
518
+ } catch {
519
+ // 响应体不可序列化(例如循环引用):只报告失败,绝不抛出。
520
+ return { written: false, reason: "serialize-failed" };
521
+ }
522
+ if (payload === undefined) payload = "";
523
+ const baseHeaders = isText
524
+ ? { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }
525
+ : { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
526
+ try {
527
+ res.writeHead(status, { ...baseHeaders, ...(headers ?? {}) });
528
+ res.end(payload);
529
+ } catch {
530
+ // socket 竞态(已销毁/已 end)只影响本次响应,不能再次抛出。
531
+ return { written: false, reason: "write-failed" };
532
+ }
533
+ finalizedResponses.add(res);
534
+ return { written: true, reason: "written" };
535
+ }
536
+
537
+ /**
538
+ * RPC transport 关注点模块。
539
+ *
540
+ * @param {object} options
541
+ * @param {string} options.path - legacy exact route 路径。
542
+ * @param {string} options.channel - Connection 逻辑频道。
543
+ * @param {string} options.endpoint - Connection endpoint 名。
544
+ */
545
+ export function createRpcTransportModule({ path, channel, endpoint } = {}) {
546
+ for (const [name, value] of [["path", path], ["channel", channel], ["endpoint", endpoint]]) {
547
+ if (typeof value !== "string" || value.length === 0) {
548
+ throw new TypeError("createRpcTransportModule 需要非空 " + name);
549
+ }
550
+ }
551
+ return Object.freeze({
552
+ path,
553
+ channel,
554
+ endpoint,
555
+ probe: probeConnectionRpc,
556
+ createRegistration: (options) => createConnectionRegistration(options),
557
+ // 传输加固 seam:index.js 的 legacy route 与自持 channel 路由都从这里取,
558
+ // 保证两条传输共用同一预算选择与同一个不会二次写响应的收尾函数。
559
+ // bodyLimits 的键集是已锁定的历史契约(恰好 json/image 两档),
560
+ // settings-commit 档位不在这里新增键,而是通过下面的 limitFor 暴露。
561
+ bodyLimits: Object.freeze({ json: RPC_JSON_BODY_LIMIT, image: RPC_IMAGE_BODY_LIMIT }),
562
+ readBody: readRpcRequestBody,
563
+ // 业务 dispatch 的已解码 payload 字节检查与传输层读取共用同一预算选择函数
564
+ // (普通 16KiB / settings-commit 8MiB / image-route 32MiB),两条传输与两层
565
+ // 检查因此不可能各自漂移出一个不同的上限。
566
+ limitFor: rpcBodyLimitFor,
567
+ finalizeResponse: finalizeRpcResponse,
568
+ isResponseSettled: isRpcResponseSettled,
569
+ isBodyTooLarge: isRpcBodyTooLargeError,
570
+ describe: () => ({ path, channel, endpoint }),
571
+ dispose() {},
572
+ });
573
+ }