dsh-pocket 1.0.16 → 1.0.17
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/client/api.js +10 -8
- package/client/client.js +43 -22
- package/client/index.jsx +37 -18
- package/lib/index.js +3 -1
- package/lib/pocket-sw.js +8 -1
- package/lib/proxy.mjs +33 -4
- package/lib/push.mjs +4 -0
- package/lib/restart.js +49 -17
- package/lib/service.mjs +42 -18
- package/lib/tunnel.mjs +11 -4
- package/lib/web-rpc.js +16 -8
- package/package.json +1 -1
package/client/api.js
CHANGED
|
@@ -15,20 +15,22 @@ export const POCKET_ENDPOINTS = Object.freeze({
|
|
|
15
15
|
restart: 'pocket.restart',
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
/** 语义化版本比较:a > b 返回正数,相等 0,a < b
|
|
18
|
+
/** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
|
|
19
19
|
export function compareVersions(a, b) {
|
|
20
|
-
const pa = String(a).replace(/^
|
|
21
|
-
const pb = String(b).replace(/^
|
|
20
|
+
const pa = String(a).replace(/^[vV]/, '').split('.');
|
|
21
|
+
const pb = String(b).replace(/^[vV]/, '').split('.');
|
|
22
22
|
for (let i = 0; i < 3; i++) {
|
|
23
23
|
const x = parseInt(pa[i], 10) || 0;
|
|
24
24
|
const y = parseInt(pb[i], 10) || 0;
|
|
25
25
|
if (x !== y) return x - y;
|
|
26
26
|
}
|
|
27
|
-
//
|
|
28
|
-
const aPre =
|
|
29
|
-
const bPre =
|
|
30
|
-
if (aPre
|
|
31
|
-
return
|
|
27
|
+
// 数字段相等:无预发布后缀的更新;都有后缀时按后缀字典序(alpha < beta < rc…)
|
|
28
|
+
const aPre = String(a).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
|
|
29
|
+
const bPre = String(b).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
|
|
30
|
+
if (!aPre && !bPre) return 0;
|
|
31
|
+
if (!aPre) return 1;
|
|
32
|
+
if (!bPre) return -1;
|
|
33
|
+
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
|
package/client/client.js
CHANGED
|
@@ -48,17 +48,19 @@ var POCKET_ENDPOINTS = Object.freeze({
|
|
|
48
48
|
restart: "pocket.restart"
|
|
49
49
|
});
|
|
50
50
|
function compareVersions(a, b) {
|
|
51
|
-
const pa = String(a).replace(/^
|
|
52
|
-
const pb = String(b).replace(/^
|
|
51
|
+
const pa = String(a).replace(/^[vV]/, "").split(".");
|
|
52
|
+
const pb = String(b).replace(/^[vV]/, "").split(".");
|
|
53
53
|
for (let i = 0; i < 3; i++) {
|
|
54
54
|
const x = parseInt(pa[i], 10) || 0;
|
|
55
55
|
const y = parseInt(pb[i], 10) || 0;
|
|
56
56
|
if (x !== y) return x - y;
|
|
57
57
|
}
|
|
58
|
-
const aPre =
|
|
59
|
-
const bPre =
|
|
60
|
-
if (aPre
|
|
61
|
-
return
|
|
58
|
+
const aPre = String(a).replace(/^[vV]/, "").match(/-.*$/)?.[0] ?? "";
|
|
59
|
+
const bPre = String(b).replace(/^[vV]/, "").match(/-.*$/)?.[0] ?? "";
|
|
60
|
+
if (!aPre && !bPre) return 0;
|
|
61
|
+
if (!aPre) return 1;
|
|
62
|
+
if (!bPre) return -1;
|
|
63
|
+
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
|
|
62
64
|
}
|
|
63
65
|
function redactStatus(s) {
|
|
64
66
|
return {
|
|
@@ -1328,8 +1330,14 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1328
1330
|
return () => clearInterval(t);
|
|
1329
1331
|
}, []);
|
|
1330
1332
|
(0, import_react2.useEffect)(() => {
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
+
(async () => {
|
|
1334
|
+
try {
|
|
1335
|
+
const s = await call(POCKET_ENDPOINTS.pushStatus, {});
|
|
1336
|
+
setPushEnabled(s.enabled);
|
|
1337
|
+
if (s.enabled) setPushState(await setupPush(rpcCall));
|
|
1338
|
+
} catch {
|
|
1339
|
+
}
|
|
1340
|
+
})();
|
|
1333
1341
|
}, []);
|
|
1334
1342
|
(0, import_react2.useEffect)(() => {
|
|
1335
1343
|
let alive = true;
|
|
@@ -1356,6 +1364,11 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1356
1364
|
try {
|
|
1357
1365
|
await call(POCKET_ENDPOINTS.restart, {});
|
|
1358
1366
|
} catch (err) {
|
|
1367
|
+
const msg = String(err?.message ?? "");
|
|
1368
|
+
if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed/i.test(msg)) {
|
|
1369
|
+
setUpdateInfo((u) => ({ ...u, restarting: true, result: "ok" }));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1359
1372
|
setUpdateInfo((u) => ({ ...u, restarting: false, result: "fail", output: err.message }));
|
|
1360
1373
|
}
|
|
1361
1374
|
};
|
|
@@ -1369,26 +1382,34 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1369
1382
|
}
|
|
1370
1383
|
};
|
|
1371
1384
|
const enablePush = async () => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1385
|
+
try {
|
|
1386
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: true });
|
|
1387
|
+
setPushEnabled(true);
|
|
1388
|
+
setPushState(await setupPush(rpcCall));
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
setError(err?.message ?? "\u63A8\u9001\u5F00\u542F\u5931\u8D25 | failed to enable push");
|
|
1391
|
+
}
|
|
1375
1392
|
};
|
|
1376
1393
|
const disablePush = async () => {
|
|
1377
1394
|
try {
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1395
|
+
try {
|
|
1396
|
+
if ("serviceWorker" in navigator) {
|
|
1397
|
+
const reg = await navigator.serviceWorker.getRegistration("/pocket-sw.js");
|
|
1398
|
+
const sub = await reg?.pushManager?.getSubscription();
|
|
1399
|
+
if (sub) {
|
|
1400
|
+
const endpoint = sub.endpoint;
|
|
1401
|
+
await sub.unsubscribe();
|
|
1402
|
+
await call(POCKET_ENDPOINTS.pushUnsubscribe, { endpoint });
|
|
1403
|
+
}
|
|
1385
1404
|
}
|
|
1405
|
+
} catch {
|
|
1386
1406
|
}
|
|
1387
|
-
|
|
1407
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: false });
|
|
1408
|
+
setPushEnabled(false);
|
|
1409
|
+
setPushState("off");
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
setError(err?.message ?? "\u63A8\u9001\u5173\u95ED\u5931\u8D25 | failed to disable push");
|
|
1388
1412
|
}
|
|
1389
|
-
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: false });
|
|
1390
|
-
setPushEnabled(false);
|
|
1391
|
-
setPushState("off");
|
|
1392
1413
|
};
|
|
1393
1414
|
const startTunnel = async () => {
|
|
1394
1415
|
setBusy(true);
|
package/client/index.jsx
CHANGED
|
@@ -85,9 +85,15 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
85
85
|
return () => clearInterval(t);
|
|
86
86
|
}, []);
|
|
87
87
|
|
|
88
|
-
//
|
|
88
|
+
// 读取宿主开关状态;已开启时同步检查浏览器订阅状态(否则刷新后一直显示"检查中…")
|
|
89
89
|
useEffect(() => {
|
|
90
|
-
|
|
90
|
+
(async () => {
|
|
91
|
+
try {
|
|
92
|
+
const s = await call(POCKET_ENDPOINTS.pushStatus, {});
|
|
93
|
+
setPushEnabled(s.enabled);
|
|
94
|
+
if (s.enabled) setPushState(await setupPush(rpcCall));
|
|
95
|
+
} catch { /* 忽略瞬时失败 */ }
|
|
96
|
+
})();
|
|
91
97
|
}, []);
|
|
92
98
|
|
|
93
99
|
// 版本检测:host 当前版本 vs npm registry latest(registry 带 CORS *)
|
|
@@ -116,8 +122,13 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
116
122
|
setUpdateInfo((u) => ({ ...u, restarting: true }));
|
|
117
123
|
try {
|
|
118
124
|
await call(POCKET_ENDPOINTS.restart, {});
|
|
119
|
-
// 宿主即将退出重启,无需更多处理
|
|
120
125
|
} catch (err) {
|
|
126
|
+
// 宿主 500ms 后自杀,RPC 响应可能来不及送达——网络断连视为「已请求重启」
|
|
127
|
+
const msg = String(err?.message ?? '');
|
|
128
|
+
if (/connection|socket|fetch|network|abort|cancelled|ECONN|disconnect|closed/i.test(msg)) {
|
|
129
|
+
setUpdateInfo((u) => ({ ...u, restarting: true, result: 'ok' }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
121
132
|
setUpdateInfo((u) => ({ ...u, restarting: false, result: 'fail', output: err.message }));
|
|
122
133
|
}
|
|
123
134
|
};
|
|
@@ -135,27 +146,35 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
135
146
|
|
|
136
147
|
// 开启推送:宿主开关开 + 浏览器订阅(安全上下文才有效)
|
|
137
148
|
const enablePush = async () => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
try {
|
|
150
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: true });
|
|
151
|
+
setPushEnabled(true);
|
|
152
|
+
setPushState(await setupPush(rpcCall));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
setError(err?.message ?? '推送开启失败 | failed to enable push');
|
|
155
|
+
}
|
|
141
156
|
};
|
|
142
157
|
|
|
143
158
|
// 关闭推送:取消浏览器订阅 + 宿主开关关
|
|
144
159
|
const disablePush = async () => {
|
|
145
160
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
try {
|
|
162
|
+
if ('serviceWorker' in navigator) {
|
|
163
|
+
const reg = await navigator.serviceWorker.getRegistration('/pocket-sw.js');
|
|
164
|
+
const sub = await reg?.pushManager?.getSubscription();
|
|
165
|
+
if (sub) {
|
|
166
|
+
const endpoint = sub.endpoint;
|
|
167
|
+
await sub.unsubscribe();
|
|
168
|
+
await call(POCKET_ENDPOINTS.pushUnsubscribe, { endpoint });
|
|
169
|
+
}
|
|
153
170
|
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
171
|
+
} catch { /* 浏览器侧失败不影响宿主开关 */ }
|
|
172
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: false });
|
|
173
|
+
setPushEnabled(false);
|
|
174
|
+
setPushState('off');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setError(err?.message ?? '推送关闭失败 | failed to disable push');
|
|
177
|
+
}
|
|
159
178
|
};
|
|
160
179
|
|
|
161
180
|
const startTunnel = async () => {
|
package/lib/index.js
CHANGED
|
@@ -115,7 +115,9 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
115
115
|
const disposers = [];
|
|
116
116
|
const disposeRpc = installPocketRpc(ctx, {
|
|
117
117
|
service,
|
|
118
|
-
|
|
118
|
+
// push 传 promise:插件启动早期 createPushService 可能还没 resolve,
|
|
119
|
+
// RPC 侧每次调用前动态 await(不能传空 fallback——会拿到空的 VAPID key)
|
|
120
|
+
push: internals.push ?? pushPromise,
|
|
119
121
|
runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
|
|
120
122
|
restart: internals.restart ?? pocketRestart,
|
|
121
123
|
restartNotice: internals.restartNotice ?? readRestartNotice,
|
package/lib/pocket-sw.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// dsh-pocket Service Worker(经 webServer 同源提供:/pocket-sw.js)
|
|
2
2
|
// 职责:接收推送 → 显示通知;点击通知 → 聚焦/打开 DSH 页面。
|
|
3
|
-
|
|
3
|
+
// 图标用内联 data URI(不依赖 dsh 静态资源,避免 404 导致通知无图标)。
|
|
4
|
+
const ICON = 'data:image/svg+xml,' + encodeURIComponent(
|
|
5
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' +
|
|
6
|
+
'<rect width="64" height="64" rx="14" fill="#4f6ef7"/>' +
|
|
7
|
+
'<rect x="16" y="12" width="32" height="40" rx="5" fill="#fff"/>' +
|
|
8
|
+
'<rect x="22" y="18" width="20" height="26" rx="2" fill="#4f6ef7"/>' +
|
|
9
|
+
'<circle cx="32" cy="49" r="2.2" fill="#fff"/>' +
|
|
10
|
+
'</svg>');
|
|
4
11
|
|
|
5
12
|
self.addEventListener('push', (event) => {
|
|
6
13
|
let data = {};
|
package/lib/proxy.mjs
CHANGED
|
@@ -20,8 +20,17 @@ const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
|
|
|
20
20
|
* 非安全上下文(http://<LAN-IP>:端口)里浏览器没有 crypto.randomUUID,
|
|
21
21
|
* 前端(DSH 连接层 mint RPC id 用)会直接抛 "crypto.randomUUID is not a function"。
|
|
22
22
|
* 通过代理给 HTML 文档注入 polyfill(只在缺少时生效,用 getRandomValues 实现 v4)。
|
|
23
|
+
* 带 data-dsh-pocket-polyfill 标记:注入判重用它,而不是搜索 "crypto.randomUUID"
|
|
24
|
+
* 字样(dsh 页面源码里可能恰好出现该字符串,导致误判为已注入而跳过)。
|
|
23
25
|
*/
|
|
24
|
-
const RANDOM_UUID_POLYFILL = `<script>!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>`;
|
|
26
|
+
const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>`;
|
|
27
|
+
|
|
28
|
+
const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
|
|
29
|
+
|
|
30
|
+
/** 上游响应是否压缩过(压缩流不能做文本注入,会损坏页面)。 */
|
|
31
|
+
function isCompressed(headers) {
|
|
32
|
+
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
/** 默认注入到经代理的 HTML 文档里:crypto.randomUUID polyfill(非安全上下文必需)。 */
|
|
27
36
|
const DEFAULT_INJECT = RANDOM_UUID_POLYFILL;
|
|
@@ -52,13 +61,14 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
52
61
|
(proxyRes) => {
|
|
53
62
|
log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
|
|
54
63
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
55
|
-
//
|
|
56
|
-
|
|
64
|
+
// 只给**未压缩**的 HTML 文档注入(SSE/WS/JS/CSS 原样透传;压缩流注入会损坏页面);
|
|
65
|
+
// 注入后修正 Content-Length
|
|
66
|
+
if (injectHtml && contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
57
67
|
const chunks = [];
|
|
58
68
|
proxyRes.on('data', (c) => chunks.push(c));
|
|
59
69
|
proxyRes.on('end', () => {
|
|
60
70
|
let html = Buffer.concat(chunks).toString('utf8');
|
|
61
|
-
if (!html.includes(
|
|
71
|
+
if (!html.includes(INJECT_MARK)) {
|
|
62
72
|
html = html.replace(/<head[^>]*>/i, (m) => `${m}${injectHtml}`);
|
|
63
73
|
}
|
|
64
74
|
const out = Buffer.from(html, 'utf8');
|
|
@@ -74,6 +84,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
74
84
|
}
|
|
75
85
|
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
76
86
|
proxyRes.pipe(res);
|
|
87
|
+
// 客户端断开时销毁上游响应(SSE 流手机断连后不留僵尸连接)
|
|
88
|
+
res.on('close', () => proxyRes.destroy());
|
|
77
89
|
},
|
|
78
90
|
);
|
|
79
91
|
proxyReq.on('error', (err) => {
|
|
@@ -100,6 +112,23 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
100
112
|
if (proxyHead?.length) socket.write(proxyHead);
|
|
101
113
|
proxySocket.pipe(socket);
|
|
102
114
|
socket.pipe(proxySocket);
|
|
115
|
+
// 任一端断开都要清理另一端(避免上游残留僵尸连接占用 dsh 连接槽)
|
|
116
|
+
const teardown = () => { try { proxySocket.destroy(); } catch {} try { socket.destroy(); } catch {} };
|
|
117
|
+
proxySocket.on('close', teardown);
|
|
118
|
+
socket.on('close', teardown);
|
|
119
|
+
});
|
|
120
|
+
// 上游返回普通 HTTP 响应(非 101):把状态码/头回写后断开,别让客户端永久挂起
|
|
121
|
+
proxyReq.on('response', (proxyRes) => {
|
|
122
|
+
if (proxyRes.statusCode === 101) return; // 理论上 101 走 upgrade 事件
|
|
123
|
+
try {
|
|
124
|
+
const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
|
|
125
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
126
|
+
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
127
|
+
}
|
|
128
|
+
socket.end(raw.join('\r\n') + '\r\n\r\n');
|
|
129
|
+
proxyRes.resume(); // 消费掉上游响应体,释放连接
|
|
130
|
+
} catch { /* socket 已关 */ }
|
|
131
|
+
socket.destroy();
|
|
103
132
|
});
|
|
104
133
|
proxyReq.on('error', () => socket.destroy());
|
|
105
134
|
// 关键:浏览器在握手请求后可能立即发出首帧(如 mux 流的初始 RPC),
|
package/lib/push.mjs
CHANGED
|
@@ -33,6 +33,7 @@ function defaultWebPush() {
|
|
|
33
33
|
* @param {string} [opts.home] $DSH_HOME(默认 ~/.dsh)
|
|
34
34
|
* @param {object} [opts.webpush] web-push 库(测试注入 stub)
|
|
35
35
|
* @param {string} [opts.subject] VAPID subject(mailto:)
|
|
36
|
+
* @param {object} [opts.log] 日志(默认 console)
|
|
36
37
|
* @param {object} [opts.internals] 测试注入:mkdir/write/read
|
|
37
38
|
* @returns {Promise<PushService>}
|
|
38
39
|
*/
|
|
@@ -40,6 +41,7 @@ export async function createPushService({
|
|
|
40
41
|
home,
|
|
41
42
|
webpush = defaultWebPush(),
|
|
42
43
|
subject = 'mailto:shaobeichen@outlook.com',
|
|
44
|
+
log = console,
|
|
43
45
|
internals = {},
|
|
44
46
|
} = {}) {
|
|
45
47
|
const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
@@ -132,6 +134,8 @@ export async function createPushService({
|
|
|
132
134
|
if (code === 410 || code === 404) {
|
|
133
135
|
subs.delete(endpoint);
|
|
134
136
|
await persist();
|
|
137
|
+
} else {
|
|
138
|
+
log.warn?.('dsh-pocket: push send failed (endpoint %s…) | 推送失败: %s', String(endpoint).slice(0, 48), err?.message ?? err);
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
}
|
package/lib/restart.js
CHANGED
|
@@ -3,50 +3,80 @@
|
|
|
3
3
|
//
|
|
4
4
|
// 方案借鉴 dshmarket 的 self-restart(lib/restart.js,MIT):不直接拉起新
|
|
5
5
|
// 进程,而是先拉一个 detached 的 node 辅助进程,等旧进程退出、端口释放
|
|
6
|
-
//
|
|
6
|
+
// 后再拉起新 dsh,并把新进程输出写入临时日志——避免端口竞争
|
|
7
7
|
// (EADDRINUSE)导致新进程静默崩溃。
|
|
8
8
|
//
|
|
9
|
+
// 与 dshmarket 的差异:不赌固定 1.5s 延时,而是轮询探测端口真正释放
|
|
10
|
+
// (ECONNREFUSED)再拉起,旧进程退出慢也不会撞端口。
|
|
11
|
+
//
|
|
9
12
|
// 注意:新进程 detached,不挂终端——停止方式:lsof -ti :3080 | xargs kill -9。
|
|
10
13
|
|
|
11
14
|
import { spawn } from 'node:child_process';
|
|
12
15
|
import { tmpdir } from 'node:os';
|
|
13
16
|
import { join } from 'node:path';
|
|
14
17
|
|
|
15
|
-
/**
|
|
18
|
+
/** 从启动参数里解析 dsh web 端口(--port/-p),默认 3080。 */
|
|
19
|
+
export function dshPortFromArgs(args) {
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
|
|
22
|
+
const n = Number(args[i + 1]);
|
|
23
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return 3080;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 重建启动调用(与当前宿主相同的命令,含 node 运行参数)。 */
|
|
16
30
|
export function restartLaunch() {
|
|
17
31
|
return {
|
|
18
32
|
file: process.argv[0], // node
|
|
19
|
-
args: [process.argv[1], ...process.argv.slice(2)], // <bin.js> + web [flags]
|
|
33
|
+
args: [...process.execArgv, process.argv[1], ...process.argv.slice(2)], // [flags] <bin.js> + web [flags]
|
|
20
34
|
cwd: process.cwd(),
|
|
21
35
|
};
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
/**
|
|
25
|
-
function helperCode(launch, logOut, logErr) {
|
|
38
|
+
/** 辅助进程代码:等 dsh 端口真正释放(最多 20s)→ 拉起新 dsh → 输出写日志。 */
|
|
39
|
+
function helperCode(launch, logOut, logErr, port) {
|
|
26
40
|
const spawn = "const { spawn } = require('node:child_process')";
|
|
27
41
|
const fs = "const fs = require('node:fs')";
|
|
42
|
+
const net = "const net = require('node:net')";
|
|
28
43
|
const file = `const file = ${JSON.stringify(launch.file)}`;
|
|
29
44
|
const args = `const args = ${JSON.stringify(launch.args)}`;
|
|
30
45
|
const cwd = `const cwd = ${JSON.stringify(launch.cwd)}`;
|
|
31
46
|
const o = `const logOut = ${JSON.stringify(logOut)}`;
|
|
32
47
|
const e = `const logErr = ${JSON.stringify(logErr)}`;
|
|
33
48
|
const body = [
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
49
|
+
'function portFree(p, cb) {',
|
|
50
|
+
' const s = net.connect(p, "127.0.0.1")',
|
|
51
|
+
' s.once("connect", () => { s.destroy(); cb(false) })', // 还能连上 = 端口仍被占用
|
|
52
|
+
' s.once("error", () => cb(true))', // 连接被拒 = 已释放
|
|
53
|
+
'}',
|
|
54
|
+
'function waitPort(p, tries, cb) {',
|
|
55
|
+
' portFree(p, (free) => {',
|
|
56
|
+
' if (free || tries <= 0) cb(free)',
|
|
57
|
+
' else setTimeout(() => waitPort(p, tries - 1, cb), 200)',
|
|
58
|
+
' })',
|
|
59
|
+
'}',
|
|
60
|
+
`waitPort(${port}, 100, (free) => {`, // 最多 100×200ms = 20s
|
|
61
|
+
' setTimeout(() => {',
|
|
62
|
+
' try {',
|
|
63
|
+
' const out = fs.openSync(logOut, "a")',
|
|
64
|
+
' const err = fs.openSync(logErr, "a")',
|
|
65
|
+
' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env })',
|
|
66
|
+
' child.unref()',
|
|
67
|
+
' } catch (ex) {',
|
|
68
|
+
' try { fs.appendFileSync(logErr, "restart helper failed: " + (ex && ex.message) + "\\n") } catch {}',
|
|
69
|
+
' }',
|
|
70
|
+
' }, 300)',
|
|
71
|
+
'})',
|
|
42
72
|
].join('\n');
|
|
43
|
-
return [spawn, fs, file, args, cwd, o, e, body].join('\n');
|
|
73
|
+
return [spawn, fs, net, file, args, cwd, o, e, body].join('\n');
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
/**
|
|
47
77
|
* 拉起替代宿主(detached 辅助进程交接),随后结束当前进程。
|
|
48
78
|
* @param {object} opts
|
|
49
|
-
* @param {number} [opts.handoffMs]
|
|
79
|
+
* @param {number} [opts.handoffMs] 保留参数(兼容旧调用;现由端口探测接管)
|
|
50
80
|
* @param {object} [opts.internals] 测试注入:spawn / kill
|
|
51
81
|
* @returns {{helperPid:number|null, logOut:string, logErr:string}}
|
|
52
82
|
*/
|
|
@@ -54,20 +84,22 @@ export function restartHost({ handoffMs = 1500, internals = {} } = {}) {
|
|
|
54
84
|
const spawnFn = internals.spawn ?? spawn;
|
|
55
85
|
const killFn = internals.kill ?? ((pid) => process.kill(pid, 'SIGTERM'));
|
|
56
86
|
const launch = restartLaunch();
|
|
87
|
+
const port = dshPortFromArgs(launch.args);
|
|
57
88
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
58
89
|
const logOut = join(tmpdir(), `dsh-pocket-restart-${stamp}.out.log`);
|
|
59
90
|
const logErr = join(tmpdir(), `dsh-pocket-restart-${stamp}.err.log`);
|
|
60
91
|
|
|
61
92
|
let helperPid = null;
|
|
62
93
|
try {
|
|
63
|
-
const helper = spawnFn(process.execPath, ['-e', helperCode(launch, logOut, logErr)], {
|
|
94
|
+
const helper = spawnFn(process.execPath, ['-e', helperCode(launch, logOut, logErr, port)], {
|
|
64
95
|
detached: true,
|
|
65
96
|
stdio: 'ignore',
|
|
66
97
|
env: process.env,
|
|
67
98
|
});
|
|
68
99
|
helper.unref?.();
|
|
100
|
+
helper.on?.('error', () => {}); // 参数异常等异步错误兜底,别让旧进程先崩
|
|
69
101
|
helperPid = helper.pid ?? null;
|
|
70
|
-
//
|
|
102
|
+
// 短暂等待后结束当前进程(释放端口);由辅助进程探测到端口释放后拉起新宿主
|
|
71
103
|
setTimeout(() => { try { killFn(process.pid); } catch { /* 忽略 */ } }, 500);
|
|
72
104
|
} catch (err) {
|
|
73
105
|
return { helperPid: null, logOut, logErr, error: err?.message ?? String(err) };
|
package/lib/service.mjs
CHANGED
|
@@ -49,8 +49,21 @@ export function createPocketService({
|
|
|
49
49
|
let proxy = null;
|
|
50
50
|
let tunnel = null;
|
|
51
51
|
let tunnelAbort = null;
|
|
52
|
+
/** in-flight 隧道启动(单飞):并发调用复用同一次,避免 spawn 多个 cloudflared 孤儿进程 */
|
|
53
|
+
let tunnelPromise = null;
|
|
52
54
|
/** 隧道进度:{ phase: idle|downloading|starting|registering|ready|error, detail, startedAt } */
|
|
53
55
|
const tunnelState = { phase: 'idle', detail: '', startedAt: null };
|
|
56
|
+
/** 二维码缓存:URL → data URL promise。status() 每 3 秒轮询一次,不能每次都重新生成(CPU 密集)。 */
|
|
57
|
+
const qrCache = new Map();
|
|
58
|
+
const encodeQr = internals.encodeQr ?? qrDataUrl;
|
|
59
|
+
async function qrCached(text) {
|
|
60
|
+
if (!text) return null;
|
|
61
|
+
if (!qrCache.has(text)) {
|
|
62
|
+
if (qrCache.size > 8) qrCache.clear(); // 隧道 URL 每次重启换新,防止无限增长
|
|
63
|
+
qrCache.set(text, encodeQr(text).catch(() => null));
|
|
64
|
+
}
|
|
65
|
+
return qrCache.get(text);
|
|
66
|
+
}
|
|
54
67
|
|
|
55
68
|
return {
|
|
56
69
|
/** 启动局域网代理(幂等)。 */
|
|
@@ -64,11 +77,13 @@ export function createPocketService({
|
|
|
64
77
|
return proxy;
|
|
65
78
|
},
|
|
66
79
|
|
|
67
|
-
/** 启动公网隧道(幂等;返回公网 URL)。进度写进 tunnelState
|
|
80
|
+
/** 启动公网隧道(幂等;返回公网 URL)。进度写进 tunnelState。并发调用单飞。 */
|
|
68
81
|
async startTunnel() {
|
|
69
82
|
await this.startProxy();
|
|
70
83
|
if (tunnel) return tunnel.url;
|
|
71
|
-
|
|
84
|
+
if (tunnelPromise) return tunnelPromise; // 复用 in-flight,防孤儿 cloudflared
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
tunnelAbort = controller;
|
|
72
87
|
tunnelState.startedAt = Date.now();
|
|
73
88
|
const onPhase = (phase) => {
|
|
74
89
|
tunnelState.phase = phase;
|
|
@@ -77,23 +92,33 @@ export function createPocketService({
|
|
|
77
92
|
else if (phase === 'registering') tunnelState.detail = '连接 Cloudflare 边缘(通常 5-30 秒)| connecting to Cloudflare edge (usually 5-30s)';
|
|
78
93
|
else if (phase === 'ready') tunnelState.detail = '隧道就绪 | ready';
|
|
79
94
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
tunnelPromise = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
const result = await startTunnel({ port: proxy.port, home, signal: controller.signal, onPhase });
|
|
98
|
+
// 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
|
|
99
|
+
tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
|
|
100
|
+
tunnelState.phase = 'ready';
|
|
101
|
+
return tunnel.url;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
|
|
104
|
+
if (!controller.signal.aborted) {
|
|
105
|
+
tunnelState.phase = 'error';
|
|
106
|
+
tunnelState.detail = err?.message ?? String(err);
|
|
107
|
+
}
|
|
108
|
+
tunnelState.startedAt = null; // 失败后清掉计时,避免 UI 误显"启动中"
|
|
109
|
+
throw err;
|
|
110
|
+
} finally {
|
|
111
|
+
tunnelPromise = null;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
return tunnelPromise;
|
|
91
115
|
},
|
|
92
116
|
|
|
93
117
|
/** 停止公网隧道(代理保持)。 */
|
|
94
118
|
stopTunnel() {
|
|
95
119
|
tunnelAbort?.abort();
|
|
96
120
|
tunnelAbort = null;
|
|
121
|
+
tunnelPromise = null; // 丢弃已 abort 的 in-flight(其 finally 会再清一次,无害)
|
|
97
122
|
if (tunnel) tunnel.kill();
|
|
98
123
|
tunnel = null;
|
|
99
124
|
tunnelState.phase = 'idle';
|
|
@@ -101,20 +126,19 @@ export function createPocketService({
|
|
|
101
126
|
tunnelState.startedAt = null;
|
|
102
127
|
},
|
|
103
128
|
|
|
104
|
-
/** 状态快照(RPC 返回,不含敏感信息;二维码 data URL
|
|
129
|
+
/** 状态快照(RPC 返回,不含敏感信息;二维码 data URL 本地生成 + 缓存)。 */
|
|
105
130
|
async status() {
|
|
106
131
|
const lan = getLan();
|
|
107
132
|
const proxyPort = proxy?.port ?? null;
|
|
108
133
|
const lanUrl = lan && proxyPort ? `http://${lan}:${proxyPort}` : null;
|
|
109
|
-
const encode = internals.encodeQr ?? qrDataUrl;
|
|
110
134
|
return {
|
|
111
135
|
proxyRunning: proxy !== null,
|
|
112
136
|
proxyPort,
|
|
113
137
|
lanUrl,
|
|
114
|
-
lanQr:
|
|
138
|
+
lanQr: await qrCached(lanUrl),
|
|
115
139
|
tunnelRunning: tunnel !== null,
|
|
116
140
|
tunnelUrl: tunnel?.url ?? null,
|
|
117
|
-
tunnelQr:
|
|
141
|
+
tunnelQr: await qrCached(tunnel?.url ?? null),
|
|
118
142
|
tunnelState: { ...tunnelState },
|
|
119
143
|
dshPort,
|
|
120
144
|
};
|
|
@@ -126,7 +150,7 @@ export function createPocketService({
|
|
|
126
150
|
if (proxy) {
|
|
127
151
|
const p = proxy;
|
|
128
152
|
proxy = null;
|
|
129
|
-
await p.close();
|
|
153
|
+
try { await p.close(); } catch { /* server 已关闭等边缘情况 */ }
|
|
130
154
|
}
|
|
131
155
|
},
|
|
132
156
|
};
|
package/lib/tunnel.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 无密码模式:URL 即钥匙(dsh web 能执行代码,请勿把二维码/URL 发给别人)。
|
|
5
5
|
|
|
6
6
|
import { spawn, execSync } from 'node:child_process';
|
|
7
|
-
import { mkdir, access, chmod } from 'node:fs/promises';
|
|
7
|
+
import { mkdir, access, chmod, rm } from 'node:fs/promises';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join, dirname } from 'node:path';
|
|
10
10
|
import { pipeline } from 'node:stream/promises';
|
|
@@ -25,7 +25,7 @@ async function downloadCloudflared(binPath) {
|
|
|
25
25
|
// cloudflared 新版发布资产是 .tgz 压缩包(内含 cloudflared 二进制)
|
|
26
26
|
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${os}-${a}.tgz`;
|
|
27
27
|
console.log(`⬇️ 正在下载 cloudflared(首次运行,之后跳过)…`);
|
|
28
|
-
const res = await fetch(url);
|
|
28
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(180_000) });
|
|
29
29
|
if (!res.ok) throw new Error(`cloudflared 下载失败(HTTP ${res.status})`);
|
|
30
30
|
const dir = dirname(binPath);
|
|
31
31
|
const tgz = join(dir, `cloudflared.tgz`);
|
|
@@ -38,6 +38,8 @@ async function downloadCloudflared(binPath) {
|
|
|
38
38
|
});
|
|
39
39
|
const extracted = join(dir, `cloudflared${ext}`);
|
|
40
40
|
if (os !== 'windows') await chmod(extracted, 0o755);
|
|
41
|
+
// 解压成功就删掉 ~20MB 的 tgz,避免长期占用缓存目录
|
|
42
|
+
await rm(tgz, { force: true }).catch(() => {});
|
|
41
43
|
return extracted;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -51,6 +53,9 @@ function cloudflaredOnPath() {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** in-flight 下载(单飞):并发调用复用同一次,防止交错写入损坏 tgz。 */
|
|
57
|
+
let downloading = null;
|
|
58
|
+
|
|
54
59
|
/**
|
|
55
60
|
* 拿一个可用的 cloudflared 路径。
|
|
56
61
|
* 优先:PATH 已有 → 直接用;否则用持久缓存($DSH_HOME/dsh-pocket/cloudflared),
|
|
@@ -67,8 +72,10 @@ export async function resolveCloudflared({ home, onPhase = () => {} } = {}) {
|
|
|
67
72
|
} catch { /* 缓存缺失,下载 */ }
|
|
68
73
|
onPhase('downloading');
|
|
69
74
|
await mkdir(cacheDir, { recursive: true });
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
if (!downloading) {
|
|
76
|
+
downloading = downloadCloudflared(bin).finally(() => { downloading = null; });
|
|
77
|
+
}
|
|
78
|
+
return downloading;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
package/lib/web-rpc.js
CHANGED
|
@@ -32,6 +32,9 @@ export function installPocketRpc(ctx, { service, push, log = console, runUpdate
|
|
|
32
32
|
return ok({ ...redactStatus(await service.status()), restartNotice: notice });
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// push 可能是 promise(插件启动早期 createPushService 尚未 resolve);动态取实例
|
|
36
|
+
const pushApi = await Promise.resolve(push).catch(() => null);
|
|
37
|
+
|
|
35
38
|
try {
|
|
36
39
|
if (endpoint === POCKET_ENDPOINTS.status) {
|
|
37
40
|
return await statusPayload();
|
|
@@ -45,22 +48,27 @@ export function installPocketRpc(ctx, { service, push, log = console, runUpdate
|
|
|
45
48
|
return await statusPayload();
|
|
46
49
|
}
|
|
47
50
|
if (endpoint === POCKET_ENDPOINTS.pushVapidKey) {
|
|
48
|
-
return
|
|
51
|
+
if (!pushApi) return fail('bad-request', '推送服务未就绪 | push service unavailable');
|
|
52
|
+
return ok({ publicKey: pushApi.vapidPublicKey() });
|
|
49
53
|
}
|
|
50
54
|
if (endpoint === POCKET_ENDPOINTS.pushSubscribe) {
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
if (!pushApi) return fail('bad-request', '推送服务未就绪 | push service unavailable');
|
|
56
|
+
const added = await pushApi.subscribe(payload?.subscription);
|
|
57
|
+
return ok({ subscribed: added, count: pushApi.count() });
|
|
53
58
|
}
|
|
54
59
|
if (endpoint === POCKET_ENDPOINTS.pushUnsubscribe) {
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
if (!pushApi) return fail('bad-request', '推送服务未就绪 | push service unavailable');
|
|
61
|
+
const removed = await pushApi.unsubscribe(payload?.endpoint);
|
|
62
|
+
return ok({ removed, count: pushApi.count() });
|
|
57
63
|
}
|
|
58
64
|
if (endpoint === POCKET_ENDPOINTS.pushStatus) {
|
|
59
|
-
|
|
65
|
+
if (!pushApi) return fail('bad-request', '推送服务未就绪 | push service unavailable');
|
|
66
|
+
return ok({ enabled: pushApi.isEnabled(), count: pushApi.count() });
|
|
60
67
|
}
|
|
61
68
|
if (endpoint === POCKET_ENDPOINTS.pushSetEnabled) {
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
if (!pushApi) return fail('bad-request', '推送服务未就绪 | push service unavailable');
|
|
70
|
+
const enabled = await pushApi.setEnabled(payload?.enabled === true);
|
|
71
|
+
return ok({ enabled, count: pushApi.count() });
|
|
64
72
|
}
|
|
65
73
|
if (endpoint === POCKET_ENDPOINTS.version) {
|
|
66
74
|
return ok({ current: runUpdate?.currentVersion?.() ?? null, loaded: runUpdate?.loadedVersion?.() ?? null });
|
package/package.json
CHANGED