@jacksontian/kite-server 0.3.0 → 0.4.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/lib/store.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 持久化:单文件 JSON,异步落盘(脏标记 + 节流 + 串行化,全程无同步 IO);
3
+ * 以及卡死任务回收(sweepStaleRunning)。
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export function createStore(ctx) {
10
+ const DB_FILE = path.join(ctx.cfg.dataDir, 'tasks.json');
11
+ const MAX_TASKS = ctx.cfg.maxTasks;
12
+ const STALE_RUNNING_MS = ctx.cfg.staleRunningMs;
13
+
14
+ const state = { tasks: [], heartbeats: {} };
15
+
16
+ async function loadState() {
17
+ let raw;
18
+ try {
19
+ raw = await fs.promises.readFile(DB_FILE, 'utf8');
20
+ } catch {
21
+ return; // 首次启动,文件尚不存在,从空状态开始
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(raw);
25
+ state.tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
26
+ state.heartbeats = parsed.heartbeats || {};
27
+ } catch {
28
+ // 文件损坏:先备份,避免被后续写盘静默覆盖丢失历史数据
29
+ console.warn('[server] tasks.json 已损坏,备份后从空状态启动');
30
+ await fs.promises.rename(DB_FILE, `${DB_FILE}.corrupt-${Date.now()}`).catch(() => {});
31
+ }
32
+ }
33
+
34
+ // 只保留最近 MAX_TASKS 个终态任务,进行中的不受影响,防止无限增长
35
+ function pruneFinishedTasks() {
36
+ const finished = state.tasks.filter((t) => ['done', 'failed', 'canceled'].includes(t.status));
37
+ if (finished.length > MAX_TASKS) {
38
+ const sorted = finished.sort((a, b) => b.finishedAt - a.finishedAt);
39
+ const cutoff = sorted[MAX_TASKS - 1].finishedAt;
40
+ state.tasks = state.tasks.filter(
41
+ (t) => !['done', 'failed', 'canceled'].includes(t.status) || t.finishedAt >= cutoff,
42
+ );
43
+ }
44
+ }
45
+
46
+ // 落盘策略:脏标记 + 300ms 节流合并高频写(心跳/日志上报),串行链防交叠,
47
+ // tmp + rename 原子替换。崩溃最多丢约 300ms 的变更,
48
+ // 卡住的 running 任务由心跳超时回收兜底,可接受。
49
+ let savePending = false;
50
+ let saveTimer = null;
51
+ let savingChain = Promise.resolve();
52
+
53
+ async function flushSave() {
54
+ if (!savePending) return;
55
+ savePending = false; // 先清脏标记再序列化快照;写失败会重新标脏重试
56
+ pruneFinishedTasks();
57
+ try {
58
+ const data = JSON.stringify(state, null, 2);
59
+ const tmp = DB_FILE + '.tmp';
60
+ // 0600:tasks.json 含全部命令/日志/结果,不允许同机其他用户读取
61
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 });
62
+ await fs.promises.chmod(tmp, 0o600); // writeFile 的 mode 只在创建时生效,历史文件需显式收紧
63
+ await fs.promises.rename(tmp, DB_FILE);
64
+ } catch (err) {
65
+ console.error(`[server] 落盘失败,下次变更时重试: ${err.message}`);
66
+ scheduleSave();
67
+ }
68
+ }
69
+
70
+ function scheduleSave() {
71
+ savePending = true;
72
+ if (saveTimer) return;
73
+ saveTimer = setTimeout(() => {
74
+ saveTimer = null;
75
+ savingChain = savingChain.then(flushSave);
76
+ }, 300).unref();
77
+ }
78
+
79
+ // 退出前把待写变更落盘(systemd 停服走 SIGTERM)
80
+ for (const sig of ['SIGTERM', 'SIGINT']) {
81
+ process.on(sig, () => {
82
+ savingChain.then(flushSave).finally(() => process.exit(0));
83
+ });
84
+ }
85
+
86
+ // Worker 崩溃/掉线后,回收卡死在 running 的任务。
87
+ // pty 交互会话例外:时长不可预期,由 ptyIdleSec 空闲回收(见 sweepPtyIdle)
88
+ function sweepStaleRunning() {
89
+ const now = Date.now();
90
+ // 顺带清理离线超过 7 天的 Worker 心跳记录,防止 heartbeats 无限增长
91
+ let hbPruned = false;
92
+ for (const [id, hb] of Object.entries(state.heartbeats)) {
93
+ if (now - hb.at > 7 * 24 * 3600 * 1000) {
94
+ delete state.heartbeats[id];
95
+ hbPruned = true;
96
+ }
97
+ }
98
+ if (hbPruned) scheduleSave();
99
+ for (const t of state.tasks) {
100
+ if (t.status !== 'running' || t.type === 'pty') continue;
101
+ const hb = t.workerId ? state.heartbeats[t.workerId] : null;
102
+ const workerAlive = hb && now - hb.at < 90 * 1000;
103
+ if (!workerAlive && t.startedAt && now - t.startedAt > STALE_RUNNING_MS) {
104
+ t.status = 'failed';
105
+ t.finishedAt = now;
106
+ t.result = { error: 'Worker 离线超时,任务被服务端回收' };
107
+ t.logs.push('[server] Worker 心跳超时,任务被标记为失败');
108
+ ctx.broadcast({ type: 'task.updated', task: ctx.taskSummary(t), result: t.result });
109
+ ctx.notifyIm(t, 'failed');
110
+ }
111
+ }
112
+ }
113
+
114
+ return { state, loadState, scheduleSave, flushSave, sweepStaleRunning };
115
+ }
package/lib/summary.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 任务/Worker 摘要视图:WS 推送与 API 列表共用的只读投影。
3
+ */
4
+
5
+ export function taskSummary(t) {
6
+ return {
7
+ id: t.id,
8
+ type: t.type,
9
+ title: t.title,
10
+ status: t.status,
11
+ createdAt: t.createdAt,
12
+ startedAt: t.startedAt,
13
+ finishedAt: t.finishedAt,
14
+ workerId: t.workerId,
15
+ targetWorker: t.targetWorker || null,
16
+ logCount: t.logs.length,
17
+ };
18
+ }
19
+
20
+ export function workersSnapshot(heartbeats) {
21
+ const now = Date.now();
22
+ return Object.entries(heartbeats).map(([id, hb]) => ({
23
+ workerId: id,
24
+ hostname: hb.hostname,
25
+ lastSeenAt: hb.at,
26
+ online: now - hb.at < 90 * 1000,
27
+ projects: hb.projects || [],
28
+ }));
29
+ }
package/lib/util.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 服务端通用小工具:常量时间比较、JSON 响应、请求体解析、任务公开视图。
3
+ */
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ export function timingSafeEqualStr(a, b) {
8
+ const ha = crypto.createHash('sha256').update(String(a)).digest();
9
+ const hb = crypto.createHash('sha256').update(String(b)).digest();
10
+ return crypto.timingSafeEqual(ha, hb);
11
+ }
12
+
13
+ export function json(res, code, body) {
14
+ const data = JSON.stringify(body);
15
+ res.writeHead(code, {
16
+ 'Content-Type': 'application/json; charset=utf-8',
17
+ 'Cache-Control': 'no-store',
18
+ });
19
+ res.end(data);
20
+ }
21
+
22
+ export function parseBody(rawBody) {
23
+ if (!rawBody || !rawBody.length) return {};
24
+ try {
25
+ return JSON.parse(rawBody.toString('utf8'));
26
+ } catch {
27
+ const err = new Error('invalid json');
28
+ err.statusCode = 400;
29
+ throw err;
30
+ }
31
+ }
32
+
33
+ export function publicTask(task) {
34
+ // 浅拷贝切断与内部状态的引用别名,防止响应侧改动波及 state.tasks;
35
+ // 任务现有字段(含 logs/result/payload)均为控制台可见,无需裁剪
36
+ return { ...task };
37
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * WebSocket 连接管理:/ws 控制台实时广播 + pty 终端输入上行;
3
+ * /worker/ws Worker 任务通知与 pty 会话 IO 中继。
4
+ * 鉴权走连接后的第一条消息(Token 不进 URL,不落日志)。
5
+ */
6
+
7
+ import { WebSocketServer } from 'ws';
8
+ import { workersSnapshot } from './summary.js';
9
+ import { PTY_TAIL_BYTES } from './pty.js';
10
+
11
+ // maxPayload:限制单帧 1MiB(pty 单帧输出/输入远小于此),
12
+ // 杜绝默认 100MiB 大帧配合广播造成的内存放大
13
+ const WS_OPTS = { noServer: true, maxPayload: 1024 * 1024 };
14
+
15
+ export function createWebSockets(ctx) {
16
+ const wss = new WebSocketServer(WS_OPTS);
17
+
18
+ wss.on('connection', (ws, req) => {
19
+ const remote = req.socket.remoteAddress;
20
+ ws.isAuthed = false;
21
+ ws.isAlive = true;
22
+
23
+ // 连接后 5 秒内必须完成鉴权,否则关闭,防止裸连接占资源
24
+ const authTimer = setTimeout(() => ws.close(4001, 'auth timeout'), 5000);
25
+
26
+ ws.on('message', (raw) => {
27
+ if (!ws.isAuthed) {
28
+ let msg;
29
+ try {
30
+ msg = JSON.parse(raw.toString());
31
+ } catch {
32
+ ws.close(4002, 'bad auth message');
33
+ return;
34
+ }
35
+ if (!msg || msg.type !== 'auth') {
36
+ ws.close(4002, 'bad auth message');
37
+ return;
38
+ }
39
+ // 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),同样走时间窗 + nonce 防重放;
40
+ // 控制台通道只认控制台密钥
41
+ const result = ctx.auth.verifySignature(
42
+ ['WS', String(msg.ts), String(msg.nonce)],
43
+ msg.ts,
44
+ msg.nonce,
45
+ msg.sig,
46
+ ctx.cfg.apiSecret,
47
+ );
48
+ if (!result.ok) {
49
+ console.warn(`[server] 拒绝 WS 连接,来源 ${remote}:${result.reason}`);
50
+ ws.close(4003, 'auth failed');
51
+ return;
52
+ }
53
+ clearTimeout(authTimer);
54
+ ws.isAuthed = true;
55
+ ctx.wsClients.add(ws);
56
+ // 鉴权通过立即下发全量快照,之后的增量靠 broadcast
57
+ ws.send(
58
+ JSON.stringify({
59
+ type: 'hello',
60
+ tasks: [...ctx.state.tasks].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(ctx.taskSummary),
61
+ workers: workersSnapshot(ctx.state.heartbeats),
62
+ }),
63
+ );
64
+ return;
65
+ }
66
+
67
+ // 鉴权后只受理 pty 终端交互消息,其余一律忽略
68
+ let msg;
69
+ try {
70
+ msg = JSON.parse(raw.toString());
71
+ } catch {
72
+ return;
73
+ }
74
+ if (!msg || typeof msg.taskId !== 'string') return;
75
+ const taskId = msg.taskId.slice(0, 64);
76
+
77
+ if (msg.type === 'pty.attach') {
78
+ // 打开终端:先补回看缓冲,再告知 Worker 侧会话是否仍活;
79
+ // 不活时附带 reason,控制台据此区分等待指引:
80
+ // not_claimed 任务还在 pending,等 Worker 上线领取
81
+ // worker_offline 会话在册但 Worker 通道断开,重连后自动重新挂载
82
+ // starting 已领取但 session.started 未到(或已退出待 finish 上报)
83
+ // ended 任务已终态或不存在,会话已结束/被回收
84
+ const s = ctx.ptySessions.get(taskId);
85
+ const live = Boolean(s && s.ws && s.ws.readyState === s.ws.OPEN);
86
+ let reason;
87
+ if (!live) {
88
+ if (s) {
89
+ reason = 'worker_offline';
90
+ } else {
91
+ const task = ctx.state.tasks.find((t) => t.id === taskId);
92
+ if (!task || ['done', 'failed', 'canceled'].includes(task.status)) reason = 'ended';
93
+ else if (task.status === 'pending') reason = 'not_claimed';
94
+ else reason = 'starting';
95
+ }
96
+ }
97
+ ctx.wsSendJson(ws, {
98
+ type: 'pty.attach',
99
+ id: taskId,
100
+ tail: s ? s.tail : '',
101
+ live,
102
+ reason,
103
+ });
104
+ return;
105
+ }
106
+ if (msg.type === 'session.input') {
107
+ const s = ctx.ptySessions.get(taskId);
108
+ if (!s) return ctx.wsSendJson(ws, { type: 'pty.exited', id: taskId, reason: '会话已结束' });
109
+ s.lastActiveAt = Date.now();
110
+ ctx.wsSendJson(s.ws, { type: 'session.input', taskId, data: String(msg.data || '').slice(0, 65536) });
111
+ return;
112
+ }
113
+ if (msg.type === 'session.resize') {
114
+ const s = ctx.ptySessions.get(taskId);
115
+ if (!s) return;
116
+ ctx.wsSendJson(s.ws, {
117
+ type: 'session.resize',
118
+ taskId,
119
+ cols: Number(msg.cols) || 80,
120
+ rows: Number(msg.rows) || 24,
121
+ });
122
+ return;
123
+ }
124
+ if (msg.type === 'session.close') {
125
+ const s = ctx.ptySessions.get(taskId);
126
+ if (s) ctx.wsSendJson(s.ws, { type: 'session.close', taskId });
127
+ }
128
+ });
129
+
130
+ ws.on('pong', () => {
131
+ ws.isAlive = true;
132
+ });
133
+ ws.on('close', () => {
134
+ clearTimeout(authTimer);
135
+ ctx.wsClients.delete(ws);
136
+ });
137
+ ws.on('error', () => {});
138
+ });
139
+
140
+ // --- Worker 通道 /worker/ws:鉴权方式同 /ws;鉴权后下发 task.available,
141
+ // 并承载 pty 会话上行消息(session.started/output/exited)---
142
+ const workerWss = new WebSocketServer(WS_OPTS);
143
+
144
+ workerWss.on('connection', (ws, req) => {
145
+ const remote = req.socket.remoteAddress;
146
+ ws.isAuthed = false;
147
+ ws.isAlive = true;
148
+
149
+ const authTimer = setTimeout(() => ws.close(4001, 'auth timeout'), 5000);
150
+
151
+ ws.on('message', (raw) => {
152
+ if (!ws.isAuthed) {
153
+ let msg;
154
+ try {
155
+ msg = JSON.parse(raw.toString());
156
+ } catch {
157
+ ws.close(4002, 'bad auth message');
158
+ return;
159
+ }
160
+ if (!msg || msg.type !== 'auth') {
161
+ ws.close(4002, 'bad auth message');
162
+ return;
163
+ }
164
+ // Worker 通道只认 Worker 侧密钥:控制台密钥泄露不能冒充 Worker 领任务。
165
+ // 逐一尝试 WORKER_AUTH 条目;命中 workerSecrets 绑定密钥时身份以绑定
166
+ // 为准(自报 workerId 不一致即拒绝),共享密钥为兼容路径(身份自报)
167
+ let result = { ok: false, reason: '缺少签名头' };
168
+ let boundId = null;
169
+ for (const entry of ctx.cfg.workerAuth) {
170
+ result = ctx.auth.verifySignature(
171
+ ['WS', String(msg.ts), String(msg.nonce)],
172
+ msg.ts,
173
+ msg.nonce,
174
+ msg.sig,
175
+ entry.secret,
176
+ );
177
+ if (result.ok) {
178
+ boundId = entry.workerId;
179
+ break;
180
+ }
181
+ }
182
+ if (!result.ok) {
183
+ console.warn(`[server] 拒绝 Worker WS 连接,来源 ${remote}:${result.reason}`);
184
+ ws.close(4003, 'auth failed');
185
+ return;
186
+ }
187
+ clearTimeout(authTimer);
188
+ const claimedId = String(msg.workerId || 'unknown').slice(0, 128);
189
+ if (boundId && claimedId !== boundId) {
190
+ console.warn(
191
+ `[server] 拒绝 Worker WS 连接,来源 ${remote}:workerId "${claimedId}" 与密钥绑定身份 "${boundId}" 不一致`,
192
+ );
193
+ ws.close(4003, 'identity mismatch');
194
+ return;
195
+ }
196
+ ws.isAuthed = true;
197
+ ws.workerId = boundId || claimedId;
198
+ ctx.wsWorkers.add(ws);
199
+ console.log(`[server] Worker ${ws.workerId} 通道已连接(来源 ${remote})`);
200
+ // 断线窗口内可能已有积压任务,连接建立即补一次通知(只推指名本 Worker 的)
201
+ if (ctx.state.tasks.some((t) => t.status === 'pending' && t.targetWorker === ws.workerId)) {
202
+ ws.send(JSON.stringify({ type: 'task.available' }));
203
+ }
204
+ return;
205
+ }
206
+
207
+ // --- pty 会话上行消息 ---
208
+ let msg;
209
+ try {
210
+ msg = JSON.parse(raw.toString());
211
+ } catch {
212
+ return;
213
+ }
214
+ if (!msg || typeof msg.taskId !== 'string') return;
215
+ const taskId = msg.taskId.slice(0, 64);
216
+ const task = ctx.state.tasks.find((t) => t.id === taskId);
217
+
218
+ if (msg.type === 'session.started') {
219
+ // 会话注册(含 Worker 通道重连后的重新挂载);只认该 Worker 自己领取的任务
220
+ if (!task || task.type !== 'pty' || task.workerId !== ws.workerId) return;
221
+ const prev = ctx.ptySessions.get(taskId);
222
+ ctx.ptySessions.set(taskId, {
223
+ ws,
224
+ tail: prev ? prev.tail : '',
225
+ lastActiveAt: Date.now(),
226
+ });
227
+ ctx.broadcast({ type: 'pty.started', id: taskId, pid: Number(msg.pid) || 0 });
228
+ return;
229
+ }
230
+ if (msg.type === 'session.output') {
231
+ const s = ctx.ptySessions.get(taskId);
232
+ if (!s) return;
233
+ const data = String(msg.data || '');
234
+ s.lastActiveAt = Date.now();
235
+ s.tail = (s.tail + data).slice(-PTY_TAIL_BYTES); // 回看缓冲,重连可重放
236
+ ctx.broadcast({ type: 'pty.data', id: taskId, data });
237
+ return;
238
+ }
239
+ if (msg.type === 'session.exited') {
240
+ // 只清会话映射;任务终态由 Worker 随后的 HTTP finish 上报负责
241
+ ctx.closePtySession(taskId, {});
242
+ }
243
+ });
244
+
245
+ ws.on('pong', () => {
246
+ ws.isAlive = true;
247
+ });
248
+ ws.on('close', () => {
249
+ clearTimeout(authTimer);
250
+ ctx.wsWorkers.delete(ws);
251
+ // 该连接持有的会话置为游离:PTY 在 Worker 上还活着,重连后会重新挂载;
252
+ // 超过 ptyIdleSec 未重连则由 sweepPtyIdle 判失败
253
+ for (const [, s] of ctx.ptySessions) {
254
+ if (s.ws === ws) s.ws = null;
255
+ }
256
+ });
257
+ ws.on('error', () => {});
258
+ });
259
+
260
+ return { wss, workerWss };
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacksontian/kite-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "kite 云端信箱 API:HMAC 签名鉴权、任务队列、WebSocket 实时推送、手机端 Web 控制台",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,15 +8,25 @@
8
8
  "node": ">=24.0.0"
9
9
  },
10
10
  "bin": {
11
- "kites": "server.js"
11
+ "kites": "bin/server.js"
12
12
  },
13
- "main": "server.js",
13
+ "main": "bin/server.js",
14
14
  "scripts": {
15
- "test": "node --test"
15
+ "lint": "eslint .",
16
+ "lint:fix": "eslint . --fix",
17
+ "test": "node --test",
18
+ "cov": "c8 --reporter=text --reporter=html node --test"
19
+ },
20
+ "c8": {
21
+ "exclude": [
22
+ "**/test/**",
23
+ "web/vendor/**",
24
+ "**/*.config.js"
25
+ ]
16
26
  },
17
27
  "files": [
18
- "server.js",
19
- "config.js",
28
+ "bin/",
29
+ "lib/",
20
30
  "web/",
21
31
  "deploy/",
22
32
  "config.example.yaml"
@@ -37,5 +47,11 @@
37
47
  "publishConfig": {
38
48
  "registry": "https://registry.npmjs.org/",
39
49
  "access": "public"
50
+ },
51
+ "devDependencies": {
52
+ "@eslint/js": "^10.0.1",
53
+ "c8": "^12.0.0",
54
+ "eslint": "^10.9.0",
55
+ "globals": "^17.11.0"
40
56
  }
41
57
  }