@mzzsfy/dsh-tunnel 0.1.1 → 0.1.3

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 CHANGED
@@ -24,20 +24,24 @@
24
24
  ## 使用
25
25
 
26
26
  - ai 通道: 会话内直接让 ai 开隧道 `tunnel_open`(name, targetPort, entry?, wsPaths?) / `tunnel_list` / `tunnel_close`; 「允许 ai 使用」开关(默认开)关闭即向 ai 注销三件工具, 看板与 REST 管理不受影响
27
- - GUI 通道: 侧边栏「穿透隧道」面板手动添加/删除/复制访问地址(与 ai 同表); 设置卡两枚开关——「允许 ai 使用隧道工具」与「看板移入 better-sidebar 侧边栏」(设置 > 插件页 / 0.1.7+ 插件管理页包详情)
27
+ - GUI 通道: 侧边栏「穿透隧道」面板手动添加/删除/复制访问地址(与 ai 同表); 设置卡两枚开关——「允许 ai 使用隧道工具」与「看板移入 better-sidebar 侧边栏」(设置 > 插件页 / 0.1.7+ 插件管理页包详情);数据与偏好变更经公共 WS 总线 `@mzzsfy/dsh-tunnel` 插件通道推送;host/client 在 `apply(ctx)` 调用 `setupCommonTools(ctx)`,以 `ctx.fiber.entry.options.name` 的完整 npm 包名绑定通道,组件捕获所属 ctx 的 bus,卸载可提前退订;ctx 卸载释放连接持有与全部订阅
28
28
  - 设置面: legacy(≤0.1.6) 设置 > 插件页独立配置节; 0.1.7+ Config volatile 字段自动配置节(connectTimeoutMs / aiTools / sidebarTab 均支持原地热更, dataDir 为环境路径不进 UI)。`connectTimeoutMs`(响应头等待超时, 改动即时生效, 无需重启)
29
29
  - 持久化 `~/.dsh/dsh-tunnel/tunnels.json`(随 `DSH_HOME`), 重启自动恢复; 目标拒连 502, 响应头等待超时(默认 10s, 头到即解除, SSE 不受影响)504
30
- - REST: `/api/tunnel/tunnels`(GET/POST/DELETE `/api/tunnel/tunnels/:name`) + `/api/tunnel/status` + `/api/tunnel/ui-settings`(PATCH: aiTools / sidebarTab), 与 GUI/ai 同一入口
30
+ - REST: `/api/tunnel/tunnels`(GET/POST/DELETE `/api/tunnel/tunnels/:name`) + `/api/tunnel/status` + `/api/tunnel/ui-settings`(POST: aiTools / sidebarTab), 与 GUI/ai 同一入口
31
+
32
+ ## client 依赖
33
+
34
+ 看板通过 `@mzzsfy/dsh-common-tools/client` 订阅数据变更。安装时同时启用 `dsh-session-manager`,由其唯一代挂条目装载公共 client,并排在 tunnel 前物化。公共模块缺失时装载层会报告具体依赖错误;宿主 React 模块缺失时控制台报告原因并禁用面板。
31
35
 
32
36
  ## 验证
33
37
 
34
- - `node --test "test/*.test.mjs"`: 36 场景 + 8 api 用例 + 11 ai 开关注册面 + 12 守卫断言(mock webServer + 真实 http/net 端到端)
38
+ - `npm test`(经 _mocks.common-tools.mjs 预载公共依赖桩): 37 场景 + 8 api 用例 + 11 ai 开关注册面 + 11 守卫断言 + client 端 8 用例(id 守卫 + 总线生命周期;mock webServer + 真实 http/net 端到端)
35
39
  - `node scripts/compat/tunnel-fullchain.mjs`: 隔离 dsh 实例全链路(真实激活/恢复/分发面七项探针)
36
40
 
37
41
  ## 设计文档
38
42
 
39
- `docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)。
43
+ `docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)。两份均为仓库本地维护文档,不入库,npm 安装读者不可见。
40
44
 
41
45
  ## dsh 版本兼容
42
46
 
43
- 按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
47
+ 按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md`(本地维护,不入库)与本包 `兼容性测试.md`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-tunnel",
3
3
  "description": "路径穿透隧道:在 dsh 宿主进程内把 /p/<name> 反向代理到本地 TCP 端口(HTTP strip 转发 + 声明式 WS upgrade + 3xx Location 单头改写),支持 <name>.* 子域名全路径透传(任意 WS 路径);GUI 看板手动管理隧道,设置页独立配置节与侧边栏注入开关;向 ai 注册 tunnel_open/tunnel_list/tunnel_close 工具且「允许 ai 使用」可开关(关闭即注销);代理逻辑全在插件进程内,不引入 nginx/caddy 等外部反代",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -14,7 +14,10 @@
14
14
  "patch": "./cordis.patch.yml"
15
15
  },
16
16
  "client": {
17
- "platform": "web"
17
+ "platform": "web",
18
+ "external": [
19
+ "@mzzsfy/dsh-common-tools/client"
20
+ ]
18
21
  },
19
22
  "compatibility": {
20
23
  "dshReleases": {
@@ -25,7 +28,7 @@
25
28
  }
26
29
  },
27
30
  "scripts": {
28
- "test": "node --test \"test/*.test.mjs\"",
31
+ "test": "node --experimental-test-module-mocks --import ./test/_mocks.common-tools.mjs --test \"test/*.test.mjs\"",
29
32
  "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
30
33
  },
31
34
  "license": "MIT",
@@ -36,6 +39,9 @@
36
39
  "engines": {
37
40
  "node": ">=22"
38
41
  },
42
+ "dependencies": {
43
+ "@mzzsfy/dsh-common-tools": "^0.2.0"
44
+ },
39
45
  "peerDependencies": {
40
46
  "@deepseek-ai/schemastery": ">=3.18.0",
41
47
  "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2"
package/src/client.js CHANGED
@@ -9,8 +9,6 @@ const PANEL_ID = 'tunnel'
9
9
  const PANEL_LABEL = '穿透隧道'
10
10
  const TAB_ID = 'tunnel:board'
11
11
  const TAB_ORDER = 47
12
- const REFRESH_INTERVAL_MS = 30 * 1000
13
- const PREF_POLL_MS = 5 * 1000
14
12
  const COPY_FLASH_MS = 1200
15
13
  const PORT_MIN = 1
16
14
  const PORT_MAX = 65535
@@ -48,25 +46,25 @@ async function copyText(text) {
48
46
  /* SBUILD-BEGIN */
49
47
  const STYLE = `
