dsh-mobile-plugin 0.0.0-stage → 0.1.57
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 +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// 空清单守卫(工作区 / 会话列表共用)。
|
|
4
|
+
//
|
|
5
|
+
// 「没取到」与「真的什么都没有」在单份快照上长得一模一样:都是
|
|
6
|
+
// `{ ok: true, items: [] }`。因此读取侧必须**如实报错**(见 lib/mobile/workspaces.js
|
|
7
|
+
// 的 `host-workspace-list-degraded` 与 lib/mobile/sessions.js 的
|
|
8
|
+
// `host-session-list-degraded`),守卫则负责兜住「宿主确实回了合法空列表」这种情况
|
|
9
|
+
// ——它和真实删除无法区分,所以按时间兜底。
|
|
10
|
+
//
|
|
11
|
+
// 后果(真机缺陷):一份空快照会一路通过推送路径的 `.ok` 判定,被当成权威清单推给
|
|
12
|
+
// 中转冻结下来。中转只在响应上附一个 `stale` 标记,而 App 根本不读它,于是下一次 15s
|
|
13
|
+
// 轮询就把好数据覆盖成空 —— 侧边栏工作区整行消失、其下所有会话塌进「未分组」,
|
|
14
|
+
// 设备仍显示「在线」(那是另一条独立的 /status 探测线),点不开,且全程零错误提示。
|
|
15
|
+
//
|
|
16
|
+
// 「用户真的把工作区/会话全删了」同样表现为空列表,单看一份快照区分不了,所以按时间兜底:
|
|
17
|
+
// 距上一次确认过的非空清单还不到 graceMs 时拒绝空清单、保留上一份镜像;超过 graceMs
|
|
18
|
+
// 则放行,让真实删除也能收敛,不会把侧边栏永久钉死在旧数据上。
|
|
19
|
+
//
|
|
20
|
+
// 抽成独立模块是为了能被真正单测(早期版本把这段逻辑内联在 lib/index.js 的闭包里,
|
|
21
|
+
// 只能靠 grep 源码做契约测试,行为本身一次都跑不到)。
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs')
|
|
24
|
+
|
|
25
|
+
/** 空清单宽限:距上一次非空清单不到这么久,就不承认「被清空」。 */
|
|
26
|
+
const EMPTY_LIST_GRACE_MS = 15000
|
|
27
|
+
const EMPTY_WORKSPACE_GRACE_MS = EMPTY_LIST_GRACE_MS
|
|
28
|
+
const EMPTY_SESSION_LIST_GRACE_MS = EMPTY_LIST_GRACE_MS
|
|
29
|
+
|
|
30
|
+
/** 读回上次进程留下的非空基准;文件不存在/损坏都按「没有基准」处理。 */
|
|
31
|
+
function readPersistedBaseline(persistPath) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(fs.readFileSync(persistPath, 'utf8'))
|
|
34
|
+
const value = Number(raw && raw.lastNonEmptyAt)
|
|
35
|
+
return Number.isFinite(value) && value > 0 ? value : 0
|
|
36
|
+
} catch {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 持久化非空基准。插件会随桌面 harness 频繁重启,基准只存在内存里的话,
|
|
42
|
+
* 重启后的第一份空快照(往往正好撞上桌面刚起来的忙乱期)会畅通无阻。 */
|
|
43
|
+
function persistBaseline(persistPath, lastNonEmptyAt) {
|
|
44
|
+
if (!persistPath) return
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(persistPath, JSON.stringify({ lastNonEmptyAt }), 'utf8')
|
|
47
|
+
} catch {
|
|
48
|
+
// 落盘只是加固,失败不影响本次判定。
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 造一个空清单守卫(`{ ok:true, items:[...] }` 形状的快照)。
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string} [options.label] 阻断理由里的清单名称,默认「列表」
|
|
57
|
+
* @param {string} [options.persistPath] 基准落盘路径(可选,单槽位一个文件)
|
|
58
|
+
* @param {number} [options.graceMs] 宽限窗口,默认 EMPTY_LIST_GRACE_MS
|
|
59
|
+
* @param {() => number} [options.now] 时钟,测试可注入
|
|
60
|
+
* @returns {{ check: (body: unknown) => string|null, peek: () => { lastNonEmptyAt: number } }}
|
|
61
|
+
* `check` 返回 null 表示这份快照可用;否则返回阻断理由(可直接喂给 recordMirrorPush)。
|
|
62
|
+
*/
|
|
63
|
+
function createEmptyListGuard(options) {
|
|
64
|
+
const label = (options && typeof options.label === 'string' && options.label) ? options.label : '列表'
|
|
65
|
+
const graceMs = options && Number.isFinite(options.graceMs)
|
|
66
|
+
? Number(options.graceMs)
|
|
67
|
+
: EMPTY_LIST_GRACE_MS
|
|
68
|
+
const now = (options && typeof options.now === 'function') ? options.now : Date.now
|
|
69
|
+
const persistPath = (options && typeof options.persistPath === 'string') ? options.persistPath : ''
|
|
70
|
+
let lastNonEmptyAt = persistPath ? readPersistedBaseline(persistPath) : 0
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
check(body) {
|
|
74
|
+
if (!body || typeof body !== 'object' || body.ok !== true) {
|
|
75
|
+
return `${label} 快照不可用,本次未推送`
|
|
76
|
+
}
|
|
77
|
+
const count = Array.isArray(body.items) ? body.items.length : 0
|
|
78
|
+
if (count > 0) {
|
|
79
|
+
lastNonEmptyAt = now()
|
|
80
|
+
persistBaseline(persistPath, lastNonEmptyAt)
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
// 显式空态(`emptyConfirmed: true`):读取侧已经断言「上游回了一份合法且为空的
|
|
84
|
+
// 清单」——形状异常会走错误通道(见 sessions/workspaces 的 build*Body),能带上这个
|
|
85
|
+
// 标记就说明这不是「没取到」。此时不该再被时间窗挡住:用户真的把侧栏清空时,时间
|
|
86
|
+
// 窗正好是「手机侧永远看不到清空」的来源。时间兜底只留给没有这个标记的旧快照。
|
|
87
|
+
if (body.emptyConfirmed === true) {
|
|
88
|
+
if (lastNonEmptyAt) {
|
|
89
|
+
lastNonEmptyAt = 0
|
|
90
|
+
persistBaseline(persistPath, 0)
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
if (lastNonEmptyAt && now() - lastNonEmptyAt < graceMs) {
|
|
95
|
+
return `${label}快照为空且距上次非空清单不足 ${graceMs / 1000}s,本次未推送`
|
|
96
|
+
+ `(防止把「没取到」广播成「你没有${label}」)`
|
|
97
|
+
}
|
|
98
|
+
// 从来没有过非空清单(本来就没有),或已经空过整个宽限窗口(真的删光了):
|
|
99
|
+
// 两种都认下来。放行后清基准,否则后续空快照会被同一基准一直挡着。
|
|
100
|
+
if (lastNonEmptyAt) {
|
|
101
|
+
lastNonEmptyAt = 0
|
|
102
|
+
persistBaseline(persistPath, 0)
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
},
|
|
106
|
+
peek() {
|
|
107
|
+
return { lastNonEmptyAt }
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createWorkspaceGuard(options) {
|
|
113
|
+
return createEmptyListGuard({ ...(options || {}), label: '工作区' })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createSessionListGuard(options) {
|
|
117
|
+
return createEmptyListGuard({ ...(options || {}), label: '会话' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
EMPTY_LIST_GRACE_MS,
|
|
122
|
+
EMPTY_WORKSPACE_GRACE_MS,
|
|
123
|
+
EMPTY_SESSION_LIST_GRACE_MS,
|
|
124
|
+
createEmptyListGuard,
|
|
125
|
+
createWorkspaceGuard,
|
|
126
|
+
createSessionListGuard,
|
|
127
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sidecar worker:真正持有「中转隧道 + 局域网代理」的那个子进程。
|
|
5
|
+
*
|
|
6
|
+
* 宿主不自己开 WebSocket、也不自己监听局域网端口,而是 fork 出这个 worker,用环回
|
|
7
|
+
* HTTP + Bearer 令牌对话(见 lib/sidecar-facade.cjs)。这样隧道崩了、原生模块加载
|
|
8
|
+
* 失败都只死一个子进程,宿主仍能如实把状态报给 /api/relay/status。
|
|
9
|
+
*
|
|
10
|
+
* 三条不能忘的语义:
|
|
11
|
+
* 1. 状态既要在 `/status` 上按需可查,也要在**变化的那一刻**主动 `process.send` 给宿主
|
|
12
|
+
* (publishState):宿主的健康轮询是 10s 一次,只靠轮询的话隧道真断后它最多 10s
|
|
13
|
+
* 仍以为连着、继续往一条死隧道推镜像;而「断又连」整段落在两次轮询之间时,宿主
|
|
14
|
+
* 连一次「刚连上」都观测不到,那次全量补推就永远不来。
|
|
15
|
+
* 2. `/tunnel/send` 先把镜像快照留在本地(localSnapshot)再尝试真发:局域网直连的手机
|
|
16
|
+
* 走 snapshotRead 读这份快照,隧道断了也不至于读到一份过期镜像。
|
|
17
|
+
* 3. 请求体一律先量后读(readBody + 显式上限):`/configure` 与镜像帧都有字节上限,
|
|
18
|
+
* 超限立刻 413,别让一个畸形请求把内存吃光。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const http = require('node:http')
|
|
22
|
+
const { RelayTunnel } = require('./relay/tunnel.js')
|
|
23
|
+
const { T } = require('./relay/protocol.js')
|
|
24
|
+
const { createLanProxy } = require('./relay/lan-proxy.js')
|
|
25
|
+
const { lanUrls } = require('./relay/lan-network.cjs')
|
|
26
|
+
const core = require('./relay/core.js')
|
|
27
|
+
const { createReconnectScheduler } = require('./reconnect-scheduler.cjs')
|
|
28
|
+
const { isMirrorSnapshotFresh } = require('./mirror-snapshot.cjs')
|
|
29
|
+
|
|
30
|
+
const parentPort = Number(process.env.DSH_SIDECAR_PARENT_PORT)
|
|
31
|
+
const token = process.env.DSH_SIDECAR_TOKEN
|
|
32
|
+
const authorization = `Bearer ${token}`
|
|
33
|
+
let config = {}
|
|
34
|
+
const sockets = new Set()
|
|
35
|
+
let tunnel = null
|
|
36
|
+
let lan = null
|
|
37
|
+
let tunnelState = 'off'
|
|
38
|
+
let tunnelError = null
|
|
39
|
+
let lanState = 'off'
|
|
40
|
+
let lanError = null
|
|
41
|
+
// 局域网代理**实际**在监听的端口。0 = 还没起来。首选端口被占时会退到内核分配的
|
|
42
|
+
// 空闲端口(见 lib/relay/port-allocator.cjs 与 lan-proxy 的 bind 兜底),所以凡是要
|
|
43
|
+
// 对外报地址的地方都必须用 lanUrlPort(),不能用 config.lanPort。
|
|
44
|
+
let lanBoundPort = 0
|
|
45
|
+
function lanUrlPort() { return lanBoundPort || config.lanPort }
|
|
46
|
+
/**
|
|
47
|
+
* 隧道/局域网状态一变就**立刻**经 IPC 推给宿主,别等宿主那 10s 一次的健康轮询。
|
|
48
|
+
*
|
|
49
|
+
* 轮询仍然是权威(只有它能发现「worker 整个死了」——那时 IPC 也断了,没人会推),
|
|
50
|
+
* 但它是滞后的:隧道真断之后宿主最多 10s 仍认为已连接,期间照常推镜像,每次撞
|
|
51
|
+
* sidecar-facade 的 5s 超时,该会话的单飞门被占住,事件只能合并成一次补推。反过来,
|
|
52
|
+
* 隧道「断又连」若整段落在两次轮询之间,宿主看不到跳变,onTunnelUp 那次全量补推会被漏掉。
|
|
53
|
+
*
|
|
54
|
+
* 所以:轮询继续做权威兜底,状态跳变由这里即时上报(见 lib/sidecar-lifecycle.cjs 的
|
|
55
|
+
* applyTunnelState —— 两条来源共用一个边沿判定,不会重复触发补推)。
|
|
56
|
+
*/
|
|
57
|
+
function publishState() {
|
|
58
|
+
process.send?.({
|
|
59
|
+
type: 'state',
|
|
60
|
+
tunnel: { state: tunnelState, error: tunnelError },
|
|
61
|
+
lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
let networkTimer = null
|
|
65
|
+
let configuring = Promise.resolve()
|
|
66
|
+
let localSnapshot = null // 桌面 pushSync 推来的移动端镜像快照(本机保留供读取隔离)
|
|
67
|
+
const NETWORK_POLL_MS = 5000
|
|
68
|
+
// 隧道重连间隔。onClose 与 start().catch 两条失败路径共用这个幂等调度器,
|
|
69
|
+
// 否则每次失败会排两个定时器(重连风暴);幂等性由
|
|
70
|
+
// test/reconnect-scheduler.test.cjs 精确验证。
|
|
71
|
+
const RECONNECT_DELAY_MS = 5000
|
|
72
|
+
const reconnect = createReconnectScheduler({ delayMs: RECONNECT_DELAY_MS, onFire: () => startTunnel() })
|
|
73
|
+
|
|
74
|
+
function send(res, code, value) {
|
|
75
|
+
if (res.headersSent) return res.end()
|
|
76
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
77
|
+
res.end(JSON.stringify(value))
|
|
78
|
+
}
|
|
79
|
+
function authorized(req) { return typeof token === 'string' && token.length >= 64 && req.headers.authorization === authorization }
|
|
80
|
+
// 请求体超限要能和外层异常区分开:它是请求本身的问题(4xx),不是服务不可用。
|
|
81
|
+
// 旧实现在这里一律 reject(new Error('request too large')),最后被 catch-all 兜成 503,
|
|
82
|
+
// 宿主侧记下来的就是一条没头没尾的「sidecar facade request failed: 503」。
|
|
83
|
+
class BodyTooLarge extends Error {}
|
|
84
|
+
/** /tunnel/send 的请求体上限。
|
|
85
|
+
*
|
|
86
|
+
* 这个接口收的是桌面推给移动端的镜像:整份会话列表 + 工作区 + 当前会话的完整消息、
|
|
87
|
+
* 统计与模型。实测桌面一次推送就超过 1MB,而中转清洗后真正留下的只有 ~114KB——
|
|
88
|
+
* 旧的 1MB 上限是在把有用的东西挡在门外。
|
|
89
|
+
*
|
|
90
|
+
* 上限的依据是**中转那侧的帧上限**,不是「估个够大的数」:
|
|
91
|
+
* dsh-mobile-server/lib/config.js 的 MAX_FRAME_BYTES 默认 8MB,一帧超过它,
|
|
92
|
+
* 中转会以 1009 'frame too large' 直接关掉隧道(lib/protocol.js → onError)。
|
|
93
|
+
* 丢一次镜像可以恢复,隧道被关掉要等重连、期间手机端全瞎——所以宁可拒也不能赌。
|
|
94
|
+
* 留 64KB 余量给帧头。推得再大也只是这一次 413,隧道安然无恙。
|
|
95
|
+
*/
|
|
96
|
+
const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
|
|
97
|
+
// /configure 只送 relay.json 那几十个字段,给 64KB 绰绰有余;放宽只会让误用更难发现。
|
|
98
|
+
const CONFIGURE_MAX_BODY = 64 * 1024
|
|
99
|
+
function readBody(req, limit) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
let raw = ''
|
|
102
|
+
let size = 0
|
|
103
|
+
let over = false
|
|
104
|
+
req.on('data', chunk => {
|
|
105
|
+
if (over) return
|
|
106
|
+
size += chunk.length
|
|
107
|
+
if (size > limit) {
|
|
108
|
+
// 关键:拒了之后必须停止累积。旧实现在 reject 之后继续往 raw 上拼,
|
|
109
|
+
// 一个已被判定超限的请求照样把整个 body 读进内存——上限反倒成了放大器。
|
|
110
|
+
over = true
|
|
111
|
+
raw = ''
|
|
112
|
+
return reject(new BodyTooLarge(`request body too large (${size} > ${limit} bytes)`))
|
|
113
|
+
}
|
|
114
|
+
raw += chunk
|
|
115
|
+
})
|
|
116
|
+
req.on('end', () => { if (over) return; try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) } })
|
|
117
|
+
req.on('error', reject)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
function parentRequest(path, body, signal) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const req = http.request({ host: '127.0.0.1', port: parentPort, path, method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, timeout: 10000 }, resolve)
|
|
123
|
+
req.on('error', reject)
|
|
124
|
+
signal?.addEventListener('abort', () => req.destroy(), { once: true })
|
|
125
|
+
req.end(JSON.stringify(body))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
async function parentJson(path, body, signal) {
|
|
129
|
+
const res = await parentRequest(path, body, signal)
|
|
130
|
+
let raw = ''; res.setEncoding('utf8'); for await (const chunk of res) raw += chunk
|
|
131
|
+
let value; try { value = JSON.parse(raw || '{}') } catch { throw new Error('invalid parent RPC response') }
|
|
132
|
+
if (res.statusCode !== 200) throw new Error(value.error?.message || value.error || `parent RPC failed: ${res.statusCode}`)
|
|
133
|
+
return value.data ?? value
|
|
134
|
+
}
|
|
135
|
+
function upstream() { return `http://127.0.0.1:${config.upstreamPort}` }
|
|
136
|
+
// 读取隔离:把移动端高频读请求(sessions/workspaces/messages/stats)从本地快照直接回,
|
|
137
|
+
// 不再打扰主进程。快照缺失或路径不在清单内时返回 null → 调用方回源主进程。
|
|
138
|
+
function snapshotRead(info) {
|
|
139
|
+
// 镜像只在中转连着时才会有新版本推来(原因见 mirror-snapshot.cjs 的说明):冻结的快照必须
|
|
140
|
+
// 回源主进程,否则中转一断、局域网仍健康时,新建的会话/工作区在 App 里永远不出现。
|
|
141
|
+
if (!isMirrorSnapshotFresh(localSnapshot)) return null
|
|
142
|
+
let url
|
|
143
|
+
try { url = new URL(String((info && info.path) || ''), 'http://127.0.0.1') } catch { return null }
|
|
144
|
+
const pathname = url.pathname
|
|
145
|
+
const body =
|
|
146
|
+
pathname === '/api/mobile/v1/sessions' ? localSnapshot.sessions
|
|
147
|
+
: pathname === '/api/mobile/v1/workspaces' ? localSnapshot.workspaces
|
|
148
|
+
: pathname === '/api/mobile/v1/session/messages' ? (localSnapshot.messages || {})[url.searchParams.get('sessionId') || '']
|
|
149
|
+
: pathname === '/api/mobile/v1/session/stats' ? (localSnapshot.stats || {})[url.searchParams.get('sessionId') || '']
|
|
150
|
+
: null
|
|
151
|
+
if (!body || typeof body !== 'object' || body.ok !== true) return null
|
|
152
|
+
if (pathname === '/api/mobile/v1/session/messages') {
|
|
153
|
+
const afterSeqRaw = url.searchParams.get('afterSeq')
|
|
154
|
+
if (afterSeqRaw !== null && afterSeqRaw !== '' && /^\d+$/.test(afterSeqRaw)) {
|
|
155
|
+
const after = Number(afterSeqRaw)
|
|
156
|
+
const items = Array.isArray(body.items) ? body.items.filter((it) => (typeof it?.seq === 'number' ? it.seq > after : true)) : body.items
|
|
157
|
+
const lastSeq = items && items.length ? Math.max(...items.map((it) => it?.seq || 0)) : after
|
|
158
|
+
return { ...body, items, lastSeq }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return body
|
|
162
|
+
}
|
|
163
|
+
function stopNetworkMonitor() {
|
|
164
|
+
if (networkTimer) { clearInterval(networkTimer); networkTimer = null }
|
|
165
|
+
}
|
|
166
|
+
function scheduleReconnect() { reconnect.schedule() }
|
|
167
|
+
function stopTunnel() {
|
|
168
|
+
reconnect.cancel()
|
|
169
|
+
if (tunnel) tunnel.stop()
|
|
170
|
+
tunnel = null; tunnelState = 'off'; tunnelError = null
|
|
171
|
+
publishState()
|
|
172
|
+
}
|
|
173
|
+
function startNetworkMonitor() {
|
|
174
|
+
stopNetworkMonitor()
|
|
175
|
+
if (!config.enabled || !config.relayUrl || !config.lanEnabled) return
|
|
176
|
+
let previous = lanUrls(lanUrlPort())
|
|
177
|
+
networkTimer = setInterval(() => {
|
|
178
|
+
const current = lanUrls(lanUrlPort())
|
|
179
|
+
if (JSON.stringify(current) === JSON.stringify(previous)) return
|
|
180
|
+
previous = current
|
|
181
|
+
// lanUrls is sent during register; reconnect only after a real change.
|
|
182
|
+
startTunnel()
|
|
183
|
+
}, NETWORK_POLL_MS)
|
|
184
|
+
}
|
|
185
|
+
function stopLan() {
|
|
186
|
+
if (lan) lan.stop()
|
|
187
|
+
lan = null; lanState = 'off'; lanError = null; lanBoundPort = 0
|
|
188
|
+
publishState()
|
|
189
|
+
}
|
|
190
|
+
async function stopServices() { stopNetworkMonitor(); stopTunnel(); stopLan() }
|
|
191
|
+
function startTunnel() {
|
|
192
|
+
if (!config.enabled || !config.relayUrl || !config.upstreamPort) return
|
|
193
|
+
stopTunnel(); tunnelState = 'connecting'
|
|
194
|
+
publishState()
|
|
195
|
+
const current = new RelayTunnel({
|
|
196
|
+
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId, pairingCode: config.pairingCode,
|
|
197
|
+
name: config.name, upstream: upstream(),
|
|
198
|
+
registerSecret: config.registerSecret,
|
|
199
|
+
lanUrls: config.lanEnabled ? lanUrls(lanUrlPort()) : [],
|
|
200
|
+
readSnapshot: snapshotRead,
|
|
201
|
+
// 实例身份守卫:只有还活着的那个隧道实例才许改状态。onClose / onPairRequest /
|
|
202
|
+
// start().catch 都有这道闸,只有 onRegistered 漏了——而它恰恰是最容易迟到的一个
|
|
203
|
+
// (注册帧是在 ws 上异步回来的)。漏掉的后果是:configure() 或网络监视器重建隧道后,
|
|
204
|
+
// 旧实例迟到的 registered 帧把 tunnelState 写成 'connected',可此刻 tunnel 指向的
|
|
205
|
+
// 是新实例、它的 ws 还停在 CONNECTING。于是 /status 报「已连接」、宿主据此放行镜像
|
|
206
|
+
// 推送,worker 却按真值回 503,镜像一条也发不出去(现场:host.log 里
|
|
207
|
+
// 「bg queue: sidecar facade request failed: 503」刷屏,而隧道其实在正常转发会话)。
|
|
208
|
+
onRegistered: () => { if (tunnel !== current) return; tunnelState = 'connected'; tunnelError = null; publishState() },
|
|
209
|
+
onClose: () => { if (tunnel === current && config.enabled) { tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect() } },
|
|
210
|
+
onPairRequest: async (message) => {
|
|
211
|
+
try {
|
|
212
|
+
const result = await parentJson('/api/relay/internal/approval', { device: message.device || {} })
|
|
213
|
+
if (tunnel !== current) return
|
|
214
|
+
if (result.approved) current.sendPairConfirm(message.requestId, true, core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC))
|
|
215
|
+
else current.sendPairConfirm(message.requestId, false)
|
|
216
|
+
} catch { if (tunnel === current) current.sendPairConfirm(message.requestId, false) }
|
|
217
|
+
},
|
|
218
|
+
onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
|
|
219
|
+
})
|
|
220
|
+
tunnel = current
|
|
221
|
+
current.start().catch(error => { if (tunnel === current) { tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect() } })
|
|
222
|
+
}
|
|
223
|
+
function startLan() {
|
|
224
|
+
// 总开关关闭时不能只停隧道:旧实现漏了 `config.enabled`,于是 /api/relay/toggle
|
|
225
|
+
// 关掉远程连接后,局域网代理仍监听 0.0.0.0:lanPort 继续接受配对。
|
|
226
|
+
if (!config.enabled || !config.lanEnabled || !config.upstreamPort) return
|
|
227
|
+
stopLan(); lanState = 'connecting'
|
|
228
|
+
publishState()
|
|
229
|
+
const current = createLanProxy({
|
|
230
|
+
lanPort: config.lanPort, pairingCode: config.pairingCode, instanceId: config.instanceId, lanSecret: config.lanSecret,
|
|
231
|
+
upstreamPort: config.upstreamPort,
|
|
232
|
+
readSnapshot: snapshotRead,
|
|
233
|
+
onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
|
|
234
|
+
onApprove: async (device) => {
|
|
235
|
+
try {
|
|
236
|
+
const result = await parentJson('/api/relay/internal/approval', { device: device || {} })
|
|
237
|
+
return { approved: Boolean(result.approved), token: result.approved ? core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC) : null }
|
|
238
|
+
} catch { return { approved: false, token: null } }
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
lan = current
|
|
242
|
+
current.start().then(() => {
|
|
243
|
+
if (lan !== current) return
|
|
244
|
+
lanState = 'listening'
|
|
245
|
+
const bound = typeof current.port === 'function' ? current.port() : 0
|
|
246
|
+
const changed = bound > 0 && bound !== lanBoundPort
|
|
247
|
+
if (bound > 0) lanBoundPort = bound
|
|
248
|
+
publishState()
|
|
249
|
+
// 实际端口和首选端口不同时,中转侧那份 lanUrls(注册时随 metadata 上报的)
|
|
250
|
+
// 带的就是首选端口,手机从公网拿到的局域网候选全是死地址。重连一次隧道,让
|
|
251
|
+
// 中转拿到真正的端口。
|
|
252
|
+
if (changed && config.enabled && config.relayUrl) startTunnel()
|
|
253
|
+
}).catch(error => { if (lan === current) { lanState = 'error'; lanError = error instanceof Error ? error.message : String(error); publishState() } })
|
|
254
|
+
}
|
|
255
|
+
async function configure(next) {
|
|
256
|
+
if (!next || typeof next !== 'object' || Array.isArray(next)) throw new Error('invalid configuration')
|
|
257
|
+
const allowed = ['enabled', 'relayUrl', 'instanceId', 'pairingCode', 'name', 'upstreamPort', 'lanPort', 'lanEnabled', 'lanSecret', 'registerSecret']
|
|
258
|
+
if (Object.keys(next).some(key => !allowed.includes(key))) throw new Error('unknown configuration field')
|
|
259
|
+
if (typeof next.instanceId !== 'string' || next.instanceId.length > 128) throw new Error('invalid instanceId')
|
|
260
|
+
if (typeof next.pairingCode !== 'string' || !/^\d{6}$/.test(next.pairingCode)) throw new Error('invalid pairingCode')
|
|
261
|
+
if (typeof next.upstreamPort !== 'number' || !Number.isInteger(next.upstreamPort) || next.upstreamPort < 1 || next.upstreamPort > 65535) throw new Error('invalid upstreamPort')
|
|
262
|
+
if (next.lanPort !== undefined && (!Number.isInteger(next.lanPort) || next.lanPort < 1024 || next.lanPort > 65535)) throw new Error('invalid lanPort')
|
|
263
|
+
if (next.registerSecret !== undefined && (typeof next.registerSecret !== 'string' || next.registerSecret.length > 512)) throw new Error('invalid registerSecret')
|
|
264
|
+
await stopServices()
|
|
265
|
+
config = { ...next }
|
|
266
|
+
if (config.enabled) {
|
|
267
|
+
startTunnel()
|
|
268
|
+
startNetworkMonitor()
|
|
269
|
+
if (config.lanEnabled) startLan()
|
|
270
|
+
}
|
|
271
|
+
return status()
|
|
272
|
+
}
|
|
273
|
+
function status() {
|
|
274
|
+
return { ok: true, sidecar: true, enabled: Boolean(config.enabled), relayUrl: config.relayUrl || '', instanceId: config.instanceId, name: config.name, localPort: config.upstreamPort, tunnel: { state: tunnelState, error: tunnelError }, lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError } }
|
|
275
|
+
}
|
|
276
|
+
const server = http.createServer(async (req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
if (!authorized(req)) return send(res, 401, { ok: false, error: 'unauthorized' })
|
|
279
|
+
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { ok: true, service: 'dsh-ai-hub-relay-sidecar', capabilities: ['relay-tunnel', 'lan-proxy', 'pairing-approval-rpc'] })
|
|
280
|
+
if (req.method === 'GET' && req.url === '/status') return send(res, 200, status())
|
|
281
|
+
if (req.method === 'POST' && req.url === '/configure') { const next = await readBody(req, CONFIGURE_MAX_BODY); configuring = configuring.then(() => configure(next)); return send(res, 200, await configuring) }
|
|
282
|
+
if (req.method === 'POST' && req.url === '/tunnel/send') {
|
|
283
|
+
const body = await readBody(req, TUNNEL_SEND_MAX_BODY);
|
|
284
|
+
// 桌面→relay 主动推送(移动端镜像缓存):复用隧道立即 sendJson;同时在本地保留快照供读取隔离。
|
|
285
|
+
if (body && typeof body === 'object' && body.kind === 'sync') localSnapshot = body;
|
|
286
|
+
if (tunnel && tunnel.connected) {
|
|
287
|
+
tunnel.sendJson(T.sync, 0, body && typeof body === 'object' ? body : { kind: 'raw', data: body });
|
|
288
|
+
return send(res, 202, { ok: true });
|
|
289
|
+
}
|
|
290
|
+
return send(res, 503, { ok: false, error: 'tunnel not connected' });
|
|
291
|
+
}
|
|
292
|
+
if (req.method === 'POST' && req.url === '/revoke-all') {
|
|
293
|
+
// 清除所有授权:请求中转清空本实例全部设备授权(先于本机密钥轮换调用)。
|
|
294
|
+
if (!(tunnel && tunnel.connected)) return send(res, 503, { ok: false, error: 'tunnel not connected' });
|
|
295
|
+
try {
|
|
296
|
+
const result = await tunnel.requestRevokeAll()
|
|
297
|
+
return send(res, 200, { ok: true, revoked: result?.revoked || 0 })
|
|
298
|
+
} catch (error) { return send(res, 502, { ok: false, error: error instanceof Error ? error.message : String(error) }) }
|
|
299
|
+
}
|
|
300
|
+
return send(res, 404, { ok: false, error: 'not found' })
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// 体积超限归 4xx:它是请求本身装不下,不是这个服务不可用。兜成 503 的代价是
|
|
303
|
+
// 宿主侧把「body 太大」和「隧道没连上」记成同一条错,两者都长得一模一样。
|
|
304
|
+
if (error instanceof BodyTooLarge) return send(res, 413, { ok: false, error: error.message })
|
|
305
|
+
return send(res, 503, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)) })
|
|
309
|
+
server.listen(0, '127.0.0.1', () => { const address = server.address(); process.send?.({ type: 'ready', port: address.port }) })
|
|
310
|
+
const shutdown = () => {
|
|
311
|
+
reconnect.cancel()
|
|
312
|
+
server.close()
|
|
313
|
+
for (const socket of sockets) socket.destroy()
|
|
314
|
+
void stopServices()
|
|
315
|
+
setTimeout(() => process.exit(0), 1000).unref()
|
|
316
|
+
}
|
|
317
|
+
process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown)
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 父进程(宿主)没了就自己退出:否则这里会变成一具握着隧道的尸体。
|
|
321
|
+
*
|
|
322
|
+
* ~~Node 的 fork 子进程不会随父进程退出~~。宿主被 Cmd+Q / 被系统内存压力清掉 / 被
|
|
323
|
+
* SIGKILL 时,没有任何人会给这里发 SIGTERM,IPC 通道也只是**静默 EOF**(Node 不会
|
|
324
|
+
* 替我们发 disconnect)。于是 worker 继续活着、继续握着那条 `register` 成功过的中转
|
|
325
|
+
* 隧道、继续如实回答 `/api/mobile/v1/status`(它知道的宿主身份还是那一份),中转的
|
|
326
|
+
* `instances` 里那条记录就永不删除 —— 手机侧栏的设备「在线」从此再也灭不掉,正文
|
|
327
|
+
* 镜像也停在最后一帧。2026-10-10 真机故障,根因就在这里。
|
|
328
|
+
*
|
|
329
|
+
* 两道判据都留着,互相兜底:
|
|
330
|
+
* - `disconnect`:宿主正常退出 / 主动销毁 IPC 时立刻触发,退出最快;
|
|
331
|
+
* - ppid 巡检:宿主被 SIGKILL 时**不发** disconnect(实测只看到 ppid 变成 1),
|
|
332
|
+
* 只能靠定时对账。启动时记下 ppid,之后对不上就退出;间隔与宿主那边的健康轮询
|
|
333
|
+
* 同量级(10s),2s 只是让「关掉桌面 → 手机显示离线」快一点。
|
|
334
|
+
*
|
|
335
|
+
* 为什么是整进程退出而不是只 `stopServices()`:隧道一关,中转最多 60s(心跳 pong
|
|
336
|
+
* 超时)才删掉实例,这段时间手机仍显示在线;而孤儿进程本来就没有存在的理由,退出是
|
|
337
|
+
* 最快的收敛路径(也让 `ps` 里不再留下一行认不出归属的 node 进程)。
|
|
338
|
+
*/
|
|
339
|
+
const PARENT_WATCH_INTERVAL_MS = 2000
|
|
340
|
+
const parentPidAtStart = process.ppid
|
|
341
|
+
let parentWatchTimer = null
|
|
342
|
+
function watchParent() {
|
|
343
|
+
if (parentWatchTimer) return
|
|
344
|
+
parentWatchTimer = setInterval(() => {
|
|
345
|
+
if (process.ppid === parentPidAtStart) return
|
|
346
|
+
process.stderr.write('[dsh-mobile-plugin] parent process gone, sidecar shutting down\n')
|
|
347
|
+
shutdown()
|
|
348
|
+
}, PARENT_WATCH_INTERVAL_MS)
|
|
349
|
+
// 看护本身不该把进程按在这里:宿主正常退出时靠 disconnect 收尾。
|
|
350
|
+
if (typeof parentWatchTimer.unref === 'function') parentWatchTimer.unref()
|
|
351
|
+
}
|
|
352
|
+
process.once('disconnect', () => { process.stderr.write('[dsh-mobile-plugin] parent IPC closed, sidecar shutting down\n'); shutdown() })
|
|
353
|
+
watchParent()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SidecarFacade:宿主 ⇄ sidecar worker 的环回 HTTP 客户端 + 生命周期看守。
|
|
5
|
+
*
|
|
6
|
+
* fork worker、等 ready、把它的 stdout/stderr 转发到宿主 stderr(诊断只有这一条通道),
|
|
7
|
+
* 之后按需 request('/status' | '/configure' | '/tunnel/send' | '/revoke-all')。
|
|
8
|
+
* 两条上报通道都是「宿主必须立刻知道」的事:onDown(worker 意外退出,否则宿主的隧道/
|
|
9
|
+
* 局域网快照会永远冻在临死前那一帧,比状态难看危险得多)与 onState(worker 在状态
|
|
10
|
+
* 变化那一刻主动推的一帧,比 10s 轮询早得多)。
|
|
11
|
+
*
|
|
12
|
+
* 刻意不做重试:request 超时就是超时,由调用方(sidecar-lifecycle)决定是否重启;
|
|
13
|
+
* 在这里偷偷重试会把「隧道已死」拖成「偶尔能通」,反而更难诊断。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const http = require('node:http')
|
|
17
|
+
const { fork } = require('node:child_process')
|
|
18
|
+
const { join } = require('node:path')
|
|
19
|
+
|
|
20
|
+
const worker = join(__dirname, 'sidecar-facade-worker.cjs')
|
|
21
|
+
|
|
22
|
+
class SidecarFacade {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.parentPort = options.parentPort
|
|
25
|
+
this.token = options.token
|
|
26
|
+
this.child = null
|
|
27
|
+
this.port = null
|
|
28
|
+
this.pending = new Map()
|
|
29
|
+
this.nextId = 1
|
|
30
|
+
// worker 意外死掉时通知宿主。宿主那边有一份 sidecarSnapshot(隧道/局域网的
|
|
31
|
+
// 最近一次快照),如果没人告诉它 worker 已经没了,这份快照就永远冻在临死前
|
|
32
|
+
// 那一帧:/api/relay/status 会继续报 tunnel: connected、lan: listening,
|
|
33
|
+
// 而局域网端口上早就没人监听了。状态乐观比状态难看危险得多——它会让宿主
|
|
34
|
+
// 继续按「隧道是通的」去推镜像,然后一路 503。
|
|
35
|
+
this.onDown = typeof options.onDown === 'function' ? options.onDown : null
|
|
36
|
+
// 隧道/局域网状态**跳变**的即时上报(worker 侧 publishState())。健康轮询仍然是权威
|
|
37
|
+
// (只有它能发现「worker 整个没了」——那时 IPC 也断了),但轮询是 10s 一次的滞后快照:
|
|
38
|
+
// 隧道真断之后宿主最多 10s 仍认为已连接,期间照推镜像、每次撞 5s 超时,该会话的单飞门
|
|
39
|
+
// 被占住;反过来「断又连」整段落在两次轮询之间时,宿主看不到跳变,全量补推会被漏掉。
|
|
40
|
+
// 这里只做转发,边沿判定在宿主侧(lib/sidecar-lifecycle.cjs 的 applyTunnelState),
|
|
41
|
+
// 与轮询共用同一个判定,所以不会把一次重连算成两次补推。
|
|
42
|
+
this.onState = typeof options.onState === 'function' ? options.onState : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start() {
|
|
46
|
+
if (this.child) return
|
|
47
|
+
const child = fork(worker, [], { env: { ...process.env, DSH_SIDECAR_PARENT_PORT: String(this.parentPort), DSH_SIDECAR_TOKEN: this.token }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] })
|
|
48
|
+
this.child = child
|
|
49
|
+
// 意外退出必须立刻反映到宿主,不能等下一次健康轮询。这里带身份守卫:stop() 先把
|
|
50
|
+
// this.child 置空,紧接着 start() 会产生新 child,旧 child 迟到的 exit 绝不能把
|
|
51
|
+
// 刚起来的新实例又拉下水(和 worker 侧 tunnel 的 `tunnel === current` 同一道理)。
|
|
52
|
+
child.once('exit', (code, signal) => {
|
|
53
|
+
if (this.child !== child) return
|
|
54
|
+
this.child = null
|
|
55
|
+
// port 也要清:留着它的话后续 request 会对着一个已经没人监听的端口发,
|
|
56
|
+
// 报出来的是 ECONNREFUSED 之类天书,而不是「sidecar 已经不在了」。
|
|
57
|
+
this.port = null
|
|
58
|
+
if (this.onDown) this.onDown(new Error(signal ? `sidecar facade killed by ${signal}` : `sidecar facade exited ${code}`))
|
|
59
|
+
})
|
|
60
|
+
// 子进程的 stdout/stderr 必须有人消费。旧实现只给 stderr 开了 pipe 却从不读,
|
|
61
|
+
// 于是隧道与局域网代理的全部诊断输出被静默丢弃(排障时表现为「宿主日志里
|
|
62
|
+
// 什么都没有,连崩溃都看不见」);写入量涨过管道缓冲后,worker 的事件循环
|
|
63
|
+
// 还会被写阻塞卡死。这里原样转发到宿主自己的 stderr(systemd 收进 host.log)。
|
|
64
|
+
child.stdout?.on('data', (chunk) => process.stderr.write(chunk))
|
|
65
|
+
child.stderr?.on('data', (chunk) => process.stderr.write(chunk))
|
|
66
|
+
// 状态帧必须用常驻监听:下面 ready 用的是 child.once('message'),它会把第一条消息吃掉。
|
|
67
|
+
// 状态上报与 ready 恰好都在启动窗口里发,用 once 会漏掉第一条(而且 after ready 的
|
|
68
|
+
// 那些也永远收不到)。
|
|
69
|
+
child.on('message', message => {
|
|
70
|
+
if (!this.onState || !message || message.type !== 'state') return
|
|
71
|
+
try { this.onState(message) } catch { /* 状态回写不能反过来弄挂 facade */ }
|
|
72
|
+
})
|
|
73
|
+
const ready = new Promise((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => reject(new Error('sidecar facade startup timeout')), 5000)
|
|
75
|
+
child.once('message', message => {
|
|
76
|
+
if (message?.type === 'ready') { clearTimeout(timer); this.port = message.port; resolve(message) }
|
|
77
|
+
})
|
|
78
|
+
child.once('error', reject)
|
|
79
|
+
child.once('exit', code => { if (code !== 0) reject(new Error(`sidecar facade exited ${code}`)) })
|
|
80
|
+
})
|
|
81
|
+
await ready
|
|
82
|
+
await this.request('/health')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `timeoutMs` 可覆盖:隧道侧的 `/revoke-all` 需要给中转 10s 去逐个断开设备,
|
|
86
|
+
// 而这里旧实现硬编码 5s,慢中转下宿主永远先超时、`/api/relay/revoke-all` 只好
|
|
87
|
+
// 报 revoked:0(看起来像「撤销成功但一台都没撤」)。
|
|
88
|
+
request(pathname, method = 'GET', body, timeoutMs = 5000) {
|
|
89
|
+
if (!this.port) return Promise.reject(new Error('sidecar facade is not running'))
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const headers = { authorization: `Bearer ${this.token}` }
|
|
92
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
93
|
+
const req = http.request({ host: '127.0.0.1', port: this.port, path: pathname, method, headers }, res => {
|
|
94
|
+
let raw = ''; res.setEncoding('utf8'); res.on('data', chunk => { raw += chunk }); res.on('end', () => {
|
|
95
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
96
|
+
// 把 worker 侧返回的 error 原文带进异常。raw 这时已经在手上,却一直被丢掉,
|
|
97
|
+
// 于是「请求体太大」「隧道没连上」「帧太大」在宿主侧全长成同一句
|
|
98
|
+
// `sidecar facade request failed: 503`——排查时无从下手,只能反过来去
|
|
99
|
+
// 读 worker 进程的 stderr。这一行是把整个镜像 503 查出来的关键。
|
|
100
|
+
let detail = ''
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(raw || '{}')
|
|
103
|
+
if (parsed && typeof parsed.error === 'string' && parsed.error) detail = ` (${parsed.error})`
|
|
104
|
+
} catch { /* 非 JSON 响应就只能留状态码 */ }
|
|
105
|
+
return reject(new Error(`sidecar facade request failed: ${res.statusCode}${detail}`))
|
|
106
|
+
}
|
|
107
|
+
try { resolve(JSON.parse(raw || '{}')) } catch { reject(new Error('invalid sidecar facade response')) }
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
const timer = setTimeout(() => req.destroy(new Error('sidecar facade request timeout')), timeoutMs)
|
|
111
|
+
req.once('close', () => clearTimeout(timer))
|
|
112
|
+
req.on('error', reject)
|
|
113
|
+
if (body !== undefined) req.end(JSON.stringify(body)); else req.end()
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 桌面→relay 主动推送(移动端镜像缓存):复用隧道 sendJson(T.sync)。 */
|
|
118
|
+
pushSync(payload) {
|
|
119
|
+
return this.request('/tunnel/send', 'POST', payload)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async configure(config) {
|
|
123
|
+
return this.request('/configure', 'POST', config)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async stop() {
|
|
127
|
+
const child = this.child
|
|
128
|
+
this.child = null
|
|
129
|
+
this.port = null
|
|
130
|
+
if (!child) return
|
|
131
|
+
await new Promise(resolve => {
|
|
132
|
+
let settled = false
|
|
133
|
+
const finish = () => { if (!settled) { settled = true; clearTimeout(timer); resolve() } }
|
|
134
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish() }, 1500)
|
|
135
|
+
child.once('exit', finish)
|
|
136
|
+
child.kill('SIGTERM')
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { SidecarFacade }
|