@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.
- package/dist/config/capabilities.js +46 -0
- package/dist/main.js +64 -28
- package/dist/routes/agent.js +27 -0
- package/dist/routes/auth.js +78 -0
- package/dist/routes/relay.js +173 -0
- package/dist/services/ServiceManager.js +79 -9
- package/dist/services/memory/MemoryManager.js +18 -2
- package/dist/services/relay/LoopbackForwarder.js +292 -0
- package/dist/services/relay/RelayClient.js +541 -0
- package/dist/services/relay/protocol.js +526 -0
- package/dist/services/relay/relayConfig.js +64 -0
- package/dist/services/session/Session.js +38 -15
- package/dist/services/session/SessionStore.js +64 -15
- package/migrations/016_add_message_seq.sql +27 -0
- package/package.json +1 -1
|
@@ -64,12 +64,28 @@ class MemoryManager {
|
|
|
64
64
|
config = DEFAULT_CONFIG;
|
|
65
65
|
childAgent;
|
|
66
66
|
res;
|
|
67
|
+
mirror;
|
|
67
68
|
summaryModels = null;
|
|
68
|
-
constructor(session, signal, childAgent, res = null) {
|
|
69
|
+
constructor(session, signal, childAgent, res = null, mirror = null) {
|
|
69
70
|
this.session = session;
|
|
70
71
|
this.signal = signal;
|
|
71
72
|
this.childAgent = childAgent;
|
|
72
73
|
this.res = res;
|
|
74
|
+
this.mirror = mirror;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 发送 SSE 事件:先镜像给该用户的其他终端,再写回发起端。
|
|
78
|
+
*
|
|
79
|
+
* 旧实现只写 res,导致「上下文压缩中」等事件无法多终端同步。
|
|
80
|
+
*/
|
|
81
|
+
emitSSE(data) {
|
|
82
|
+
try {
|
|
83
|
+
this.mirror?.(data);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// 镜像失败不影响发起端
|
|
87
|
+
}
|
|
88
|
+
sendSSE(this.res, data);
|
|
73
89
|
}
|
|
74
90
|
/**
|
|
75
91
|
* 构造注入上下文的摘要消息。
|
|
@@ -421,7 +437,7 @@ ${conversation}
|
|
|
421
437
|
async generateSummaryAsync(messages, lastSummary) {
|
|
422
438
|
// 非 childAgent 会话通知 Desktop 显示"上下文压缩中"
|
|
423
439
|
if (!this.childAgent) {
|
|
424
|
-
|
|
440
|
+
this.emitSSE({ type: 'context_compressing' });
|
|
425
441
|
}
|
|
426
442
|
const MAX_INPUT_CHARS = 30000; // 单次摘要最大输入字符数
|
|
427
443
|
// 格式化所有消息用于估算长度
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 回环转发器
|
|
4
|
+
*
|
|
5
|
+
* 把中继帧还原成一次对本机 `127.0.0.1:{实际端口}` 的**真实 HTTP 请求**,
|
|
6
|
+
* 而不是在内存里伪造 req/res 直接喂给 express。
|
|
7
|
+
*
|
|
8
|
+
* 为什么坚持走真实回环:
|
|
9
|
+
* 1. 完整复用既有中间件链(helmet / cors / compression / broadcastDataChanges
|
|
10
|
+
* / errorHandler)与路由,行为与直连模式逐字节一致,不需要为中继单独维护一套;
|
|
11
|
+
* 2. 内存伪造 req/res 必须模拟 IncomingMessage 与 ServerResponse 的全部行为
|
|
12
|
+
* (流式写、背压、trailers、连接中断),任何遗漏都会在 SSE 或上传上出问题;
|
|
13
|
+
* 3. 回环没有真实网络开销,代价可以忽略。
|
|
14
|
+
*
|
|
15
|
+
* 关键约束:响应必须**逐块**回传,不得聚合。否则 SSE 首字延迟被拉长,
|
|
16
|
+
* 流式对话会退化成「等很久然后一次性出现」。
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.verifyLoopbackIdentity = exports.probeLoopback = exports.LoopbackWebSocket = exports.LoopbackRequest = void 0;
|
|
23
|
+
const http_1 = __importDefault(require("http"));
|
|
24
|
+
const shared_1 = require("@myassis/shared");
|
|
25
|
+
const protocol_js_1 = require("./protocol.js");
|
|
26
|
+
const logger = (0, shared_1.getLogger)('relay/Loopback');
|
|
27
|
+
/** 回环请求的目标主机:固定本机,绝不接受外部指定 */
|
|
28
|
+
const LOOPBACK_HOST = '127.0.0.1';
|
|
29
|
+
/** 单个中继请求的上限(含等待网关处理),流式响应不受此限制约束首字之后的时长 */
|
|
30
|
+
const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* 一次进行中的回环请求。
|
|
33
|
+
* RelayClient 持有它以便投递请求体、以及在对端中止时取消。
|
|
34
|
+
*/
|
|
35
|
+
class LoopbackRequest {
|
|
36
|
+
handlers;
|
|
37
|
+
req;
|
|
38
|
+
finished = false;
|
|
39
|
+
/** 发送窗口耗尽时暂停响应读取,避免上游写入速度超过隧道 */
|
|
40
|
+
paused = false;
|
|
41
|
+
res = null;
|
|
42
|
+
constructor(port, head, handlers) {
|
|
43
|
+
this.handlers = handlers;
|
|
44
|
+
// 逐跳头必须剥掉:对端声明的 transfer-encoding/connection 只对那一跳有效
|
|
45
|
+
const headers = (0, protocol_js_1.stripHopByHopHeaders)(head.headers);
|
|
46
|
+
// content-length 不可信:请求体经隧道分片后由 Node 自行按 chunked 发送
|
|
47
|
+
delete headers['content-length'];
|
|
48
|
+
// host 必须改写为回环地址,否则 express 的 host 相关逻辑会看到外部域名
|
|
49
|
+
headers['host'] = `${LOOPBACK_HOST}:${port}`;
|
|
50
|
+
// 保留真实来源,供网关侧审计与限流使用
|
|
51
|
+
if (head.remoteAddr) {
|
|
52
|
+
headers['x-forwarded-for'] = head.remoteAddr;
|
|
53
|
+
}
|
|
54
|
+
// 标记中继来源:便于日志区分,也让业务侧可按需拒绝高危操作
|
|
55
|
+
headers['x-myassis-relay'] = '1';
|
|
56
|
+
this.req = http_1.default.request({
|
|
57
|
+
host: LOOPBACK_HOST,
|
|
58
|
+
port,
|
|
59
|
+
method: head.method,
|
|
60
|
+
path: head.path,
|
|
61
|
+
headers,
|
|
62
|
+
// 回环不需要连接复用带来的复杂性,出错语义更简单
|
|
63
|
+
agent: false,
|
|
64
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
65
|
+
});
|
|
66
|
+
this.req.on('response', (res) => this.handleResponse(res));
|
|
67
|
+
this.req.on('timeout', () => {
|
|
68
|
+
this.fail(protocol_js_1.AbortReason.TIMEOUT, `回环请求超时(${REQUEST_TIMEOUT_MS}ms)`);
|
|
69
|
+
});
|
|
70
|
+
this.req.on('error', (err) => {
|
|
71
|
+
// ECONNREFUSED 意味着网关自身 HTTP 服务不可用(如正在重启)
|
|
72
|
+
this.fail(protocol_js_1.AbortReason.GATEWAY_ERROR, err.message);
|
|
73
|
+
});
|
|
74
|
+
if (!head.hasBody) {
|
|
75
|
+
this.req.end();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 写入一段请求体,返回 false 表示上游背压,调用方应暂缓 */
|
|
79
|
+
writeBody(chunk) {
|
|
80
|
+
if (this.finished)
|
|
81
|
+
return true;
|
|
82
|
+
return this.req.write(chunk);
|
|
83
|
+
}
|
|
84
|
+
/** 请求体结束 */
|
|
85
|
+
endBody() {
|
|
86
|
+
if (this.finished)
|
|
87
|
+
return;
|
|
88
|
+
this.req.end();
|
|
89
|
+
}
|
|
90
|
+
/** 对端中止:主动销毁回环请求,让网关侧尽快释放资源 */
|
|
91
|
+
abort(reason, message) {
|
|
92
|
+
if (this.finished)
|
|
93
|
+
return;
|
|
94
|
+
this.finished = true;
|
|
95
|
+
logger.debug(`回环请求中止: reason=${reason} ${message}`);
|
|
96
|
+
this.req.destroy();
|
|
97
|
+
this.res?.destroy();
|
|
98
|
+
}
|
|
99
|
+
/** 发送窗口耗尽时暂停读取响应(流控的落点) */
|
|
100
|
+
pauseResponse() {
|
|
101
|
+
if (this.paused)
|
|
102
|
+
return;
|
|
103
|
+
this.paused = true;
|
|
104
|
+
this.res?.pause();
|
|
105
|
+
}
|
|
106
|
+
/** 收到 WINDOW_UPDATE 后恢复读取 */
|
|
107
|
+
resumeResponse() {
|
|
108
|
+
if (!this.paused)
|
|
109
|
+
return;
|
|
110
|
+
this.paused = false;
|
|
111
|
+
this.res?.resume();
|
|
112
|
+
}
|
|
113
|
+
handleResponse(res) {
|
|
114
|
+
this.res = res;
|
|
115
|
+
const headers = (0, protocol_js_1.stripHopByHopHeaders)(res.headers);
|
|
116
|
+
// content-length 在分帧后不再成立(且 SSE 本就没有),交由对端按流结束判断
|
|
117
|
+
delete headers['content-length'];
|
|
118
|
+
// 立刻发出响应头:SSE / 流式对话的首字延迟完全取决于这一步
|
|
119
|
+
this.handlers.onHead(res.statusCode || 502, headers);
|
|
120
|
+
if (this.paused) {
|
|
121
|
+
res.pause();
|
|
122
|
+
}
|
|
123
|
+
res.on('data', (chunk) => {
|
|
124
|
+
// 单帧上限由协议约定,这里就地切分,避免调用方再拼一次
|
|
125
|
+
for (let offset = 0; offset < chunk.length; offset += protocol_js_1.MAX_FRAME_PAYLOAD) {
|
|
126
|
+
this.handlers.onData(new Uint8Array(chunk.buffer, chunk.byteOffset + offset, Math.min(protocol_js_1.MAX_FRAME_PAYLOAD, chunk.length - offset)));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
if (this.finished)
|
|
131
|
+
return;
|
|
132
|
+
this.finished = true;
|
|
133
|
+
this.handlers.onEnd();
|
|
134
|
+
});
|
|
135
|
+
res.on('error', (err) => {
|
|
136
|
+
this.fail(protocol_js_1.AbortReason.GATEWAY_ERROR, err.message);
|
|
137
|
+
});
|
|
138
|
+
// aborted 而非 end:上游异常断开时必须让对端知道响应不完整
|
|
139
|
+
res.on('aborted', () => {
|
|
140
|
+
this.fail(protocol_js_1.AbortReason.GATEWAY_ERROR, '本机响应被中断');
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
fail(reason, message) {
|
|
144
|
+
if (this.finished)
|
|
145
|
+
return;
|
|
146
|
+
this.finished = true;
|
|
147
|
+
logger.warn(`回环请求失败: ${message}`);
|
|
148
|
+
this.req.destroy();
|
|
149
|
+
this.handlers.onError(reason, message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
exports.LoopbackRequest = LoopbackRequest;
|
|
153
|
+
/**
|
|
154
|
+
* 回环 WebSocket 通道。
|
|
155
|
+
*
|
|
156
|
+
* 同样走真实回环:直接对 `ws://127.0.0.1:{port}/ws` 发起连接,
|
|
157
|
+
* 复用 WebSocketService 既有的鉴权(?token=)与多终端逻辑。
|
|
158
|
+
*/
|
|
159
|
+
class LoopbackWebSocket {
|
|
160
|
+
handlers;
|
|
161
|
+
ws = null;
|
|
162
|
+
closed = false;
|
|
163
|
+
constructor(port, path, headers, handlers) {
|
|
164
|
+
this.handlers = handlers;
|
|
165
|
+
// 延迟 require:ws 已是既有依赖,这里避免在未启用中继时也加载
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
167
|
+
const { WebSocket } = require('ws');
|
|
168
|
+
const forwarded = (0, protocol_js_1.stripHopByHopHeaders)(headers);
|
|
169
|
+
delete forwarded['host'];
|
|
170
|
+
delete forwarded['sec-websocket-key'];
|
|
171
|
+
delete forwarded['sec-websocket-version'];
|
|
172
|
+
delete forwarded['sec-websocket-extensions'];
|
|
173
|
+
delete forwarded['sec-websocket-accept'];
|
|
174
|
+
const ws = new WebSocket(`ws://${LOOPBACK_HOST}:${port}${path}`, {
|
|
175
|
+
headers: forwarded,
|
|
176
|
+
});
|
|
177
|
+
this.ws = ws;
|
|
178
|
+
ws.on('open', () => this.handlers.onOpen());
|
|
179
|
+
ws.on('message', (data, isBinary) => {
|
|
180
|
+
this.handlers.onMessage(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), isBinary);
|
|
181
|
+
});
|
|
182
|
+
ws.on('close', (code, reason) => {
|
|
183
|
+
if (this.closed)
|
|
184
|
+
return;
|
|
185
|
+
this.closed = true;
|
|
186
|
+
this.handlers.onClose(normalizeCloseCode(code), reason?.toString() || '');
|
|
187
|
+
});
|
|
188
|
+
ws.on('error', (err) => {
|
|
189
|
+
if (this.closed)
|
|
190
|
+
return;
|
|
191
|
+
this.closed = true;
|
|
192
|
+
logger.warn(`回环 WebSocket 失败: ${err.message}`);
|
|
193
|
+
// 1011 = internal error,让远端客户端按「服务异常」处理并自行重连
|
|
194
|
+
this.handlers.onClose(1011, err.message);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** 转发一条客户端消息到本机 */
|
|
198
|
+
send(data, binary) {
|
|
199
|
+
const ws = this.ws;
|
|
200
|
+
if (!ws || ws.readyState !== 1 /* OPEN */)
|
|
201
|
+
return;
|
|
202
|
+
ws.send(data, { binary });
|
|
203
|
+
}
|
|
204
|
+
/** 关闭回环连接 */
|
|
205
|
+
close(code = 1000, reason = '') {
|
|
206
|
+
this.closed = true;
|
|
207
|
+
try {
|
|
208
|
+
this.ws?.close(normalizeCloseCode(code), reason);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
this.ws?.terminate();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
exports.LoopbackWebSocket = LoopbackWebSocket;
|
|
216
|
+
/**
|
|
217
|
+
* 规整 WebSocket 关闭码。
|
|
218
|
+
*
|
|
219
|
+
* 1005(无状态码)与 1006(异常关闭)不允许出现在 close 帧里,
|
|
220
|
+
* 直接透传会让 ws 抛错,从而把一次正常的对端断开变成网关侧异常。
|
|
221
|
+
*/
|
|
222
|
+
function normalizeCloseCode(code) {
|
|
223
|
+
if (!Number.isInteger(code) || code < 1000 || code > 4999)
|
|
224
|
+
return 1011;
|
|
225
|
+
if (code === 1005 || code === 1006)
|
|
226
|
+
return 1011;
|
|
227
|
+
return code;
|
|
228
|
+
}
|
|
229
|
+
/** 供测试与诊断:探测本机 HTTP 端口是否可用 */
|
|
230
|
+
function probeLoopback(port, timeoutMs = 2000) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const req = http_1.default.request({ host: LOOPBACK_HOST, port, path: '/health', method: 'GET', timeout: timeoutMs, agent: false }, (res) => {
|
|
233
|
+
res.resume();
|
|
234
|
+
resolve((res.statusCode || 0) < 500);
|
|
235
|
+
});
|
|
236
|
+
req.on('error', () => resolve(false));
|
|
237
|
+
req.on('timeout', () => {
|
|
238
|
+
req.destroy();
|
|
239
|
+
resolve(false);
|
|
240
|
+
});
|
|
241
|
+
req.end();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
exports.probeLoopback = probeLoopback;
|
|
245
|
+
/**
|
|
246
|
+
* 校验回环端口上的进程确实是**本进程**。
|
|
247
|
+
*
|
|
248
|
+
* 起因是一次真实故障:一台机器上同时跑着新旧两个网关,3001 被旧版占用后
|
|
249
|
+
* 新版顺延到 3002。隧道由新版建立(凭据在它手上),但如果回环端口取错,
|
|
250
|
+
* 请求就会被送进旧版进程,表现为隧道在线、/health 也 200,而所有 /api/v1/*
|
|
251
|
+
* 返回 404 —— 一个极难定位的症状。
|
|
252
|
+
*
|
|
253
|
+
* 判据用 /health 里的 gatewayId:它来自配对凭据,同一台机器上不会重复,
|
|
254
|
+
* 比单纯「端口是否可连」强得多(两个版本的 /health 都会返回 200)。
|
|
255
|
+
*
|
|
256
|
+
* @param expectedGatewayId 本进程持有的 gatewayId
|
|
257
|
+
* @returns 一致返回 null,不一致或探测失败返回可读原因
|
|
258
|
+
*/
|
|
259
|
+
function verifyLoopbackIdentity(port, expectedGatewayId, timeoutMs = 2000) {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
const req = http_1.default.request({ host: LOOPBACK_HOST, port, path: '/health', method: 'GET', timeout: timeoutMs, agent: false }, (res) => {
|
|
262
|
+
const chunks = [];
|
|
263
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
264
|
+
res.on('end', () => {
|
|
265
|
+
try {
|
|
266
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
267
|
+
const actual = body?.relay?.gatewayId;
|
|
268
|
+
if (!actual) {
|
|
269
|
+
resolve(`回环端口 ${port} 上的网关未上报 gatewayId,可能是同机运行的旧版本网关占用了该端口`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (actual !== expectedGatewayId) {
|
|
273
|
+
resolve(`回环端口 ${port} 上的网关身份不符(期望 ${expectedGatewayId},实际 ${actual}),` +
|
|
274
|
+
'同机可能运行着另一个网关实例');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
resolve(null);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
resolve(`回环端口 ${port} 的 /health 响应无法解析`);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
req.on('error', (err) => resolve(`回环端口 ${port} 探测失败: ${err.message}`));
|
|
285
|
+
req.on('timeout', () => {
|
|
286
|
+
req.destroy();
|
|
287
|
+
resolve(`回环端口 ${port} 探测超时`);
|
|
288
|
+
});
|
|
289
|
+
req.end();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
exports.verifyLoopbackIdentity = verifyLoopbackIdentity;
|