@skillpie/dsh-remote 0.0.0-stage → 1.1.0

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,1025 @@
1
+ /**
2
+ * dsh-remote — relay core: code registry, per-code routes, bridging.
3
+ *
4
+ * The same core serves the server-side relay face (on dsh.skillpie.cn) and
5
+ * the local direct mode. A host socket registers with an 8-digit code; the
6
+ * core mounts the public surface for `/<code>` on the given webServer:
7
+ *
8
+ * - `gui` mode (default): transparently mounts the host's real DSH Web GUI
9
+ * under `/<code>/`. HTTP requests are stripped of the prefix, framed over
10
+ * the control channel, and served from the host's own web server over
11
+ * loopback; the GUI's single mux WebSocket (`/api/remote.mux`) is spliced
12
+ * byte-by-byte. Set-cookie lines are rewritten per code (name prefix
13
+ * `r<code>-`, `Path=/` → `Path=/<code>/`) so several hosts can share one
14
+ * relay origin without cookie collisions.
15
+ * - `console` mode: the lightweight visitor console page and its JSON
16
+ * command channel.
17
+ *
18
+ * Registry: hosts keyed by hostId, codes kept unique; tokens stored hashed.
19
+ * A host reconnecting with the correct token supersedes its stale socket;
20
+ * an unknown hostId claims its id fresh (fail-closed, matching the built-in
21
+ * relay's design) and receives a newly minted token.
22
+ */
23
+ import { randomBytes, randomUUID } from 'node:crypto'
24
+ import { WebSocketServer } from 'ws'
25
+ import {
26
+ PROTOCOL_VERSION,
27
+ isValidCode,
28
+ hashToken,
29
+ parseFrame,
30
+ VISITOR_COMMANDS,
31
+ encodeBin,
32
+ decodeBin,
33
+ } from './protocol.js'
34
+ import { adaptSocket } from './ws-adapter.js'
35
+ import { fileRouteKey } from './file-route.js'
36
+
37
+ const HELLO_TIMEOUT_MS = 5_000
38
+ const OFFLINE_GRACE_MS = 10 * 60_000
39
+ const VISITOR_IDLE_MS = 90_000
40
+ const HTTP_OP_TIMEOUT_MS = 150_000
41
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
42
+ /**
43
+ * Inbound caps. Host frames carry whole GUI responses as binary chunks
44
+ * (capped at 8 MiB each by the sender); visitor frames carry POST bodies in
45
+ * the same chunking, so the visitor cap stays generous too. Commands in
46
+ * console mode are tiny and checked separately.
47
+ */
48
+ const MAX_HOST_FRAME_BYTES = 8 * 1024 * 1024
49
+ const MAX_HTML_INJECT_BYTES = 4 * 1024 * 1024
50
+ const MAX_VISITOR_FRAME_BYTES = 8 * 1024 * 1024
51
+
52
+ export class RelayCore {
53
+ constructor({ webServer, consoleHtml, log = () => {}, maxVisitorsPerHost = 8, mode = 'console' }) {
54
+ this.webServer = webServer
55
+ this.consoleHtml = consoleHtml
56
+ this.log = log
57
+ this.maxVisitorsPerHost = maxVisitorsPerHost
58
+ this.mode = mode === 'gui' ? 'gui' : 'console'
59
+ /** hostId → host record */
60
+ this.hosts = new Map()
61
+ /** code → hostId */
62
+ this.codeIndex = new Map()
63
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_VISITOR_FRAME_BYTES })
64
+ this.hostWss = new WebSocketServer({ noServer: true, maxPayload: MAX_HOST_FRAME_BYTES })
65
+ this.cmdSeq = 0
66
+ this.timers = new Set()
67
+ this.closed = false
68
+ }
69
+
70
+ later(fn, ms) {
71
+ const timer = setTimeout(() => {
72
+ this.timers.delete(timer)
73
+ if (!this.closed) fn()
74
+ }, ms)
75
+ this.timers.add(timer)
76
+ return timer
77
+ }
78
+
79
+ disposeTimer(timer) {
80
+ clearTimeout(timer)
81
+ this.timers.delete(timer)
82
+ }
83
+
84
+ /** webServer upgrade handler for the host control channel. */
85
+ acceptHostUpgrade(req, rawSocket, head) {
86
+ if (this.closed) { rawSocket.destroy(); return }
87
+ try {
88
+ this.hostWss.handleUpgrade(req, rawSocket, head, (raw) => {
89
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
90
+ ? req.headers['x-forwarded-for'].split(',')[0].trim()
91
+ : ''
92
+ this.acceptHostSocket(adaptSocket(raw), forwarded)
93
+ })
94
+ } catch (error) {
95
+ this.log('relay', `宿主升级失败(已隔离): ${error?.message ?? error}`)
96
+ try { rawSocket.destroy() } catch { /* gone */ }
97
+ }
98
+ }
99
+
100
+ /** One host control frame, isolated from socket-listener exceptions. */
101
+ onHostFrame(state, socket, data, fail, ip) {
102
+ if (typeof data !== 'string') {
103
+ // Binary: GUI splice payload (response chunk / ws bytes).
104
+ if (!state.hostId) return
105
+ const host = this.hosts.get(state.hostId)
106
+ if (!host) return
107
+ host.lastSeen = Date.now()
108
+ const decoded = decodeBin(data)
109
+ if (!decoded) return
110
+ this.routeBin(host, decoded.meta, decoded.payload)
111
+ return
112
+ }
113
+ const frame = parseFrame(data, MAX_HOST_FRAME_BYTES)
114
+ if (!frame) {
115
+ if (data.length > MAX_HOST_FRAME_BYTES) {
116
+ this.log('relay', `dropped oversized host frame (${Math.round(data.length / 1024)} KiB)`)
117
+ }
118
+ return
119
+ }
120
+ if (frame.t === 'hello') {
121
+ this.disposeTimer(state.helloTimer)
122
+ const registeredId = this.registerHost(socket, frame, state.ip ?? '', fail)
123
+ if (registeredId) state.hostId = registeredId
124
+ return
125
+ }
126
+ if (!state.hostId) return
127
+ const host = this.hosts.get(state.hostId)
128
+ if (!host) return
129
+ host.lastSeen = Date.now()
130
+ if (frame.t === 'hb') {
131
+ socket.send({ t: 'hb' })
132
+ return
133
+ }
134
+ if (frame.t === 'res') this.routeResult(host, frame)
135
+ else if (frame.t === 'push') this.routePush(host, frame)
136
+ else if (frame.t === 'hres' || frame.t === 'hfail') this.routeHttp(host, frame)
137
+ else if (frame.t === 'hend') this.finishHttp(host, frame.id)
138
+ else if (frame.t === 'wok' || frame.t === 'wfail') this.routeSpliceControl(host, frame)
139
+ else if (frame.t === 'wclose') this.finishSplice(host, frame)
140
+ else if (frame.t === 'wdata-text') this.routeText(host, frame)
141
+ }
142
+
143
+ /** Accept one host end (already adapted: pipe end or ws adapter). */
144
+ acceptHostSocket(socket, ip = '') {
145
+ const state = { hostId: undefined, helloTimer: undefined, ip }
146
+ const fail = (error) => {
147
+ socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: false, error })
148
+ this.later(() => socket.close(), 150)
149
+ }
150
+ state.helloTimer = this.later(() => {
151
+ if (!state.hostId) fail('hello timeout')
152
+ }, HELLO_TIMEOUT_MS)
153
+ const offMessage = socket.onMessage((data) => {
154
+ try {
155
+ this.onHostFrame(state, socket, data, fail, state.ip)
156
+ } catch (error) {
157
+ this.log('relay', `宿主机帧处理异常(已隔离): ${error?.message ?? error}`)
158
+ }
159
+ })
160
+ const offClose = socket.onClose(() => {
161
+ this.disposeTimer(state.helloTimer)
162
+ offMessage()
163
+ offClose()
164
+ if (!state.hostId) return
165
+ const host = this.hosts.get(state.hostId)
166
+ if (!host || host.socket !== socket) return
167
+ this.log('relay', `host offline: ${host.name} (${host.code})`)
168
+ host.socket = undefined
169
+ for (const visitor of host.visitors.values()) visitor.socket.close()
170
+ host.visitors.clear()
171
+ host.pending.clear()
172
+ for (const op of host.httpOps.values()) {
173
+ this.disposeTimer(op.timer)
174
+ try { op.res.destroy() } catch { op.res.end() }
175
+ }
176
+ host.httpOps.clear()
177
+ for (const splice of host.splices.values()) {
178
+ try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
179
+ }
180
+ host.splices.clear()
181
+ // Keep the routes briefly so a reconnecting host reclaims them; a host
182
+ // that stays away loses the registration after the grace window.
183
+ host.offlineTimer = this.later(() => this.removeHost(host.hostId, 'offline grace elapsed'), OFFLINE_GRACE_MS)
184
+ })
185
+ }
186
+
187
+ registerHost(socket, hello, ip, fail) {
188
+ const hostId = typeof hello.hostId === 'string' ? hello.hostId.slice(0, 128) : ''
189
+ const code = isValidCode(hello.code) ? hello.code : null
190
+ if (!hostId || !code) return fail('invalid hello')
191
+ const existing = this.hosts.get(hostId)
192
+ const token = typeof hello.token === 'string' ? hello.token : ''
193
+ if (existing) {
194
+ if (token !== '' && hashToken(token) !== existing.tokenHash) {
195
+ this.log('relay', `rejected host ${hostId}: token mismatch`)
196
+ return fail('hostId already registered with a different token')
197
+ }
198
+ if (token === '' && existing.tokenHash !== '') {
199
+ this.log('relay', `rejected host ${hostId}: empty token for known identity`)
200
+ return fail('hostId already registered; token required')
201
+ }
202
+ }
203
+ // Code anti-hijack: a live code bound to another host id never rebinds.
204
+ const codeOwner = this.codeIndex.get(code)
205
+ if (codeOwner !== undefined && codeOwner !== hostId && this.hosts.get(codeOwner)) {
206
+ return fail('code already in use')
207
+ }
208
+ const minted = token === '' || existing?.tokenHash !== hashToken(token)
209
+ const newToken = minted ? randomBytes(24).toString('hex') : token
210
+ if (existing?.offlineTimer) this.disposeTimer(existing.offlineTimer)
211
+ if (existing?.socket && existing.socket !== socket) existing.socket.close()
212
+ if (codeOwner !== undefined && codeOwner !== hostId) {
213
+ // Old owner is gone (removeHost clears the index only for its own code;
214
+ // reaching here means a stale index entry) — reclaim defensively.
215
+ this.codeIndex.delete(code)
216
+ }
217
+ const host = existing ?? {
218
+ hostId,
219
+ visitors: new Map(),
220
+ pending: new Map(),
221
+ httpOps: new Map(),
222
+ splices: new Map(),
223
+ routes: [],
224
+ fileKey: undefined,
225
+ createdAt: Date.now(),
226
+ }
227
+ if (existing) {
228
+ for (const route of host.routes) route()
229
+ host.routes = []
230
+ }
231
+ const previousCode = host.code
232
+ host.code = code
233
+ host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
234
+ host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
235
+ host.ip = ip
236
+ host.fileKey = fileRouteKey(hostId)
237
+ host.tokenHash = hashToken(newToken)
238
+ host.socket = socket
239
+ host.lastSeen = Date.now()
240
+ this.hosts.set(hostId, host)
241
+ this.codeIndex.set(code, hostId)
242
+ if (previousCode && previousCode !== code && this.codeIndex.get(previousCode) === hostId) {
243
+ this.codeIndex.delete(previousCode)
244
+ }
245
+ // Mount the public surface for this code.
246
+ try {
247
+ if (this.mode === 'gui') {
248
+ host.routes.push(this.webServer.register({
249
+ kind: 'prefix',
250
+ path: `/${code}`,
251
+ handler: (req, res) => {
252
+ try { this.proxyHttp(req, res, code, host) } catch (error) {
253
+ this.log('relay', `代理请求失败(已隔离): ${error?.message ?? error}`)
254
+ try { res.writeHead(500).end() } catch { /* gone */ }
255
+ }
256
+ },
257
+ }))
258
+ host.routes.push(this.webServer.registerUpgrade({
259
+ path: `/${code}/api/remote.mux`,
260
+ handler: (req, upgradeSocket, head) => {
261
+ try { this.acceptWsSplice(req, upgradeSocket, head, host) } catch (error) {
262
+ this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
263
+ try { upgradeSocket.destroy() } catch { /* gone */ }
264
+ }
265
+ },
266
+ }))
267
+ } else {
268
+ host.routes.push(this.webServer.register({
269
+ kind: 'prefix',
270
+ path: `/${code}`,
271
+ handler: (req, res) => {
272
+ try { this.serveConsole(req, res, code, host) } catch (error) {
273
+ this.log('relay', `控制台请求失败(已隔离): ${error?.message ?? error}`)
274
+ try { res.writeHead(500).end() } catch { /* gone */ }
275
+ }
276
+ },
277
+ }))
278
+ host.routes.push(this.webServer.registerUpgrade({
279
+ path: `/${code}/ws`,
280
+ handler: (req, upgradeSocket, head) => {
281
+ try { this.acceptVisitor(req, upgradeSocket, head, host) } catch (error) {
282
+ this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
283
+ try { upgradeSocket.destroy() } catch { /* gone */ }
284
+ }
285
+ },
286
+ }))
287
+ }
288
+ } catch (error) {
289
+ this.log('relay', `route registration failed for ${code}: ${error?.message ?? error}`)
290
+ this.codeIndex.delete(code)
291
+ this.hosts.delete(hostId)
292
+ return fail('route registration failed')
293
+ }
294
+ this.log('relay', `host registered: ${host.name} code=${code} mode=${this.mode}${minted ? ' (token minted)' : ''}`)
295
+ socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: true, token: newToken })
296
+ return hostId
297
+ }
298
+
299
+ removeHost(hostId, reason) {
300
+ const host = this.hosts.get(hostId)
301
+ if (!host) return
302
+ this.hosts.delete(hostId)
303
+ if (this.codeIndex.get(host.code) === hostId) this.codeIndex.delete(host.code)
304
+ if (host.offlineTimer) this.disposeTimer(host.offlineTimer)
305
+ for (const route of host.routes) route()
306
+ host.routes = []
307
+ host.socket?.close()
308
+ for (const visitor of host.visitors.values()) visitor.socket.close()
309
+ host.visitors.clear()
310
+ host.pending.clear()
311
+ for (const op of host.httpOps.values()) {
312
+ this.disposeTimer(op.timer)
313
+ try { op.res.destroy() } catch { op.res.end() }
314
+ }
315
+ host.httpOps.clear()
316
+ for (const splice of host.splices.values()) {
317
+ try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
318
+ }
319
+ host.splices.clear()
320
+ this.log('relay', `host removed: ${host.name} (${host.code}) — ${reason}`)
321
+ }
322
+
323
+ /* ── gui mode: transparent HTTP + WS splice ─────────────────────────── */
324
+
325
+ stripPrefix(pathname, code) {
326
+ if (pathname === `/${code}`) return '/'
327
+ return pathname.slice(`/${code}`.length) || '/'
328
+ }
329
+
330
+ /** Strip the per-code prefix from visitor cookie names. */
331
+ stripCodeCookies(header, code) {
332
+ if (typeof header !== 'string' || header === '') return undefined
333
+ const prefix = `r${code}-`
334
+ const kept = []
335
+ for (const segment of header.split(';')) {
336
+ const pair = segment.trim()
337
+ if (!pair) continue
338
+ const at = pair.indexOf('=')
339
+ if (at === -1) continue
340
+ const name = pair.slice(0, at)
341
+ if (!name.startsWith(prefix)) continue
342
+ kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
343
+ }
344
+ return kept.length > 0 ? kept.join('; ') : undefined
345
+ }
346
+
347
+ /** Prefix visitor cookie names and scope Path to the code mount. */
348
+ rewriteSetCookie(line, code) {
349
+ const prefix = `r${code}-`
350
+ const at = line.indexOf('=')
351
+ if (at === -1) return line
352
+ let out = `${prefix}${line.slice(0, at)}=${line.slice(at + 1)}`
353
+ out = out.replace(/Path=\/(?![^\s;])/i, `Path=/${code}/`)
354
+ return out
355
+ }
356
+
357
+ offlineHtml(code) {
358
+ return `<!DOCTYPE html>
359
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>(function(){try{localStorage.setItem('dsh-remote-last-code','${code}');sessionStorage.setItem('dsh-remote-last-code','${code}');if((location.search||'').indexOf('gui')===-1)history.replaceState(null,'','/')}catch(e){}})();</script>
360
+ <meta name="viewport" content="width=device-width, initial-scale=1">
361
+ <title>远程控制 ${code}</title>
362
+ <style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.big{font-size:17px}.hint{font-size:12.5px;color:#9aa0a6}code{font-family:ui-monospace,Menlo,monospace;color:#6b84ff}</style></head>
363
+ <body><div class="big">宿主机暂时离线</div><div class="hint">远程码 <code>${code}</code> 有效,但宿主机尚未连上中转站。页面会自动重试。</div></body></html>`
364
+ }
365
+
366
+ /**
367
+ * Visitor-side URL shim. The proxied GUI and plugin panels call absolute
368
+ * same-origin paths (`/api/...`, `/dsh-dba/...`), which only resolve on the
369
+ * host's own origin. Under `/<code>/` they must ride the prefix so they land
370
+ * on the relay's proxy route and splice mux instead of the SPA fallback.
371
+ * Injected into proxied HTML right after `<head>`; runs before app scripts.
372
+ */
373
+ visitorShimScript(code) {
374
+ const prefix = JSON.stringify(`/${code}`)
375
+ const map = JSON.stringify(['/api/', '/dsh-dba/', '/dsh-market/'])
376
+ return `<script>(function(){
377
+ window.__DSH_REMOTE_VISITOR__=true;
378
+ try{localStorage.setItem('dsh-remote-last-code',${JSON.stringify(code)})}catch(e){}
379
+ var P=${prefix};var M=${map};
380
+ function pick(p){for(var i=0;i<M.length;i++){var m=M[i];if(p===m||p.slice(0,m.length)===m)return true}return false}
381
+ function rebase(u){try{var x=new URL(u,location.href);if((x.protocol==='http:'||x.protocol==='https:'||x.protocol==='ws:'||x.protocol==='wss:')&&x.host===location.host&&pick(x.pathname)){return P+x.pathname+x.search+x.hash}}catch(e){}return u}
382
+ var of=window.fetch;
383
+ window.fetch=function(input,init){
384
+ try{
385
+ if(typeof input==='string')return of(rebase(input),init);
386
+ if(input&&typeof input.url==='string'){var nu=rebase(input.url);if(nu!==input.url)return of(new Request(nu,input),init)}
387
+ }catch(e){}
388
+ return of(input,init);
389
+ };
390
+ var OW=window.WebSocket;
391
+ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?u:String(u));super(u,p)}};
392
+ })(/* dsh-remote visitor shim */);</script>`
393
+ }
394
+
395
+ /**
396
+ * Code concealment: remember the code, pin relative resolution to the
397
+ * tunnel prefix (history.replaceState would change how `<base href="./">`
398
+ * resolves), strip the code from the visible URL, and register the
399
+ * origin-level service worker so later visits to the bare origin restore
400
+ * the tunnel without the code ever reappearing in the address bar.
401
+ * Injected right after `<base>`; runs before app scripts.
402
+ */
403
+ concealScript(code) {
404
+ const codeJson = JSON.stringify(code)
405
+ const prefixJson = JSON.stringify(`/${code}/`)
406
+ return `<script>(function(){
407
+ window.__DSH_REMOTE_CONCEAL__=true;
408
+ try{
409
+ var C=${codeJson};
410
+ if((location.search||'').indexOf('gui')!==-1){try{sessionStorage.setItem('dsh-remote-bypass','1')}catch(e){}return}
411
+ var b=document.querySelector('base');
412
+ if(b)b.setAttribute('href',${prefixJson});
413
+ try{localStorage.setItem('dsh-remote-last-code',C)}catch(e){}
414
+ try{sessionStorage.setItem('dsh-remote-last-code',C)}catch(e){}
415
+ try{history.replaceState(null,'','/')}catch(e){}
416
+ if(navigator.serviceWorker&&window.isSecureContext){
417
+ navigator.serviceWorker.register('/dsh-remote-sw.js?code='+C,{scope:'/'}).catch(function(){})
418
+ }
419
+ }catch(e){}
420
+ })(/* dsh-remote conceal */);</script>`
421
+ }
422
+
423
+ /**
424
+ * Visitor-side "download file" menu patch. The host GUI's file menu
425
+ * (OpenTargetButton) only hands paths to the host desktop; a remote
426
+ * visitor has no way to save the file locally. This script watches for
427
+ * that menu opening, prepends a "下载文件" item (styled after the native
428
+ * menu rows) and serves the file through the host's `/dsh-remote-file`
429
+ * route via the tunnel.
430
+ *
431
+ * The absolute path comes from the preview header's PathLabel
432
+ * (`[data-path-label]` holds it in `title`); as a fallback we tap mux
433
+ * frames for the last path the app requested (openWorkspacePath /
434
+ * workspacePathApplications payloads carry `"path":"…"`).
435
+ * Injected last, after the visitor shim (its WebSocket wrapper stays).
436
+ */
437
+ downloadMenuScript(code, key) {
438
+ const codeJson = JSON.stringify(code)
439
+ const keyJson = JSON.stringify(String(key ?? ''))
440
+ return `<script>(function(){
441
+ window.__DSH_REMOTE_DOWNLOAD__=true;
442
+ try{
443
+ var C=${codeJson};var K=${keyJson};
444
+ var M=/显示文件位置|Show file location|ファイルの場所| 파일 위치|Открыть расположение/;
445
+ var PATH_RE=/^([\\/~]|[A-Za-z]:[\\\\\\/])/;
446
+ function isPath(s){return typeof s==='string'&&s.length>1&&s.length<1024&&PATH_RE.test(s)&&s.indexOf('/')>-1}
447
+ var lastMux=null;
448
+ function notePath(p){if(isPath(p))lastMux={path:p,at:Date.now()}}
449
+ var OW=window.WebSocket;
450
+ window.WebSocket=class extends OW{constructor(u,p){super(u,p);var self=this;var s=super.send;
451
+ // tap outgoing mux frames for file paths (fallback when no PathLabel DOM)
452
+ this.send=function(data){
453
+ try{
454
+ if(typeof data==='string'&&data.indexOf('"path"')>-1){
455
+ var m=data.match(/"path"\\s*:\\s*"((?:[^"\\\\]|\\\\(?:u[0-9a-fA-F]{4}|.))*)"/);
456
+ if(m){try{notePath(JSON.parse('"'+m[1]+'"'))}catch(e){}}
457
+ }
458
+ }catch(e){}
459
+ return s.call(self,data)}
460
+ }};
461
+ function base(){return location.pathname.replace(new RegExp('^/'+C+'(/|$)'),'/')==='/'
462
+ ? '/'+C : location.pathname.split('/').slice(0,2).join('/')}
463
+ function download(path){
464
+ var url=base()+'/dsh-remote-file?path='+encodeURIComponent(path);
465
+ return fetch(url,{credentials:'omit',headers:{'x-dsh-remote-key':K}}).then(function(r){
466
+ if(!r.ok)throw new Error('HTTP '+r.status);
467
+ var cd=r.headers.get('content-disposition')||'';
468
+ var m=cd.match(/filename\\*=UTF-8''([^;]+)/i);
469
+ var name=m?decodeURIComponent(m[1]):(path.split('/').pop()||'download');
470
+ return r.blob().then(function(b){
471
+ var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
472
+ document.body.appendChild(a);a.click();a.remove();
473
+ setTimeout(function(){URL.revokeObjectURL(a.href)},30000);
474
+ });
475
+ });
476
+ }
477
+ function findLabel(el){
478
+ for(var n=el,i=0;n&&i<8;i++,n=n.parentElement){
479
+ var lbl=n.querySelector&&n.querySelector('[data-path-label]');
480
+ if(lbl){var t=lbl.getAttribute('title')||lbl.textContent;if(isPath(t))return t}
481
+ }
482
+ return null;
483
+ }
484
+ function patch(menu){
485
+ if(menu.getAttribute('data-dsh-remote-dl'))return;
486
+ menu.setAttribute('data-dsh-remote-dl','1');
487
+ var items=menu.querySelectorAll('[role=menuitem]');
488
+ if(!items.length)return;
489
+ var itemsArr=Array.prototype.slice.call(items);
490
+ // Only file menus: the native reveal item, or a menu anchored by the file target.
491
+ var trigger=null;
492
+ var mid=menu.id||'';
493
+ if(mid){var cands=document.querySelectorAll('[aria-controls]');
494
+ for(var i=0;i<cands.length;i++){var v=(cands[i].getAttribute('aria-controls')||'').split(/\\s+/);
495
+ if(v.indexOf(mid)>-1){trigger=cands[i];break}}}
496
+ var hasReveal=itemsArr.some(function(it){return M.test(it.textContent||'')});
497
+ var isFile=hasReveal||(trigger&&trigger.closest&&trigger.closest('[data-open-target="file"]'));
498
+ if(!isFile)return;
499
+ var first=itemsArr[0];
500
+ var btn=document.createElement('button');
501
+ btn.setAttribute('role','menuitem');
502
+ btn.setAttribute('type','button');
503
+ btn.className=first.className||'';
504
+ btn.style.cssText='all:unset;display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;cursor:pointer;color:inherit;font:inherit;';
505
+ for(var a=0;a<first.attributes.length;a++){var at=first.attributes[a];
506
+ if(at.name!=='role'&&at.name!=='type'&&at.name!=='disabled'&&at.name.indexOf('data-')!==0&&!/^(id|aria-(labelledby|describedby))$/.test(at.name)){try{btn.setAttribute(at.name,at.value)}catch(e){}}}
507
+ btn.setAttribute('data-dsh-remote-dl-item','1');
508
+ var zh=(document.documentElement.lang||'').toLowerCase().indexOf('zh')===0;
509
+ btn.innerHTML='<span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;opacity:.85">'
510
+ +'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.2v7.6m0 0 2.8-2.8M8 9.8 5.2 7M3 12.6h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
511
+ +'</span><span></span>';
512
+ btn.lastElementChild.textContent=zh?'下载文件':'Download file';
513
+ btn.addEventListener('click',function(ev){
514
+ ev.preventDefault();ev.stopPropagation();
515
+ var path=findLabel(trigger||first)||((lastMux&&Date.now()-(lastMux.at||0)<120000)?lastMux.path:null);
516
+ if(!path){
517
+ var t=btn.lastElementChild;var old=t.textContent;
518
+ t.textContent=zh?'无法定位文件':'Cannot locate file';
519
+ setTimeout(function(){t.textContent=old},1200);
520
+ return;
521
+ }
522
+ download(path).catch(function(){});
523
+ });
524
+ first.parentNode.insertBefore(btn,first);
525
+ }
526
+ var mo=new MutationObserver(function(muts){
527
+ for(var i=0;i<muts.length;i++){var add=muts[i].addedNodes;
528
+ for(var j=0;j<add.length;j++){var n=add[j];
529
+ if(!n||n.nodeType!==1)continue;
530
+ var menus=n.matches&&n.matches('[role=menu]')?[n]:(n.querySelectorAll?n.querySelectorAll('[role=menu]'):[]);
531
+ for(var k=0;k<menus.length;k++){try{patch(menus[k])}catch(e){}}
532
+ }}
533
+ });
534
+ function start(){try{mo.observe(document.body,{childList:true,subtree:true})}catch(e){}}
535
+ if(document.body)start();else document.addEventListener('DOMContentLoaded',start);
536
+ // Menus already open before the observer started.
537
+ try{document.querySelectorAll('[role=menu]').forEach(patch)}catch(e){}
538
+ }catch(e){}
539
+ })(/* dsh-remote download menu */);</script>`
540
+ }
541
+
542
+ injectVisitorShim(html, code, fileKey) {
543
+ if (typeof html !== 'string' || html.length === 0) return html
544
+ let scripts = this.concealScript(code) + this.visitorShimScript(code)
545
+ if (typeof fileKey === 'string' && fileKey.length > 0) {
546
+ scripts += this.downloadMenuScript(code, fileKey)
547
+ }
548
+ if (/<base[^>]*>/i.test(html)) return html.replace(/<base[^>]*>/i, (m) => m + scripts)
549
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + scripts)
550
+ return scripts + html
551
+ }
552
+
553
+ proxyHttp(req, res, code, host) {
554
+ let pathname = '/'
555
+ let search = ''
556
+ try {
557
+ const url = new URL(req.url ?? '/', 'http://localhost')
558
+ pathname = url.pathname
559
+ search = url.search
560
+ } catch { pathname = '/' }
561
+ const subpath = this.stripPrefix(pathname, code)
562
+ const targetPath = subpath + search
563
+ const send = (status, body, headers = {}) => {
564
+ if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
565
+ res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers })
566
+ res.end(body)
567
+ }
568
+ if (pathname === `/${code}` && (req.method === 'GET' || req.method === 'HEAD')) {
569
+ res.writeHead(302, { location: `/${code}/`, 'cache-control': 'no-store' })
570
+ res.end()
571
+ return
572
+ }
573
+ if (!host.socket) {
574
+ if (subpath === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
575
+ send(200, this.offlineHtml(code), { 'Content-Type': 'text/html; charset=utf-8' })
576
+ } else {
577
+ send(504, '宿主机离线\n', { 'Content-Type': 'text/plain; charset=utf-8' })
578
+ }
579
+ return
580
+ }
581
+ if (host.httpOps.size >= 64) {
582
+ send(429, 'too many in-flight requests\n', { 'Content-Type': 'text/plain; charset=utf-8' })
583
+ return
584
+ }
585
+ const id = `h${++this.cmdSeq}`
586
+ // Buffer the request body (GUI uploads included), then frame it over.
587
+ const chunks = []
588
+ let size = 0
589
+ let done = false
590
+ let failed = false
591
+ req.on('data', (chunk) => {
592
+ size += chunk.length
593
+ if (size > MAX_REQUEST_BODY_BYTES) {
594
+ if (!failed) { failed = true; send(413, 'payload too large\n', { 'Content-Type': 'text/plain; charset=utf-8' }) }
595
+ req.destroy()
596
+ return
597
+ }
598
+ chunks.push(chunk)
599
+ })
600
+ req.on('error', () => { failed = true })
601
+ req.on('end', () => { done = true })
602
+ const op = { res, timer: undefined, done: false, method: req.method }
603
+ host.httpOps.set(id, op)
604
+ op.timer = this.later(() => {
605
+ if (!op.done) {
606
+ host.httpOps.delete(id)
607
+ send(504, '宿主机响应超时\n', { 'Content-Type': 'text/plain; charset=utf-8' })
608
+ }
609
+ }, HTTP_OP_TIMEOUT_MS)
610
+ res.on('close', () => {
611
+ if (!op.done && host.socket) host.socket.send({ t: 'habort', id })
612
+ })
613
+ const dispatch = () => {
614
+ if (failed || this.closed) { host.httpOps.delete(id); return }
615
+ const body = Buffer.concat(chunks)
616
+ host.socket.send({
617
+ t: 'hreq',
618
+ id,
619
+ m: req.method,
620
+ p: targetPath,
621
+ h: {
622
+ ...sanitize(req.headers, code),
623
+ ...(body.length > 0 ? { 'content-length': String(body.length) } : {}),
624
+ },
625
+ })
626
+ if (body.length > 0) host.socket.sendRaw(encodeBin({ t: 'hchunk', id }, body))
627
+ host.socket.send({ t: 'hend', id })
628
+ }
629
+ if (done) dispatch()
630
+ else req.on('end', () => dispatch())
631
+
632
+ function sanitize(headers, cd) {
633
+ const out = {}
634
+ const drop = new Set(['host', 'origin', 'referer', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'accept-encoding', 'content-length'])
635
+ for (const [name, value] of Object.entries(headers)) {
636
+ const lower = name.toLowerCase()
637
+ if (drop.has(lower) || lower.startsWith('sec-')) continue
638
+ if (lower === 'cookie') {
639
+ const stripped = stripCookiesHeader(value, cd)
640
+ if (stripped) out.cookie = stripped
641
+ continue
642
+ }
643
+ out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
644
+ }
645
+ return out
646
+ }
647
+ function stripCookiesHeader(header, cd) {
648
+ if (typeof header !== 'string' || header === '') return undefined
649
+ const prefix = `r${cd}-`
650
+ const kept = []
651
+ for (const segment of header.split(';')) {
652
+ const pair = segment.trim()
653
+ const at = pair.indexOf('=')
654
+ if (at === -1) continue
655
+ const name = pair.slice(0, at)
656
+ if (!name.startsWith(prefix)) continue
657
+ kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
658
+ }
659
+ return kept.length > 0 ? kept.join('; ') : undefined
660
+ }
661
+ }
662
+
663
+ routeHttp(host, frame) {
664
+ const op = host.httpOps.get(frame.id)
665
+ if (!op) return
666
+ if (frame.t === 'hfail') {
667
+ host.httpOps.delete(frame.id)
668
+ this.disposeTimer(op.timer)
669
+ op.done = true
670
+ if (!op.res.headersSent) {
671
+ op.res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
672
+ op.res.end(`宿主机桥接失败: ${frame.error ?? 'unknown'}\n`)
673
+ } else {
674
+ try { op.res.destroy() } catch { op.res.end() }
675
+ }
676
+ return
677
+ }
678
+ const headers = { ...(frame.h ?? {}) }
679
+ const setCookies = Array.isArray(frame.sc) ? frame.sc.map((line) => this.rewriteSetCookie(line, host.code)) : []
680
+ if (setCookies.length > 0) headers['set-cookie'] = setCookies
681
+ // HTML 响应缓冲注入访客 shim(GET/HEAD 才有 body;上限见 MAX_HTML_INJECT_BYTES)。
682
+ if (
683
+ (op.method === 'GET' || op.method === 'HEAD') &&
684
+ /text\/html/i.test(String(headers['content-type'] ?? ''))
685
+ ) {
686
+ op.htmlInject = true
687
+ op.parts = []
688
+ op.htmlBytes = 0
689
+ }
690
+ try {
691
+ op.res.writeHead(frame.s ?? 502, headers)
692
+ } catch (error) {
693
+ this.log('relay', `bad upstream response for ${host.code}: ${error?.message ?? error}`)
694
+ host.httpOps.delete(frame.id)
695
+ this.disposeTimer(op.timer)
696
+ op.done = true
697
+ try { op.res.destroy() } catch { op.res.end() }
698
+ }
699
+ }
700
+
701
+ finishHttp(host, id) {
702
+ const op = host.httpOps.get(id)
703
+ if (!op) return
704
+ host.httpOps.delete(id)
705
+ this.disposeTimer(op.timer)
706
+ op.done = true
707
+ try {
708
+ if (op.htmlInject) {
709
+ const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code, host.fileKey)
710
+ op.res.end(html)
711
+ } else {
712
+ op.res.end()
713
+ }
714
+ } catch { op.res.destroy() }
715
+ }
716
+
717
+ routeText(host, frame) {
718
+ if (frame.t !== 'wdata-text' || typeof frame.text !== 'string') return
719
+ const splice = host.splices.get(frame.id)
720
+ // The visitor socket is a WebSocket: send a ws text frame, never raw bytes.
721
+ if (splice && !splice.closed) splice.socket.sendText(frame.text)
722
+ }
723
+
724
+ routeBin(host, meta, payload) {
725
+ if (meta.t === 'hchunk') {
726
+ const op = host.httpOps.get(meta.id)
727
+ if (!op || op.res.writableEnded) return
728
+ if (op.htmlInject) {
729
+ op.htmlBytes += payload.length
730
+ if (op.htmlBytes <= MAX_HTML_INJECT_BYTES) {
731
+ op.parts.push(payload)
732
+ return
733
+ }
734
+ // 超限放弃注入:先冲掉已缓冲内容再直写当前块,保持字节顺序。
735
+ op.htmlInject = false
736
+ for (const part of op.parts) op.res.write(part)
737
+ op.parts = []
738
+ }
739
+ op.res.write(payload)
740
+ return
741
+ }
742
+ if (meta.t === 'wdata') {
743
+ const splice = host.splices.get(meta.id)
744
+ // The visitor socket is a WebSocket: send a ws binary frame, never raw bytes.
745
+ if (splice && !splice.closed) splice.socket.sendRaw(payload)
746
+ }
747
+ }
748
+
749
+ acceptWsSplice(req, rawSocket, head, host) {
750
+ if (this.closed || !host) { rawSocket.destroy(); return }
751
+ const origin = req.headers.origin
752
+ if (typeof origin === 'string' && origin !== '') {
753
+ let sameOrigin = false
754
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
755
+ if (!sameOrigin) { rawSocket.destroy(); return }
756
+ }
757
+ if (host.splices.size >= this.maxVisitorsPerHost) {
758
+ this.log('relay', `splice rejected on ${host.code}: visitor cap`)
759
+ rawSocket.destroy()
760
+ return
761
+ }
762
+ let target = '/api/remote.mux'
763
+ try {
764
+ const url = new URL(req.url ?? '/', 'http://localhost')
765
+ target = this.stripPrefix(url.pathname, host.code) + url.search
766
+ } catch { target = '/api/remote.mux' }
767
+ const subpath = target
768
+ const id = `s${++this.cmdSeq}`
769
+ const splicedHeaders = sanitizeUpgradeHeaders(req.headers, host.code)
770
+ function sanitizeUpgradeHeaders(headers, cd) {
771
+ const out = {}
772
+ const drop = new Set(['host', 'origin', 'referer', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol'])
773
+ for (const [name, value] of Object.entries(headers)) {
774
+ const lower = name.toLowerCase()
775
+ if (drop.has(lower) || lower.startsWith('sec-')) continue
776
+ if (lower === 'cookie') {
777
+ const prefix = `r${cd}-`
778
+ const kept = []
779
+ for (const segment of String(value).split(';')) {
780
+ const pair = segment.trim()
781
+ const at = pair.indexOf('=')
782
+ if (at === -1) continue
783
+ const cname = pair.slice(0, at)
784
+ if (!cname.startsWith(prefix)) continue
785
+ kept.push(`${cname.slice(prefix.length)}=${pair.slice(at + 1)}`)
786
+ }
787
+ if (kept.length > 0) out.cookie = kept.join('; ')
788
+ continue
789
+ }
790
+ out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
791
+ }
792
+ return out
793
+ }
794
+ this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
795
+ const socket = adaptSocket(raw)
796
+ const splice = { raw, socket, closed: false }
797
+ host.splices.set(id, splice)
798
+ if (!host.socket) {
799
+ socket.close()
800
+ host.splices.delete(id)
801
+ return
802
+ }
803
+ host.socket.send({ t: 'wopen', id, p: subpath, h: splicedHeaders })
804
+ this.log('relay', `splice ${id} opened on ${host.code}: ${subpath}`)
805
+ const offMessage = socket.onMessage((data) => {
806
+ try {
807
+ if (!host.socket) return
808
+ // The adapter hands through Buffers for binary and strings for text.
809
+ if (Buffer.isBuffer(data)) host.socket.sendRaw(encodeBin({ t: 'wdata', id }, data))
810
+ else host.socket.send({ t: 'wdata-text', id, text: data })
811
+ } catch (error) {
812
+ this.log('relay', `访客帧处理异常(已隔离): ${error?.message ?? error}`)
813
+ }
814
+ })
815
+ const offClose = socket.onClose(() => {
816
+ offMessage()
817
+ offClose()
818
+ if (splice.closed) return
819
+ splice.closed = true
820
+ host.splices.delete(id)
821
+ if (host.socket) host.socket.send({ t: 'wclose', id, code: 1001, reason: '' })
822
+ this.log('relay', `splice ${id} closed on ${host.code}`)
823
+ })
824
+ })
825
+ }
826
+
827
+ routeSpliceControl(host, frame) {
828
+ const splice = host.splices.get(frame.id)
829
+ if (!splice) return
830
+ if (frame.t === 'wfail') {
831
+ splice.closed = true
832
+ host.splices.delete(frame.id)
833
+ try { splice.socket.close(1014, 'upstream failed') } catch { splice.raw.destroy() }
834
+ this.log('relay', `splice ${frame.id} failed: ${frame.error ?? 'unknown'}`)
835
+ }
836
+ // wok needs no relay-side action; splice is already bidirectional.
837
+ }
838
+
839
+ finishSplice(host, frame) {
840
+ const splice = host.splices.get(frame.id)
841
+ if (!splice) return
842
+ splice.closed = true
843
+ host.splices.delete(frame.id)
844
+ try { splice.socket.close(frame.code ?? 1000, frame.reason ?? '') } catch { try { splice.raw.destroy() } catch { /* gone */ } }
845
+ }
846
+
847
+ /* ── console mode ───────────────────────────────────────────────────── */
848
+
849
+ serveConsole(req, res, code, host) {
850
+ let pathname = ''
851
+ try { pathname = new URL(req.url ?? '/', 'http://localhost').pathname } catch { pathname = '/' }
852
+ const send = (status, body, type) => {
853
+ res.writeHead(status, {
854
+ 'Content-Type': type,
855
+ 'Cache-Control': 'no-store',
856
+ 'X-Content-Type-Options': 'nosniff',
857
+ })
858
+ res.end(body)
859
+ }
860
+ if (pathname !== `/${code}`) {
861
+ send(404, JSON.stringify({ ok: false, error: 'not found' }) + '\n', 'application/json; charset=utf-8')
862
+ return
863
+ }
864
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
865
+ send(405, JSON.stringify({ ok: false, error: 'method not allowed' }) + '\n', 'application/json; charset=utf-8')
866
+ return
867
+ }
868
+ send(200, this.consoleHtml({ code, offline: !host.socket }), 'text/html; charset=utf-8')
869
+ }
870
+
871
+ acceptVisitor(req, rawSocket, head, host) {
872
+ if (this.closed || !host) { rawSocket.destroy(); return }
873
+ // Same-origin browsers only; scripts without Origin pass (curl, CLI).
874
+ const origin = req.headers.origin
875
+ if (typeof origin === 'string' && origin !== '') {
876
+ let sameOrigin = false
877
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
878
+ if (!sameOrigin) { rawSocket.destroy(); return }
879
+ }
880
+ if (host.visitors.size >= this.maxVisitorsPerHost) {
881
+ this.log('relay', `visitor rejected on ${host.code}: visitor cap`)
882
+ rawSocket.destroy()
883
+ return
884
+ }
885
+ this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
886
+ const socket = adaptSocket(raw)
887
+ const visitorId = 'v-' + randomUUID().slice(0, 8)
888
+ host.visitors.set(visitorId, { socket, connectedAt: Date.now() })
889
+ this.log('relay', `visitor ${visitorId} connected to ${host.code}`)
890
+ // Engine composes the welcome payload; relay forwards it as-is.
891
+ this.toHost(host, 'visitor.hello', visitorId, undefined, (frame) => {
892
+ if (frame.ok) {
893
+ socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, host: frame.data.host, sessions: frame.data.sessions })
894
+ } else {
895
+ socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, error: frame.error })
896
+ }
897
+ })
898
+ let visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
899
+ const refreshIdle = () => {
900
+ this.disposeTimer(visitorIdleTimer)
901
+ visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
902
+ }
903
+ const offMessage = socket.onMessage((data) => {
904
+ if (typeof data !== 'string') return
905
+ refreshIdle()
906
+ const frame = parseFrame(data, 64 * 1024)
907
+ if (!frame || frame.t !== 'cmd' || typeof frame.id !== 'string') return
908
+ if (VISITOR_COMMANDS.has(frame.cmd)) {
909
+ this.toHost(host, frame.cmd, visitorId, frame.p, (result) => {
910
+ socket.send({ t: 'res', id: frame.id, ok: result.ok, ...(result.ok ? { data: result.data } : { error: result.error }) })
911
+ })
912
+ } else {
913
+ socket.send({ t: 'res', id: frame.id, ok: false, error: 'unsupported command' })
914
+ }
915
+ })
916
+ const offClose = socket.onClose(() => {
917
+ this.disposeTimer(visitorIdleTimer)
918
+ offMessage()
919
+ offClose()
920
+ host.visitors.delete(visitorId)
921
+ if (host.socket) this.toHost(host, 'visitor.gone', visitorId, undefined, () => {})
922
+ this.log('relay', `visitor ${visitorId} disconnected from ${host.code}`)
923
+ })
924
+ })
925
+ }
926
+
927
+ toHost(host, cmd, visitorId, p, onResult) {
928
+ const socket = host.socket
929
+ if (!socket) return onResult({ ok: false, error: '宿主机离线' })
930
+ const id = `c${++this.cmdSeq}`
931
+ host.pending.set(id, { onResult, timer: this.later(() => {
932
+ host.pending.delete(id)
933
+ onResult({ ok: false, error: '宿主机响应超时' })
934
+ }, 20_000) })
935
+ socket.send({ t: 'cmd', id, from: visitorId, cmd, ...(p === undefined ? {} : { p }) })
936
+ }
937
+
938
+ routeResult(host, frame) {
939
+ const entry = host.pending.get(frame.id)
940
+ if (!entry) return
941
+ host.pending.delete(frame.id)
942
+ this.disposeTimer(entry.timer)
943
+ entry.onResult({ ok: frame.ok === true, data: frame.data, error: frame.error })
944
+ }
945
+
946
+ routePush(host, frame) {
947
+ if (typeof frame.to !== 'string') return
948
+ const visitor = host.visitors.get(frame.to)
949
+ if (visitor) visitor.socket.send({ t: 'push', f: frame.f })
950
+ }
951
+
952
+ detachAll() {
953
+ this.closed = true
954
+ for (const timer of this.timers) clearTimeout(timer)
955
+ this.timers.clear()
956
+ for (const hostId of [...this.hosts.keys()]) this.removeHost(hostId, 'relay shutting down')
957
+ this.wss.close()
958
+ this.hostWss.close()
959
+ }
960
+
961
+ status() {
962
+ return {
963
+ mode: this.mode,
964
+ hosts: [...this.hosts.values()].map((host) => ({
965
+ hostId: host.hostId,
966
+ code: host.code,
967
+ name: host.name,
968
+ dsh: host.dsh,
969
+ online: host.socket !== undefined,
970
+ visitors: this.mode === 'gui' ? host.splices.size : host.visitors.size,
971
+ createdAt: host.createdAt,
972
+ })),
973
+ }
974
+ }
975
+ }
976
+
977
+ export { PROTOCOL_VERSION }
978
+
979
+ /**
980
+ * Origin-level service worker served at /dsh-remote-sw.js. It reads its own
981
+ * scriptURL query for the code and, from then on, intercepts navigations to
982
+ * the bare origin (which belong to the relay's authenticated GUI and 401 for
983
+ * visitors) and serves the tunnel under /<code>/ instead — the address bar
984
+ * keeps showing the bare origin. Carriers of the relay server's own session
985
+ * cookie (`dsh-auth-…`) pass through untouched, so the relay owner's GUI is
986
+ * never hijacked, and `?gui` is a manual escape.
987
+ */
988
+ export function serviceWorkerScript() {
989
+ return `/* dsh-remote visitor restore worker */
990
+ var CODE='';
991
+ try{CODE=new URL(self.location.href).searchParams.get('code')||''}catch(e){}
992
+ self.addEventListener('install',function(e){self.skipWaiting()});
993
+ self.addEventListener('activate',function(e){e.waitUntil(self.clients.claim())});
994
+ self.addEventListener('fetch',function(event){
995
+ try{
996
+ var req=event.request;
997
+ if(req.mode!=='navigate')return;
998
+ var url=new URL(req.url);
999
+ if(url.origin!==self.location.origin)return;
1000
+ if((url.search||'').indexOf('gui')!==-1)return;
1001
+ if((req.headers.get('cookie')||'').indexOf('dsh-auth-')!==-1)return;
1002
+ if(url.pathname!=='/'&&url.pathname!=='')return;
1003
+ if(!/^[1-9]\\d{7}$/.test(CODE))return;
1004
+ event.respondWith((async function(){
1005
+ var up;
1006
+ try{up=await fetch('/'+CODE+'/',{cache:'no-store'})}catch(e){return}
1007
+ var ct=up.headers.get('content-type')||'';
1008
+ if(ct.indexOf('text/html')===-1)return up;
1009
+ var text=await up.text();
1010
+ if(text.indexOf('__DSH_REMOTE_VISITOR__')===-1)return fetch(req);
1011
+ return new Response(text,{status:up.status,statusText:up.statusText,headers:up.headers});
1012
+ })());
1013
+ }catch(e){}
1014
+ });
1015
+ `
1016
+ }
1017
+
1018
+ export function serveServiceWorker(req, res) {
1019
+ res.writeHead(200, {
1020
+ 'Content-Type': 'text/javascript; charset=utf-8',
1021
+ 'Cache-Control': 'no-store',
1022
+ 'Service-Worker-Allowed': '/',
1023
+ })
1024
+ res.end(serviceWorkerScript())
1025
+ }