dsh-remote-hub 0.2.2 → 0.3.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.
- package/README.md +15 -2
- package/bin/dsh-remote.mjs +5 -0
- package/lib/bridge.js +24 -2
- package/lib/discover.js +43 -48
- package/lib/index.js +9 -1
- package/lib/supervisor.js +138 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,9 +19,9 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
|
|
|
19
19
|
|
|
20
20
|
## 下载手机端 App
|
|
21
21
|
|
|
22
|
-
**DSH Remote v4.20.
|
|
22
|
+
**DSH Remote v4.20.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
|
|
23
23
|
|
|
24
|
-
[⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.
|
|
24
|
+
[⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.1/DSH-Remote-v4.20.1.apk) ·
|
|
25
25
|
[全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
|
|
26
26
|
|
|
27
27
|
安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
|
|
@@ -222,6 +222,19 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
|
|
|
222
222
|
> 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
|
|
223
223
|
> 并在 `status.json` 里写明原因,不会假装成功。
|
|
224
224
|
|
|
225
|
+
### 装完之后
|
|
226
|
+
|
|
227
|
+
插件随 DSH 启动自动工作,**不需要手动跑 connect.cmd**;装的时候没有提示是正常的,日志里会出现:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:<端口>/_dsh-remote/pair)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
想手动确认/手动起,就在仓库目录双击 `connect.cmd`(它和插件是同一份代码,会打印地址与 Cookie 并自动打开二维码)。
|
|
234
|
+
|
|
235
|
+
**Cookie 跨重启有效**:桥接始终向 DSH 呈现固定 authority(默认 `127.0.0.1:1815`),
|
|
236
|
+
所以 DSH 换端口、桥接重启都不会让手机上的凭据失效;只有签名密钥变化或 Cookie 到期(30 天)才需要重新扫码。
|
|
237
|
+
|
|
225
238
|
### 扫码配对(一次性配对码)
|
|
226
239
|
|
|
227
240
|
不想手抄 Cookie 就用二维码:
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -136,6 +136,11 @@ async function main() {
|
|
|
136
136
|
switch (command) {
|
|
137
137
|
case 'status':
|
|
138
138
|
printStatus()
|
|
139
|
+
if (!readStatus()) {
|
|
140
|
+
console.log(' 下一步:dsh-remote start --open (起桥接并打开配对二维码)')
|
|
141
|
+
console.log(' 或双击仓库里的 connect.cmd')
|
|
142
|
+
console.log('')
|
|
143
|
+
}
|
|
139
144
|
break
|
|
140
145
|
case 'connect':
|
|
141
146
|
if (fs.existsSync(CONNECT_FILE)) console.log(fs.readFileSync(CONNECT_FILE, 'utf8'))
|
package/lib/bridge.js
CHANGED
|
@@ -149,6 +149,24 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
|
|
|
149
149
|
})
|
|
150
150
|
|
|
151
151
|
const server = http.createServer((req, res) => {
|
|
152
|
+
// Health probe: lets the CLI and a second instance tell "my bridge is already running"
|
|
153
|
+
// apart from "some other program holds the port".
|
|
154
|
+
if (String(req.url || '') === '/_dsh-remote/health') {
|
|
155
|
+
const body = JSON.stringify({
|
|
156
|
+
ok: true,
|
|
157
|
+
service: 'dsh-remote-bridge',
|
|
158
|
+
// A bridge built before pairing existed has no such route; callers use this flag to decide
|
|
159
|
+
// whether an already-listening port can be adopted or must be bypassed.
|
|
160
|
+
pairing: pairing != null,
|
|
161
|
+
bridgePort: listenPort,
|
|
162
|
+
authority,
|
|
163
|
+
upstreamPort: upstream.port || null,
|
|
164
|
+
})
|
|
165
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
166
|
+
res.end(body)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
152
170
|
// Pairing is served by the bridge itself: it must work before the phone has any credential,
|
|
153
171
|
// and it must NOT be proxied to DSH.
|
|
154
172
|
if (pairing && String(req.url || '').startsWith('/_dsh-remote/pair')) {
|
|
@@ -198,9 +216,13 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
|
|
|
198
216
|
server.once('error', reject)
|
|
199
217
|
server.listen(listenPort, '0.0.0.0', () => {
|
|
200
218
|
server.removeListener('error', reject)
|
|
201
|
-
|
|
219
|
+
// `listenPort: 0` asks the OS for a free port; the real one must be read back, otherwise the
|
|
220
|
+
// status file and every printed URL would carry port 0.
|
|
221
|
+
const addr = server.address()
|
|
222
|
+
const boundPort = addr && typeof addr === 'object' ? addr.port : listenPort
|
|
223
|
+
log(`bridge listening on 0.0.0.0:${boundPort} (Host: ${authority})`)
|
|
202
224
|
resolve({
|
|
203
|
-
port:
|
|
225
|
+
port: boundPort,
|
|
204
226
|
close: () =>
|
|
205
227
|
new Promise((done) => {
|
|
206
228
|
server.close(() => done())
|
package/lib/discover.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Find the DSH host's HTTP port.
|
|
3
3
|
*
|
|
4
4
|
* The desktop host listens on a **different port every restart** (observed 1815, 5894, 14299,
|
|
5
|
-
* 8312, 12077), so the port cannot be configured once and forgotten.
|
|
6
|
-
* with the inspector and other loopback services, so "the port is listening" is not enough —
|
|
7
|
-
* only the real web server answers our authenticated RPC.
|
|
5
|
+
* 8312, 12077), so the port cannot be configured once and forgotten.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Detection is a fingerprint, not a status code: a **cookieless** `GET /` addressed with the port
|
|
8
|
+
* as Host answers `401 dsh web authentication required`. That sentence is DSH's own trust fence,
|
|
9
|
+
* and no other local service produces it. A plain 200 is worthless here — plenty of tools answer
|
|
10
|
+
* 200 to anything (a real false-positive source on other machines), and an authenticated probe
|
|
11
|
+
* cannot run before the credential exists.
|
|
12
12
|
*
|
|
13
13
|
* @module lib/discover
|
|
14
14
|
*/
|
|
@@ -17,13 +17,14 @@ import http from 'node:http'
|
|
|
17
17
|
import { execFile } from 'node:child_process'
|
|
18
18
|
|
|
19
19
|
const PROBE_TIMEOUT_MS = 1500
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
const NEWLINE = String.fromCharCode(10)
|
|
21
|
+
const CARRIAGE = String.fromCharCode(13)
|
|
22
|
+
|
|
23
|
+
function splitLines(text) {
|
|
24
|
+
return String(text)
|
|
25
|
+
.split(NEWLINE)
|
|
26
|
+
.map((line) => line.split(CARRIAGE).join(''))
|
|
27
|
+
}
|
|
27
28
|
|
|
28
29
|
function run(cmd, args, timeout = 8000) {
|
|
29
30
|
return new Promise((resolve) => {
|
|
@@ -38,7 +39,7 @@ export async function listeningPorts() {
|
|
|
38
39
|
const ports = new Set()
|
|
39
40
|
if (process.platform === 'win32') {
|
|
40
41
|
const out = await run('netstat', ['-ano', '-p', 'tcp'])
|
|
41
|
-
for (const line of out
|
|
42
|
+
for (const line of splitLines(out)) {
|
|
42
43
|
const m = /^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line)
|
|
43
44
|
if (!m) continue
|
|
44
45
|
const addr = m[1]
|
|
@@ -49,17 +50,16 @@ export async function listeningPorts() {
|
|
|
49
50
|
}
|
|
50
51
|
return [...ports]
|
|
51
52
|
}
|
|
52
|
-
// macOS / Linux
|
|
53
53
|
const ss = await run('ss', ['-ltn'])
|
|
54
54
|
if (ss) {
|
|
55
|
-
for (const line of ss
|
|
55
|
+
for (const line of splitLines(ss)) {
|
|
56
56
|
const m = /\s(?:\S*):(\d+)\s/.exec(` ${line} `)
|
|
57
57
|
if (m) ports.add(Number(m[1]))
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
if (ports.size === 0) {
|
|
61
61
|
const lsof = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
|
|
62
|
-
for (const line of lsof
|
|
62
|
+
for (const line of splitLines(lsof)) {
|
|
63
63
|
const m = /:(\d+)\s+\(LISTEN\)/.exec(line)
|
|
64
64
|
if (m) ports.add(Number(m[1]))
|
|
65
65
|
}
|
|
@@ -67,29 +67,36 @@ export async function listeningPorts() {
|
|
|
67
67
|
return [...ports].filter((p) => Number.isInteger(p) && p > 0)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Is `port` the DSH web server?
|
|
72
|
+
*
|
|
73
|
+
* Resolves true only for `401` + `dsh web authentication required`, and false for anything that
|
|
74
|
+
* carries our own bridge tag (a bridge would forward the same response).
|
|
75
|
+
*/
|
|
76
|
+
export function probePort(port) {
|
|
72
77
|
return new Promise((resolve) => {
|
|
73
78
|
const req = http.request(
|
|
74
79
|
{
|
|
75
80
|
host: '127.0.0.1',
|
|
76
81
|
port,
|
|
77
|
-
path:
|
|
78
|
-
method: '
|
|
82
|
+
path: '/',
|
|
83
|
+
method: 'GET',
|
|
79
84
|
timeout: PROBE_TIMEOUT_MS,
|
|
80
|
-
headers: {
|
|
81
|
-
'content-type': 'application/json',
|
|
82
|
-
'content-length': Buffer.byteLength(PROBE_BODY),
|
|
83
|
-
host: authority,
|
|
84
|
-
cookie,
|
|
85
|
-
},
|
|
85
|
+
headers: { host: `127.0.0.1:${port}` },
|
|
86
86
|
},
|
|
87
87
|
(res) => {
|
|
88
|
-
// Another dsh-remote bridge also answers our RPC (it forwards to the real host); treating
|
|
89
|
-
// it as upstream would chain bridges, so tagged responses are rejected.
|
|
90
88
|
const isBridge = res.headers['x-dsh-remote-bridge'] === '1'
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
const chunks = []
|
|
90
|
+
let seen = 0
|
|
91
|
+
res.on('data', (chunk) => {
|
|
92
|
+
if (seen < 8192) chunks.push(chunk)
|
|
93
|
+
seen += chunk.length
|
|
94
|
+
})
|
|
95
|
+
res.on('end', () => {
|
|
96
|
+
if (isBridge) return resolve(false)
|
|
97
|
+
const head = Buffer.concat(chunks).toString('utf8')
|
|
98
|
+
resolve(res.statusCode === 401 && /dsh web authentication required/i.test(head))
|
|
99
|
+
})
|
|
93
100
|
},
|
|
94
101
|
)
|
|
95
102
|
req.on('error', () => resolve(false))
|
|
@@ -97,7 +104,6 @@ export function probePort(port, { authority, cookie }) {
|
|
|
97
104
|
req.destroy()
|
|
98
105
|
resolve(false)
|
|
99
106
|
})
|
|
100
|
-
req.write(PROBE_BODY)
|
|
101
107
|
req.end()
|
|
102
108
|
})
|
|
103
109
|
}
|
|
@@ -105,39 +111,28 @@ export function probePort(port, { authority, cookie }) {
|
|
|
105
111
|
/**
|
|
106
112
|
* A resolver that remembers the last known-good port.
|
|
107
113
|
*
|
|
108
|
-
* @param {object} options
|
|
109
|
-
* @param {
|
|
110
|
-
* @param {() => Promise<string>|string} options.cookie current cookie (may be re-minted)
|
|
111
|
-
* @param {number} [options.pinned=0] skip discovery when > 0
|
|
114
|
+
* @param {object} [options]
|
|
115
|
+
* @param {number} [options.pinned=0] skip discovery entirely when > 0
|
|
112
116
|
* @param {number} [options.ttlMs=30000]
|
|
117
|
+
* @param {number[]} [options.exclude=[]] ports that must never be treated as upstream (our bridge)
|
|
113
118
|
*/
|
|
114
|
-
export function createDiscovery({
|
|
119
|
+
export function createDiscovery({ pinned = 0, ttlMs = 30_000, exclude = [] } = {}) {
|
|
115
120
|
let port = pinned || 0
|
|
116
121
|
let resolvedAt = 0
|
|
117
122
|
let inFlight = null
|
|
118
123
|
|
|
119
|
-
async function resolveCookie() {
|
|
120
|
-
try {
|
|
121
|
-
return await cookie()
|
|
122
|
-
} catch {
|
|
123
|
-
return ''
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
124
|
async function resolve(force = false) {
|
|
128
125
|
if (pinned) return pinned
|
|
129
126
|
if (!force && port && Date.now() - resolvedAt < ttlMs) return port
|
|
130
127
|
if (inFlight) return inFlight
|
|
131
128
|
inFlight = (async () => {
|
|
132
|
-
const ck = await resolveCookie()
|
|
133
|
-
if (!ck) return port
|
|
134
129
|
const skip = new Set(exclude.filter((p) => Number.isInteger(p) && p > 0))
|
|
135
130
|
const ports = (await listeningPorts()).filter((p) => !skip.has(p))
|
|
136
131
|
// Probe the remembered port first: it is usually still right and saves a full sweep.
|
|
137
132
|
const ordered = port && ports.includes(port) ? [port, ...ports.filter((p) => p !== port)] : ports
|
|
138
133
|
for (const candidate of ordered) {
|
|
139
134
|
// eslint-disable-next-line no-await-in-loop
|
|
140
|
-
if (await probePort(candidate
|
|
135
|
+
if (await probePort(candidate)) {
|
|
141
136
|
port = candidate
|
|
142
137
|
resolvedAt = Date.now()
|
|
143
138
|
return port
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { start } from './supervisor.js'
|
|
|
16
16
|
|
|
17
17
|
export const name = 'dsh-remote-hub'
|
|
18
18
|
|
|
19
|
-
export const version = '0.
|
|
19
|
+
export const version = '0.3.1'
|
|
20
20
|
|
|
21
21
|
/** No host services are required: the plugin only needs Node builtins. */
|
|
22
22
|
export const inject = []
|
|
@@ -52,6 +52,14 @@ export function apply(ctx, config = {}) {
|
|
|
52
52
|
start({ ...config, log })
|
|
53
53
|
.then((started) => {
|
|
54
54
|
handle = started
|
|
55
|
+
if (started?.status?.bridgePort) {
|
|
56
|
+
// Make the next step discoverable from the log: after installing the plugin there is no
|
|
57
|
+
// other place telling the user how to get the phone connected.
|
|
58
|
+
log(
|
|
59
|
+
`dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:${started.status.bridgePort}/_dsh-remote/pair);` +
|
|
60
|
+
'不想装插件时也可以直接双击仓库里的 connect.cmd',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
55
63
|
if (closed) return handle.close()
|
|
56
64
|
return undefined
|
|
57
65
|
})
|
package/lib/supervisor.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module lib/supervisor
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import http from 'node:http'
|
|
12
13
|
import fs from 'node:fs'
|
|
13
14
|
import os from 'node:os'
|
|
14
15
|
import path from 'node:path'
|
|
@@ -101,18 +102,56 @@ function writeStatus(status) {
|
|
|
101
102
|
fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Ask whatever listens on `port` whether it is a dsh-remote bridge, and what it supports. */
|
|
106
|
+
function probeHealth(port, timeoutMs = 1200) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const req = http.request(
|
|
109
|
+
{ host: '127.0.0.1', port, path: '/_dsh-remote/health', method: 'GET', timeout: timeoutMs },
|
|
110
|
+
(res) => {
|
|
111
|
+
const chunks = []
|
|
112
|
+
res.on('data', (c) => chunks.push(c))
|
|
113
|
+
res.on('end', () => {
|
|
114
|
+
try {
|
|
115
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
116
|
+
} catch {
|
|
117
|
+
resolve(null)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
req.on('error', () => resolve(null))
|
|
123
|
+
req.on('timeout', () => {
|
|
124
|
+
req.destroy()
|
|
125
|
+
resolve(null)
|
|
126
|
+
})
|
|
127
|
+
req.end()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
104
131
|
/**
|
|
105
132
|
* Start everything.
|
|
106
133
|
*
|
|
107
134
|
* @param {object} config
|
|
108
135
|
* @param {boolean} [config.enabled=true]
|
|
109
136
|
* @param {number} [config.port=6300]
|
|
110
|
-
* @param {string} [config.authority='127.0.0.1:1815']
|
|
137
|
+
* @param {string} [config.authority='127.0.0.1:1815'] fixed on purpose; 'auto' follows the host port
|
|
111
138
|
* @param {'auto'|'off'|'ngrok'|'cloudflared'} [config.tunnel='auto']
|
|
112
139
|
* @param {number} [config.upstreamPort=0]
|
|
113
140
|
* @param {string} [config.domain='']
|
|
114
141
|
* @param {(msg: string) => void} [config.log]
|
|
115
142
|
*/
|
|
143
|
+
/**
|
|
144
|
+
* Servers started by this plugin, by port.
|
|
145
|
+
*
|
|
146
|
+
* Plugin reload (hot module replacement) mounts the new build while the previous instance may still
|
|
147
|
+
* hold its socket. Keeping the handles on globalThis lets the new instance close its predecessor in
|
|
148
|
+
* the same process instead of leaving a stale bridge — built from older code — owning the port.
|
|
149
|
+
*/
|
|
150
|
+
function instanceRegistry() {
|
|
151
|
+
if (!globalThis.__dshRemoteInstances) globalThis.__dshRemoteInstances = new Map()
|
|
152
|
+
return globalThis.__dshRemoteInstances
|
|
153
|
+
}
|
|
154
|
+
|
|
116
155
|
export async function start(config = {}) {
|
|
117
156
|
const {
|
|
118
157
|
enabled = true,
|
|
@@ -149,16 +188,21 @@ export async function start(config = {}) {
|
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
// The bridge listens on `port`; probing it would forward to itself, so it must be excluded
|
|
152
|
-
// from upstream discovery.
|
|
191
|
+
// from upstream discovery. Discovery never blocks startup: the bridge must come up even when the
|
|
192
|
+
// DSH web server has not bound its port yet (plugin loads early in host boot), otherwise the
|
|
193
|
+
// pairing page is unreachable and the failure looks like "the plugin did nothing".
|
|
153
194
|
const discovery = createDiscovery({
|
|
154
|
-
authority,
|
|
155
|
-
cookie: () => cookie,
|
|
156
195
|
pinned: Number(upstreamPort) || 0,
|
|
157
196
|
exclude: [Number(port) || 6300],
|
|
158
197
|
})
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
|
|
199
|
+
const ipForPairing = lanAddress()
|
|
200
|
+
/** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
|
|
201
|
+
let lastPublicUrl = null
|
|
202
|
+
|
|
203
|
+
/** The bridge may have fallen back to an ephemeral port; everything must report the real one. */
|
|
204
|
+
let bridgeInstance = null
|
|
205
|
+
const actualBridgePort = () => (bridgeInstance ? bridgeInstance.port : Number(port) || 6300)
|
|
162
206
|
|
|
163
207
|
// Pairing: the QR carries a one-time code, not the cookie; redeeming burns it.
|
|
164
208
|
const pairingStore = createPairingStore()
|
|
@@ -166,23 +210,85 @@ export async function start(config = {}) {
|
|
|
166
210
|
store: pairingStore,
|
|
167
211
|
// Read at redeem time so a tunnel URL that appears later is still handed to the phone.
|
|
168
212
|
current: () => ({
|
|
169
|
-
lan: ipForPairing ? `http://${ipForPairing}:${
|
|
213
|
+
lan: ipForPairing ? `http://${ipForPairing}:${actualBridgePort()}` : null,
|
|
170
214
|
public: lastPublicUrl ?? null,
|
|
171
215
|
cookie,
|
|
172
216
|
}),
|
|
173
217
|
}
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
/** Assigned once the tunnel exists; the registry disposer may run before/after that. */
|
|
220
|
+
let tunnelRef = null
|
|
221
|
+
const registry = instanceRegistry()
|
|
222
|
+
const wantedPort = Number(port) || 6300
|
|
223
|
+
const previous = registry.get(wantedPort)
|
|
224
|
+
if (previous) {
|
|
225
|
+
log(`dsh-remote: closing the previous bridge on ${wantedPort} (plugin reload)`)
|
|
226
|
+
try {
|
|
227
|
+
await previous.close()
|
|
228
|
+
} catch (err) {
|
|
229
|
+
log(`dsh-remote: previous bridge close failed: ${err?.message ?? err}`)
|
|
230
|
+
}
|
|
231
|
+
registry.delete(wantedPort)
|
|
232
|
+
}
|
|
178
233
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
234
|
+
let bridge
|
|
235
|
+
try {
|
|
236
|
+
bridge = await startBridge({
|
|
237
|
+
listenPort: Number(port) || 6300,
|
|
238
|
+
authority,
|
|
239
|
+
upstream: discovery,
|
|
240
|
+
pairing,
|
|
241
|
+
log,
|
|
242
|
+
})
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (!/EADDRINUSE/.test(String(err?.message ?? err))) throw err
|
|
245
|
+
// Port busy. Three cases, in order of preference:
|
|
246
|
+
// 1. a live bridge of ours that supports pairing -> adopt it (nothing to do);
|
|
247
|
+
// 2. a live bridge of ours from an older build (no pairing route) -> must bypass it;
|
|
248
|
+
// 3. some unrelated program -> bypass it.
|
|
249
|
+
const wanted = Number(port) || 6300
|
|
250
|
+
const existing = await probeHealth(wanted)
|
|
251
|
+
if (existing?.ok && existing.pairing) {
|
|
252
|
+
log(`dsh-remote: port ${wanted} is already served by a dsh-remote bridge; adopting it`)
|
|
253
|
+
return {
|
|
254
|
+
status: readStatus() ?? { updatedAt: Date.now(), phase: 'adopted', bridgePort: wanted, authority },
|
|
255
|
+
close: async () => {},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (existing?.ok) {
|
|
259
|
+
log('dsh-remote: port is held by an old bridge WITHOUT pairing support; using another port')
|
|
260
|
+
} else {
|
|
261
|
+
log(`dsh-remote: port ${wanted} is busy (not a dsh-remote bridge); using another port`)
|
|
262
|
+
}
|
|
263
|
+
bridge = await startBridge({
|
|
264
|
+
listenPort: 0,
|
|
265
|
+
authority,
|
|
266
|
+
upstream: discovery,
|
|
267
|
+
pairing,
|
|
268
|
+
log,
|
|
269
|
+
})
|
|
270
|
+
log(`dsh-remote: bound to ${bridge.port} instead; use the URLs below, not the old port`)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Wait for the host's web server in the background: plugin mode runs before DSH binds its port.
|
|
274
|
+
const waitDeadline = Date.now() + 120_000
|
|
275
|
+
const waitForHost = async () => {
|
|
276
|
+
let attempts = 0
|
|
277
|
+
while (Date.now() < waitDeadline) {
|
|
278
|
+
attempts += 1
|
|
279
|
+
// eslint-disable-next-line no-await-in-loop
|
|
280
|
+
const found = await discovery.resolve(true)
|
|
281
|
+
if (found) {
|
|
282
|
+
log(`dsh-remote: upstream host on 127.0.0.1:${found} (after ${attempts} probe(s))`)
|
|
283
|
+
return found
|
|
284
|
+
}
|
|
285
|
+
// eslint-disable-next-line no-await-in-loop
|
|
286
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
287
|
+
}
|
|
288
|
+
log('dsh-remote: no DSH web server found after 2 minutes — is DSH running?')
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
const upstreamReady = waitForHost()
|
|
186
292
|
|
|
187
293
|
const tunnel = await startTunnel({
|
|
188
294
|
kind: tunnelKind,
|
|
@@ -192,10 +298,24 @@ export async function start(config = {}) {
|
|
|
192
298
|
log,
|
|
193
299
|
})
|
|
194
300
|
|
|
301
|
+
tunnelRef = tunnel
|
|
302
|
+
bridgeInstance = bridge
|
|
303
|
+
registry.set(bridge.port, {
|
|
304
|
+
close: async () => {
|
|
305
|
+
try {
|
|
306
|
+
tunnelRef?.close?.()
|
|
307
|
+
} catch {}
|
|
308
|
+
try {
|
|
309
|
+
await bridge.close()
|
|
310
|
+
} catch {}
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
195
314
|
lastPublicUrl = tunnel.publicUrl
|
|
196
315
|
const ip = ipForPairing
|
|
197
316
|
const status = {
|
|
198
317
|
updatedAt: Date.now(),
|
|
318
|
+
phase: 'ready',
|
|
199
319
|
bridgePort: bridge.port,
|
|
200
320
|
authority,
|
|
201
321
|
upstreamPort: discovery.port || null,
|
package/package.json
CHANGED