dsh-mobile-plugin 0.1.88 → 0.1.89

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.
@@ -33,6 +33,12 @@ const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
33
33
  // 不是「一轮推送有多大」:预览页面一次推几 MB 很正常,按 1MiB 判超限只会把正常的页面
34
34
  // 判成 502。
35
35
  const WS_UPSTREAM_PENDING_MAX_BYTES = 32 * 1024 * 1024;
36
+ // 局域网直连允许同时活着的上游 SSE 条数。它兜的是**半开连接**:手机走了但 TCP 还没
37
+ // 被拆掉(飞机模式、睡死、拔线都不会发 FIN),只有这种情况才需要一道闸。
38
+ // 与中转路的同名上限(tunnel.js 的 16)刻意不是一个量级:那边所有设备共用一条隧道,
39
+ // 设备多就得挤;这边每台设备各有自己的 TCP,实测 3 台设备正常十来条。所以这里留出
40
+ // 成倍余量,只当失控闸——正常使用不该碰到它。
41
+ const MAX_UPSTREAM_SSE_CONNECTIONS = 64;
36
42
 
37
43
  function createLanProxy(opts) {
38
44
  const {
@@ -51,10 +57,15 @@ function createLanProxy(opts) {
51
57
  onApprove = async () => ({ approved: false }),
52
58
  /** 读取隔离:本地镜像快照读取回调,命中时直接回(不再连主进程)。 */
53
59
  readSnapshot = () => null,
60
+ /** 上游 SSE 的失控闸,默认见 MAX_UPSTREAM_SSE_CONNECTIONS。 */
61
+ maxUpstreamSse = MAX_UPSTREAM_SSE_CONNECTIONS,
54
62
  } = opts;
55
63
 
56
64
  let server = null;
57
65
  let wss = null;
66
+ // 活着的上游请求计数(自检台账与失控闸消费):见 proxyToUpstream 的拆除注释。
67
+ let upstreamOpen = 0;
68
+ let upstreamSseOpen = 0;
58
69
  // 实际监听的端口。首选端口被占时(见 lib/relay/port-allocator.cjs)会退到内核
59
70
  // 分配的空闲端口,这时对外必须报 `actualPort` 而不是 `lanPort`,否则状态接口和
60
71
  // 配对二维码里带的都是那个根本没在监听的死端口。
@@ -192,6 +203,18 @@ function createLanProxy(opts) {
192
203
  }
193
204
  // 保留手机端原始 Host(信任围栏按 Host 判定,配合桌面壳 --trusted-host 本机 IP);缺省回退本地上游
194
205
  if (!headers.host) headers.host = `${upstreamHost}:${upstreamPort}`;
206
+ const isSse = String(req.headers.accept || '').toLowerCase().includes('text/event-stream');
207
+ if (isSse && upstreamSseOpen >= maxUpstreamSse) {
208
+ // 留一行日志:否则事后只看到手机端「连不上」,看不出是被这道闸挡的。
209
+ log(`上游 SSE 已达上限 ${maxUpstreamSse},拒绝新流(手机侧会重连)`);
210
+ try {
211
+ res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '2' });
212
+ res.end('局域网直连的上游长流已达上限,请稍后重连\n');
213
+ } catch { /* 已关闭 */ }
214
+ return;
215
+ }
216
+ upstreamOpen += 1;
217
+ if (isSse) upstreamSseOpen += 1;
195
218
  const up = http.request(
196
219
  { hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url, headers },
197
220
  (ures) => {
@@ -203,6 +226,11 @@ function createLanProxy(opts) {
203
226
  ures.pipe(res);
204
227
  },
205
228
  );
229
+ // 计数只在这里退:ClientRequest 的 close 一定会来(正常收尾、报错、被 destroy 都算)。
230
+ up.on('close', () => {
231
+ upstreamOpen -= 1;
232
+ if (isSse) upstreamSseOpen -= 1;
233
+ });
206
234
  up.on('error', (err) => {
207
235
  log('proxy error:', err.message);
208
236
  try {
@@ -210,6 +238,22 @@ function createLanProxy(opts) {
210
238
  res.end(`upstream error: ${err.message}`);
211
239
  } catch { /* 已关闭 */ }
212
240
  });
241
+ /**
242
+ * 手机走了,必须把上游这条请求一起拆掉。
243
+ *
244
+ * 旧实现只挂了 `error`:手机断开之后上游请求还活着,宿主那边的响应就永远等不到
245
+ * `close`,于是 events 流与每条会话流的订阅**永不退订**——宿主继续往一条没人读的
246
+ * 连接上生成帧与正文。2026-10-19 实测:一台机器 12 条手机连接攒出 216 条上游长流,
247
+ * 宿主进程涨到 3.5~5G、CPU 30~40%,重启后回到 530M。同一个插件的中转路径有
248
+ * `httpCancel` 帧负责拆(tunnel.js),只有局域网这条路漏了。
249
+ *
250
+ * `res.writableEnded` 是判据:正常活完的响应结束时就已置位,不该再去动上游;
251
+ * 只有「客户端提前走」才会落到 false。
252
+ */
253
+ res.on('close', () => {
254
+ if (res.writableEnded) return;
255
+ try { up.destroy() } catch { /* 已断开 */ }
256
+ });
213
257
  req.pipe(up);
214
258
  }
215
259
 
@@ -565,7 +609,16 @@ function createLanProxy(opts) {
565
609
  // 对外只暴露「实际在监听的端口」。配置端口仍是 `lanPort`(那只是首选值)。
566
610
  const port = () => actualPort;
567
611
 
568
- return { start, stop, port };
612
+ return {
613
+ start,
614
+ stop,
615
+ port,
616
+ /**
617
+ * 自检用:此刻活着的上游请求数,以及其中 SSE 的条数。
618
+ * 两个数都该跟着手机连接一起涨落;只涨不落就说明拆除那一环又断了。
619
+ */
620
+ stats: () => ({ upstreamOpen, sseOpen: upstreamSseOpen }),
621
+ };
569
622
  }
570
623
 
571
624
  module.exports = { createLanProxy };
@@ -94,6 +94,14 @@ class RelayTunnel {
94
94
  return !!this.ws && this.ws.readyState === WebSocket.OPEN;
95
95
  }
96
96
 
97
+ /**
98
+ * 中转路上活着的上游 SSE 条数。自检台账(`/api/relay/status` 的 `selfCheck`)读它:
99
+ * 局域网那条路有同名上限,两条路放一起才看得出「上游长流是不是只涨不落」。
100
+ */
101
+ get upstreamSseCount() {
102
+ return this.sseClients.size;
103
+ }
104
+
97
105
  /**
98
106
  * 发送队列积压到上限时**只丢这一帧,不断开隧道**。
99
107
  *
@@ -36,6 +36,9 @@ const readReceipt = require('../read-receipt.cjs');
36
36
  // 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
37
37
  // 「那半边根本没在跑」和「跑了但没清掉」(详见 lib/desktop-half-stats.cjs 的文件头)。
38
38
  const desktopHalf = require('../desktop-half-stats.cjs');
39
+ // 内存自检台账:两个进程的内存、活着的流数、镜像缓存与凭据库规模。口径(「宿主进程」
40
+ // 不等于「插件内存」)与各字段的来源写在 lib/mobile/self-check.cjs 的文件头。
41
+ const { createSelfCheck } = require('../mobile/self-check.cjs');
39
42
  // 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
40
43
  // 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
41
44
  const { checkForUpdate } = require('../update-registry.cjs');
@@ -233,11 +236,28 @@ function registerControlPlaneRoutes(deps) {
233
236
  // 插件 ctx。只给「桌面端说它读过了」那条路由用:它要清掉宿主侧的完成提醒。
234
237
  // 缺省 null 时那条路由降级为「只回 200、什么都不清」。
235
238
  ctx = null,
239
+ // 内存自检台账的取值器(`/api/relay/status` 的 `selfCheck` 字段)。
240
+ // `getSidecarSnapshot` 是 worker 的 `/status` 快照——侧车内存与中转路 SSE 数只有
241
+ // 这一条既有通道;`getLanProxyStats` 是局域网代理的 `stats()`,约定形状
242
+ // `() => ({ upstreamOpen, sseOpen })`。拿不到就报 null(`unavailable` 里说明原因),
243
+ // 不拿 0 顶替:0 是「一条流都没有」,与「不知道」是两件事。
244
+ getSidecarSnapshot = () => null,
245
+ getMirrorPush = () => null,
246
+ getMirrorPushFailures = () => null,
247
+ getLanProxyStats = () => null,
236
248
  // **外壳**暴露的换代服务(`dshMobilePluginRuntime`)。更新的机制住在外壳里,而这几条
237
249
  // 路由住在代里 —— 代被换掉之后服务还在,接口也就跟着活下来。
238
250
  // 缺省返回 null:`lib/impl.js` 单独挂载时(测试 / 老宿主)没有外壳,如实报不可用。
239
251
  getPluginRuntime = () => null,
240
252
  } = deps;
253
+ const selfCheck = createSelfCheck({
254
+ ctx,
255
+ credentials,
256
+ getSidecarSnapshot,
257
+ getMirrorPush,
258
+ getMirrorPushFailures,
259
+ getLanProxyStats,
260
+ });
241
261
  const webAccessWindow = createWebAccessWindow();
242
262
  const localOnly = createWebAccessLocalOnly({
243
263
  localOnly: baseLocalOnly,
@@ -327,7 +347,7 @@ function registerControlPlaneRoutes(deps) {
327
347
  const webAccess = body.enabled ? webAccessWindow.enable() : webAccessWindow.disable();
328
348
  return sendJson(res, 200, { ok: true, webAccess });
329
349
  }) });
330
- registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), webAccess: webAccessWindow.status(), ...mobileActivity() })) })
350
+ registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), webAccess: webAccessWindow.status(), ...mobileActivity(), selfCheck: selfCheck.snapshot() })) })
331
351
  // 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
332
352
  // 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
333
353
  // /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SSE 写帧的唯一实现:单会话流(lib/mobile/stream.js)与设备级流(lib/mobile/events.js)
5
+ * 共用这一份。
6
+ *
7
+ * 为什么要合并:两条流曾经各有一份几乎相同的实现,而 2026-10-17 的宿主 OOM(堆快照里
8
+ * 17.6 万条 SSE 帧、466MB 挂在 `WriteWrap._chunks` 上,七十分钟涨到 3.9G 被杀)先修了
9
+ * 会话流那份,隔一轮才发现设备流那份还漏着 —— 同一个坑修两遍,就是因为实现有两份。
10
+ * 现在「这条连接上到底积压了多少字节」「软/硬水位怎么判」「SSE 记录长什么样」「稳定
11
+ * 事件 id 怎么算」「写不动时怎么等 drain」都只在这里,两条流只在调用点上声明自己的策略。
12
+ *
13
+ * 两条流刻意保留的差异(不是遗漏,改之前先看各自的理由):
14
+ * - 丢帧口径:会话流的软水位只丢**没有 seq** 的增量(`assistant/chunk` 是草稿,同一轮
15
+ * 收尾的 `assistant/message` 带完整正文、重连 `replaySince` 能补齐);设备级流的帧
16
+ * 全是「有东西变了」的幂等信号,软水位一律丢,下一次变更或重连补发会再补上。
17
+ * 由调用方传 `shedWhenBuffered`。
18
+ * - 写不动时:会话流 `await` 一次 drain(20s 超时再收尾);设备级流直接收尾让客户端
19
+ * 重连 —— 它没有 Last-Event-ID 续传,但连上时会 `replayKnownSignals()` 补发当前状态,
20
+ * 所以「断开重连」是它的正常收敛路径。
21
+ * - 填充:会话流每条记录补 2048 字节 SSE 注释(Android RN 的 XHR 会攒小 chunk,
22
+ * 不补就迟迟不触发 onprogress);设备级流的帧很少、单帧也小,不补。
23
+ */
24
+
25
+ const crypto = require('crypto');
26
+
27
+ const SSE_RETRY_MS = 3000;
28
+
29
+ /**
30
+ * 写缓冲的两道水位(字节)。
31
+ *
32
+ * `res.write()` 在客户端读得慢时返回 false,字节留在 Node 的写队列里。两道闸分开,因为
33
+ * 两种帧「丢了能不能补」不一样:软水位只丢可丢弃的帧(见上面丢帧口径),连接继续活着;
34
+ * 硬水位连不可丢的帧都放不下了,就收尾让客户端重连补齐 —— 断流可恢复,堆内存不会。
35
+ */
36
+ const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
37
+ const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
38
+
39
+ /**
40
+ * 等一次 `drain` 的上限。超过这么久还没排空,说明对面不是慢、是真的不读了(手机切
41
+ * 后台、中转卡住),继续等只是把这条连接挂在那里,不如收尾让客户端重连。
42
+ */
43
+ const SSE_DRAIN_TIMEOUT_MS = 20_000;
44
+
45
+ /** 默认丢帧口径:没有 seq 的帧可以丢,有 seq 的不能(丢了不回放就在手机上留个洞)。 */
46
+ const shedWithoutSeq = (payload) => !Number.isFinite(payload && payload.seq);
47
+
48
+ /** 每条记录后面补的 SSE 注释(客户端忽略内容,只用来把小 chunk 撑过 RN 的缓冲阈值)。 */
49
+ function ssePadding(bytes) {
50
+ const size = Number(bytes) > 0 ? Math.floor(Number(bytes)) : 0;
51
+ return size > 0 ? `: ${' '.repeat(size)}` : '';
52
+ }
53
+
54
+ /**
55
+ * 这条连接上真正「还没发出去」的字节数。
56
+ *
57
+ * 只看 `res.writableLength` 是错的:它只算响应对象自己那一层,真正的积压在底下的套接字
58
+ * 写队列里(堆快照里那些 `{chunk, encoding, callback}`)。2026-10-18 复测证实:只看前者
59
+ * 时闸门从不触发,内存照涨。所以顺着 socket 一起看,取较大值。
60
+ */
61
+ function bufferedBytesOf(res) {
62
+ const own = Number(res.writableLength) || 0;
63
+ const socket = res.socket || res.connection;
64
+ const queued = socket ? Number(socket.writableLength) || 0 : 0;
65
+ return Math.max(own, queued);
66
+ }
67
+
68
+ /**
69
+ * SSE 记录的 `id:` 字段:客户端断线重连时用它续传,所以必须对同一帧稳定。
70
+ *
71
+ * `namespace` 只参与「认不出身份时按内容摘要兜底」的那一支:两条流的兜底前缀不同
72
+ * (会话流用 `session`、设备级流用 `device`),改它会改客户端看到的 id 形状。
73
+ */
74
+ function stableEventId(payload, frame, namespace = 'session') {
75
+ const source = payload && payload.type === 'session/event' ? payload : frame && frame.payload;
76
+ if (source && typeof source === 'object') {
77
+ if (source.type === 'session/event' && source.sessionId && source.event && Number.isFinite(source.event.seq)) {
78
+ return `${source.sessionId}:${source.event.seq}`;
79
+ }
80
+ }
81
+ if (payload && typeof payload === 'object') {
82
+ for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
83
+ if (typeof payload[key] === 'string' && payload[key]) return payload[key];
84
+ }
85
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : namespace;
86
+ const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
87
+ return `${sessionId}:${payload.type || 'event'}:${digest}`;
88
+ }
89
+ return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
90
+ }
91
+
92
+ /**
93
+ * 写一条 SSE 记录。
94
+ *
95
+ * @param {object} res 响应对象(真实 http.ServerResponse,或测试里的最小假体)
96
+ * @param {object} payload 要写进 `data:` 的内容
97
+ * @param {object} [options]
98
+ * @param {object} [options.frame] 宿主 mux 帧,只用于算稳定 id
99
+ * @param {string} [options.namespace='session'] 兜底 id 的前缀
100
+ * @param {string} [options.padding=''] 每条记录后补的 SSE 注释行(见 ssePadding)
101
+ * @param {number} [options.retryMs=SSE_RETRY_MS] SSE 重连间隔
102
+ * @param {(payload: object) => boolean} [options.shedWhenBuffered] 超过软水位时这一帧能不能丢
103
+ * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
104
+ * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
105
+ * `shed:true` → 这条帧被丢掉、连接还活着;
106
+ * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
107
+ */
108
+ function writeSse(res, payload, options = {}) {
109
+ const {
110
+ frame = null,
111
+ namespace = 'session',
112
+ padding = '',
113
+ retryMs = SSE_RETRY_MS,
114
+ shedWhenBuffered = shedWithoutSeq,
115
+ } = options;
116
+ const buffered = bufferedBytesOf(res);
117
+ if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
118
+ if (buffered > SSE_SHED_BUFFERED_BYTES && shedWhenBuffered(payload)) return { ok: true, shed: true };
119
+ const id = stableEventId(payload, frame, namespace);
120
+ const fields = [`retry: ${retryMs}`];
121
+ if (id) fields.push(`id: ${id}`);
122
+ fields.push(`data: ${JSON.stringify(payload)}`);
123
+ if (padding) fields.push(padding);
124
+ fields.push('', '');
125
+ try {
126
+ // `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
127
+ // 堆起来。把它当成功吞掉,正是那次 4GB OOM 的直接原因。
128
+ const accepted = res.write(`${fields.join('\n')}\n`);
129
+ return { ok: true, needDrain: accepted === false };
130
+ } catch { return { ok: false }; }
131
+ }
132
+
133
+ /**
134
+ * 等底层把缓冲吐出去(`drain`)。背压的正解:写不动就停手,而不是继续塞。
135
+ *
136
+ * @returns {Promise<boolean>} 真的 drain 了 → true;超时或流被中断 → false(该收尾)。
137
+ */
138
+ function waitForDrain(res, signal, timeoutMs = SSE_DRAIN_TIMEOUT_MS) {
139
+ return new Promise((resolve) => {
140
+ if (signal && signal.aborted) return resolve(false);
141
+ let done = false;
142
+ const cleanup = () => {
143
+ clearTimeout(timer);
144
+ if (typeof res.off === 'function') res.off('drain', onDrain);
145
+ if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
146
+ };
147
+ const finish = (value) => { if (done) return; done = true; cleanup(); resolve(value); };
148
+ const onDrain = () => finish(true);
149
+ const onAbort = () => finish(false);
150
+ const timer = setTimeout(() => finish(false), timeoutMs);
151
+ if (typeof timer.unref === 'function') timer.unref();
152
+ if (typeof res.once === 'function') res.once('drain', onDrain);
153
+ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
154
+ });
155
+ }
156
+
157
+ module.exports = {
158
+ SSE_RETRY_MS,
159
+ SSE_SHED_BUFFERED_BYTES,
160
+ SSE_CLOSE_BUFFERED_BYTES,
161
+ SSE_DRAIN_TIMEOUT_MS,
162
+ ssePadding,
163
+ bufferedBytesOf,
164
+ stableEventId,
165
+ writeSse,
166
+ waitForDrain,
167
+ shedWithoutSeq,
168
+ };
@@ -331,7 +331,14 @@ async function configure(next) {
331
331
  return status()
332
332
  }
333
333
  function status() {
334
- 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 } }
334
+ // 自检台账(`/api/relay/status` 的 `selfCheck`)要的三样东西只有 worker 这边量得到:
335
+ // - `process.memoryUsage`:侧车是**另一个进程**,这是插件唯一能直接量到的自有内存;
336
+ // - `streams.lan`:局域网代理里此刻活着的上游流(手机半开连接攒起来时,只有它只涨不落);
337
+ // - `streams.relaySseOpen`:中转路上活着的上游 SSE 条数。
338
+ // 量不到的一律报 null、不拿 0 顶替:0 是「一条都没有」,与「不知道」是两件事。
339
+ const lanStreams = lan && typeof lan.stats === 'function' ? lan.stats() : null
340
+ const relaySseOpen = tunnel ? (typeof tunnel.upstreamSseCount === 'number' ? tunnel.upstreamSseCount : null) : 0
341
+ 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 }, process: { memoryUsage: process.memoryUsage() }, streams: { lan: lanStreams, relaySseOpen } }
335
342
  }
336
343
  const server = http.createServer(async (req, res) => {
337
344
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.88",
3
+ "version": "0.1.89",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",