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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备级事件流(GET /api/mobile/v1/events/stream):手机上侧栏那条 SSE。
|
|
3
|
+
*
|
|
4
|
+
* 它只被当成「有东西变了」的信号,绝不当作数据源(真正的数据还要 App 自己去拉列表),
|
|
5
|
+
* 所以这里把每条宿主帧削到只剩能表示「列表需要重拉」的部分(见 listRelevantPayload)。
|
|
6
|
+
* 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
|
|
7
|
+
* 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
|
|
8
|
+
* 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
|
|
9
|
+
* device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)
|
|
10
|
+
* 与 device/heartbeat(心跳)三类自产帧,App 侧靠它们判断「这条流还活着」「该重拉列表」
|
|
11
|
+
* 「该给某个会话补正文」并据此决定要不要把兜底轮询降频。
|
|
12
|
+
*
|
|
13
|
+
* 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
|
|
14
|
+
* 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
|
|
15
|
+
* 各一帧 device/session-activity(见 replayKnownSignals)。补的是断流窗口,不是新变更:
|
|
16
|
+
* 只读信号总线的快照,不 bump、不前移基准,App 侧对同 revision / 同水位幂等。
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
23
|
+
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
24
|
+
|
|
25
|
+
const SSE_RETRY_MS = 3000;
|
|
26
|
+
const DEDUP_WINDOW = 2048;
|
|
27
|
+
/** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
|
|
28
|
+
const HEARTBEAT_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 诊断日志。这条流过去的失败路径是一个空 catch(里面只有一句「客户端断开或流已结束」
|
|
32
|
+
* 的注释),于是「连着却一句话都不说」在宿主侧没有任何痕迹:手机把兜底轮询降成 60s,
|
|
33
|
+
* 而宿主日志里查不到这条流是正常收尾、被客户端断开,还是循环抛错。会话级流
|
|
34
|
+
* (mobile/stream.js)早就有 logStream,这里是它的设备级对偶——任何一条异常路径都必须
|
|
35
|
+
* 留下可 grep 的行。
|
|
36
|
+
*/
|
|
37
|
+
function logDeviceStream(message, error) {
|
|
38
|
+
const detail = error instanceof Error ? (error.stack || error.message) : (error == null ? '' : String(error));
|
|
39
|
+
try {
|
|
40
|
+
process.stderr.write(`[dsh-mobile-plugin] device/stream ${message}${detail ? `: ${detail}` : ''}\n`);
|
|
41
|
+
} catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stableEventId(payload, frame) {
|
|
45
|
+
if (payload && typeof payload === 'object') {
|
|
46
|
+
if (payload.type === 'session/event' && payload.sessionId && payload.event && Number.isFinite(payload.event.seq)) {
|
|
47
|
+
return `${payload.sessionId}:${payload.event.seq}`;
|
|
48
|
+
}
|
|
49
|
+
for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
|
|
50
|
+
if (typeof payload[key] === 'string' && payload[key]) return payload[key];
|
|
51
|
+
}
|
|
52
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'device';
|
|
53
|
+
const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
|
|
54
|
+
return `${sessionId}:${payload.type || 'event'}:${digest}`;
|
|
55
|
+
}
|
|
56
|
+
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeSse(res, payload, frame) {
|
|
60
|
+
const id = stableEventId(payload, frame);
|
|
61
|
+
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
62
|
+
if (id) fields.push(`id: ${id}`);
|
|
63
|
+
fields.push(`data: ${JSON.stringify(payload)}`, '', '');
|
|
64
|
+
try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function* mergeHostAndMux(ctx, signal) {
|
|
68
|
+
const sources = [];
|
|
69
|
+
if (typeof ctx.apiProxy?.events?.mux === 'function') {
|
|
70
|
+
sources.push(ctx.apiProxy.events.mux({ rpcId: 'mobile-device-stream', payload: {} }, signal)[Symbol.asyncIterator]());
|
|
71
|
+
}
|
|
72
|
+
if (typeof ctx.apiProxy?.events?.host === 'function') {
|
|
73
|
+
sources.push(ctx.apiProxy.events.host({ rpcId: 'mobile-host-stream', payload: {} }, signal)[Symbol.asyncIterator]());
|
|
74
|
+
}
|
|
75
|
+
const pending = new Map(sources.map((source, index) => [index, source.next()]));
|
|
76
|
+
while (pending.size > 0) {
|
|
77
|
+
const result = await Promise.race([...pending.entries()].map(async ([index, promise]) => ({ index, step: await promise })));
|
|
78
|
+
if (result.step.done) {
|
|
79
|
+
pending.delete(result.index);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
pending.set(result.index, sources[result.index].next());
|
|
83
|
+
yield result.step.value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Trim a device-wide stream payload to the metadata the mobile session-list
|
|
89
|
+
* actually consumes. `session/event` frames carry the FULL message content
|
|
90
|
+
* (payload.event.data includes the whole assistant/user message); forwarding
|
|
91
|
+
* them to every paired phone floods the relay and the client with megabytes
|
|
92
|
+
* whenever a session is actively generating. The device-wide stream is only
|
|
93
|
+
* used by the phone to refresh the session list (turn/start/end/title), so it
|
|
94
|
+
* needs the event identity and inner type, not the content. Other frame types
|
|
95
|
+
* (summary/queue/projection/question/approval/host) are small and kept intact.
|
|
96
|
+
*/
|
|
97
|
+
function lightPayload(payload) {
|
|
98
|
+
if (!payload || typeof payload !== 'object' || payload.type !== 'session/event') return payload;
|
|
99
|
+
const ev = payload.event && typeof payload.event === 'object' ? payload.event : null;
|
|
100
|
+
if (!ev) return payload;
|
|
101
|
+
const innerType = ev.data && typeof ev.data === 'object' && typeof ev.data.type === 'string' ? ev.data.type : undefined;
|
|
102
|
+
return {
|
|
103
|
+
type: 'session/event',
|
|
104
|
+
sessionId: payload.sessionId,
|
|
105
|
+
event: {
|
|
106
|
+
type: ev.type,
|
|
107
|
+
seq: ev.seq,
|
|
108
|
+
time: ev.time,
|
|
109
|
+
...(innerType ? { data: { type: innerType } } : {}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only the event kinds the mobile session-list sidebar actually consumes are
|
|
115
|
+
// allowed through the device-wide stream. Message/tool/projection frames are
|
|
116
|
+
// irrelevant to the sidebar and are the bulk of the flood whenever any session
|
|
117
|
+
// is generating; they belong to the per-session /session/stream instead.
|
|
118
|
+
const LIST_EVENT_INNER = new Set(['turn/start', 'turn/end', 'session/title', 'session/disposed']);
|
|
119
|
+
function listRelevantPayload(rawPayload) {
|
|
120
|
+
if (!rawPayload || typeof rawPayload !== 'object') return null;
|
|
121
|
+
const type = rawPayload.type;
|
|
122
|
+
if (type === 'session/summary' || type === 'session/queue' || type === 'session/subscribed') return rawPayload;
|
|
123
|
+
if (type === 'question/requested' || type === 'question/resolved' || type === 'approval/requested' || type === 'approval/asked' || type === 'approval/decided' || type === 'approval/resolved') return rawPayload;
|
|
124
|
+
if (typeof type === 'string' && type.startsWith('host/')) return rawPayload;
|
|
125
|
+
if (type === 'session/event') {
|
|
126
|
+
const ev = rawPayload.event && typeof rawPayload.event === 'object' ? rawPayload.event : null;
|
|
127
|
+
const inner = ev && ev.data && typeof ev.data === 'object' ? ev.data.type : undefined;
|
|
128
|
+
if (LIST_EVENT_INNER.has(inner)) return lightPayload(rawPayload);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Device-wide compatibility stream. The mux frame remains intact in `payload`;
|
|
136
|
+
* the envelope only provides a stable event identity for future clients.
|
|
137
|
+
*/
|
|
138
|
+
async function handleDeviceStream(ctx, req, res) {
|
|
139
|
+
try {
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
142
|
+
'cache-control': 'no-cache',
|
|
143
|
+
connection: 'keep-alive',
|
|
144
|
+
});
|
|
145
|
+
} catch { return; }
|
|
146
|
+
|
|
147
|
+
res.write(': connected\n\n');
|
|
148
|
+
const ac = new AbortController();
|
|
149
|
+
req.on('close', () => ac.abort());
|
|
150
|
+
// 写坏掉的连接不该把宿主整条流拖住,也不该让 'error' 在 res 上无人接住而变成未捕获异常。
|
|
151
|
+
// (守卫 on 的存在是为了那些只实现 writeHead/write/end 的最小假体。)
|
|
152
|
+
if (typeof res.on === 'function') res.on('error', () => ac.abort());
|
|
153
|
+
/** 这条连接上真正写出去的帧数,只用于收尾日志(诊断「连着却一帧不发」)。 */
|
|
154
|
+
let framesSent = 0;
|
|
155
|
+
const writeFrame = (devicePayload) => {
|
|
156
|
+
const written = writeSse(res, {
|
|
157
|
+
type: 'device/event',
|
|
158
|
+
version: 1,
|
|
159
|
+
payload: devicePayload,
|
|
160
|
+
}, null);
|
|
161
|
+
if (written) framesSent += 1;
|
|
162
|
+
return written;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
|
|
166
|
+
*
|
|
167
|
+
* 设备级流此前只有宿主事件这一个来源,而宿主的 0.2.0 兼容桥只订阅 workspace/follow +
|
|
168
|
+
* session/control,会话状态变化(session/projection)会被 listRelevantPayload 丢掉 ——
|
|
169
|
+
* 手机于是「连着却收不到任何会话状态变化」,兜底轮询又被降成 60s。这里补上真源。
|
|
170
|
+
*
|
|
171
|
+
* 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再
|
|
172
|
+
* 等于「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听(隧道断着也照常判定),
|
|
173
|
+
* 收尾时必须注销,否则它永远以为有人听。
|
|
174
|
+
*/
|
|
175
|
+
const releaseSignal = sharedListSignal.watch();
|
|
176
|
+
const unsubscribeSignal = sharedListSignal.onBump((event) => {
|
|
177
|
+
if (ac.signal.aborted) return;
|
|
178
|
+
if (!writeFrame({ type: 'device/list-changed', revision: event.revision, at: event.at })) ac.abort();
|
|
179
|
+
});
|
|
180
|
+
/**
|
|
181
|
+
* 自产的「某个会话的正文水位涨了」帧,与「清单变了」是两条独立的通知。
|
|
182
|
+
*
|
|
183
|
+
* 清单信号只说「列表内容变了」,手机据此重拉列表;但列表摘要里的 lastMessage 只是
|
|
184
|
+
* 一句话,正文(回复的长文本、提问的选项)在会话缓存里。没有这条帧时,只有**打开着**
|
|
185
|
+
* 那个会话的手机才会实时收到正文(session/stream 是按会话订阅的),没打开的会话要等
|
|
186
|
+
* 用户点进去才拉 —— 而这正好是「点开对话要等一会儿才看到内容」的来源。
|
|
187
|
+
*
|
|
188
|
+
* 水位由镜像调度器**算出后广播**,与镜像是否真的推进去解耦(见 lib/mirror/pusher.cjs
|
|
189
|
+
* 的 announceSessionActivity:隧道未连、内容未变跳过、推送成功三条路径都会广播)——
|
|
190
|
+
* 它说的是「本机正文到哪了」,不是「中转镜像里有到哪了」。手机收到帧后立刻带 afterSeq
|
|
191
|
+
* 去 /session/messages 增量拉:走中转的手机读到的是刚推上去的镜像,走局域网直连的手机
|
|
192
|
+
* 读到的是 sidecar 本地那份快照(/tunnel/send 先落盘再尝试发),两条路都拿得到。
|
|
193
|
+
* 同样必须直订 + 收尾注销,理由与上面那条一致。
|
|
194
|
+
*/
|
|
195
|
+
const unsubscribeActivity = sharedSessionSignal.onBump((event) => {
|
|
196
|
+
if (ac.signal.aborted) return;
|
|
197
|
+
if (!writeFrame({ type: 'device/session-activity', sessionId: event.sessionId, seq: event.seq, at: event.at })) ac.abort();
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* 连上就先补一帧「当前状态」。
|
|
201
|
+
*
|
|
202
|
+
* 设备级流没有会话级流那套 Last-Event-ID 续传(App 对这条流不做断点续传,它只把帧
|
|
203
|
+
* 当「有东西变了」的信号),所以断流窗口里发生的清单变更与正文水位在重连后必须补上。
|
|
204
|
+
* 不补的后果:手机重连后 state 是空的,而侧栏兜底轮询已经因为「流活着」被降到 60s ——
|
|
205
|
+
* 中间这几分钟的变化要等到下一次真实变更才可见。
|
|
206
|
+
*
|
|
207
|
+
* 这里只读两条信号总线**已经广播过**的最近值(snapshot),既不 bump 也不前移任何基准,
|
|
208
|
+
* 所以不会制造新变更、不影响推送侧的幂等。App 侧对同 revision / 同水位是幂等的
|
|
209
|
+
* (app 的 handleDeviceSignal 按 revision 与 seq 判重),重连补发不会引发重复刷新。
|
|
210
|
+
* 帧形状与实时帧完全一致(同为 device/event 信封包 device/list-changed 或
|
|
211
|
+
* device/session-activity),App 不需要任何改动。
|
|
212
|
+
*/
|
|
213
|
+
const replayKnownSignals = () => {
|
|
214
|
+
const list = sharedListSignal.snapshot();
|
|
215
|
+
if (list && list.revision) {
|
|
216
|
+
if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
|
|
217
|
+
}
|
|
218
|
+
for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
|
|
219
|
+
if (!(seq > 0)) continue;
|
|
220
|
+
if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
// 补发失败说明连接已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
|
|
225
|
+
// 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。
|
|
226
|
+
if (!replayKnownSignals()) ac.abort();
|
|
227
|
+
const keepAlive = setInterval(() => {
|
|
228
|
+
try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
|
|
229
|
+
// 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
|
|
230
|
+
// 兜底轮询降到 60s(见 app/src/services/viewmodels/signal-liveness.ts)。
|
|
231
|
+
if (!writeFrame({ type: 'device/heartbeat', at: Date.now() })) ac.abort();
|
|
232
|
+
}, HEARTBEAT_MS);
|
|
233
|
+
// 定时器不该拖住进程退出(同 lib/sidecar-facade-worker.cjs 的 shutdown)。测试里的
|
|
234
|
+
// setInterval 桩没有 unref,先判类型。
|
|
235
|
+
if (keepAlive && typeof keepAlive.unref === 'function') keepAlive.unref();
|
|
236
|
+
const seenEventIds = new Set();
|
|
237
|
+
const seenEventOrder = [];
|
|
238
|
+
// 「客户端走了」与「循环正常结束」必须分开:前者不该再写日志说流被关,后者要留痕,
|
|
239
|
+
// 否则「连着却一帧不发」在宿主日志里查不出是哪种。
|
|
240
|
+
let clientGone = false;
|
|
241
|
+
let loopEnded = false;
|
|
242
|
+
try {
|
|
243
|
+
for await (const frame of mergeHostAndMux(ctx, ac.signal)) {
|
|
244
|
+
const rawPayload = frame && frame.payload;
|
|
245
|
+
if (!rawPayload || typeof rawPayload !== 'object') continue;
|
|
246
|
+
// Keep only the lightweight session-list signals; drop message/tool/
|
|
247
|
+
// projection frames that flood every paired phone during active sessions.
|
|
248
|
+
const payload = listRelevantPayload(rawPayload);
|
|
249
|
+
if (!payload) continue;
|
|
250
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
251
|
+
const eventId = stableEventId(rawPayload, frame);
|
|
252
|
+
if (eventId) {
|
|
253
|
+
if (seenEventIds.has(eventId)) continue;
|
|
254
|
+
seenEventIds.add(eventId);
|
|
255
|
+
seenEventOrder.push(eventId);
|
|
256
|
+
if (seenEventOrder.length > DEDUP_WINDOW) {
|
|
257
|
+
const evicted = seenEventOrder.shift();
|
|
258
|
+
if (evicted) seenEventIds.delete(evicted);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const envelope = {
|
|
262
|
+
type: 'device/event',
|
|
263
|
+
version: 1,
|
|
264
|
+
eventId,
|
|
265
|
+
sessionId,
|
|
266
|
+
rpcId: typeof frame.rpcId === 'string' ? frame.rpcId : undefined,
|
|
267
|
+
payload,
|
|
268
|
+
};
|
|
269
|
+
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
270
|
+
if (!writeSse(res, envelope, frame)) { clientGone = true; break; }
|
|
271
|
+
framesSent += 1;
|
|
272
|
+
}
|
|
273
|
+
if (!clientGone) loopEnded = true;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// 旧实现是裸 catch:mux 循环一旦抛错(底层 HTTP SSE 中断、返回非异步可迭代、
|
|
276
|
+
// signal abort),这条流就停在 preamble 之后既不产出也不终止,宿主侧查不出原因。
|
|
277
|
+
logDeviceStream('mux loop threw', error);
|
|
278
|
+
}
|
|
279
|
+
clearInterval(keepAlive);
|
|
280
|
+
unsubscribeSignal();
|
|
281
|
+
unsubscribeActivity();
|
|
282
|
+
releaseSignal();
|
|
283
|
+
try {
|
|
284
|
+
if (!res.writableEnded) {
|
|
285
|
+
res.end();
|
|
286
|
+
logDeviceStream(`stream closed (loopEnded=${loopEnded}, aborted=${ac.signal.aborted}, clientGone=${clientGone}, frames=${framesSent})`, null);
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logDeviceStream('stream end failed', error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { handleDeviceStream, listRelevantPayload, lightPayload, HEARTBEAT_MS };
|