@wenbin_wb/dsh-bridge 2.10.7 → 2.10.9

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.
@@ -3,6 +3,9 @@
3
3
 
4
4
  import { randomBytes, pbkdf2 as pbkdf2Callback, timingSafeEqual } from 'node:crypto'
5
5
  import { promisify } from 'node:util'
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join, dirname } from 'node:path'
6
9
 
7
10
  const pbkdf2Async = promisify(pbkdf2Callback)
8
11
 
@@ -18,6 +21,50 @@ const LEGACY_PBKDF2_ITERATIONS = 10000
18
21
  const PBKDF2_KEYLEN = 32
19
22
  const PBKDF2_PREFIX = 'pbkdf2-sha256$'
20
23
 
24
+ // 登录 Session 持久化:宿主(dsh web 进程)重启后,已登录设备免重输访问密码。
25
+ // 落盘文件与 config.json 同目录(~/.dsh/dsh-bridge/sessions.json,权限 600);
26
+ // sessionToken 与 secretToken 同级机密(48 位随机 hex),同目录存储不扩大暴露面。
27
+ // 改密码 / 切模式 / 重新生成 token 时的 sessions.clear() 会同步清空文件(吊销语义保持);
28
+ // 文件缺失或损坏时安全降级为空 Map(等价于旧行为:重新登录一次)。
29
+ // 路径可通过构造参数 sessionsFile 注入(测试传临时目录,避免读写真实环境)。
30
+ const DEFAULT_SESSIONS_FILE = join(
31
+ process.env.DSH_HOME ?? join(homedir(), '.dsh'),
32
+ 'dsh-bridge', 'sessions.json'
33
+ )
34
+
35
+ class PersistentSessionMap extends Map {
36
+ /** @param {string} file 会话落盘文件路径 */
37
+ constructor(file) {
38
+ super()
39
+ this._file = file
40
+ }
41
+ _flush() {
42
+ try {
43
+ mkdirSync(dirname(this._file), { recursive: true })
44
+ writeFileSync(this._file, JSON.stringify([...this.entries()]), { mode: 0o600 })
45
+ } catch { /* 落盘失败降级为纯内存语义,不阻断认证流程 */ }
46
+ }
47
+ static load(file) {
48
+ const m = new PersistentSessionMap(file)
49
+ try {
50
+ const arr = JSON.parse(readFileSync(file, 'utf8'))
51
+ if (Array.isArray(arr)) {
52
+ const now = Date.now()
53
+ for (const [k, v] of arr) {
54
+ // 只恢复结构合法且未过期的会话
55
+ if (typeof k === 'string' && v && Number.isFinite(v.expiresAt) && v.expiresAt > now) {
56
+ Map.prototype.set.call(m, k, { createdAt: Number(v.createdAt) || 0, expiresAt: Number(v.expiresAt) })
57
+ }
58
+ }
59
+ }
60
+ } catch { /* 文件缺失/损坏:降级为空 */ }
61
+ return m
62
+ }
63
+ set(k, v) { super.set(k, v); this._flush(); return this }
64
+ delete(k) { const r = super.delete(k); if (r) this._flush(); return r }
65
+ clear() { super.clear(); this._flush() }
66
+ }
67
+
21
68
  export class AuthManager {
22
69
  /**
23
70
  * @param {object} opts
@@ -30,9 +77,10 @@ export class AuthManager {
30
77
  * @param {boolean} [opts.config.allowLoopback=true]
31
78
  * @param {boolean} [opts.config.adminProtection=true] 管理保护独立开关:关闭后管理操作免 adminToken
32
79
  * @param {(patch: object) => Promise<void>|void} [opts.onPersist]
80
+ * @param {string} [opts.sessionsFile] 登录会话持久化文件路径(默认 ~/.dsh/dsh-bridge/sessions.json;测试注入临时目录)
33
81
  * @param {object} [opts.logger]
34
82
  */
35
- constructor({ config = {}, onPersist, logger = console } = {}) {
83
+ constructor({ config = {}, onPersist, sessionsFile, logger = console } = {}) {
36
84
  this.logger = logger
37
85
  this.onPersist = onPersist
38
86
 
@@ -51,8 +99,9 @@ export class AuthManager {
51
99
  // 内部隧道专用鉴权密钥(内存生成,用于辨别本地自建隧道转发流量与真实本机访问)
52
100
  this.internalTunnelSecret = randomBytes(24).toString('hex')
53
101
 
54
- // Session 内存存储:sessionToken -> { createdAt, expiresAt }
55
- this.sessions = new Map()
102
+ // Session 存储与重启恢复:sessionToken -> { createdAt, expiresAt }
103
+ // 持久化到 sessions.json(路径可经 sessionsFile 注入),宿主(dsh web)重启后已登录设备免重输访问密码
104
+ this.sessions = PersistentSessionMap.load(sessionsFile ?? DEFAULT_SESSIONS_FILE)
56
105
  // 管理员解锁 Session 内存存储:adminToken -> { createdAt, expiresAt }
57
106
  this.adminSessions = new Map()
58
107
  // 防暴力破解:ip -> { failedCount, lockUntil }
@@ -540,7 +589,8 @@ export class AuthManager {
540
589
 
541
590
  dispose() {
542
591
  clearInterval(this._cleanupTimer)
543
- this.sessions.clear()
592
+ // 不清空 sessions:进程退出时内存随进程自然消失;
593
+ // 若此处 clear() 会把持久化文件一并清空,宿主重启后已登录设备仍会全部丢失
544
594
  this.rateLimits.clear()
545
595
  }
546
596
  }
@@ -16,6 +16,7 @@ export const BRIDGE_ENDPOINTS = {
16
16
  setLanIp: 'setLanIp',
17
17
  checkVersion: 'checkVersion',
18
18
  upgradePlugin: 'upgradePlugin',
19
+ upgradeDsh: 'upgradeDsh',
19
20
  restartDsh: 'restartDsh',
20
21
  exportBackup: 'exportBackup',
21
22
  importBackup: 'importBackup',
package/lib/bridge-rpc.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import QRCode from 'qrcode';
5
5
  import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from './bridge-rpc-constants.js';
6
+ import { registerRpcChannel } from './connection-compat.js';
6
7
  import { RateLimiter } from './security/rate-limiter.js';
7
8
 
8
9
  export { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS };
@@ -87,7 +88,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
87
88
  return () => {};
88
89
  }
89
90
 
90
- return ctx.connection.rpc.handle(
91
+ // connection-compat 注册:兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归
92
+ // (上游 register() 里对 connection 自身 ctx 取 webServer 而未加 inject 作用域)
93
+ return registerRpcChannel(
94
+ ctx,
91
95
  BRIDGE_RPC_CHANNEL,
92
96
  async (endpoint, payload = {}, signal) => {
93
97
  if (signal?.aborted) return fail('cancelled', 'Request was cancelled');
@@ -289,6 +293,14 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
289
293
  return ok(result);
290
294
  }
291
295
 
296
+ if (endpoint === BRIDGE_ENDPOINTS.upgradeDsh) {
297
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
298
+ if (adminErr) return adminErr;
299
+
300
+ const result = await service.upgradeDsh(payload);
301
+ return ok(result);
302
+ }
303
+
292
304
  if (endpoint === BRIDGE_ENDPOINTS.restartDsh) {
293
305
  const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
294
306
  if (adminErr) return adminErr;
@@ -478,7 +490,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
478
490
  return fail('bad-request', err.message);
479
491
  }
480
492
  },
481
- { authority: 'loopback' }
493
+ // DSH 0.1.0/0.1.1 支持 authority 选项(仅回环可达);0.1.2+ 忽略之,透传无副作用。
494
+ // 见 lib/connection-compat.js 的 registerRpcChannel 说明。
495
+ { authority: 'loopback' },
496
+ logger
482
497
  );
483
498
  }
484
499
 
package/lib/compat.js CHANGED
@@ -1,129 +1,129 @@
1
- // 运行时兼容垫片(lib/compat.js)
2
- //
3
- // 背景:DSH 核心依赖链(@deepseek-ai/dsh-timeout ← dsh-llm)在每次 agent 请求上
4
- // 调用 AbortSignal.any([...]),该 API 仅存在于 Node 20.3+/22+。运行在低版本 Node
5
- // (如 Node 18)上时,通过桥接发送消息会直接抛
6
- // "AbortSignal.any is not a function ... (internal)"
7
- // 本模块在缺失时安装规范兼容的垫片;环境本身支持时不做任何事(返回 false)。
8
- //
9
- // 浏览器侧(旧 Safari/WebView)由 lib/index.js 的 HTML_HEAD_INJECTIONS 注入同语义垫片。
10
-
11
- function abortWith(controller, reason) {
12
- try {
13
- controller.abort(reason)
14
- } catch {
15
- try { controller.abort() } catch { /* 不可中止的信号:忽略 */ }
16
- }
17
- }
18
-
19
- /**
20
- * 在目标环境中安装 AbortSignal.any / AbortSignal.timeout 垫片(仅缺失时)。
21
- * @param {object} [target] 可注入的环境(默认 globalThis),便于测试
22
- * @returns {boolean} 是否实际安装了垫片
23
- */
24
- export function installAbortSignalCompat(target = globalThis) {
25
- const Signal = target.AbortSignal
26
- const Controller = target.AbortController
27
- if (!Signal || !Controller) return false
28
-
29
- let installed = false
30
-
31
- // ---- AbortSignal.any ----
32
- if (typeof Signal.any !== 'function') {
33
- Signal.any = (signals) => {
34
- const list = Array.from(signals ?? [])
35
- const controller = new Controller()
36
- // 规范语义:任一源信号已中止 → 立即以该原因中止
37
- for (const s of list) {
38
- if (s && s.aborted) {
39
- abortWith(controller, s.reason)
40
- return controller.signal
41
- }
42
- }
43
- const onAbort = (eventOrSignal) => {
44
- // 真实 EventTarget 的 abort 事件参数是 event(event.target = 信号),
45
- // 部分非标准实现直接传信号本身——两者都兼容取 reason
46
- const src = eventOrSignal && eventOrSignal.target ? eventOrSignal.target : eventOrSignal
47
- cleanup()
48
- abortWith(controller, src ? src.reason : undefined)
49
- }
50
- const cleanup = () => {
51
- for (const s of list) {
52
- try { s.removeEventListener('abort', onAbort) } catch { /* 非标准信号:忽略 */ }
53
- }
54
- }
55
- for (const s of list) {
56
- try { s.addEventListener('abort', onAbort, { once: true }) } catch { /* 非标准信号:忽略 */ }
57
- }
58
- return controller.signal
59
- }
60
- installed = true
61
- }
62
-
63
- // ---- AbortSignal.timeout(Node 17.3+ / 较新浏览器才有,顺手补齐)----
64
- if (typeof Signal.timeout !== 'function') {
65
- Signal.timeout = (delayMs) => {
66
- const controller = new Controller()
67
- const delay = Math.max(0, Number(delayMs) || 0)
68
- const reason = typeof target.DOMException === 'function'
69
- ? new target.DOMException('The operation timed out.', 'TimeoutError')
70
- : new Error('The operation timed out.')
71
- const timer = setTimeout(() => abortWith(controller, reason), delay)
72
- // 规范语义:timeout 信号的超时定时器不阻止进程退出
73
- if (typeof timer.unref === 'function') timer.unref()
74
- return controller.signal
75
- }
76
- installed = true
77
- }
78
-
79
- return installed
80
- }
81
-
82
- /**
83
- * 浏览器侧垫片源码:由代理注入到 HTML <head>(在宿主所有脚本之前执行),
84
- * 为 iOS 16 / 旧 Safari / 旧 WebView(无 AbortSignal.any,Safari 17.4 才加入)
85
- * 上的 DSH 网页客户端补齐。多行可读源码,勿手工压缩成单行(配平易错)。
86
- */
87
- export const BROWSER_ABORT_SIGNAL_POLYFILL = `<script data-dsh-bridge-polyfill="2">
88
- !function () {
89
- try {
90
- var S = self.AbortSignal;
91
- if (typeof S !== 'function') return;
92
- if (typeof S.any !== 'function') {
93
- S.any = function (signals) {
94
- var list = Array.prototype.slice.call(signals || []);
95
- var c = new self.AbortController();
96
- var onAbort = function (ev) {
97
- var src = ev && ev.target ? ev.target : ev;
98
- cleanup();
99
- try { c.abort(src ? src.reason : undefined); } catch (e) { c.abort(); }
100
- };
101
- var cleanup = function () {
102
- for (var i = 0; i < list.length; i++) {
103
- try { list[i].removeEventListener('abort', onAbort); } catch (e) {}
104
- }
105
- };
106
- for (var i = 0; i < list.length; i++) {
107
- if (list[i] && list[i].aborted) {
108
- try { c.abort(list[i].reason); } catch (e) { c.abort(); }
109
- return c.signal;
110
- }
111
- }
112
- for (var i = 0; i < list.length; i++) {
113
- try { list[i].addEventListener('abort', onAbort, { once: true }); } catch (e) {}
114
- }
115
- return c.signal;
116
- };
117
- }
118
- if (typeof S.timeout !== 'function') {
119
- S.timeout = function (ms) {
120
- var c = new self.AbortController();
121
- setTimeout(function () {
122
- try { c.abort(new Error('The operation timed out.')); } catch (e) { c.abort(); }
123
- }, Math.max(0, Number(ms) || 0));
124
- return c.signal;
125
- };
126
- }
127
- } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
128
- }();
129
- </script>`
1
+ // 运行时兼容垫片(lib/compat.js)
2
+ //
3
+ // 背景:DSH 核心依赖链(@deepseek-ai/dsh-timeout ← dsh-llm)在每次 agent 请求上
4
+ // 调用 AbortSignal.any([...]),该 API 仅存在于 Node 20.3+/22+。运行在低版本 Node
5
+ // (如 Node 18)上时,通过桥接发送消息会直接抛
6
+ // "AbortSignal.any is not a function ... (internal)"
7
+ // 本模块在缺失时安装规范兼容的垫片;环境本身支持时不做任何事(返回 false)。
8
+ //
9
+ // 浏览器侧(旧 Safari/WebView)由 lib/index.js 的 HTML_HEAD_INJECTIONS 注入同语义垫片。
10
+
11
+ function abortWith(controller, reason) {
12
+ try {
13
+ controller.abort(reason)
14
+ } catch {
15
+ try { controller.abort() } catch { /* 不可中止的信号:忽略 */ }
16
+ }
17
+ }
18
+
19
+ /**
20
+ * 在目标环境中安装 AbortSignal.any / AbortSignal.timeout 垫片(仅缺失时)。
21
+ * @param {object} [target] 可注入的环境(默认 globalThis),便于测试
22
+ * @returns {boolean} 是否实际安装了垫片
23
+ */
24
+ export function installAbortSignalCompat(target = globalThis) {
25
+ const Signal = target.AbortSignal
26
+ const Controller = target.AbortController
27
+ if (!Signal || !Controller) return false
28
+
29
+ let installed = false
30
+
31
+ // ---- AbortSignal.any ----
32
+ if (typeof Signal.any !== 'function') {
33
+ Signal.any = (signals) => {
34
+ const list = Array.from(signals ?? [])
35
+ const controller = new Controller()
36
+ // 规范语义:任一源信号已中止 → 立即以该原因中止
37
+ for (const s of list) {
38
+ if (s && s.aborted) {
39
+ abortWith(controller, s.reason)
40
+ return controller.signal
41
+ }
42
+ }
43
+ const onAbort = (eventOrSignal) => {
44
+ // 真实 EventTarget 的 abort 事件参数是 event(event.target = 信号),
45
+ // 部分非标准实现直接传信号本身——两者都兼容取 reason
46
+ const src = eventOrSignal && eventOrSignal.target ? eventOrSignal.target : eventOrSignal
47
+ cleanup()
48
+ abortWith(controller, src ? src.reason : undefined)
49
+ }
50
+ const cleanup = () => {
51
+ for (const s of list) {
52
+ try { s.removeEventListener('abort', onAbort) } catch { /* 非标准信号:忽略 */ }
53
+ }
54
+ }
55
+ for (const s of list) {
56
+ try { s.addEventListener('abort', onAbort, { once: true }) } catch { /* 非标准信号:忽略 */ }
57
+ }
58
+ return controller.signal
59
+ }
60
+ installed = true
61
+ }
62
+
63
+ // ---- AbortSignal.timeout(Node 17.3+ / 较新浏览器才有,顺手补齐)----
64
+ if (typeof Signal.timeout !== 'function') {
65
+ Signal.timeout = (delayMs) => {
66
+ const controller = new Controller()
67
+ const delay = Math.max(0, Number(delayMs) || 0)
68
+ const reason = typeof target.DOMException === 'function'
69
+ ? new target.DOMException('The operation timed out.', 'TimeoutError')
70
+ : new Error('The operation timed out.')
71
+ const timer = setTimeout(() => abortWith(controller, reason), delay)
72
+ // 规范语义:timeout 信号的超时定时器不阻止进程退出
73
+ if (typeof timer.unref === 'function') timer.unref()
74
+ return controller.signal
75
+ }
76
+ installed = true
77
+ }
78
+
79
+ return installed
80
+ }
81
+
82
+ /**
83
+ * 浏览器侧垫片源码:由代理注入到 HTML <head>(在宿主所有脚本之前执行),
84
+ * 为 iOS 16 / 旧 Safari / 旧 WebView(无 AbortSignal.any,Safari 17.4 才加入)
85
+ * 上的 DSH 网页客户端补齐。多行可读源码,勿手工压缩成单行(配平易错)。
86
+ */
87
+ export const BROWSER_ABORT_SIGNAL_POLYFILL = `<script data-dsh-bridge-polyfill="2">
88
+ !function () {
89
+ try {
90
+ var S = self.AbortSignal;
91
+ if (typeof S !== 'function') return;
92
+ if (typeof S.any !== 'function') {
93
+ S.any = function (signals) {
94
+ var list = Array.prototype.slice.call(signals || []);
95
+ var c = new self.AbortController();
96
+ var onAbort = function (ev) {
97
+ var src = ev && ev.target ? ev.target : ev;
98
+ cleanup();
99
+ try { c.abort(src ? src.reason : undefined); } catch (e) { c.abort(); }
100
+ };
101
+ var cleanup = function () {
102
+ for (var i = 0; i < list.length; i++) {
103
+ try { list[i].removeEventListener('abort', onAbort); } catch (e) {}
104
+ }
105
+ };
106
+ for (var i = 0; i < list.length; i++) {
107
+ if (list[i] && list[i].aborted) {
108
+ try { c.abort(list[i].reason); } catch (e) { c.abort(); }
109
+ return c.signal;
110
+ }
111
+ }
112
+ for (var i = 0; i < list.length; i++) {
113
+ try { list[i].addEventListener('abort', onAbort, { once: true }); } catch (e) {}
114
+ }
115
+ return c.signal;
116
+ };
117
+ }
118
+ if (typeof S.timeout !== 'function') {
119
+ S.timeout = function (ms) {
120
+ var c = new self.AbortController();
121
+ setTimeout(function () {
122
+ try { c.abort(new Error('The operation timed out.')); } catch (e) { c.abort(); }
123
+ }, Math.max(0, Number(ms) || 0));
124
+ return c.signal;
125
+ };
126
+ }
127
+ } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
128
+ }();
129
+ </script>`
@@ -0,0 +1,115 @@
1
+ // 运行时兼容垫片:DSH ≥ 0.1.5-alpha.1 的 connection RPC 通道注册回归
2
+ // (lib/connection-compat.js)
3
+ //
4
+ // 背景(上游回归,非本插件声明问题)
5
+ // ---------------------------------------------------------------
6
+ // @deepseek-ai/dsh-client-connection 在 0.1.5-alpha.1 改了插件级服务声明:
7
+ // ≤ 0.1.3-alpha.2 const inject = ["webServer", "credentials"];
8
+ // ≥ 0.1.5-alpha.1 const inject = ["credentials"]; // webServer 改为可选
9
+ // 同时把 /api 路由改用作用域注入 ctx.inject(["webServer"], (webCtx) => …)。
10
+ // 但 HostConnectionService.register() 里那一句没有同步改(各版本行号均为 618):
11
+ // return owner.effect(() => owner.webServer.register(route),
12
+ // `client-connection: ${channel} rpc channel`);
13
+ // 其中 owner === this.ctx,即 connection 插件自己的 ctx。该 ctx 的 fiber 不再把
14
+ // webServer 记入 inject,于是 cordis 4 的服务守卫直接抛:
15
+ // cannot get property "webServer" without inject
16
+ // 注册发生在插件树加载期,异常向上冒泡 → 整棵插件树加载失败,`dsh web` 起不来:
17
+ // Error: dsh: plugin tree failed to load: failed to apply loader entry
18
+ // dsh-bridge (@wenbin_wb/dsh-bridge): cannot get property "webServer" without inject
19
+ //
20
+ // 影响面:任何调用 ctx.connection.rpc.handle() 的插件都会踩中(dsh-bridge 只是第一个撞上的)。
21
+ // 已逐一核对 npm 上的版本:0.1.5-alpha.1 / alpha.2 / rc.1 / rc.2 全部未修
22
+ // (0.1.5-rc.2 为当前已发布的最新版),0.1.3-alpha.2 及更早正常。
23
+ //
24
+ // 本垫片的做法
25
+ // ---------------------------------------------------------------
26
+ // 不改上游代码、不猜内部私有方法,只走两个公开面:
27
+ // 1. 正常路径:ctx.connection.rpc.handle(channel, handler)(公开 API,未来上游修好即恢复);
28
+ // 2. 兜底路径:仅当该调用抛出 "webServer … without inject" 时,在 root 的
29
+ // `internal/get` 瀑布事件上临时挂一个监听器 —— 先让默认解析跑,默认解析失败
30
+ // (抛出的正是同一个 error 对象)才补上本插件已通过 inject 拿到的 webServer 引用。
31
+ // webServer 是单例服务,语义等价;默认解析成功时零干预。
32
+ // 监听器只在这一次同步注册调用期间存在,返回后立即摘除(cordis 的 effect 同步执行,
33
+ // 注册完成后 owner.webServer 不再被访问),因此对宿主其余部分完全无副作用。
34
+ //
35
+ // 上游修好后本模块自动空转:首次调用不再抛错,兜底分支根本不会进入。
36
+
37
+ const MISSING_INJECT = /without inject/;
38
+
39
+ /**
40
+ * 判断错误是否为"跨服务取属性缺 inject 作用域"的守卫错误,且对象是 webServer。
41
+ * @param {unknown} err 捕获到的异常
42
+ * @returns {boolean} 是否命中该回归
43
+ */
44
+ export function isMissingWebServerInjectError(err) {
45
+ if (!(err instanceof Error)) return false;
46
+ return MISSING_INJECT.test(err.message) && /webServer/.test(err.message);
47
+ }
48
+
49
+ /**
50
+ * 在 root 的 `internal/get` 瀑布事件上挂一个临时兜底:默认解析失败时提供 webServer。
51
+ *
52
+ * cordis 4 的 `ctx.events` 是所有子 ctx 共享的同一个实例,且 `internal/get` 派发时
53
+ * 不带 context 过滤条件(thisArg 为 null),因此在本插件 ctx 上注册的监听器同样会对
54
+ * connection 插件 ctx 的查找生效。监听器由 root fiber 持有,须手工摘除。
55
+ *
56
+ * @param {object} ctx 本插件的 ctx
57
+ * @returns {() => void} 摘除函数
58
+ */
59
+ export function installWebServerResolutionFallback(ctx) {
60
+ const webServer = ctx?.webServer;
61
+ if (webServer === undefined) return () => {};
62
+
63
+ const listener = function (lookupCtx, prop, error, next) {
64
+ if (prop !== 'webServer') return next();
65
+ try {
66
+ return next();
67
+ } catch (err) {
68
+ // 默认解析抛的不是本次守卫错误 → 原样上抛,绝不掩盖真实故障
69
+ if (err !== error) throw err;
70
+ }
71
+ // 默认解析确认失败:补上本插件已 inject 的单例引用(语义等价)
72
+ return webServer;
73
+ };
74
+
75
+ const off = ctx.on?.('internal/get', listener, { global: true });
76
+ return typeof off === 'function' ? off : () => {};
77
+ }
78
+
79
+ /**
80
+ * 注册 connection RPC 通道,并兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归。
81
+ *
82
+ * @param {object} ctx 本插件的 ctx(须已 inject `connection` 与 `webServer`)
83
+ * @param {string} channel RPC 通道名(如 `/dsh-bridge`)
84
+ * @param {Function} handler 通道处理器
85
+ * @param {object} [rpcOptions] 透传给 connection 的通道选项。
86
+ * DSH 0.1.0/0.1.1 的 `rpc.handle(channel, handler, options)` 支持
87
+ * `{ authority: 'loopback' }`(仅回环可达,非回环请求 403);
88
+ * 0.1.2-rc.1 起该第三参数已被移除(更高版本忽略它,传入无副作用)。
89
+ * 本插件依赖它来做回环加固,因此必须原样透传、不得丢失。
90
+ * @param {{warn?: Function}} [logger] 可选日志器
91
+ * @returns {Function} 由 connection 服务返回的处置函数
92
+ */
93
+ export function registerRpcChannel(ctx, channel, handler, rpcOptions, logger) {
94
+ try {
95
+ return ctx.connection.rpc.handle(channel, handler, rpcOptions);
96
+ } catch (err) {
97
+ if (!isMissingWebServerInjectError(err)) throw err;
98
+
99
+ // 上游回归:owner(connection 插件自己的 ctx)解析不到 webServer。
100
+ // 该调用在 owner.webServer 处、于 webServer.register() 之前就抛,未产生任何副作用,
101
+ // 因此可以安全地补上兜底后重试一次。
102
+ const dispose = installWebServerResolutionFallback(ctx);
103
+ try {
104
+ const registered = ctx.connection.rpc.handle(channel, handler, rpcOptions);
105
+ logger?.warn?.(
106
+ 'dsh-bridge: 检测到宿主 DSH 的 connection RPC 注册回归(cannot get property "webServer" without inject,'
107
+ + '见 dsh-client-connection 的 register());已用 webServer 解析兜底完成通道注册。'
108
+ + '此为宿主侧问题(DSH 0.1.5-alpha.1 起),插件侧垫片会在上游修复后自动空转。'
109
+ );
110
+ return registered;
111
+ } finally {
112
+ dispose();
113
+ }
114
+ }
115
+ }