nx-kv 0.10.0 → 0.10.1
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/CHANGELOG.md +13 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/core/serve-port.js +139 -0
- package/src/modules/system/index.js +4 -1
- package/src/runtime/cli.js +33 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.1] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **端口被占用时不再报错退出:自动复用或顺延,并把浏览器指过去**。`serve` 绑定端口
|
|
12
|
+
遇到 `EADDRINUSE` 时,先探测该端口上的 `/api/bootstrap`:占用者**就是本项目的面板**
|
|
13
|
+
(`app.name` 相同)就直接复用它——不再起第二个进程——并打开浏览器指向它;占用者是
|
|
14
|
+
别的服务(或探测不出来)则顺延到**下一个空闲端口**起自己的面板,并打开浏览器指向新
|
|
15
|
+
面板,不会把浏览器指向别人的服务。
|
|
16
|
+
- **跨项目统一的关闭开关 `NX_SERVE_NO_OPEN`**:设成 `1` / `true` / `yes` / `on` 时
|
|
17
|
+
`serve` 完全不开浏览器(所有 nx-xx 项目共用这一个变量名);`--no-open` 仍然只关
|
|
18
|
+
当前这一次。
|
|
19
|
+
|
|
7
20
|
## [0.10.0] - 2026-10-20
|
|
8
21
|
|
|
9
22
|
### Added
|
package/README.md
CHANGED
|
@@ -28,6 +28,13 @@ pnpm test
|
|
|
28
28
|
|
|
29
29
|
冒烟测试**对着假后端跑**(`tests/fake-backend.mjs`),不会碰生产服务器上的真实数据。
|
|
30
30
|
|
|
31
|
+
`nx-kv serve` 默认会打开浏览器;**端口被占时不会报错退出**:
|
|
32
|
+
|
|
33
|
+
- 占用者**就是本项目的面板** → 直接复用它(不重复起进程),并打开它;
|
|
34
|
+
- 占用者是别的服务 → 顺延到**下一个空闲端口**起自己的面板,并打开它。
|
|
35
|
+
|
|
36
|
+
`--no-open` 只关这一次;`NX_SERVE_NO_OPEN=1`(`true` / `yes` / `on` 同义)则是**所有 nx-xx 项目共用**的全局开关,设为真值后 `serve` 完全不开浏览器。
|
|
37
|
+
|
|
31
38
|
## 登录
|
|
32
39
|
|
|
33
40
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// serve 的端口协商 + 浏览器打开策略。
|
|
2
|
+
//
|
|
3
|
+
// 零业务语义:所有 nx-xx 面板共用这一份,靠环境变量 NX_SERVE_NO_OPEN 统一关闭自动开浏览器。
|
|
4
|
+
// 放 core/ 是因为它只依赖 Node 内置模块与「本项目的 app 名」,不涉及任何功能域。
|
|
5
|
+
//
|
|
6
|
+
// 这份文件在所有 nx-xx 项目之间**逐字节相同**——改一处就要全量同步,
|
|
7
|
+
// 否则「口径统一」这件事本身会悄悄分叉。
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 所有 nx-xx 项目共享的关闭开关。
|
|
12
|
+
* 设成 1 / true / yes / on 即禁止 `serve` 自动打开浏览器(正常启动与端口复用都算)。
|
|
13
|
+
* 命令行 `--no-open` 只影响这一次;这个环境变量对整个 shell 生效。
|
|
14
|
+
*/
|
|
15
|
+
export const NO_OPEN_ENV = 'NX_SERVE_NO_OPEN';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 探测面板时给的应答窗口。
|
|
19
|
+
*
|
|
20
|
+
* 1500ms 而不是更短:面板的 bootstrap 不一定是纯本机计算——nx-kv 登录后要去后端
|
|
21
|
+
* 拉分组,链路一慢就超过 800ms。窗口太窄的后果不是「探测失败」这么轻,
|
|
22
|
+
* 而是**把自己的面板误判成别人的服务**,于是白起一个实例、占掉第二个端口——
|
|
23
|
+
* 恰好是这套机制要消灭的那种体验。
|
|
24
|
+
*/
|
|
25
|
+
export const PROBE_TIMEOUT_MS = 1500;
|
|
26
|
+
|
|
27
|
+
/** 真值判定:1 / true / yes / on(大小写不敏感)算开,其他一律算关。 */
|
|
28
|
+
export function isEnvTruthy(value) {
|
|
29
|
+
if (value === undefined || value === null) return false;
|
|
30
|
+
const s = String(value).trim().toLowerCase();
|
|
31
|
+
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 是否允许自动打开浏览器。
|
|
36
|
+
* 优先级:`--no-open` 命令行开关 > NX_SERVE_NO_OPEN 环境变量 > 默认允许。
|
|
37
|
+
*/
|
|
38
|
+
export function autoOpenEnabled(flagNoOpen) {
|
|
39
|
+
if (flagNoOpen) return false;
|
|
40
|
+
return !isEnvTruthy(process.env[NO_OPEN_ENV]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 探一次端口,返回 { data, timedOut }。
|
|
45
|
+
*
|
|
46
|
+
* 把「超时」和「不是面板」分开,是因为两者的后续动作不同:
|
|
47
|
+
* 超时说明端口上可能正是一个应答慢的面板,值得再等一次;
|
|
48
|
+
* 明确不是面板就没有再等的必要。
|
|
49
|
+
*/
|
|
50
|
+
async function probePanelOnce(port, host, timeoutMs, headers) {
|
|
51
|
+
try {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
54
|
+
let res;
|
|
55
|
+
try {
|
|
56
|
+
res = await fetch(`http://${host}:${port}/api/bootstrap`, {
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
headers: headers || undefined,
|
|
59
|
+
});
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) return { data: null, timedOut: false };
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
return { data: (body && body.data) || null, timedOut: false };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { data: null, timedOut: !!(err && err.name === 'AbortError') };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 探测端口上有没有面板在跑:GET /api/bootstrap,短超时。
|
|
73
|
+
*
|
|
74
|
+
* 所有 nx-xx 面板都暴露 /api/bootstrap,响应里带 app.name / app.version ——
|
|
75
|
+
* 这就是跨项目唯一的认领签名(别的服务不会恰好返回它)。
|
|
76
|
+
* 不是面板、无响应、超时一律返回 null。
|
|
77
|
+
*
|
|
78
|
+
* headers 形参给需要鉴权的项目用(nx-as 的管理面要 Bearer 密钥);
|
|
79
|
+
* 默认不传,匿名探测。
|
|
80
|
+
*/
|
|
81
|
+
export async function probePanel(port, host = '127.0.0.1', timeoutMs = PROBE_TIMEOUT_MS, headers = null) {
|
|
82
|
+
const { data } = await probePanelOnce(port, host, timeoutMs, headers);
|
|
83
|
+
return data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 从 start 起逐个试,返回第一个能监听的端口(最多试 50 个)。
|
|
88
|
+
* 用临时 net.Server 探测、立刻关闭;与 startServer 走同一套 EADDRINUSE 判定。
|
|
89
|
+
*/
|
|
90
|
+
export function findFreePort(start, host = '127.0.0.1') {
|
|
91
|
+
return new Promise((resolve_, reject) => {
|
|
92
|
+
const tryPort = (p) => {
|
|
93
|
+
if (p > start + 50) {
|
|
94
|
+
reject(new Error(`找不到空闲端口(从 ${start} 起连续 50 个被占)`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const probe = net.createServer();
|
|
98
|
+
probe.once('error', (err) => {
|
|
99
|
+
if (err && err.code === 'EADDRINUSE') tryPort(p + 1);
|
|
100
|
+
else reject(err);
|
|
101
|
+
});
|
|
102
|
+
probe.listen(p, host, () => probe.close(() => resolve_(p)));
|
|
103
|
+
};
|
|
104
|
+
tryPort(start);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 端口冲突的统一决策。
|
|
110
|
+
*
|
|
111
|
+
* reuse —— 端口上跑的就是本项目的面板:复用它,不再起第二个进程。
|
|
112
|
+
* 起第二个会让「切项目」变成「切端口」,而用户想看的其实是已经开着的那一个。
|
|
113
|
+
* shift —— 端口被别的东西占着:顺延到下一个空闲端口,再起自己的面板。
|
|
114
|
+
*
|
|
115
|
+
* 返回 { action, port, occupant, existing }:
|
|
116
|
+
* action 'reuse' | 'shift'
|
|
117
|
+
* port reuse 时是原端口;shift 时是下一个空闲端口
|
|
118
|
+
* occupant 占用者的 app 名(探测不到就是 null)
|
|
119
|
+
*/
|
|
120
|
+
export async function resolvePortConflict(port, appName, host = '127.0.0.1', headers = null) {
|
|
121
|
+
let probe = await probePanelOnce(port, host, PROBE_TIMEOUT_MS, headers);
|
|
122
|
+
if (probe.timedOut) {
|
|
123
|
+
// 慢应答的面板也认:再等一次。别把「自己的面板」误判成「别人的服务」,
|
|
124
|
+
// 白白再起一个实例——那正是这套机制要消灭的行为。
|
|
125
|
+
probe = await probePanelOnce(port, host, PROBE_TIMEOUT_MS, headers);
|
|
126
|
+
}
|
|
127
|
+
const existing = probe.data;
|
|
128
|
+
const occupant = existing && existing.app ? existing.app.name || null : null;
|
|
129
|
+
if (existing && occupant === appName) {
|
|
130
|
+
return { action: 'reuse', port, occupant, existing };
|
|
131
|
+
}
|
|
132
|
+
const next = await findFreePort(port + 1, host);
|
|
133
|
+
return { action: 'shift', port: next, occupant, existing };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 占用者的一句话描述,用于终端提示。探测不到就是「未知服务」。 */
|
|
137
|
+
export function describeOccupant(occupant) {
|
|
138
|
+
return occupant || '未知服务';
|
|
139
|
+
}
|
|
@@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs';
|
|
|
5
5
|
import { configPathFromEnv, loadConfig } from '../../core/config.js';
|
|
6
6
|
import { badInput, notFound } from '../../core/errors.js';
|
|
7
7
|
import { listGroups } from '../../core/kvapi.js';
|
|
8
|
-
import { DEFAULT_PORT } from '../../core/paths.js';
|
|
8
|
+
import { APP_NAME, DEFAULT_PORT } from '../../core/paths.js';
|
|
9
9
|
|
|
10
10
|
const VERSION = JSON.parse(
|
|
11
11
|
readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')
|
|
@@ -33,6 +33,9 @@ async function bootstrap() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
return {
|
|
36
|
+
// app 段是跨项目的认领签名:serve 端口被占时靠 app.name 判断
|
|
37
|
+
// 「上面跑的是不是本项目的面板」——相等则复用,不等则顺延端口。
|
|
38
|
+
app: { name: APP_NAME, version: VERSION },
|
|
36
39
|
version: VERSION,
|
|
37
40
|
configPath: configPathFromEnv(),
|
|
38
41
|
baseUrl: cfg.baseUrl,
|
package/src/runtime/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// renderXxx 四处重复登记。现在命令表、help 文本、参数校验全部由 action 声明派生,
|
|
5
5
|
// 新增命令只需在所属模块的 actions 里加一条。
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { DEFAULT_PORT } from '../core/paths.js';
|
|
7
|
+
import { APP_NAME, DEFAULT_PORT } from '../core/paths.js';
|
|
8
8
|
import { configPathFromEnv } from '../core/config.js';
|
|
9
9
|
import { toErrorPayload, exitCodeOf, badInput } from '../core/errors.js';
|
|
10
10
|
import { ACTIONS, MODULES } from './registry.js';
|
|
@@ -20,7 +20,7 @@ const BUILTINS = [
|
|
|
20
20
|
{
|
|
21
21
|
id: 'serve',
|
|
22
22
|
cli: ['serve'],
|
|
23
|
-
summary: '启动 Web
|
|
23
|
+
summary: '启动 Web 面板(默认 :7877;--port N 改端口;--no-open 不开浏览器;端口上已有本面板则直接打开它;NX_SERVE_NO_OPEN=1 全局不开浏览器)',
|
|
24
24
|
flags: {
|
|
25
25
|
port: { type: 'number', default: DEFAULT_PORT },
|
|
26
26
|
'no-open': { type: 'boolean' },
|
|
@@ -256,13 +256,42 @@ async function cmdServe(ctx) {
|
|
|
256
256
|
const { startServer } = await import('./server.js');
|
|
257
257
|
const { startPanelIdleGuard } = await import('../modules/localtask/panel-guard.js');
|
|
258
258
|
const { openBrowser } = await import('../core/open.js');
|
|
259
|
+
const { autoOpenEnabled, resolvePortConflict, describeOccupant } = await import('../core/serve-port.js');
|
|
260
|
+
const host = '127.0.0.1';
|
|
259
261
|
const port = ctx.port || DEFAULT_PORT;
|
|
260
262
|
const autoExit = !!ctx['auto-exit'];
|
|
263
|
+
// --no-open 与 NX_SERVE_NO_OPEN 都看这里:任一为真即完全不开浏览器。
|
|
264
|
+
const canOpen = autoOpenEnabled(ctx['no-open']);
|
|
261
265
|
|
|
262
266
|
// 活动观测钩子:每个进来的请求(静态或 /api)都回调一次,供空闲自释放判活。
|
|
263
267
|
// 不参与响应,异常必须吞掉。
|
|
264
268
|
const onRequest = () => {};
|
|
265
|
-
|
|
269
|
+
|
|
270
|
+
let server;
|
|
271
|
+
try {
|
|
272
|
+
server = await startServer({ port, host, onRequest });
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (!err || err.code !== 'EADDRINUSE') throw err;
|
|
275
|
+
|
|
276
|
+
// 端口被占:先问一句「上面跑的是不是我」。
|
|
277
|
+
const decision = await resolvePortConflict(port, APP_NAME, host);
|
|
278
|
+
|
|
279
|
+
if (decision.action === 'reuse') {
|
|
280
|
+
// 自己的面板已经在跑:不重复启动,直接把浏览器指过去。
|
|
281
|
+
const addr = `http://${host}:${port}`;
|
|
282
|
+
console.log(APP_NAME + ' 已在运行:' + addr);
|
|
283
|
+
console.log('直接打开浏览器(不重复启动);如需重启请先停掉旧实例');
|
|
284
|
+
if (canOpen) openBrowser(addr);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 别人占的:顺延到下一个空闲端口,再起自己的面板。
|
|
289
|
+
console.log(
|
|
290
|
+
`端口 ${port} 已被占用(占用者: ${describeOccupant(decision.occupant)}),改用 ${decision.port}`
|
|
291
|
+
);
|
|
292
|
+
server = await startServer({ port: decision.port, host, onRequest });
|
|
293
|
+
}
|
|
294
|
+
|
|
266
295
|
const addr = `http://127.0.0.1:${server.address().port}`;
|
|
267
296
|
|
|
268
297
|
console.log('nx-kv · nx-kv v' + VERSION);
|
|
@@ -272,7 +301,7 @@ async function cmdServe(ctx) {
|
|
|
272
301
|
if (autoExit) console.log('模式: 空闲自释放(面板关闭且无等待中的主题后自动退出)');
|
|
273
302
|
console.log('按 Ctrl+C 停止');
|
|
274
303
|
|
|
275
|
-
if (
|
|
304
|
+
if (canOpen) openBrowser(addr);
|
|
276
305
|
|
|
277
306
|
const shutdown = () => {
|
|
278
307
|
console.log('\n正在停止...');
|