mn-ws 0.1.0 → 0.2.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.
Files changed (178) hide show
  1. package/assets/groups.json +10 -0
  2. package/assets/mn-ws/SKILL.md +55 -0
  3. package/assets/mn-ws/hooks.json +14 -0
  4. package/assets/mn-ws/install/inject-root.mjs +26 -0
  5. package/bin/ws.mjs +13 -0
  6. package/dist/core/auth.d.ts +194 -0
  7. package/dist/core/auth.d.ts.map +1 -0
  8. package/dist/core/auth.js +331 -0
  9. package/dist/core/auth.js.map +1 -0
  10. package/dist/core/brand.d.ts +11 -0
  11. package/dist/core/brand.d.ts.map +1 -0
  12. package/dist/core/brand.js +7 -0
  13. package/dist/core/brand.js.map +1 -0
  14. package/dist/core/bridge.d.ts +106 -0
  15. package/dist/core/bridge.d.ts.map +1 -0
  16. package/dist/core/bridge.js +308 -0
  17. package/dist/core/bridge.js.map +1 -0
  18. package/dist/core/config.d.ts +174 -0
  19. package/dist/core/config.d.ts.map +1 -0
  20. package/dist/core/config.js +417 -0
  21. package/dist/core/config.js.map +1 -0
  22. package/dist/core/controller.d.ts +64 -0
  23. package/dist/core/controller.d.ts.map +1 -0
  24. package/dist/core/controller.js +100 -0
  25. package/dist/core/controller.js.map +1 -0
  26. package/dist/core/errors/index.d.ts +30 -0
  27. package/dist/core/errors/index.d.ts.map +1 -0
  28. package/dist/core/errors/index.js +88 -0
  29. package/dist/core/errors/index.js.map +1 -0
  30. package/dist/core/open.d.ts +3 -0
  31. package/dist/core/open.d.ts.map +1 -0
  32. package/dist/core/open.js +27 -0
  33. package/dist/core/open.js.map +1 -0
  34. package/dist/core/paths.d.ts +15 -0
  35. package/dist/core/paths.d.ts.map +1 -0
  36. package/dist/core/paths.js +50 -0
  37. package/dist/core/paths.js.map +1 -0
  38. package/dist/core/serve-port.d.ts +73 -0
  39. package/dist/core/serve-port.d.ts.map +1 -0
  40. package/dist/core/serve-port.js +134 -0
  41. package/dist/core/serve-port.js.map +1 -0
  42. package/dist/core/session.d.ts +78 -0
  43. package/dist/core/session.d.ts.map +1 -0
  44. package/dist/core/session.js +124 -0
  45. package/dist/core/session.js.map +1 -0
  46. package/dist/core/skills/bus.d.ts +29 -0
  47. package/dist/core/skills/bus.d.ts.map +1 -0
  48. package/dist/core/skills/bus.js +114 -0
  49. package/dist/core/skills/bus.js.map +1 -0
  50. package/dist/core/skills/get.d.ts +17 -0
  51. package/dist/core/skills/get.d.ts.map +1 -0
  52. package/dist/core/skills/get.js +87 -0
  53. package/dist/core/skills/get.js.map +1 -0
  54. package/dist/core/skills/handlers.d.ts +22 -0
  55. package/dist/core/skills/handlers.d.ts.map +1 -0
  56. package/dist/core/skills/handlers.js +86 -0
  57. package/dist/core/skills/handlers.js.map +1 -0
  58. package/dist/core/skills/index.d.ts +47 -0
  59. package/dist/core/skills/index.d.ts.map +1 -0
  60. package/dist/core/skills/index.js +268 -0
  61. package/dist/core/skills/index.js.map +1 -0
  62. package/dist/core/skills/install.d.ts +18 -0
  63. package/dist/core/skills/install.d.ts.map +1 -0
  64. package/dist/core/skills/install.js +123 -0
  65. package/dist/core/skills/install.js.map +1 -0
  66. package/dist/core/skills/text-util.d.ts +2 -0
  67. package/dist/core/skills/text-util.d.ts.map +1 -0
  68. package/dist/core/skills/text-util.js +14 -0
  69. package/dist/core/skills/text-util.js.map +1 -0
  70. package/dist/core/store.d.ts +29 -0
  71. package/dist/core/store.d.ts.map +1 -0
  72. package/dist/core/store.js +100 -0
  73. package/dist/core/store.js.map +1 -0
  74. package/dist/core/views.d.ts +11 -0
  75. package/dist/core/views.d.ts.map +1 -0
  76. package/dist/core/views.js +20 -0
  77. package/dist/core/views.js.map +1 -0
  78. package/dist/index.d.ts +6 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +8 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/modules/auth/index.d.ts +73 -0
  83. package/dist/modules/auth/index.d.ts.map +1 -0
  84. package/dist/modules/auth/index.js +64 -0
  85. package/dist/modules/auth/index.js.map +1 -0
  86. package/dist/modules/auth/service.d.ts +98 -0
  87. package/dist/modules/auth/service.d.ts.map +1 -0
  88. package/dist/modules/auth/service.js +200 -0
  89. package/dist/modules/auth/service.js.map +1 -0
  90. package/dist/modules/bridge/index.d.ts +73 -0
  91. package/dist/modules/bridge/index.d.ts.map +1 -0
  92. package/dist/modules/bridge/index.js +89 -0
  93. package/dist/modules/bridge/index.js.map +1 -0
  94. package/dist/modules/bridge/service.d.ts +75 -0
  95. package/dist/modules/bridge/service.d.ts.map +1 -0
  96. package/dist/modules/bridge/service.js +134 -0
  97. package/dist/modules/bridge/service.js.map +1 -0
  98. package/dist/modules/home/index.d.ts +95 -0
  99. package/dist/modules/home/index.d.ts.map +1 -0
  100. package/dist/modules/home/index.js +123 -0
  101. package/dist/modules/home/index.js.map +1 -0
  102. package/dist/modules/home/service.d.ts +48 -0
  103. package/dist/modules/home/service.d.ts.map +1 -0
  104. package/dist/modules/home/service.js +195 -0
  105. package/dist/modules/home/service.js.map +1 -0
  106. package/dist/modules/skill/index.d.ts +78 -0
  107. package/dist/modules/skill/index.d.ts.map +1 -0
  108. package/dist/modules/skill/index.js +132 -0
  109. package/dist/modules/skill/index.js.map +1 -0
  110. package/dist/runtime/api.d.ts +24 -0
  111. package/dist/runtime/api.d.ts.map +1 -0
  112. package/dist/runtime/api.js +140 -0
  113. package/dist/runtime/api.js.map +1 -0
  114. package/dist/runtime/cli.d.ts +23 -0
  115. package/dist/runtime/cli.d.ts.map +1 -0
  116. package/dist/runtime/cli.js +410 -0
  117. package/dist/runtime/cli.js.map +1 -0
  118. package/dist/runtime/help.d.ts +29 -0
  119. package/dist/runtime/help.d.ts.map +1 -0
  120. package/dist/runtime/help.js +89 -0
  121. package/dist/runtime/help.js.map +1 -0
  122. package/dist/runtime/registry.d.ts +318 -0
  123. package/dist/runtime/registry.d.ts.map +1 -0
  124. package/dist/runtime/registry.js +75 -0
  125. package/dist/runtime/registry.js.map +1 -0
  126. package/dist/runtime/server.d.ts +10 -0
  127. package/dist/runtime/server.d.ts.map +1 -0
  128. package/dist/runtime/server.js +127 -0
  129. package/dist/runtime/server.js.map +1 -0
  130. package/dist/runtime/spec.d.ts +32 -0
  131. package/dist/runtime/spec.d.ts.map +1 -0
  132. package/dist/runtime/spec.js +180 -0
  133. package/dist/runtime/spec.js.map +1 -0
  134. package/dist/runtime/types/action.d.ts +101 -0
  135. package/dist/runtime/types/action.d.ts.map +1 -0
  136. package/dist/runtime/types/action.js +19 -0
  137. package/dist/runtime/types/action.js.map +1 -0
  138. package/dist/runtime/types/client.d.ts +39 -0
  139. package/dist/runtime/types/client.d.ts.map +1 -0
  140. package/dist/runtime/types/client.js +2 -0
  141. package/dist/runtime/types/client.js.map +1 -0
  142. package/dist/runtime/types/http.d.ts +22 -0
  143. package/dist/runtime/types/http.d.ts.map +1 -0
  144. package/dist/runtime/types/http.js +6 -0
  145. package/dist/runtime/types/http.js.map +1 -0
  146. package/dist/runtime/types/module.d.ts +72 -0
  147. package/dist/runtime/types/module.d.ts.map +1 -0
  148. package/dist/runtime/types/module.js +15 -0
  149. package/dist/runtime/types/module.js.map +1 -0
  150. package/dist/web/public/assets/CliHints-BOxU-Duc.js +1 -0
  151. package/dist/web/public/assets/index-BTRKdMIW.js +50 -0
  152. package/dist/web/public/assets/index-BjGe8ggI.css +1 -0
  153. package/dist/web/public/assets/view-CE4tnCuy.js +1 -0
  154. package/dist/web/public/assets/view-CEQ2uPxE.js +1 -0
  155. package/dist/web/public/favicon-16.png +0 -0
  156. package/dist/web/public/favicon-32.png +0 -0
  157. package/dist/web/public/favicon-48.png +0 -0
  158. package/dist/web/public/favicon.ico +0 -0
  159. package/dist/web/public/index.html +19 -0
  160. package/dist/web/public/logo-rounded.png +0 -0
  161. package/dist/web/public/logo-rounded.svg +6 -0
  162. package/dist/web/public/logo.png +0 -0
  163. package/dist/web/public/logo.svg +6 -0
  164. package/package.json +52 -31
  165. package/README.md +0 -251
  166. package/bin/index.js +0 -714
  167. package/client/bridge-client.js +0 -160
  168. package/client/web.js +0 -234
  169. package/client/ws-client.js +0 -191
  170. package/lib/apps.js +0 -237
  171. package/lib/auth.js +0 -319
  172. package/lib/config.js +0 -534
  173. package/lib/index.js +0 -15
  174. package/lib/logger.js +0 -63
  175. package/lib/protocol.js +0 -208
  176. package/lib/store.js +0 -66
  177. package/server/auth.js +0 -274
  178. package/server/server.js +0 -752
