dsh-plugin-mobile-link 0.6.0 → 0.6.5
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.md +8 -1
- package/cordis.patch.yml +12 -0
- package/lib/diagnostics.js +172 -0
- package/lib/hello.js +4 -0
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +28 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history.js +62 -16
- package/lib/index.js +16 -0
- package/lib/journal.js +279 -0
- package/lib/link.js +91 -4
- package/lib/router.js +215 -2
- package/lib/routes.js +0 -32
- package/lib/session-digest.js +117 -0
- package/lib/status.js +28 -2
- package/package.json +1 -1
- package/lib/history/probe.js +0 -195
package/lib/link.js
CHANGED
|
@@ -20,10 +20,11 @@ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl
|
|
|
20
20
|
const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
|
|
21
21
|
import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
|
|
22
22
|
import { handoffFromEndpoint, writeHandoff } from './handoff.js'
|
|
23
|
+
import { LinkJournal, defaultJournalPath } from './journal.js'
|
|
23
24
|
import { statusSnapshot } from './status.js'
|
|
24
25
|
import { mintPairCode } from './pairing.js'
|
|
25
26
|
import {
|
|
26
|
-
DEFAULT_EVENTS_BACKLOG,
|
|
27
|
+
DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
|
|
27
28
|
EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
|
|
28
29
|
} from './router.js'
|
|
29
30
|
import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
|
|
@@ -57,7 +58,14 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
57
58
|
logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
|
|
58
59
|
random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
|
|
59
60
|
watchdogMs = 180000,
|
|
61
|
+
// 慢请求阈值(毫秒);0 = 关闭逐条慢请求记录。见 router.js 的 DEFAULT_SLOW_REQUEST_MS。
|
|
62
|
+
slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
|
|
63
|
+
// 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
|
|
64
|
+
diagnostics = true,
|
|
60
65
|
inviteCode, fetchImpl = fetch, env = process.env,
|
|
66
|
+
// 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
|
|
67
|
+
// 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
|
|
68
|
+
journal, journalPath,
|
|
61
69
|
// 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
|
|
62
70
|
// 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
|
|
63
71
|
eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
|
|
@@ -69,6 +77,8 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
69
77
|
this.pongTimeoutMs = pongTimeoutMs
|
|
70
78
|
this.maxBackoffMs = maxBackoffMs
|
|
71
79
|
this.watchdogMs = watchdogMs
|
|
80
|
+
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
81
|
+
this.diagnostics = diagnostics !== false
|
|
72
82
|
this.random = random
|
|
73
83
|
this.now = now
|
|
74
84
|
this.connectImpl = connectImpl
|
|
@@ -79,13 +89,29 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
79
89
|
//: to fix, not a transport problem to retry.
|
|
80
90
|
this.needsEnroll = false
|
|
81
91
|
this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
|
|
92
|
+
/** 生命周期流水:内存环 +(默认)一份 JSONL,见 journal.js 的文件头。 */
|
|
93
|
+
this.journal = journal ?? new LinkJournal({
|
|
94
|
+
path: journalPath === false
|
|
95
|
+
? undefined
|
|
96
|
+
: (typeof journalPath === 'string' && journalPath ? journalPath : defaultJournalPath()),
|
|
97
|
+
now,
|
|
98
|
+
logger,
|
|
99
|
+
})
|
|
82
100
|
this.router = new DeviceRouter({
|
|
83
101
|
dsh: this.dsh,
|
|
84
102
|
logger,
|
|
85
103
|
now,
|
|
86
104
|
protocolVersion: PROTOCOL_VERSION,
|
|
87
105
|
eventsBacklog,
|
|
106
|
+
slowRequestMs: this.slowRequestMs,
|
|
107
|
+
diagnostics: this.diagnostics,
|
|
88
108
|
send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
|
|
109
|
+
// 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
|
|
110
|
+
// "连接器拆链"对上时间,两边必须写在同一份文件里。
|
|
111
|
+
// `options.foreign` 表示这条事件用的是**别人的钟**(App 推上来的),必须原样保留时刻。
|
|
112
|
+
onEvent: (kind, detail, options) => (options?.foreign
|
|
113
|
+
? this.journal.recordForeign(kind, detail)
|
|
114
|
+
: this.journal.record(kind, detail)),
|
|
89
115
|
// 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
|
|
90
116
|
// **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
|
|
91
117
|
// 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
|
|
@@ -108,6 +134,16 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
108
134
|
this.startedAt = undefined
|
|
109
135
|
this.connectedAt = undefined
|
|
110
136
|
this.reconnectAttempts = 0
|
|
137
|
+
/** 最近一次拆链的时刻(用来在 `link.open` 里报"距上次断开多久")。 */
|
|
138
|
+
this._lastClosedAt = undefined
|
|
139
|
+
/**
|
|
140
|
+
* socket 上最后一次系统错误(`onError` 的文本)。
|
|
141
|
+
*
|
|
142
|
+
* 为什么要专门留住它:链路被异常关闭时,`close` 回调只给一个代码(实测常见 1006,
|
|
143
|
+
* 含义是"没有关闭帧"),据此无法判断是网络、代理还是对端。系统错误文本是唯一线索,
|
|
144
|
+
* 而它以前只进了 debug 日志、不落流水(2026-10-13 现场诊断时卡在这里)。
|
|
145
|
+
*/
|
|
146
|
+
this._socketError = undefined
|
|
111
147
|
|
|
112
148
|
this._stopping = false
|
|
113
149
|
this._sessionClosed = undefined
|
|
@@ -233,6 +269,15 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
233
269
|
this._abort = new AbortController()
|
|
234
270
|
this.startedAt = this.now()
|
|
235
271
|
this.state = 'connecting'
|
|
272
|
+
this.journal.record('link.start', {
|
|
273
|
+
// 这里只能报**配置值**:真身份(含实际中转地址)要等 `#session()` 里的
|
|
274
|
+
// `resolveIdentity()` 才读到,那条记在 `link.open` 上。名字带 configured,
|
|
275
|
+
// 免得读者把占位符 `wss://relay.example.com/dsh-link` 当成真的在连的地址。
|
|
276
|
+
configuredRelay: this.config.relayUrl,
|
|
277
|
+
agentId: this.config.agentId || undefined,
|
|
278
|
+
attempt: this.reconnectAttempts,
|
|
279
|
+
host: this.hostForm,
|
|
280
|
+
})
|
|
236
281
|
// 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
|
|
237
282
|
void this.refreshLocal()
|
|
238
283
|
this._lastProgressAt = this.now()
|
|
@@ -262,6 +307,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
262
307
|
this._loop = undefined
|
|
263
308
|
this.dsh.close()
|
|
264
309
|
this.state = 'stopped'
|
|
310
|
+
this.journal.record('link.stop', {})
|
|
265
311
|
this.emit('status')
|
|
266
312
|
}
|
|
267
313
|
|
|
@@ -278,6 +324,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
278
324
|
this.needsEnroll = true
|
|
279
325
|
this.state = 'unregistered'
|
|
280
326
|
this.lastError = messageOf(error)
|
|
327
|
+
this.journal.record('link.unregistered', { error: this.lastError })
|
|
281
328
|
this.logger.error?.(this.lastError)
|
|
282
329
|
this.emit('status')
|
|
283
330
|
return
|
|
@@ -295,11 +342,13 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
295
342
|
this.needsEnroll = true
|
|
296
343
|
this.state = 'unregistered'
|
|
297
344
|
this.lastError = messageOf(error)
|
|
345
|
+
this.journal.record('link.unregistered', { error: this.lastError })
|
|
298
346
|
this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
|
|
299
347
|
this.emit('status')
|
|
300
348
|
return
|
|
301
349
|
}
|
|
302
350
|
this.lastError = messageOf(error)
|
|
351
|
+
this.journal.record('link.failed', { error: this.lastError, attempt: this.reconnectAttempts })
|
|
303
352
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
304
353
|
}
|
|
305
354
|
if (this._stopping) break
|
|
@@ -307,6 +356,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
307
356
|
this.emit('status')
|
|
308
357
|
const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
|
|
309
358
|
this.reconnectAttempts += 1
|
|
359
|
+
this.journal.record('link.backoff', { attempt: this.reconnectAttempts, delayMs: Math.round(delay) })
|
|
310
360
|
await sleep(delay, this._abort.signal)
|
|
311
361
|
}
|
|
312
362
|
this.state = 'stopped'
|
|
@@ -365,6 +415,12 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
365
415
|
this.router.lastError = undefined
|
|
366
416
|
this._lastPong = this.now()
|
|
367
417
|
this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
|
|
418
|
+
this.journal.record('link.open', {
|
|
419
|
+
relay: this.relay?.wsBase,
|
|
420
|
+
agentId: identity.agentId,
|
|
421
|
+
attempt: this.reconnectAttempts,
|
|
422
|
+
sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
|
|
423
|
+
})
|
|
368
424
|
this.emit('status')
|
|
369
425
|
|
|
370
426
|
socket.onMessage((text) => {
|
|
@@ -373,7 +429,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
373
429
|
.catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
|
|
374
430
|
return this._inbox
|
|
375
431
|
})
|
|
376
|
-
socket.onError((error) =>
|
|
432
|
+
socket.onError((error) => {
|
|
433
|
+
this._socketError = messageOf(error)
|
|
434
|
+
this.logger.debug?.(`mobile-link: relay socket error: ${this._socketError}`)
|
|
435
|
+
})
|
|
377
436
|
socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
|
|
378
437
|
|
|
379
438
|
this.#startHeartbeat()
|
|
@@ -386,9 +445,13 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
386
445
|
// 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
|
|
387
446
|
// 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
|
|
388
447
|
await this.#writeHandoff()
|
|
448
|
+
this.journal.record('dsh.ok', { endpoint: this.dsh?.endpoint?.base })
|
|
389
449
|
} catch (error) {
|
|
390
450
|
this.dshError = messageOf(error)
|
|
391
451
|
this.lastError = this.dshError
|
|
452
|
+
// 这一条很关键:本机 DSH 不可达时,手机那句 `_link/hello`/`sessions` 探针
|
|
453
|
+
// 必然超时,App 会据此重建整条链路——于是"连接器在、手机却连不上"。
|
|
454
|
+
this.journal.record('dsh.unreachable', { error: this.dshError, kind: this.dsh?.errorKind })
|
|
392
455
|
this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
|
|
393
456
|
}
|
|
394
457
|
this.emit('status')
|
|
@@ -419,6 +482,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
419
482
|
const idle = this.now() - this._lastProgressAt
|
|
420
483
|
if (!this._loopActive) {
|
|
421
484
|
this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
|
|
485
|
+
this.journal.record('watchdog.repull', { idleMs: Math.round(idle) })
|
|
422
486
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
423
487
|
this._loop = undefined
|
|
424
488
|
this._lastProgressAt = this.now()
|
|
@@ -427,6 +491,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
427
491
|
}
|
|
428
492
|
if (this.state === 'connected' && idle > this.pongTimeoutMs) {
|
|
429
493
|
this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
|
|
494
|
+
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
430
495
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
431
496
|
this.#teardownSocket(this.lastError)
|
|
432
497
|
this._lastProgressAt = this.now()
|
|
@@ -434,6 +499,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
434
499
|
}
|
|
435
500
|
if (this.state !== 'connected' && idle > this.watchdogMs) {
|
|
436
501
|
this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
|
|
502
|
+
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
437
503
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
438
504
|
this.#teardownSocket(this.lastError)
|
|
439
505
|
this._lastProgressAt = this.now()
|
|
@@ -469,6 +535,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
469
535
|
#teardownSocket(reason) {
|
|
470
536
|
this.#stopHeartbeat()
|
|
471
537
|
const socket = this.socket
|
|
538
|
+
const from = this.state
|
|
472
539
|
this.socket = undefined
|
|
473
540
|
if (socket) {
|
|
474
541
|
try {
|
|
@@ -483,6 +550,18 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
483
550
|
this.dsh.close()
|
|
484
551
|
if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
|
|
485
552
|
this.lastError = this.lastError ?? reason
|
|
553
|
+
this._lastClosedAt = this.now()
|
|
554
|
+
// 「为什么拆的」是这一族事件唯一要回答的问题:reason 来自调用点,
|
|
555
|
+
// from 是拆之前的状态(connected / backoff / connecting),
|
|
556
|
+
// connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
|
|
557
|
+
this.journal.record('link.close', {
|
|
558
|
+
reason,
|
|
559
|
+
from,
|
|
560
|
+
connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
|
|
561
|
+
// 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
|
|
562
|
+
socketError: this._socketError,
|
|
563
|
+
})
|
|
564
|
+
this._socketError = undefined
|
|
486
565
|
const resolve = this._sessionClosed
|
|
487
566
|
this._sessionClosed = undefined
|
|
488
567
|
this.emit('status')
|
|
@@ -492,10 +571,15 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
492
571
|
// ── relay I/O ───────────────────────────────────────────────────────────
|
|
493
572
|
|
|
494
573
|
#rawSend(frame) {
|
|
574
|
+
return this.#sendText(encodeFrame(frame))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
|
|
578
|
+
#sendText(text) {
|
|
495
579
|
const socket = this.socket
|
|
496
580
|
if (!socket || socket.closed) return false
|
|
497
581
|
try {
|
|
498
|
-
socket.send(
|
|
582
|
+
socket.send(text)
|
|
499
583
|
return true
|
|
500
584
|
} catch (error) {
|
|
501
585
|
this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
|
|
@@ -507,7 +591,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
507
591
|
// `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
|
|
508
592
|
// relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
|
|
509
593
|
if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
|
|
510
|
-
|
|
594
|
+
const text = encodeFrame({ ...frame, deviceId })
|
|
595
|
+
// 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
|
|
596
|
+
this.router.noteSent?.(deviceId, Buffer.byteLength(text))
|
|
597
|
+
return this.#sendText(text)
|
|
511
598
|
}
|
|
512
599
|
|
|
513
600
|
sendHostStatus() {
|
package/lib/router.js
CHANGED
|
@@ -17,6 +17,7 @@ import { FileFetcher, FSGET_CANCEL } from './files-out.js'
|
|
|
17
17
|
import { GitBridge, isGitMethod } from './git.js'
|
|
18
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
19
19
|
import { isHistoryMethod } from './history.js'
|
|
20
|
+
import { trimRecordsForWire } from './history/records.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
@@ -75,6 +76,62 @@ export function errorObject(code, message, details = {}) {
|
|
|
75
76
|
return { code, message, details }
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 旧分页通道(`session/page`)的下发形态清洗(016)。
|
|
81
|
+
*
|
|
82
|
+
* 这一路是**降级通道**:宿主自己回一页记录,连接器只是转发。它和精确窗口
|
|
83
|
+
* (`_link/sessionWindow`)必须守同一条口径 —— 只用于诊断的宿主模型输出日志
|
|
84
|
+
* 不下发(见 `specs/016-history-load-perf/contracts/history-record-trim.md`)。
|
|
85
|
+
*
|
|
86
|
+
* 两种形态都要认:宿主 RPC 的信封 `{ ok, value: { records } }`,以及直接给
|
|
87
|
+
* `{ records }` 的桩/测试形态。其余方法或结构一律原样返回,不做推断。
|
|
88
|
+
*/
|
|
89
|
+
export function trimHistoryPageForWire(method, result) {
|
|
90
|
+
if (method !== 'session/page') return result
|
|
91
|
+
if (result === null || typeof result !== 'object') return result
|
|
92
|
+
const page = result.value ?? result
|
|
93
|
+
if (page === null || typeof page !== 'object' || !Array.isArray(page.records)) return result
|
|
94
|
+
const trimmed = { ...page, records: trimRecordsForWire(page.records) }
|
|
95
|
+
return result.value === undefined ? trimmed : { ...result, value: trimmed }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
import { DeviceTimeline } from './diagnostics.js'
|
|
99
|
+
import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
|
|
100
|
+
|
|
101
|
+
/** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
|
|
102
|
+
export const DEFAULT_SLOW_REQUEST_MS = 2000
|
|
103
|
+
|
|
104
|
+
/** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
|
|
105
|
+
export const DIAGNOSTICS_METHOD = '_link/diagnostics'
|
|
106
|
+
/** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
|
|
107
|
+
export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
|
|
108
|
+
/** 单条事件里字符串字段的保留长度。 */
|
|
109
|
+
const DIAGNOSTICS_MAX_TEXT = 400
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 只留标量(字符串/数字/布尔)与标量数组。
|
|
113
|
+
*
|
|
114
|
+
* **必须把 App 的 `detail` 摊平到顶层**:App 侧的 JSONL 信封是
|
|
115
|
+
* `{kind, seq, ms, at, detail:{...}}`(见 DSHKit 的 `LinkDiagnostics.Event`),而连接器
|
|
116
|
+
* 自己的流水是**扁平**的。不摊平的话 `phase`/`ms` 这些字段会在清洗时被当成嵌套对象丢掉——
|
|
117
|
+
* 实测症状:`app.dial` 落盘后一个字段都没有(2026-10-13 09:25 的中转运行)。
|
|
118
|
+
*/
|
|
119
|
+
function sanitizeDiagnosticEvent(event) {
|
|
120
|
+
if (!event || typeof event !== 'object' || typeof event.kind !== 'string') return null
|
|
121
|
+
const { detail, ...envelope } = event
|
|
122
|
+
const flat = { ...envelope, ...(detail && typeof detail === 'object' ? detail : {}) }
|
|
123
|
+
const clean = { kind: event.kind }
|
|
124
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
125
|
+
if (key === 'kind') continue
|
|
126
|
+
if (typeof value === 'string') clean[key] = value.slice(0, DIAGNOSTICS_MAX_TEXT)
|
|
127
|
+
else if (typeof value === 'number' || typeof value === 'boolean') clean[key] = value
|
|
128
|
+
else if (Array.isArray(value) && value.every((item) => typeof item === 'string' || typeof item === 'number')) {
|
|
129
|
+
clean[key] = value.slice(0, 32)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return clean
|
|
133
|
+
}
|
|
134
|
+
|
|
78
135
|
export class DeviceRouter {
|
|
79
136
|
/**
|
|
80
137
|
* @param {object} options
|
|
@@ -84,13 +141,28 @@ export class DeviceRouter {
|
|
|
84
141
|
*/
|
|
85
142
|
constructor({
|
|
86
143
|
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
87
|
-
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
|
|
144
|
+
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
|
|
145
|
+
slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
|
|
88
146
|
} = {}) {
|
|
89
147
|
this.dsh = dsh
|
|
90
148
|
this.eventsBacklog = eventsBacklog
|
|
149
|
+
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
150
|
+
this.diagnosticsEnabled = diagnostics !== false
|
|
151
|
+
/** 每个 deviceId 当前这次接入的计时(attach 时建、detach 时收)。 */
|
|
152
|
+
this.timelines = new Map()
|
|
153
|
+
/**
|
|
154
|
+
* 当前这次请求已经发出去的字节数(在 `noteSent` 里累加、在请求收尾时取走)。
|
|
155
|
+
*
|
|
156
|
+
* 同一设备上并发请求会把字节算到一起——诊断只要量级对得上("这次响应是几 KB 还是几 MB"),
|
|
157
|
+
* 不值得为它引入逐请求关联(那要改所有回包点的形状)。
|
|
158
|
+
*/
|
|
159
|
+
this._sentBytes = new Map()
|
|
91
160
|
// 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
|
|
92
161
|
// 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
|
|
93
162
|
this.notify = notify
|
|
163
|
+
// 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
|
|
164
|
+
// 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
|
|
165
|
+
this.onEvent = onEvent
|
|
94
166
|
// Reported to the app so it can tell which wire revision it is talking to.
|
|
95
167
|
this.protocolVersion = protocolVersion
|
|
96
168
|
this.send = send
|
|
@@ -156,9 +228,12 @@ export class DeviceRouter {
|
|
|
156
228
|
return
|
|
157
229
|
}
|
|
158
230
|
switch (frame.t) {
|
|
159
|
-
case 'req':
|
|
231
|
+
case 'req': {
|
|
232
|
+
const startedAt = this.now()
|
|
160
233
|
await this.handleRequest(deviceId, frame)
|
|
234
|
+
this.#noteRequest(deviceId, frame.method, startedAt)
|
|
161
235
|
return
|
|
236
|
+
}
|
|
162
237
|
case 'open':
|
|
163
238
|
await this.handleOpen(deviceId, frame)
|
|
164
239
|
return
|
|
@@ -259,16 +334,117 @@ export class DeviceRouter {
|
|
|
259
334
|
|
|
260
335
|
// ── device lifecycle ────────────────────────────────────────────────────
|
|
261
336
|
|
|
337
|
+
/**
|
|
338
|
+
* 记一条请求的耗时与字节(spec 020 的 FR-001–FR-003)。
|
|
339
|
+
*
|
|
340
|
+
* `bytes` 取"这次请求期间发出去的字节"(见 `noteSent`)——它是一个近似值,
|
|
341
|
+
* 只在同一设备上并发请求时才会串味,而诊断要回答的是"这次响应是几 KB 还是几 MB"。
|
|
342
|
+
*/
|
|
343
|
+
#noteRequest(deviceId, method, startedAt) {
|
|
344
|
+
const timeline = this.timelines.get(deviceId)
|
|
345
|
+
if (!timeline) return
|
|
346
|
+
const bytes = this._sentBytes.get(deviceId) ?? 0
|
|
347
|
+
this._sentBytes.set(deviceId, 0)
|
|
348
|
+
timeline.noteRequest({ method, startedAt, ms: this.now() - startedAt, bytes })
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 每发一帧给某台设备就累加它的字节数(由 `link.js` 的 `sendToDevice` 调用)。 */
|
|
352
|
+
noteSent(deviceId, bytes) {
|
|
353
|
+
if (!deviceId || !Number.isFinite(bytes)) return
|
|
354
|
+
this._sentBytes.set(deviceId, (this._sentBytes.get(deviceId) ?? 0) + bytes)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 收下 App 推来的阶段记录(spec 020 的 FR-011 / 契约 §2)。
|
|
359
|
+
*
|
|
360
|
+
* 三条硬规则:
|
|
361
|
+
* 1. **时刻照抄**:App 事件的 `at`/`ms` 是它自己钟上的读数,连接器不许改写
|
|
362
|
+
* (否则"手机侧几点了"这条信息就没了,而它正是区分"App 挂起"与"拨号卡住"的依据);
|
|
363
|
+
* 2. **按序号幂等**:同一批重推不会写两遍(水位记在设备记录上);
|
|
364
|
+
* 3. **超批上限就截断**:只收前若干条并如实报 `accepted`/`lastSeq`,剩下的下次再推——
|
|
365
|
+
* 绝不假装收全了。
|
|
366
|
+
*
|
|
367
|
+
* @returns {{ok: true, value: {accepted: number, lastSeq: number}}}
|
|
368
|
+
*/
|
|
369
|
+
acceptDiagnostics(deviceId, args = {}) {
|
|
370
|
+
if (!this.diagnosticsEnabled) {
|
|
371
|
+
// 回退闸:关掉时明确拒绝,手机据此静默忽略(不影响连接)。
|
|
372
|
+
return { ok: false, error: errorObject('diagnostics/disabled', 'diagnostics intake is disabled on this connector') }
|
|
373
|
+
}
|
|
374
|
+
const record = this.devices.get(deviceId)
|
|
375
|
+
const watermark = Number(record?.diagnosticsSeq) || 0
|
|
376
|
+
const events = Array.isArray(args.events) ? args.events : []
|
|
377
|
+
let accepted = 0
|
|
378
|
+
let lastSeq = watermark
|
|
379
|
+
let bytes = 0
|
|
380
|
+
for (const raw of events) {
|
|
381
|
+
const event = sanitizeDiagnosticEvent(raw)
|
|
382
|
+
if (!event) continue
|
|
383
|
+
const seq = Number(event.seq)
|
|
384
|
+
if (Number.isFinite(seq) && seq <= watermark) continue
|
|
385
|
+
const size = JSON.stringify(event).length
|
|
386
|
+
if (bytes > 0 && bytes + size > DIAGNOSTICS_BATCH_BYTES) break
|
|
387
|
+
// 用 recordForeign:App 的时刻照抄(它是"手机侧几点"的唯一来源)。
|
|
388
|
+
// 补 `deviceId`:App 自己不知道它的 id,而多设备时没有这一列就没法把事件归到某台手机上。
|
|
389
|
+
this.onEvent(event.kind, { ...event, deviceId, source: 'app' }, { foreign: true })
|
|
390
|
+
bytes += size
|
|
391
|
+
accepted += 1
|
|
392
|
+
if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, seq)
|
|
393
|
+
}
|
|
394
|
+
if (record) record.diagnosticsSeq = lastSeq
|
|
395
|
+
const sentAt = Number(args.sentAt)
|
|
396
|
+
this.onEvent('app.push', {
|
|
397
|
+
deviceId,
|
|
398
|
+
count: accepted,
|
|
399
|
+
dropped: Number(args.dropped) || 0,
|
|
400
|
+
sentAt: Number.isFinite(sentAt) ? sentAt : undefined,
|
|
401
|
+
// 手机时钟与本机时钟的差(单向延迟几十毫秒);采集侧的对齐就靠这一个数。
|
|
402
|
+
skewMs: Number.isFinite(sentAt) ? this.now() - sentAt : undefined,
|
|
403
|
+
})
|
|
404
|
+
return { ok: true, value: { accepted, lastSeq } }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
|
|
408
|
+
#startTimeline(deviceId) {
|
|
409
|
+
this.timelines.get(deviceId)?.finish()
|
|
410
|
+
this.timelines.set(deviceId, new DeviceTimeline({
|
|
411
|
+
deviceId,
|
|
412
|
+
slowRequestMs: this.slowRequestMs,
|
|
413
|
+
now: this.now,
|
|
414
|
+
onEvent: this.onEvent,
|
|
415
|
+
}))
|
|
416
|
+
this._sentBytes.set(deviceId, 0)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 收掉一次接入计时(断开、撤销、进程收尾都走这里)。 */
|
|
420
|
+
#finishTimeline(deviceId) {
|
|
421
|
+
const timeline = this.timelines.get(deviceId)
|
|
422
|
+
if (!timeline) return
|
|
423
|
+
timeline.finish()
|
|
424
|
+
this.timelines.delete(deviceId)
|
|
425
|
+
this._sentBytes.delete(deviceId)
|
|
426
|
+
}
|
|
427
|
+
|
|
262
428
|
async attachDevice(deviceId, info = {}) {
|
|
263
429
|
if (!nonEmptyString(deviceId)) return
|
|
430
|
+
this.#startTimeline(deviceId)
|
|
264
431
|
const existing = this.devices.get(deviceId)
|
|
265
432
|
if (existing) {
|
|
266
433
|
// 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
267
434
|
if (existing.offlineSince !== undefined) {
|
|
435
|
+
const offlineForMs = this.now() - existing.offlineSince
|
|
268
436
|
existing.offlineSince = undefined
|
|
437
|
+
this.onEvent('device.reattach', { deviceId, offlineForMs, pending: existing.eventsPending.size })
|
|
269
438
|
this.logger.info?.(
|
|
270
439
|
`mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
|
|
271
440
|
this.#changed()
|
|
441
|
+
} else {
|
|
442
|
+
// **这条是"同一台设备同时又开了一条"**:中转的 `attach_device` 会直接覆盖旧
|
|
443
|
+
// 链路,旧的那条既不会再收到 `deviceDetach`、也不会出现在任何一方的状态里
|
|
444
|
+
// (2026-10-13 01:10 实测:attach 与上一条 detach 之间空了 34 秒)。记下来,
|
|
445
|
+
// 否则事后只看得到"它连上了",看不到"它连了两条"。
|
|
446
|
+
this.onEvent('device.attach-again', { deviceId, connectedForMs: this.now() - existing.connectedAt })
|
|
447
|
+
this.logger.warn?.(`mobile-link: device ${deviceId} attached again while still attached`)
|
|
272
448
|
}
|
|
273
449
|
return
|
|
274
450
|
}
|
|
@@ -288,6 +464,7 @@ export class DeviceRouter {
|
|
|
288
464
|
offlineSince: undefined,
|
|
289
465
|
eventsPending: new Map(),
|
|
290
466
|
})
|
|
467
|
+
this.onEvent('device.attach', { deviceId, name: info?.name, model: info?.model })
|
|
291
468
|
this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
|
|
292
469
|
this.#changed()
|
|
293
470
|
await this.ensureEventsStream(deviceId)
|
|
@@ -304,10 +481,12 @@ export class DeviceRouter {
|
|
|
304
481
|
* 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
|
|
305
482
|
*/
|
|
306
483
|
async detachDevice(deviceId, reason) {
|
|
484
|
+
this.#finishTimeline(deviceId)
|
|
307
485
|
const record = this.devices.get(deviceId)
|
|
308
486
|
if (!record) return
|
|
309
487
|
if (reason === 'revoked') {
|
|
310
488
|
await this.#dropDevice(deviceId)
|
|
489
|
+
this.onEvent('device.revoked', { deviceId })
|
|
311
490
|
this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
|
|
312
491
|
return
|
|
313
492
|
}
|
|
@@ -326,6 +505,7 @@ export class DeviceRouter {
|
|
|
326
505
|
for (const entry of keep) record.dlpIds.add(entry.dlpId)
|
|
327
506
|
|
|
328
507
|
if (record.offlineSince === undefined) record.offlineSince = this.now()
|
|
508
|
+
this.onEvent('device.detach', { deviceId, reason: reason ?? 'closed' })
|
|
329
509
|
this.logger.info?.(
|
|
330
510
|
`mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
|
|
331
511
|
+ '$events 流一直替它留着,直到设备被撤销或进程退出')
|
|
@@ -366,16 +546,48 @@ export class DeviceRouter {
|
|
|
366
546
|
teardownAll() {
|
|
367
547
|
const muxIds = []
|
|
368
548
|
for (const deviceId of [...this.devices.keys()]) {
|
|
549
|
+
// 进程收尾/链路重建也要把这次接入的汇总写掉,否则"连上过、发过请求"这件事没有数字。
|
|
550
|
+
this.#finishTimeline(deviceId)
|
|
369
551
|
muxIds.push(...this.streams.removeDevice(deviceId))
|
|
370
552
|
this.devices.delete(deviceId)
|
|
371
553
|
}
|
|
372
554
|
this.streams = new StreamTable()
|
|
555
|
+
// 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
|
|
556
|
+
this.timelines.clear()
|
|
557
|
+
this._sentBytes.clear()
|
|
373
558
|
return muxIds
|
|
374
559
|
}
|
|
375
560
|
|
|
376
561
|
// ── device requests ─────────────────────────────────────────────────────
|
|
377
562
|
|
|
378
563
|
async handleRequest(deviceId, frame) {
|
|
564
|
+
// App 推上来的阶段记录:**只落盘,不影响任何连接行为**(契约
|
|
565
|
+
// connector-diagnostics-method.md §4)。放在最前面,免得被后面的方法族顺序影响。
|
|
566
|
+
// 精简版会话列表(spec 021):同一份宿主数据,去掉 App 侧零读取点的字段,可按要求 gzip。
|
|
567
|
+
// 老方法 `session/list` 一个字节都不改——老版本手机继续走它,这是兼容的前提。
|
|
568
|
+
if (frame.method === SESSION_DIGEST_METHOD) {
|
|
569
|
+
const args = frame.args ?? {}
|
|
570
|
+
let result
|
|
571
|
+
try {
|
|
572
|
+
result = await this.dsh.rpc('session/list', { _request: args._request ?? {} })
|
|
573
|
+
} catch (error) {
|
|
574
|
+
result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
|
|
575
|
+
this.lastError = messageOf(error)
|
|
576
|
+
this.#changed()
|
|
577
|
+
}
|
|
578
|
+
const payload = result?.ok === true
|
|
579
|
+
? { ok: true, value: encodeDigest(digestSessionList(result), args.encoding) }
|
|
580
|
+
: result
|
|
581
|
+
this.send(deviceId, resultFrame(frame.id, payload, deviceId))
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (frame.method === DIAGNOSTICS_METHOD) {
|
|
586
|
+
const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
|
|
587
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
|
|
379
591
|
// Files the phone sends are answered here rather than forwarded: the Host
|
|
380
592
|
// has no upload endpoint, and carrying them on the existing unary frame
|
|
381
593
|
// means neither the relay nor the protocol has to change.
|
|
@@ -458,6 +670,7 @@ export class DeviceRouter {
|
|
|
458
670
|
let result
|
|
459
671
|
try {
|
|
460
672
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
673
|
+
result = trimHistoryPageForWire(frame.method, result)
|
|
461
674
|
} catch (error) {
|
|
462
675
|
result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
|
|
463
676
|
this.lastError = messageOf(error)
|
package/lib/routes.js
CHANGED
|
@@ -94,40 +94,8 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
|
|
|
94
94
|
const guard = makeGuard(ctx, logger)
|
|
95
95
|
const STATUS_ROUTE = '/mobile-link/status'
|
|
96
96
|
const PAIR_ROUTE = '/mobile-link/pair-code'
|
|
97
|
-
/// SPIKE(spec 007 / T008):只读测量宿主窗口与检索 API 的语义。
|
|
98
|
-
/// 默认关闭,只有 `DSH_MOBILE_LINK_HISTORY_PROBE=1` 时注册——它是一次性测量工具,
|
|
99
|
-
/// 不是产品面(结论已写回 specs/007-transcript-history/plan.md 的 Phase 0)。
|
|
100
|
-
const PROBE_ROUTE = '/mobile-link/history-probe'
|
|
101
97
|
const disposers = []
|
|
102
98
|
|
|
103
|
-
if (history !== undefined && process.env.DSH_MOBILE_LINK_HISTORY_PROBE === '1') {
|
|
104
|
-
disposers.push(ctx.webServer.register({
|
|
105
|
-
kind: 'exact',
|
|
106
|
-
path: PROBE_ROUTE,
|
|
107
|
-
handler: async (req, res) => {
|
|
108
|
-
if (guard(req, res)) return
|
|
109
|
-
if (req.method !== 'GET') {
|
|
110
|
-
res.statusCode = 405
|
|
111
|
-
res.setHeader('allow', 'GET')
|
|
112
|
-
res.end()
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
116
|
-
try {
|
|
117
|
-
const result = await history.probe({
|
|
118
|
-
sessionId: url.searchParams.get('sessionId') ?? '',
|
|
119
|
-
seq: url.searchParams.get('seq') ?? undefined,
|
|
120
|
-
n: url.searchParams.get('n') ?? undefined,
|
|
121
|
-
text: url.searchParams.get('text') ?? undefined,
|
|
122
|
-
})
|
|
123
|
-
sendJson(res, 200, result)
|
|
124
|
-
} catch (error) {
|
|
125
|
-
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
}))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
99
|
disposers.push(ctx.webServer.register({
|
|
132
100
|
kind: 'exact',
|
|
133
101
|
path: STATUS_ROUTE,
|