dsh-remote-hub 0.3.0 → 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/lib/bridge.js CHANGED
@@ -155,6 +155,9 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
155
155
  const body = JSON.stringify({
156
156
  ok: true,
157
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,
158
161
  bridgePort: listenPort,
159
162
  authority,
160
163
  upstreamPort: upstream.port || null,
@@ -213,9 +216,13 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
213
216
  server.once('error', reject)
214
217
  server.listen(listenPort, '0.0.0.0', () => {
215
218
  server.removeListener('error', reject)
216
- log(`bridge listening on 0.0.0.0:${listenPort} (Host: ${authority})`)
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})`)
217
224
  resolve({
218
- port: listenPort,
225
+ port: boundPort,
219
226
  close: () =>
220
227
  new Promise((done) => {
221
228
  server.close(() => done())
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.3.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 = []
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,6 +102,32 @@ 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
  *
@@ -113,6 +140,18 @@ function writeStatus(status) {
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,
@@ -177,6 +216,21 @@ export async function start(config = {}) {
177
216
  }),
178
217
  }
179
218
 
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
+ }
233
+
180
234
  let bridge
181
235
  try {
182
236
  bridge = await startBridge({
@@ -187,10 +241,25 @@ export async function start(config = {}) {
187
241
  log,
188
242
  })
189
243
  } catch (err) {
190
- // The usual cause is an older bridge (or a watchdog) still holding the port. Falling back to
191
- // an ephemeral port keeps the pairing page reachable; the real port is reported everywhere.
192
244
  if (!/EADDRINUSE/.test(String(err?.message ?? err))) throw err
193
- log(`dsh-remote: port ${port} is busy, falling back to an ephemeral port`)
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
+ }
194
263
  bridge = await startBridge({
195
264
  listenPort: 0,
196
265
  authority,
@@ -198,6 +267,7 @@ export async function start(config = {}) {
198
267
  pairing,
199
268
  log,
200
269
  })
270
+ log(`dsh-remote: bound to ${bridge.port} instead; use the URLs below, not the old port`)
201
271
  }
202
272
 
203
273
  // Wait for the host's web server in the background: plugin mode runs before DSH binds its port.
@@ -220,7 +290,6 @@ export async function start(config = {}) {
220
290
  }
221
291
  const upstreamReady = waitForHost()
222
292
 
223
- bridgeInstance = bridge
224
293
  const tunnel = await startTunnel({
225
294
  kind: tunnelKind,
226
295
  bridgePort: bridge.port,
@@ -229,6 +298,19 @@ export async function start(config = {}) {
229
298
  log,
230
299
  })
231
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
+
232
314
  lastPublicUrl = tunnel.publicUrl
233
315
  const ip = ipForPairing
234
316
  const status = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",