50
48
  /* 组件令牌层(形制对齐 dsh-im / cron-board):宿主 dsw-alias 定义在 body 作用域, tu- 令牌必须在 body 声明才能解析到主题值;
51
- fallback 取浅色中性, 深色主题由令牌接管, fallback 只兜裸宿主 */
49
+ 直接引用宿主语义别名, 禁止字面 fallback(暗色主题漏色, 规范见 DEVELOPMENT/plugin-ui-design.md) */
52
50
  body{
53
- --tu-bg:var(--dsw-alias-bg-layer-1,#ffffff);
54
- --tu-bg-sub:var(--dsw-alias-bg-layer-2,#f7f8fa);
55
- --tu-bg-muted:var(--dsw-alias-bg-module-platform,#f2f3f5);
56
- --tu-border:var(--dsw-alias-border-l2,#e5e6eb);
57
- --tu-border-strong:var(--dsw-alias-border-l3,#dfe1e5);
58
- --tu-text:var(--dsw-alias-label-primary,#1f2329);
59
- --tu-text-sub:var(--dsw-alias-label-secondary,#646a73);
60
- --tu-text-dim:var(--dsw-alias-label-tertiary,#8f959e);
61
- --tu-accent:#1677ff;
62
- --tu-accent-deep:#0958d9;
63
- --tu-accent-wash:#eaf3ff;
64
- --tu-danger:var(--dsw-alias-state-error-primary,#d54941);
65
- --tu-ok:var(--dsw-alias-state-success-primary,#20a162);
66
- --tu-hover:var(--dsw-alias-interactive-bg-hover,#f7f8fa);
67
- --tu-mask:var(--dsw-alias-bg-mask-1,rgba(31,35,41,.5));
68
- --tu-shadow:0 1px 2px rgb(31 35 41 / 4%);
69
- --tu-radius-sm:6px;--tu-radius-md:8px;--tu-radius-lg:14px;
51
+ --tu-bg:var(--dsw-alias-bg-layer-1);
52
+ --tu-bg-sub:var(--dsw-alias-bg-layer-2);
53
+ --tu-bg-muted:var(--dsw-alias-bg-module-platform);
54
+ --tu-border:var(--dsw-alias-border-l2);
55
+ --tu-border-strong:var(--dsw-alias-border-l3);
56
+ --tu-text:var(--dsw-alias-label-primary);
57
+ --tu-text-sub:var(--dsw-alias-label-secondary);
58
+ --tu-text-dim:var(--dsw-alias-label-tertiary);
59
+ --tu-accent:var(--dsw-alias-brand-primary);
60
+ --tu-accent-deep:var(--dsw-alias-button-primary-hover);
61
+ --tu-accent-wash:color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
62
+ --tu-danger:var(--dsw-alias-state-error-primary);
63
+ --tu-ok:var(--dsw-alias-state-success-primary);
64
+ --tu-hover:var(--dsw-alias-interactive-bg-hover);
65
+ --tu-mask:var(--dsw-alias-bg-mask-1);
66
+ --tu-shadow:0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent);
67
+ --tu-radius-sm:6px;--tu-radius-md:8px;--tu-radius-lg:12px;
70
68
  --tu-space-2:6px;--tu-space-3:8px;--tu-space-4:10px;--tu-space-5:12px;--tu-space-6:18px;
71
69
  --tu-font-sm:12px;--tu-font-md:13px;--tu-font-lg:15px;
72
70
  --tu-control-h:30px;
@@ -76,21 +74,21 @@ body{
76
74
  .tu-toolbar{display:flex;align-items:center;gap:var(--tu-space-3);flex-wrap:wrap}
77
75
  .tu-spacer{flex:1}
78
76
  .tu-button{min-height:var(--tu-control-h);display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--tu-border-strong);border-radius:var(--tu-radius-md);padding:0 13px;font:inherit;font-size:var(--tu-font-md);font-weight:560;background:var(--tu-bg);color:var(--tu-text);cursor:pointer;transition:border-color .15s,background .15s,color .15s}
79
- .tu-button:hover:not(:disabled){border-color:#aeb3bb;background:var(--tu-hover)}
77
+ .tu-button:hover:not(:disabled){border-color:var(--tu-border-strong);background:var(--tu-hover)}
80
78
  .tu-button:disabled{cursor:not-allowed;opacity:.55}
81
- .tu-button--primary{background:var(--tu-accent);border-color:var(--tu-accent);color:#fff}
79
+ .tu-button--primary{background:var(--tu-accent);border-color:var(--tu-accent);color:var(--dsw-alias-bg-base)}
82
80
  .tu-button--primary:hover:not(:disabled){background:var(--tu-accent-deep);border-color:var(--tu-accent-deep)}
83
81
  .tu-button--danger{color:var(--tu-danger)}
84
- .tu-button--danger:hover:not(:disabled){border-color:var(--tu-danger);background:var(--tu-danger);color:#fff}
82
+ .tu-button--danger:hover:not(:disabled){border-color:var(--tu-danger);background:var(--tu-danger);color:var(--dsw-alias-bg-base)}
85
83
  .tu-button--sm{min-height:28px;padding:0 10px;font-size:var(--tu-font-sm)}
86
- .tu-button:focus-visible,.tu-icon:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:2px}
84
+ .tu-button:focus-visible,.tu-icon:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:2px}
87
85
  .tu-meta{font-size:var(--tu-font-sm);color:var(--tu-text-sub)}
88
86
  .tu-rows{display:flex;flex-direction:column;gap:var(--tu-space-1);font-size:var(--tu-font-md)}
89
87
  .tu-row{display:flex;align-items:center;gap:var(--tu-space-3);border:1px solid var(--tu-border);border-radius:var(--tu-radius-md);padding:var(--tu-space-2) var(--tu-space-4);flex-wrap:wrap;transition:background .15s,border-color .15s}
90
88
  .tu-row:hover{background:var(--tu-hover)}
91
89
  .tu-name{font-size:14px;font-weight:600;color:var(--tu-text)}
92
90
  .tu-badge{min-height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:999px;color:var(--tu-text-sub);background:var(--tu-bg-muted);font-size:var(--tu-font-sm);white-space:nowrap}
93
- .tu-code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:var(--tu-font-sm);color:var(--tu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
91
+ .tu-code{font-family:var(--ds-font-family-code);font-size:var(--tu-font-sm);color:var(--tu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
94
92
  .tu-link{border:none;background:transparent;padding:0;font:inherit;cursor:pointer;color:var(--tu-accent);text-decoration:underline;text-underline-offset:3px}
95
93
  .tu-link:hover{color:var(--tu-accent-deep)}
96
94
  .tu-actions{display:flex;align-items:center;gap:var(--tu-space-2);margin-left:auto}
@@ -100,11 +98,11 @@ body{
100
98
  .tu-error{font-size:var(--tu-font-sm);color:var(--tu-danger);background:var(--tu-bg-sub);border-left:3px solid var(--tu-danger);border-radius:var(--tu-radius-sm);padding:var(--tu-space-2) var(--tu-space-3)}
101
99
  .tu-switch{position:relative;display:inline-flex;align-items:center;cursor:pointer;gap:var(--tu-space-2)}
102
100
  .tu-switch input[type="checkbox"] { position:absolute;opacity:0;width:1px;height:1px }
103
- .tu-switch__track{width:30px;height:17px;border-radius:999px;background:#aeb3bb;position:relative;transition:background .15s;flex:none}
104
- .tu-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.3)}
101
+ .tu-switch__track{width:30px;height:17px;border-radius:999px;background:var(--tu-border-strong);position:relative;transition:background .15s;flex:none}
102
+ .tu-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-bg-base);transition:left .15s;box-shadow:0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent)}
105
103
  .tu-switch input[type="checkbox"]:checked + .tu-switch__track{background:var(--tu-accent)}
106
104
  .tu-switch input[type="checkbox"]:checked + .tu-switch__track .tu-switch__thumb{left:15px}
107
- .tu-switch input[type="checkbox"]:focus-visible + .tu-switch__track{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:1px}
105
+ .tu-switch input[type="checkbox"]:focus-visible + .tu-switch__track{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:1px}
108
106
  .tu-switch input[type="checkbox"]:disabled + .tu-switch__track{opacity:.4;cursor:not-allowed}
109
107
  .tu-switch:not(:has(input[type="checkbox"]:disabled)):hover .tu-switch__track{background:var(--tu-accent-deep);opacity:.85}
110
108
  .tu-switch input[type="checkbox"]:checked:not(:disabled) + .tu-switch__track:hover{background:var(--tu-accent-deep)}
@@ -126,7 +124,7 @@ body{
126
124
  .tu-pc__rowLabel{flex:1;min-width:0}
127
125
  .tu-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
128
126
  .tu-modal-mask{position:fixed;inset:0;background:var(--tu-mask);display:flex;align-items:center;justify-content:center;z-index:60;animation:tu-fade-in .16s ease-out}
129
- .tu-modal{background:var(--tu-bg);color:var(--tu-text);border-radius:var(--tu-radius-lg);padding:var(--tu-space-6);max-width:520px;width:min(520px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 12px 40px rgb(31 35 41 / 18%);animation:tu-pop-in .2s ease-out}
127
+ .tu-modal{background:var(--tu-bg);color:var(--tu-text);border-radius:var(--tu-radius-lg);padding:var(--tu-space-6);max-width:520px;width:min(520px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:var(--dsw-shadow-lv3);animation:tu-pop-in .2s ease-out}
130
128
  @keyframes tu-fade-in{from{opacity:0}to{opacity:1}}
131
129
  @keyframes tu-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
132
130
  @media (prefers-reduced-motion: reduce){.tu-modal-mask,.tu-modal{animation:none}.tu-row,.tu-button,.tu-icon,.tu-switch__track,.tu-pc,.tu-pc__chevron{transition:none}}
@@ -136,9 +134,9 @@ body{
136
134
  .tu-seg{display:inline-flex;gap:2px;background:var(--tu-bg-muted);border-radius:8px;padding:2px;width:fit-content}
137
135
  .tu-seg__opt{border:none;background:transparent;border-radius:6px;padding:5px 11px;font:inherit;font-size:var(--tu-font-sm);font-weight:520;color:var(--tu-text-sub);cursor:pointer;line-height:1.2;white-space:nowrap;transition:background .15s,color .15s}
138
136
  .tu-seg__opt:hover{color:var(--tu-text)}
139
- .tu-seg__opt--on{background:var(--tu-accent);color:#fff;font-weight:560}
140
- .tu-seg__opt--on:hover{color:#fff}
141
- .tu-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:1px}
137
+ .tu-seg__opt--on{background:var(--tu-accent);color:var(--dsw-alias-bg-base);font-weight:560}
138
+ .tu-seg__opt--on:hover{color:var(--dsw-alias-bg-base)}
139
+ .tu-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:1px}
142
140
  .tu-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--tu-space-3) var(--tu-space-4)}
143
141
  .tu-field{display:flex;flex-direction:column;gap:4px;font-size:var(--tu-font-sm);min-width:0;color:var(--tu-text-sub)}
144
142
  .tu-field--wide{grid-column:1 / -1}
@@ -174,9 +172,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
174
172
  try {
175
173
  React = require('react')
176
174
  ReactDOMClient = require('react-dom/client')
177
- } catch {
175
+ } catch (error) {
176
+ console.error('[dsh-tunnel] 宿主 React 模块加载失败', error)
178
177
  return noopFactory()
179
178
  }
179
+ const tools = require('@mzzsfy/dsh-common-tools/client')
180
180
  const { useState, useEffect, useRef, useCallback } = React
181
181
  const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
182
182
 
@@ -324,26 +324,28 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
324
324
  disabled: busy || !nameOk || !portOk, onClick: submit }, '创建')))
325
325
  }
326
326
 
327
- function TunnelPanel() {
327
+ function TunnelPanel({ bus }) {
328
328
  const [rows, setRows] = useState(null)
329
329
  const [error, setError] = useState('')
330
330
  const [showAdd, setShowAdd] = useState(false)
331
331
  const [busyName, setBusyName] = useState('')
332
- const [reloadFlag, setReloadFlag] = useState(0)
333
- const reload = useCallback(() => setReloadFlag((flag) => flag + 1), [])
332
+ const refreshRef = useRef(() => {})
333
+ const reload = useCallback(() => refreshRef.current(), [])
334
334
  useEffect(() => {
335
335
  let alive = true
336
336
  const load = async () => {
337
337
  const listOutcome = await request('GET', '/tunnels')
338
338
  if (!alive) return
339
- // 成功即清错误: 瞬态失败(如插件重载期轮询)恢复后错误条不滞留
339
+ // 成功即清错误: 瞬态失败(如插件重载期刷新)恢复后错误条不滞留
340
340
  if (listOutcome.ok) { setRows(listOutcome.data.items); setError('') }
341
341
  else setError(listOutcome.error)
342
342
  }
343
- load()
344
- const timer = setInterval(load, REFRESH_INTERVAL_MS)
345
- return () => { alive = false; clearInterval(timer) }
346
- }, [reloadFlag])
343
+ // 数据刷新唯一通道:订阅推送总线(首连 onReset 即首拉,消息到达刷新)
344
+ const snapshot = tools.createSnapshotRefresh(load)
345
+ refreshRef.current = snapshot.refresh
346
+ const unsubscribe = bus.subscribe(snapshot.refresh, { onReset: snapshot.refresh })
347
+ return () => { alive = false; refreshRef.current = () => {}; snapshot.dispose(); unsubscribe() }
348
+ }, [bus])
347
349
  const remove = async (row) => {
348
350
  if (!window.confirm('删除隧道 ' + row.name + '?')) return
349
351
  setBusyName(row.name)
@@ -384,13 +386,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
384
386
  h('path', { d: 'm10.5 10.5 3 3' }))
385
387
  }
386
388
 
387
- function registerMainPanel(ctx) {
389
+ function registerMainPanel(ctx, bus) {
388
390
  return ctx.effect(() => {
389
391
  const injectDisposers = []
390
392
  injectDisposers.push(ctx.slots.inject('main', () => ctx.slots.register({
391
393
  name: 'main',
392
394
  key: PANEL_ID,
393
- }, () => h('div', { className: 'tu-main' }, h(TunnelPanel)))))
395
+ }, () => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })))))
394
396
  injectDisposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
395
397
  name: 'sidebar.panellist',
396
398
  id: PANEL_ID,
@@ -402,7 +404,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
402
404
 
403
405
  // better-sidebar tab:注册即单实例(single);只注册不主动打开,
404
406
  // 打开由用户从侧边栏目录手动进入
405
- function registerBoardTab(ctx, service) {
407
+ function registerBoardTab(ctx, service, bus) {
406
408
  return ctx.effect(() => {
407
409
  return service.registerTab({
408
410
  id: TAB_ID,
@@ -410,7 +412,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
410
412
  icon: (size) => h(PanelIcon, { size }),
411
413
  order: TAB_ORDER,
412
414
  single: true,
413
- component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel)),
415
+ component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })),
414
416
  })
415
417
  }, 'dsh-tunnel sidebar tab')
416
418
  }
@@ -480,13 +482,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
480
482
  // slots 供面板与设置卡片挂载(缺失则 fiber 未激活, 即干净禁用)
481
483
  inject: ['slots'],
482
484
  apply(ctx) {
485
+ const bus = tools.setupCommonTools(ctx)
483
486
  ensureStyle(document)
484
487
  // 挂载权交给用户:默认注册全局面板;偏好开启且侧边栏服务在场才移入扩展槽
485
488
  let currentSidebar = ctx.get('betterSidebar')
486
489
  let sidebarTabOn = false
487
490
  let tabDisposer = null
488
491
  let panelDisposer = null
489
- const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar)
492
+ const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, bus)
490
493
  const applyPref = () => {
491
494
  const wantTab = sidebarTabOn && currentSidebar !== undefined
492
495
  if (wantTab && tabDisposer === null) {
@@ -496,11 +499,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
496
499
  tabDisposer()
497
500
  tabDisposer = null
498
501
  }
499
- if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx)
502
+ if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx, bus)
500
503
  }
