@myassis/gateway 1.0.85 → 1.0.87

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.
@@ -0,0 +1,526 @@
1
+ "use strict";
2
+ /**
3
+ * ⚠️ 此文件由脚本自动生成,请勿手动编辑!
4
+ *
5
+ * 源文件: shared/src/relay/protocol.ts
6
+ * 生成方式:node scripts/sync-shared-source.js
7
+ *
8
+ * 如需修改,请编辑上述源文件后重新运行同步脚本。
9
+ * CI 会校验本文件与源文件的一致性,不一致将导致构建失败。
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.stripHopByHopHeaders = exports.checkGatewayFeatures = exports.checkProtocolVersion = exports.ReceiveWindow = exports.SendWindow = exports.StreamIdAllocator = exports.decodeWsData = exports.encodeWsDataFrames = exports.MAX_WS_DATA_CHUNK = exports.WS_DATA_PREFIX_SIZE = exports.WsDataKind = exports.splitIntoFrames = exports.decodeJsonPayload = exports.decodeFrame = exports.encodeJsonFrame = exports.encodeFrame = exports.RelayProtocolError = exports.AbortReason = exports.isControlFrame = exports.isKnownFrameType = exports.frameTypeName = exports.FrameType = exports.CONTROL_STREAM_ID = exports.PING_TIMEOUT_MS = exports.PING_INTERVAL_MS = exports.MAX_CONCURRENT_STREAMS = exports.INITIAL_WINDOW_SIZE = exports.MAX_FRAME_PAYLOAD = exports.FRAME_HEADER_SIZE = exports.RELAY_REQUIRED_FEATURE = exports.RELAY_MIN_PROTOCOL_VERSION = exports.RELAY_PROTOCOL_VERSION = void 0;
13
+ /**
14
+ * 中继帧协议(单源实现)
15
+ * ============================================================
16
+ * Gateway 与 Server 之间用一条 WSS 长连接承载多路 HTTP/WebSocket 流量,
17
+ * 因此需要一层帧协议做多路复用、流控与生命周期管理。
18
+ *
19
+ * 设计约束(来自 docs/RELAY_MODE.md):
20
+ * 1. 帧必须能逐块转发,不得为了拼完整响应而缓冲 —— 否则 SSE 首字延迟被拖长。
21
+ * 2. 必须支持信用式流控,避免单个大上传/长响应打爆中继内存。
22
+ * 3. 必须携带协议版本,网关分布在用户机器上无法强制升级。
23
+ *
24
+ * 【本文件是单源,不从 shared/src/index.ts 导出】
25
+ * @myassis/shared 是以已发布包被消费的(gateway 钉 1.0.19、server 钉 1.0.24),
26
+ * 若同时可从包内导入,就会出现「包版本」与「同步副本」两个来源而重新引入漂移。
27
+ * 各端一律使用 scripts/sync-shared-source.js 生成的本地副本,CI 校验一致性。
28
+ *
29
+ * 帧结构(大端):
30
+ * +--------+------------------+------------------+
31
+ * | type | streamId | payload |
32
+ * | 1 byte | 4 bytes (uint32) | 变长 |
33
+ * +--------+------------------+------------------+
34
+ *
35
+ * streamId 为 0 表示连接级控制帧(HELLO/PING 等),与具体流无关。
36
+ */
37
+ // ============ 常量 ============
38
+ /** 当前帧协议版本 */
39
+ exports.RELAY_PROTOCOL_VERSION = 1;
40
+ /** Server 可接受的最低网关协议版本(低于此值要求用户升级网关) */
41
+ exports.RELAY_MIN_PROTOCOL_VERSION = 1;
42
+ /**
43
+ * 中继模式要求网关必备的能力位。
44
+ *
45
+ * 存在的原因是一次真实故障:一台机器上同时跑着新旧两个网关,端口顺延后
46
+ * 隧道由新实例建立、回环请求却落到旧实例,结果 /api/v1/* 全部 404 ——
47
+ * 隧道显示在线、/health 也 200,用户只看到一个无从下手的「404」。
48
+ * 有了能力位,服务端能在第一个请求就明确回答「这台网关太旧」。
49
+ */
50
+ exports.RELAY_REQUIRED_FEATURE = 'relay@1';
51
+ /** 帧头长度:1 字节 type + 4 字节 streamId */
52
+ exports.FRAME_HEADER_SIZE = 5;
53
+ /**
54
+ * 单帧载荷上限 64KB。
55
+ * 偏小是有意为之:帧越小,SSE/流式响应的转发粒度越细,首字延迟越低。
56
+ */
57
+ exports.MAX_FRAME_PAYLOAD = 64 * 1024;
58
+ /** 每条流的初始接收窗口(信用式流控) */
59
+ exports.INITIAL_WINDOW_SIZE = 256 * 1024;
60
+ /** 单条隧道允许的最大并发流数 */
61
+ exports.MAX_CONCURRENT_STREAMS = 64;
62
+ /** 心跳间隔与超时(毫秒) */
63
+ exports.PING_INTERVAL_MS = 20000;
64
+ exports.PING_TIMEOUT_MS = 60000;
65
+ /** streamId 为 0 代表连接级控制帧 */
66
+ exports.CONTROL_STREAM_ID = 0;
67
+ /** streamId 取值上限(uint32) */
68
+ const MAX_STREAM_ID = 0xffffffff;
69
+ // ============ 帧类型 ============
70
+ /**
71
+ * 帧类型。
72
+ *
73
+ * 用 const 对象而非 enum:server 端开启了 erasableSyntaxOnly,
74
+ * TS enum 会生成运行时代码因而被禁止。这样单源可被三端共同编译。
75
+ */
76
+ exports.FrameType = {
77
+ // --- 连接级 ---
78
+ HELLO: 0x01,
79
+ HELLO_ACK: 0x02,
80
+ PING: 0x03,
81
+ PONG: 0x04,
82
+ // --- HTTP 请求/响应 ---
83
+ REQ_HEAD: 0x10,
84
+ REQ_DATA: 0x11,
85
+ REQ_END: 0x12,
86
+ RESP_HEAD: 0x13,
87
+ RESP_DATA: 0x14,
88
+ RESP_END: 0x15,
89
+ // --- 流控与生命周期 ---
90
+ STREAM_ABORT: 0x20,
91
+ WINDOW_UPDATE: 0x21,
92
+ // --- WebSocket 通道 ---
93
+ WS_OPEN: 0x30,
94
+ WS_DATA: 0x31,
95
+ WS_CLOSE: 0x32,
96
+ };
97
+ /** 帧类型 → 名称,用于日志与错误信息 */
98
+ const FRAME_TYPE_NAMES = Object.fromEntries(Object.entries(exports.FrameType).map(([name, value]) => [value, name]));
99
+ /** 取帧类型名称,未知类型返回十六进制值 */
100
+ function frameTypeName(type) {
101
+ return FRAME_TYPE_NAMES[type] ?? `0x${type.toString(16)}`;
102
+ }
103
+ exports.frameTypeName = frameTypeName;
104
+ /** 判断是否为已知帧类型 */
105
+ function isKnownFrameType(value) {
106
+ return (value === exports.FrameType.HELLO ||
107
+ value === exports.FrameType.HELLO_ACK ||
108
+ value === exports.FrameType.PING ||
109
+ value === exports.FrameType.PONG ||
110
+ value === exports.FrameType.REQ_HEAD ||
111
+ value === exports.FrameType.REQ_DATA ||
112
+ value === exports.FrameType.REQ_END ||
113
+ value === exports.FrameType.RESP_HEAD ||
114
+ value === exports.FrameType.RESP_DATA ||
115
+ value === exports.FrameType.RESP_END ||
116
+ value === exports.FrameType.STREAM_ABORT ||
117
+ value === exports.FrameType.WINDOW_UPDATE ||
118
+ value === exports.FrameType.WS_OPEN ||
119
+ value === exports.FrameType.WS_CLOSE ||
120
+ value === exports.FrameType.WS_DATA);
121
+ }
122
+ exports.isKnownFrameType = isKnownFrameType;
123
+ /** 控制帧(streamId 必须为 0) */
124
+ const CONTROL_FRAME_TYPES = new Set([
125
+ exports.FrameType.HELLO,
126
+ exports.FrameType.HELLO_ACK,
127
+ exports.FrameType.PING,
128
+ exports.FrameType.PONG,
129
+ ]);
130
+ /** 该帧类型是否为连接级控制帧 */
131
+ function isControlFrame(type) {
132
+ return CONTROL_FRAME_TYPES.has(type);
133
+ }
134
+ exports.isControlFrame = isControlFrame;
135
+ // ============ 中止原因 ============
136
+ exports.AbortReason = {
137
+ /** 客户端主动断开(对应 Desktop 的 AbortController) */
138
+ CLIENT_CLOSED: 1,
139
+ /** 上游网关处理超时 */
140
+ TIMEOUT: 2,
141
+ /** 网关本地请求失败 */
142
+ GATEWAY_ERROR: 3,
143
+ /** 违反协议(如超窗、未知帧) */
144
+ PROTOCOL_ERROR: 4,
145
+ /** 超出配额或限流 */
146
+ QUOTA_EXCEEDED: 5,
147
+ /** 隧道正在关闭 */
148
+ TUNNEL_CLOSING: 6,
149
+ };
150
+ // ============ 错误类型 ============
151
+ /** 协议层错误:解析失败、越界、非法字段等 */
152
+ class RelayProtocolError extends Error {
153
+ reason;
154
+ constructor(message, reason = exports.AbortReason.PROTOCOL_ERROR) {
155
+ super(message);
156
+ this.name = 'RelayProtocolError';
157
+ this.reason = reason;
158
+ }
159
+ }
160
+ exports.RelayProtocolError = RelayProtocolError;
161
+ // ============ 编码 ============
162
+ const textEncoder = new TextEncoder();
163
+ const textDecoder = new TextDecoder('utf-8', { fatal: false });
164
+ function assertValidStreamId(type, streamId) {
165
+ if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) {
166
+ throw new RelayProtocolError(`非法 streamId: ${streamId}`);
167
+ }
168
+ if (isControlFrame(type) && streamId !== exports.CONTROL_STREAM_ID) {
169
+ throw new RelayProtocolError(`控制帧 ${frameTypeName(type)} 的 streamId 必须为 0,实际 ${streamId}`);
170
+ }
171
+ if (!isControlFrame(type) && streamId === exports.CONTROL_STREAM_ID) {
172
+ throw new RelayProtocolError(`流帧 ${frameTypeName(type)} 的 streamId 不得为 0`);
173
+ }
174
+ }
175
+ /**
176
+ * 编码一个帧。
177
+ * @throws RelayProtocolError 载荷超过上限或 streamId 非法
178
+ */
179
+ function encodeFrame(type, streamId, payload = new Uint8Array(0)) {
180
+ if (!isKnownFrameType(type)) {
181
+ throw new RelayProtocolError(`未知帧类型: ${type}`);
182
+ }
183
+ assertValidStreamId(type, streamId);
184
+ if (payload.byteLength > exports.MAX_FRAME_PAYLOAD) {
185
+ throw new RelayProtocolError(`载荷 ${payload.byteLength} 字节超过单帧上限 ${exports.MAX_FRAME_PAYLOAD},调用方需自行分片`);
186
+ }
187
+ const buf = new Uint8Array(exports.FRAME_HEADER_SIZE + payload.byteLength);
188
+ const view = new DataView(buf.buffer);
189
+ buf[0] = type;
190
+ view.setUint32(1, streamId, false);
191
+ buf.set(payload, exports.FRAME_HEADER_SIZE);
192
+ return buf;
193
+ }
194
+ exports.encodeFrame = encodeFrame;
195
+ /** 将对象作为 JSON 载荷编码成帧 */
196
+ function encodeJsonFrame(type, streamId, value) {
197
+ return encodeFrame(type, streamId, textEncoder.encode(JSON.stringify(value)));
198
+ }
199
+ exports.encodeJsonFrame = encodeJsonFrame;
200
+ // ============ 解码 ============
201
+ /**
202
+ * 解码一个完整帧。
203
+ *
204
+ * 注意:WebSocket 天然保留消息边界,因此这里按「一条消息 = 一个帧」处理,
205
+ * 不需要像 TCP 那样做粘包拆分。
206
+ *
207
+ * @throws RelayProtocolError 长度不足、类型未知或 streamId 非法
208
+ */
209
+ function decodeFrame(data) {
210
+ if (data.byteLength < exports.FRAME_HEADER_SIZE) {
211
+ throw new RelayProtocolError(`帧长度 ${data.byteLength} 小于帧头 ${exports.FRAME_HEADER_SIZE}`);
212
+ }
213
+ const type = data[0];
214
+ if (!isKnownFrameType(type)) {
215
+ throw new RelayProtocolError(`未知帧类型: 0x${type.toString(16)}`);
216
+ }
217
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
218
+ const streamId = view.getUint32(1, false);
219
+ assertValidStreamId(type, streamId);
220
+ const payloadLength = data.byteLength - exports.FRAME_HEADER_SIZE;
221
+ if (payloadLength > exports.MAX_FRAME_PAYLOAD) {
222
+ throw new RelayProtocolError(`载荷 ${payloadLength} 字节超过单帧上限 ${exports.MAX_FRAME_PAYLOAD}`);
223
+ }
224
+ // subarray 而非 slice:避免每帧都复制一次载荷
225
+ return {
226
+ type,
227
+ streamId,
228
+ payload: data.subarray(exports.FRAME_HEADER_SIZE),
229
+ };
230
+ }
231
+ exports.decodeFrame = decodeFrame;
232
+ /**
233
+ * 把帧载荷解析为 JSON。
234
+ * @throws RelayProtocolError 载荷不是合法 JSON
235
+ */
236
+ function decodeJsonPayload(payload) {
237
+ let text;
238
+ try {
239
+ text = textDecoder.decode(payload);
240
+ }
241
+ catch {
242
+ throw new RelayProtocolError('载荷不是合法 UTF-8');
243
+ }
244
+ try {
245
+ return JSON.parse(text);
246
+ }
247
+ catch {
248
+ throw new RelayProtocolError('载荷不是合法 JSON');
249
+ }
250
+ }
251
+ exports.decodeJsonPayload = decodeJsonPayload;
252
+ // ============ 分片 ============
253
+ /**
254
+ * 将大块数据按单帧上限切分为多个帧。
255
+ *
256
+ * 用于请求体上传与响应体下发:调用方无需关心 MAX_FRAME_PAYLOAD,
257
+ * 但必须逐帧发送而不是等全部切完再发,否则又回到缓冲老路。
258
+ */
259
+ function* splitIntoFrames(type, streamId, data) {
260
+ if (data.byteLength === 0) {
261
+ yield encodeFrame(type, streamId, data);
262
+ return;
263
+ }
264
+ for (let offset = 0; offset < data.byteLength; offset += exports.MAX_FRAME_PAYLOAD) {
265
+ const end = Math.min(offset + exports.MAX_FRAME_PAYLOAD, data.byteLength);
266
+ yield encodeFrame(type, streamId, data.subarray(offset, end));
267
+ }
268
+ }
269
+ exports.splitIntoFrames = splitIntoFrames;
270
+ // ============ WebSocket 数据帧 ============
271
+ /**
272
+ * WS_DATA 载荷的首字节标记原始消息类型。
273
+ *
274
+ * WebSocket 区分 text 与 binary 两种帧,且这一区分对上层可见
275
+ * (浏览器收到 binary 会给出 Blob/ArrayBuffer 而非 string)。
276
+ * 中继必须原样保持,否则网关侧 JSON.parse(data.toString()) 之类的
277
+ * 既有逻辑会因类型变化而失效。
278
+ */
279
+ exports.WsDataKind = {
280
+ TEXT: 0,
281
+ BINARY: 1,
282
+ };
283
+ /** WS_DATA 载荷的类型标记长度 */
284
+ exports.WS_DATA_PREFIX_SIZE = 1;
285
+ /** WS_DATA 单帧可承载的最大原始数据量(需为标记留 1 字节) */
286
+ exports.MAX_WS_DATA_CHUNK = exports.MAX_FRAME_PAYLOAD - exports.WS_DATA_PREFIX_SIZE;
287
+ /**
288
+ * 把一条 WebSocket 消息编码为一个或多个 WS_DATA 帧。
289
+ *
290
+ * 注意分片语义:这里不额外标记「消息结束」,因为一条 WS 消息被拆成多帧后,
291
+ * 接收方需要按顺序拼回。为避免引入额外状态,超过单帧上限的消息在
292
+ * 每一帧都重复携带同一个类型标记,接收方按 streamId 顺序追加即可。
293
+ * 业务侧 WS 消息都是小体量 JSON,触发分片属极少数情况。
294
+ */
295
+ function* encodeWsDataFrames(streamId, data, binary) {
296
+ const kind = binary ? exports.WsDataKind.BINARY : exports.WsDataKind.TEXT;
297
+ if (data.byteLength <= exports.MAX_WS_DATA_CHUNK) {
298
+ yield encodeFrame(exports.FrameType.WS_DATA, streamId, withKind(kind, data));
299
+ return;
300
+ }
301
+ for (let offset = 0; offset < data.byteLength; offset += exports.MAX_WS_DATA_CHUNK) {
302
+ const end = Math.min(offset + exports.MAX_WS_DATA_CHUNK, data.byteLength);
303
+ yield encodeFrame(exports.FrameType.WS_DATA, streamId, withKind(kind, data.subarray(offset, end)));
304
+ }
305
+ }
306
+ exports.encodeWsDataFrames = encodeWsDataFrames;
307
+ function withKind(kind, data) {
308
+ const payload = new Uint8Array(exports.WS_DATA_PREFIX_SIZE + data.byteLength);
309
+ payload[0] = kind;
310
+ payload.set(data, exports.WS_DATA_PREFIX_SIZE);
311
+ return payload;
312
+ }
313
+ /**
314
+ * 解析 WS_DATA 载荷。
315
+ * @throws RelayProtocolError 载荷为空或类型标记非法
316
+ */
317
+ function decodeWsData(payload) {
318
+ if (payload.byteLength < exports.WS_DATA_PREFIX_SIZE) {
319
+ throw new RelayProtocolError('WS_DATA 载荷缺少类型标记');
320
+ }
321
+ const kind = payload[0];
322
+ if (kind !== exports.WsDataKind.TEXT && kind !== exports.WsDataKind.BINARY) {
323
+ throw new RelayProtocolError(`未知 WS_DATA 类型标记: ${kind}`);
324
+ }
325
+ return {
326
+ binary: kind === exports.WsDataKind.BINARY,
327
+ data: payload.subarray(exports.WS_DATA_PREFIX_SIZE),
328
+ };
329
+ }
330
+ exports.decodeWsData = decodeWsData;
331
+ // ============ streamId 分配 ============
332
+ /**
333
+ * streamId 分配器。
334
+ *
335
+ * 双方都可主动发起流(Server 转发 Desktop 请求、网关侧暂无但保留),
336
+ * 为避免 id 撞车,一侧使用奇数、一侧使用偶数。
337
+ */
338
+ class StreamIdAllocator {
339
+ next;
340
+ step = 2;
341
+ /** @param odd true 分配奇数(Server 侧),false 分配偶数(Gateway 侧) */
342
+ constructor(odd) {
343
+ this.next = odd ? 1 : 2;
344
+ }
345
+ allocate() {
346
+ const id = this.next;
347
+ // 回绕:跳过 0(保留给控制帧)
348
+ this.next += this.step;
349
+ if (this.next > MAX_STREAM_ID) {
350
+ this.next = this.next % 2 === 1 ? 1 : 2;
351
+ }
352
+ return id;
353
+ }
354
+ }
355
+ exports.StreamIdAllocator = StreamIdAllocator;
356
+ // ============ 流控 ============
357
+ /**
358
+ * 信用式发送窗口。
359
+ *
360
+ * 发送方每发出 N 字节就扣减 N 信用,收到 WINDOW_UPDATE 后补回。
361
+ * 信用耗尽时必须暂停读取上游(req.pause()),这是防止中继 OOM 的关键。
362
+ */
363
+ class SendWindow {
364
+ available;
365
+ constructor(initial = exports.INITIAL_WINDOW_SIZE) {
366
+ this.available = initial;
367
+ }
368
+ /** 当前可用信用 */
369
+ get credit() {
370
+ return this.available;
371
+ }
372
+ /** 是否还能发送数据 */
373
+ canSend(bytes) {
374
+ return bytes <= this.available;
375
+ }
376
+ /**
377
+ * 消费信用。
378
+ * @throws RelayProtocolError 信用不足(调用方应先 canSend 判断并暂停上游)
379
+ */
380
+ consume(bytes) {
381
+ if (bytes > this.available) {
382
+ throw new RelayProtocolError(`发送窗口不足:需要 ${bytes},可用 ${this.available}`);
383
+ }
384
+ this.available -= bytes;
385
+ }
386
+ /**
387
+ * 补充信用。
388
+ * @throws RelayProtocolError delta 非正或导致窗口溢出
389
+ */
390
+ increase(delta) {
391
+ if (!Number.isInteger(delta) || delta <= 0) {
392
+ throw new RelayProtocolError(`WINDOW_UPDATE 的 delta 必须为正整数,实际 ${delta}`);
393
+ }
394
+ if (this.available + delta > MAX_STREAM_ID) {
395
+ throw new RelayProtocolError('发送窗口溢出');
396
+ }
397
+ this.available += delta;
398
+ }
399
+ }
400
+ exports.SendWindow = SendWindow;
401
+ /**
402
+ * 接收窗口。
403
+ *
404
+ * 消费掉数据后调用 consume() 累积待确认字节,
405
+ * 达到阈值(半窗)才发一次 WINDOW_UPDATE,避免每帧都回一个确认帧。
406
+ */
407
+ class ReceiveWindow {
408
+ size;
409
+ pending = 0;
410
+ used = 0;
411
+ constructor(size = exports.INITIAL_WINDOW_SIZE) {
412
+ this.size = size;
413
+ }
414
+ /**
415
+ * 记录收到的数据。
416
+ * @throws RelayProtocolError 对端超发(协议违规)
417
+ */
418
+ receive(bytes) {
419
+ this.used += bytes;
420
+ if (this.used > this.size) {
421
+ throw new RelayProtocolError(`对端超发:已收 ${this.used} 超过窗口 ${this.size}`);
422
+ }
423
+ }
424
+ /**
425
+ * 标记数据已被消费,返回需要通告的 delta;
426
+ * 未达阈值返回 0 表示暂不发送 WINDOW_UPDATE。
427
+ */
428
+ consume(bytes) {
429
+ this.pending += bytes;
430
+ this.used -= bytes;
431
+ if (this.pending * 2 >= this.size) {
432
+ const delta = this.pending;
433
+ this.pending = 0;
434
+ return delta;
435
+ }
436
+ return 0;
437
+ }
438
+ /** 强制取出待确认字节(如流即将空闲时收尾) */
439
+ flush() {
440
+ const delta = this.pending;
441
+ this.pending = 0;
442
+ return delta;
443
+ }
444
+ }
445
+ exports.ReceiveWindow = ReceiveWindow;
446
+ /**
447
+ * 校验网关上报的协议版本是否被本端接受。
448
+ *
449
+ * 网关分布在用户机器上无法强制升级,因此这里只做区间判断,
450
+ * 并给出明确原因,由 Desktop 决定提示「请升级网关」还是「请升级客户端」。
451
+ */
452
+ function checkProtocolVersion(peerVersion) {
453
+ if (!Number.isInteger(peerVersion) || peerVersion <= 0) {
454
+ return { compatible: false, reason: `非法协议版本: ${peerVersion}` };
455
+ }
456
+ if (peerVersion < exports.RELAY_MIN_PROTOCOL_VERSION) {
457
+ return {
458
+ compatible: false,
459
+ reason: `网关协议版本 ${peerVersion} 过低(最低支持 ${exports.RELAY_MIN_PROTOCOL_VERSION}),请升级网关`,
460
+ };
461
+ }
462
+ if (peerVersion > exports.RELAY_PROTOCOL_VERSION) {
463
+ return {
464
+ compatible: false,
465
+ reason: `网关协议版本 ${peerVersion} 高于服务端支持的 ${exports.RELAY_PROTOCOL_VERSION},请升级服务端`,
466
+ };
467
+ }
468
+ return { compatible: true };
469
+ }
470
+ exports.checkProtocolVersion = checkProtocolVersion;
471
+ /**
472
+ * 校验网关能力位是否满足中继要求。
473
+ *
474
+ * 与协议版本分开判断:版本对得上只说明「帧能解开」,不代表网关真的具备
475
+ * 中继所需的业务路由(如 /auth/adopt-token)。旧网关完全可能协议版本正确
476
+ * 却缺少这些接口。
477
+ *
478
+ * features 缺失(undefined/空数组)也判为不兼容:能上报 features 本身就是
479
+ * 新版本的标志,宁可让老网关明确失败,也不要放它进来产生 404。
480
+ */
481
+ function checkGatewayFeatures(features) {
482
+ if (!Array.isArray(features) || features.length === 0) {
483
+ return {
484
+ compatible: false,
485
+ reason: '网关未上报能力位,版本过旧,请升级网关后重试',
486
+ };
487
+ }
488
+ if (!features.includes(exports.RELAY_REQUIRED_FEATURE)) {
489
+ return {
490
+ compatible: false,
491
+ reason: `网关不支持中继能力 ${exports.RELAY_REQUIRED_FEATURE},请升级网关后重试`,
492
+ };
493
+ }
494
+ return { compatible: true };
495
+ }
496
+ exports.checkGatewayFeatures = checkGatewayFeatures;
497
+ // ============ 头部处理 ============
498
+ /**
499
+ * hop-by-hop 头:仅对单跳连接有意义,不得透传到另一跳,
500
+ * 否则会出现「上游声明 chunked 但中继已解码」之类的语义错乱。
501
+ */
502
+ const HOP_BY_HOP_HEADERS = new Set([
503
+ 'connection',
504
+ 'keep-alive',
505
+ 'proxy-authenticate',
506
+ 'proxy-authorization',
507
+ 'te',
508
+ 'trailer',
509
+ 'transfer-encoding',
510
+ 'upgrade',
511
+ 'http2-settings',
512
+ ]);
513
+ /** 剥离 hop-by-hop 头,并统一小写键名 */
514
+ function stripHopByHopHeaders(headers) {
515
+ const result = {};
516
+ for (const [rawKey, value] of Object.entries(headers)) {
517
+ if (value === undefined)
518
+ continue;
519
+ const key = rawKey.toLowerCase();
520
+ if (HOP_BY_HOP_HEADERS.has(key))
521
+ continue;
522
+ result[key] = Array.isArray(value) ? value.join(', ') : value;
523
+ }
524
+ return result;
525
+ }
526
+ exports.stripHopByHopHeaders = stripHopByHopHeaders;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * 中继客户端配置
4
+ *
5
+ * 网关的中继凭据(gatewayId / relaySecret)在配对时由 Server 下发,
6
+ * 落在 persistStore 中;入口地址则来自环境变量,默认与 SERVER_BASE_URL 一致
7
+ * —— 中继入口就是 Server 本身,没有必要让用户再配一遍。
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.clearRelayCredential = exports.setRelayCredential = exports.getRelayCredential = exports.setRelayEnabled = exports.isRelayEnabled = exports.getTunnelUrl = exports.getRelayBaseUrl = exports.RELAY_CREDENTIAL_KEY = exports.RELAY_ENABLED_KEY = void 0;
11
+ const index_js_1 = require("../../config/index.js");
12
+ const persistStore_js_1 = require("../../stores/persistStore.js");
13
+ /** persistStore 键:中继开关 */
14
+ exports.RELAY_ENABLED_KEY = 'relay:enabled';
15
+ /** persistStore 键:配对凭据 */
16
+ exports.RELAY_CREDENTIAL_KEY = 'relay:credential';
17
+ /**
18
+ * 中继入口地址(Server 地址),末尾不带斜杠。
19
+ * 允许用 RELAY_URL 单独覆盖,便于把中继指向与业务 API 不同的入口。
20
+ */
21
+ function getRelayBaseUrl() {
22
+ const raw = (process.env.RELAY_URL || index_js_1.appConfig.serverBaseUrl || '').trim();
23
+ return raw.replace(/\/+$/, '');
24
+ }
25
+ exports.getRelayBaseUrl = getRelayBaseUrl;
26
+ /** 隧道 WS 地址:http→ws、https→wss */
27
+ function getTunnelUrl() {
28
+ const base = getRelayBaseUrl();
29
+ if (!base)
30
+ return '';
31
+ const wsBase = base.replace(/^http/i, (m) => (m === 'HTTP' ? 'WS' : 'ws'));
32
+ return `${wsBase}/relay/tunnel`;
33
+ }
34
+ exports.getTunnelUrl = getTunnelUrl;
35
+ /** 用户是否已开启中继(安全默认:关闭) */
36
+ function isRelayEnabled() {
37
+ return persistStore_js_1.persistStore.get(exports.RELAY_ENABLED_KEY, false) === true;
38
+ }
39
+ exports.isRelayEnabled = isRelayEnabled;
40
+ /** 写入中继开关 */
41
+ function setRelayEnabled(enabled) {
42
+ persistStore_js_1.persistStore.set(exports.RELAY_ENABLED_KEY, enabled === true);
43
+ }
44
+ exports.setRelayEnabled = setRelayEnabled;
45
+ /** 读取配对凭据(未配对返回 null) */
46
+ function getRelayCredential() {
47
+ const value = persistStore_js_1.persistStore.get(exports.RELAY_CREDENTIAL_KEY, null);
48
+ // userId 缺失的凭据一律视为无效(等同未配对,用户重新配对即可修复):
49
+ // 宁可让功能不可用,也不能让属主校验失去依据。
50
+ if (!value || !value.gatewayId || !value.secret || !value.userId)
51
+ return null;
52
+ return value;
53
+ }
54
+ exports.getRelayCredential = getRelayCredential;
55
+ /** 保存配对凭据 */
56
+ function setRelayCredential(credential) {
57
+ persistStore_js_1.persistStore.set(exports.RELAY_CREDENTIAL_KEY, credential);
58
+ }
59
+ exports.setRelayCredential = setRelayCredential;
60
+ /** 清除配对凭据(解绑/换账号) */
61
+ function clearRelayCredential() {
62
+ persistStore_js_1.persistStore.delete(exports.RELAY_CREDENTIAL_KEY);
63
+ }
64
+ exports.clearRelayCredential = clearRelayCredential;
@@ -440,6 +440,25 @@ class Session {
440
440
  getMessageCount() {
441
441
  return this.messages.length;
442
442
  }
443
+ /**
444
+ * 取断线期间的消息变更(新增 / 修改 / 删除)。
445
+ *
446
+ * WebSocket 重连的退避最长 30s,这期间错过的会话流事件无法补发,
447
+ * 而重连后又没有任何信号告诉终端「你落后了」,各终端就会永久分歧。
448
+ * 终端报上自己的 seq 游标,这里只回变更部分。
449
+ *
450
+ * @param sinceSeq 终端已知的最大 seq,0 表示全量
451
+ */
452
+ getMessagesChangedSince(sinceSeq) {
453
+ const store = this.store;
454
+ return {
455
+ messages: store.findMessagesChangedSince(this.id, sinceSeq),
456
+ // 删除不留痕迹,光靠 seq 发现不了离线期间被删的消息,
457
+ // 因此一并回完整 id 列表让终端剪除多余项
458
+ messageIds: store.findMessageIdsBySessionId(this.id),
459
+ maxSeq: store.getMaxMessageSeq(),
460
+ };
461
+ }
443
462
  /**
444
463
  * Get messages by page (descending order)
445
464
  */
@@ -691,8 +710,6 @@ class Session {
691
710
  const settings = await dataService_js_1.settingsService.get(token);
692
711
  const streamDelay = this.getStreamDelay(settings.streamSpeed);
693
712
  this.currentMessageId = assistantMessageId;
694
- const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
695
- const historyMessages = await memoryManager.getHistoryMessagesAsync();
696
713
  // 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
697
714
  // 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
698
715
  // 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
@@ -719,6 +736,25 @@ class Session {
719
736
  logger.error('SSE mirror error:', error);
720
737
  }
721
738
  };
739
+ // 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
740
+ // 必须在 message_start 之前同步:否则接收端会先插入助手占位,
741
+ // 导致助手消息排在用户消息前面。
742
+ //
743
+ // 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
744
+ // 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
745
+ if (!childAgent) {
746
+ mirrorToOtherClients({
747
+ type: 'user_message',
748
+ message: this.getUserMessage(content, userMessageId, attachments),
749
+ assistantPlaceholder: true,
750
+ });
751
+ }
752
+ // 必须在加载历史(可能触发上下文压缩)之前发出:
753
+ // context_compressing 是对助手消息的原地更新,接收端必须先有占位气泡。
754
+ // MemoryManager 会在压缩历史时发出 context_compressing,
755
+ // 把镜像函数传进去,使该事件也能同步到其他终端。
756
+ const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res, mirrorToOtherClients);
757
+ const historyMessages = await memoryManager.getHistoryMessagesAsync();
722
758
  // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
723
759
  const sendSSE = (res, data) => {
724
760
  mirrorToOtherClients(data);
@@ -741,19 +777,6 @@ class Session {
741
777
  clearInterval(heartbeatInterval);
742
778
  }
743
779
  }, 15000);
744
- // 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
745
- // 必须在 message_start 之前同步:否则接收端会先插入助手占位,
746
- // 导致助手消息排在用户消息前面。
747
- //
748
- // 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
749
- // 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
750
- if (!childAgent) {
751
- mirrorToOtherClients({
752
- type: 'user_message',
753
- message: this.getUserMessage(content, userMessageId, attachments),
754
- assistantPlaceholder: true,
755
- });
756
- }
757
780
  // Send message start event
758
781
  sendSSE(res, { type: 'message_start' });
759
782
  // Build messages for API call (保留 tool_call_id 等必要字段)