dsh-dock 0.6.0 → 0.7.1

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.
@@ -0,0 +1,270 @@
1
+ /* 趣味游戏 · 象棋核心规则(纯函数,无 React)——供组件复用,也便于单测。 */
2
+ export const ROWS = 10, COLS = 9;
3
+ // 类型:g 将/帅 · a 仕/士 · e 相/象 · h 马 · k 车 · c 炮 · p 兵/卒
4
+ // 颜色:r 红(下)· b 黑(上)
5
+
6
+ export function pieceChar(p) {
7
+ const map = {
8
+ g: { r: "帥", b: "將" }, a: { r: "仕", b: "士" }, e: { r: "相", b: "象" },
9
+ h: { r: "馬", b: "馬" }, k: { r: "車", b: "車" }, c: { r: "炮", b: "炮" },
10
+ p: { r: "兵", b: "卒" },
11
+ };
12
+ return map[p.t] && map[p.t][p.c];
13
+ }
14
+ export const VALUE = { g: 100000, k: 900, c: 450, h: 400, e: 200, a: 200, p: 100 };
15
+
16
+ export function initialBoard() {
17
+ const b = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
18
+ const back = ["k", "h", "e", "a", "g", "a", "e", "h", "k"];
19
+ back.forEach((t, c) => { b[0][c] = { t, c: "b" }; });
20
+ b[2][1] = { t: "c", c: "b" }; b[2][7] = { t: "c", c: "b" };
21
+ [0, 2, 4, 6, 8].forEach((c) => { b[4][c] = { t: "p", c: "b" }; });
22
+ back.forEach((t, c) => { b[9][c] = { t, c: "r" }; });
23
+ b[8][1] = { t: "c", c: "r" }; b[8][7] = { t: "c", c: "r" };
24
+ [0, 2, 4, 6, 8].forEach((c) => { b[6][c] = { t: "p", c: "r" }; });
25
+ return b;
26
+ }
27
+ export function cloneBoard(b) {
28
+ return b.map((row) => row.slice());
29
+ }
30
+ function inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
31
+ function inPalace(r, c, color) {
32
+ if (c < 3 || c > 5) return false;
33
+ return color === "r" ? r >= 7 && r <= 9 : r >= 0 && r <= 2;
34
+ }
35
+ function ownSide(r, color) { return color === "r" ? r >= 5 : r <= 4; }
36
+ function crossedRiver(r, color) { return color === "r" ? r <= 4 : r >= 5; }
37
+ function countBetween(board, r, c, tr, tc, dr, dc) {
38
+ let n = 0, rr = r + dr, cc = c + dc;
39
+ while (!(rr === tr && cc === tc)) {
40
+ if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) return -1;
41
+ if (board[rr][cc]) n += 1;
42
+ rr += dr; cc += dc;
43
+ }
44
+ return n;
45
+ }
46
+
47
+ export function attacks(board, r, c, tr, tc) {
48
+ const p = board[r][c];
49
+ if (!p) return false;
50
+ const dr = Math.sign(tr - r), dc = Math.sign(tc - c);
51
+ const t = board[tr][tc];
52
+ if (p.t === "k") {
53
+ if (r !== tr && c !== tc) return false;
54
+ return countBetween(board, r, c, tr, tc, dr, dc) === 0;
55
+ }
56
+ if (p.t === "c") {
57
+ if (r !== tr && c !== tc) return false;
58
+ if (dr === 0 && dc === 0) return false;
59
+ return countBetween(board, r, c, tr, tc, dr, dc) === 1 && !!t;
60
+ }
61
+ if (p.t === "h") {
62
+ const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
63
+ if (!((adr === 2 && adc === 1) || (adr === 1 && adc === 2))) return false;
64
+ const legR = adr === 2 ? r + dr : r;
65
+ const legC = adc === 2 ? c + dc : c;
66
+ return !board[legR][legC];
67
+ }
68
+ if (p.t === "g") {
69
+ return Math.abs(tr - r) + Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
70
+ }
71
+ if (p.t === "a") {
72
+ return Math.abs(tr - r) === 1 && Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
73
+ }
74
+ if (p.t === "e") {
75
+ const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
76
+ if (!(adr === 2 && adc === 2)) return false;
77
+ if (!ownSide(tr, p.c)) return false;
78
+ const eyeR = r + dr, eyeC = c + dc;
79
+ return !board[eyeR][eyeC];
80
+ }
81
+ if (p.t === "p") {
82
+ const forward = p.c === "r" ? r - 1 : r + 1;
83
+ if (tr === forward && tc === c) return true;
84
+ if (crossedRiver(r, p.c) && tr === r && Math.abs(tc - c) === 1) return true;
85
+ return false;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ export function findGeneral(board, color) {
91
+ for (let r = 0; r < ROWS; r += 1) {
92
+ for (let c = 0; c < COLS; c += 1) {
93
+ const p = board[r][c];
94
+ if (p && p.t === "g" && p.c === color) return { r, c };
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ export function pseudoMoves(board, r, c) {
100
+ const p = board[r][c];
101
+ if (!p) return [];
102
+ const out = [];
103
+ const push = (nr, nc) => { if (inBounds(nr, nc)) out.push([nr, nc]); };
104
+ const clear = (nr, nc) => !board[nr][nc];
105
+ const enemy = (nr, nc) => { const q = board[nr][nc]; return q && q.c !== p.c; };
106
+ if (p.t === "k") {
107
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
108
+ const nr = r + dr, nc = c + dc;
109
+ if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
110
+ });
111
+ }
112
+ if (p.t === "a") {
113
+ [[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([dr, dc]) => {
114
+ const nr = r + dr, nc = c + dc;
115
+ if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
116
+ });
117
+ }
118
+ if (p.t === "e") {
119
+ [[-2, -2], [-2, 2], [2, -2], [2, 2]].forEach(([dr, dc]) => {
120
+ const nr = r + dr, nc = c + dc, er = r + dr / 2, ec = c + dc / 2;
121
+ if (inBounds(nr, nc) && ownSide(nr, p.c) && !board[er][ec] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
122
+ });
123
+ }
124
+ if (p.t === "h") {
125
+ [[-2, -1], [-2, 1], [2, -1], [2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2]].forEach(([dr, dc]) => {
126
+ const nr = r + dr, nc = c + dc;
127
+ const legR = Math.abs(dr) === 2 ? r + dr / 2 : r;
128
+ const legC = Math.abs(dc) === 2 ? c + dc / 2 : c;
129
+ if (inBounds(nr, nc) && !board[legR][legC] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
130
+ });
131
+ }
132
+ if (p.t === "k" || p.t === "c") {
133
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
134
+ let rr = r + dr, cc = c + dc, jumped = false;
135
+ while (inBounds(rr, cc)) {
136
+ const q = board[rr][cc];
137
+ if (p.t === "k") {
138
+ if (!q) push(rr, cc);
139
+ else { if (q.c !== p.c) push(rr, cc); break; }
140
+ } else {
141
+ if (!q && !jumped) push(rr, cc);
142
+ else if (q && !jumped) jumped = true;
143
+ else if (q && jumped) { if (q.c !== p.c) push(rr, cc); break; }
144
+ }
145
+ rr += dr; cc += dc;
146
+ }
147
+ });
148
+ }
149
+ if (p.t === "p") {
150
+ const forward = p.c === "r" ? -1 : 1;
151
+ [[forward, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
152
+ const nr = r + dr, nc = c + dc;
153
+ if (!inBounds(nr, nc) || (dc !== 0 && !crossedRiver(r, p.c))) return;
154
+ if (clear(nr, nc) || enemy(nr, nc)) push(nr, nc);
155
+ });
156
+ }
157
+ return out;
158
+ }
159
+ export function applyMove(board, fr, fc, tr, tc) {
160
+ const nb = cloneBoard(board);
161
+ nb[tr][tc] = nb[fr][fc];
162
+ nb[fr][fc] = null;
163
+ return nb;
164
+ }
165
+ export function inCheck(board, color) {
166
+ const g = findGeneral(board, color);
167
+ if (!g) return true;
168
+ const enemy = color === "r" ? "b" : "r";
169
+ for (let r = 0; r < ROWS; r += 1) {
170
+ const p = board[r][g.c];
171
+ if (p && p.t === "g" && p.c === enemy) {
172
+ let clear = true;
173
+ const lo = Math.min(r, g.r), hi = Math.max(r, g.r);
174
+ for (let i = lo + 1; i < hi; i += 1) if (board[i][g.c]) { clear = false; break; }
175
+ if (clear) return true;
176
+ }
177
+ }
178
+ for (let r = 0; r < ROWS; r += 1) {
179
+ for (let c = 0; c < COLS; c += 1) {
180
+ const p = board[r][c];
181
+ if (p && p.c === enemy && attacks(board, r, c, g.r, g.c)) return true;
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+ export function legalMoves(board, color) {
187
+ const out = [];
188
+ for (let r = 0; r < ROWS; r += 1) {
189
+ for (let c = 0; c < COLS; c += 1) {
190
+ const p = board[r][c];
191
+ if (!p || p.c !== color) continue;
192
+ for (const [nr, nc] of pseudoMoves(board, r, c)) {
193
+ const nb = applyMove(board, r, c, nr, nc);
194
+ if (!inCheck(nb, color)) out.push([r, c, nr, nc]);
195
+ }
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+ export function hasLegalMoves(board, color) {
201
+ return legalMoves(board, color).length > 0;
202
+ }
203
+ function evaluatePosition(board) {
204
+ let score = 0;
205
+ for (let r = 0; r < ROWS; r += 1) {
206
+ for (let c = 0; c < COLS; c += 1) {
207
+ const p = board[r][c];
208
+ if (!p) continue;
209
+ let v = VALUE[p.t];
210
+ if (p.t === "p") v += crossedRiver(r, p.c) ? 50 : 0;
211
+ score += p.c === "b" ? v : -v;
212
+ }
213
+ }
214
+ return score;
215
+ }
216
+ function evalFrom(board, color) {
217
+ const e = evaluatePosition(board);
218
+ return color === "b" ? e : -e;
219
+ }
220
+ function negamax(board, color, depth, alpha, beta) {
221
+ const moves = legalMoves(board, color);
222
+ if (moves.length === 0) {
223
+ return color === "b" ? -1000000 + (10 - depth) : 1000000 - (10 - depth);
224
+ }
225
+ if (depth === 0) return evalFrom(board, color);
226
+ const enemy = color === "r" ? "b" : "r";
227
+ const ordered = orderMoves(board, moves);
228
+ let best = -Infinity;
229
+ for (const m of ordered) {
230
+ const nb = applyMove(board, m[0], m[1], m[2], m[3]);
231
+ const score = -negamax(nb, enemy, depth - 1, -beta, -alpha);
232
+ if (score > best) best = score;
233
+ if (best > alpha) alpha = best;
234
+ if (alpha >= beta) break;
235
+ }
236
+ return best;
237
+ }
238
+ function orderMoves(board, moves) {
239
+ const scored = moves.map((m) => {
240
+ let s = 0;
241
+ const target = board[m[2]][m[3]];
242
+ if (target) s += VALUE[target.t] * 10;
243
+ return { m, s };
244
+ });
245
+ scored.sort((a, b) => b.s - a.s);
246
+ return scored.map((o) => o.m);
247
+ }
248
+ export function bestMove(board, color, depth) {
249
+ const moves = legalMoves(board, color);
250
+ if (!moves.length) return null;
251
+ if (depth <= 1) {
252
+ let best = moves[0], bestScore = -Infinity;
253
+ for (const m of moves) {
254
+ const nb = applyMove(board, m[0], m[1], m[2], m[3]);
255
+ const s = evalFrom(nb, color);
256
+ if (s > bestScore) { bestScore = s; best = m; }
257
+ }
258
+ return best;
259
+ }
260
+ const enemy = color === "r" ? "b" : "r";
261
+ const ordered = orderMoves(board, moves);
262
+ let best = ordered[0], bestScore = -Infinity, alpha = -Infinity;
263
+ for (const m of ordered) {
264
+ const nb = applyMove(board, m[0], m[1], m[2], m[3]);
265
+ const score = -negamax(nb, enemy, depth - 1, -Infinity, -alpha);
266
+ if (score > bestScore) { bestScore = score; best = m; }
267
+ if (bestScore > alpha) alpha = bestScore;
268
+ }
269
+ return best;
270
+ }
@@ -0,0 +1,279 @@
1
+ // dsh-dock · 手机接力局域网安全网关
2
+ //
3
+ // DSH 0.1.1-rc.2 有意拒绝直接绑定 0.0.0.0:浏览器表层尚无登录认证,直接开放
4
+ // 会把代码执行能力交给同一网络里的任何人。本文件提供一个最小认证反向代理:主 DSH
5
+ // 继续只监听 127.0.0.1,手机凭 256-bit 一次性链接换取 HttpOnly 会话后才能访问。
6
+ import { createServer, request as httpRequest } from 'node:http'
7
+ import { networkInterfaces } from 'node:os'
8
+ import { randomBytes } from 'node:crypto'
9
+
10
+ const COOKIE_NAME = 'dsh_mobile_session'
11
+ const CONNECT_PATH = '/__dsh_mobile/connect'
12
+ const HEALTH_PATH = '/__dsh_mobile/health'
13
+ const COMPAT_PATH = '/__dsh_mobile/compat.js'
14
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000
15
+ const MAX_CONNECT_BODY = 4096
16
+ const MAX_HTML_BYTES = 2 * 1024 * 1024
17
+
18
+ // LAN HTTP is not a browser secure context. Some mobile browsers therefore expose
19
+ // crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
20
+ // connection/workspace clients call randomUUID() directly. This tiny standards-compatible
21
+ // UUID v4 fallback is injected before every DSH bootstrap script through the proxy only.
22
+ const MOBILE_COMPAT_JS = `(()=>{const c=globalThis.crypto;if(!c||typeof c.randomUUID==='function'||typeof c.getRandomValues!=='function')return;const make=()=>{const b=new Uint8Array(16);c.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=Array.from(b,x=>x.toString(16).padStart(2,'0')).join('');return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20)};try{Object.defineProperty(c,'randomUUID',{value:make,configurable:true})}catch{try{c.randomUUID=make}catch{}}})()`
23
+
24
+ function secret() { return randomBytes(32).toString('base64url') }
25
+ function now() { return Date.now() }
26
+ function isPrivateIpv4(ip) {
27
+ const p = String(ip).split('.').map(Number)
28
+ return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
29
+ }
30
+ function interfacePriority(name) {
31
+ if (/^en0$/.test(name)) return 100
32
+ if (/^en\d+$/.test(name)) return 90
33
+ if (/^(eth|enp|wlan|wl)/.test(name)) return 80
34
+ if (/^bridge/.test(name)) return 20
35
+ if (/^(utun|tun|tap)/.test(name)) return 10
36
+ return 50
37
+ }
38
+
39
+ /** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
40
+ export function lanAddresses() {
41
+ const rows = []
42
+ const table = networkInterfaces()
43
+ for (const [name, list] of Object.entries(table)) {
44
+ for (const item of list || []) {
45
+ if (!item || item.internal || item.family !== 'IPv4') continue
46
+ rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
47
+ }
48
+ }
49
+ return rows.sort((a, b) => Number(b.private) - Number(a.private)
50
+ || interfacePriority(b.interface) - interfacePriority(a.interface)
51
+ || a.interface.localeCompare(b.interface))
52
+ }
53
+
54
+ function parseCookies(header) {
55
+ const result = {}
56
+ for (const part of String(header || '').split(';')) {
57
+ const at = part.indexOf('=')
58
+ if (at <= 0) continue
59
+ result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
60
+ }
61
+ return result
62
+ }
63
+ function sendJson(res, status, value, headers = {}) {
64
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
65
+ res.end(JSON.stringify(value))
66
+ }
67
+ function readSmallJson(req) {
68
+ return new Promise((resolve, reject) => {
69
+ let size = 0
70
+ const chunks = []
71
+ req.on('data', (chunk) => {
72
+ size += chunk.length
73
+ if (size > MAX_CONNECT_BODY) { reject(new Error('请求过大')); req.destroy(); return }
74
+ chunks.push(chunk)
75
+ })
76
+ req.on('end', () => {
77
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
78
+ catch { reject(new Error('请求格式错误')) }
79
+ })
80
+ req.on('error', reject)
81
+ })
82
+ }
83
+ function connectHtml() {
84
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>连接 DSH</title><style>html{font-family:system-ui,-apple-system,sans-serif;color-scheme:dark light}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);background:#111318;color:#f4f6fa}.card{box-sizing:border-box;width:min(420px,calc(100vw - 32px));padding:28px;border:1px solid #30343d;border-radius:18px;background:#1c1f26;box-shadow:0 18px 60px #0007;text-align:center}.icon{width:48px;height:48px;margin:0 auto 16px;display:grid;place-items:center;border-radius:15px;background:#38bdf822;color:#38bdf8}.icon svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#abb1bd;line-height:1.6;font-size:14px}.error{margin-top:16px;padding:10px 12px;border-radius:10px;background:#ef44441a;color:#ff8b8b;text-align:left}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f1f4f8;color:#16181d}.card{background:#fff;border-color:#d8dde6}.card p{color:#586173}}</style></head><body><main class="card"><div class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></div><h1>正在安全连接</h1><p id="status">验证一次性链接并进入 DSH…</p><div id="error" class="error" role="alert" hidden></div></main><script>(()=>{const status=document.getElementById('status'),error=document.getElementById('error'),token=decodeURIComponent(location.hash.slice(1));history.replaceState(null,'',location.pathname);if(!token){status.textContent='连接链接不完整';error.hidden=false;error.textContent='请回到电脑端重新复制手机接力链接。';return}fetch(${JSON.stringify(CONNECT_PATH)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok)throw new Error(d.error||'连接验证失败');status.textContent='验证成功,正在打开任务…';location.replace('/#dsh-mobile-relay='+encodeURIComponent(d.launch))}).catch(e=>{status.textContent='无法连接';error.hidden=false;error.textContent=e&&e.message?e.message:String(e)})})()</script></body></html>`
85
+ }
86
+ function rewriteProxyHeaders(headers, upstreamAuthority) {
87
+ const next = { ...headers, host: upstreamAuthority }
88
+ delete next.cookie
89
+ // HTML must stay uncompressed so the proxy can inject the early compatibility script.
90
+ delete next['accept-encoding']
91
+ if (next.origin) next.origin = 'http://' + upstreamAuthority
92
+ return next
93
+ }
94
+ function injectMobileCompat(html) {
95
+ if (html.includes('data-dsh-mobile-compat')) return html
96
+ const match = /<head(?:\s[^>]*)?>/i.exec(html)
97
+ const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
98
+ if (!match) return script + html
99
+ const at = match.index + match[0].length
100
+ return html.slice(0, at) + script + html.slice(at)
101
+ }
102
+ function writeUpgradeHead(socket, response) {
103
+ const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
104
+ for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
105
+ socket.write(lines.join('\r\n') + '\r\n\r\n')
106
+ }
107
+
108
+ /**
109
+ * 开启一个需要一次性链接认证的局域网代理。返回的 issue() 生成只在 fragment 中传输的令牌。
110
+ */
111
+ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort }) {
112
+ return new Promise((resolve, reject) => {
113
+ const pending = new Map() // gateway token -> { pairId, launch, expiresAt }
114
+ const sessions = new Map() // cookie secret -> { pairId, expiresAt, seenAt }
115
+ const upgradedSockets = new Map() // client socket -> { sessionId, pairId, upstreamSocket }
116
+ const authority = upstreamHost + ':' + upstreamPort
117
+
118
+ function purge() {
119
+ const stamp = now()
120
+ for (const [key, value] of pending) if (value.expiresAt <= stamp) pending.delete(key)
121
+ const expiredSessions = new Set()
122
+ for (const [key, value] of sessions) {
123
+ if (value.expiresAt <= stamp) { sessions.delete(key); expiredSessions.add(key) }
124
+ }
125
+ if (expiredSessions.size) {
126
+ for (const [socket, value] of upgradedSockets) {
127
+ if (expiredSessions.has(value.sessionId)) {
128
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
129
+ socket.destroy()
130
+ }
131
+ }
132
+ }
133
+ }
134
+ function authorize(req) {
135
+ purge()
136
+ const value = parseCookies(req.headers.cookie)[COOKIE_NAME]
137
+ const session = value ? sessions.get(value) : null
138
+ if (!session) return null
139
+ session.seenAt = now()
140
+ return { ...session, sessionId: value }
141
+ }
142
+ function proxyHttp(req, res) {
143
+ const upstream = httpRequest({
144
+ hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
145
+ headers: rewriteProxyHeaders(req.headers, authority),
146
+ }, (upstreamRes) => {
147
+ const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
148
+ if (!contentType.includes('text/html')) {
149
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
150
+ upstreamRes.pipe(res)
151
+ return
152
+ }
153
+ const chunks = []
154
+ let size = 0
155
+ upstreamRes.on('data', (chunk) => {
156
+ size += chunk.length
157
+ if (size <= MAX_HTML_BYTES) chunks.push(chunk)
158
+ })
159
+ upstreamRes.on('end', () => {
160
+ if (size > MAX_HTML_BYTES) {
161
+ if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
162
+ return
163
+ }
164
+ const headers = { ...upstreamRes.headers }
165
+ delete headers['content-length']
166
+ delete headers['content-encoding']
167
+ headers['cache-control'] = 'no-store'
168
+ res.writeHead(upstreamRes.statusCode || 502, headers)
169
+ res.end(injectMobileCompat(Buffer.concat(chunks).toString('utf8')))
170
+ })
171
+ })
172
+ upstream.on('error', (error) => {
173
+ if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
174
+ else res.destroy(error)
175
+ })
176
+ req.pipe(upstream)
177
+ }
178
+
179
+ const server = createServer(async (req, res) => {
180
+ const url = new URL(req.url || '/', 'http://dsh-mobile.internal')
181
+ if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
182
+ if (url.pathname === CONNECT_PATH && req.method === 'GET') {
183
+ res.writeHead(200, {
184
+ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
185
+ 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
186
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
187
+ })
188
+ return res.end(connectHtml())
189
+ }
190
+ if (url.pathname === CONNECT_PATH && req.method === 'POST') {
191
+ try {
192
+ const body = await readSmallJson(req)
193
+ const token = String(body && body.token || '')
194
+ const grant = pending.get(token)
195
+ if (!grant || grant.expiresAt <= now()) return sendJson(res, 403, { ok: false, error: '一次性链接无效或已过期,请在电脑端重新开启。' })
196
+ pending.delete(token)
197
+ const sessionId = secret()
198
+ sessions.set(sessionId, { pairId: grant.pairId, expiresAt: now() + SESSION_TTL_MS, seenAt: now() })
199
+ return sendJson(res, 200, { ok: true, launch: grant.launch }, {
200
+ 'set-cookie': `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/`,
201
+ })
202
+ } catch (error) {
203
+ return sendJson(res, 400, { ok: false, error: error.message || String(error) })
204
+ }
205
+ }
206
+ if (!authorize(req)) return sendJson(res, 401, { ok: false, error: '手机连接未认证或已过期,请使用电脑端生成的接力链接重新进入。' })
207
+ if (url.pathname === COMPAT_PATH && req.method === 'GET') {
208
+ res.writeHead(200, {
209
+ 'content-type': 'text/javascript; charset=utf-8',
210
+ 'cache-control': 'no-store',
211
+ 'x-content-type-options': 'nosniff',
212
+ })
213
+ return res.end(MOBILE_COMPAT_JS)
214
+ }
215
+ return proxyHttp(req, res)
216
+ })
217
+
218
+ server.on('upgrade', (req, socket, head) => {
219
+ const authorized = authorize(req)
220
+ if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
221
+ const connection = { sessionId: authorized.sessionId, pairId: authorized.pairId, upstreamSocket: null }
222
+ upgradedSockets.set(socket, connection)
223
+ const forgetSocket = () => upgradedSockets.delete(socket)
224
+ socket.once('close', forgetSocket)
225
+ const upstream = httpRequest({
226
+ hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
227
+ headers: rewriteProxyHeaders(req.headers, authority),
228
+ })
229
+ upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
230
+ connection.upstreamSocket = upstreamSocket
231
+ writeUpgradeHead(socket, response)
232
+ if (head && head.length) upstreamSocket.write(head)
233
+ if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
234
+ socket.once('close', () => upstreamSocket.destroy())
235
+ upstreamSocket.once('close', () => socket.destroy())
236
+ socket.pipe(upstreamSocket).pipe(socket)
237
+ })
238
+ upstream.on('response', (response) => {
239
+ socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
240
+ })
241
+ upstream.on('error', () => socket.destroy())
242
+ upstream.end()
243
+ })
244
+ server.once('error', reject)
245
+ server.listen(port, '0.0.0.0', () => {
246
+ server.removeListener('error', reject)
247
+ const listened = server.address()
248
+ const listenedPort = listened && typeof listened === 'object' ? listened.port : port
249
+ resolve({
250
+ port: listenedPort,
251
+ addresses: lanAddresses(),
252
+ issue(pairId, launch, expiresAt) {
253
+ const token = secret()
254
+ pending.set(token, { pairId, launch, expiresAt })
255
+ return token
256
+ },
257
+ revokePair(pairId) {
258
+ for (const [key, value] of pending) if (value.pairId === pairId) pending.delete(key)
259
+ for (const [key, value] of sessions) if (value.pairId === pairId) sessions.delete(key)
260
+ for (const [socket, value] of upgradedSockets) {
261
+ if (value.pairId !== pairId) continue
262
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
263
+ socket.destroy()
264
+ }
265
+ },
266
+ purge,
267
+ close() {
268
+ pending.clear(); sessions.clear()
269
+ for (const [socket, value] of upgradedSockets) {
270
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
271
+ socket.destroy()
272
+ }
273
+ upgradedSockets.clear()
274
+ return new Promise((done) => server.close(() => done()))
275
+ },
276
+ })
277
+ })
278
+ })
279
+ }