nx-kv 0.8.2 → 0.8.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/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/modules/localtask/index.js +11 -0
- package/src/modules/localtask/service.js +135 -118
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.8.3] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **wait 统一到常驻主面板(固定端口 7877),彻底移除随机端口临时弹窗**:
|
|
10
|
+
- wait 阻塞时不再在 wait 进程内临时起 http 服务器/弹窗,而是复用 `nx-kv serve` 的
|
|
11
|
+
「本地任务」页:已在运行则直接打开并深链到当前主题
|
|
12
|
+
(`#/localtask?key=<主题>&cwd=<目录>`,前端按 key/cwd 自动选中);
|
|
13
|
+
- 主面板未运行时,wait 在后台 **detached 自动拉起** `nx-kv serve --no-open
|
|
14
|
+
--auto-exit`(独立常驻进程,健康检查通过后再开浏览器;拉起失败只降级为纯阻塞、
|
|
15
|
+
绝不报错中断);
|
|
16
|
+
- **主面板页面常驻、提交后不关闭、可连续排队**;wait 拿到任务或超时退出后 serve 依然存活,
|
|
17
|
+
回填统一由约每 0.5 秒的跨进程文件轮询取走(独立进程写入返回 `delivered:false`);
|
|
18
|
+
- `--no-open` 语义保持:不开浏览器、也不自动拉起 serve,仅阻塞等待(冒烟/CI 依赖)。
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- 删除随 wait 生命周期起停的随机端口临时弹窗服务器及其内嵌页面
|
|
23
|
+
(`src/modules/localtask/popup.js`),以及与之重复的弹窗专用 HTTP 路由;
|
|
24
|
+
实时任务统一在固定端口常驻主面板完成。
|
|
25
|
+
**行为变更**:不再有「wait 结束即关闭」的临时窗口,也不再输出随机端口地址——
|
|
26
|
+
请改用常驻面板 `http://127.0.0.1:7877/`(wait 会自动打开/拉起)。
|
|
27
|
+
|
|
5
28
|
## [0.8.2] - 2026-10-12
|
|
6
29
|
|
|
7
30
|
### Added
|
package/package.json
CHANGED
|
@@ -271,5 +271,16 @@ export default {
|
|
|
271
271
|
run: (ctx) => service.cancelWait({ cwd: ctx.cwd, key: ctx.key }),
|
|
272
272
|
render: renderCancel,
|
|
273
273
|
},
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
// 探测常驻面板上当前正打开的「实时任务」标签数(活动 SSE 连接数)。
|
|
277
|
+
// wait 开页前先经此端点:有活标签就复用、不再 openBrowser 新开页(避免 Edge 重复开标签);
|
|
278
|
+
// 没有活标签时才开页深链。探测失败 / 旧版无此端点时 service 保守返回 0,不阻塞 wait。
|
|
279
|
+
id: 'localtask.presence',
|
|
280
|
+
cli: ['localtask', 'presence'],
|
|
281
|
+
http: ['GET', '/api/localtasks/presence'],
|
|
282
|
+
summary: '探测主面板上当前打开的「实时任务」标签数(活动 SSE 连接数;用于 wait 不重复开页)',
|
|
283
|
+
run: () => service.panelPresence(),
|
|
284
|
+
},
|
|
274
285
|
],
|
|
275
286
|
};
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
import http from 'node:http';
|
|
25
25
|
import crypto from 'node:crypto';
|
|
26
26
|
import fsp from 'node:fs/promises';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
27
28
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
28
|
-
import {
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { APP_DIR, DEFAULT_PORT } from '../../core/paths.js';
|
|
29
31
|
import { badInput } from '../../core/errors.js';
|
|
30
32
|
import { openBrowser } from '../../core/open.js';
|
|
31
|
-
import { renderPopupPage } from './popup.js';
|
|
32
33
|
|
|
33
34
|
export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
|
|
34
35
|
const POLL_MS = 500; // 跨进程兜底轮询间隔
|
|
@@ -720,9 +721,16 @@ export async function waitForTask({
|
|
|
720
721
|
return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true };
|
|
721
722
|
}
|
|
722
723
|
|
|
723
|
-
// 2. 没有 pending →
|
|
724
|
-
|
|
725
|
-
|
|
724
|
+
// 2. 没有 pending → 复用常驻主面板(不再起随机端口临时弹窗):
|
|
725
|
+
// a) 确保主面板在跑(不存在就 detached 拉起 serve --no-open --auto-exit);
|
|
726
|
+
// b) 探测活动 SSE 连接:有活标签就不开新页(避免 Edge 重复开标签);
|
|
727
|
+
// 没活标签就 openBrowser 深链到当前主题。
|
|
728
|
+
// 失败只降级为纯阻塞——绝不抛错中断 wait。
|
|
729
|
+
// --no-open 模式(冒烟/CI)不发起任何面板相关动作:不开浏览器、不拉起 serve、不开 SSE。
|
|
730
|
+
// 这样可避免在测试里 spawn 出一堆孤儿进程占着 7877 端口。
|
|
731
|
+
const panelInfo = open ? await ensureMainPanel() : { running: false, started: false, url: '' };
|
|
732
|
+
const presence = panelInfo.running ? await probePresence() : 0;
|
|
733
|
+
const deepLink = deepLinkUrl(key, cwd);
|
|
726
734
|
|
|
727
735
|
// 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
|
|
728
736
|
let resolveOutcome;
|
|
@@ -755,7 +763,7 @@ export async function waitForTask({
|
|
|
755
763
|
.catch(() => {});
|
|
756
764
|
}, POLL_MS);
|
|
757
765
|
|
|
758
|
-
//
|
|
766
|
+
// 登记「等待中」(含截止时刻与现状),面板据此显示倒计时
|
|
759
767
|
await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
|
|
760
768
|
|
|
761
769
|
// 登记之后再查一次关闭标记:setWaiting 刚清掉上一轮残留的标记,因此这里若有值
|
|
@@ -767,13 +775,32 @@ export async function waitForTask({
|
|
|
767
775
|
// waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
|
|
768
776
|
if (typeof onReady === 'function') {
|
|
769
777
|
try {
|
|
770
|
-
await onReady({ addr, key, cwd, name, summary });
|
|
778
|
+
await onReady({ addr: deepLink, key, cwd, name, summary });
|
|
771
779
|
} catch {
|
|
772
780
|
/* 钩子异常不得影响 wait */
|
|
773
781
|
}
|
|
774
782
|
}
|
|
775
783
|
|
|
776
|
-
|
|
784
|
+
// 没活标签且主面板在跑 → 开浏览器深链(让用户能在面板里选中主题并响应等待事件)。
|
|
785
|
+
// 已有活标签 / --no-open / 主面板未启动 → 都不开(避免重复标签 + 冒烟/CI 兼容)。
|
|
786
|
+
const openPage = open && presence === 0 && panelInfo.running;
|
|
787
|
+
if (openPage) openBrowser(deepLink);
|
|
788
|
+
|
|
789
|
+
// 周期复查:等待期间每 5s 查一次活动标签;万一开始时判错(页面被关),20s 冷却内重开。
|
|
790
|
+
// 不到点不开新页(节流,避免「标签被关 → 立刻开 → 立刻关」抖动)。
|
|
791
|
+
const REVIEW_MS = 5_000;
|
|
792
|
+
const REOPEN_COOLDOWN_MS = 20_000;
|
|
793
|
+
let lastReopenAt = 0;
|
|
794
|
+
const review = setInterval(async () => {
|
|
795
|
+
if (!panelInfo.running || !open) return;
|
|
796
|
+
const n = await probePresence().catch(() => 0);
|
|
797
|
+
if (n > 0) return; // 有活标签,不动
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
if (now - lastReopenAt < REOPEN_COOLDOWN_MS) return; // 冷却中
|
|
800
|
+
lastReopenAt = now;
|
|
801
|
+
openBrowser(deepLink);
|
|
802
|
+
}, REVIEW_MS);
|
|
803
|
+
review.unref?.();
|
|
777
804
|
|
|
778
805
|
let result;
|
|
779
806
|
try {
|
|
@@ -787,123 +814,14 @@ export async function waitForTask({
|
|
|
787
814
|
} finally {
|
|
788
815
|
clearInterval(poll);
|
|
789
816
|
clearInterval(cancelPoll);
|
|
817
|
+
clearInterval(review);
|
|
790
818
|
clearTimeout(timer);
|
|
791
819
|
waiters.delete(key);
|
|
792
820
|
await clearWaiting(key);
|
|
793
|
-
await stopServer(server);
|
|
794
821
|
}
|
|
795
822
|
return result;
|
|
796
823
|
}
|
|
797
824
|
|
|
798
|
-
// ─── 内嵌弹窗服务器 ─────────────────────────────────────────────────
|
|
799
|
-
|
|
800
|
-
function sendJson(res, status, obj) {
|
|
801
|
-
res.writeHead(status, {
|
|
802
|
-
'content-type': 'application/json; charset=utf-8',
|
|
803
|
-
'cache-control': 'no-store',
|
|
804
|
-
});
|
|
805
|
-
res.end(JSON.stringify(obj));
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
function sendHtml(res, html) {
|
|
809
|
-
res.writeHead(200, {
|
|
810
|
-
'content-type': 'text/html; charset=utf-8',
|
|
811
|
-
'cache-control': 'no-store',
|
|
812
|
-
});
|
|
813
|
-
res.end(html);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
async function readBody(req) {
|
|
817
|
-
const chunks = [];
|
|
818
|
-
let size = 0;
|
|
819
|
-
for await (const c of req) {
|
|
820
|
-
size += c.length;
|
|
821
|
-
if (size > 1024 * 1024) throw new Error('请求体过大');
|
|
822
|
-
chunks.push(c);
|
|
823
|
-
}
|
|
824
|
-
if (!chunks.length) return {};
|
|
825
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// 与主 api.js 同一套跨站防护:弹窗服务器虽只绑 127.0.0.1,浏览器里任意页面都能
|
|
829
|
-
// 向它发请求,非本机来源一律挡。
|
|
830
|
-
function originAllowed(req) {
|
|
831
|
-
const origin = req.headers.origin;
|
|
832
|
-
if (!origin) return true;
|
|
833
|
-
try {
|
|
834
|
-
const h = new URL(origin).hostname;
|
|
835
|
-
return h === '127.0.0.1' || h === 'localhost' || h === '::1';
|
|
836
|
-
} catch {
|
|
837
|
-
return false;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
|
|
842
|
-
const url = new URL(req.url, 'http://127.0.0.1/');
|
|
843
|
-
const method = (req.method || 'GET').toUpperCase();
|
|
844
|
-
|
|
845
|
-
try {
|
|
846
|
-
if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
847
|
-
sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (url.pathname === '/api/localtasks' && method === 'GET') {
|
|
852
|
-
sendJson(res, 200, { ok: true, data: await listTasks() });
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
if (url.pathname === '/api/localtasks' && method === 'POST') {
|
|
857
|
-
if (!originAllowed(req)) {
|
|
858
|
-
sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
const body = await readBody(req);
|
|
862
|
-
sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
if (url.pathname === '/api/localtasks/item' && method === 'DELETE') {
|
|
867
|
-
if (!originAllowed(req)) {
|
|
868
|
-
sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
|
|
869
|
-
} else {
|
|
870
|
-
const body = await readBody(req);
|
|
871
|
-
sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
|
|
872
|
-
}
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
sendJson(res, 404, { ok: false, error: '不存在: ' + method + ' ' + url.pathname });
|
|
877
|
-
} catch (err) {
|
|
878
|
-
const code = err && err.code === 'INVALID_INPUT' ? 400 : err && err.code === 'NOT_FOUND' ? 404 : 500;
|
|
879
|
-
sendJson(res, code, { ok: false, error: String((err && err.message) || err), code: err.code || 'INTERNAL' });
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
function startPopupServer(key, cwd, summary, timeoutMs) {
|
|
884
|
-
const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
|
|
885
|
-
return new Promise((resolve_, reject) => {
|
|
886
|
-
server.once('error', reject);
|
|
887
|
-
server.listen(0, '127.0.0.1', () => resolve_(server));
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function stopServer(server) {
|
|
892
|
-
return new Promise((resolve_) => {
|
|
893
|
-
const force = setTimeout(() => {
|
|
894
|
-
try {
|
|
895
|
-
server.closeAllConnections();
|
|
896
|
-
} catch {
|
|
897
|
-
/* Node 版本不支持则忽略,close 仍会兜底 */
|
|
898
|
-
}
|
|
899
|
-
}, 800);
|
|
900
|
-
server.close(() => {
|
|
901
|
-
clearTimeout(force);
|
|
902
|
-
resolve_();
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
|
|
907
825
|
// ─── 面板空闲自释放所需的观测接口(panel-guard.js 调用)────────────
|
|
908
826
|
|
|
909
827
|
// 当前有多少主题「真的在等待」——常驻面板的空闲自释放据此否决退出:
|
|
@@ -974,3 +892,102 @@ export function addEventStreamConn(req, res) {
|
|
|
974
892
|
res.on('close', cleanup);
|
|
975
893
|
return conn;
|
|
976
894
|
}
|
|
895
|
+
|
|
896
|
+
// ─── 常驻主面板(固定端口)统一入口 ───────────────────────────────────
|
|
897
|
+
|
|
898
|
+
// 「主面板就绪」=端口上有可响应的服务。
|
|
899
|
+
// 这与 /api/ping 不同:ping 是面板壳层每 15s 主动上报「有页面开着」,
|
|
900
|
+
// 这里只问「端口上有没有人接 HTTP」——用于决定是否要拉起 serve。
|
|
901
|
+
async function probePanelReady(timeoutMs = 900) {
|
|
902
|
+
const url = `${panelBaseUrl()}/api/ping`;
|
|
903
|
+
return new Promise((resolve_) => {
|
|
904
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
905
|
+
res.resume();
|
|
906
|
+
resolve_(true);
|
|
907
|
+
});
|
|
908
|
+
req.on('timeout', () => {
|
|
909
|
+
req.destroy();
|
|
910
|
+
resolve_(false);
|
|
911
|
+
});
|
|
912
|
+
req.on('error', () => resolve_(false));
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 「活动 SSE 连接数」= 主面板上当前正打开的「实时任务」标签数。
|
|
917
|
+
// 用于 wait 决策:已有活标签就不重开页面,让那个标签响铃/打角标/弹通知;
|
|
918
|
+
// 没有活标签时再 openBrowser 新开深链标签。
|
|
919
|
+
//
|
|
920
|
+
// 任何失败/旧版无此端点都按 0 处理(保守地开一次页),绝不因探测失败而阻塞 wait。
|
|
921
|
+
async function probePresence(timeoutMs = 900) {
|
|
922
|
+
const url = `${panelBaseUrl()}/api/localtasks/presence`;
|
|
923
|
+
return new Promise((resolve_) => {
|
|
924
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
925
|
+
let body = '';
|
|
926
|
+
res.setEncoding('utf8');
|
|
927
|
+
res.on('data', (c) => {
|
|
928
|
+
body += c;
|
|
929
|
+
});
|
|
930
|
+
res.on('end', () => {
|
|
931
|
+
try {
|
|
932
|
+
const j = JSON.parse(body);
|
|
933
|
+
const d = j && typeof j === 'object' && j.data ? j.data : j;
|
|
934
|
+
resolve_(Math.max(0, Number(d && d.clients) || 0));
|
|
935
|
+
} catch {
|
|
936
|
+
resolve_(0);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
req.on('timeout', () => {
|
|
941
|
+
req.destroy();
|
|
942
|
+
resolve_(0);
|
|
943
|
+
});
|
|
944
|
+
req.on('error', () => resolve_(0));
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function panelBaseUrl() {
|
|
949
|
+
return `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 深链到主面板「实时任务」页并选中当前主题(key 优先,cwd 兜底)。
|
|
953
|
+
function deepLinkUrl(key, cwd) {
|
|
954
|
+
return `${panelBaseUrl()}/#/localtask?key=${encodeURIComponent(key)}&cwd=${encodeURIComponent(cwd)}`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// 本仓库/已安装包的 CLI 入口(src/modules/localtask/ 上三级到包根)。
|
|
958
|
+
function binCliPath() {
|
|
959
|
+
return fileURLToPath(new URL('../../../bin/cli.mjs', import.meta.url));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 确保常驻主面板可用:已在运行直接复用;否则后台 detached 拉起一个独立
|
|
963
|
+
// `nx-kv serve --no-open --auto-exit`(unref,不随 wait 退出而关闭),再轮询健康端点。
|
|
964
|
+
// 失败只返回 running:false,绝不抛错——wait 仍可阻塞等待 CLI/轮询回填。
|
|
965
|
+
//
|
|
966
|
+
// --auto-exit:这个实例是**为等用户回填而临时拉起**的,用完就该让位——面板页全部关闭、
|
|
967
|
+
// 又没有等待中的主题时自行退出(宽限期见 panel-guard.js)。下一次 wait 需要时会再拉起
|
|
968
|
+
// (冷启动 <1s)。用户手动 `nx-kv serve` 的实例不带此标志,不会自己跑掉。
|
|
969
|
+
async function ensureMainPanel() {
|
|
970
|
+
const url = panelBaseUrl() + '/';
|
|
971
|
+
if (await probePanelReady()) return { running: true, started: false, url };
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
const child = spawn(process.execPath, [binCliPath(), 'serve', '--no-open', '--auto-exit'], {
|
|
975
|
+
detached: true,
|
|
976
|
+
stdio: 'ignore',
|
|
977
|
+
windowsHide: true,
|
|
978
|
+
});
|
|
979
|
+
child.on('error', () => {
|
|
980
|
+
/* 见轮询兜底 */
|
|
981
|
+
});
|
|
982
|
+
child.unref();
|
|
983
|
+
} catch {
|
|
984
|
+
return { running: false, started: false, url };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const deadline = Date.now() + 15_000;
|
|
988
|
+
while (Date.now() < deadline) {
|
|
989
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
990
|
+
if (await probePanelReady(700)) return { running: true, started: true, url };
|
|
991
|
+
}
|
|
992
|
+
return { running: false, started: true, url };
|
|
993
|
+
}
|