aws-runtime-bridge 1.9.70 → 1.9.71

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,21 @@
1
+ /**
2
+ * Bridge 反向隧道客户端
3
+ *
4
+ * 当 bridge 位于 NAT/内网(无公网),server 有公网时:bridge 注册虚拟隧道地址
5
+ *(形如 http(s)://server/tunnel/{instanceId}),注册成功后通过本客户端主动连出到
6
+ * server 的 /ws/bridge-tunnel,server 对 bridge 的 HTTP/WebSocket 调用均经此隧道转发。
7
+ *
8
+ * 多 server 支持:autoRegisterTargets 配置多个 server 时,每个 server 注册成功后
9
+ * 各自建立一条独立隧道,按归一化 serverUrl 为 key 并存于 tunnels Map,互不干扰;
10
+ * 同一 server 重复注册(重试/token 轮换)时先拆旧隧道再以最新 instanceId 重建。
11
+ *
12
+ * 协议帧格式见 BridgeTunnelService(server 端)。
13
+ */
14
+ /**
15
+ * 启动(或重启)到指定 server 的隧道。
16
+ * 同一 serverUrl 已存在隧道时先拆旧重建,不同 serverUrl 的隧道并存。
17
+ */
18
+ export declare function startTunnelClient(serverUrl: string, instanceId: string): void;
19
+ /** 停止全部隧道(graceful shutdown 用) */
20
+ export declare function stopTunnelClient(): void;
21
+ //# sourceMappingURL=tunnel-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tunnel-client.d.ts","sourceRoot":"","sources":["../../src/services/tunnel-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAgGH;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAuB7E;AAED,kCAAkC;AAClC,wBAAgB,gBAAgB,IAAI,IAAI,CAMvC"}
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Bridge 反向隧道客户端
3
+ *
4
+ * 当 bridge 位于 NAT/内网(无公网),server 有公网时:bridge 注册虚拟隧道地址
5
+ *(形如 http(s)://server/tunnel/{instanceId}),注册成功后通过本客户端主动连出到
6
+ * server 的 /ws/bridge-tunnel,server 对 bridge 的 HTTP/WebSocket 调用均经此隧道转发。
7
+ *
8
+ * 多 server 支持:autoRegisterTargets 配置多个 server 时,每个 server 注册成功后
9
+ * 各自建立一条独立隧道,按归一化 serverUrl 为 key 并存于 tunnels Map,互不干扰;
10
+ * 同一 server 重复注册(重试/token 轮换)时先拆旧隧道再以最新 instanceId 重建。
11
+ *
12
+ * 协议帧格式见 BridgeTunnelService(server 端)。
13
+ */
14
+ import axios, { AxiosError } from "axios";
15
+ import WebSocket from "ws";
16
+ import { getRuntimeAccessToken, normalizeSchedulerBaseUrl, } from "./runtime-binding.js";
17
+ import { loadConfigs, loadPrimaryLifecycleConfig } from "./auto-register.js";
18
+ import { logger } from "../utils/logger.js";
19
+ import { port } from "../config.js";
20
+ const PING_INTERVAL_MS = 15_000;
21
+ const RECONNECT_DELAY_MS = 5_000;
22
+ /** 本地 WS open 前的消息排队上限,防止异常情况下队列无限增长 */
23
+ const MAX_PENDING_WS_MESSAGES = 200;
24
+ /** server 端鉴权失败的关闭码(见 BridgeTunnelWebSocketHandler.CLOSE_AUTH_FAILED) */
25
+ const CLOSE_CODE_AUTH_FAILED = 4001;
26
+ /** 活跃隧道:key = 归一化 serverUrl,value = 该 server 的独立隧道状态 */
27
+ const tunnels = new Map();
28
+ function resolveTunnelEndpoint(state) {
29
+ const targetUrl = state.serverUrl;
30
+ const instanceId = state.instanceId;
31
+ if (!targetUrl || !instanceId) {
32
+ return null;
33
+ }
34
+ try {
35
+ const base = new URL(targetUrl);
36
+ const wsProto = base.protocol === "https:" ? "wss:" : "ws:";
37
+ const scopedToken = getRuntimeAccessToken(targetUrl);
38
+ // userKey 回退必须按触发注册的 server 匹配,避免多 target 时错用第一个 target 的凭据
39
+ const matchedConfig = loadConfigs().find((item) => normalizeSchedulerBaseUrl(item.serverUrl) === targetUrl) || loadPrimaryLifecycleConfig();
40
+ const runtimeAccessToken = scopedToken || matchedConfig.userKey;
41
+ if (scopedToken) {
42
+ logger.debug("[TunnelClient] 使用 runtime access token 连接隧道");
43
+ }
44
+ else if (matchedConfig.userKey) {
45
+ logger.warn("[TunnelClient] getRuntimeAccessToken 返回空,回退到 userKey(非 runtime token,鉴权可能失败): targetUrl=", targetUrl);
46
+ }
47
+ if (!runtimeAccessToken) {
48
+ logger.warn("[TunnelClient] 缺少 runtime token,无法建立隧道: targetUrl=", targetUrl);
49
+ return null;
50
+ }
51
+ // token 走 X-Runtime-Token 握手头,不进 URL,避免写入 server access log
52
+ const query = new URLSearchParams({ instanceId });
53
+ return {
54
+ url: `${wsProto}//${base.host}/ws/bridge-tunnel?${query.toString()}`,
55
+ token: runtimeAccessToken,
56
+ };
57
+ }
58
+ catch (e) {
59
+ logger.warn("[TunnelClient] 无法解析 serverUrl:", String(e));
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * 启动(或重启)到指定 server 的隧道。
65
+ * 同一 serverUrl 已存在隧道时先拆旧重建,不同 serverUrl 的隧道并存。
66
+ */
67
+ export function startTunnelClient(serverUrl, instanceId) {
68
+ const normalized = normalizeSchedulerBaseUrl(serverUrl);
69
+ if (!normalized) {
70
+ logger.warn("[TunnelClient] serverUrl 无效,无法启动隧道:", serverUrl);
71
+ return;
72
+ }
73
+ // 同一 server 已有隧道:先拆掉,避免重复注册导致同 server 多连接并存、
74
+ // 旧连接的 close 回调误清掉新连接的本地流
75
+ const existing = tunnels.get(normalized);
76
+ if (existing) {
77
+ existing.shuttingDown = true;
78
+ teardownConnection(existing);
79
+ tunnels.delete(normalized);
80
+ }
81
+ const state = {
82
+ serverUrl: normalized,
83
+ instanceId,
84
+ streams: new Map(),
85
+ shuttingDown: false,
86
+ };
87
+ tunnels.set(normalized, state);
88
+ connect(state);
89
+ }
90
+ /** 停止全部隧道(graceful shutdown 用) */
91
+ export function stopTunnelClient() {
92
+ for (const state of tunnels.values()) {
93
+ state.shuttingDown = true;
94
+ teardownConnection(state);
95
+ }
96
+ tunnels.clear();
97
+ }
98
+ /**
99
+ * 拆除指定隧道连接:清定时器、关闭全部本地流、关闭隧道 WS。
100
+ * 关闭前移除隧道 WS 的事件监听,防止其 close/error 回调再次触发
101
+ * cleanup/scheduleReconnect 干扰后续新连接。
102
+ */
103
+ function teardownConnection(state) {
104
+ if (state.timer) {
105
+ clearInterval(state.timer);
106
+ state.timer = undefined;
107
+ }
108
+ if (state.reconnectTimer) {
109
+ clearTimeout(state.reconnectTimer);
110
+ state.reconnectTimer = undefined;
111
+ }
112
+ for (const [id, stream] of state.streams.entries()) {
113
+ try {
114
+ stream.ws.close(1000, "tunnel client stopped");
115
+ }
116
+ catch {
117
+ // ignore
118
+ }
119
+ state.streams.delete(id);
120
+ }
121
+ if (state.ws) {
122
+ state.ws.removeAllListeners();
123
+ try {
124
+ state.ws.close(1000, "tunnel client stopped");
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ state.ws = undefined;
130
+ }
131
+ }
132
+ function connect(state) {
133
+ if (state.shuttingDown)
134
+ return;
135
+ const instanceId = state.instanceId;
136
+ if (!instanceId) {
137
+ return;
138
+ }
139
+ const endpoint = resolveTunnelEndpoint(state);
140
+ if (!endpoint) {
141
+ scheduleReconnect(state);
142
+ return;
143
+ }
144
+ logger.info("[TunnelClient] 连接隧道服务端:", endpoint.url);
145
+ const ws = new WebSocket(endpoint.url, {
146
+ handshakeTimeout: 15_000,
147
+ headers: { "X-Runtime-Token": endpoint.token },
148
+ });
149
+ state.ws = ws;
150
+ ws.on("open", () => {
151
+ logger.info("[TunnelClient] 隧道连接已建立: server=", state.serverUrl, "instanceId=", instanceId);
152
+ state.reconnectTimer && clearTimeout(state.reconnectTimer);
153
+ state.reconnectTimer = undefined;
154
+ state.timer = setInterval(() => {
155
+ if (ws.readyState === WebSocket.OPEN) {
156
+ ws.send(JSON.stringify({ t: "ping" }));
157
+ }
158
+ }, PING_INTERVAL_MS);
159
+ });
160
+ ws.on("unexpected-response", (req, res) => {
161
+ logger.error("[TunnelClient] 服务端拒绝连接(非 101 响应): server=", state.serverUrl, "statusCode=", res.statusCode, "statusMessage=", res.statusMessage);
162
+ });
163
+ ws.on("upgrade", (res) => {
164
+ logger.debug("[TunnelClient] 收到升级响应: statusCode=", res.statusCode);
165
+ });
166
+ ws.on("message", (raw) => {
167
+ let text;
168
+ if (typeof raw === "string") {
169
+ text = raw;
170
+ }
171
+ else if (Buffer.isBuffer(raw)) {
172
+ text = raw.toString("utf-8");
173
+ }
174
+ else {
175
+ text = Buffer.from(raw).toString("utf-8");
176
+ }
177
+ try {
178
+ const msg = JSON.parse(text);
179
+ handleMessage(state, msg);
180
+ }
181
+ catch (e) {
182
+ logger.warn("[TunnelClient] 解析隧道消息失败:", String(e));
183
+ }
184
+ });
185
+ ws.on("close", (code, reason) => {
186
+ logger.warn("[TunnelClient] 隧道连接关闭: server=", state.serverUrl, "code=", code, "reason=", String(reason));
187
+ cleanup(state);
188
+ if (code === CLOSE_CODE_AUTH_FAILED) {
189
+ // 鉴权失败:继续重连只会每 5s 刷一次鉴权失败日志。
190
+ // 停止自动重连,等下次注册成功(autoRegisterSingle)显式重启隧道。
191
+ logger.warn("[TunnelClient] 鉴权失败(4001),停止自动重连;待重新注册后再启动隧道: server=", state.serverUrl);
192
+ return;
193
+ }
194
+ scheduleReconnect(state);
195
+ });
196
+ ws.on("error", (err) => {
197
+ logger.warn("[TunnelClient] 隧道连接错误: server=", state.serverUrl, "err=", err.message, "code=", err.code);
198
+ cleanup(state);
199
+ scheduleReconnect(state);
200
+ });
201
+ }
202
+ function cleanup(state) {
203
+ if (state.timer) {
204
+ clearInterval(state.timer);
205
+ state.timer = undefined;
206
+ }
207
+ // 关闭本地 WS 流
208
+ for (const [id, stream] of state.streams.entries()) {
209
+ try {
210
+ stream.ws.close(1011, "tunnel disconnected");
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ state.streams.delete(id);
216
+ }
217
+ state.ws = undefined;
218
+ }
219
+ function scheduleReconnect(state) {
220
+ if (state.shuttingDown || state.reconnectTimer)
221
+ return;
222
+ state.reconnectTimer = setTimeout(() => {
223
+ state.reconnectTimer = undefined;
224
+ connect(state);
225
+ }, RECONNECT_DELAY_MS);
226
+ }
227
+ function handleMessage(state, msg) {
228
+ switch (msg.t) {
229
+ case "req":
230
+ handleHttpRequest(state, msg);
231
+ break;
232
+ case "wsopen":
233
+ handleWsOpen(state, msg);
234
+ break;
235
+ case "wsmsg":
236
+ handleWsMessage(state, msg);
237
+ break;
238
+ case "wsclose":
239
+ handleWsClose(state, msg);
240
+ break;
241
+ case "ping":
242
+ // server 端心跳探测,回复 pong 保持连接活跃
243
+ sendJson(state, { t: "pong" });
244
+ break;
245
+ case "pong":
246
+ break;
247
+ default:
248
+ logger.debug("[TunnelClient] 未知消息类型: t=", msg.t);
249
+ }
250
+ }
251
+ async function handleHttpRequest(state, msg) {
252
+ const id = msg.id;
253
+ const method = msg.method || "GET";
254
+ const path = msg.path || "/";
255
+ const localUrl = `http://127.0.0.1:${port}${path}`;
256
+ try {
257
+ const bodyBuffer = msg.body ? Buffer.from(msg.body, "base64") : undefined;
258
+ const headers = {};
259
+ if (msg.headers) {
260
+ for (const [key, values] of Object.entries(msg.headers)) {
261
+ if (values && values.length > 0) {
262
+ headers[key] = values[0];
263
+ }
264
+ }
265
+ }
266
+ const response = await axios.request({
267
+ url: localUrl,
268
+ method,
269
+ headers,
270
+ data: bodyBuffer,
271
+ timeout: 120_000,
272
+ responseType: "arraybuffer",
273
+ validateStatus: () => true,
274
+ });
275
+ const respHeaders = {};
276
+ for (const [key, val] of Object.entries(response.headers)) {
277
+ if (val === undefined)
278
+ continue;
279
+ respHeaders[key] = Array.isArray(val) ? val : [String(val)];
280
+ }
281
+ const bodyBase64 = Buffer.isBuffer(response.data)
282
+ ? response.data.toString("base64")
283
+ : "";
284
+ sendJson(state, {
285
+ t: "res",
286
+ id,
287
+ status: response.status,
288
+ headers: respHeaders,
289
+ body: bodyBase64 || null,
290
+ });
291
+ }
292
+ catch (err) {
293
+ const errorMsg = err instanceof AxiosError ? err.message : String(err);
294
+ logger.warn("[TunnelClient] 本地转发失败:", method, path, "->", errorMsg);
295
+ sendJson(state, {
296
+ t: "res",
297
+ id,
298
+ status: 502,
299
+ headers: { "content-type": ["text/plain; charset=utf-8"] },
300
+ body: Buffer.from(errorMsg).toString("base64"),
301
+ });
302
+ }
303
+ }
304
+ function handleWsOpen(state, msg) {
305
+ const id = msg.id;
306
+ const path = msg.path || "/";
307
+ const localWsUrl = `ws://127.0.0.1:${port}${path}`;
308
+ let localWs;
309
+ try {
310
+ localWs = new WebSocket(localWsUrl, { handshakeTimeout: 10_000 });
311
+ }
312
+ catch (e) {
313
+ logger.warn("[TunnelClient] 本地 WS 连接失败:", String(e));
314
+ sendJson(state, { t: "wsclose", id, code: 1011, reason: "local ws connect failed" });
315
+ return;
316
+ }
317
+ const stream = { ws: localWs, pending: [] };
318
+ state.streams.set(id, stream);
319
+ localWs.on("open", () => {
320
+ // 先 flush open 前排队消息(保持到达顺序),再通知 server 流已就绪
321
+ for (const item of stream.pending) {
322
+ sendToLocal(stream, item.data, item.bin);
323
+ }
324
+ stream.pending = [];
325
+ sendJson(state, { t: "wsopened", id });
326
+ });
327
+ localWs.on("message", (data, isBinary) => {
328
+ // ws 库通过第二参数 isBinary 区分文本/二进制帧;data 类型始终是 Buffer。
329
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
330
+ sendJson(state, {
331
+ t: "wsmsg",
332
+ id,
333
+ bin: isBinary,
334
+ d: buffer.toString("base64"),
335
+ });
336
+ });
337
+ localWs.on("close", (code, reason) => {
338
+ state.streams.delete(id);
339
+ sendJson(state, { t: "wsclose", id, code, reason: String(reason) });
340
+ });
341
+ localWs.on("error", (err) => {
342
+ logger.warn("[TunnelClient] 本地 WS 错误: id=", id, "err=", err.message);
343
+ state.streams.delete(id);
344
+ try {
345
+ localWs.close(1011, err.message);
346
+ }
347
+ catch {
348
+ // ignore
349
+ }
350
+ sendJson(state, { t: "wsclose", id, code: 1011, reason: err.message });
351
+ });
352
+ }
353
+ function sendToLocal(stream, data, bin) {
354
+ if (bin) {
355
+ stream.ws.send(data);
356
+ }
357
+ else {
358
+ stream.ws.send(data.toString("utf-8"));
359
+ }
360
+ }
361
+ function handleWsMessage(state, msg) {
362
+ const stream = state.streams.get(msg.id);
363
+ if (!stream)
364
+ return;
365
+ const data = msg.d ? Buffer.from(msg.d, "base64") : Buffer.alloc(0);
366
+ if (stream.ws.readyState !== WebSocket.OPEN) {
367
+ // 本地 WS 尚未 open:排队等待,避免 PTY 打开瞬间的输入被丢弃
368
+ if (stream.pending.length >= MAX_PENDING_WS_MESSAGES) {
369
+ stream.pending.shift();
370
+ logger.warn("[TunnelClient] 本地 WS 未就绪,排队消息超上限,丢弃最旧一条: id=", msg.id);
371
+ }
372
+ stream.pending.push({ data, bin: Boolean(msg.bin) });
373
+ return;
374
+ }
375
+ sendToLocal(stream, data, Boolean(msg.bin));
376
+ }
377
+ function handleWsClose(state, msg) {
378
+ const stream = state.streams.get(msg.id);
379
+ if (!stream)
380
+ return;
381
+ state.streams.delete(msg.id);
382
+ try {
383
+ stream.ws.close(msg.code ?? 1000, msg.reason || "");
384
+ }
385
+ catch {
386
+ // ignore
387
+ }
388
+ }
389
+ function sendJson(state, payload) {
390
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN)
391
+ return;
392
+ try {
393
+ state.ws.send(JSON.stringify(payload));
394
+ }
395
+ catch (e) {
396
+ logger.warn("[TunnelClient] sendJson 失败:", String(e));
397
+ }
398
+ }
@@ -376,7 +376,7 @@ async function flushChange(fullPath) {
376
376
  async function sendChangeRecord(ctx, record, cacheKey) {
377
377
  try {
378
378
  const callbackBaseUrl = resolveSchedulerBaseUrl().url;
379
- const runtimeAccessToken = getRuntimeAccessToken(undefined, callbackBaseUrl) || getRuntimeAccessToken();
379
+ const runtimeAccessToken = getRuntimeAccessToken(callbackBaseUrl) || getRuntimeAccessToken();
380
380
  if (!runtimeAccessToken) {
381
381
  log.warn('Cannot send change record: no auth token');
382
382
  return;
@@ -87,7 +87,7 @@ export async function downloadSkillZip(skillPackage, targetDir) {
87
87
  ? String(skillPackage.fileName).trim()
88
88
  : 'skill-package.zip';
89
89
  const outputPath = path.join(targetDir, fileName);
90
- const runtimeAccessToken = getRuntimeAccessToken(undefined, schedulerBaseUrl) || getRuntimeAccessToken();
90
+ const runtimeAccessToken = getRuntimeAccessToken(schedulerBaseUrl) || getRuntimeAccessToken();
91
91
  if (!runtimeAccessToken) {
92
92
  throw new Error('runtime access token is required to download skill package');
93
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws-runtime-bridge",
3
- "version": "1.9.70",
3
+ "version": "1.9.71",
4
4
  "description": "AgentsWorkStudio runtime bridge service for machine-level agent runtime integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",