@mzzsfy/dsh-tunnel 0.1.2 → 0.1.4

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
@@ -29,15 +29,19 @@
29
29
  - 持久化 `~/.dsh/dsh-tunnel/tunnels.json`(随 `DSH_HOME`), 重启自动恢复; 目标拒连 502, 响应头等待超时(默认 10s, 头到即解除, SSE 不受影响)504
30
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
31
 
32
+ ## client 依赖
33
+
34
+ 看板通过 `@mzzsfy/dsh-common-tools/client` 订阅数据变更。安装时同时启用 `dsh-session-manager`,由其唯一代挂条目装载公共 client,并排在 tunnel 前物化。公共模块缺失时装载层会报告具体依赖错误;宿主 React 模块缺失时控制台报告原因并禁用面板。
35
+
32
36
  ## 验证
33
37
 
34
- - `node --test "test/*.test.mjs"`: 37 场景 + 8 api 用例 + 11 ai 开关注册面 + 11 守卫断言 + client 端 8 用例(id 守卫 + 总线生命周期;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.2",
4
+ "version": "0.1.4",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -169,15 +169,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
169
169
  function factory(require) {
170
170
  let React = null
171
171
  let ReactDOMClient = null
172
- let tools = null
173
172
  try {
174
173
  React = require('react')
175
174
  ReactDOMClient = require('react-dom/client')
176
- // 公共工具库:bus 订阅是数据刷新唯一通道,缺失即整面板禁用(无轮询回退)
177
- tools = require('@mzzsfy/dsh-common-tools/client')
178
- } catch {
175
+ } catch (error) {
176
+ console.error('[dsh-tunnel] 宿主 React 模块加载失败', error)
179
177
  return noopFactory()
180
178
  }
179
+ const tools = require('@mzzsfy/dsh-common-tools/client')
181
180
  const { useState, useEffect, useRef, useCallback } = React
182
181
  const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
183
182
 
package/src/index.js CHANGED
@@ -90,10 +90,12 @@ export const SETTINGS_SCHEMA = z.object({
90
90
 
91
91
  const normalizeWsPath = (value) => value.replace(/\/+$/, '')
92
92
 
93
- // 校验作用于归一前的原始值; '/' 为合法输入
93
+ // 校验作用于归一前的原始值; '/' 为合法输入。空串 = 根路径归一产物(F26:
94
+ // '/' 去尾斜杠得 '',恢复校验须接受,否则根 WS 隧道重启丢)
94
95
  function normalizeWsPaths(raw) {
95
96
  if (raw === undefined) return []
96
- if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string' || !WS_PATH_CHARS_RE.test(item))) return undefined
97
+ const legal = (item) => typeof item === 'string' && (item === '' || WS_PATH_CHARS_RE.test(item))
98
+ if (!Array.isArray(raw) || raw.some((item) => !legal(item))) return undefined
97
99
  return [...new Set(raw.map(normalizeWsPath))]
98
100
  }
99
101
 
@@ -201,6 +203,11 @@ function forwardHttp(req, res, record, connectTimeoutMs) {
201
203
  }
202
204
  res.writeHead(proxyRes.statusCode, headers)
203
205
  proxyRes.pipe(res)
206
+ // 上游响应中断即终止下游(F25,与 res close 反向联动成对):
207
+ // aborted 覆盖请求体阶段中断,error 覆盖响应期套接字错误;
208
+ // 两态都是"响应不再完整",挂着的下游连接应立刻回收
209
+ proxyRes.on('aborted', () => res.destroy())
210
+ proxyRes.on('error', () => res.destroy())
204
211
  })
205
212
  proxyReq.on('error', (error) => {
206
213
  if (res.headersSent) return res.destroy()
@@ -385,6 +392,11 @@ export function apply(ctx, config) {
385
392
  if (!VALID_ENTRIES.has(entry)) {
386
393
  return { ok: false, error: `entry 不合法: 须为 ${ENTRY_PATH} 或 ${ENTRY_SUBDOMAIN}` }
387
394
  }
395
+ // 自代理拒绝(F27):target 指向本监听端口时,子域名全路径透传+Host 原样
396
+ // 会把上游请求再导回自身,形成无限自代理环
397
+ if (args.targetPort === webServer.port) {
398
+ return { ok: false, error: `targetPort 不得为宿主监听端口 ${webServer.port}(自代理环)` }
399
+ }
388
400
  // 子域名全路径全协议透传, wsPaths 无意义
389
401
  const wsPaths = entry === ENTRY_SUBDOMAIN ? [] : normalizeWsPaths(args.wsPaths)
390
402
  if (wsPaths === undefined) {
@@ -521,6 +533,11 @@ export function apply(ctx, config) {
521
533
  console.warn(`${WARN_TAG}隧道表存在非法行, 已跳过: ${JSON.stringify(row)}`)
522
534
  continue
523
535
  }
536
+ // 自代理拒绝同 open(F27):恢复面不放行指向宿主监听端口的环
537
+ if (row.targetPort === webServer.port) {
538
+ console.warn(`${WARN_TAG}隧道 ${row.name} 的 targetPort 为宿主监听端口 ${webServer.port}(自代理环), 已跳过`)
539
+ continue
540
+ }
524
541
  if (tunnels.has(row.name)) {
525
542
  console.warn(`${WARN_TAG}隧道表存在重复名 ${row.name}, 仅恢复首行`)
526
543
  continue
@@ -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
+ })