dsh-mobile-plugin 0.1.91 → 0.1.93

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.
@@ -4,7 +4,13 @@
4
4
  * dsh-mobile 流式:SSE 转发目标会话的增量事件。
5
5
  *
6
6
  * 订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
7
- * 每 15s 发一行注释保持连接;客户端断开即取消订阅。
7
+ * 每 15s 发一行注释 + 一帧 `session/heartbeat` 数据帧;客户端断开即取消订阅。
8
+ *
9
+ * 为什么要那帧心跳:注释行只是保活,客户端一个事件都收不到。而「流被正常收尾」
10
+ * (中转重启、隧道掉了、宿主自己结束响应)在 App 那边看起来和「这条流还开着」一模一样
11
+ * —— react-native-sse 在 readyState 走到 DONE 时既不派发 error 也不派发 close。所以
12
+ * App 需要一个**自己数得到**的活口,两拍没到就主动断开重连(见 app 侧
13
+ * services/viewmodels/stream-liveness.ts)。这帧不带 seq、不进消息模型、不写缓存。
8
14
  *
9
15
  * 断线恢复:SSE 客户端重连时携带 `Last-Event-ID: <sessionId>:<seq>`(或查询参数
10
16
  * `lastSeq=<seq>`)。服务端先在 history 中回放 `seq > lastSeq` 的事件(收敛状态),
@@ -37,6 +43,14 @@ const questions = require('./questions.js');
37
43
  const SSE_FLUSH_PADDING_BYTES = 2048;
38
44
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
39
45
 
46
+ /**
47
+ * keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
48
+ *
49
+ * 必须与 App 的 `STREAM_HEARTBEAT_MS` 一致(`app/src/services/viewmodels/stream-liveness.ts`,
50
+ * 由 `test/stream-liveness.test.cjs` 的跨仓断言钉着)。
51
+ */
52
+ const HEARTBEAT_MS = 15_000;
53
+
40
54
  /**
41
55
  * 可回答的审批请求帧类型。宿主 apiProxy 的 `requestedFrame()` 发的是 mux 帧
42
56
  * `approval/requested`(`rpcId` 在帧级、`approvalId` 在 payload);会话事件
@@ -80,7 +94,8 @@ function writeSse(res, payload, frame) {
80
94
  // 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
81
95
  // 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
82
96
  // 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
83
- shedWhenBuffered: (value) => !isApprovalPayload(value) && shedWithoutSeq(value),
97
+ // 心跳同理必须保住:丢了它,App 会把一条健康的流判成死链(见 isLivenessHeartbeat)。
98
+ shedWhenBuffered: (value) => !isApprovalPayload(value) && !isLivenessHeartbeat(value) && shedWithoutSeq(value),
84
99
  });
85
100
  }
86
101
 
@@ -94,6 +109,19 @@ function isApprovalPayload(payload) {
94
109
  || type === 'question/requested' || type === 'question/resolved';
95
110
  }
96
111
 
112
+ /**
113
+ * 这一帧是「App 用来数活口」的心跳,不是聊天内容。
114
+ *
115
+ * 它走和正文一样的 writeSse(所以同样受背压闸门管),但**必须**在软水位上被保住:
116
+ * 心跳被丢掉就等于这条流没说话,App 会在 35s 后把一条完全健康的连接判死并重连 ——
117
+ * 那是比「丢一张草稿」贵得多的代价。它一帧一百多字节,不是撑爆写队列的那类帧。
118
+ *
119
+ * 它不带 seq,所以**绝不能**推进 `lastDeliveredSeq`(那会让回放跳过一个真事件)。
120
+ */
121
+ function isLivenessHeartbeat(payload) {
122
+ return Boolean(payload) && payload.type === 'session/heartbeat';
123
+ }
124
+
97
125
  /**
98
126
  * 解析断线续传位置:
99
127
  * - `Last-Event-ID: <sessionId>:<seq>`(标准 SSE 头);
@@ -152,8 +180,18 @@ async function handleStream(ctx, req, res) {
152
180
  clearInterval(keepAlive);
153
181
  ac.abort();
154
182
  try { res.end(); } catch { /* ignore */ }
