@myassis/gateway 1.0.92 → 1.0.94

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.
@@ -206,6 +206,9 @@ class WebSocketService {
206
206
  if (options.excludeClientId && client.clientId === options.excludeClientId) {
207
207
  return;
208
208
  }
209
+ if (options.onlyClientId && client.clientId !== options.onlyClientId) {
210
+ return;
211
+ }
209
212
  sent = this.sendToConnection(client, message) || sent;
210
213
  });
211
214
  return sent;
@@ -43,6 +43,7 @@ class RelayClient {
43
43
  stopped = false;
44
44
  /** Server 在 HELLO_ACK 中下发的参数,未收到前用协议默认值 */
45
45
  initialWindowSize = protocol_js_1.INITIAL_WINDOW_SIZE;
46
+ wsInitialWindowSize = protocol_js_1.WS_INITIAL_WINDOW_SIZE;
46
47
  maxConcurrentStreams = protocol_js_1.MAX_CONCURRENT_STREAMS;
47
48
  /**
48
49
  * 启动中继。
@@ -350,6 +351,11 @@ class RelayClient {
350
351
  this.initialWindowSize = ack.initialWindowSize;
351
352
  if (ack.maxConcurrentStreams > 0)
352
353
  this.maxConcurrentStreams = ack.maxConcurrentStreams;
354
+ // 旧 Server 不下发该字段:回退到 HTTP 窗口,与改造前行为一致。
355
+ // 两侧必须用同一个值,否则接收方会把合法数据当成「对端超发」。
356
+ this.wsInitialWindowSize = ack.wsInitialWindowSize > 0
357
+ ? ack.wsInitialWindowSize
358
+ : this.initialWindowSize;
353
359
  this.failureCount = 0;
354
360
  this.lastError = undefined;
355
361
  this.connectedAt = Date.now();
@@ -461,8 +467,9 @@ class RelayClient {
461
467
  return;
462
468
  }
463
469
  const open = (0, protocol_js_1.decodeJsonPayload)(payload);
464
- const sendWindow = new protocol_js_1.SendWindow(this.initialWindowSize);
465
- const recvWindow = new protocol_js_1.ReceiveWindow(this.initialWindowSize);
470
+ // WS 没有逗条背压,窗口耗尽只能中止整条流,因此用更宽的专用窗口
471
+ const sendWindow = new protocol_js_1.SendWindow(this.wsInitialWindowSize);
472
+ const recvWindow = new protocol_js_1.ReceiveWindow(this.wsInitialWindowSize);
466
473
  const socket = new LoopbackForwarder_js_1.LoopbackWebSocket(this.localPort, open.path, open.headers, {
467
474
  onOpen: () => {
468
475
  // 回环连上后无需额外通知:远端 WS 在 Server 侧已经 accept,
@@ -9,7 +9,7 @@
9
9
  * CI 会校验本文件与源文件的一致性,不一致将导致构建失败。
10
10
  */
11
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;
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.WS_INITIAL_WINDOW_SIZE = 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
13
  /**
14
14
  * 中继帧协议(单源实现)
15
15
  * ============================================================
@@ -55,8 +55,29 @@ exports.FRAME_HEADER_SIZE = 5;
55
55
  * 偏小是有意为之:帧越小,SSE/流式响应的转发粒度越细,首字延迟越低。
56
56
  */
57
57
  exports.MAX_FRAME_PAYLOAD = 64 * 1024;
58
- /** 每条流的初始接收窗口(信用式流控) */
58
+ /**
59
+ * HTTP 流的初始接收窗口(信用式流控)。
60
+ *
61
+ * 保持 256KB:HTTP 流有真正的背压——信用耗尽会 req.pause() 暂停
62
+ * 上游,等 WINDOW_UPDATE 回来再继续,窗口小只影响吞吐不会丢数据,
63
+ * 而帧细粒度对 SSE 首字延迟有利。
64
+ */
59
65
  exports.INITIAL_WINDOW_SIZE = 256 * 1024;
66
+ /**
67
+ * WebSocket 流的初始接收窗口。
68
+ *
69
+ * 必须远大于 HTTP 窗口,因为 WS 没有背压可用:ws 库不支持逗条
70
+ * 暂停,一条消息发不下去就只能 abortStream(见
71
+ * RelayHub.forwardWebSocket 与 RelayClient.onWsOpen 里的 QUOTA_EXCEEDED 分支)。
72
+ * 一旦中止,终端的通知通道就断了,那一段的会话镜像事件全丢。
73
+ *
74
+ * 现实触发条件:会话镜像会把含 base64 附件的用户消息整条下发,
75
+ * 一张 5MB 的图经 base64 膨胀约 4/3 后接近 7MB,瞬时打穿 256KB。
76
+ *
77
+ * 16MB 不是预分配:窗口只是「允许对端在未收到确认前先发多少」。
78
+ * 而且 WS 流数量等于在线终端数(个位数),不是 MAX_CONCURRENT_STREAMS。
79
+ */
80
+ exports.WS_INITIAL_WINDOW_SIZE = 16 * 1024 * 1024;
60
81
  /** 单条隧道允许的最大并发流数 */
61
82
  exports.MAX_CONCURRENT_STREAMS = 64;
62
83
  /** 心跳间隔与超时(毫秒) */
@@ -47,6 +47,16 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
47
47
  * 广播出去会让多个终端同时弹窗并重复回传批准结果。
48
48
  */
49
49
  const NON_MIRRORED_SSE_EVENTS = new Set(['heartbeat', 'approval_pending']);
50
+ /**
51
+ * 只属于发起终端的事件类型。
52
+ *
53
+ * 这些事件不能广播(否则多终端重复弹窗),但也不能丢:
54
+ * 中继模式下 SSE 一旦被隐式关闭,approval_pending 写不出去,
55
+ * 发起端永远不弹确认框,而 registerApprovalWaiter 会一直堵到
56
+ * 5 分钟超时,整轮对话看起来就是“卡住”。
57
+ * 因此 SSE 不可用时改成定向走 WebSocket 补送给同一个发起终端。
58
+ */
59
+ const ORIGINATOR_ONLY_SSE_EVENTS = new Set(['approval_pending']);
50
60
  // 批准等待缓存:token -> { resolve, reject, expiresAt }
51
61
  const approvalWaiters = new Map();
52
62
  /**
@@ -120,6 +130,17 @@ class Session {
120
130
  isGenerating = false;
121
131
  unreadCount = 0;
122
132
  currentMessageId = null;
133
+ /**
134
+ * 镜像事件序号(会话级单调递增)。
135
+ *
136
+ * WebSocket 镜像是 fire-and-forget:连接不在 OPEN(重连窗口)或中继流
137
+ * 被中止时,sendToUser 会静默丢弃,接收端无从得知。带上序号后,
138
+ * 终端一旦发现跳号就能立即触发补拉,而不是靠「连接是否断过」猜。
139
+ *
140
+ * 不持久化:它只用于检测单次进程内的事件连续性,网关重启后
141
+ * 终端会因 WebSocket 重连而重置基准。
142
+ */
143
+ mirrorSeq = 0;
123
144
  abortController = null;
124
145
  /** 后台预压缩的延迟定时器 */
125
146
  precompressionTimer = null;
@@ -741,6 +762,9 @@ class Session {
741
762
  if (NON_MIRRORED_SSE_EVENTS.has(data?.type))
742
763
  return;
743
764
  try {
765
+ // 序号在「决定要发」后才抬:被过滤掉的事件不占号,
766
+ // 否则接收端会把正常的过滤看成丢包。
767
+ this.mirrorSeq += 1;
744
768
  wsService.sendToUser(String(this.userId), {
745
769
  type: 'session_stream',
746
770
  payload: {
@@ -748,6 +772,7 @@ class Session {
748
772
  agentId: this.agentId,
749
773
  userMessageId,
750
774
  assistantMessageId,
775
+ streamSeq: this.mirrorSeq,
751
776
  event: data,
752
777
  },
753
778
  }, { excludeClientId: sseAlive ? clientId : undefined });
@@ -775,12 +800,48 @@ class Session {
775
800
  // 把镜像函数传进去,使该事件也能同步到其他终端。
776
801
  const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res, mirrorToOtherClients);
777
802
  const historyMessages = await memoryManager.getHistoryMessagesAsync();
803
+ /**
804
+ * 将只属于发起终端的事件改走 WebSocket 补送。
805
+ *
806
+ * 不占用 mirrorSeq:该序号是会话级镜像流的连续编号,
807
+ * 定向补送若占一个号,其他终端就会把它当成丢包而误报缺口。
808
+ */
809
+ const sendToOriginator = (data) => {
810
+ if (!wsService)
811
+ return;
812
+ try {
813
+ // 没有 clientId(老版终端)时退回广播:审批 token 只能兑付一次,
814
+ // 重复回传会被忽略,比没人收到而卡 5 分钟更可接受。
815
+ const delivered = wsService.sendToUser(String(this.userId), {
816
+ type: 'session_stream',
817
+ payload: {
818
+ sessionId: this.id,
819
+ agentId: this.agentId,
820
+ userMessageId,
821
+ assistantMessageId,
822
+ event: data,
823
+ },
824
+ }, clientId ? { onlyClientId: clientId } : {});
825
+ if (!delivered) {
826
+ logger.warn(`会话 ${this.id} 的 ${data?.type} 事件无法下发:SSE 已断且无可用 WebSocket 连接`);
827
+ }
828
+ }
829
+ catch (error) {
830
+ logger.error('SSE originator fallback error:', error);
831
+ }
832
+ };
778
833
  // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
779
834
  const sendSSE = (res, data) => {
780
835
  mirrorToOtherClients(data);
781
836
  // 连接已断开时不再写入:写只会抛错刷日志,内容已改由镜像下发
782
- if (!res || !sseAlive)
837
+ if (!res || !sseAlive) {
838
+ // 镜像不负责的那几类事件,在 SSE 不可用时需要定向补送;
839
+ // heartbeat 是 SSE 保活专用,没了 SSE 也就不需要补送。
840
+ if (ORIGINATOR_ONLY_SSE_EVENTS.has(data?.type)) {
841
+ sendToOriginator(data);
842
+ }
783
843
  return;
844
+ }
784
845
  try {
785
846
  res.write(`data: ${JSON.stringify(data)}\n\n`);
786
847
  res.flush?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.92",
3
+ "version": "1.0.94",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {