dsh-mobile-plugin 0.1.99 → 0.2.0
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
|
@@ -42,6 +42,15 @@ const MAX_RETIRED = 2000;
|
|
|
42
42
|
/** 按天的账保留天数(约三年;一天一百多字节,超出的从最老的开始裁)。 */
|
|
43
43
|
const MAX_DAYS = 1100;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 每条会话自己那份「按天用量」保留多少天。
|
|
47
|
+
*
|
|
48
|
+
* 它存在只为回答「这张卡片在这个范围里用了多少」:页面的三档范围最大 30 天,
|
|
49
|
+
* 再留一倍余量足够;更老的日期按天的总账仍然还在,只是卡片不再按那条会话拆得开。
|
|
50
|
+
* 不封顶的话,两百条会话 × 三年就是一份几十 MB 的 JSON,每次采集都要整份读写。
|
|
51
|
+
*/
|
|
52
|
+
const MAX_DAILY_DAYS = 62;
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
55
|
* 热力图最多给多少天(52 周)。
|
|
47
56
|
*
|
|
@@ -125,6 +134,18 @@ function sanitizeUsage(source) {
|
|
|
125
134
|
return usage;
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
/** 上下文构成快照:三条都缺或全 0 就当没有(别在卡片上画一个 0 的环)。 */
|
|
138
|
+
function readComposition(source) {
|
|
139
|
+
if (!source || typeof source !== 'object') return null;
|
|
140
|
+
const composition = {
|
|
141
|
+
systemTokens: num(source.systemTokens),
|
|
142
|
+
toolsTokens: num(source.toolsTokens),
|
|
143
|
+
messageTokens: num(source.messageTokens),
|
|
144
|
+
};
|
|
145
|
+
if (composition.systemTokens + composition.toolsTokens + composition.messageTokens <= 0) return null;
|
|
146
|
+
return { ...composition, at: num(source.at) };
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
function sanitizeLedger(raw) {
|
|
129
150
|
if (!raw || typeof raw !== 'object') return emptyLedger();
|
|
130
151
|
if (raw.version !== VERSION) return emptyLedger();
|
|
@@ -141,6 +162,19 @@ function sanitizeLedger(raw) {
|
|
|
141
162
|
if (raw.sessions && typeof raw.sessions === 'object') {
|
|
142
163
|
for (const [sessionId, value] of Object.entries(raw.sessions)) {
|
|
143
164
|
if (!sessionId || !value || typeof value !== 'object') continue;
|
|
165
|
+
const daily = {};
|
|
166
|
+
if (value.daily && typeof value.daily === 'object') {
|
|
167
|
+
for (const [day, usageOfDay] of Object.entries(value.daily)) {
|
|
168
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !usageOfDay || typeof usageOfDay !== 'object') continue;
|
|
169
|
+
daily[day] = sanitizeUsage(usageOfDay);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const billed = sanitizeUsage(value.billed);
|
|
173
|
+
// 老账本(2026-10-24 之前)没有每条会话的按天用量:把它的历史量记在它最后一次
|
|
174
|
+
// 活动那天。这跟「首采回填」是同一条近似,也是一次性回填 —— 之后就只记增量。
|
|
175
|
+
if (Object.keys(daily).length === 0 && billedOf(billed) > 0) {
|
|
176
|
+
daily[dayKeyOf(num(value.lastAt) || Date.now())] = { ...billed };
|
|
177
|
+
}
|
|
144
178
|
ledger.sessions[sessionId] = {
|
|
145
179
|
title: typeof value.title === 'string' ? value.title : '',
|
|
146
180
|
workspaceName: typeof value.workspaceName === 'string' ? value.workspaceName : '',
|
|
@@ -152,7 +186,11 @@ function sanitizeLedger(raw) {
|
|
|
152
186
|
gone: value.gone === true,
|
|
153
187
|
// 退场 = 这条卡片已经不在最近 MAX_SESSIONS 里,只留水位与身份。
|
|
154
188
|
retired: value.retired === true,
|
|
155
|
-
billed
|
|
189
|
+
billed,
|
|
190
|
+
// 这条会话自己那份按天用量:卡片上的数字要跟着页面范围走(见 `rangeUsageOf`)。
|
|
191
|
+
daily,
|
|
192
|
+
// 最后一次观测到的上下文构成:跟着会话走,不再全局只留一份。
|
|
193
|
+
composition: readComposition(value.composition),
|
|
156
194
|
// 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
|
|
157
195
|
watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
|
|
158
196
|
};
|
|
@@ -160,6 +198,8 @@ function sanitizeLedger(raw) {
|
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
if (raw.composition && typeof raw.composition === 'object') {
|
|
201
|
+
// 老账本只有一份全局构成(每轮被最后读完的那条覆盖)。留着当兜底:
|
|
202
|
+
// 没有任何一条会话带自己的构成时才用它。
|
|
163
203
|
ledger.composition = {
|
|
164
204
|
systemTokens: num(raw.composition.systemTokens),
|
|
165
205
|
toolsTokens: num(raw.composition.toolsTokens),
|
|
@@ -225,12 +265,40 @@ function trim(ledger) {
|
|
|
225
265
|
for (const id of pool.slice(MAX_RETIRED)) delete ledger.sessions[id];
|
|
226
266
|
}
|
|
227
267
|
}
|
|
268
|
+
// 每条会话自己那份按天用量也有上限:它只服务「这张卡片在这个范围里用了多少」,
|
|
269
|
+
// 最老的那些日子从它自己的账上裁掉,按天的总账不动。
|
|
270
|
+
for (const record of Object.values(ledger.sessions)) {
|
|
271
|
+
if (!record.daily) continue;
|
|
272
|
+
const own = Object.keys(record.daily).sort();
|
|
273
|
+
if (own.length > MAX_DAILY_DAYS) {
|
|
274
|
+
for (const day of own.slice(0, own.length - MAX_DAILY_DAYS)) delete record.daily[day];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
228
277
|
const days = Object.keys(ledger.days).sort();
|
|
229
278
|
if (days.length > MAX_DAYS) {
|
|
230
279
|
for (const day of days.slice(0, days.length - MAX_DAYS)) delete ledger.days[day];
|
|
231
280
|
}
|
|
232
281
|
}
|
|
233
282
|
|
|
283
|
+
/** 账本里一条会话记录的初始形状(`applyDelta` 与读盘兜底共用一份)。 */
|
|
284
|
+
function emptySession(at) {
|
|
285
|
+
return {
|
|
286
|
+
title: '',
|
|
287
|
+
workspaceName: '',
|
|
288
|
+
lastText: '',
|
|
289
|
+
cwd: '',
|
|
290
|
+
firstAt: at,
|
|
291
|
+
lastAt: 0,
|
|
292
|
+
collectedAt: 0,
|
|
293
|
+
gone: false,
|
|
294
|
+
retired: false,
|
|
295
|
+
billed: emptyUsage(),
|
|
296
|
+
daily: {},
|
|
297
|
+
composition: null,
|
|
298
|
+
watermark: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
234
302
|
/**
|
|
235
303
|
* 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
|
|
236
304
|
*
|
|
@@ -250,20 +318,12 @@ function applyDelta(ledger, entry) {
|
|
|
250
318
|
// 与 dsh-context 的 requests 口径差不到一条结算的距离。
|
|
251
319
|
day.requests += delta.steps;
|
|
252
320
|
|
|
253
|
-
const record = ledger.sessions[sessionId] || (ledger.sessions[sessionId] =
|
|
254
|
-
title: '',
|
|
255
|
-
workspaceName: '',
|
|
256
|
-
lastText: '',
|
|
257
|
-
cwd: '',
|
|
258
|
-
firstAt: at,
|
|
259
|
-
lastAt: 0,
|
|
260
|
-
collectedAt: 0,
|
|
261
|
-
gone: false,
|
|
262
|
-
retired: false,
|
|
263
|
-
billed: emptyUsage(),
|
|
264
|
-
watermark: null,
|
|
265
|
-
});
|
|
321
|
+
const record = ledger.sessions[sessionId] || (ledger.sessions[sessionId] = emptySession(at));
|
|
266
322
|
for (const key of USAGE_KEYS) record.billed[key] += delta[key];
|
|
323
|
+
// 这条会话自己那份按天用量:卡片的数字要跟页面范围走,所以增量在「记哪一天」这件事上
|
|
324
|
+
// 必须和按天的总账记在同一个日期上(首采回填也是同一天)。
|
|
325
|
+
const ownDay = record.daily[date] || (record.daily[date] = emptyUsage());
|
|
326
|
+
for (const key of USAGE_KEYS) ownDay[key] += delta[key];
|
|
267
327
|
record.lastAt = Math.max(record.lastAt, at);
|
|
268
328
|
if (!record.firstAt) record.firstAt = at;
|
|
269
329
|
// 退场的卡片又有了新用量:回到列表里来(水位一直在,所以这次只记增量)。
|
|
@@ -283,11 +343,33 @@ function billedOf(usage) {
|
|
|
283
343
|
return num(usage.uncachedInputTokens) + num(usage.cacheReadTokens) + num(usage.cacheWriteTokens) + num(usage.outputTokens);
|
|
284
344
|
}
|
|
285
345
|
|
|
346
|
+
/**
|
|
347
|
+
* 一条会话在 [startDay, today] 里的用量。
|
|
348
|
+
*
|
|
349
|
+
* `startDay` 为 null(`all`)时直接用记录里的累计值 —— 累计值是权威,而它那份按天用量
|
|
350
|
+
* 只保留最近 `MAX_DAILY_DAYS` 天。
|
|
351
|
+
*/
|
|
352
|
+
function usageInRange(record, startDay, today) {
|
|
353
|
+
const billed = record.billed || emptyUsage();
|
|
354
|
+
if (!startDay) return sanitizeUsage(billed);
|
|
355
|
+
const usage = emptyUsage();
|
|
356
|
+
const daily = record.daily || {};
|
|
357
|
+
for (const [day, value] of Object.entries(daily)) {
|
|
358
|
+
if (day < startDay || day > today) continue;
|
|
359
|
+
for (const key of USAGE_KEYS) usage[key] += num(value[key]);
|
|
360
|
+
}
|
|
361
|
+
return usage;
|
|
362
|
+
}
|
|
363
|
+
|
|
286
364
|
/**
|
|
287
365
|
* 按范围折算成 App 要的总览(纯函数)。
|
|
288
366
|
*
|
|
289
367
|
* 逐日序列会把范围内**每一天都列出来**(没有活动的补 0):柱状图靠它保持等距,
|
|
290
368
|
* 由页面决定怎么省略日期标签。
|
|
369
|
+
*
|
|
370
|
+
* 三块口径必须对得上(2026-10-24 改):`kpi` 是范围内总量,`sessions` 里的每张卡片是
|
|
371
|
+
* **这条会话在同一个范围里**的用量,`retired` / `untracked` 把不在卡片上的那部分明说出来 ——
|
|
372
|
+
* 卡片加起来永远解释不了总量,是这个页面被读成「数据对不上」的根源。
|
|
291
373
|
*/
|
|
292
374
|
function summarize(ledger, range, now) {
|
|
293
375
|
const today = dayKeyOf(now);
|
|
@@ -336,6 +418,7 @@ function summarize(ledger, range, now) {
|
|
|
336
418
|
if (point.requests > 0 || point.turns > 0) kpi.days += 1;
|
|
337
419
|
}
|
|
338
420
|
kpi.activeMs = kpi.llmMs + kpi.toolMs;
|
|
421
|
+
const rangeBilled = billedOf(kpi);
|
|
339
422
|
|
|
340
423
|
// 热力图给足最近 52 周(364 天):手机上只画得下 8 周,iPad 横屏能摊到三十周上下 ——
|
|
341
424
|
// 窗口由页面按自己的宽度截最后几周,宿主这边一律给满,否则宽屏想多画也没有数据。
|
|
@@ -346,20 +429,42 @@ function summarize(ledger, range, now) {
|
|
|
346
429
|
heatmap.push({ day, tokens: source ? billedOf(source) : 0 });
|
|
347
430
|
}
|
|
348
431
|
|
|
349
|
-
const rangeStartAt = startDay ? new Date(`${startDay}T00:00:00`).getTime() : 0;
|
|
350
432
|
const sessions = [];
|
|
433
|
+
let retiredSessions = 0;
|
|
434
|
+
let retiredBilled = 0;
|
|
435
|
+
// 构成快照取「最近有动作的那条会话」:每轮只在不是同一条时换,不再被并发读完的最后一条覆盖。
|
|
436
|
+
let composition = null;
|
|
437
|
+
let compositionSession = null;
|
|
351
438
|
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
352
|
-
|
|
353
|
-
if (record.retired === true)
|
|
354
|
-
|
|
439
|
+
const usage = usageInRange(record, startDay, today);
|
|
440
|
+
if (record.retired === true) {
|
|
441
|
+
// 退场记录只留水位,不上卡片:它的量早就记在按天的账上了。范围里有量时在汇总行报个数。
|
|
442
|
+
const billed = billedOf(usage);
|
|
443
|
+
if (billed > 0) {
|
|
444
|
+
retiredSessions += 1;
|
|
445
|
+
retiredBilled += billed;
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
|
|
450
|
+
composition = {
|
|
451
|
+
systemTokens: num(record.composition.systemTokens),
|
|
452
|
+
toolsTokens: num(record.composition.toolsTokens),
|
|
453
|
+
messageTokens: num(record.composition.messageTokens),
|
|
454
|
+
};
|
|
455
|
+
compositionSession = { lastAt: num(record.lastAt), sessionId, title: record.title || '' };
|
|
456
|
+
}
|
|
457
|
+
const billedTokens = billedOf(usage);
|
|
458
|
+
// 范围内一个 token、一轮都没有的会话不上卡片:切到「近 7 天」时老会话不该占着位置。
|
|
459
|
+
if (billedTokens === 0 && num(usage.turns) === 0) continue;
|
|
355
460
|
sessions.push({
|
|
356
461
|
sessionId,
|
|
357
462
|
title: record.title || '',
|
|
358
463
|
...(record.workspaceName ? { workspaceName: record.workspaceName } : {}),
|
|
359
464
|
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
360
465
|
...(record.lastText ? { lastText: record.lastText } : {}),
|
|
361
|
-
billedTokens
|
|
362
|
-
turns: num(
|
|
466
|
+
billedTokens,
|
|
467
|
+
turns: num(usage.turns),
|
|
363
468
|
lastAt: num(record.lastAt),
|
|
364
469
|
...(record.gone ? { gone: true } : {}),
|
|
365
470
|
});
|
|
@@ -373,13 +478,25 @@ function summarize(ledger, range, now) {
|
|
|
373
478
|
});
|
|
374
479
|
kpi.sessions = sessions.length;
|
|
375
480
|
|
|
481
|
+
// 卡片 + 退场合计之后仍然少的那一截:老版本把整条记录删掉过(水位与明细都没了),
|
|
482
|
+
// 按天的账上却还留着那些天。明说出来,别让用户自己拿计算器发现差额。
|
|
483
|
+
// 只报量、不报条数 —— 那些会话连记录都没了,条数是编不出来的。
|
|
484
|
+
const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0) + retiredBilled;
|
|
485
|
+
const untrackedBilled = Math.max(0, rangeBilled - tracked);
|
|
486
|
+
|
|
487
|
+
// 没有任何一条会话带自己的构成时(升级前的老账本),退回那份全局快照。
|
|
488
|
+
if (!composition && ledger.composition) composition = { ...ledger.composition };
|
|
489
|
+
|
|
376
490
|
return {
|
|
377
491
|
generatedAt: now,
|
|
378
492
|
range,
|
|
379
493
|
kpi,
|
|
380
494
|
days,
|
|
381
495
|
heatmap,
|
|
382
|
-
...(
|
|
496
|
+
...(composition ? { composition } : {}),
|
|
497
|
+
...(compositionSession ? { compositionSessionId: compositionSession.sessionId } : {}),
|
|
498
|
+
retired: { sessions: retiredSessions, tokens: retiredBilled },
|
|
499
|
+
untrackedBilled,
|
|
383
500
|
sessions,
|
|
384
501
|
};
|
|
385
502
|
}
|
|
@@ -389,6 +506,7 @@ module.exports = {
|
|
|
389
506
|
MAX_SESSIONS,
|
|
390
507
|
MAX_RETIRED,
|
|
391
508
|
MAX_DAYS,
|
|
509
|
+
MAX_DAILY_DAYS,
|
|
392
510
|
HEATMAP_DAYS,
|
|
393
511
|
LAST_TEXT_MAX,
|
|
394
512
|
stateFile,
|
|
@@ -403,5 +521,6 @@ module.exports = {
|
|
|
403
521
|
startDayOf,
|
|
404
522
|
emptyUsage,
|
|
405
523
|
billedOf,
|
|
524
|
+
usageInRange,
|
|
406
525
|
num,
|
|
407
526
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 按设备登记的 SSE 流。
|
|
5
|
+
*
|
|
6
|
+
* token 换了只保证**下一次请求**被拒;一条已经开着的流不会自己断,被踢的手机还能继续收
|
|
7
|
+
* 消息,直到它自己重连。不做这一步,「单台吊销」就只完成了一半。
|
|
8
|
+
*
|
|
9
|
+
* 登记表按 deviceId 分组:同一台手机可能同时开着两条流(会话流 + 设备级事件流),
|
|
10
|
+
* 两台手机之间互不影响。收流由吊销那条路径调 `drop`,逐条 `end()` 掉。
|
|
11
|
+
*
|
|
12
|
+
* 这里**不做超时与配额**:宿主本来就是给自己那几台设备推东西,流的上限由它们自己的
|
|
13
|
+
* 打开次数决定,加一层闸只会把「手机连着却收不到」变成一种新的失败形态。
|
|
14
|
+
*/
|
|
15
|
+
const streams = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 登记一条流。
|
|
19
|
+
*
|
|
20
|
+
* @param {string} deviceId 设备 id(凭据记录上的 `deviceId`)
|
|
21
|
+
* @param {object} res 这条流的响应对象(只需要 `end` 与 `destroy`)
|
|
22
|
+
* @returns {() => void} 注销函数;流自己关掉时调用,避免表越攒越大
|
|
23
|
+
*/
|
|
24
|
+
function track(deviceId, res) {
|
|
25
|
+
const key = typeof deviceId === 'string' ? deviceId : '';
|
|
26
|
+
if (!key || !res) return () => {};
|
|
27
|
+
let set = streams.get(key);
|
|
28
|
+
if (!set) {
|
|
29
|
+
set = new Set();
|
|
30
|
+
streams.set(key, set);
|
|
31
|
+
}
|
|
32
|
+
set.add(res);
|
|
33
|
+
return () => {
|
|
34
|
+
const current = streams.get(key);
|
|
35
|
+
if (!current) return;
|
|
36
|
+
current.delete(res);
|
|
37
|
+
if (current.size === 0) streams.delete(key);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 收掉这台设备上所有已建立的流。
|
|
43
|
+
*
|
|
44
|
+
* 先 `end()` 再 `destroy()`:`end` 让对端看到正常的流结束(App 会在下一次请求上收到
|
|
45
|
+
* `device-revoked`,而不是一个「网络出错」);对已经卡住的连接 `destroy` 是兜底。
|
|
46
|
+
*
|
|
47
|
+
* @returns {number} 实际收掉了多少条
|
|
48
|
+
*/
|
|
49
|
+
function drop(deviceId) {
|
|
50
|
+
const key = typeof deviceId === 'string' ? deviceId : '';
|
|
51
|
+
const set = streams.get(key);
|
|
52
|
+
if (!set) return 0;
|
|
53
|
+
streams.delete(key);
|
|
54
|
+
let closed = 0;
|
|
55
|
+
for (const res of set) {
|
|
56
|
+
closed += 1;
|
|
57
|
+
try { res.end?.(); } catch { /* 对端已经走了 */ }
|
|
58
|
+
try { res.destroy?.(); } catch { /* 同上 */ }
|
|
59
|
+
}
|
|
60
|
+
return closed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 测试与自检用:当前登记了多少条流。 */
|
|
64
|
+
function size() {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const set of streams.values()) total += set.size;
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 测试用:清空登记表。 */
|
|
71
|
+
function reset() { streams.clear(); }
|
|
72
|
+
|
|
73
|
+
module.exports = { track, drop, size, reset };
|
package/lib/mobile/stream.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
const { sendJson } = require('../shared/http-util');
|
|
27
|
+
const e2ee = require('../shared/e2ee');
|
|
28
|
+
const { headerOf, wrapper, signedPath } = require('../shared/e2ee-request');
|
|
29
|
+
const { ACCEPT_ENC_HEADER, compressPlain } = require('../shared/e2ee-plain');
|
|
27
30
|
const {
|
|
28
31
|
writeSse: sharedWriteSse,
|
|
29
32
|
waitForDrain,
|
|
@@ -36,6 +39,9 @@ const { unwrap } = require('./envelope.js');
|
|
|
36
39
|
const { normalizeJobs } = require('./jobs.js');
|
|
37
40
|
const approvals = require('./approvals.js');
|
|
38
41
|
const questions = require('./questions.js');
|
|
42
|
+
const streamRegistry = require('./stream-registry.js');
|
|
43
|
+
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
44
|
+
const { viaChannel } = require('../shared/via-header');
|
|
39
45
|
|
|
40
46
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
41
47
|
// internal progress threshold. Keep each window of records padded past that
|
|
@@ -54,6 +60,24 @@ const SSE_FLUSH_PADDING_BYTES = 2048;
|
|
|
54
60
|
const SSE_PAD_MIN_INTERVAL_MS = 120;
|
|
55
61
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
56
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 一次断线重放最多灌多少字节。
|
|
65
|
+
*
|
|
66
|
+
* `REPLAY_WINDOW` 按事件条数限量,挡不住「几百条事件、每个都带大附件」的会话:实测有
|
|
67
|
+
* 一条会话单次重放 36MB,同一个游标被反复重放一天烧掉 450MB。到了这条线就不补了,
|
|
68
|
+
* 改成让客户端去按页拉权威历史 —— 那条路一页两百条、实测几十 KB,便宜一个数量级。
|
|
69
|
+
*/
|
|
70
|
+
const REPLAY_MAX_BYTES = 512 * 1024;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 缺口超过这个跨度就干脆不回放。
|
|
74
|
+
*
|
|
75
|
+
* 客户端落后得这么远时,把窗口整个灌过去再让它去拉历史,等于同一段内容走了两遍
|
|
76
|
+
* (App 收到 resync 帧本来就会做一次全量 authoritative 拉取)。这个值远小于
|
|
77
|
+
* `REPLAY_WINDOW`:只补「刚刚断线这几下」的缺口,剩下的交给分页历史。
|
|
78
|
+
*/
|
|
79
|
+
const REPLAY_MAX_GAP = 200;
|
|
80
|
+
|
|
57
81
|
/**
|
|
58
82
|
* keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
|
|
59
83
|
*
|
|
@@ -136,6 +160,9 @@ function writeSse(res, payload, frame, options = {}) {
|
|
|
136
160
|
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
137
161
|
// 心跳同理必须保住:丢了它,App 会把一条健康的流判成死链(见 isLivenessHeartbeat)。
|
|
138
162
|
shedWhenBuffered: (value) => !isApprovalPayload(value) && !isLivenessHeartbeat(value) && shedWithoutSeq(value),
|
|
163
|
+
// 协议 v2:每帧一条独立密文(§8.3(5))。封装器由开流那一处给 —— 它才知道这条流的
|
|
164
|
+
// 请求 ts / nonce 与帧序号(AAD 要绑这三样)。
|
|
165
|
+
sealFrame: typeof options.sealFrame === 'function' ? options.sealFrame : null,
|
|
139
166
|
});
|
|
140
167
|
}
|
|
141
168
|
|
|
@@ -191,6 +218,35 @@ function parseResumeSeq(req, sessionId) {
|
|
|
191
218
|
* SSE:订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
|
|
192
219
|
* 每 15s 发一行注释保持连接;客户端断开即取消订阅。
|
|
193
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* 造这条流的帧封装器(§8.3(5))。
|
|
223
|
+
*
|
|
224
|
+
* AAD 绑「路径 + 请求的 ts/nonce + 帧序号」:前三个把这一帧钉死在这条流上(别的流、
|
|
225
|
+
* 上一次连接都塞不进来),帧序号再挡住「把上一段流的帧插到这一段里」,顺带让客户端能发现丢帧。
|
|
226
|
+
*
|
|
227
|
+
* 帧序号从 1 起、宿主自己数、不进明文。
|
|
228
|
+
*
|
|
229
|
+
* 逐帧也先压再封(`x-dsh-accept-enc` 带 `g` 时):一轮流式回复里带完整正文的
|
|
230
|
+
* `assistant/message` 常常几十 KB,草稿增量里的中文也压得很动,而每帧压一次的开销
|
|
231
|
+
* 远小于它省下的字节。小于阈值(1KB)的帧不压,信封形状与原来逐字节一致。
|
|
232
|
+
*
|
|
233
|
+
* @returns {null | ((payload: unknown) => { v: number, z?: string, n: string, c: string })}
|
|
234
|
+
*/
|
|
235
|
+
function createFrameSealer(req) {
|
|
236
|
+
const opened = req && req.__dshE2ee;
|
|
237
|
+
if (!opened || !opened.keys || !opened.req) return null;
|
|
238
|
+
const { keys, req: request } = opened;
|
|
239
|
+
const path = signedPath(req);
|
|
240
|
+
const acceptEnc = headerOf(req, ACCEPT_ENC_HEADER);
|
|
241
|
+
let seq = 0;
|
|
242
|
+
return (payload) => {
|
|
243
|
+
seq += 1;
|
|
244
|
+
const { payload: plain, z } = compressPlain(Buffer.from(JSON.stringify(payload), 'utf8'), acceptEnc);
|
|
245
|
+
const sealed = e2ee.seal(keys.down, plain, e2ee.sseAad(path, request.ts, request.nonce, seq));
|
|
246
|
+
return wrapper(sealed, z);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
194
250
|
async function handleStream(ctx, req, res) {
|
|
195
251
|
const url = new URL(req.url, 'http://x');
|
|
196
252
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -202,22 +258,37 @@ async function handleStream(ctx, req, res) {
|
|
|
202
258
|
const streamAddress = typeof parentSessionId === 'string' && parentSessionId
|
|
203
259
|
? { sessionId, parentSessionId, ...(mode === 'one-shot' || mode === 'continuable' ? { mode } : {}) }
|
|
204
260
|
: { sessionId };
|
|
261
|
+
// 协议 v2:这条流要逐帧封装。`req.__dshE2ee` 由 `createGuarded` 在解封请求时挂上。
|
|
262
|
+
// **要在 writeHead 之前算**:`X-Dsh-Proto: 2` 只能在头发出去之前声明,写过头再
|
|
263
|
+
// `setHeader` 是无效的(Node 会静默忽略),客户端就永远不知道帧是信封。
|
|
264
|
+
const sealFrame = createFrameSealer(req);
|
|
265
|
+
const head = {
|
|
266
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
267
|
+
'cache-control': 'no-cache',
|
|
268
|
+
connection: 'keep-alive',
|
|
269
|
+
'x-accel-buffering': 'no',
|
|
270
|
+
'content-encoding': 'identity',
|
|
271
|
+
...(sealFrame ? { 'x-dsh-proto': '2' } : {}),
|
|
272
|
+
};
|
|
205
273
|
try {
|
|
206
|
-
res.writeHead(200,
|
|
207
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
208
|
-
'cache-control': 'no-cache',
|
|
209
|
-
connection: 'keep-alive',
|
|
210
|
-
'x-accel-buffering': 'no',
|
|
211
|
-
'content-encoding': 'identity',
|
|
212
|
-
});
|
|
274
|
+
res.writeHead(200, head);
|
|
213
275
|
} catch (error) { logStream(sessionId, 'writeHead failed', error); return; }
|
|
214
276
|
res.write(': connected\n\n');
|
|
215
277
|
const ac = new AbortController();
|
|
216
|
-
|
|
278
|
+
// 按设备登记这条流:吊销时宿主主动断它(§3.3)。没有这一步,被踢的手机还能继续
|
|
279
|
+
// 收消息,直到它自己重连 —— 「单台吊销」就只生效了一半。
|
|
280
|
+
const untrack = streamRegistry.track(req.__dshRecord && req.__dshRecord.deviceId, res);
|
|
281
|
+
res.on('close', () => { untrack(); ac.abort(); });
|
|
217
282
|
// 与设备级流(mobile/events.js)对齐:写坏掉的连接不该让 'error' 在 res 上无人接住
|
|
218
283
|
// 而变成未捕获异常,那时 aborted 仍是 false,收尾分支会以为客户端还在、白写一帧 resync。
|
|
219
284
|
res.on('error', () => ac.abort());
|
|
285
|
+
|
|
220
286
|
const keepAlive = setInterval(() => {
|
|
287
|
+
// 开着流但没轮询的会话也要算「在线」(§3.4):只靠请求命中来记「最近连接」的话,
|
|
288
|
+
// 一条长对话流开着、列表不轮询,面板上那台设备会被误判成离线。
|
|
289
|
+
if (req.__dshRecord) {
|
|
290
|
+
try { credentialsEntry().touch(req.__dshRecord, { channel: viaChannel(req) }); } catch { /* 记账失败不影响流 */ }
|
|
291
|
+
}
|
|
221
292
|
try {
|
|
222
293
|
res.write(': keepalive\n\n');
|
|
223
294
|
} catch (error) {
|
|
@@ -232,7 +303,7 @@ async function handleStream(ctx, req, res) {
|
|
|
232
303
|
// 注释行客户端看不见,所以每一拍再补一帧数据心跳:App 靠「最近真的收到过帧」判断
|
|
233
304
|
// 这条流还在说话(见 app 侧 services/viewmodels/stream-liveness.ts)。写不出去同样
|
|
234
305
|
// 说明客户端已经不在,按上面那条路径收尾。
|
|
235
|
-
if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }).ok) {
|
|
306
|
+
if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }, null, { sealFrame }).ok) {
|
|
236
307
|
logStream(sessionId, 'heartbeat write failed', null);
|
|
237
308
|
clearInterval(keepAlive);
|
|
238
309
|
ac.abort();
|
|
@@ -247,11 +318,11 @@ async function handleStream(ctx, req, res) {
|
|
|
247
318
|
// 审批卡由审批桥在帧循环之外推进这条连接:宿主那半条通道(`approval/request` 钩子)
|
|
248
319
|
// 跟 mux 帧流没关系,见 mobile/approvals.js。接入时它会把仍然可作答的卡补发一遍。
|
|
249
320
|
const detachApprovals = approvals.attachSession(sessionId, (frame) => {
|
|
250
|
-
writeSse(res, frame, { payload: frame });
|
|
321
|
+
writeSse(res, frame, { payload: frame }, { sealFrame });
|
|
251
322
|
});
|
|
252
323
|
// 提问卡同理(见 mobile/questions.js):`user-questions/request` 钩子与 mux 帧流无关。
|
|
253
324
|
const detachQuestions = questions.attachSession(sessionId, (frame) => {
|
|
254
|
-
writeSse(res, frame, { payload: frame });
|
|
325
|
+
writeSse(res, frame, { payload: frame }, { sealFrame });
|
|
255
326
|
});
|
|
256
327
|
|
|
257
328
|
const resumeSeq = parseResumeSeq(req, sessionId);
|
|
@@ -276,11 +347,11 @@ async function handleStream(ctx, req, res) {
|
|
|
276
347
|
|
|
277
348
|
// 断线续传:回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
278
349
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
279
|
-
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
|
|
350
|
+
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame);
|
|
280
351
|
if (replay.gap && !ac.signal.aborted) {
|
|
281
352
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
282
353
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
283
|
-
if (!writeSse(res, value, { payload: value }).ok) clientGone = true;
|
|
354
|
+
if (!writeSse(res, value, { payload: value }, { sealFrame }).ok) clientGone = true;
|
|
284
355
|
}
|
|
285
356
|
}
|
|
286
357
|
|
|
@@ -289,7 +360,7 @@ async function handleStream(ctx, req, res) {
|
|
|
289
360
|
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
290
361
|
// 而不是把帧继续往内存里塞。
|
|
291
362
|
const push = async (payload, frameArg) => {
|
|
292
|
-
const sent = writeSse(res, payload, frameArg);
|
|
363
|
+
const sent = writeSse(res, payload, frameArg, { sealFrame });
|
|
293
364
|
if (sent.shed) return 'shed';
|
|
294
365
|
if (!sent.ok) return 'closed';
|
|
295
366
|
if (sent.needDrain) {
|
|
@@ -355,7 +426,7 @@ async function handleStream(ctx, req, res) {
|
|
|
355
426
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
|
356
427
|
// 路径补上缺口,而不是把静默断流当成正常状态。
|
|
357
428
|
if (!ac.signal.aborted && !clientGone) {
|
|
358
|
-
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true });
|
|
429
|
+
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true }, null, { sealFrame });
|
|
359
430
|
}
|
|
360
431
|
res.end();
|
|
361
432
|
logStream(sessionId, `stream closed (loopEnded=${loopEnded}, aborted=${ac.signal.aborted}, clientGone=${clientGone}, deliveredSeq=${lastDeliveredSeq})`, null);
|
|
@@ -370,7 +441,7 @@ async function handleStream(ctx, req, res) {
|
|
|
370
441
|
* 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
|
|
371
442
|
* 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
|
|
372
443
|
*/
|
|
373
|
-
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address) {
|
|
444
|
+
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null) {
|
|
374
445
|
try {
|
|
375
446
|
// 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
|
|
376
447
|
// 这条断线续传静默退化成「后面缺一段」。
|
|
@@ -393,6 +464,14 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
393
464
|
return e && Number.isFinite(e.seq) && e.seq > sinceSeq;
|
|
394
465
|
})
|
|
395
466
|
.sort((a, b) => ((a?.event || a).seq) - ((b?.event || b).seq));
|
|
467
|
+
// 落后太远就别在流里补了:这一段客户端本来就要靠权威历史重拉一遍,再从流里灌一遍
|
|
468
|
+
// 是同一份内容走两趟。空回放 + 报缺口,让 App 走分页历史那条便宜的路。
|
|
469
|
+
const farBehind = Boolean(candidates.length) && (candidates[candidates.length - 1].event || candidates[candidates.length - 1]).seq - sinceSeq > REPLAY_MAX_GAP;
|
|
470
|
+
if (gap || farBehind) {
|
|
471
|
+
logStream(sessionId, `replay skipped (gap=${gap} farBehind=${farBehind} cursor=${sinceSeq} newest=${(candidates[candidates.length - 1]?.event || candidates[candidates.length - 1])?.seq})`, null);
|
|
472
|
+
return { gap: true, oldestSeq: oldestSeq ?? sinceSeq + 1 };
|
|
473
|
+
}
|
|
474
|
+
let written = 0;
|
|
396
475
|
for (const entry of candidates) {
|
|
397
476
|
if (signal.aborted) break;
|
|
398
477
|
const e = entry?.event || entry;
|
|
@@ -402,11 +481,17 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
402
481
|
const payload = { type: 'session/event', sessionId, event };
|
|
403
482
|
// 成批灌入:这一节几百条连着写,逐条补满 2048 字节注释等于给一次重连凭空加几 MB
|
|
404
483
|
// (见 writeSse 的 options.burst)。
|
|
405
|
-
const sent = writeSse(res, event, { payload }, { burst: true });
|
|
484
|
+
const sent = writeSse(res, event, { payload }, { burst: true, sealFrame });
|
|
406
485
|
if (!sent.ok) break;
|
|
486
|
+
written += (sent.bytes ?? 0);
|
|
407
487
|
// 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
|
|
408
488
|
if (sent.needDrain && !(await waitForDrain(res, signal))) break;
|
|
409
489
|
if (Number.isFinite(event.seq)) onDelivered(event.seq);
|
|
490
|
+
// 字节到顶也一样:剩下的交给分页历史,别再灌。
|
|
491
|
+
if (written >= REPLAY_MAX_BYTES) {
|
|
492
|
+
logStream(sessionId, `replay truncated at ${written}B (cursor=${sinceSeq})`, null);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
410
495
|
}
|
|
411
496
|
return { gap, oldestSeq };
|
|
412
497
|
} catch (error) { /* 回放尽力而为;失败不阻塞实时流 */
|
|
@@ -415,4 +500,4 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
415
500
|
}
|
|
416
501
|
}
|
|
417
502
|
|
|
418
|
-
module.exports = { handleStream, parseResumeSeq };
|
|
503
|
+
module.exports = { handleStream, parseResumeSeq, createFrameSealer };
|