183
+ return;
184
+ }
185
+ // 注释行客户端看不见,所以每一拍再补一帧数据心跳:App 靠「最近真的收到过帧」判断
186
+ // 这条流还在说话(见 app 侧 services/viewmodels/stream-liveness.ts)。写不出去同样
187
+ // 说明客户端已经不在,按上面那条路径收尾。
188
+ if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }).ok) {
189
+ logStream(sessionId, 'heartbeat write failed', null);
190
+ clearInterval(keepAlive);
191
+ ac.abort();
192
+ try { res.end(); } catch { /* ignore */ }
155
193
  }
156
- }, 15_000);
194
+ }, HEARTBEAT_MS);
157
195
  // 定时器不该拖住进程退出:宿主/worker 关停时若这条 SSE 还挂着,事件循环会被它按在
158
196
  // 15s 一拍上,收尾要等到下一次 tick 才发生(lib/sidecar-facade-worker.cjs 的
159
197
  // shutdown 就是这么做的)。测试里的 setInterval 桩没有 unref,所以先判类型。
@@ -214,7 +252,8 @@ async function handleStream(ctx, req, res) {
214
252
  const seq = p.event && Number.isFinite(p.event.seq) ? p.event.seq : null;
215
253
  // 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
216
254
  if (seq !== null && seq <= lastDeliveredSeq) continue;
217
- const event = await resolveEventAttachments(ctx, sessionId, p.event);
255
+ // 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。
256
+ const event = await resolveEventAttachments(ctx, sessionId, p.event, { referencesOnly: true });
218
257
  // 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
219
258
  // 同一轮收尾的 assistant/message 会把完整正文补上。
220
259
  const result = await push(event, { ...frame, payload: { ...p, event } });
@@ -293,7 +332,7 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
293
332
  for (const entry of candidates) {
294
333
  if (signal.aborted) break;
295
334
  const e = entry?.event || entry;
296
- const event = await resolveEventAttachments(ctx, sessionId, e);
335
+ const event = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
297
336
  // Keep replay data identical to live SSE data: the client reducer consumes
298
337
  // the event's own type (turn/start, assistant/message, etc.).
299
338
  const payload = { type: 'session/event', sessionId, event };
@@ -9,13 +9,13 @@
9
9
  * 为什么分片,而不是一次 POST 传完:
10
10
  * - 一次传完会把「能传多大的文件」变成一条运维配置 —— 中转的
11
11
  * `MAX_BODY_BYTES`(dsh-mobile-server/lib/config.js)默认 50MB,插件自己的
12
- * readJsonBody 默认只有 256KB。分片后单请求只有 1MB,与这些上限无关。
12
+ * readJsonBody 默认只有 256KB。分片后单请求最大只有 2MB,与这些上限无关。
13
13
  * - 手机侧网络不稳,分片能只重传坏掉的那一片,而不是整个文件。
14
14
  *
15
15
  * 为什么片内容走 base64 而不是原始字节:隧道协议本身支持原始字节
16
16
  * (lib/relay/protocol.js 的 httpBody 帧),但 App 侧没有任何「发二进制 body」的
17
17
  * 先例,RN 的 fetch 对 ArrayBuffer body 的支持也随版本变;图片通道本来就以 base64
18
- * 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 1MB → 约 1.37MB),
18
+ * 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 2MB → 约 2.73MB),
19
19
  * 仍远低于任何一层上限。
20
20
  *
