@ntdgy/dsh-remote-bridge-plugin 0.0.0-stage → 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.
@@ -0,0 +1,825 @@
1
+ /**
2
+ * 出站 WebSocket 客户端。
3
+ *
4
+ * 关键约束:
5
+ * - 只有 DSH 主动拨出,网关永不主动连入(约束 C1)
6
+ * - 指数退避重连,上限可配;stop() 必须清理 timer
7
+ * - 凭据不进 URL 查询串,握手后第一帧带(见 ADR-003)
8
+ * - 入站方向**只接受 RpcRequest**;帧(kind:'frame')由实例发往浏览器,
9
+ * 反向任何帧一律丢弃并计数(ADR-004 纪律 1)
10
+ */
11
+ import { createHmac } from 'node:crypto';
12
+ import WebSocket from 'ws';
13
+ import { classifyWireMessage, encodeHttpBodyFrame, decodeHttpBodyFrame, decodeWsDataFrame, } from '@ntdgy/dsh-remote-proto';
14
+ import { RpcError } from "./errors.js";
15
+ /** WS ping 间隔;一个间隔内没收到 pong 即判死连接。 */
16
+ const PING_INTERVAL_MS = 15_000;
17
+ /**
18
+ * 挑战应答的签名载荷。
19
+ *
20
+ * `nonce:instanceId:timestamp` —— instanceId 与 timestamp 都进签名,防止:
21
+ * - 拿 A 实例的应答去冒充 B 实例(instanceId 不同则签名不同);
22
+ * - 重放旧应答(timestamp 由本端自带,配合网关的 nonce 一次性消费与 TTL)。
23
+ *
24
+ * 与网关 AgentAuth.verify 的既有实现对同一载荷格式,签名语义两侧必须逐字一致。
25
+ */
26
+ function signChallenge(instanceKey, nonce, instanceId, timestamp) {
27
+ return createHmac('sha256', instanceKey)
28
+ .update(`${nonce}:${instanceId}:${timestamp}`)
29
+ .digest('base64url');
30
+ }
31
+ /**
32
+ * 把一个抛出的值格式化成**可指认**的一行文案。
33
+ *
34
+ * 目标:让同一故障在任何观测路径下产出同一段文字,且带上 name 与 cause 链,
35
+ * 而不是退化成裸的 "Cannot read properties of undefined"。
36
+ */
37
+ function describeError(value) {
38
+ if (!(value instanceof Error))
39
+ return String(value);
40
+ const parts = [];
41
+ const name = value.name === 'Error' ? '' : value.name;
42
+ parts.push(name === '' ? value.message : `${name}: ${value.message}`);
43
+ const code = value.code;
44
+ if (typeof code === 'string' && !parts[0].includes(code))
45
+ parts.push(`(code=${code})`);
46
+ // cause 链最多展开 3 层:够指认来源,又不至于把响应体撑爆。
47
+ let cause = value.cause;
48
+ for (let depth = 0; depth < 3 && cause !== undefined; depth += 1) {
49
+ if (cause instanceof Error) {
50
+ parts.push(`<- ${cause.name === 'Error' ? cause.message : `${cause.name}: ${cause.message}`}`);
51
+ cause = cause.cause;
52
+ }
53
+ else {
54
+ parts.push(`<- ${String(cause)}`);
55
+ break;
56
+ }
57
+ }
58
+ return parts.join(' ');
59
+ }
60
+ export function checkRelayUrlScheme(url) {
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(url);
64
+ }
65
+ catch {
66
+ return { fatalReason: `relayUrl is not a valid URL: ${url}` };
67
+ }
68
+ if (parsed.protocol === 'wss:')
69
+ return {};
70
+ if (parsed.protocol === 'ws:') {
71
+ return {
72
+ insecureReason: 'relayUrl uses ws:// (plaintext): the HMAC handshake can be bypassed by a ' +
73
+ 'man-in-the-middle. Acceptable only behind a local TLS terminator or in local testing.',
74
+ };
75
+ }
76
+ return { fatalReason: `relayUrl must be ws:// or wss://, got ${parsed.protocol}` };
77
+ }
78
+ /** 插件实际实现的 method 子集;白名单全量在 proto,这里只报"我支持这些"。 */
79
+ const ADVERTISED_METHODS = [
80
+ 'session.list',
81
+ 'session.history',
82
+ 'session.prompt',
83
+ 'session.follow',
84
+ ];
85
+ /**
86
+ * WS 建连(TCP + HTTP Upgrade)超时。
87
+ *
88
+ * 为什么必须有:对端如果是 **TCP 黑洞**(丢包而非回 RST),内核握手会一直重传,
89
+ * ws 的 open/error/close **一个都不触发**,socket 永久停在 CONNECTING。
90
+ * 那样退避重连根本不会被调度(它挂在 close 事件上),整条链路静默死锁——
91
+ * 表现为"插件在跑,但网关永远收不到 hello"。
92
+ *
93
+ * 15s:覆盖跨洲 RTT + TLS 握手的正常上限,又短于网关 45s 的心跳巡检窗口,
94
+ * 保证一次坏连接不会拖过一个巡检周期。
95
+ */
96
+ const WS_HANDSHAKE_TIMEOUT_MS = 15_000;
97
+ /**
98
+ * 发送缓冲的水位阈值(字节)。
99
+ *
100
+ * 选值依据(不是拍脑袋):
101
+ * - 单个 HTTP 响应体上限 HTTP_BODY_MAX_BYTES = 32MB,一帧最大也就这个量级;
102
+ * - 软上限 8MB ≈ 正常突发(若干并发小响应 + 帧流)的几倍,不该误报;
103
+ * - 硬上限 64MB ≈ 两个满额响应体。到这一步网关显然已经不读了,
104
+ * 再写就是纯粹的内存燃烧,断连比继续灌更负责。
105
+ *
106
+ * 用 bufferedAmount(ws 暴露的内核+userland 待发字节数)作为唯一判据,
107
+ * 不自己维护计数器——那会在重连/多路复用时与实际不同步。
108
+ */
109
+ const SEND_BUFFER_SOFT_LIMIT_BYTES = 8 * 1024 * 1024;
110
+ const SEND_BUFFER_HARD_LIMIT_BYTES = 64 * 1024 * 1024;
111
+ /**
112
+ * 同时在途的入站 HTTP 请求上限。
113
+ *
114
+ * 为什么要有:`void this.onHttpRequest(...)` 是无上限并发派发。
115
+ * 每个在途请求最终都会 produce 一个最大 32MB 的响应体,且都要排队经过上面那个发送缓冲。
116
+ * 恶意或故障网关只要狂发 http-request 就能让本进程同时持有几十份大 buffer。
117
+ *
118
+ * 32:按 32MB 上限算,最坏 1GB 上下,仍是可控量级;正常面板的并发远低于此。
119
+ */
120
+ const MAX_INFLIGHT_HTTP_REQUESTS = 32;
121
+ export class RelayClient {
122
+ socket;
123
+ timer;
124
+ heartbeatTimer;
125
+ /** WS ping/pong 存活探测的定时器。 */
126
+ pingTimer;
127
+ /** 上一轮 ping 是否尚未收到 pong。 */
128
+ awaitingPong = false;
129
+ delay;
130
+ stopped = false;
131
+ connectionId;
132
+ /** 是否已收到 hello 回执。未确认前收到的业务请求一律拒绝。 */
133
+ registered = false;
134
+ heartbeatIntervalMs;
135
+ /** 被丢弃的反向帧计数,供调试读取(ADR-004 纪律 1 的可观测位)。 */
136
+ droppedFrames = 0;
137
+ /** 认证被拒次数。持续增长 = 密钥配错,而不是网络问题。 */
138
+ authFailures = 0;
139
+ /**
140
+ * 本次连接上 hello 的请求 id。
141
+ *
142
+ * 用来校验 hello-challenge 是不是**这一次**握手的挑战:断线重连时旧连接与新连接
143
+ * 可能短暂共存,迟到的挑战若不校验,会拿过期 nonce 去签名,
144
+ * 表现为"握手失败但看不出原因"。
145
+ */
146
+ pendingHelloId;
147
+ /** 在途的入站 HTTP 请求数,与 MAX_INFLIGHT_HTTP_REQUESTS 对照。 */
148
+ inflightHttpRequests = 0;
149
+ opts;
150
+ constructor(opts) {
151
+ this.opts = opts;
152
+ this.delay = opts.reconnectBaseMs;
153
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs;
154
+ }
155
+ get connected() {
156
+ return this.socket?.readyState === WebSocket.OPEN;
157
+ }
158
+ /** 已注册(收到 hello 回执)才算真正可用。 */
159
+ get ready() {
160
+ return this.connected && this.registered;
161
+ }
162
+ get stats() {
163
+ return {
164
+ droppedFrames: this.droppedFrames,
165
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
166
+ authFailures: this.authFailures,
167
+ };
168
+ }
169
+ start() {
170
+ this.stopped = false;
171
+ this.connect();
172
+ }
173
+ stop() {
174
+ this.stopped = true;
175
+ this.clearHeartbeat();
176
+ if (this.timer) {
177
+ clearTimeout(this.timer);
178
+ this.timer = undefined;
179
+ }
180
+ this.socket?.close(1000, 'shutdown');
181
+ this.socket = undefined;
182
+ this.registered = false;
183
+ }
184
+ connect() {
185
+ if (this.stopped)
186
+ return;
187
+ // scheme 校验:非 TLS 链路可被中间人旁路掉 HMAC 挑战应答(nonce 与签名都可被替换),
188
+ // 那样"认证通过"就只是假象。
189
+ //
190
+ // 这里必须区分两种结果,否则会出现「告警刷屏」和「把可接受配置当错误」两个问题:
191
+ // · 不可用(非 ws/wss)→ 致命,重连没意义,但也不该崩进程
192
+ // · 可用但不安全(ws://)→ **只提醒一次**,然后照常连接
193
+ //
194
+ // 上一版把两者混在一起:ws:// 也走 reportFatalConfig 并 scheduleReconnect,
195
+ // 于是每一次重连都打一条同样的 stderr(实测 8 条),而且把「本机联调合法配置」
196
+ // 当成错误处理。日志噪音会让人对真正的告警失去敏感 —— 这比不告警更糟。
197
+ const schemeVerdict = checkRelayUrlScheme(this.opts.url);
198
+ if (schemeVerdict.insecureReason !== undefined) {
199
+ this.warnInsecureSchemeOnce(schemeVerdict.insecureReason);
200
+ }
201
+ if (schemeVerdict.fatalReason !== undefined) {
202
+ this.reportFatalConfig(schemeVerdict.fatalReason);
203
+ this.scheduleReconnect();
204
+ return;
205
+ }
206
+ let socket;
207
+ try {
208
+ socket = new WebSocket(this.opts.url, { handshakeTimeout: WS_HANDSHAKE_TIMEOUT_MS });
209
+ }
210
+ catch (error) {
211
+ // new WebSocket 对畸形 URL 会同步抛。不接住会从 socket.on('open') 的调用栈里炸出去。
212
+ this.reportFatalConfig(`invalid relayUrl: ${error instanceof Error ? error.message : String(error)}`);
213
+ this.scheduleReconnect();
214
+ return;
215
+ }
216
+ this.socket = socket;
217
+ this.registered = false;
218
+ // 新连接:网关那边没有本实例的任何状态,下一次心跳必须完整带上 launchUrl。
219
+ this.lastSentLaunchUrl = undefined;
220
+ socket.on('open', async () => {
221
+ // TCP keepalive 兜底:对端进程被 kill -9 后内核层最终会探到。
222
+ // 但默认以小时计,所以**主判据是 WS ping/pong**(见 startLivenessProbe),这里只是兜底。
223
+ const raw = socket._socket;
224
+ raw?.setKeepAlive?.(true, 30_000);
225
+ // 凭据不进查询串:握手后立即发 hello(**不带密钥**)。
226
+ this.pendingHelloId = crypto.randomUUID();
227
+ // ADR-005 运行时自声明:读取 webServer 已注册路由。
228
+ // 可能要在别的插件注册完之前等一小段时间(见 collectWithRetry),
229
+ // 因此这里是 async —— 但 hello 只是稍晚发出,不影响协议正确性。
230
+ let prefixes = [];
231
+ if (this.opts.collectHttpPrefixes) {
232
+ try {
233
+ prefixes = await this.opts.collectHttpPrefixes();
234
+ }
235
+ catch {
236
+ // 读取失败不影响握手本身:退化为「未上报」,网关会打印提示。
237
+ prefixes = [];
238
+ }
239
+ }
240
+ // 已认证应用 URL:网关据此代持 DSH 会话。取不到就不上报(不是错误)。
241
+ let launchUrl;
242
+ if (this.opts.collectLaunchUrl) {
243
+ try {
244
+ const value = this.opts.collectLaunchUrl();
245
+ if (typeof value === 'string' && value.length > 0)
246
+ launchUrl = value;
247
+ }
248
+ catch {
249
+ // 拿不到 token 不影响握手本身:退化为「用户自行带 ?token=」。
250
+ launchUrl = undefined;
251
+ }
252
+ }
253
+ const hello = {
254
+ id: this.pendingHelloId,
255
+ method: 'hello',
256
+ params: {
257
+ instanceId: this.opts.instanceId,
258
+ version: '0.1.0',
259
+ methods: ADVERTISED_METHODS,
260
+ ...(prefixes.length > 0 ? { httpPrefixes: prefixes } : {}),
261
+ ...(launchUrl !== undefined ? { launchUrl } : {}),
262
+ },
263
+ ts: Date.now(),
264
+ };
265
+ socket.send(JSON.stringify(hello));
266
+ // 心跳**推迟到挑战应答通过之后**(见 onResponse)。
267
+ //
268
+ // 以前在 open 时就开跑,理由是"网关在 hello 时即登记、早发可避免巡检误杀"。
269
+ // P5 起网关不再在 hello 时登记,而是挑战应答通过才登记;未认证连接发心跳
270
+ // 只会被当成未授权流量。把它挪到 registered=true 之后,语义与网关一致。
271
+ });
272
+ socket.on('message', (raw, isBinary) => {
273
+ // 二进制帧不是 JSON:HTTP body 与 WS 数据帧都走这里,按 id 前缀分流。
274
+ if (isBinary) {
275
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
276
+ this.onBinary(buf);
277
+ return;
278
+ }
279
+ void this.onMessage(raw.toString());
280
+ });
281
+ // 收到 pong 即证明对端进程活着(协议层,不受应用层缓冲区迷惑)。
282
+ socket.on('pong', () => {
283
+ this.awaitingPong = false;
284
+ });
285
+ socket.on('close', (code, reason) => {
286
+ this.socket = undefined;
287
+ this.registered = false;
288
+ this.pendingHelloId = undefined;
289
+ this.clearHeartbeat();
290
+ this.opts.onClose(`code=${code} reason=${reason.toString()}`, code);
291
+ this.scheduleReconnect();
292
+ });
293
+ socket.on('error', (err) => {
294
+ // error 之后必然跟随 close,这里只记录,不重复调度重连。
295
+ this.opts.onClose(err.message, -1);
296
+ });
297
+ }
298
+ /**
299
+ * ADR-005 HTTP 转发:先回控制帧,再回二进制 body 帧。
300
+ *
301
+ * 顺序是协议约定(body 帧必须紧随其控制帧);这里用 void 并发调度,
302
+ * 多个请求互不阻塞。
303
+ */
304
+ /**
305
+ * 在途 HTTP 请求已满:回明确的错误帧,不排队。
306
+ *
307
+ * 复用既有的 http-response-error 通道而不是硬塞一个 503 响应:
308
+ * - HttpResponseFrame 没有 reason 字段,伪造一个能让网关解析失败;
309
+ * - 该通道的语义正是"请求根本没被完整处理",与本场景一致,前端能区分。
310
+ *
311
+ * 排队不是选项:排队只是把内存压力推迟到队列里,而且让请求方白等到超时,
312
+ * 错误更不可读。明确拒绝比慢慢耗尽内存负责。
313
+ */
314
+ rejectTooBusy(frame) {
315
+ const errorFrame = {
316
+ kind: 'http-response-error',
317
+ id: frame.id,
318
+ code: 'internal',
319
+ message: `bridge busy: more than ${MAX_INFLIGHT_HTTP_REQUESTS} concurrent requests in flight`,
320
+ };
321
+ this.sendRaw(JSON.stringify(errorFrame));
322
+ }
323
+ async onHttpRequest(frame) {
324
+ // POST 的请求体由紧随控制帧的二进制帧送达。到这里时它可能还没到
325
+ // (两条独立 WS 消息,顺序不由我们控制),所以等待一小段时间再取。
326
+ const body = frame.bodyLength > 0 ? await this.awaitRequestBody(frame.id) : undefined;
327
+ const handler = this.opts.onHttpRequest;
328
+ let result;
329
+ if (!handler) {
330
+ result = {
331
+ frame: {
332
+ kind: 'http-response-error',
333
+ id: frame.id,
334
+ code: 'upstream_unreachable',
335
+ message: 'plugin has no HTTP forwarder configured',
336
+ },
337
+ };
338
+ }
339
+ else {
340
+ try {
341
+ result = await handler(frame, body);
342
+ }
343
+ catch (err) {
344
+ result = {
345
+ frame: {
346
+ kind: 'http-response-error',
347
+ id: frame.id,
348
+ code: 'internal',
349
+ message: err instanceof Error ? err.message : String(err),
350
+ },
351
+ };
352
+ }
353
+ }
354
+ this.sendRaw(JSON.stringify(result.frame));
355
+ if (result.body && result.body.byteLength > 0) {
356
+ this.sendRaw(encodeHttpBodyFrame(frame.id, result.body));
357
+ }
358
+ }
359
+ /**
360
+ * 底层发送:JSON 走文本帧,Uint8Array 走二进制帧。带**发送水位**检查。
361
+ *
362
+ * 为什么需要:ws 的 send() 只是把数据塞进内核/userland 缓冲**立即返回**。
363
+ * 网关不读(挂住、限速、被暂停)时缓冲只增不减,本进程内存随之线性增长;
364
+ * 单个 HTTP 响应体上限是 32MB(HTTP_BODY_MAX_BYTES),几十个在途响应就足以吃光内存。
365
+ * 故障网关或恶意网关都能远程触发这件事——私网 DSH 不该被公网对端拖垮。
366
+ *
367
+ * 策略:**不丢帧**(丢帧会让上面的多路复用协议失去同步),而是当水位超过硬上限时
368
+ * 主动断开本条连接。断开是安全的收口:上层走既有退避重连,网关侧 dropInstance
369
+ * 会作废名下订阅,本端重建状态后重新开始。
370
+ *
371
+ * @returns 是否真的写出去了。调用方目前都不依赖返回值,保留以便将来分流。
372
+ */
373
+ sendRaw(payload) {
374
+ const socket = this.socket;
375
+ if (!socket || socket.readyState !== WebSocket.OPEN)
376
+ return false;
377
+ const buffered = socket.bufferedAmount;
378
+ if (buffered > SEND_BUFFER_HARD_LIMIT_BYTES) {
379
+ // 硬上限:继续写只会吃内存,直接弃连接,交给退避重连。
380
+ process.stderr.write(`remote-bridge: send buffer exceeded ${SEND_BUFFER_HARD_LIMIT_BYTES} bytes ` +
381
+ `(buffered=${buffered}); closing the connection to shed load\n`);
382
+ this.opts.onBackpressure?.(buffered);
383
+ // 1009 = message too big 的语义不合适;用 1013(try again later)表达"对端跟不上"。
384
+ try {
385
+ socket.close(1013, 'send buffer overflow');
386
+ }
387
+ catch { /* already gone */ }
388
+ return false;
389
+ }
390
+ if (buffered > SEND_BUFFER_SOFT_LIMIT_BYTES) {
391
+ // 软上限:只告警不中断。留出观察窗口,避免正常突发流量被直接掐断。
392
+ this.opts.onProtocolWarning?.(`send buffer high watermark: ${buffered} bytes (soft limit ${SEND_BUFFER_SOFT_LIMIT_BYTES})`);
393
+ }
394
+ socket.send(payload);
395
+ return true;
396
+ }
397
+ /** 发一条控制帧(JSON 文本帧)。ADR-005 T34 的 ws-open-ack / ws-close 走这里。 */
398
+ sendControl(frame) {
399
+ this.sendRaw(JSON.stringify(frame));
400
+ }
401
+ /** 发一条二进制帧(WS 数据帧)。与 HTTP body 帧共用通道,靠 id 前缀区分。 */
402
+ sendBinary(payload) {
403
+ this.sendRaw(payload);
404
+ }
405
+ /**
406
+ * 入站分发。
407
+ *
408
+ * 修复点(原 client.ts:101):旧判定把**响应**当 hello 请求判,
409
+ * 而网关 hello 回执是 `{ id, ok: true, result }`,没有 method 字段,
410
+ * 因此 onOpen 永不触发、退避永不重置。现在用 proto 的唯一判别函数
411
+ * classifyWireMessage 区分顶层消息(request / response / frame / frame-end /
412
+ * sub-control / hello-challenge)。"挑战"因此是**类型级**判别,不靠约定。
413
+ */
414
+ /** 待配对的 HTTP 请求体:id → {body, resolve}。 */
415
+ pendingBodies = new Map();
416
+ /**
417
+ * 等请求体二进制帧。超时返回 undefined,由调用方报明确错误而不是挂死。
418
+ *
419
+ * 用 Promise + 定时器而不是轮询:这是纯粹的「等一条消息」语义。
420
+ */
421
+ awaitRequestBody(id) {
422
+ const already = this.pendingBodies.get(id);
423
+ if (already) {
424
+ this.pendingBodies.delete(id);
425
+ return Promise.resolve(already.body);
426
+ }
427
+ return new Promise((resolve) => {
428
+ const timer = setTimeout(() => {
429
+ this.pendingBodies.delete(id);
430
+ resolve(undefined);
431
+ }, 5_000);
432
+ timer.unref?.();
433
+ this.pendingBodies.set(id, {
434
+ body: new Uint8Array(0),
435
+ resolve: (b) => { clearTimeout(timer); resolve(b); },
436
+ });
437
+ });
438
+ }
439
+ /** 二进制帧入口:区分 HTTP body 与 WS 数据帧(按 id 前缀 'h' / 'w')。 */
440
+ onBinary(raw) {
441
+ const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
442
+ if (view.length < 5) {
443
+ this.droppedFrames += 1;
444
+ return;
445
+ }
446
+ const idLen = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false);
447
+ if (idLen === 0 || idLen > 256 || view.length < 4 + idLen) {
448
+ this.droppedFrames += 1;
449
+ return;
450
+ }
451
+ const id = new TextDecoder().decode(view.subarray(4, 4 + idLen));
452
+ if (id.startsWith('w')) {
453
+ const frame = decodeWsDataFrame(view);
454
+ if (!frame) {
455
+ this.droppedFrames += 1;
456
+ return;
457
+ }
458
+ this.opts.onWsData?.(frame.id, frame.opcode, frame.payload);
459
+ return;
460
+ }
461
+ // 'h' 前缀:HTTP 请求体(POST)。配给等待中的 onHttpRequest。
462
+ const decodedBody = decodeHttpBodyFrame(view);
463
+ if (!decodedBody) {
464
+ this.droppedFrames += 1;
465
+ return;
466
+ }
467
+ const waiting = this.pendingBodies.get(decodedBody.id);
468
+ if (!waiting) {
469
+ // 请求已超时或 id 未知:计数不静默。
470
+ this.droppedFrames += 1;
471
+ return;
472
+ }
473
+ this.pendingBodies.delete(decodedBody.id);
474
+ waiting.resolve(decodedBody.body);
475
+ }
476
+ async onMessage(raw) {
477
+ let parsed;
478
+ try {
479
+ parsed = JSON.parse(raw);
480
+ }
481
+ catch {
482
+ this.opts.onProtocolWarning?.('inbound message is not valid JSON');
483
+ return;
484
+ }
485
+ const kind = classifyWireMessage(parsed);
486
+ // ── 统一入口守卫:握手完成前只接受握手相关的消息 ────────────────────
487
+ //
488
+ // 上一版的守卫写在 onInboundRequest **里面**,于是同级的四个分支
489
+ // (http-request / ws-open / ws-flow / ws-close)全部漏掉 ——
490
+ // 新加分支还会继续漏,因为拦截不在入口。
491
+ //
492
+ // 现在的规则:未认证时只放行
493
+ // · hello-challenge —— 网关下发的挑战,握手需要
494
+ // · response —— 对挑战的应答由 onResponse 内部判定 id 是否匹配
495
+ // 其余一律丢弃并计数。这与 onInboundRequest 里的语义一致,但覆盖了全部分支。
496
+ if (!this.registered && kind !== 'hello-challenge' && kind !== 'response') {
497
+ this.droppedFrames += 1;
498
+ this.opts.onProtocolWarning?.('inbound "' + String(kind) + '" dropped: handshake not completed');
499
+ return;
500
+ }
501
+ switch (kind) {
502
+ case 'response':
503
+ this.onResponse(parsed);
504
+ return;
505
+ case 'request':
506
+ await this.onInboundRequest(parsed);
507
+ return;
508
+ case 'frame':
509
+ case 'frame-end':
510
+ // 帧只能由实例发往浏览器。反向收到帧即协议违规,丢弃并计数。
511
+ this.droppedFrames += 1;
512
+ this.opts.onProtocolWarning?.('inbound frame rejected: frames travel instance→browser only');
513
+ return;
514
+ case 'sub-control':
515
+ this.onSubscriptionControl(parsed);
516
+ return;
517
+ case 'hello-challenge':
518
+ this.onHelloChallenge(parsed);
519
+ return;
520
+ case 'http-request': {
521
+ // ADR-005:网关转来的 HTTP 请求。并发处理,不排队(多路复用)。
522
+ //
523
+ // 但并发**必须有上限**:每个请求最终持有一份最大 32MB 的响应体,
524
+ // 无上限派发等于把本进程的内存交给对端决定。
525
+ // 超限时回一个明确的错误帧而不是排队 —— 排队只会把内存压力推迟,
526
+ // 而且让请求方白等到超时,错误更不可读。
527
+ const frame = parsed;
528
+ if (this.inflightHttpRequests >= MAX_INFLIGHT_HTTP_REQUESTS) {
529
+ this.rejectTooBusy(frame);
530
+ return;
531
+ }
532
+ this.inflightHttpRequests += 1;
533
+ void this.onHttpRequest(frame).finally(() => {
534
+ this.inflightHttpRequests -= 1;
535
+ });
536
+ return;
537
+ }
538
+ case 'ws-open':
539
+ // ADR-005 T34:WS 升级。异步发起,不阻塞消息循环。
540
+ this.opts.onWsOpen?.(parsed);
541
+ return;
542
+ case 'ws-flow': {
543
+ const flow = parsed;
544
+ this.opts.onWsFlow?.(flow.id, flow.action);
545
+ return;
546
+ }
547
+ case 'ws-close': {
548
+ const close = parsed;
549
+ this.opts.onWsClose?.(close.id, close.code, close.reason);
550
+ return;
551
+ }
552
+ default:
553
+ this.opts.onProtocolWarning?.('inbound message not classifiable');
554
+ }
555
+ }
556
+ /**
557
+ * 订阅控制帧(网关 → 插件)。
558
+ *
559
+ * 控制面事务:不产生 RpcResponse,失败只告警。畸形消息不计入统计,
560
+ * 否则网关侧一个 bug 会让插件的协议告警计数失真。
561
+ */
562
+ onSubscriptionControl(message) {
563
+ const sub = message;
564
+ if (typeof sub.subscriptionId !== 'string' || sub.subscriptionId === '') {
565
+ this.opts.onProtocolWarning?.('sub-control rejected: missing subscriptionId');
566
+ return;
567
+ }
568
+ const reason = typeof sub.reason === 'string' ? sub.reason : 'cancelled';
569
+ this.opts.onSubscriptionControl(sub.subscriptionId, reason);
570
+ }
571
+ /**
572
+ * 网关的认证挑战(ADR-003 挑战应答第二跳)。
573
+ *
574
+ * 挑战是独立顶层控制帧 `kind:'hello-challenge'`(网关 → 插件,与 sub-control 同向),
575
+ * **不是**失败响应里附带的字段——"挑战"与"真拒"的区分是类型级的,
576
+ * 不靠"失败里有没有 nonce"这种约定。
577
+ */
578
+ onHelloChallenge(frame) {
579
+ // 必须是**本次**握手的挑战:断线重连时旧连接的挑战可能迟到。
580
+ // 丢弃比"拿过期 nonce 去签名"好——后者只会失败,而且看不出原因。
581
+ // 注意:这不算 authFailure,过期挑战不是认证失败。
582
+ if (frame.requestId !== this.pendingHelloId) {
583
+ this.opts.onProtocolWarning?.(`hello-challenge ignored: requestId ${frame.requestId} does not match pending hello ${this.pendingHelloId ?? '(none)'}`);
584
+ return;
585
+ }
586
+ const signature = signChallenge(this.opts.instanceKey, frame.nonce, this.opts.instanceId, frame.timestamp);
587
+ // 只回签名,不回 nonce、更不回 key。nonce 由网关持有,插件侧无需回显。
588
+ const auth = {
589
+ id: crypto.randomUUID(),
590
+ method: 'hello.auth',
591
+ params: { instanceId: this.opts.instanceId, signature, timestamp: frame.timestamp },
592
+ ts: Date.now(),
593
+ };
594
+ this.send(auth);
595
+ }
596
+ /** 网关对本插件的响应(hello 回执 / 心跳回执 / 认证失败)。 */
597
+ onResponse(response) {
598
+ if (response.ok) {
599
+ // hello 回执是注册成功的**唯一**权威信号:重置退避、采纳网关的心跳间隔。
600
+ const heartbeatIntervalMs = response.result
601
+ ?.heartbeatIntervalMs;
602
+ if (typeof heartbeatIntervalMs === 'number' && heartbeatIntervalMs > 0) {
603
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
604
+ }
605
+ if (!this.registered) {
606
+ this.registered = true;
607
+ this.connectionId = response.result?.connectionId;
608
+ this.delay = this.opts.reconnectBaseMs; // 注册成功才重置退避
609
+ // 心跳从这一刻才开始——认证通过前发心跳只会被网关当成未授权流量。
610
+ this.scheduleHeartbeat();
611
+ // 存活探测与心跳同时开始:心跳管「网关收到我」,ping/pong 管「网关还活着」。
612
+ this.startLivenessProbe();
613
+ this.opts.onOpen(this.connectionId ?? 'unknown');
614
+ }
615
+ return;
616
+ }
617
+ // 到这里就是**纯粹的失败**——挑战是独立控制帧 hello-challenge,不经这里。
618
+ // (失败响应里不携带任何认证数据;把挑战混进失败是已经否决的承载方式。)
619
+ //
620
+ // 'unauthenticated' = 认证被拒(签名错 / 无 key / 重放),与传输错误分开上报:
621
+ // 密钥配错是运维事故,混进 disconnected 会让人去查网络。
622
+ //
623
+ // 两种情况都**不**重置退避、**不**置 registered,由调用方走正常的指数退避重连。
624
+ if (response.error.code === 'unauthenticated') {
625
+ this.authFailures += 1;
626
+ this.opts.onAuthFailure?.(response.error.code, response.error.message);
627
+ return;
628
+ }
629
+ this.opts.onProtocolWarning?.(`gateway error ${response.error.code}: ${response.error.message}`);
630
+ }
631
+ /** 浏览器 → 插件方向:只接受 RpcRequest(ADR-004 纪律 1)。 */
632
+ async onInboundRequest(req) {
633
+ if (req.method === 'hello' || !this.registered) {
634
+ this.send({
635
+ id: req.id,
636
+ ok: false,
637
+ error: { code: 'unauthenticated', message: 'hello handshake not completed for this connection' },
638
+ });
639
+ return;
640
+ }
641
+ try {
642
+ const result = await this.opts.onRequest(req);
643
+ this.send({ id: req.id, ok: true, result });
644
+ }
645
+ catch (err) {
646
+ // T16:错误在边界处必须**完整**出去。
647
+ //
648
+ // 以前这里只取 err.message。DSH 侧的 RemoteError / SessionFormatError 把
649
+ // 关键上下文放在 name / code / details / cause 上(例如
650
+ // "stored session … is corrupt: SessionFormatError: …"),只发 message
651
+ // 会让同一个故障在"进程内直调"和"经 relay 端到端"两种观测方式下文案不一致,
652
+ // 排查时看起来像两个不同的 bug。
653
+ //
654
+ // 这里仍然只是**格式化**,不是吞错:没有 try/catch 兜住原始异常,
655
+ // code 与 message 的原值一律保留。
656
+ const error = err instanceof RpcError
657
+ ? err
658
+ : new RpcError('internal', describeError(err), { cause: err });
659
+ this.send({ id: req.id, ok: false, error: { code: error.code, message: error.message } });
660
+ }
661
+ }
662
+ /**
663
+ * 主动心跳。
664
+ *
665
+ * 用 setTimeout 链而非 setInterval:发送本身不阻塞消息循环(ws.send 是
666
+ * 非阻塞入队),但排程必须与连接生命周期同生共死,避免 stop 后残留 tick。
667
+ * 心跳不等待回执(回执只是网关时间戳),因此不存在"心跳阻塞消息循环"。
668
+ */
669
+ scheduleHeartbeat() {
670
+ this.clearHeartbeat();
671
+ if (this.stopped)
672
+ return;
673
+ this.heartbeatTimer = setTimeout(() => {
674
+ this.heartbeatTimer = undefined;
675
+ this.sendHeartbeat();
676
+ this.scheduleHeartbeat();
677
+ }, this.heartbeatIntervalMs);
678
+ }
679
+ clearHeartbeat() {
680
+ if (this.heartbeatTimer) {
681
+ clearTimeout(this.heartbeatTimer);
682
+ this.heartbeatTimer = undefined;
683
+ }
684
+ this.stopLivenessProbe();
685
+ }
686
+ /**
687
+ * WS ping/pong 存活探测 —— **应用层心跳发现不了对端进程死亡**。
688
+ *
689
+ * 为什么必须用协议层 ping:relay 被 kill -9 后,ws.send() 仍会成功
690
+ * (写进内核发送缓冲),应用层 heartbeat 永远收不到回复也不报错,
691
+ * 连接半开挂起、重连永不触发。实测就是这么卡住的。
692
+ *
693
+ * 规则:每 pingIntervalMs 发一次 ping;若**上一轮 ping 尚未收到 pong**,
694
+ * 就判定连接已死 → terminate()(不是 close():半开连接调 close() 会等握手,
695
+ * 等不到就卡住),随后走既有退避重连。
696
+ */
697
+ startLivenessProbe() {
698
+ this.stopLivenessProbe();
699
+ if (this.stopped)
700
+ return;
701
+ this.awaitingPong = false;
702
+ this.pingTimer = setInterval(() => {
703
+ const socket = this.socket;
704
+ if (!socket || socket.readyState !== WebSocket.OPEN)
705
+ return;
706
+ // 上一轮 ping 没等到 pong → 对端已死。
707
+ if (this.awaitingPong) {
708
+ this.opts.onProtocolWarning?.('liveness: previous ping got no pong within ' + PING_INTERVAL_MS + 'ms; terminating dead connection');
709
+ this.awaitingPong = false;
710
+ try {
711
+ // terminate 而非 close:半开连接上 close() 会等对端握手,永远等不到。
712
+ socket.terminate();
713
+ }
714
+ catch {
715
+ /* 已断开 */
716
+ }
717
+ return;
718
+ }
719
+ this.awaitingPong = true;
720
+ try {
721
+ socket.ping();
722
+ }
723
+ catch {
724
+ this.awaitingPong = false;
725
+ }
726
+ }, PING_INTERVAL_MS);
727
+ this.pingTimer.unref?.();
728
+ }
729
+ stopLivenessProbe() {
730
+ if (this.pingTimer) {
731
+ clearInterval(this.pingTimer);
732
+ this.pingTimer = undefined;
733
+ }
734
+ this.awaitingPong = false;
735
+ }
736
+ /** 上一次**成功发送**的 launchUrl,用于变化时才重发(省流量)。 */
737
+ lastSentLaunchUrl;
738
+ sendHeartbeat() {
739
+ const params = { instanceId: this.opts.instanceId };
740
+ // 心跳顺带上报最新的 launchUrl。三个理由:
741
+ // ① 网关重启后内存缓存全空,只有周期性重发才能让它重新代持;
742
+ // ② DSH 重启会换 token,心跳自动把新值推过去;
743
+ // ③ hello 时可能还取不到(connection 服务未就绪),心跳在运行期一定能取到。
744
+ //
745
+ // 值没变就不带:这是常态,没必要每 15 秒重复一份 token。
746
+ // 取不到时**不清空** lastSent —— 否则短暂的读取失败会让下一次又重发一遍。
747
+ if (this.opts.collectLaunchUrl) {
748
+ try {
749
+ const current = this.opts.collectLaunchUrl();
750
+ if (typeof current === 'string' && current.length > 0 && current !== this.lastSentLaunchUrl) {
751
+ params.launchUrl = current;
752
+ this.lastSentLaunchUrl = current;
753
+ }
754
+ }
755
+ catch {
756
+ // 取不到就这轮不带,不影响心跳本身。
757
+ }
758
+ }
759
+ this.send({ id: crypto.randomUUID(), method: 'heartbeat', params, ts: Date.now() });
760
+ }
761
+ /**
762
+ * 投递一条订阅帧(ADR-004)。
763
+ *
764
+ * 未就绪(socket 未开或 hello 未回执)时返回 false 而不抛:帧流是异步泵,
765
+ * 投递失败只会让浏览器少几帧,正确的是让订阅侧收到 frame-end 而不是炸掉泵循环。
766
+ */
767
+ sendFrame(frame) {
768
+ if (!this.ready)
769
+ return false;
770
+ return this.send(frame);
771
+ }
772
+ /** 投递帧流终止信号。返回 false 的含义同 sendFrame。 */
773
+ sendFrameEnd(end) {
774
+ if (!this.ready)
775
+ return false;
776
+ return this.send(end);
777
+ }
778
+ send(message) {
779
+ const socket = this.socket;
780
+ if (socket?.readyState !== WebSocket.OPEN)
781
+ return false;
782
+ socket.send(JSON.stringify(message));
783
+ return true;
784
+ }
785
+ /**
786
+ * 上报一条"配置层面不可恢复、但每次重连都应可见"的问题。
787
+ *
788
+ * 走 stderr 而不是 ctx.logger:配置错误通常发生在插件还没激活时,
789
+ * 这时 logger 可能不可用;而运维排查的第一现场是进程启动输出。
790
+ * 文案里绝不带 instanceKey。
791
+ */
792
+ reportFatalConfig(detail) {
793
+ process.stderr.write(`remote-bridge: ${detail}\n`);
794
+ this.opts.onProtocolWarning?.(detail);
795
+ }
796
+ /**
797
+ * 只提醒一次「链路不安全」。
798
+ *
799
+ * 为什么不能走 reportFatalConfig:那条路径每次重连都会执行,
800
+ * 实测 ws:// 配置重连 8 次就打 8 条一模一样的 stderr。
801
+ * 同一个已知情况反复刷屏,会让人对真正的告警麻木 —— 比不告警更糟。
802
+ *
803
+ * 用实例字段记状态而不是模块级标志:多实例(不同 relayUrl)各自独立判断。
804
+ */
805
+ warnInsecureSchemeOnce(detail) {
806
+ if (this.warnedInsecureScheme)
807
+ return;
808
+ this.warnedInsecureScheme = true;
809
+ process.stderr.write(`remote-bridge: ${detail}\n`);
810
+ this.opts.onProtocolWarning?.(detail);
811
+ }
812
+ /** ws:// 告警是否已打过(只提醒一次,避免重连刷屏)。 */
813
+ warnedInsecureScheme = false;
814
+ scheduleReconnect() {
815
+ if (this.stopped || this.timer)
816
+ return;
817
+ const delay = this.delay;
818
+ this.delay = Math.min(this.delay * 2, this.opts.reconnectMaxMs);
819
+ this.timer = setTimeout(() => {
820
+ this.timer = undefined;
821
+ this.connect();
822
+ }, delay);
823
+ }
824
+ }
825
+ //# sourceMappingURL=client.js.map