dsh-pocket 1.12.0 → 1.12.2
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.en.md +1 -1
- package/README.md +1 -1
- package/lib/proxy.mjs +61 -2
- package/lib/tunnel.mjs +7 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -46,7 +46,7 @@ What it looks like — the phone shows the exact same UI as your computer, live:
|
|
|
46
46
|
| 🔐 Access PIN | Public links require an **8-digit PIN** (rotated on every tunnel start by default; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-digit PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
|
|
47
47
|
| 🔑 Custom PINs | Both the public and LAN PINs can be **set to your own fixed 8-digit number** in Settings (custom PINs are never auto-rotated) |
|
|
48
48
|
| 🧘 Session persistence | Enter the PIN once and you're set for a long time (login is tied to the computer's dsh web process: as long as it stays up, the phone won't ask again; **after a dsh web restart/update, enter it once more**) |
|
|
49
|
-
| ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways |
|
|
49
|
+
| ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways; built-in WS heartbeat keep-alive (defeats silent NAT/battery link drops with auto-reconnect) |
|
|
50
50
|
| 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
|
|
51
51
|
| 🗜️ Transfer compression | Large JSON responses are gzip/brotli'd on the fly (17MB session history → ~1MB; brotli quality 6: fast and bandwidth-friendly) — faster loads, less mobile data |
|
|
52
52
|
| 🔁 Tunnel auto-restore | After a DSH restart the previously-running public tunnel comes back automatically |
|
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
|
|
|
46
46
|
| 🔐 访问密码 | 公网链接需输入 **8 位数字密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位数字密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
|
|
47
47
|
| 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位数字**(自定义后公网不再自动换新) |
|
|
48
48
|
| 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
|
|
49
|
-
| ⚡ 实时同步 | 流式输出走 WebSocket
|
|
49
|
+
| ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) |
|
|
50
50
|
| 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
|
|
51
51
|
| 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
|
|
52
52
|
| 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 |
|
package/lib/proxy.mjs
CHANGED
|
@@ -209,6 +209,62 @@ function loopbackAuthority(headers, upstream) {
|
|
|
209
209
|
return headers;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
|
|
213
|
+
// DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
|
|
214
|
+
// 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
|
|
215
|
+
// 丢弃:没有 FIN/RST,浏览器收不到 close 事件,dsh-client-connection 也就
|
|
216
|
+
// 永远不会重连——手机页面看起来还开着,实则实时通道已死(消息不同步、
|
|
217
|
+
// 点击会话卡在加载)。
|
|
218
|
+
//
|
|
219
|
+
// 代理在每个透传的 WS 连接上定期向浏览器侧发送协议层 Ping(0x89 0x00,
|
|
220
|
+
// server→client 不掩码):
|
|
221
|
+
// - 浏览器网络栈按 RFC 6455 自动回 Pong(不经过任何 JS),一来一回让
|
|
222
|
+
// 双向都有流量,NAT/防火墙空闲超时不再触发;
|
|
223
|
+
// - 连续 missLimit 个周期没有任何入站字节(浏览器已死或链路被静默丢弃)
|
|
224
|
+
// → 主动 destroy 连接:浏览器拿到 close 后 dsh-client-connection 会
|
|
225
|
+
// 按指数退避自动重连,实时通道随即恢复。
|
|
226
|
+
// 只 Ping 浏览器侧:上游是本机 loopback,不会过期;浏览器回的 Pong 原样
|
|
227
|
+
// 透传给上游 ws 服务(未请求的 Pong 对 ws 库无害,只触发无害的 pong 事件)。
|
|
228
|
+
const WS_PING_FRAME = Buffer.from([0x89, 0x00]); // FIN + opcode 9、长度 0、不掩码
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 在透传的浏览器侧 socket 上挂载心跳:定期 Ping 保活 + 静默断链检测。
|
|
232
|
+
* 任一路由方向只要有字节流动(Pong 响应)就把静默计数归零;连续 missLimit
|
|
233
|
+
* 个周期零入站流量则判定链路已死,销毁 socket 触发浏览器端重连。
|
|
234
|
+
* @param {import('node:net').Socket} socket 浏览器侧的透传 socket
|
|
235
|
+
* @param {{intervalMs?:number, missLimit?:number}} [opts] 心跳周期与容忍的静默周期数
|
|
236
|
+
*/
|
|
237
|
+
function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 } = {}) {
|
|
238
|
+
let misses = 0;
|
|
239
|
+
let stopped = false;
|
|
240
|
+
const onInbound = () => { misses = 0; };
|
|
241
|
+
const timer = setInterval(() => {
|
|
242
|
+
if (stopped) return;
|
|
243
|
+
misses += 1;
|
|
244
|
+
if (misses >= missLimit) {
|
|
245
|
+
// 连续多个周期没有任何入站流量(连 Pong 都没有)→ 静默断链,断开让客户端重连
|
|
246
|
+
socket.destroy();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// write 到已销毁的 socket 会抛错(destroy 竞态),写前检查并兜底
|
|
250
|
+
if (!socket.destroyed) {
|
|
251
|
+
try { socket.write(WS_PING_FRAME); } catch { /* 忽略 */ }
|
|
252
|
+
}
|
|
253
|
+
}, intervalMs);
|
|
254
|
+
timer.unref?.();
|
|
255
|
+
socket.on('data', onInbound);
|
|
256
|
+
const cleanup = () => {
|
|
257
|
+
if (stopped) return;
|
|
258
|
+
stopped = true;
|
|
259
|
+
clearInterval(timer);
|
|
260
|
+
socket.off('data', onInbound);
|
|
261
|
+
socket.off('close', cleanup);
|
|
262
|
+
socket.off('error', cleanup);
|
|
263
|
+
};
|
|
264
|
+
socket.on('close', cleanup);
|
|
265
|
+
socket.on('error', cleanup);
|
|
266
|
+
}
|
|
267
|
+
|
|
212
268
|
/**
|
|
213
269
|
* 启动 dsh-pocket 代理。
|
|
214
270
|
* @param {object} opts
|
|
@@ -217,10 +273,11 @@ function loopbackAuthority(headers, upstream) {
|
|
|
217
273
|
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
|
|
218
274
|
* @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
|
|
219
275
|
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
|
|
220
|
-
* @param {object} [opts.rateLimit]
|
|
276
|
+
* @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
|
|
277
|
+
* @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
|
|
221
278
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
222
279
|
*/
|
|
223
|
-
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null } = {}) {
|
|
280
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {} } = {}) {
|
|
224
281
|
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
225
282
|
const server = createServer((req, res) => {
|
|
226
283
|
// 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
|
|
@@ -384,6 +441,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
384
441
|
if (proxyHead?.length) socket.write(proxyHead);
|
|
385
442
|
proxySocket.pipe(socket);
|
|
386
443
|
socket.pipe(proxySocket);
|
|
444
|
+
// 心跳注入(PR #41):保活 + 静默断链检测(见 attachWebSocketHeartbeat)
|
|
445
|
+
if (heartbeat !== false) attachWebSocketHeartbeat(socket, heartbeat ?? {});
|
|
387
446
|
// 任一端断开都要清理另一端(避免上游残留僵尸连接占用 dsh 连接槽)
|
|
388
447
|
const teardown = () => { try { proxySocket.destroy(); } catch {} try { socket.destroy(); } catch {} };
|
|
389
448
|
proxySocket.on('close', teardown);
|
package/lib/tunnel.mjs
CHANGED
|
@@ -11,7 +11,13 @@ import { pipeline } from 'node:stream/promises';
|
|
|
11
11
|
import { Readable } from 'node:stream';
|
|
12
12
|
import { createWriteStream } from 'node:fs';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// 快速隧道 URL:https://<随机子域>.trycloudflare.com
|
|
15
|
+
// (?!api\.) 负向前瞻排除保留子域 api(issue #32):某些 cloudflared 版本/网络环境下
|
|
16
|
+
// 进程输出会先出现 https://api.trycloudflare.com(Cloudflare API 注册地址),原正则
|
|
17
|
+
// [a-z0-9-]+ 会把它误当隧道 URL → 设置页/二维码给出 api 地址 → 扫码打开返回
|
|
18
|
+
// {"code":10005,"message":"Method Not Allowed"}。api.trycloudflare.com 访问 GET 实测
|
|
19
|
+
// 正是该错误体,与 issue 完全一致。
|
|
20
|
+
export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
|
|
15
21
|
|
|
16
22
|
function platformBinary() {
|
|
17
23
|
const archMap = { x64: 'amd64', arm64: 'arm64' };
|
package/package.json
CHANGED