501
- const prefWatch = setInterval(async () => {
504
+ // 偏好仲裁数据源:订阅总线刷新(ui-settings 变更即 publish),不再周期轮询
505
+ let prefAlive = true
506
+ const syncPref = async () => {
502
507
  try {
503
508
  const outcome = await request('GET', '/status')
509
+ if (!prefAlive) return
504
510
  const next = outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false
505
511
  if (next !== sidebarTabOn) {
506
512
  const previous = sidebarTabOn
@@ -508,15 +514,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
508
514
  try {
509
515
  applyPref()
510
516
  } catch (error) {
511
- // 形态切换失败回滚偏好, 下轮轮询重试, 避免入口消失后卡死
517
+ // 形态切换失败回滚偏好, 下轮事件重试, 避免入口消失后卡死
512
518
  sidebarTabOn = previous
513
519
  console.warn('[dsh-tunnel] 看板形态切换失败:', error)
514
520
  }
515
521
  }
516
- } catch { /* 轮询失败保形态不变, 下轮重试 */ }
517
- }, PREF_POLL_MS)
522
+ } catch { /* 刷新失败保形态不变, 下轮事件重试 */ }
523
+ }
524
+ const prefRefresh = tools.createSnapshotRefresh(syncPref)
525
+ const unsubscribePref = bus.subscribe(prefRefresh.refresh, { onReset: prefRefresh.refresh })
518
526
  ctx.effect(() => () => {
519
- clearInterval(prefWatch)
527
+ prefAlive = false
528
+ prefRefresh.dispose()
529
+ unsubscribePref()
520
530
  if (tabDisposer) tabDisposer()
521
531
  if (panelDisposer) panelDisposer()
522
532
  }, 'dsh-tunnel mount arbitration')
