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/docs/api/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: DeepSeek Harness Mobile API
|
|
4
|
-
version: 2.8.
|
|
4
|
+
version: 2.8.1
|
|
5
5
|
description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
|
|
6
6
|
servers:
|
|
7
7
|
- url: http://127.0.0.1:3090/api/v1
|
|
@@ -24,7 +24,7 @@ paths:
|
|
|
24
24
|
properties:
|
|
25
25
|
ok: { type: boolean, const: true }
|
|
26
26
|
version: { type: string, const: v1 }
|
|
27
|
-
pluginVersion: { type: string, const: 2.8.
|
|
27
|
+
pluginVersion: { type: string, const: 2.8.1 }
|
|
28
28
|
/debug/host-services:
|
|
29
29
|
get:
|
|
30
30
|
operationId: hostServices
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
package ai.deepseek.dsh.workspace
|
|
2
|
+
|
|
3
|
+
import java.io.Closeable
|
|
4
|
+
import java.util.Base64
|
|
5
|
+
import java.util.concurrent.CompletableFuture
|
|
6
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
7
|
+
import java.util.concurrent.Executors
|
|
8
|
+
import java.util.concurrent.ScheduledFuture
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
12
|
+
import kotlinx.serialization.Serializable
|
|
13
|
+
import kotlinx.serialization.json.Json
|
|
14
|
+
import kotlinx.serialization.json.JsonElement
|
|
15
|
+
import kotlinx.serialization.json.JsonObject
|
|
16
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
17
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
18
|
+
import kotlinx.serialization.json.jsonObject
|
|
19
|
+
import kotlinx.serialization.json.jsonPrimitive
|
|
20
|
+
import kotlinx.serialization.json.put
|
|
21
|
+
import okhttp3.HttpUrl
|
|
22
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
23
|
+
import okhttp3.OkHttpClient
|
|
24
|
+
import okhttp3.Request
|
|
25
|
+
import okhttp3.Response
|
|
26
|
+
import okhttp3.WebSocket
|
|
27
|
+
import okhttp3.WebSocketListener
|
|
28
|
+
|
|
29
|
+
/** `/ws/mobile` 的子协议名。握手时**必须**排在子协议列表第一位,服务端据此判断协议版本。 */
|
|
30
|
+
const val MOBILE_PROTOCOL: String = "dsh-mobile-v1"
|
|
31
|
+
|
|
32
|
+
/** hello 里宣告的协议修订号。与会话快照/问答审批的 `question-approval-v2` 是两码事。 */
|
|
33
|
+
const val MOBILE_HELLO_PROTOCOL: Int = 3
|
|
34
|
+
|
|
35
|
+
/** 服务端主动断开时用的 close code。4001 = 鉴权/配对失败或设备已被吊销,**重连无意义**。 */
|
|
36
|
+
const val MOBILE_CLOSE_AUTH_FAILED: Int = 4001
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `/ws/mobile` 的凭证载体。三种二选一,与服务端 `onConnection` 的判定顺序一致:
|
|
40
|
+
* 1. [Pairing] → 子协议 `dsh-pair.<code>`,**必须**带 `X-DSH-Device-ID`;
|
|
41
|
+
* 2. [Token] → 子协议 `dsh-auth.<token>`(推荐,浏览器/受控环境下不必自定义头);
|
|
42
|
+
* 3. [Bearer] → 子协议只留 `dsh-mobile-v1`,token 走 `Authorization: Bearer`。
|
|
43
|
+
*
|
|
44
|
+
* 刻意**没有** `?token=` 变体:服务端见到 URL query 里的 token/code 会直接 `close(4001)`,
|
|
45
|
+
* 因为它会漏进代理日志。别在这里补一个方便的后门。
|
|
46
|
+
*/
|
|
47
|
+
sealed class MobileCredentials {
|
|
48
|
+
/** 首配:一次性配对码 + 设备标识。配对成功后服务端会先推 `paired` 再推 `hello`。 */
|
|
49
|
+
data class Pairing(
|
|
50
|
+
val code: String,
|
|
51
|
+
val deviceId: String,
|
|
52
|
+
val deviceName: String? = null,
|
|
53
|
+
) : MobileCredentials()
|
|
54
|
+
|
|
55
|
+
/** 已配对设备:把配对拿到的 token 放进子协议。 */
|
|
56
|
+
data class Token(val token: String) : MobileCredentials()
|
|
57
|
+
|
|
58
|
+
/** 已配对设备:token 放 Authorization 头,子协议只保留 `dsh-mobile-v1`。 */
|
|
59
|
+
data class Bearer(val token: String) : MobileCredentials()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** hello 帧里的设备视图。回环管理员连接没有这一节(服务端会整段省略,而不是给 null)。 */
|
|
63
|
+
@Serializable
|
|
64
|
+
data class MobileDevice(
|
|
65
|
+
val id: String,
|
|
66
|
+
val name: String,
|
|
67
|
+
val scopes: List<String> = emptyList(),
|
|
68
|
+
val rootIds: List<String> = emptyList(),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/** 连接建立后服务端推的第一帧。`protocol` 必须等于 [MOBILE_HELLO_PROTOCOL]。 */
|
|
72
|
+
@Serializable
|
|
73
|
+
data class MobileHello(
|
|
74
|
+
val protocol: Int,
|
|
75
|
+
val capabilities: List<String> = emptyList(),
|
|
76
|
+
val authenticated: Boolean = false,
|
|
77
|
+
val device: MobileDevice? = null,
|
|
78
|
+
val port: Int? = null,
|
|
79
|
+
val clients: Int? = null,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
/** 配对成功的凭证下发帧,只会在 `MobileCredentials.Pairing` 流程里出现,且**早于** hello。 */
|
|
83
|
+
@Serializable
|
|
84
|
+
data class MobilePaired(val token: String, val device: MobileDevice)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 服务端 `error` 帧。`code` 是稳定契约(`UNKNOWN_TYPE` / `NOT_IMPLEMENTED` / `MISSING_PARAMS` /
|
|
88
|
+
* `FORBIDDEN_SCOPE` / `bad-request` …),`message` 是给人看的,可能变。
|
|
89
|
+
*/
|
|
90
|
+
class MobileGatewayException(
|
|
91
|
+
val code: String,
|
|
92
|
+
override val message: String,
|
|
93
|
+
val requestType: String? = null,
|
|
94
|
+
val requestId: String? = null,
|
|
95
|
+
val statusCode: Int? = null,
|
|
96
|
+
) : RuntimeException(message)
|
|
97
|
+
|
|
98
|
+
/** 请求发出去了但等不到回帧。多半是服务端不认识这个 type,或帧被背压队列丢了。 */
|
|
99
|
+
class MobileRequestTimeoutException(val requestId: String, val type: String, val timeoutMs: Long) :
|
|
100
|
+
RuntimeException("$type (requestId=$requestId) 在 ${timeoutMs}ms 内没有回帧")
|
|
101
|
+
|
|
102
|
+
/** 连接没等到 hello 就断了,或 hello 的 protocol 不匹配。 */
|
|
103
|
+
class MobileHandshakeException(message: String) : RuntimeException(message)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `/ws/mobile` 的一帧响应。`kind` 是 `MobileServerFrame.kind` 的成员之一;
|
|
107
|
+
* `requestId` 是从 `data` 里提出来的一等字段,方便直接关联。
|
|
108
|
+
*/
|
|
109
|
+
data class MobileFrame(val kind: String, val data: JsonElement) {
|
|
110
|
+
val requestId: String? = (data as? JsonObject)?.get("requestId")?.let {
|
|
111
|
+
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `/ws/mobile` 的帧回调。全部方法都有默认空实现,调用方只覆盖关心的那几个。
|
|
117
|
+
*
|
|
118
|
+
* 回调都在 OkHttp 的读线程上跑,**不要**在里面做阻塞的 [MobileGatewayClient.request]:
|
|
119
|
+
* 那条路径要等读线程派发回帧,会自己把自己锁死。需要请求-响应就用 [MobileGatewayClient.request],
|
|
120
|
+
* 它内部走的是独立的等待者,但仍建议在调用方线程里用。
|
|
121
|
+
*/
|
|
122
|
+
interface MobileGatewayListener {
|
|
123
|
+
/** 每条非 `event` 帧。hello/paired 已单独派发,这里不再重复。 */
|
|
124
|
+
fun onFrame(frame: MobileFrame) {}
|
|
125
|
+
|
|
126
|
+
/** `{kind:'event', data:{...}}`。事件本身带 `type`(如 `chat.message.delta`)。 */
|
|
127
|
+
fun onEvent(event: JsonElement) {}
|
|
128
|
+
|
|
129
|
+
/** 首帧 hello。`protocol != 3` 时先调这个,再走 [onPermanentFailure]。 */
|
|
130
|
+
fun onHello(hello: MobileHello) {}
|
|
131
|
+
|
|
132
|
+
/** 配对成功,拿到长期 token。只有配对流程会触发。 */
|
|
133
|
+
fun onPaired(paired: MobilePaired) {}
|
|
134
|
+
|
|
135
|
+
/** `ping` 的回帧。`at` 与 `time` 是同一毫秒(服务端刻意如此)。 */
|
|
136
|
+
fun onPong(at: Long, time: Long) {}
|
|
137
|
+
|
|
138
|
+
fun onConnectionChanged(connected: Boolean) {}
|
|
139
|
+
|
|
140
|
+
/** 重连没意义的失败(4001、hello 协议不匹配、配对码失效)。 */
|
|
141
|
+
fun onPermanentFailure(error: Throwable) {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `/ws/mobile` 移动端网关客户端。
|
|
146
|
+
*
|
|
147
|
+
* 与 [DshWorkspaceClient] 的分工:REST 客户端覆盖「一次请求一次响应」的能力面,
|
|
148
|
+
* 本类覆盖长连接面 —— hello/能力协商、`event` 推送、HITL 交互、分块传输、
|
|
149
|
+
* 以及靠 `requestId` 关联的异步请求-响应。服务端是 WS 优先、REST 兜底,
|
|
150
|
+
* 所以**同一个能力两处都有**时优先走本类;本类没有的能力再回落到 REST。
|
|
151
|
+
*
|
|
152
|
+
* 用法:
|
|
153
|
+
* ```
|
|
154
|
+
* val client = MobileGatewayClient("ws://127.0.0.1:3090", MobileCredentials.Token(token)) { frame -> ... }
|
|
155
|
+
* val hello = client.awaitHello()
|
|
156
|
+
* client.subscribe(sessionId)
|
|
157
|
+
* client.request("session-stats", mapOf("sessionId" to sessionId))
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* 线程模型:所有写都在 OkHttp 的 WebSocket 队列里串行;[request] 的等待者在调用方线程阻塞,
|
|
161
|
+
* 回帧由读线程按 `requestId` 唤醒。回调见 [MobileGatewayListener] 的线程说明。
|
|
162
|
+
*/
|
|
163
|
+
class MobileGatewayClient(
|
|
164
|
+
wsBaseUrl: String,
|
|
165
|
+
private val credentials: MobileCredentials,
|
|
166
|
+
private val http: OkHttpClient = OkHttpClient(),
|
|
167
|
+
private val listener: MobileGatewayListener = object : MobileGatewayListener {},
|
|
168
|
+
) : Closeable {
|
|
169
|
+
private val json = Json { ignoreUnknownKeys = true }
|
|
170
|
+
private val url: HttpUrl = buildUrl(wsBaseUrl)
|
|
171
|
+
private val closed = AtomicBoolean(false)
|
|
172
|
+
private val scheduler = Executors.newSingleThreadScheduledExecutor()
|
|
173
|
+
private val pending = ConcurrentHashMap<String, CompletableFuture<MobileFrame>>()
|
|
174
|
+
private val requestSeq = AtomicLong(0)
|
|
175
|
+
private val helloFuture = CompletableFuture<MobileHello>()
|
|
176
|
+
|
|
177
|
+
@Volatile private var socket: WebSocket? = null
|
|
178
|
+
@Volatile private var retry: ScheduledFuture<*>? = null
|
|
179
|
+
@Volatile private var attempts = 0
|
|
180
|
+
|
|
181
|
+
/** 已收到的 hello;握手完成前为 null。 */
|
|
182
|
+
@Volatile
|
|
183
|
+
var hello: MobileHello? = null
|
|
184
|
+
private set
|
|
185
|
+
|
|
186
|
+
/** 配对流程拿到的 token;非配对流程恒为 null。 */
|
|
187
|
+
@Volatile
|
|
188
|
+
var pairedToken: String? = null
|
|
189
|
+
private set
|
|
190
|
+
|
|
191
|
+
init { connect() }
|
|
192
|
+
|
|
193
|
+
// ── 握手 ─────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 把 `ws://host:port` 或 `http(s)://host:port` 归一成 `ws(s)://host:port/ws/mobile`。
|
|
197
|
+
* 传完整路径也接受(幂等),省得调用方两处拼路径拼出 `/ws/mobile/ws/mobile`。
|
|
198
|
+
*/
|
|
199
|
+
private fun buildUrl(base: String): HttpUrl {
|
|
200
|
+
val normalized = when {
|
|
201
|
+
base.startsWith("ws://") -> "http://" + base.removePrefix("ws://")
|
|
202
|
+
base.startsWith("wss://") -> "https://" + base.removePrefix("wss://")
|
|
203
|
+
else -> base
|
|
204
|
+
}
|
|
205
|
+
val parsed = normalized.trimEnd('/').toHttpUrl()
|
|
206
|
+
if (parsed.encodedPath.endsWith("/ws/mobile")) return parsed
|
|
207
|
+
return parsed.newBuilder().addPathSegment("ws").addPathSegment("mobile").build()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 子协议列表:`dsh-mobile-v1` 永远第一位,凭证前缀紧随其后。 */
|
|
211
|
+
private fun subprotocols(): String = when (credentials) {
|
|
212
|
+
is MobileCredentials.Pairing -> "$MOBILE_PROTOCOL, dsh-pair.${credentials.code}"
|
|
213
|
+
is MobileCredentials.Token -> "$MOBILE_PROTOCOL, dsh-auth.${credentials.token}"
|
|
214
|
+
is MobileCredentials.Bearer -> MOBILE_PROTOCOL
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun requestBuilder(): Request.Builder {
|
|
218
|
+
val builder = Request.Builder().url(url).header("Sec-WebSocket-Protocol", subprotocols())
|
|
219
|
+
when (credentials) {
|
|
220
|
+
is MobileCredentials.Pairing -> {
|
|
221
|
+
builder.header("X-DSH-Device-ID", credentials.deviceId)
|
|
222
|
+
// 设备名走 Base64(UTF-8):HTTP 头只保证 ISO-8859-1,中文名直传会被截断。
|
|
223
|
+
credentials.deviceName?.takeIf { it.isNotBlank() }?.let {
|
|
224
|
+
builder.header("X-DSH-Device-Name", Base64.getEncoder().encodeToString(it.toByteArray(Charsets.UTF_8)))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
is MobileCredentials.Bearer -> builder.header("Authorization", "Bearer ${credentials.token}")
|
|
228
|
+
is MobileCredentials.Token -> Unit
|
|
229
|
+
}
|
|
230
|
+
return builder
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun connect() {
|
|
234
|
+
if (closed.get()) return
|
|
235
|
+
socket = http.newWebSocket(requestBuilder().build(), object : WebSocketListener() {
|
|
236
|
+
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
237
|
+
attempts = 0
|
|
238
|
+
listener.onConnectionChanged(true)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
242
|
+
// 单帧解析失败不该掀桌子:服务端可能先于客户端升级而多发了一种帧。
|
|
243
|
+
runCatching { dispatch(text) }.onFailure(listener::onPermanentFailure)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
247
|
+
failPending(MobileHandshakeException("连接关闭:$code $reason"))
|
|
248
|
+
listener.onConnectionChanged(false)
|
|
249
|
+
when (code) {
|
|
250
|
+
// 4001 覆盖「配对码无效/过期、设备 ID 缺失、token 被吊销」。
|
|
251
|
+
// 重连只会刷出一串同样的 4001,所以直接判永久失败。
|
|
252
|
+
MOBILE_CLOSE_AUTH_FAILED -> {
|
|
253
|
+
val error = IllegalStateException("移动网关拒绝了凭证($reason),需要重新配对或换 token。")
|
|
254
|
+
helloFuture.completeExceptionally(error)
|
|
255
|
+
listener.onPermanentFailure(error)
|
|
256
|
+
}
|
|
257
|
+
else -> reconnect()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) {
|
|
262
|
+
failPending(error)
|
|
263
|
+
listener.onConnectionChanged(false)
|
|
264
|
+
// 429 = 连接数打满(上限 32,鉴权后才计数)。等一会儿再试是合理的,别当永久失败。
|
|
265
|
+
if (response?.code == 401 || response?.code == 403) {
|
|
266
|
+
helloFuture.completeExceptionally(error)
|
|
267
|
+
listener.onPermanentFailure(error)
|
|
268
|
+
} else {
|
|
269
|
+
reconnect()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 指数退避重连:1s,2s,4s,8s,16s,封顶 30s。与 [EventSubscription] 同一套参数,
|
|
277
|
+
* 免得同一个 App 里两条长连接节奏不一样、把服务端 32 个槽位一起打满。
|
|
278
|
+
*/
|
|
279
|
+
private fun reconnect() {
|
|
280
|
+
if (closed.get()) return
|
|
281
|
+
val delaySeconds = minOf(30L, 1L shl minOf(attempts++, 5))
|
|
282
|
+
retry?.cancel(false)
|
|
283
|
+
retry = scheduler.schedule(::connect, delaySeconds, TimeUnit.SECONDS)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private fun dispatch(text: String) {
|
|
287
|
+
val envelope = json.parseToJsonElement(text).jsonObject
|
|
288
|
+
val kind = envelope["kind"]?.jsonPrimitive?.contentOrNullSafe()
|
|
289
|
+
?: throw MobileHandshakeException("服务端帧缺少 kind:${text.take(200)}")
|
|
290
|
+
val data = envelope["data"] ?: JsonObject(emptyMap())
|
|
291
|
+
val frame = MobileFrame(kind, data)
|
|
292
|
+
|
|
293
|
+
when (kind) {
|
|
294
|
+
"hello" -> {
|
|
295
|
+
val parsed = json.decodeFromJsonElement(MobileHello.serializer(), data)
|
|
296
|
+
hello = parsed
|
|
297
|
+
listener.onHello(parsed)
|
|
298
|
+
if (parsed.protocol != MOBILE_HELLO_PROTOCOL) {
|
|
299
|
+
// 协议不匹配时**不要**继续用这条连接发帧:字段语义已经不可信了。
|
|
300
|
+
val error = MobileHandshakeException(
|
|
301
|
+
"移动网关协议是 ${parsed.protocol},本 SDK 只支持 $MOBILE_HELLO_PROTOCOL。",
|
|
302
|
+
)
|
|
303
|
+
helloFuture.completeExceptionally(error)
|
|
304
|
+
listener.onPermanentFailure(error)
|
|
305
|
+
close()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
helloFuture.complete(parsed)
|
|
309
|
+
}
|
|
310
|
+
"paired" -> {
|
|
311
|
+
val parsed = json.decodeFromJsonElement(MobilePaired.serializer(), data)
|
|
312
|
+
pairedToken = parsed.token
|
|
313
|
+
listener.onPaired(parsed)
|
|
314
|
+
}
|
|
315
|
+
"pong" -> {
|
|
316
|
+
val at = (data as? JsonObject)?.get("at")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: 0L
|
|
317
|
+
val time = (data as? JsonObject)?.get("time")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: at
|
|
318
|
+
listener.onPong(at, time)
|
|
319
|
+
}
|
|
320
|
+
"event" -> listener.onEvent(data)
|
|
321
|
+
else -> listener.onFrame(frame)
|
|
322
|
+
}
|
|
323
|
+
// requestId 关联放在最后:即使上面某个分支已经派发过回调,等待者也必须被唤醒。
|
|
324
|
+
frame.requestId?.let { id -> pending.remove(id)?.complete(frame) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 非字符串 primitive(数字/布尔)的 `content` 本身就是字面量文本,取它即可。 */
|
|
328
|
+
private fun JsonPrimitive.contentOrNullSafe(): String = content
|
|
329
|
+
|
|
330
|
+
// ── 请求-响应 ────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 发一帧并等它的回帧,靠 `requestId` 关联。
|
|
334
|
+
*
|
|
335
|
+
* 关联约定(与服务端一致,见 asyncapi 的 `/ws/mobile` 描述):
|
|
336
|
+
* - `requestId` 是不透明字符串,服务端**原样回显**,客户端只管比对;
|
|
337
|
+
* - 服务端在没有 requestId 时**整段省略**这个键,不会回 `null` 或空串;
|
|
338
|
+
* - `file-resolve` / `file-download-open` / `file-upload-open` 三帧的 requestId 是**必填**,
|
|
339
|
+
* 缺了服务端回 `bad-request`;
|
|
340
|
+
* - 关联是**每连接**作用域,不同连接可以用同一个 requestId。
|
|
341
|
+
*
|
|
342
|
+
* @throws MobileGatewayException 服务端回 `error` 帧(`UNKNOWN_TYPE` / `MISSING_PARAMS` 等)。
|
|
343
|
+
* @throws MobileRequestTimeoutException 超时。
|
|
344
|
+
*/
|
|
345
|
+
fun request(
|
|
346
|
+
type: String,
|
|
347
|
+
payload: Map<String, JsonElement> = emptyMap(),
|
|
348
|
+
requestId: String = "req-${requestSeq.incrementAndGet()}",
|
|
349
|
+
timeoutMs: Long = 30_000,
|
|
350
|
+
): MobileFrame {
|
|
351
|
+
val future = CompletableFuture<MobileFrame>()
|
|
352
|
+
pending[requestId] = future
|
|
353
|
+
val frame = buildJsonObject {
|
|
354
|
+
put("type", type)
|
|
355
|
+
put("requestId", requestId)
|
|
356
|
+
payload.forEach { (key, value) -> put(key, value) }
|
|
357
|
+
}
|
|
358
|
+
return try {
|
|
359
|
+
sendRaw(frame)
|
|
360
|
+
val reply = future.get(timeoutMs, TimeUnit.MILLISECONDS)
|
|
361
|
+
if (reply.kind == "error") throw toException(reply, type)
|
|
362
|
+
reply
|
|
363
|
+
} catch (timeout: java.util.concurrent.TimeoutException) {
|
|
364
|
+
throw MobileRequestTimeoutException(requestId, type, timeoutMs)
|
|
365
|
+
} catch (failure: java.util.concurrent.ExecutionException) {
|
|
366
|
+
// failPending 会把连接错误包成 ExecutionException;还原成真实原因才好定位。
|
|
367
|
+
throw (failure.cause as? RuntimeException) ?: IllegalStateException("请求失败:$type", failure.cause)
|
|
368
|
+
} finally {
|
|
369
|
+
pending.remove(requestId)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 发一帧不等回帧(`message`、`subscribe`、`approval-response` 这类)。 */
|
|
374
|
+
fun send(frame: JsonObject) {
|
|
375
|
+
sendRaw(frame)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 便捷封装:`{type:'ping'}`。回帧走 [MobileGatewayListener.onPong]。 */
|
|
379
|
+
fun ping() {
|
|
380
|
+
sendRaw(buildJsonObject { put("type", "ping") })
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 订阅单个会话的事件流。服务端会顺带回放该会话未决的审批/提问(带 `replayed:true`)。 */
|
|
384
|
+
fun subscribe(sessionId: String) {
|
|
385
|
+
sendRaw(buildJsonObject { put("type", "subscribe"); put("sessionId", sessionId) })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 取消订阅。之后事件流不再按会话过滤,回到全量。 */
|
|
389
|
+
fun unsubscribe() {
|
|
390
|
+
sendRaw(buildJsonObject { put("type", "unsubscribe") })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private fun sendRaw(frame: JsonObject) {
|
|
394
|
+
val target = socket ?: throw IllegalStateException("移动网关连接尚未建立。")
|
|
395
|
+
if (!target.send(json.encodeToString(JsonObject.serializer(), frame))) {
|
|
396
|
+
// send 返回 false 说明写队列已满/连接已关,帧根本没出去。静默丢帧比抛错更难查。
|
|
397
|
+
throw IllegalStateException("帧没有进入发送队列(连接已关闭或写缓冲已满):$frame")
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private fun toException(reply: MobileFrame, type: String): MobileGatewayException {
|
|
402
|
+
val data = reply.data as? JsonObject
|
|
403
|
+
val code = data?.get("code")?.jsonPrimitive?.contentOrNullSafe() ?: "UNKNOWN"
|
|
404
|
+
val message = data?.get("message")?.jsonPrimitive?.contentOrNullSafe() ?: code
|
|
405
|
+
return MobileGatewayException(
|
|
406
|
+
code = code,
|
|
407
|
+
message = message,
|
|
408
|
+
requestType = data?.get("requestType")?.jsonPrimitive?.contentOrNullSafe() ?: type,
|
|
409
|
+
requestId = reply.requestId,
|
|
410
|
+
statusCode = data?.get("statusCode")?.jsonPrimitive?.contentOrNullSafe()?.toIntOrNull(),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fun failPending(error: Throwable) {
|
|
415
|
+
pending.values.forEach { it.completeExceptionally(error) }
|
|
416
|
+
pending.clear()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/** 等 hello。握手失败(4001、协议不匹配、连接建立不起来)会抛异常而不是干等。 */
|
|
422
|
+
fun awaitHello(timeoutMs: Long = 15_000): MobileHello = try {
|
|
423
|
+
helloFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
|
|
424
|
+
} catch (timeout: java.util.concurrent.TimeoutException) {
|
|
425
|
+
throw MobileHandshakeException("${timeoutMs}ms 内没有收到 hello")
|
|
426
|
+
} catch (failure: java.util.concurrent.ExecutionException) {
|
|
427
|
+
throw (failure.cause as? RuntimeException) ?: MobileHandshakeException("握手失败:${failure.cause?.message}")
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
override fun close() {
|
|
431
|
+
if (!closed.compareAndSet(false, true)) return
|
|
432
|
+
retry?.cancel(false)
|
|
433
|
+
failPending(IllegalStateException("客户端已关闭"))
|
|
434
|
+
socket?.close(1000, "Client closed")
|
|
435
|
+
scheduler.shutdownNow()
|
|
436
|
+
}
|
|
437
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -2208,12 +2208,36 @@ interface HostRemoteHandle {
|
|
|
2208
2208
|
}
|
|
2209
2209
|
//#endregion
|
|
2210
2210
|
//#region src/host/ws-handler.d.ts
|
|
2211
|
+
/**
|
|
2212
|
+
* `/ws/mobile` 的可调参数。
|
|
2213
|
+
*
|
|
2214
|
+
* 三个都有默认值,且**必须可注入**:默认值(32 / 30 s / 512)在单测里既等不起
|
|
2215
|
+
* (30 s 的心跳周期)也压不满(512 深度要发五百多帧)。可注入才能把「第 33 条 429」
|
|
2216
|
+
* 「两个周期踢人」「溢出补 resync」这三条**文档承诺的行为**变成可断言的用例。
|
|
2217
|
+
*/
|
|
2218
|
+
interface MobileGatewayOptions {
|
|
2219
|
+
/** 同时允许的连接数上限,默认 {@link DEFAULT_MOBILE_MAX_CONNECTIONS}。 */
|
|
2220
|
+
maxConnections?: number;
|
|
2221
|
+
/** 心跳周期(毫秒),默认 {@link DEFAULT_MOBILE_HEARTBEAT_INTERVAL_MS}。 */
|
|
2222
|
+
heartbeatIntervalMs?: number;
|
|
2223
|
+
/** 单连接发送队列深度,默认 {@link DEFAULT_MOBILE_QUEUE_LIMIT}。 */
|
|
2224
|
+
queueLimit?: number;
|
|
2225
|
+
}
|
|
2211
2226
|
declare class MobileGatewayWsHandler {
|
|
2212
2227
|
private readonly database;
|
|
2213
2228
|
private readonly chat;
|
|
2214
2229
|
private readonly auth;
|
|
2215
2230
|
private readonly capabilitiesFlags;
|
|
2216
2231
|
private readonly sockets;
|
|
2232
|
+
/**
|
|
2233
|
+
* 当前异步上下文里正在处理的那一帧客户端请求的 `requestId`。
|
|
2234
|
+
*
|
|
2235
|
+
* **为什么必须是 AsyncLocalStorage 而不是实例字段/闭包变量**:`webSocket.on('message')`
|
|
2236
|
+
* 里是 `void this.handleMessage(...)`(不 await),同一条连接可以同时有多个请求在途;
|
|
2237
|
+
* 任何「先存起来、发送时再读」的共享槽位都会把并发请求的 id 串到一起。ALS 把上下文
|
|
2238
|
+
* 绑定在**这条 await 链**上,`await` 穿过它、并发分支各持一份,互不可见。
|
|
2239
|
+
*/
|
|
2240
|
+
private readonly frameRequestId;
|
|
2217
2241
|
private settingsService;
|
|
2218
2242
|
private filesService;
|
|
2219
2243
|
/**
|
|
@@ -2239,7 +2263,15 @@ declare class MobileGatewayWsHandler {
|
|
|
2239
2263
|
* typertGateway+connection 后注入;传入 undefined 表示当前内核不提供(优雅降级)。
|
|
2240
2264
|
*/
|
|
2241
2265
|
attachRemoteServices(remote: HostRemoteHandle | undefined): void;
|
|
2242
|
-
|
|
2266
|
+
/** 同时允许的连接数上限(鉴权后计数;server.ts 在 Upgrade 前据此回 429)。 */
|
|
2267
|
+
private readonly maxConnections;
|
|
2268
|
+
/** 心跳周期:每周期 ping 一次,连续两个周期没回 pong 就 terminate。 */
|
|
2269
|
+
private readonly heartbeatIntervalMs;
|
|
2270
|
+
/** 单连接发送队列深度(溢出时先丢可重建帧)。 */
|
|
2271
|
+
private readonly queueLimit;
|
|
2272
|
+
/** 心跳定时器(首个连接接入时惰性启动,{@link stop} 时清掉)。 */
|
|
2273
|
+
private heartbeatTimer;
|
|
2274
|
+
constructor(database: WorkspaceDatabase, chat: DshChatAdapter, auth: AuthService, events: WorkspaceEventBus, capabilitiesFlags: CapabilityFlags, options?: MobileGatewayOptions);
|
|
2243
2275
|
private readonly eventBridge;
|
|
2244
2276
|
/**
|
|
2245
2277
|
* 判定这次 upgrade 是否属于 /ws/mobile。server.ts 负责实际的 WebSocket 升级,
|
|
@@ -2265,7 +2297,18 @@ declare class MobileGatewayWsHandler {
|
|
|
2265
2297
|
private sendHello;
|
|
2266
2298
|
private stateOf;
|
|
2267
2299
|
private principalToDeviceView;
|
|
2300
|
+
/**
|
|
2301
|
+
* 收到一条文本帧的入口:解码 → 校验 → **绑定 requestId 上下文** → 分发。
|
|
2302
|
+
*
|
|
2303
|
+
* 这里同时是唯一的异常兜底点。调用方是 `webSocket.on('message')` 里的
|
|
2304
|
+
* `void this.handleMessage(...)`(不 await),任何从这里冒出去的 rejection 都会成为
|
|
2305
|
+
* 进程级 unhandledRejection —— DSH 宿主装了 `installFailLoud`,直接 `proc.exit(1)`。
|
|
2306
|
+
* 而 `handleHistory` 等基础帧是直接 `await this.chat.*`,一次越权的 `sessionId` 就能抛
|
|
2307
|
+
* `ApiError(404, 'SESSION_NOT_FOUND')`。所以分发必须整体包在 try/catch 里。
|
|
2308
|
+
*/
|
|
2268
2309
|
private handleMessage;
|
|
2310
|
+
/** 把一帧已解码、已校验的请求分发给对应的处理器(异常由 {@link handleMessage} 兜底)。 */
|
|
2311
|
+
private routeFrame;
|
|
2269
2312
|
private handlePing;
|
|
2270
2313
|
private handleSubscribe;
|
|
2271
2314
|
private handleUnsubscribe;
|
|
@@ -2279,10 +2322,41 @@ declare class MobileGatewayWsHandler {
|
|
|
2279
2322
|
private handleSessions;
|
|
2280
2323
|
private handleHistory;
|
|
2281
2324
|
private handleAttachment;
|
|
2282
|
-
/**
|
|
2325
|
+
/**
|
|
2326
|
+
* 向一条连接发一条帧(带背压队列)。
|
|
2327
|
+
*
|
|
2328
|
+
* 应答帧统一在这里回显 `requestId`:从 {@link frameRequestId} 取**当前异步上下文**的
|
|
2329
|
+
* requestId,交给 {@link withRequestIdEcho} 决定要不要塞进 `data`。处理器无需再各自
|
|
2330
|
+
* 复制一遍(已手写回显的处理器不受影响 —— 注入规则是「`data` 里已有就不覆盖」)。
|
|
2331
|
+
*/
|
|
2283
2332
|
private sendFrame;
|
|
2333
|
+
/**
|
|
2334
|
+
* 压入发送队列,队列满时**先牺牲可重建的帧**。
|
|
2335
|
+
*
|
|
2336
|
+
* 与 `/api/v1/events`(server.ts 的 `enqueue`)同一套取舍,只是这里没有「可重建事件
|
|
2337
|
+
* 标记表」可用,改用 kind 判据。为什么不能无脑 `shift()` 丢最旧:最旧的那条可能正是
|
|
2338
|
+
* 某次请求的**应答**,丢掉它客户端就会一直等到超时(而它等的那个东西其实早成功了),
|
|
2339
|
+
* 反过来一条 `chat.message.delta` 丢了只影响一句的流式观感,history 一拉就齐。
|
|
2340
|
+
*
|
|
2341
|
+
* 被丢的帧数累加到 `state.dropped`,由 {@link drain} 在排空后补一条
|
|
2342
|
+
* `connection-resync-required` 告知客户端 —— **静默丢帧比丢帧本身更糟**。
|
|
2343
|
+
*/
|
|
2344
|
+
private enqueue;
|
|
2284
2345
|
private sendError;
|
|
2285
|
-
/**
|
|
2346
|
+
/**
|
|
2347
|
+
* 把分发过程中冒出来的异常变成一条 `error` 帧(范式与各域处理器的 `reportError` 一致)。
|
|
2348
|
+
*
|
|
2349
|
+
* **这里的 try/catch 不是多余的**:`sendFrame` 第一行就是 `this.stateOf(webSocket)`,而
|
|
2350
|
+
* `teardown` 会在 `close`/`error` 时把连接从 `sockets` 摘掉。连接已断时 `stateOf` 抛
|
|
2351
|
+
* `ApiError(4001, 'NOT_AUTHENTICATED')` —— 在 catch 块里再抛一次,就等于没修。
|
|
2352
|
+
*/
|
|
2353
|
+
private reportFrameError;
|
|
2354
|
+
/**
|
|
2355
|
+
* 把队列里的帧逐条发出去,排空后按需补一条「你漏了 N 帧」的告知帧。
|
|
2356
|
+
*
|
|
2357
|
+
* 补发放在**排空之后**而不是溢出当场:溢出那一刻队列正满,插一条进去还得再挤掉别人;
|
|
2358
|
+
* 等排空再补,既不会二次溢出,也能把同一段时间内多次溢出合并成一条(`dropped` 累加)。
|
|
2359
|
+
*/
|
|
2286
2360
|
private drain;
|
|
2287
2361
|
/** 拒绝未鉴权连接(401 close code)。 */
|
|
2288
2362
|
private closeUnauthorized;
|
|
@@ -2290,6 +2364,29 @@ declare class MobileGatewayWsHandler {
|
|
|
2290
2364
|
private closeWithCode;
|
|
2291
2365
|
/** 关闭所有连接(server.stop 时调用)。 */
|
|
2292
2366
|
stop(timeoutMs?: number): Promise<void>;
|
|
2367
|
+
/**
|
|
2368
|
+
* 心跳循环:每 `heartbeatIntervalMs` ping 一次,**连续错过两个周期**才 terminate。
|
|
2369
|
+
*
|
|
2370
|
+
* 为什么必须有这一步:`/ws/mobile` 是手机上的长连接,切网、熄屏、NAT 表项过期都会留下
|
|
2371
|
+
* **半开连接** —— 内核这边没有任何事件,TCP 要等自己超时才报错(默认以分钟计),期间
|
|
2372
|
+
* 这条连接一直占着 `sockets` 里的额度、每次广播还白查一遍库。API 文档承诺「连续错过
|
|
2373
|
+
* 2 个心跳周期 terminate」,这里就是把它兑现。
|
|
2374
|
+
*
|
|
2375
|
+
* 两个刻意的取舍:
|
|
2376
|
+
* - `terminate()` 而不是 `close()`:对端已经不在,优雅挥手没人收,只会多等一个超时;
|
|
2377
|
+
* `close`/`error` 里注册的 `teardown` 会照常摘表、摘事件桥、中止语音与文件传输;
|
|
2378
|
+
* - 存活标记用底层 `pong` 控制帧:客户端库自动回,不需要业务代码配合(业务层的
|
|
2379
|
+
* `ping` 帧是另一回事,用于测 RTT)。
|
|
2380
|
+
*/
|
|
2381
|
+
private startHeartbeat;
|
|
2382
|
+
private clearHeartbeat;
|
|
2383
|
+
/**
|
|
2384
|
+
* 连接数上限(供 server.ts 在 **Upgrade 之前**判定是否回 429)。
|
|
2385
|
+
*
|
|
2386
|
+
* 为什么要在 upgrade 前判:升级之后就只能发 WS 关闭码,`Retry-After` 根本无法表达,
|
|
2387
|
+
* 客户端也拿不到「稍后再试」这个语义(只会看到一次莫名其妙的断连)。
|
|
2388
|
+
*/
|
|
2389
|
+
connectionLimit(): number;
|
|
2293
2390
|
/** 当前连接数(供 metrics / 观测)。 */
|
|
2294
2391
|
connectionCount(): number;
|
|
2295
2392
|
}
|