dsh-remote-bridge 2.8.0 → 2.8.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.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/api/asyncapi.yaml +591 -1
- package/docs/api/openapi.yaml +2 -2
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/MobileGatewayClient.kt +437 -0
- package/lib/index.d.ts +100 -3
- package/lib/index.js +295 -18
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
9
9
|
import { isIP } from "node:net";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
11
|
import { WebSocket, WebSocketServer } from "ws";
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
13
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
13
14
|
//#region src/shared/contracts.ts
|
|
14
15
|
const DEVICE_SCOPES = [
|
|
@@ -3293,7 +3294,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
3293
3294
|
}
|
|
3294
3295
|
//#endregion
|
|
3295
3296
|
//#region src/shared/version.ts
|
|
3296
|
-
const PLUGIN_VERSION = "2.8.
|
|
3297
|
+
const PLUGIN_VERSION = "2.8.1";
|
|
3297
3298
|
//#endregion
|
|
3298
3299
|
//#region src/host/capabilities.ts
|
|
3299
3300
|
/**
|
|
@@ -8370,6 +8371,101 @@ function decodeFrame(text) {
|
|
|
8370
8371
|
return null;
|
|
8371
8372
|
}
|
|
8372
8373
|
}
|
|
8374
|
+
/**
|
|
8375
|
+
* **不**回显 `requestId` 的服务端帧 kind:流式分块与服务端主动推送。
|
|
8376
|
+
*
|
|
8377
|
+
* 这些帧不与「某一次请求」一一对应(一次请求可能引发 0..N 条),客户端拿它们去配对
|
|
8378
|
+
* 只会误伤;而 `speech-state` 例外地在处理器里手写了 `requestId`,那属于它自己的
|
|
8379
|
+
* 语义,注入层不覆盖(见 {@link withRequestIdEcho} 的「已有不覆盖」规则)。
|
|
8380
|
+
*
|
|
8381
|
+
* 显式列举而非前缀/启发式判断:新增推送 kind 时**必须**同步本表,否则会误注入。
|
|
8382
|
+
*/
|
|
8383
|
+
const REQUEST_ID_ECHO_EXCLUDED_KINDS = /* @__PURE__ */ new Set([
|
|
8384
|
+
"hello",
|
|
8385
|
+
"paired",
|
|
8386
|
+
"pong",
|
|
8387
|
+
"subscribed",
|
|
8388
|
+
"event",
|
|
8389
|
+
"question-requested",
|
|
8390
|
+
"approval-requested",
|
|
8391
|
+
"approval-resolved",
|
|
8392
|
+
"question-resolved",
|
|
8393
|
+
"speech-state",
|
|
8394
|
+
"speech-transcript",
|
|
8395
|
+
"speech-preparing",
|
|
8396
|
+
"tasks-updated",
|
|
8397
|
+
"goal-updated",
|
|
8398
|
+
"session-title-changed",
|
|
8399
|
+
"queue-item-updated",
|
|
8400
|
+
"connection-resync-required",
|
|
8401
|
+
"file-download-chunk",
|
|
8402
|
+
"file-upload-chunk-accepted"
|
|
8403
|
+
]);
|
|
8404
|
+
/**
|
|
8405
|
+
* 反压溢出后的「你漏了 N 帧,重拉一次」提示帧 kind。
|
|
8406
|
+
*
|
|
8407
|
+
* 为什么必须有一条这样的帧:`sendFrame` 的发送队列是有界的([SOCKET_QUEUE_LIMIT] 同量级),
|
|
8408
|
+
* 溢出时只能丢帧。**丢帧本身不可怕,静默丢帧才可怕** —— 客户端会以为自己看到的是完整
|
|
8409
|
+
* 事件流,于是本地状态与内核悄悄分叉(少了一条审批、少了一个 goal 变更),而且没有任何
|
|
8410
|
+
* 判据能让它发现。所以溢出时优先丢可重建的帧,并在队列排空后补一条本帧,把「静默」
|
|
8411
|
+
* 变成「告知」。
|
|
8412
|
+
*/
|
|
8413
|
+
const RESYNC_REQUIRED_KIND = "connection-resync-required";
|
|
8414
|
+
/**
|
|
8415
|
+
* 可重建的服务端帧:丢掉之后客户端重拉一次(history / tasks / goal)就能补齐。
|
|
8416
|
+
*
|
|
8417
|
+
* 判据是「这帧的信息是否只存在于这一帧里」:
|
|
8418
|
+
* - `event`(含 `chat.message.delta`)——增量文本,丢了下一个 delta 就断句,但正文在
|
|
8419
|
+
* history 里,客户端按 `asOfSeq` 重拉即补齐;
|
|
8420
|
+
* - `tasks-updated` / `goal-updated` / `session-title-changed` / `queue-item-updated` ——
|
|
8421
|
+
* 都是「某某变了」的通知,真正的状态在 REST/`tasks`/`goal` 帧里可重取。
|
|
8422
|
+
*
|
|
8423
|
+
* 反过来,**应答帧一律不可重建**(丢了这次请求就永远等不到回音)、大载荷帧
|
|
8424
|
+
* (`history` / `file-*` / `speech-*`)也不可 —— 重发成本远高于丢弃一条 delta。
|
|
8425
|
+
*/
|
|
8426
|
+
const REBUILDABLE_SERVER_KINDS = /* @__PURE__ */ new Set([
|
|
8427
|
+
"event",
|
|
8428
|
+
"tasks-updated",
|
|
8429
|
+
"goal-updated",
|
|
8430
|
+
"session-title-changed",
|
|
8431
|
+
"queue-item-updated"
|
|
8432
|
+
]);
|
|
8433
|
+
/** 该服务端帧是否属于「丢了可以重拉补齐」的类型(反压溢出时优先丢它)。 */
|
|
8434
|
+
function isRebuildableServerFrame(kind) {
|
|
8435
|
+
return REBUILDABLE_SERVER_KINDS.has(kind);
|
|
8436
|
+
}
|
|
8437
|
+
/**
|
|
8438
|
+
* 从客户端帧取 `requestId`:只认非空字符串,其余(缺失 / 空串 / 非字符串)一律
|
|
8439
|
+
* 视为「没带」——契约规定 absent ⇒ 应答里连 `requestId` 键都不出现。
|
|
8440
|
+
*/
|
|
8441
|
+
function requestIdOf(frame) {
|
|
8442
|
+
const value = frame.requestId;
|
|
8443
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
8444
|
+
}
|
|
8445
|
+
/**
|
|
8446
|
+
* 给应答帧的 `data` 注入 `requestId`(纯函数,不改动入参)。
|
|
8447
|
+
*
|
|
8448
|
+
* 四条「原样返回」规则:
|
|
8449
|
+
* 1. 请求没带 `requestId`(absent / 空串)——不许凭空造出这个键;
|
|
8450
|
+
* 2. kind 属于流式或主动推送({@link REQUEST_ID_ECHO_EXCLUDED_KINDS});
|
|
8451
|
+
* 3. `data` 不是普通对象(省略 / 数组 / `null` / 标量)——没有放的位置,改形状会破坏契约;
|
|
8452
|
+
* 4. `data` 里已经有 `requestId`——处理器手写的回显优先,**绝不覆盖**已验收的行为。
|
|
8453
|
+
*/
|
|
8454
|
+
function withRequestIdEcho(frame, requestId) {
|
|
8455
|
+
if (requestId === void 0 || requestId === "") return frame;
|
|
8456
|
+
if (REQUEST_ID_ECHO_EXCLUDED_KINDS.has(frame.kind)) return frame;
|
|
8457
|
+
const data = frame.data;
|
|
8458
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return frame;
|
|
8459
|
+
const record = data;
|
|
8460
|
+
if ("requestId" in record) return frame;
|
|
8461
|
+
return {
|
|
8462
|
+
...frame,
|
|
8463
|
+
data: {
|
|
8464
|
+
...record,
|
|
8465
|
+
requestId
|
|
8466
|
+
}
|
|
8467
|
+
};
|
|
8468
|
+
}
|
|
8373
8469
|
//#endregion
|
|
8374
8470
|
//#region src/host/ws-file-transfer.ts
|
|
8375
8471
|
/**
|
|
@@ -10888,15 +10984,23 @@ var SessionHandler = class {
|
|
|
10888
10984
|
async handleSessionCreate(principal, frame) {
|
|
10889
10985
|
const rootId = typeof frame.rootId === "string" ? frame.rootId : void 0;
|
|
10890
10986
|
const cwd = typeof frame.cwd === "string" ? frame.cwd : void 0;
|
|
10891
|
-
const
|
|
10892
|
-
const
|
|
10987
|
+
const workspaceId = typeof frame.workspaceId === "string" && frame.workspaceId !== "" ? frame.workspaceId : void 0;
|
|
10988
|
+
const agentPreset = typeof frame.agentPreset === "string" && frame.agentPreset !== "" ? frame.agentPreset : void 0;
|
|
10989
|
+
const reuseBlank = typeof frame.reuseBlank === "boolean" ? frame.reuseBlank : void 0;
|
|
10893
10990
|
try {
|
|
10894
|
-
const created = await this.chat.
|
|
10991
|
+
const created = workspaceId !== void 0 ? await this.chat.createSessionInWorkspace(principal, workspaceId, {
|
|
10992
|
+
...agentPreset === void 0 ? {} : { agentPreset },
|
|
10993
|
+
...reuseBlank === void 0 ? {} : { reuseBlank }
|
|
10994
|
+
}) : await this.chat.createSession(principal, rootId ?? (principal.rootIds === "all" ? "" : [...principal.rootIds][0] ?? ""), cwd ?? "", {
|
|
10995
|
+
...agentPreset === void 0 ? {} : { agentPreset },
|
|
10996
|
+
...reuseBlank === void 0 ? {} : { reuseBlank }
|
|
10997
|
+
});
|
|
10895
10998
|
this.sendFrame({
|
|
10896
10999
|
kind: "session-created",
|
|
10897
11000
|
data: {
|
|
10898
11001
|
sessionId: created.id,
|
|
10899
|
-
rootId:
|
|
11002
|
+
...workspaceId === void 0 ? { rootId } : { workspaceId },
|
|
11003
|
+
...created.agentPreset === void 0 ? {} : { agentPreset: created.agentPreset }
|
|
10900
11004
|
}
|
|
10901
11005
|
});
|
|
10902
11006
|
} catch (error) {
|
|
@@ -12583,7 +12687,11 @@ var MobileEventBridge = class {
|
|
|
12583
12687
|
//#endregion
|
|
12584
12688
|
//#region src/host/ws-handler.ts
|
|
12585
12689
|
/** 单条连接的最大发送队列深度(与 /api/v1/events 保持一致)。 */
|
|
12586
|
-
const
|
|
12690
|
+
const DEFAULT_MOBILE_QUEUE_LIMIT = 512;
|
|
12691
|
+
/** 同时允许的 `/ws/mobile` 连接数上限(与 PROTOCOL/API 文档承诺的 32 一致)。 */
|
|
12692
|
+
const DEFAULT_MOBILE_MAX_CONNECTIONS = 32;
|
|
12693
|
+
/** 心跳周期。连续错过 **两个** 周期才 terminate(偶发丢包不误杀)。 */
|
|
12694
|
+
const DEFAULT_MOBILE_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
12587
12695
|
/** 从 IncomingMessage 取单个请求头(Node 里同名头可能是数组,取第一个)。 */
|
|
12588
12696
|
function headerValue(req, name) {
|
|
12589
12697
|
const raw = req.headers[name.toLowerCase()];
|
|
@@ -12623,6 +12731,15 @@ var MobileGatewayWsHandler = class {
|
|
|
12623
12731
|
auth;
|
|
12624
12732
|
capabilitiesFlags;
|
|
12625
12733
|
sockets = /* @__PURE__ */ new Map();
|
|
12734
|
+
/**
|
|
12735
|
+
* 当前异步上下文里正在处理的那一帧客户端请求的 `requestId`。
|
|
12736
|
+
*
|
|
12737
|
+
* **为什么必须是 AsyncLocalStorage 而不是实例字段/闭包变量**:`webSocket.on('message')`
|
|
12738
|
+
* 里是 `void this.handleMessage(...)`(不 await),同一条连接可以同时有多个请求在途;
|
|
12739
|
+
* 任何「先存起来、发送时再读」的共享槽位都会把并发请求的 id 串到一起。ALS 把上下文
|
|
12740
|
+
* 绑定在**这条 await 链**上,`await` 穿过它、并发分支各持一份,互不可见。
|
|
12741
|
+
*/
|
|
12742
|
+
frameRequestId = new AsyncLocalStorage();
|
|
12626
12743
|
settingsService;
|
|
12627
12744
|
filesService;
|
|
12628
12745
|
/**
|
|
@@ -12653,11 +12770,22 @@ var MobileGatewayWsHandler = class {
|
|
|
12653
12770
|
if (this.speech === void 0) this.speech = new SpeechHandler(remote, (key) => (frame) => this.sendFrame(key, frame));
|
|
12654
12771
|
else this.speech.setRemote(remote);
|
|
12655
12772
|
}
|
|
12656
|
-
|
|
12773
|
+
/** 同时允许的连接数上限(鉴权后计数;server.ts 在 Upgrade 前据此回 429)。 */
|
|
12774
|
+
maxConnections;
|
|
12775
|
+
/** 心跳周期:每周期 ping 一次,连续两个周期没回 pong 就 terminate。 */
|
|
12776
|
+
heartbeatIntervalMs;
|
|
12777
|
+
/** 单连接发送队列深度(溢出时先丢可重建帧)。 */
|
|
12778
|
+
queueLimit;
|
|
12779
|
+
/** 心跳定时器(首个连接接入时惰性启动,{@link stop} 时清掉)。 */
|
|
12780
|
+
heartbeatTimer;
|
|
12781
|
+
constructor(database, chat, auth, events, capabilitiesFlags, options = {}) {
|
|
12657
12782
|
this.database = database;
|
|
12658
12783
|
this.chat = chat;
|
|
12659
12784
|
this.auth = auth;
|
|
12660
12785
|
this.capabilitiesFlags = capabilitiesFlags;
|
|
12786
|
+
this.maxConnections = Math.max(1, options.maxConnections ?? DEFAULT_MOBILE_MAX_CONNECTIONS);
|
|
12787
|
+
this.heartbeatIntervalMs = Math.max(1, options.heartbeatIntervalMs ?? DEFAULT_MOBILE_HEARTBEAT_INTERVAL_MS);
|
|
12788
|
+
this.queueLimit = Math.max(1, options.queueLimit ?? DEFAULT_MOBILE_QUEUE_LIMIT);
|
|
12661
12789
|
this.eventBridge = new MobileEventBridge(events, makeEventAuthorizer(chat), (conn, event) => this.sendFrame(conn, {
|
|
12662
12790
|
kind: "event",
|
|
12663
12791
|
data: event
|
|
@@ -12793,6 +12921,7 @@ var MobileGatewayWsHandler = class {
|
|
|
12793
12921
|
queue: [],
|
|
12794
12922
|
draining: false,
|
|
12795
12923
|
isAlive: true,
|
|
12924
|
+
dropped: 0,
|
|
12796
12925
|
subscribed: void 0,
|
|
12797
12926
|
handlers: {
|
|
12798
12927
|
sessions: new SessionHandler(this.chat, (frame) => this.sendFrame(webSocket, frame), this.database),
|
|
@@ -12810,6 +12939,7 @@ var MobileGatewayWsHandler = class {
|
|
|
12810
12939
|
};
|
|
12811
12940
|
this.sockets.set(webSocket, state);
|
|
12812
12941
|
this.eventBridge.attach(webSocket, state.principal);
|
|
12942
|
+
this.startHeartbeat();
|
|
12813
12943
|
webSocket.on("pong", () => {
|
|
12814
12944
|
state.isAlive = true;
|
|
12815
12945
|
});
|
|
@@ -12883,6 +13013,15 @@ var MobileGatewayWsHandler = class {
|
|
|
12883
13013
|
rootIds: [...principal.rootIds]
|
|
12884
13014
|
};
|
|
12885
13015
|
}
|
|
13016
|
+
/**
|
|
13017
|
+
* 收到一条文本帧的入口:解码 → 校验 → **绑定 requestId 上下文** → 分发。
|
|
13018
|
+
*
|
|
13019
|
+
* 这里同时是唯一的异常兜底点。调用方是 `webSocket.on('message')` 里的
|
|
13020
|
+
* `void this.handleMessage(...)`(不 await),任何从这里冒出去的 rejection 都会成为
|
|
13021
|
+
* 进程级 unhandledRejection —— DSH 宿主装了 `installFailLoud`,直接 `proc.exit(1)`。
|
|
13022
|
+
* 而 `handleHistory` 等基础帧是直接 `await this.chat.*`,一次越权的 `sessionId` 就能抛
|
|
13023
|
+
* `ApiError(404, 'SESSION_NOT_FOUND')`。所以分发必须整体包在 try/catch 里。
|
|
13024
|
+
*/
|
|
12886
13025
|
async handleMessage(webSocket, state, text) {
|
|
12887
13026
|
const frame = decodeFrame(text);
|
|
12888
13027
|
if (frame === null || typeof frame !== "object" || Array.isArray(frame)) {
|
|
@@ -12895,6 +13034,17 @@ var MobileGatewayWsHandler = class {
|
|
|
12895
13034
|
this.sendError(webSocket, "MISSING_TYPE", "Frame missing \"type\" field.");
|
|
12896
13035
|
return;
|
|
12897
13036
|
}
|
|
13037
|
+
const requestId = requestIdOf(clientFrame);
|
|
13038
|
+
await this.frameRequestId.run(requestId, async () => {
|
|
13039
|
+
try {
|
|
13040
|
+
await this.routeFrame(webSocket, state, clientFrame, type);
|
|
13041
|
+
} catch (error) {
|
|
13042
|
+
this.reportFrameError(webSocket, type, error);
|
|
13043
|
+
}
|
|
13044
|
+
});
|
|
13045
|
+
}
|
|
13046
|
+
/** 把一帧已解码、已校验的请求分发给对应的处理器(异常由 {@link handleMessage} 兜底)。 */
|
|
13047
|
+
async routeFrame(webSocket, state, clientFrame, type) {
|
|
12898
13048
|
switch (type) {
|
|
12899
13049
|
case "ping":
|
|
12900
13050
|
this.handlePing(webSocket);
|
|
@@ -13057,13 +13207,14 @@ var MobileGatewayWsHandler = class {
|
|
|
13057
13207
|
const opts = {};
|
|
13058
13208
|
if (beforeSeq !== void 0) opts.beforeSeq = beforeSeq;
|
|
13059
13209
|
if (maxMessages !== void 0) opts.maxMessages = maxMessages;
|
|
13060
|
-
const { events, hasMore } = await this.chat.history(state.principal, sessionId, opts);
|
|
13210
|
+
const { events, hasMore, projections } = await this.chat.history(state.principal, sessionId, opts);
|
|
13061
13211
|
this.sendFrame(webSocket, {
|
|
13062
13212
|
kind: "history",
|
|
13063
13213
|
data: {
|
|
13064
13214
|
sessionId,
|
|
13065
13215
|
events,
|
|
13066
|
-
hasMore
|
|
13216
|
+
hasMore,
|
|
13217
|
+
...beforeSeq !== void 0 || projections === void 0 ? {} : { projections }
|
|
13067
13218
|
}
|
|
13068
13219
|
});
|
|
13069
13220
|
}
|
|
@@ -13090,10 +13241,17 @@ var MobileGatewayWsHandler = class {
|
|
|
13090
13241
|
}
|
|
13091
13242
|
});
|
|
13092
13243
|
}
|
|
13093
|
-
/**
|
|
13244
|
+
/**
|
|
13245
|
+
* 向一条连接发一条帧(带背压队列)。
|
|
13246
|
+
*
|
|
13247
|
+
* 应答帧统一在这里回显 `requestId`:从 {@link frameRequestId} 取**当前异步上下文**的
|
|
13248
|
+
* requestId,交给 {@link withRequestIdEcho} 决定要不要塞进 `data`。处理器无需再各自
|
|
13249
|
+
* 复制一遍(已手写回显的处理器不受影响 —— 注入规则是「`data` 里已有就不覆盖」)。
|
|
13250
|
+
*/
|
|
13094
13251
|
sendFrame(webSocket, frame) {
|
|
13095
13252
|
const state = this.stateOf(webSocket);
|
|
13096
|
-
const text = encodeFrame(frame);
|
|
13253
|
+
const text = encodeFrame(withRequestIdEcho(frame, this.frameRequestId.getStore()));
|
|
13254
|
+
const rebuildable = isRebuildableServerFrame(frame.kind);
|
|
13097
13255
|
const send = () => {
|
|
13098
13256
|
if (webSocket.readyState !== WebSocket.OPEN) return;
|
|
13099
13257
|
try {
|
|
@@ -13102,8 +13260,30 @@ var MobileGatewayWsHandler = class {
|
|
|
13102
13260
|
this.sockets.delete(webSocket);
|
|
13103
13261
|
}
|
|
13104
13262
|
};
|
|
13105
|
-
|
|
13106
|
-
|
|
13263
|
+
this.enqueue(webSocket, state, {
|
|
13264
|
+
send,
|
|
13265
|
+
rebuildable
|
|
13266
|
+
});
|
|
13267
|
+
}
|
|
13268
|
+
/**
|
|
13269
|
+
* 压入发送队列,队列满时**先牺牲可重建的帧**。
|
|
13270
|
+
*
|
|
13271
|
+
* 与 `/api/v1/events`(server.ts 的 `enqueue`)同一套取舍,只是这里没有「可重建事件
|
|
13272
|
+
* 标记表」可用,改用 kind 判据。为什么不能无脑 `shift()` 丢最旧:最旧的那条可能正是
|
|
13273
|
+
* 某次请求的**应答**,丢掉它客户端就会一直等到超时(而它等的那个东西其实早成功了),
|
|
13274
|
+
* 反过来一条 `chat.message.delta` 丢了只影响一句的流式观感,history 一拉就齐。
|
|
13275
|
+
*
|
|
13276
|
+
* 被丢的帧数累加到 `state.dropped`,由 {@link drain} 在排空后补一条
|
|
13277
|
+
* `connection-resync-required` 告知客户端 —— **静默丢帧比丢帧本身更糟**。
|
|
13278
|
+
*/
|
|
13279
|
+
enqueue(webSocket, state, item) {
|
|
13280
|
+
if (state.queue.length >= this.queueLimit) {
|
|
13281
|
+
const victim = state.queue.findIndex((entry) => entry.rebuildable);
|
|
13282
|
+
if (victim >= 0) state.queue.splice(victim, 1);
|
|
13283
|
+
else state.queue.shift();
|
|
13284
|
+
state.dropped += 1;
|
|
13285
|
+
}
|
|
13286
|
+
state.queue.push(item);
|
|
13107
13287
|
if (!state.draining) {
|
|
13108
13288
|
state.draining = true;
|
|
13109
13289
|
this.drain(webSocket, state);
|
|
@@ -13119,15 +13299,58 @@ var MobileGatewayWsHandler = class {
|
|
|
13119
13299
|
}
|
|
13120
13300
|
});
|
|
13121
13301
|
}
|
|
13122
|
-
/**
|
|
13302
|
+
/**
|
|
13303
|
+
* 把分发过程中冒出来的异常变成一条 `error` 帧(范式与各域处理器的 `reportError` 一致)。
|
|
13304
|
+
*
|
|
13305
|
+
* **这里的 try/catch 不是多余的**:`sendFrame` 第一行就是 `this.stateOf(webSocket)`,而
|
|
13306
|
+
* `teardown` 会在 `close`/`error` 时把连接从 `sockets` 摘掉。连接已断时 `stateOf` 抛
|
|
13307
|
+
* `ApiError(4001, 'NOT_AUTHENTICATED')` —— 在 catch 块里再抛一次,就等于没修。
|
|
13308
|
+
*/
|
|
13309
|
+
reportFrameError(webSocket, type, error) {
|
|
13310
|
+
const data = error instanceof ApiError ? {
|
|
13311
|
+
code: error.code,
|
|
13312
|
+
message: error.message,
|
|
13313
|
+
statusCode: error.status,
|
|
13314
|
+
requestType: type
|
|
13315
|
+
} : {
|
|
13316
|
+
code: "INTERNAL_ERROR",
|
|
13317
|
+
message: "Frame handling failed.",
|
|
13318
|
+
requestType: type
|
|
13319
|
+
};
|
|
13320
|
+
try {
|
|
13321
|
+
this.sendFrame(webSocket, {
|
|
13322
|
+
kind: "error",
|
|
13323
|
+
data
|
|
13324
|
+
});
|
|
13325
|
+
} catch {}
|
|
13326
|
+
}
|
|
13327
|
+
/**
|
|
13328
|
+
* 把队列里的帧逐条发出去,排空后按需补一条「你漏了 N 帧」的告知帧。
|
|
13329
|
+
*
|
|
13330
|
+
* 补发放在**排空之后**而不是溢出当场:溢出那一刻队列正满,插一条进去还得再挤掉别人;
|
|
13331
|
+
* 等排空再补,既不会二次溢出,也能把同一段时间内多次溢出合并成一条(`dropped` 累加)。
|
|
13332
|
+
*/
|
|
13123
13333
|
async drain(webSocket, state) {
|
|
13124
13334
|
while (state.queue.length > 0) {
|
|
13125
|
-
const
|
|
13126
|
-
if (
|
|
13127
|
-
send();
|
|
13335
|
+
const item = state.queue.shift();
|
|
13336
|
+
if (item === void 0) break;
|
|
13337
|
+
item.send();
|
|
13128
13338
|
await new Promise((resolve) => setImmediate(resolve));
|
|
13129
13339
|
}
|
|
13130
13340
|
state.draining = false;
|
|
13341
|
+
if (state.dropped > 0) {
|
|
13342
|
+
const dropped = state.dropped;
|
|
13343
|
+
state.dropped = 0;
|
|
13344
|
+
try {
|
|
13345
|
+
this.sendFrame(webSocket, {
|
|
13346
|
+
kind: RESYNC_REQUIRED_KIND,
|
|
13347
|
+
data: {
|
|
13348
|
+
dropped,
|
|
13349
|
+
reason: "backpressure"
|
|
13350
|
+
}
|
|
13351
|
+
});
|
|
13352
|
+
} catch {}
|
|
13353
|
+
}
|
|
13131
13354
|
}
|
|
13132
13355
|
/** 拒绝未鉴权连接(401 close code)。 */
|
|
13133
13356
|
closeUnauthorized(webSocket) {
|
|
@@ -13143,6 +13366,7 @@ var MobileGatewayWsHandler = class {
|
|
|
13143
13366
|
}
|
|
13144
13367
|
/** 关闭所有连接(server.stop 时调用)。 */
|
|
13145
13368
|
async stop(timeoutMs = 1e3) {
|
|
13369
|
+
this.clearHeartbeat();
|
|
13146
13370
|
this.eventBridge.dispose();
|
|
13147
13371
|
const closures = [];
|
|
13148
13372
|
for (const webSocket of [...this.sockets.keys()]) closures.push(new Promise((resolve) => {
|
|
@@ -13153,6 +13377,50 @@ var MobileGatewayWsHandler = class {
|
|
|
13153
13377
|
await Promise.all(closures);
|
|
13154
13378
|
this.sockets.clear();
|
|
13155
13379
|
}
|
|
13380
|
+
/**
|
|
13381
|
+
* 心跳循环:每 `heartbeatIntervalMs` ping 一次,**连续错过两个周期**才 terminate。
|
|
13382
|
+
*
|
|
13383
|
+
* 为什么必须有这一步:`/ws/mobile` 是手机上的长连接,切网、熄屏、NAT 表项过期都会留下
|
|
13384
|
+
* **半开连接** —— 内核这边没有任何事件,TCP 要等自己超时才报错(默认以分钟计),期间
|
|
13385
|
+
* 这条连接一直占着 `sockets` 里的额度、每次广播还白查一遍库。API 文档承诺「连续错过
|
|
13386
|
+
* 2 个心跳周期 terminate」,这里就是把它兑现。
|
|
13387
|
+
*
|
|
13388
|
+
* 两个刻意的取舍:
|
|
13389
|
+
* - `terminate()` 而不是 `close()`:对端已经不在,优雅挥手没人收,只会多等一个超时;
|
|
13390
|
+
* `close`/`error` 里注册的 `teardown` 会照常摘表、摘事件桥、中止语音与文件传输;
|
|
13391
|
+
* - 存活标记用底层 `pong` 控制帧:客户端库自动回,不需要业务代码配合(业务层的
|
|
13392
|
+
* `ping` 帧是另一回事,用于测 RTT)。
|
|
13393
|
+
*/
|
|
13394
|
+
startHeartbeat() {
|
|
13395
|
+
if (this.heartbeatTimer !== void 0) return;
|
|
13396
|
+
this.heartbeatTimer = setInterval(() => {
|
|
13397
|
+
for (const [socket, state] of this.sockets) {
|
|
13398
|
+
if (!state.isAlive) {
|
|
13399
|
+
socket.terminate();
|
|
13400
|
+
continue;
|
|
13401
|
+
}
|
|
13402
|
+
state.isAlive = false;
|
|
13403
|
+
try {
|
|
13404
|
+
socket.ping();
|
|
13405
|
+
} catch {}
|
|
13406
|
+
}
|
|
13407
|
+
}, this.heartbeatIntervalMs);
|
|
13408
|
+
this.heartbeatTimer.unref?.();
|
|
13409
|
+
}
|
|
13410
|
+
clearHeartbeat() {
|
|
13411
|
+
if (this.heartbeatTimer === void 0) return;
|
|
13412
|
+
clearInterval(this.heartbeatTimer);
|
|
13413
|
+
this.heartbeatTimer = void 0;
|
|
13414
|
+
}
|
|
13415
|
+
/**
|
|
13416
|
+
* 连接数上限(供 server.ts 在 **Upgrade 之前**判定是否回 429)。
|
|
13417
|
+
*
|
|
13418
|
+
* 为什么要在 upgrade 前判:升级之后就只能发 WS 关闭码,`Retry-After` 根本无法表达,
|
|
13419
|
+
* 客户端也拿不到「稍后再试」这个语义(只会看到一次莫名其妙的断连)。
|
|
13420
|
+
*/
|
|
13421
|
+
connectionLimit() {
|
|
13422
|
+
return this.maxConnections;
|
|
13423
|
+
}
|
|
13156
13424
|
/** 当前连接数(供 metrics / 观测)。 */
|
|
13157
13425
|
connectionCount() {
|
|
13158
13426
|
return this.sockets.size;
|
|
@@ -13231,7 +13499,11 @@ var RemoteApiServer = class {
|
|
|
13231
13499
|
clientTracking: false,
|
|
13232
13500
|
maxPayload: Math.max(1, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES)
|
|
13233
13501
|
});
|
|
13234
|
-
this.wsMobile = new MobileGatewayWsHandler(options.database, options.chat, options.auth, options.events, { jobs: options.capabilities?.jobs === true }
|
|
13502
|
+
this.wsMobile = new MobileGatewayWsHandler(options.database, options.chat, options.auth, options.events, { jobs: options.capabilities?.jobs === true }, {
|
|
13503
|
+
maxConnections: this.maxConnections,
|
|
13504
|
+
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
13505
|
+
queueLimit: this.socketQueueLimit
|
|
13506
|
+
});
|
|
13235
13507
|
}
|
|
13236
13508
|
/**
|
|
13237
13509
|
* 注入全局模型目录与文件浏览依赖(index.ts 在 router 装配期调用)。
|
|
@@ -13469,6 +13741,11 @@ var RemoteApiServer = class {
|
|
|
13469
13741
|
try {
|
|
13470
13742
|
const url = new URL(req.url ?? "/", "http://workspace.local");
|
|
13471
13743
|
if (this.wsMobile.ownsPath(req)) {
|
|
13744
|
+
if (this.wsMobile.connectionCount() >= this.wsMobile.connectionLimit()) {
|
|
13745
|
+
recordWsRejected();
|
|
13746
|
+
socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nRetry-After: 5\r\nContent-Length: 0\r\n\r\n");
|
|
13747
|
+
return;
|
|
13748
|
+
}
|
|
13472
13749
|
this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
|
|
13473
13750
|
this.wsMobile.onConnection(webSocket, req);
|
|
13474
13751
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-bridge",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.19.0",
|