21
21
  * 顺序与幂等:chunk 只接受「下一片」,重复送达同一片按重传处理(回当前 received,
@@ -32,11 +32,16 @@ const crypto = require('node:crypto');
32
32
 
33
33
  const { sendJson, readJsonBody } = require('../shared/http-util');
34
34
 
35
- // 片大小 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s(`app/src/api/core.ts`
36
- // 的 REQUEST_TIMEOUT_MS),1MB 片在慢速上行网络上会撞超时;片越小越稳,代价只是多几次往返。
35
+ // 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
36
+ // (`app/src/api/core.ts` 的 REQUEST_TIMEOUT_MS),一上来就给大片的机器会在慢速上行
37
+ // 网络上撞超时。客户端从这一档起步、按实测周期自己往上爬,爬到 `CHUNK_MAX_BYTES` 为止 ——
38
+ // 局域网里能一路放大,公网上爬两下就停住。所以 begin 回的是「起步值 + 上限」两个数,
39
+ // 不是「就传这么大」。
37
40
  const CHUNK_BYTES = 256 * 1024;
38
- /** 单片请求体上限:1MB base64 后约 1.37MB,留出信封与转义余量。 */
39
- const CHUNK_MAX_BODY = 4 * 1024 * 1024;
41
+ // 片大小的上限(原始字节):base64 后约 2.73MB,仍在下面 8MB 的请求体预算内。
42
+ const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
43
+ /** 单片请求体上限:4MB base64 后约 5.33MB,留出信封与转义余量。 */
44
+ const CHUNK_MAX_BODY = 8 * 1024 * 1024;
40
45
  /** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
41
46
  const NAME_MAX_CHARS = 120;
42
47
  /** 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。 */
@@ -182,7 +187,8 @@ async function handleUploadBegin(_ctx, req, res) {
182
187
  };
183
188
  uploads.set(uploadId, job);
184
189
  scheduleUploadExpiry(uploadId, job);
185
- sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES });
190
+ // chunkSize 是起步值、maxChunkSize 是天花板:客户端从前者开始按实测周期往上爬。
191
+ sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES, maxChunkSize: CHUNK_MAX_BYTES });
186
192
  }
187
193
 
188
194
  /** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
@@ -259,4 +265,5 @@ module.exports = {
259
265
  inboxRoot,
260
266
  sweepExpiredUploads,
261
267
  CHUNK_BYTES,
268
+ CHUNK_MAX_BYTES,
262
269
  };
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
- const { stableRevision } = require('../shared/list-revision.js');
18
+ const { isFreshRequest } = require('../shared/fresh-request');
19
+ const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
19
20
  // 会话列表缓存归 sessions.js 管。归档/移动会话会改变列表行本身,必须让那边立刻失效,
20
21
  // 否则手机端按完「归档会话」,那一行最长 10 秒还留在侧栏里,看着像没归档。
21
22
  const { invalidateSessionList } = require('./sessions.js');
@@ -80,8 +81,21 @@ async function buildWorkspacesBody(ctx) {
80
81
  };
81
82
  }
82
83
 
83
- async function handleWorkspaces(ctx, _req, res) {
84
- sendJson(res, 200, await buildWorkspacesBody(ctx));
84
+ /**
85
+ * GET /api/mobile/v1/workspaces?rev=<上次的 revision>(token)
86
+ *
87
+ * 与 `/sessions` 同一条条件刷新契约(形状见 `lib/shared/list-revision.js` 的
88
+ * `unchangedListBody`):`rev` 与最终 body 的 revision **恰好相等**时只回一句
89
+ * `{ok, unchanged:true, revision, generatedAt}`,不带 items。这条路由没有响应缓存,
90
+ * 所以只判「这份刚构建出来的 body」。
91
+ * `?fresh=1` 是写后刷新,必须拿到真数据,一律不走这条。
92
+ */
93
+ async function handleWorkspaces(ctx, req, res) {
94
+ const url = new URL(req.url, 'http://x');
95
+ const rev = (url.searchParams.get('rev') || '').trim();
96
+ const body = await buildWorkspacesBody(ctx);
97
+ const unchanged = isFreshRequest(req.url) ? null : unchangedListBody(body, rev);
98
+ sendJson(res, 200, unchanged || body);
85
99
  }
86
100
 
87
101
  /**