@mzzsfy/dsh-tunnel 0.1.0
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 +39 -0
- package/cordis.patch.yml +9 -0
- package/package.json +42 -0
- package/src/api.mjs +116 -0
- package/src/client.js +548 -0
- package/src/index.js +695 -0
- package/test/ai-switch.test.mjs +272 -0
- package/test/api.test.mjs +145 -0
- package/test/client-id.test.mjs +25 -0
- package/test/switch-guard.test.mjs +81 -0
- package/test/tunnel.test.mjs +921 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# dsh-tunnel
|
|
2
|
+
|
|
3
|
+
穿透隧道插件: 在 dsh 宿主进程内把本地 TCP 端口反向代理出来, 并向 ai 注册 `tunnel_open` / `tunnel_list` / `tunnel_close` 三个工具, 赋予 ai 主动开关隧道的能力。代理逻辑全在插件进程内, 不引入 nginx/caddy 等外部反代。
|
|
4
|
+
|
|
5
|
+
## 双入口形态
|
|
6
|
+
|
|
7
|
+
### path(默认)——子路径模式
|
|
8
|
+
|
|
9
|
+
`http://<host>:<port>/p/<name>/` → 本机 targetPort:
|
|
10
|
+
|
|
11
|
+
- HTTP 前缀 strip 纯字节转发, 保留原 Host, 附 `x-forwarded-host/for/proto`; 3xx 同源根绝对路径的 `Location` 单头补前缀
|
|
12
|
+
- WebSocket: 创建时声明 `wsPaths`(相对隧道根), 每值注册带/不带尾斜杠两形态 exact upgrade 路由; 缺省 = 纯 HTTP
|
|
13
|
+
- 应用需支持子路径部署(vite `--base` / jupyter `base_url` / gradio `root_path` / streamlit `baseUrlPath`), 由 ai 以正确 base 参数启动
|
|
14
|
+
|
|
15
|
+
### subdomain——子域名模式
|
|
16
|
+
|
|
17
|
+
`http://<name>.<任意泛解析到本机的域名>:<port>/` → 本机 targetPort(本机浏览器直接 `<name>.localhost:<port>`):
|
|
18
|
+
|
|
19
|
+
- 全路径全协议透传: 无 strip、不改写 Location、WebSocket 任意路径可用(动态内核通道如 jupyter 无需枚举)
|
|
20
|
+
- 应用按根路径正常启动, 无需 base 参数
|
|
21
|
+
- 实现: wrap+shadow 分流(与 dsh-auto-trust-all 同构), 隧道 Host 命中即转发, 隧道域名上与宿主路由完全隔离; Host 信任依赖 dsh-auto-trust-all
|
|
22
|
+
- 限制: 隧道名避开入口域名首标签(如 GUI 经 `dsh.example.com` 访问则别用隧道名 `dsh`)
|
|
23
|
+
|
|
24
|
+
## 使用
|
|
25
|
+
|
|
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+ 插件管理页包详情)
|
|
28
|
+
- 设置面: legacy(≤0.1.6) 设置 > 插件页独立配置节; 0.1.7+ Config volatile 字段自动配置节(connectTimeoutMs / aiTools / sidebarTab 均支持原地热更, dataDir 为环境路径不进 UI)。`connectTimeoutMs`(响应头等待超时, 改动即时生效, 无需重启)
|
|
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 同一入口
|
|
31
|
+
|
|
32
|
+
## 验证
|
|
33
|
+
|
|
34
|
+
- `node --test "test/*.test.mjs"`: 36 场景 + 8 api 用例 + 11 ai 开关注册面 + 12 守卫断言(mock webServer + 真实 http/net 端到端)
|
|
35
|
+
- `node scripts/compat/tunnel-fullchain.mjs`: 隔离 dsh 实例全链路(真实激活/恢复/分发面七项探针)
|
|
36
|
+
|
|
37
|
+
## 设计文档
|
|
38
|
+
|
|
39
|
+
`docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-tunnel bundle patch: 把本包 host 平面行注册进 profile 根
|
|
2
|
+
# (`dsh plugin add @mzzsfy/dsh-tunnel` 应用):
|
|
3
|
+
# - 路径穿透隧道: /p/<name> 前缀路由 + 声明式 WS upgrade + /api/tunnel REST 面 + ai 工具三件。
|
|
4
|
+
# - client 半区(隧道看板 + 设置页卡片)随宿主条目进入客户端模块表(dsh.client.platform=web)。
|
|
5
|
+
# 隧道表存自有文件 ~/.dsh/dsh-tunnel/tunnels.json; 设置偏好(侧边栏开关/超时)经宿主 settings 服务。
|
|
6
|
+
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-tunnel
|
|
9
|
+
name: '@mzzsfy/dsh-tunnel'
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mzzsfy/dsh-tunnel",
|
|
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.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"bundle": {
|
|
14
|
+
"patch": "./cordis.patch.yml"
|
|
15
|
+
},
|
|
16
|
+
"client": {
|
|
17
|
+
"platform": "web"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
22
|
+
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mzzsfy/dsh-plugin.git"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@deepseek-ai/schemastery": ">=3.18.0",
|
|
34
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.2"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"test",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"README.md"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// api: REST 路由 /api/tunnel/*。宿主 webServer 仅支持 exact/prefix 注册,
|
|
2
|
+
// 本插件注册一条 prefix, handler 内按 method + 路径段表驱动分发(:name 路径段捕获);
|
|
3
|
+
// 隧道增删改查直通宿主半区 tunnelsApi(open/list/close 同一入口, 与 ai 工具面同表);
|
|
4
|
+
// 错误语义对齐仓内惯例: 业务错误中文透传, 系统级错误收敛固定文案并落服务端日志。
|
|
5
|
+
|
|
6
|
+
const BODY_MAX_BYTES = 64 * 1024
|
|
7
|
+
|
|
8
|
+
export const ROUTE_PREFIX = '/api/tunnel'
|
|
9
|
+
|
|
10
|
+
export const MESSAGES = {
|
|
11
|
+
notFound: '接口不存在',
|
|
12
|
+
badJsonBody: '请求体不是合法 JSON',
|
|
13
|
+
bodyTooLarge: '请求体超过上限',
|
|
14
|
+
systemError: '操作失败(系统级错误, 详见服务端日志)',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sendJson(res, status, payload) {
|
|
18
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
19
|
+
res.end(JSON.stringify(payload))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function respondError(res, error, logSystem) {
|
|
23
|
+
const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
|
|
24
|
+
if (isSystem && logSystem) logSystem(String(error && error.stack || error))
|
|
25
|
+
const message = isSystem ? MESSAGES.systemError : (error && error.message ? error.message : String(error))
|
|
26
|
+
sendJson(res, 400, { error: message })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readBody(req) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let size = 0
|
|
32
|
+
const chunks = []
|
|
33
|
+
req.on('data', (chunk) => {
|
|
34
|
+
size += chunk.length
|
|
35
|
+
if (size > BODY_MAX_BYTES) {
|
|
36
|
+
reject(new Error(MESSAGES.bodyTooLarge))
|
|
37
|
+
req.destroy()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
chunks.push(chunk)
|
|
41
|
+
})
|
|
42
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
43
|
+
req.on('error', reject)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readJsonBody(req) {
|
|
48
|
+
let body
|
|
49
|
+
try {
|
|
50
|
+
body = JSON.parse(await readBody(req))
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error && error.message === MESSAGES.bodyTooLarge) throw error
|
|
53
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
54
|
+
}
|
|
55
|
+
return body && typeof body === 'object' ? body : {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// readUi/updateUi: 设置读写在宿主半区(settings 服务语义归属 index.js), 此处仅路由
|
|
59
|
+
export function createApi({ tunnelsApi, readUi, updateUi, logSystem }) {
|
|
60
|
+
const routes = [
|
|
61
|
+
{ method: 'GET', segments: ['tunnels'], handler: () => ({ items: tunnelsApi.list().tunnels }) },
|
|
62
|
+
{ method: 'POST', segments: ['tunnels'], handler: ({ body }) => {
|
|
63
|
+
const outcome = tunnelsApi.open(body)
|
|
64
|
+
if (!outcome.ok) throw new Error(outcome.error)
|
|
65
|
+
return outcome
|
|
66
|
+
} },
|
|
67
|
+
{ method: 'DELETE', segments: ['tunnels', ':name'], handler: ({ params }) => tunnelsApi.close({ name: params.name }) },
|
|
68
|
+
{ method: 'GET', segments: ['status'], handler: () => ({ ui: readUi(), total: tunnelsApi.list().tunnels.length }) },
|
|
69
|
+
{ method: 'POST', segments: ['ui-settings'], handler: ({ body }) => updateUi(body) },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
function matchRoute(method, segments) {
|
|
73
|
+
for (const route of routes) {
|
|
74
|
+
if (route.method !== method || route.segments.length !== segments.length) continue
|
|
75
|
+
const params = {}
|
|
76
|
+
let matched = true
|
|
77
|
+
for (let i = 0; i < segments.length && matched; i++) {
|
|
78
|
+
const expected = route.segments[i]
|
|
79
|
+
if (expected.startsWith(':')) {
|
|
80
|
+
try {
|
|
81
|
+
params[expected.slice(1)] = decodeURIComponent(segments[i])
|
|
82
|
+
} catch {
|
|
83
|
+
matched = false
|
|
84
|
+
}
|
|
85
|
+
} else matched = expected === segments[i]
|
|
86
|
+
}
|
|
87
|
+
if (matched) return { route, params }
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async handle(req, res) {
|
|
94
|
+
const url = new URL(req.url, 'http://localhost')
|
|
95
|
+
const pathname = url.pathname.startsWith(ROUTE_PREFIX + '/')
|
|
96
|
+
? url.pathname.slice(ROUTE_PREFIX.length)
|
|
97
|
+
: url.pathname === ROUTE_PREFIX ? '/' : null
|
|
98
|
+
if (pathname === null) {
|
|
99
|
+
sendJson(res, 404, { error: MESSAGES.notFound })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
103
|
+
try {
|
|
104
|
+
const matched = matchRoute(req.method, segments)
|
|
105
|
+
if (!matched) {
|
|
106
|
+
sendJson(res, 404, { error: MESSAGES.notFound })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const body = req.method === 'POST' ? await readJsonBody(req) : undefined
|
|
110
|
+
sendJson(res, 200, await matched.route.handler({ req, res, url, params: matched.params, body }))
|
|
111
|
+
} catch (error) {
|
|
112
|
+
respondError(res, error, logSystem)
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|