package/src/index.js CHANGED
@@ -13,10 +13,13 @@ import { join } from 'node:path'
13
13
  import z from '@deepseek-ai/schemastery'
14
14
  import { defineTool } from '@deepseek-ai/dsh-tools'
15
15
 
16
+ import { setupCommonTools, createChangeNotifier } from '@mzzsfy/dsh-common-tools/host'
16
17
  import { createApi, MESSAGES, ROUTE_PREFIX as API_PREFIX } from './api.mjs'
17
18
 
18
19
  export const name = 'dsh-tunnel'
19
20
 
21
+ // 推送总线:变更通知统一走公共依赖 common-tools(webServer 形状到位即激活,
22
+ // 客户端轮询已废除不留双路径);插件卸载时总线按在场消费方自动收尾
20
23
  export const inject = ['webServer']
21
24
 
22
25
  const PATH_PREFIX = '/p/'
@@ -280,6 +283,9 @@ function saveStore(dataDir, rows) {
280
283
 
281
284
  export function apply(ctx, config) {
282
285
  const webServer = ctx.webServer
286
+ const bus = setupCommonTools(ctx)
287
+ const changes = createChangeNotifier(() => bus.publish())
288
+ ctx.effect(() => () => changes.dispose(), '通知变更合并')
283
289
 
284
290
  // 形状守卫: 宿主形态漂移时干净禁用并告警, 绝不破坏宿主
285
291
  const shapeOk = webServer.exact instanceof Map
@@ -392,6 +398,7 @@ export function apply(ctx, config) {
392
398
  subdomains.set(record.name, record)
393
399
  tunnels.set(record.name, record)
394
400
  persist()
401
+ changes.refresh()
395
402
  return { ok: true, name: record.name, entry, host: `${record.name}.localhost`, port: webServer.port }
396
403
  }
397
404
  let disposeRoutes
@@ -402,6 +409,7 @@ export function apply(ctx, config) {
402
409
  }
403
410
  tunnels.set(record.name, { ...record, disposeRoutes })
404
411
  persist()
412
+ changes.refresh()
405
413
  return { ok: true, name: record.name, entry, path: PATH_PREFIX + record.name, port: webServer.port }
406
414
  },
407
415
  list() {
@@ -424,6 +432,7 @@ export function apply(ctx, config) {
424
432
  subdomains.delete(args.name)
425
433
  tunnels.delete(args.name)
426
434
  persist()
435
+ changes.refresh()
427
436
  return { ok: true, removed: true }
428
437
  },
429
438
  disposeAll() {
@@ -656,6 +665,7 @@ export function apply(ctx, config) {
656
665
  if (Object.keys(patch).length === 0) throw new Error(uiBooleanError(UI_PREF_KEYS))
657
666
  if (!(await persistPatch(patch))) return { ok: false, error: SETTINGS_UNAVAILABLE }
658
667
  applyPreferences()
668
+ changes.refresh()
659
669
  return { ok: true, ui: readUi() }
660
670
  }
661
671
 
@@ -0,0 +1,20 @@
1
+ // common-tools host 桩(经 --import 预载,先于被测模块实例化拦截 ESM):
2
+ // publish 记入 busStub.published(断言点零改);onPresence 注册即报 0,
3
+ // busStub.presence 供测试手动驱动连接计数
4
+ import { mock } from 'node:test'
5
+
6
+ export const busStub = { published: [], presence: new Set() }
7
+ export function resetBusStub() {
8
+ busStub.published = []
9
+ busStub.presence = new Set()
10
+ }
11
+
12
+ mock.module('@mzzsfy/dsh-common-tools/host', {
13
+ namedExports: {
14
+ createChangeNotifier: refresh => ({ refresh, dispose() {} }),
15
+ setupCommonTools: () => ({
16
+ publish: (payload = null) => busStub.published.push(payload),
17
+ onPresence(fn) { busStub.presence.add(fn); fn(0); return () => busStub.presence.delete(fn) },
18
+ }),
19
+ },
20
+ })
@@ -8,6 +8,8 @@ import { mkdtempSync, rmSync } from 'node:fs'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
 
11
+ import { busStub, resetBusStub } from './_mocks.common-tools.mjs'
12
+
11
13
  const TOOL_NAMES = ['tunnel_open', 'tunnel_list', 'tunnel_close']
12
14
 
13
15
  class MockWebServer {
@@ -56,6 +58,9 @@ function createCtx({ tools, settings, configEditor, fiber } = {}) {
56
58
  if (typeof disposer === 'function') effectDisposers.push(disposer)
57
59
  return disposer
58
60
  }
61
+ // 总线 publish 桩:桥接到最近 createCtx 的 published 供断言
62
+ resetBusStub()
63
+ const published = busStub.published
59
64
  const ctx = {
60
65
  webServer: undefined,
61
66
  fiber,
@@ -68,7 +73,7 @@ function createCtx({ tools, settings, configEditor, fiber } = {}) {
68
73
  effect: runEffect,
69
74
  inject(names, cb) { cb({ tools, settings, effect: runEffect }) },
70
75
  }
71
- return { ctx, events, effectDisposers }
76
+ return { ctx, events, effectDisposers, published }
72
77
  }
73
78
 
74
79
  // legacy mock settings: 与 tunnel.test.mjs 同款语义(内存存储 + watch 登记 + 手动触发)
@@ -0,0 +1,146 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+ const PKG = '@mzzsfy/dsh-tunnel'
7
+ const flush = () => new Promise(resolve => setImmediate(resolve))
8
+
9
+ function fixture(sidebarTab) {
10
+ let definition, pendingEffects
11
+ const owners = new Map(), requests = [], clocks = new Set()
12
+ const window = { __ModuleLoader__: { load: value => { definition = value } } }
13
+ const document = { getElementById: () => ({}) }
14
+ const react = {
15
+ createElement: (type, props, ...children) => ({ type, props: { ...props, children } }),
16
+ useState: value => [typeof value === 'function' ? value() : value, () => {}],
17
+ useRef: value => ({ current: value }),
18
+ useCallback: fn => fn,
19
+ useEffect: fn => pendingEffects.push(fn),
20
+ }
21
+ const tools = { createSnapshotRefresh: refresh => ({ refresh, dispose() {} }), setupCommonTools(ctx) {
22
+ assert.equal(ctx.fiber.entry.options.name, PKG)
23
+ const owner = { active: true, subscriptions: new Set() }
24
+ owners.set(ctx, owner)
25
+ ctx.effect(() => () => { owner.active = false; owner.subscriptions.clear() })
26
+ return { subscribe(listener, { onReset } = {}) {
27
+ assert.ok(owner.active, '不能订阅已卸载的 ctx')
28
+ const subscription = { listener, onReset }
29
+ owner.subscriptions.add(subscription)
30
+ return () => owner.subscriptions.delete(subscription)
31
+ } }
32
+ } }
33
+ const fetch = async path => {
34
+ requests.push(path)
35
+ return { ok: true, json: async () => ({ items: [], ui: { sidebarTab } }) }
36
+ }
37
+ new Function('window', 'document', 'fetch', 'setInterval', 'clearInterval', source)(
38
+ window, document, fetch,
39
+ fn => { clocks.add(fn); return fn }, fn => clocks.delete(fn))
40
+ const plugin = definition.factory(name => {
41
+ if (name === 'react') return react
42
+ if (name === 'react-dom/client') return {}
43
+ assert.equal(name, '@mzzsfy/dsh-common-tools/client')
44
+ return tools
45
+ })
46
+ function activate() {
47
+ const cleanups = [], entries = new Set(), tabs = new Set()
48
+ const service = { registerTab(tab) { tabs.add(tab); return () => tabs.delete(tab) } }
49
+ const ctx = {
50
+ fiber: { entry: { options: { name: PKG } } },
51
+ get: name => name === 'betterSidebar' ? service : undefined,
52
+ inject() {},
53
+ effect(fn) {
54
+ const cleanup = fn()
55
+ let active = true
56
+ const dispose = () => { if (active) { active = false; cleanup?.() } }
57
+ cleanups.push(dispose)
58
+ return dispose
59
+ },
60
+ slots: {
61
+ inject(name, fn) { return name === 'main' || name === 'sidebar.panellist' ? fn() : () => {} },
62
+ register(options, component) {
63
+ const entry = { ...options, component }
64
+ entries.add(entry)
65
+ return () => entries.delete(entry)
66
+ },
67
+ },
68
+ }
69
+ plugin.apply(ctx)
70
+ return {
71
+ get subscriptions() { return owners.get(ctx).subscriptions },
72
+ reset() { for (const item of [...this.subscriptions]) item.onReset?.() },
73
+ emit() { for (const item of [...this.subscriptions]) item.listener(null) },
74
+ mount() {
75
+ const component = sidebarTab ? [...tabs][0]?.component : [...entries].find(item => item.name === 'main')?.component
76
+ assert.equal(typeof component, 'function', '面板应已注册')
77
+ pendingEffects = []
78
+ const render = node => {
79
+ if (Array.isArray(node)) return node.forEach(render)
80
+ if (!node || typeof node !== 'object') return
81
+ if (typeof node.type === 'function') render(node.type(node.props))
82
+ else render(node.props.children)
83
+ }
84
+ render(component({ visible: true }))
85
+ const disposers = pendingEffects.map(effect => effect()).filter(value => typeof value === 'function')
86
+ return () => { for (const dispose of disposers.splice(0).reverse()) dispose() }
87
+ },
88
+ dispose() { for (const dispose of [...cleanups].reverse()) dispose() },
89
+ get registrations() { return entries.size + tabs.size },
90
+ }
91
+ }
92
+ return { activate, requests, clocks }
93
+ }
94
+
95
+ for (const sidebarTab of [false, true]) {
96
+ const surface = sidebarTab ? '侧栏' : '主面板'
97
+ for (const survivorIndex of [0, 1]) {
98
+ test(`Given 同 factory 的 A/B 已注册${surface} When 卸载 ${survivorIndex === 0 ? 'B' : 'A'} 后挂载存活面板 Then 仍用自己的租约订阅和刷新`, async () => {
99
+ const f = fixture(sidebarTab), instances = [f.activate(), f.activate()]
100
+ for (const instance of instances) instance.reset()
101
+ await flush()
102
+ const survivor = instances[survivorIndex], removed = instances[1 - survivorIndex]
103
+ removed.dispose()
104
+ assert.equal(removed.subscriptions.size, 0)
105
+ assert.equal(removed.registrations, 0)
106
+ const unmount = survivor.mount()
107
+ assert.equal(survivor.subscriptions.size, 2)
108
+ const before = f.requests.length
109
+ survivor.reset()
110
+ await flush()
111
+ assert.equal(f.requests.length - before, 2)
112
+ const afterReset = f.requests.length
113
+ survivor.emit()
114
+ await flush()
115
+ assert.equal(f.requests.length - afterReset, 2)
116
+ unmount()
117
+ assert.equal(survivor.subscriptions.size, 1, '组件卸载仅清自己的数据订阅')
118
+ const unmountAgain = survivor.mount()
119
+ assert.equal(survivor.subscriptions.size, 2)
120
+ survivor.dispose()
121
+ assert.equal(survivor.subscriptions.size, 0)
122
+ unmountAgain()
123
+ survivor.dispose()
124
+ assert.equal(f.clocks.size, 0)
125
+ })
126
+ }
127
+
128
+ test(`Given A/B 的${surface}都已挂载 When 卸载 A Then 只清 A 的订阅并保留 B 的组件订阅`, async () => {
129
+ const f = fixture(sidebarTab), a = f.activate(), b = f.activate()
130
+ a.reset(); b.reset()
131
+ await flush()
132
+ const unmountA = a.mount(), unmountB = b.mount()
133
+ assert.equal(a.subscriptions.size, 2)
134
+ assert.equal(b.subscriptions.size, 2)
135
+ a.dispose()
136
+ assert.equal(a.subscriptions.size, 0)
137
+ assert.equal(b.subscriptions.size, 2)
138
+ unmountA()
139
+ assert.equal(b.subscriptions.size, 2)
140
+ unmountB()
141
+ assert.equal(b.subscriptions.size, 1)
142
+ b.dispose()
143
+ assert.equal(b.subscriptions.size, 0)
144
+ assert.equal(f.clocks.size, 0)
145
+ })
146
+ }
@@ -0,0 +1,29 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import vm from 'node:vm'
5
+
6
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
7
+ function materialize(missing) {
8
+ let definition
9
+ const diagnostics = []
10
+ const window = { __ModuleLoader__: { load(value) { definition = value } } }
11
+ vm.runInNewContext(source, { window, console: { error: (...args) => diagnostics.push(args) } })
12
+ const require = name => {
13
+ if (name === missing) throw new Error(`module missing: ${name}`)
14
+ return {}
15
+ }
16
+ return { definition, require, diagnostics }
17
+ }
18
+
19
+ test('Given 缺公共client When 物化隧道模块 Then 向装载层抛出具体依赖错误', () => {
20
+ const f = materialize('@mzzsfy/dsh-common-tools/client')
21
+ assert.throws(() => f.definition.factory(f.require), /module missing: @mzzsfy\/dsh-common-tools\/client/)
22
+ })
23
+ for (const missing of ['react', 'react-dom/client']) test(`Given 缺${missing} When 物化 Then 禁用并报告宿主依赖原因`, () => {
24
+ const f = materialize(missing)
25
+ const module = f.definition.factory(f.require)
26
+ assert.deepEqual(Array.from(module.inject), [])
27
+ assert.equal(f.diagnostics.length, 1)
28
+ assert.match(String(f.diagnostics[0]), new RegExp(missing))
29
+ })
@@ -1,5 +1,5 @@
1
1
  // 开关样式守卫 BDD:裸 checkbox/裸 input 状态选择器会静默损坏 UI 或误伤同 label 的文本输入,
2
- // 属无报错的 UI 退化, 静态断言锁死 tu-switch 结构(参照 cb-switch/mce-switch 同款守卫)。
2
+ // 属无报错的 UI 退化, 静态断言锁死 tu-switch 结构(参照 cb-switch/tn-switch 同款守卫)。
3
3
  import { readFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
@@ -10,6 +10,8 @@ import { tmpdir } from 'node:os'
10
10
  import { join } from 'node:path'
11
11
  import { once } from 'node:events'
12
12
 
13
+ import { busStub, resetBusStub } from './_mocks.common-tools.mjs'
14
+
13
15
  const DEFAULT_CONNECT_TIMEOUT = 10 * 1000
14
16
  const PORT_MAX = 65535
15
17
 
@@ -51,6 +53,9 @@ function createCtx(webServer, tools = undefined, settings = undefined) {
51
53
  if (typeof disposer === 'function') effectDisposers.push(disposer)
52
54
  return disposer
53
55
  }
56
+ // 总线 publish 桩:桥接到最近 createCtx 的 published 供断言
57
+ resetBusStub()
58
+ const published = busStub.published
54
59
  const ctx = {
55
60
  webServer,
56
61
  get: () => undefined,
@@ -60,7 +65,7 @@ function createCtx(webServer, tools = undefined, settings = undefined) {
60
65
  cb({ tools: toolsService, settings, effect: runEffect })
61
66
  },
62
67
  }
63
- return { ctx, toolsService, effectDisposers }
68
+ return { ctx, toolsService, effectDisposers, published }
64
69
  }
65
70
 
66
71
  // mock settings 服务: 内存存储 + watch 回调登记, 测试手动触发模拟设置页写入
@@ -92,7 +97,7 @@ process.on('exit', () => {
92
97
 
93
98
  async function applyPlugin(webServer, options = {}) {
94
99
  const { apply } = await import('../src/index.js')
95
- const { ctx, toolsService, effectDisposers } = createCtx(webServer, undefined, options.settings)
100
+ const { ctx, toolsService, effectDisposers, published } = createCtx(webServer, undefined, options.settings)
96
101
  const dataDir = options.dataDir ?? defaultDataDir()
97
102
  const config = { connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT, dataDir }
98
103
  const warnings = []
@@ -110,7 +115,7 @@ async function applyPlugin(webServer, options = {}) {
110
115
  dispose()
111
116
  for (const disposer of effectDisposers.reverse()) disposer()
112
117
  }
113
- return { webServer, tools, dataDir, warnings, dispose: disposeAll, ctx }
118
+ return { webServer, tools, dataDir, warnings, dispose: disposeAll, ctx, published }
114
119
  }
115
120
 
116
121
  function toolOf(plugin, name) {
@@ -262,6 +267,17 @@ test('场景2 open: Given 合法参数与 wsPaths Then prefix 路由与两形态
262
267
  assert.deepEqual(persisted.map((row) => row.name), ['app'])
263
268
  })
264
269
 
270
+ test('场景2b 总线通知: Given 增删与偏好写路径 Then publish() 契约', async (t) => {
271
+ const plugin = await applyPlugin(new MockWebServer())
272
+ assert.deepEqual(plugin.published, [], '装载期无变更不发布')
273
+ await open(plugin, { name: 'app', targetPort: PORT_MAX - 1 })
274
+ await open(plugin, { name: 'app2', targetPort: PORT_MAX - 2, wsPaths: [] })
275
+ await close(plugin, 'app')
276
+ const published = plugin.published
277
+ assert.equal(published.length, 3, 'open 两次 + close 一次')
278
+ for (const payload of published) assert.equal(payload, null)
279
+ })
280
+
265
281
  test('场景3 校验: Given 非法名或越界端口 Then 拒收且不注册不落盘', async (t) => {
266
282
  const plugin = await applyPlugin(new MockWebServer())
267
283
  for (const args of [{ name: 'App', targetPort: 80 }, { name: 'a.b', targetPort: 80 }, { name: 'ok', targetPort: 0 }, { name: 'ok', targetPort: PORT_MAX + 1 }]) {