@@ -1,160 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * 桥接客户端的**请求还原器**:把 server 半发来的帧还原成一次真正的 HTTP 请求,
5
- * 打给本机的 nx 服务,再把响应封回帧。
6
- *
7
- * ## 为什么要「还原」而不是「转发」
8
- *
9
- * server 半拿到的是一段 HTTP 报文的**内容**(method / path / query / headers /
10
- * body),不是一条活的 socket。所以这一侧必须**造一个请求**出来 ——
11
- * 于是「哪些头能带过去」就全靠这里的规则决定,而规则错了症状都很隐蔽:
12
- *
13
- * · 带上 Origin → nx-kv 的跨站防护 403(直连好好的,走桥就 403)
14
- * · 带上 Connection / Transfer-Encoding → 新连接自己会重算,带过去就是错的
15
- * · 带上 content-length → body 经 base64 往返后长度可能变了,对不上就挂死
16
- *
17
- * 所以:**hop-by-hop 头 + Origin 全部剥掉,content-length 自己算。**
18
- */
19
-
20
- const http = require('node:http');
21
- const https = require('node:https');
22
-
23
- const {
24
- FRAME, encodeFrame, normalizeHeaders, stripRequestHeaders, flattenHeaders,
25
- MAX_BODY_BYTES,
26
- } = require('../lib/protocol');
27
-
28
- class BridgeClient {
29
- /**
30
- * @param {object} opts
31
- * @param {object} opts.apps { app -> base }(base 形如 http://127.0.0.1:7877)
32
- * @param {object} [opts.logger]
33
- * @param {(frame: object) => void} opts.send 把帧发回 server 半
34
- */
35
- constructor({ apps = {}, logger = null, send }) {
36
- this.apps = apps;
37
- this.logger = logger;
38
- this.send = send;
39
- /** requestId -> {req, res} 本地在跑的请求 */
40
- this.inflight = new Map();
41
- }
42
-
43
- /** 处理一个 request 帧。 */
44
- async handleRequest(frame) {
45
- const id = String(frame.id || '');
46
- const app = String(frame.app || '');
47
- const base = this.apps[app];
48
- if (!base) {
49
- this._respondError(id, 'unknown_app', `未注册应用 ${app}`);
50
- return;
51
- }
52
-
53
- let url;
54
- try {
55
- url = new URL(String(base));
56
- } catch {
57
- this._respondError(id, 'bad_base', `应用 ${app} 的 base 不是合法 URL:${base}`);
58
- return;
59
- }
60
-
61
- const path = String(frame.path || '/');
62
- const query = frame.query && typeof frame.query === 'object'
63
- ? new URLSearchParams(frame.query).toString()
64
- : '';
65
- const targetPath = path + (query ? `?${query}` : '');
66
-
67
- let body;
68
- try {
69
- body = Buffer.from(String(frame.body || ''), 'base64');
70
- } catch {
71
- this._respondError(id, 'bad_body', 'body 不是合法 base64');
72
- return;
73
- }
74
- if (body.length > MAX_BODY_BYTES) {
75
- this._respondError(id, 'body_too_large', `body ${body.length} 超过上限 ${MAX_BODY_BYTES}`);
76
- return;
77
- }
78
-
79
- // 剥掉不能转发的头(Origin 在其中)
80
- const headers = flattenHeaders(stripRequestHeaders(normalizeHeaders(frame.headers)));
81
- // content-length 按实际 body 重算:body 走过一趟 base64,长度以本地为准
82
- delete headers['content-length'];
83
- if (body.length > 0) headers['content-length'] = String(body.length);
84
-
85
- const mod = url.protocol === 'https:' ? https : http;
86
- const req = mod.request({
87
- protocol: url.protocol,
88
- hostname: url.hostname,
89
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
90
- method: frame.method || 'GET',
91
- path: targetPath,
92
- headers,
93
- }, (res) => {
94
- const chunks = [];
95
- let size = 0;
96
- res.on('data', (c) => {
97
- size += c.length;
98
- if (size > MAX_BODY_BYTES) {
99
- req.destroy(new Error('响应体超过上限'));
100
- return;
101
- }
102
- chunks.push(c);
103
- });
104
- res.on('end', () => {
105
- this.inflight.delete(id);
106
- const buf = Buffer.concat(chunks);
107
- this.send({
108
- type: FRAME.RESPONSE,
109
- id,
110
- status: res.statusCode || 0,
111
- headers: normalizeHeaders(res.headers),
112
- body: buf.toString('base64'),
113
- ts: Date.now(),
114
- });
115
- if (this.logger) {
116
- this.logger.debug('bridged', { app, path: targetPath, status: res.statusCode, bytes: buf.length });
117
- }
118
- });
119
- });
120
-
121
- this.inflight.set(id, req);
122
-
123
- req.on('timeout', () => { req.destroy(new Error('本机请求超时')); });
124
- req.on('error', (e) => {
125
- this.inflight.delete(id);
126
- this._respondError(id, e.code || 'request_failed', e.message);
127
- });
128
-
129
- if (body.length > 0) req.write(body);
130
- req.end();
131
- }
132
-
133
- /** 中断一个在跑的请求(server 侧 HTTP 客户端断开了)。 */
134
- abort(id) {
135
- const req = this.inflight.get(String(id));
136
- if (!req) return false;
137
- this.inflight.delete(String(id));
138
- try { req.destroy(); } catch { /* ignore */ }
139
- return true;
140
- }
141
-
142
- /** 断开时把所有在跑的请求收掉。 */
143
- abortAll() {
144
- for (const [, req] of this.inflight) {
145
- try { req.destroy(); } catch { /* ignore */ }
146
- }
147
- this.inflight.clear();
148
- }
149
-
150
- _respondError(id, code, message) {
151
- this.send({
152
- type: FRAME.ERROR,
153
- id,
154
- code,
155
- error: String(message || code),
156
- });
157
- }
158
- }
159
-
160
- module.exports = { BridgeClient };
package/client/web.js DELETED
@@ -1,234 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * 本地 Web 面板:`npx mn-ws client web`。
5
- *
6
- * 只绑 127.0.0.1 —— 这是本机诊断面,不是对外服务。面板里能看的:
7
- * · 桥接连接状态(连没连、注册了哪些 app、被谁顶替过)
8
- * · **哪些可以桥接**:逐个 app 探测本地端口存活,并标出是否在白名单里
9
- * · 最近请求日志(app / 方法 / 路径 / 状态码 / 耗时)
10
- * · 一个最小 API 试验台:选 app + 填路径 → 直接打过去看响应
11
- */
12
-
13
- const http = require('node:http');
14
- const { URL } = require('url');
15
-
16
- const { scanApps } = require('../lib/apps');
17
-
18
- /**
19
- * 面板默认端口 —— 与 server 半的默认端口一致(7777)。
20
- *
21
- * 单独提出来导出,是为了能在单测里钉死这个值:
22
- * 端口散在多个文件里时,「改了一处忘了另一处」是这类项目最常见的低级坑
23
- * (见 design.md 坑位清单 K21)。
24
- */
25
- const DEFAULT_WEB_PORT = 7777;
26
-
27
- /**
28
- * 启动本地面板。
29
- *
30
- * @param {object} opts
31
- * @param {number} [opts.port] 面板端口(默认 7777)
32
- * @param {string} [opts.host] 绑定地址(默认 127.0.0.1)
33
- * @param {object} opts.apps { app -> base } 白名单(可桥接的)
34
- * @param {object} [opts.appOverrides]
35
- * @param {object} [opts.client] WsBridgeClient(可空)
36
- * @param {object} [opts.logger]
37
- * @returns {Promise<{port:number, close: () => Promise<void>}>}
38
- */
39
- async function startWebPanel(opts = {}) {
40
- // 用 ?? 而不是 ||:port=0 是「让系统随机给一个端口」的正经用法,
41
- // 被 || 吃掉会变成 7777 —— 测试里想用随机端口就会莫名其妙去抢 7777。
42
- const port = opts.port === undefined || opts.port === null ? DEFAULT_WEB_PORT : Number(opts.port);
43
- const host = opts.host || '127.0.0.1';
44
- const apps = opts.apps || {};
45
- const overrides = opts.appOverrides || {};
46
- const client = opts.client || null;
47
- const logger = opts.logger || null;
48
-
49
- const server = http.createServer(async (req, res) => {
50
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
51
- const send = (code, obj, type = 'application/json; charset=utf-8') => {
52
- const payload = type.startsWith('application/json') ? JSON.stringify(obj) : obj;
53
- res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
54
- res.end(payload);
55
- };
56
-
57
- try {
58
- if (url.pathname === '/') {
59
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
60
- res.end(renderHtml());
61
- return;
62
- }
63
-
64
- // 可桥接清单:白名单 + 端口存活探测
65
- if (url.pathname === '/api/apps') {
66
- const names = Object.keys(apps);
67
- const list = await scanApps({ host: '127.0.0.1', apps: names, overrides });
68
- send(200, { ok: true, apps: list, configured: names });
69
- return;
70
- }
71
-
72
- // 桥接状态
73
- if (url.pathname === '/api/status') {
74
- send(200, {
75
- ok: true,
76
- state: client ? client.state : 'idle',
77
- bridgeId: client ? client.bridgeId : '',
78
- url: client ? client.url : '',
79
- attempts: client ? client.attempts : 0,
80
- configured: Object.keys(apps),
81
- });
82
- return;
83
- }
84
-
85
- // 最小 API 试验台:直打本地端口,不经过 server 半
86
- if (url.pathname === '/api/probe' && req.method === 'POST') {
87
- const raw = await readBody(req);
88
- const body = raw.length ? JSON.parse(raw) : {};
89
- const app = String(body.app || '');
90
- const base = apps[app];
91
- if (!base) { send(400, { ok: false, error: 'app_not_configured' }); return; }
92
- const target = new URL(String(body.path || '/'), base);
93
- const out = await probeHttp(target, body.method || 'GET', body.raw || '');
94
- send(200, { ok: true, ...out });
95
- return;
96
- }
97
-
98
- send(404, { ok: false, error: 'not_found' });
99
- } catch (e) {
100
- send(500, { ok: false, error: String((e && e.message) || e) });
101
- }
102
- });
103
-
104
- await new Promise((resolve_, reject) => {
105
- server.once('error', reject);
106
- server.listen(port, host, () => resolve_());
107
- });
108
-
109
- if (logger) logger.info('本地面板已启动', { url: `http://${host}:${port}/` });
110
-
111
- return {
112
- port,
113
- close: () => new Promise((r) => server.close(() => r())),
114
- };
115
- }
116
-
117
- function readBody(req) {
118
- return new Promise((resolve, reject) => {
119
- const chunks = [];
120
- req.on('data', (c) => chunks.push(c));
121
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
122
- req.on('error', reject);
123
- });
124
- }
125
-
126
- function probeHttp(target, method, raw) {
127
- return new Promise((resolve) => {
128
- const mod = target.protocol === 'https:' ? require('node:https') : require('node:http');
129
- const req = mod.request({
130
- protocol: target.protocol,
131
- hostname: target.hostname,
132
- port: target.port || (target.protocol === 'https:' ? 443 : 80),
133
- method,
134
- path: target.pathname + target.search,
135
- headers: raw ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(raw) } : {},
136
- timeout: 8000,
137
- }, (res) => {
138
- let body = '';
139
- res.on('data', (c) => { body += c.toString('utf8'); });
140
- res.on('end', () => resolve({
141
- status: res.statusCode,
142
- headers: res.headers,
143
- body: body.slice(0, 8000),
144
- truncated: body.length > 8000,
145
- }));
146
- });
147
- req.on('timeout', () => { req.destroy(new Error('超时')); });
148
- req.on('error', (e) => resolve({ status: 0, error: e.message }));
149
- if (raw) req.write(raw);
150
- req.end();
151
- });
152
- }
153
-
154
- function renderHtml() {
155
- return `<!doctype html>
156
- <html lang="zh-CN"><head><meta charset="utf-8">
157
- <meta name="viewport" content="width=device-width,initial-scale=1">
158
- <title>mn-ws 桥接面板</title>
159
- <style>
160
- :root{color-scheme:light dark}
161
- body{font:14px/1.6 -apple-system,"Segoe UI",system-ui,sans-serif;margin:0;padding:20px;background:#f6f6f7;color:#1c1c1e}
162
- @media(prefers-color-scheme:dark){body{background:#161618;color:#f2f2f7}}
163
- h1{font-size:18px;margin:0 0 4px} .sub{opacity:.6;font-size:12px;margin-bottom:16px}
164
- .card{background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
165
- @media(prefers-color-scheme:dark){.card{background:#1f1f22;box-shadow:none}}
166
- table{border-collapse:collapse;width:100%;font-size:13px}
167
- th,td{text-align:left;padding:6px 8px;border-bottom:1px solid rgba(128,128,128,.25)}
168
- th{font-weight:600;opacity:.7;font-size:12px}
169
- .ok{color:#1a9e46;font-weight:600} .bad{color:#d13438;font-weight:600}
170
- input,select,textarea,button{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit}
171
- textarea{width:100%;min-height:60px;font-family:ui-monospace,monospace;font-size:12px}
172
- button{background:#0a84ff;color:#fff;border:0;cursor:pointer;font-weight:600}
173
- pre{background:rgba(128,128,128,.12);padding:10px;border-radius:8px;overflow:auto;font-size:12px;max-height:300px}
174
- .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px}
175
- code{font-family:ui-monospace,monospace}
176
- </style></head><body>
177
- <h1>mn-ws 桥接面板</h1>
178
- <div class="sub">本机诊断面 · 只监听 127.0.0.1 · 不会把任何服务暴露到公网</div>
179
-
180
- <div class="card">
181
- <div class="row"><b>桥接状态</b><span id="st"></span></div>
182
- <div class="sub" id="stDetail"></div>
183
- </div>
184
-
185
- <div class="card">
186
- <b>哪些可以桥接</b>
187
- <div class="sub">白名单里的 app 逐个探测本地端口;端口在监听 = 可桥。</div>
188
- <table id="apps"><thead><tr><th>应用</th><th>本地端口</th><th>存活</th></tr></thead><tbody></tbody></table>
189
- </div>
190
-
191
- <div class="card">
192
- <b>API 试验台</b>
193
- <div class="sub">直打本地端口,不经过公网 server 半。</div>
194
- <div class="row">
195
- <select id="sel"></select>
196
- <input id="path" value="/api/ping" size="28">
197
- <button id="go">发送</button>
198
- </div>
199
- <div class="row"><input id="body" placeholder='请求体(JSON,可空)' style="flex:1"></div>
200
- <pre id="out">—</pre>
201
- </div>
202
-
203
- <script>
204
- const $ = (id) => document.getElementById(id);
205
- async function tick(){
206
- try{
207
- const st = await (await fetch('/api/status')).json();
208
- $('st').textContent = st.state;
209
- $('st').className = (st.state==='registered'||st.state==='connected')?'ok':'bad';
210
- $('stDetail').textContent = 'server: '+(st.url||'—')+' · 白名单: '+(st.configured||[]).join(', ')+' · 重连次数: '+st.attempts;
211
- const a = await (await fetch('/api/apps')).json();
212
- const tb = $('apps').querySelector('tbody'); tb.innerHTML='';
213
- (a.apps||[]).forEach(x=>{
214
- const tr=document.createElement('tr');
215
- tr.innerHTML='<td><code>'+x.app+'</code> '+x.title+'</td><td>'+(x.port||'—')+'</td><td class="'+(x.alive?'ok':'bad')+'">'+(x.alive?'在监听':'未运行')+'</td>';
216
- tb.appendChild(tr);
217
- });
218
- const sel=$('sel'), cur=sel.value; sel.innerHTML='';
219
- (a.apps||[]).forEach(x=>{ const o=document.createElement('option'); o.value=x.app; o.textContent=x.app; sel.appendChild(o); });
220
- if(cur) sel.value=cur;
221
- }catch(e){ $('st').textContent='面板取数失败: '+e.message; }
222
- }
223
- $('go').onclick = async ()=>{
224
- const body=$('body').value;
225
- $('out').textContent='请求中…';
226
- const r = await (await fetch('/api/probe',{method:'POST',headers:{'content-type':'application/json'},
227
- body:JSON.stringify({app:$('sel').value,path:$('path').value,raw:body})})).json();
228
- $('out').textContent = r.ok ? ('HTTP '+r.status+'\n\n'+JSON.stringify(r.headers||{},null,2)+'\n\n'+r.body) : (r.error||'失败');
229
- };
230
- tick(); setInterval(tick, 3000);
231
- </script></body></html>`;
232
- }
233
-
234
- module.exports = { startWebPanel, DEFAULT_WEB_PORT };
@@ -1,191 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * 桥接客户端的**连接器**:连 server 半、注册、重连、重注册。
5
- *
6
- * 与 mn-rt 的 client/lib/ws-client.js 同款骨架:指数退避重连 + 状态回调。
7
- * 差别在**注册内容**:mn-rt 注册的是"我是一台 PC",
8
- * mn-ws 注册的是"我是一台桥,能桥这些 app"。
9
- */
10
-
11
- const WebSocket = require('ws');
12
-
13
- const { FRAME, encodeFrame, decodeFrame } = require('../lib/protocol');
14
- const { describeApp } = require('../lib/apps');
15
- const { BridgeClient } = require('./bridge-client');
16
-
17
- const RECONNECT_BASE_MS = 1000;
18
- const RECONNECT_MAX_MS = 30_000;
19
-
20
- class WsBridgeClient {
21
- /**
22
- * @param {object} opts
23
- * @param {string} opts.url server 半的 ws:// 地址
24
- * @param {string} [opts.token] 注册令牌(设备令牌或静态门禁)
25
- * @param {string} [opts.bridgeId]
26
- * @param {object} opts.apps { app -> base }
27
- * @param {object} [opts.logger]
28
- * @param {(s: object) => void} [opts.onStatus]
29
- */
30
- constructor({ url, token = '', bridgeId = '', apps = {}, logger = null, onStatus = null }) {
31
- this.url = url;
32
- this.token = token;
33
- this.bridgeId = bridgeId;
34
- this.apps = apps;
35
- this.logger = logger;
36
- this.onStatus = onStatus || (() => {});
37
-
38
- this.ws = null;
39
- this.stopped = false;
40
- this.attempts = 0;
41
- this.timer = null;
42
- this.state = 'idle';
43
-
44
- this.bridge = new BridgeClient({
45
- apps,
46
- logger,
47
- send: (frame) => this._send(frame),
48
- });
49
- }
50
-
51
- _send(frame) {
52
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
53
- try {
54
- this.ws.send(encodeFrame(frame));
55
- return true;
56
- } catch (e) {
57
- if (this.logger) this.logger.warn('发送帧失败', { error: e.message });
58
- return false;
59
- }
60
- }
61
-
62
- _setState(state, extra) {
63
- this.state = state;
64
- this.onStatus({ state, ...(extra || {}) });
65
- }
66
-
67
- /** 连上并注册。 */
68
- start() {
69
- this.stopped = false;
70
- this._connect();
71
- }
72
-
73
- /** 断开并停止重连。 */
74
- stop() {
75
- this.stopped = true;
76
- if (this.timer) { clearTimeout(this.timer); this.timer = null; }
77
- this.bridge.abortAll();
78
- if (this.ws) {
79
- try { this.ws.close(1000, 'client stopped'); } catch { /* ignore */ }
80
- this.ws = null;
81
- }
82
- this._setState('stopped');
83
- }
84
-
85
- _connect() {
86
- if (this.stopped) return;
87
- this._setState('connecting');
88
-
89
- let ws;
90
- try {
91
- ws = new WebSocket(this.url, { handshakeTimeout: 15_000 });
92
- } catch (e) {
93
- this._scheduleReconnect(`连接失败:${e.message}`);
94
- return;
95
- }
96
- this.ws = ws;
97
-
98
- ws.on('open', () => {
99
- this.attempts = 0;
100
- this._setState('connected');
101
- // 注册:带上 app 表 + 每个 app 的元信息(标题、端口),
102
- // server 半据此汇总成**元信息目录**,远端客户端登录后先拉目录、
103
- // 看清楚「哪些能路由」再选。
104
- const apps = Object.entries(this.apps).map(([app, base]) => describeApp(app, base));
105
- this._send({
106
- type: FRAME.REGISTER,
107
- role: 'bridge',
108
- bridgeId: this.bridgeId,
109
- apps,
110
- token: this.token,
111
- });
112
- if (this.logger) {
113
- this.logger.info('已连上 server,注册桥接应用', { apps: apps.map((a) => a.app) });
114
- }
115
- });
116
-
117
- ws.on('message', (raw) => {
118
- const msg = decodeFrame(raw);
119
- if (!msg) {
120
- if (this.logger) this.logger.warn('收到非法帧');
121
- return;
122
- }
123
- this._onFrame(msg);
124
- });
125
-
126
- ws.on('close', (code, reason) => {
127
- this.ws = null;
128
- this.bridge.abortAll();
129
- if (this.stopped) { this._setState('stopped'); return; }
130
- this._scheduleReconnect(`连接断开(${code}${reason ? ` ${reason}` : ''})`);
131
- });
132
-
133
- ws.on('error', (e) => {
134
- if (this.logger) this.logger.warn('socket 错误', { error: e && e.message });
135
- // close 事件会随后到达,重连逻辑挂在 close 上,这里不重复处理
136
- });
137
- }
138
-
139
- _onFrame(msg) {
140
- switch (msg.type) {
141
- case FRAME.WELCOME:
142
- if (this.logger) this.logger.info('收到 welcome', { peerId: msg.peerId });
143
- break;
144
- case FRAME.REGISTERED:
145
- this._setState('registered', { apps: msg.apps || [], replaced: msg.replaced || [] });
146
- if (this.logger) {
147
- this.logger.info('注册成功', { apps: msg.apps, replaced: msg.replaced });
148
- }
149
- break;
150
- case FRAME.REQUEST:
151
- this.bridge.handleRequest(msg).catch((e) => {
152
- this.bridge._respondError(String(msg.id || ''), 'handle_failed', e.message);
153
- });
154
- break;
155
- case FRAME.ABORT:
156
- this.bridge.abort(String(msg.id || ''));
157
- break;
158
- case FRAME.PING:
159
- this._send({ type: FRAME.PONG });
160
- break;
161
- case FRAME.SHUTDOWN:
162
- if (this.logger) this.logger.warn('服务端要求下线', { reason: msg.reason });
163
- this._setState('shutdown');
164
- break;
165
- case FRAME.ERROR:
166
- if (this.logger) this.logger.warn('服务端报错', { error: msg.error, detail: msg.detail });
167
- this._setState('error', { error: msg.error });
168
- break;
169
- default:
170
- break;
171
- }
172
- }
173
-
174
- /** 指数退避重连:1s → 2s → 4s … 封顶 30s。 */
175
- _scheduleReconnect(reason) {
176
- if (this.stopped) return;
177
- if (this.timer) return;
178
- const delay = Math.min(RECONNECT_BASE_MS * (2 ** this.attempts), RECONNECT_MAX_MS);
179
- this.attempts += 1;
180
- this._setState('reconnecting', { reason, retryInMs: delay });
181
- if (this.logger) {
182
- this.logger.warn('准备重连', { reason, delayMs: delay, attempt: this.attempts });
183
- }
184
- this.timer = setTimeout(() => {
185
- this.timer = null;
186
- this._connect();
187
- }, delay);
188
- }
189
- }
190
-
191
- module.exports = { WsBridgeClient };