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.
Files changed (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * 局域网代理端口的自适应分配。
5
+ *
6
+ * 背景:默认端口 18787 是个「谁都能想到」的数字,于是同一台机器上只要还有别的
7
+ * 服务先占了它(这台开发机就被 `billion-context start --port 18787` 占着),
8
+ * 局域网代理的 `listen` 就会 EADDRINUSE,整条 LAN 直连链路直接死掉——而
9
+ * `/api/relay/status` 只会说 `lan.state='error'`,用户看到的现象是「手机配不上对」,
10
+ * 和真正的原因(端口被占)隔了十万八千里。
11
+ *
12
+ * 这里把「挑端口」从配置里解耦出来:先探测首选端口,空闲就用它;被占就让内核
13
+ * 分配一个空闲端口,并把实际端口回传(由调用方写进状态、状态接口和配对二维码,
14
+ * 否则二维码里带的会是那个根本没在监听的死端口)。
15
+ */
16
+
17
+ const net = require('node:net')
18
+
19
+ /** 试绑一次端口确认它可用。占用/权限等一切失败都算「不可用」。 */
20
+ function canListen(port, host) {
21
+ return new Promise(resolve => {
22
+ const probe = net.createServer()
23
+ probe.unref()
24
+ probe.once('error', () => resolve(false))
25
+ probe.once('listening', () => probe.close(() => resolve(true)))
26
+ try {
27
+ probe.listen(port, host)
28
+ } catch {
29
+ resolve(false)
30
+ }
31
+ })
32
+ }
33
+
34
+ /** 让内核分配一个当前空闲的端口(bind(0) 后立刻释放)。 */
35
+ function freePort(host) {
36
+ return new Promise(resolve => {
37
+ const probe = net.createServer()
38
+ probe.unref()
39
+ probe.once('error', () => resolve(0))
40
+ probe.listen(0, host, () => {
41
+ const address = probe.address()
42
+ const port = address && typeof address.port === 'number' ? address.port : 0
43
+ probe.close(() => resolve(port))
44
+ })
45
+ })
46
+ }
47
+
48
+ /**
49
+ * 解析出本次真正要监听的端口。
50
+ * @returns {Promise<{port: number, adjusted: boolean, requested: number}>}
51
+ * `adjusted=true` 表示首选端口被占、换了一个。
52
+ */
53
+ async function resolvePort(requested, host = '0.0.0.0') {
54
+ const wanted = Number.isInteger(requested) && requested >= 1024 && requested <= 65535 ? requested : 18787
55
+ if (await canListen(wanted, host)) return { port: wanted, adjusted: false, requested: wanted }
56
+ const port = await freePort(host)
57
+ return { port: port > 0 ? port : wanted, adjusted: true, requested: wanted }
58
+ }
59
+
60
+ module.exports = { canListen, freePort, resolvePort }
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 预览字节管道(宿主侧)。
5
+ *
6
+ * 手机 App 的内置浏览器要能看到桌面机上「开发中的网页」,而那些 dev server 只监听
7
+ * 宿主机的 127.0.0.1:<port>。手机、中转、局域网代理都够不着它,所以链路上必须有一
8
+ * 段跑在宿主进程里的搬运工——就是这里。
9
+ *
10
+ * 为什么是**字节管道**而不是又一个 HTTP 代理:
11
+ * 一个页面不是「一次请求」而是「几百次请求」——ES 模块、CSS、图片、XHR、还有 HMR
12
+ * 那条活着的 WebSocket。浏览器引擎自己发这些请求,App 侧的 JS 拦不住。所以端口只在
13
+ * 建立管道时协商一次,之后这管道对内容一无所知,只搬字节。
14
+ *
15
+ * 这么做的收益是结构性的:内置浏览器地址栏里是 `http://localhost:3001/`,宿主机上
16
+ * 的 dev server 看到的 Host 也是 `localhost:3001`——两边对「我在哪」的认知完全一致。
17
+ * 于是 dev server 生成的一切绝对地址(资源路径、接口地址、跳转 Location、HMR 连接
18
+ * 地址)都自动闭环,**dev server 零配置**。反过来说,如果这里做半吊子的路径改写,
19
+ * `/assets/app.js` 这类绝对路径立刻全 404。
20
+ *
21
+ * 这一层刻意「不认识」HTTP 与 WebSocket:
22
+ *
23
+ * - 手机侧的原生模块把 WebView 写下的原始字节塞进 WebSocket 的二进制帧;
24
+ * - 这里 `net.connect(<环回地址>, port)` 之后双向对搬。环回地址要**双栈**试:macOS 上
25
+ * 不少 dev server(Vite 等)默认只监听 `::1`,只拨 `127.0.0.1` 会连不上;
26
+ * - 中间任何一层都不解析、不改写,所以 WS 升级、SSE、分块传输、自定义子协议全都
27
+ * 原样穿过(HMR 不用砍也不用绕——浏览器和 Vite 自己完成握手与分帧)。
28
+ *
29
+ * 代价要说清楚:正因为宿主无法审查内容,**端口就是这里唯一的边界**,所以下面有
30
+ * `protectedPorts()` 那道按身份的拒绝(DSH 自身的 web 端口永远不许从预览进来)。
31
+ *
32
+ * 与仓库里 `dsh-mobile-server/scripts/adb-ws-bridge.cjs` 是同一个套路(原始 TCP 流包进 WebSocket 帧),
33
+ * 那个先例证明这条路在本项目里走得通。
34
+ */
35
+
36
+ const net = require('node:net');
37
+ const { WebSocketServer } = require('ws');
38
+
39
+ /**
40
+ * 同一个宿主上同时在跑的预览连接上限。
41
+ *
42
+ * 浏览器对单个源本就有 6 条左右的并发连接限制(HTTP 与 WebSocket 各算),所以一个
43
+ * 正常页面根本到不了这个数;这个上限只是防止某个页面失控时把宿主的 fd 吃光。
44
+ */
45
+ const MAX_PREVIEW_STREAMS = 32;
46
+
47
+ /** 单条 WS 消息上限,与中转侧 MAX_WS_MESSAGE_BYTES / MAX_FRAME_BYTES 对齐。 */
48
+ const MAX_PREVIEW_MESSAGE_BYTES = 8 * 1024 * 1024;
49
+
50
+ /** 往手机方向的水位:缓冲超了就把 dev server 那头按住,落下来再放开。 */
51
+ const SEND_HIGH_WATER_BYTES = 4 * 1024 * 1024;
52
+ const SEND_LOW_WATER_BYTES = 1 * 1024 * 1024;
53
+ const SEND_RESUME_POLL_MS = 20;
54
+
55
+ /** 连上游的一发超时:只覆盖「还没握上」这一段;握上之后是长连接,不能有读超时。 */
56
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 10_000;
57
+
58
+ /**
59
+ * WS 握手已成、但上游 `net.connect` 还没连上的那几百微秒里,手机已经发出来的字节
60
+ * 最多攒这么多。浏览器不会等我们连好上游才发请求,所以这段字节必须存住;攒过头说明
61
+ * 上游根本连不上,这时宁可断掉这条管道,也不能无限吃内存。
62
+ */
63
+ const MAX_PREVIEW_PENDING_BYTES = 1024 * 1024;
64
+
65
+ /**
66
+ * 从升级请求的查询串里取 port。
67
+ *
68
+ * 中转的升级分发(`server.js` 的 `server.on('upgrade')`)不看路径,只做鉴权后把
69
+ * `req.url` 原样交给隧道;宿主的 webServer 又只按 pathname 匹配升级路由、忽略查询串
70
+ * ——所以端口只能这么带,也只能这么读。
71
+ *
72
+ * @returns {number|null} 合法端口,或 null(缺参数、非数字、越界都算非法)
73
+ */
74
+ function parsePreviewPort(req) {
75
+ let params;
76
+ try {
77
+ params = new URL(req && req.url ? req.url : '/', 'http://preview.invalid').searchParams;
78
+ } catch {
79
+ return null;
80
+ }
81
+ const raw = params.get('port');
82
+ if (raw === null || !/^[0-9]{1,5}$/.test(raw)) return null;
83
+ const port = Number(raw);
84
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
85
+ return port;
86
+ }
87
+
88
+ const HTML_HEAD = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
89
+ + '<meta name="viewport" content="width=device-width,initial-scale=1">'
90
+ + '<title>预览未建立</title><style>'
91
+ + 'body{margin:0;padding:28px 20px;font:15px/1.7 -apple-system,"PingFang SC","Noto Sans CJK SC",sans-serif;'
92
+ + 'background:#14161a;color:#e6e8ec}'
93
+ + 'h1{margin:0 0 10px;font-size:17px;font-weight:600}'
94
+ + 'p{margin:0 0 10px;color:#a9b0bb}'
95
+ + 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#f0b849}'
96
+ + '</style></head><body>';
97
+
98
+ /**
99
+ * 失败页。由**宿主**生成再借这条字节管道发给内置浏览器——手机原生模块只是个哑管道,
100
+ * 它没有能力编出人话;而原因只有宿主知道(端口没人监听?被拒?)。
101
+ */
102
+ function renderFailureHtml({ title, detail, port }) {
103
+ return `${HTML_HEAD}<h1>${title}</h1><p>${detail}</p>`
104
+ + `<p>端口:<code>${port}</code></p>`
105
+ + '</body></html>';
106
+ }
107
+
108
+ /** 升级**之前**的拒绝:握手还没成,所以按普通 HTTP 应答,对方的 WS 客户端会看到状态码。 */
109
+ function refuseUpgrade(socket, status, reason, text) {
110
+ if (!socket || socket.destroyed) return;
111
+ const body = Buffer.from(renderFailureHtml({ title: text, detail: '', port: '' }), 'utf8');
112
+ socket.write(
113
+ `HTTP/1.1 ${status} ${reason}\r\n`
114
+ + 'Content-Type: text/html; charset=utf-8\r\n'
115
+ + `Content-Length: ${body.length}\r\n`
116
+ + 'Connection: close\r\n\r\n',
117
+ );
118
+ socket.end(body);
119
+ }
120
+
121
+ /**
122
+ * @param {object} deps
123
+ * @param {(req: object) => boolean} deps.authorized 移动端鉴权(lib/mobile/auth.js 的 authorized)
124
+ * @param {() => Set<number>} deps.protectedPorts DSH 自身占用的端口,永远不许从预览进来
125
+ * @param {(message: string) => void} [deps.onLog]
126
+ */
127
+ function createPreviewService({ authorized, protectedPorts, onLog }) {
128
+ const wss = new WebSocketServer({
129
+ noServer: true,
130
+ perMessageDeflate: false,
131
+ maxPayload: MAX_PREVIEW_MESSAGE_BYTES,
132
+ });
133
+
134
+ /**
135
+ * 走过这道门的端口。这张表是「预览到底碰过什么」的唯一记录,内存态、随宿主重启清空
136
+ * ——端口本身不是长期授权,是一次使用留下的痕迹,用于手机端展示与排查。
137
+ */
138
+ const grants = new Map();
139
+ let activeStreams = 0;
140
+ let closed = false;
141
+
142
+ const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不该影响搬运 */ } };
143
+
144
+ function noteGrant(port) {
145
+ const now = Date.now();
146
+ const grant = grants.get(port);
147
+ if (grant) {
148
+ grant.streams += 1;
149
+ grant.lastUsedAt = now;
150
+ } else {
151
+ grants.set(port, { port, firstUsedAt: now, lastUsedAt: now, streams: 1 });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 一条管道:完成 WebSocket 握手,然后把它与 `127.0.0.1:<port>` 对搬。
157
+ */
158
+ function openPipe(ws, port) {
159
+ activeStreams += 1;
160
+ noteGrant(port);
161
+
162
+ let upstream = null;
163
+ let finished = false;
164
+ let resumeTimer = null;
165
+ let upstreamReady = false;
166
+ let pendingBytes = 0;
167
+ /** 上游还没连上时手机先发过来的字节,见 MAX_PREVIEW_PENDING_BYTES。 */
168
+ const pending = [];
169
+
170
+ const finish = () => {
171
+ if (finished) return;
172
+ finished = true;
173
+ activeStreams -= 1;
174
+ if (resumeTimer) { clearInterval(resumeTimer); resumeTimer = null; }
175
+ pending.length = 0;
176
+ pendingBytes = 0;
177
+ try { ws.close(); } catch { /* 可能已经关了 */ }
178
+ if (upstream && !upstream.destroyed) upstream.destroy();
179
+ };
180
+
181
+ /** 手机 → dev server;`write` 返回 false 表示内核缓冲满了,此时按住 WS 的读端。 */
182
+ const writeToUpstream = (chunk) => {
183
+ if (finished || !upstream || upstream.destroyed) return;
184
+ if (!upstream.write(chunk)) ws.pause();
185
+ };
186
+
187
+ /** 发往手机方向的水位控制:`ws` 没有 drain 事件,只能轮询 bufferedAmount。 */
188
+ const startResumePoll = () => {
189
+ if (resumeTimer || finished) return;
190
+ resumeTimer = setInterval(() => {
191
+ if (finished || ws.readyState !== ws.OPEN) {
192
+ clearInterval(resumeTimer); resumeTimer = null; return;
193
+ }
194
+ if (ws.bufferedAmount <= SEND_LOW_WATER_BYTES) {
195
+ clearInterval(resumeTimer); resumeTimer = null;
196
+ if (upstream && !upstream.destroyed) upstream.resume();
197
+ }
198
+ }, SEND_RESUME_POLL_MS);
199
+ if (typeof resumeTimer.unref === 'function') resumeTimer.unref();
200
+ };
201
+
202
+ // 这两个监听必须**立刻**挂上,不能等 `upstream.on('connect')`:手机在 WS 握手一
203
+ // 完成就会把 HTTP 请求字节发出来,而上游 TCP 往往还在路上。挂晚了这几百微秒内的
204
+ // 字节会被直接丢掉——表现就是「页面时好时坏、偶尔整页空白」,且只在真机上偶发。
205
+ ws.on('message', (data) => {
206
+ if (finished) return;
207
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
208
+ if (chunk.length === 0) return;
209
+ if (upstreamReady) { writeToUpstream(chunk); return; }
210
+ pending.push(chunk);
211
+ pendingBytes += chunk.length;
212
+ if (pendingBytes > MAX_PREVIEW_PENDING_BYTES) finish();
213
+ });
214
+ ws.on('close', finish);
215
+ ws.on('error', finish);
216
+
217
+ // 上游必须**双栈**拨号:macOS 上 Vite 之类的 dev server 默认只监听 `::1`,只连
218
+ // `127.0.0.1` 必然是 ECONNREFUSED,用户看到的就是「电脑上这个端口没有服务」。
219
+ // 两个地址都是环回,谁在听就用谁;只有在前一个「还没连上就失败」时才换下一个。
220
+ const upstreamHosts = ['127.0.0.1', '::1'];
221
+ let hostIndex = 0;
222
+ let connectTimer = null;
223
+ let lastError = 'ECONNREFUSED';
224
+
225
+ const clearConnectTimer = () => {
226
+ if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; }
227
+ };
228
+
229
+ /** 两个环回地址都连不上:握手已经成了,没法再用 HTTP 状态码,只能借管道送失败页。 */
230
+ const failUpstream = () => {
231
+ log(`preview port ${port}: ${lastError}`);
232
+ // 这条管道本来就是透明的,索性由宿主写一段 502 响应借它送进内置浏览器——比让
233
+ // WebView 显示系统错误页清楚得多。
234
+ if (ws.readyState === ws.OPEN) {
235
+ const body = Buffer.from(renderFailureHtml({
236
+ title: '电脑上这个端口没有服务',
237
+ detail: lastError === 'ECONNREFUSED'
238
+ ? '开发服务可能已经停了。回到 DSH 重新启动它,再刷新这个页面。'
239
+ : `宿主连不上 127.0.0.1:${port},也连不上 [::1]:${port}(${lastError})。`,
240
+ port,
241
+ }), 'utf8');
242
+ const head = Buffer.from(
243
+ 'HTTP/1.1 502 Bad Gateway\r\n'
244
+ + 'Content-Type: text/html; charset=utf-8\r\n'
245
+ + `Content-Length: ${body.length}\r\n`
246
+ + 'Connection: close\r\n\r\n',
247
+ 'utf8',
248
+ );
249
+ try { ws.send(Buffer.concat([head, body]), { binary: true }); } catch { /* 已经断了 */ }
250
+ }
251
+ finish();
252
+ };
253
+
254
+ /** 换下一个环回地址;都试过了就按失败收场。 */
255
+ const advanceHost = () => {
256
+ if (finished) return;
257
+ hostIndex += 1;
258
+ if (hostIndex < upstreamHosts.length) { dialUpstream(); return; }
259
+ failUpstream();
260
+ };
261
+
262
+ function dialUpstream() {
263
+ const host = upstreamHosts[hostIndex];
264
+ const socketToUpstream = net.connect({ host, port });
265
+ upstream = socketToUpstream;
266
+
267
+ // 握上手之前给一发超时;连上之后这条 TCP 就是长连接(SSE、HMR 都从它过),
268
+ // 不能再有任何读超时。
269
+ connectTimer = setTimeout(() => {
270
+ connectTimer = null;
271
+ if (finished || upstreamReady || socketToUpstream !== upstream) return;
272
+ lastError = 'ETIMEDOUT';
273
+ log(`preview port ${port}: ${host} 连接超时`);
274
+ try { socketToUpstream.destroy(); } catch { /* 可能已经断了 */ }
275
+ advanceHost();
276
+ }, UPSTREAM_CONNECT_TIMEOUT_MS);
277
+ if (typeof connectTimer.unref === 'function') connectTimer.unref();
278
+
279
+ socketToUpstream.on('error', (error) => {
280
+ if (finished || socketToUpstream !== upstream) return;
281
+ lastError = (error && error.code) || (error && error.message) || 'unknown';
282
+ clearConnectTimer();
283
+ if (!upstreamReady) {
284
+ log(`preview port ${port}: ${host} 连不上(${lastError})`);
285
+ advanceHost();
286
+ return;
287
+ }
288
+ failUpstream();
289
+ });
290
+
291
+ socketToUpstream.on('connect', () => {
292
+ clearConnectTimer();
293
+ log(`preview port ${port}: 管道已接通(${host})`);
294
+ upstreamReady = true;
295
+
296
+ socketToUpstream.on('drain', () => { if (!finished && ws.readyState === ws.OPEN) ws.resume(); });
297
+
298
+ // dev server → 手机。
299
+ socketToUpstream.on('data', (chunk) => {
300
+ if (finished || ws.readyState !== ws.OPEN) return;
301
+ if (ws.bufferedAmount > SEND_HIGH_WATER_BYTES) {
302
+ socketToUpstream.pause();
303
+ startResumePoll();
304
+ }
305
+ ws.send(chunk, { binary: true });
306
+ });
307
+
308
+ socketToUpstream.on('close', finish);
309
+
310
+ // 冲掉「连上之前」攒下的字节(顺序不变)。
311
+ for (const chunk of pending) {
312
+ if (finished) break;
313
+ writeToUpstream(chunk);
314
+ }
315
+ pending.length = 0;
316
+ pendingBytes = 0;
317
+ });
318
+ }
319
+
320
+ dialUpstream();
321
+ }
322
+
323
+ /**
324
+ * 宿主 webServer 的升级处理器(`ctx.webServer.registerUpgrade`)。
325
+ *
326
+ * 拒绝一定要发生在 `handleUpgrade` **之前**:那时 socket 还是一条普通 HTTP 连接,
327
+ * 可以老实回 401/403;一旦完成握手,就只能靠管道里塞失败页了。
328
+ */
329
+ function handleUpgrade(req, socket, head) {
330
+ if (closed) return refuseUpgrade(socket, 503, 'Service Unavailable', '预览管道已关闭');
331
+
332
+ const port = parsePreviewPort(req);
333
+ if (port === null) return refuseUpgrade(socket, 400, 'Bad Request', '缺少或非法的 port 参数');
334
+
335
+ let allowed = false;
336
+ try { allowed = Boolean(authorized(req)); } catch { allowed = false; }
337
+ if (!allowed) return refuseUpgrade(socket, 401, 'Unauthorized', '移动端凭据无效');
338
+
339
+ // DSH 自身的端口按**身份**拒绝,不是按数字拉黑。3080 是宿主 web 服务的实际端口
340
+ // (`ctx.webServer.port`,可能被配置改过),从预览进来等于把桌面端 GUI/API 搬到了
341
+ // 手机内置浏览器里——那条路刻意不做,所以在这里用同一个真值掐掉。
342
+ if (protectedPorts().has(port)) {
343
+ return refuseUpgrade(socket, 403, 'Forbidden', `端口 ${port} 属于 DSH 自身,不允许预览`);
344
+ }
345
+
346
+ if (activeStreams >= MAX_PREVIEW_STREAMS) {
347
+ return refuseUpgrade(socket, 503, 'Service Unavailable', '预览连接数已达上限');
348
+ }
349
+
350
+ wss.handleUpgrade(req, socket, head, (ws) => openPipe(ws, port));
351
+ }
352
+
353
+ return {
354
+ handleUpgrade,
355
+ /** 「预览碰过哪些端口」的快照,供状态接口展示。 */
356
+ grants: () => [...grants.values()].sort((a, b) => a.port - b.port).map((grant) => ({ ...grant })),
357
+ stats: () => ({ activeStreams, maxStreams: MAX_PREVIEW_STREAMS }),
358
+ close: () => {
359
+ closed = true;
360
+ for (const client of wss.clients) {
361
+ try { client.terminate(); } catch { /* 已经断了 */ }
362
+ }
363
+ wss.close();
364
+ },
365
+ };
366
+ }
367
+
368
+ module.exports = {
369
+ MAX_PREVIEW_STREAMS,
370
+ MAX_PREVIEW_MESSAGE_BYTES,
371
+ createPreviewService,
372
+ parsePreviewPort,
373
+ renderFailureHtml,
374
+ };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dsh-mobile-server 隧道协议 v2 编解码(取代 v1 的 JSON/base64 文本帧)。
5
+ *
6
+ * 每条隧道消息 = 一个 WS binary 帧:
7
+ * [type: 1B][id: 4B 大端][length: 4B 大端][payload: length B]
8
+ *
9
+ * - 数据通道(httpBody / httpChunk / wsText / wsBinary)的 payload 为
10
+ * 原始字节,不再 base64(消除 ~33% 体积膨胀);
11
+ * - 控制消息(register / httpHead 等)payload 为 UTF-8 JSON;
12
+ * - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
13
+ *
14
+ * 协议语义见 ../../../dsh-mobile-server/PROTOCOL.md(v2 章节)。
15
+ *
16
+ * 本文件是 dsh-mobile-server/lib/protocol.js 的自包含副本
17
+ * (插件包不能跨目录引用中转仓库的文件),唯一允许的差异是上面那行路径注释。
18
+ * 改动权威实现的 T 表后,必须同步修改本文件。
19
+ */
20
+
21
+ const T = {
22
+ register: 0x01, // id=0 JSON {instanceId, pairingCode, name, lanUrls?, lanCandidates?}
23
+ registered: 0x02, // id=0 JSON {}
24
+ updatePairing: 0x03, // id=0 JSON {pairingCode}
25
+ httpRequest: 0x04, // id=N JSON {method, path, headers};body 随后以 httpBody 流式发送
26
+ httpBody: 0x05, // id=N raw bytes(空 payload = body 结束)
27
+ httpHead: 0x06, // id=N JSON {status, headers}
28
+ httpChunk: 0x07, // id=N raw bytes
29
+ httpEnd: 0x08, // id=N 空
30
+ httpError: 0x09, // id=N JSON {message}
31
+ wsUpgrade: 0x0A, // id=N JSON {path, headers}
32
+ wsOpen: 0x0B, // id=N 空
33
+ wsError: 0x0C, // id=N JSON {message}
34
+ wsText: 0x0D, // id=N raw bytes(上游 WS 文本帧数据)
35
+ wsBinary: 0x0E, // id=N raw bytes(上游 WS 二进制帧数据)
36
+ wsClose: 0x0F, // id=N 2B 大端 close code
37
+ ping: 0x10, // id=0 空
38
+ pong: 0x11, // id=0 空
39
+ pairRequest: 0x12, // id=0 JSON {requestId, device:{id?, deviceId?, name?, platform?, osVersion?, appVersion?, ip?}}(手机配对 → 请求桌面授权;ip 由中转注入)
40
+ pairConfirm: 0x13, // id=0 JSON {requestId, approved, token?}(桌面授权结果 → 中转 → 手机)
41
+ httpCancel: 0x14, // id=N 空;取消上游 HTTP 请求/响应
42
+ httpPause: 0x15, // id=N 空;暂停向中转发送响应数据
43
+ httpResume: 0x16, // id=N 空;恢复向中转发送响应数据
44
+ sync: 0x17, // id=0 JSON {kind, ...};桌面→中转主动推送(移动端镜像缓存)
45
+ revokeAll: 0x18, // id=0 JSON {at};桌面请求中转清空本实例全部设备授权
46
+ revokeAllAck: 0x19, // id=0 JSON {ok, revoked};中转确认已清空
47
+ };
48
+
49
+ const TYPE_NAME = {};
50
+ for (const [name, code] of Object.entries(T)) TYPE_NAME[code] = name;
51
+
52
+ const HEADER_LEN = 9;
53
+
54
+ /** 编码一帧;字符串/JSON 直接写入最终帧,避免中间 Buffer 与整段复制。 */
55
+ function encodeFrame(type, id, payload) {
56
+ let body = null;
57
+ let text = null;
58
+ if (Buffer.isBuffer(payload)) {
59
+ body = payload;
60
+ } else if (typeof payload === 'string') {
61
+ text = payload;
62
+ } else if (payload !== undefined && payload !== null) {
63
+ text = JSON.stringify(payload);
64
+ }
65
+ const bodyLength = body ? body.length : (text === null ? 0 : Buffer.byteLength(text, 'utf8'));
66
+ const buf = Buffer.allocUnsafe(HEADER_LEN + bodyLength);
67
+ buf.writeUInt8(type, 0);
68
+ buf.writeUInt32BE(id >>> 0, 1);
69
+ buf.writeUInt32BE(bodyLength, 5);
70
+ if (body) body.copy(buf, HEADER_LEN);
71
+ else if (bodyLength) buf.write(text, HEADER_LEN, bodyLength, 'utf8');
72
+ return buf;
73
+ }
74
+
75
+ /** JSON 帧编码助手。 */
76
+ function jsonFrame(type, id, obj) {
77
+ return encodeFrame(type, id, JSON.stringify(obj));
78
+ }
79
+
80
+ /**
81
+ * 创建流式帧解码器:WS 消息可能包含多帧或半帧(send 大 buffer 时 TCP 层
82
+ * 分片,ws 库会重组为一个 message;此处仍做拼接防御)。
83
+ * 用法:const push = createFrameDecoder(onFrame); push(chunk);
84
+ */
85
+ function createFrameDecoder(onFrame, options = {}) {
86
+ const maxFrameBytes = options.maxFrameBytes == null ? 64 * 1024 * 1024 : options.maxFrameBytes;
87
+ const onError = options.onError || (() => {});
88
+ let buf = Buffer.alloc(0);
89
+ return function push(chunk) {
90
+ if (!Buffer.isBuffer(chunk)) chunk = Buffer.from(chunk);
91
+ if (chunk.length > maxFrameBytes + HEADER_LEN || buf.length + chunk.length > maxFrameBytes + HEADER_LEN) {
92
+ onError(new Error('frame too large'));
93
+ buf = Buffer.alloc(0);
94
+ return false;
95
+ }
96
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
97
+ while (buf.length >= HEADER_LEN) {
98
+ const type = buf.readUInt8(0);
99
+ const id = buf.readUInt32BE(1);
100
+ const len = buf.readUInt32BE(5);
101
+ if (len > maxFrameBytes) {
102
+ onError(new Error('frame too large'));
103
+ buf = Buffer.alloc(0);
104
+ return false;
105
+ }
106
+ if (buf.length < HEADER_LEN + len) break;
107
+ const payload = buf.subarray(HEADER_LEN, HEADER_LEN + len);
108
+ buf = buf.subarray(HEADER_LEN + len);
109
+ onFrame({ type, id, payload });
110
+ }
111
+ };
112
+ }
113
+
114
+ /** 解析控制帧 payload 为对象(失败返回空对象)。 */
115
+ function parseJson(buf) {
116
+ try {
117
+ return JSON.parse(buf.toString('utf8'));
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ /** 按字节截断字符串,且不切断 UTF-8 码点(ws 会拒绝非法 reason)。 */
124
+ function truncateUtf8(text, maxBytes) {
125
+ const bytes = Buffer.from(text, 'utf8');
126
+ let end = Math.min(maxBytes, bytes.length);
127
+ while (end > 0) {
128
+ const last = bytes[end - 1];
129
+ if ((last & 0x80) === 0) return bytes.subarray(0, end);
130
+ let start = end - 1;
131
+ while (start > 0 && (bytes[start] & 0xc0) === 0x80) start -= 1;
132
+ const lead = bytes[start];
133
+ const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
134
+ if ((lead & 0xc0) !== 0x80 && start + width <= end) return bytes.subarray(0, end);
135
+ end = start;
136
+ }
137
+ return Buffer.alloc(0);
138
+ }
139
+
140
+ // WS 关闭帧:reason 上限 123 字节(RFC 6455 控制帧 125 字节减去 2 字节 code)。
141
+ const MAX_CLOSE_REASON_BYTES = 123;
142
+ // 1005/1006/1015 是保留值,不得出现在线上;1004 语义不明,ws 同样拒绝。
143
+ const RESERVED_CLOSE_CODES = [1004, 1005, 1006, 1015];
144
+ /** close code 是否可出现在线上(ws 对保留值/越界值会直接抛错)。 */
145
+ function validCloseCode(code) {
146
+ return Number.isInteger(code) && code >= 1000 && code <= 4999 && !RESERVED_CLOSE_CODES.includes(code);
147
+ }
148
+ /** 编码 wsClose payload:2B 大端 close code + 不切断码点的 UTF-8 reason。
149
+ * reason 可能是 string,也可能是 ws 'close' 事件给的 Buffer。Buffer 不需要单独
150
+ * 分支:String(buf) 走的是 Buffer.prototype.toString(),本身就按 UTF-8 解码
151
+ * ('<Buffer e5 86 8d …>' 只是 util.inspect / console.log 的显示形式,不是
152
+ * String() 的结果)。 */
153
+ function encodeClosePayload(code, reason) {
154
+ const c = validCloseCode(code) ? code : 1000;
155
+ return Buffer.concat([Buffer.from([(c >> 8) & 255, c & 255]), truncateUtf8(String(reason || ''), MAX_CLOSE_REASON_BYTES)]);
156
+ }
157
+ /** 解码 wsClose payload。code 可直接交给 ws.close();raw 是线上原值(供日志)。 */
158
+ function decodeClosePayload(payload) {
159
+ const raw = payload && payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
160
+ const ok = validCloseCode(raw);
161
+ const reason = payload && payload.length > 2 ? truncateUtf8(payload.subarray(2), MAX_CLOSE_REASON_BYTES).toString('utf8') : '';
162
+ return { code: ok ? raw : 1000, raw, validCode: ok, reason };
163
+ }
164
+
165
+ module.exports = {
166
+ T,
167
+ TYPE_NAME,
168
+ HEADER_LEN,
169
+ encodeFrame,
170
+ jsonFrame,
171
+ createFrameDecoder,
172
+ parseJson,
173
+ truncateUtf8,
174
+ encodeClosePayload,
175
+ decodeClosePayload,
176
+ MAX_CLOSE_REASON_BYTES,
177
+ };