@jacksontian/kite-server 0.4.0 → 0.5.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/static.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 静态文件(Web 控制台:移动端 index.html / PC 端 pc.html,按 UA 分发)
2
+ * 静态文件(Web 控制台:单页应用,index.html 一份,手机/PC 自适应)
3
3
  */
4
4
 
5
5
  import fs from 'node:fs';
@@ -13,16 +13,10 @@ const MIME = {
13
13
  '.png': 'image/png',
14
14
  };
15
15
 
16
- // 移动端 UA 特征词;命中走手机版,其余(桌面浏览器)走 PC 版。
17
- // iPadOS 13+ 默认伪装 Mac UA,会落到 PC 版——需要时可显式访问 /index.html
18
- const MOBILE_UA = /android|iphone|ipod|ipad|mobile|harmonyos/i;
19
-
20
16
  export function createStaticServer(WEB_DIR) {
21
17
  return function serveStatic(req, res, pathname) {
22
18
  let rel = pathname.replace(/^\/+/, '');
23
- if (!rel) {
24
- rel = MOBILE_UA.test(req.headers['user-agent'] || '') ? 'index.html' : 'pc.html';
25
- }
19
+ if (!rel) rel = 'index.html';
26
20
  // resolve 归一化 ../ 后按「目录 + 分隔符」前缀判断,避免同级前缀目录(web-xxx)绕过
27
21
  const filePath = path.resolve(WEB_DIR, rel);
28
22
  if (filePath !== WEB_DIR && !filePath.startsWith(WEB_DIR + path.sep)) {
@@ -38,8 +32,9 @@ export function createStaticServer(WEB_DIR) {
38
32
  'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
39
33
  'Cache-Control': 'no-store',
40
34
  // 纵深防御:页面资源一律限同源,阻断潜在注入脚本把密钥外传(connect-src
41
- // 的 'self' 按 CSP3 含同源 ws/wss,实时通道不受影响);页面含内联 <script>
42
- // 与行内样式操作,script/style 需放行 'unsafe-inline'
35
+ // 的 'self' 按 CSP3 含同源 ws/wss,实时通道不受影响);应用逻辑在外部
36
+ // app.js('self' 放行),head 仅剩主题初始化内联脚本,故 script-src 还需
37
+ // 'unsafe-inline';style-src 同理(行内样式操作)
43
38
  'Content-Security-Policy':
44
39
  "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
45
40
  "img-src 'self' data:; connect-src 'self'",
package/lib/store.js CHANGED
@@ -1,43 +1,53 @@
1
1
  /**
2
2
  * 持久化:单文件 JSON,异步落盘(脏标记 + 节流 + 串行化,全程无同步 IO);
3
- * 以及卡死任务回收(sweepStaleRunning)。
3
+ * 以及卡死会话回收(sweepStaleRunning)。
4
4
  */
5
5
 
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
 
9
9
  export function createStore(ctx) {
10
- const DB_FILE = path.join(ctx.cfg.dataDir, 'tasks.json');
11
- const MAX_TASKS = ctx.cfg.maxTasks;
10
+ const DB_FILE = path.join(ctx.cfg.dataDir, 'sessions.json');
11
+ const MAX_SESSIONS = ctx.cfg.maxSessions;
12
12
  const STALE_RUNNING_MS = ctx.cfg.staleRunningMs;
13
13
 
14
- const state = { tasks: [], heartbeats: {} };
14
+ const state = { sessions: [], heartbeats: {} };
15
15
 
16
16
  async function loadState() {
17
17
  let raw;
18
18
  try {
19
19
  raw = await fs.promises.readFile(DB_FILE, 'utf8');
20
20
  } catch {
21
- return; // 首次启动,文件尚不存在,从空状态开始
21
+ // 旧版本数据文件叫 tasks.json:新文件不存在时迁移读取并改名,避免丢历史会话
22
+ const legacy = path.join(ctx.cfg.dataDir, 'tasks.json');
23
+ try {
24
+ raw = await fs.promises.readFile(legacy, 'utf8');
25
+ await fs.promises.rename(legacy, DB_FILE);
26
+ console.log('[server] 已将历史数据文件 tasks.json 迁移为 sessions.json');
27
+ } catch {
28
+ return; // 首次启动,文件尚不存在,从空状态开始
29
+ }
22
30
  }
23
31
  try {
24
32
  const parsed = JSON.parse(raw);
25
- state.tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
33
+ // 旧文件数组键为 tasks,新文件为 sessions,两者都兼容(迁移改名后下次落盘即刷新)
34
+ state.sessions = Array.isArray(parsed.sessions) ? parsed.sessions
35
+ : Array.isArray(parsed.tasks) ? parsed.tasks : [];
26
36
  state.heartbeats = parsed.heartbeats || {};
27
37
  } catch {
28
38
  // 文件损坏:先备份,避免被后续写盘静默覆盖丢失历史数据
29
- console.warn('[server] tasks.json 已损坏,备份后从空状态启动');
39
+ console.warn('[server] sessions.json 已损坏,备份后从空状态启动');
30
40
  await fs.promises.rename(DB_FILE, `${DB_FILE}.corrupt-${Date.now()}`).catch(() => {});
31
41
  }
32
42
  }
33
43
 
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) {
44
+ // 只保留最近 MAX_SESSIONS 个终态会话,进行中的不受影响,防止无限增长
45
+ function pruneFinishedSessions() {
46
+ const finished = state.sessions.filter((t) => ['done', 'failed', 'canceled'].includes(t.status));
47
+ if (finished.length > MAX_SESSIONS) {
38
48
  const sorted = finished.sort((a, b) => b.finishedAt - a.finishedAt);
39
- const cutoff = sorted[MAX_TASKS - 1].finishedAt;
40
- state.tasks = state.tasks.filter(
49
+ const cutoff = sorted[MAX_SESSIONS - 1].finishedAt;
50
+ state.sessions = state.sessions.filter(
41
51
  (t) => !['done', 'failed', 'canceled'].includes(t.status) || t.finishedAt >= cutoff,
42
52
  );
43
53
  }
@@ -45,7 +55,7 @@ export function createStore(ctx) {
45
55
 
46
56
  // 落盘策略:脏标记 + 300ms 节流合并高频写(心跳/日志上报),串行链防交叠,
47
57
  // tmp + rename 原子替换。崩溃最多丢约 300ms 的变更,
48
- // 卡住的 running 任务由心跳超时回收兜底,可接受。
58
+ // 卡住的 running 会话由心跳超时回收兜底,可接受。
49
59
  let savePending = false;
50
60
  let saveTimer = null;
51
61
  let savingChain = Promise.resolve();
@@ -53,11 +63,11 @@ export function createStore(ctx) {
53
63
  async function flushSave() {
54
64
  if (!savePending) return;
55
65
  savePending = false; // 先清脏标记再序列化快照;写失败会重新标脏重试
56
- pruneFinishedTasks();
66
+ pruneFinishedSessions();
57
67
  try {
58
68
  const data = JSON.stringify(state, null, 2);
59
69
  const tmp = DB_FILE + '.tmp';
60
- // 0600:tasks.json 含全部命令/日志/结果,不允许同机其他用户读取
70
+ // 0600:sessions.json 含全部命令/日志/结果,不允许同机其他用户读取
61
71
  await fs.promises.writeFile(tmp, data, { mode: 0o600 });
62
72
  await fs.promises.chmod(tmp, 0o600); // writeFile 的 mode 只在创建时生效,历史文件需显式收紧
63
73
  await fs.promises.rename(tmp, DB_FILE);
@@ -83,7 +93,7 @@ export function createStore(ctx) {
83
93
  });
84
94
  }
85
95
 
86
- // Worker 崩溃/掉线后,回收卡死在 running 的任务。
96
+ // Worker 崩溃/掉线后,回收卡死在 running 的会话。
87
97
  // pty 交互会话例外:时长不可预期,由 ptyIdleSec 空闲回收(见 sweepPtyIdle)
88
98
  function sweepStaleRunning() {
89
99
  const now = Date.now();
@@ -96,16 +106,16 @@ export function createStore(ctx) {
96
106
  }
97
107
  }
98
108
  if (hbPruned) scheduleSave();
99
- for (const t of state.tasks) {
109
+ for (const t of state.sessions) {
100
110
  if (t.status !== 'running' || t.type === 'pty') continue;
101
111
  const hb = t.workerId ? state.heartbeats[t.workerId] : null;
102
112
  const workerAlive = hb && now - hb.at < 90 * 1000;
103
113
  if (!workerAlive && t.startedAt && now - t.startedAt > STALE_RUNNING_MS) {
104
114
  t.status = 'failed';
105
115
  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 });
116
+ t.result = { error: 'Worker 离线超时,会话被服务端回收' };
117
+ t.logs.push('[server] Worker 心跳超时,会话被标记为失败');
118
+ ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(t), result: t.result });
109
119
  ctx.notifyIm(t, 'failed');
110
120
  }
111
121
  }
package/lib/summary.js CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
- * 任务/Worker 摘要视图:WS 推送与 API 列表共用的只读投影。
2
+ * 会话/Worker 摘要视图:WS 推送与 API 列表共用的只读投影。
3
3
  */
4
4
 
5
- export function taskSummary(t) {
5
+ export function sessionSummary(t) {
6
6
  return {
7
7
  id: t.id,
8
8
  type: t.type,
9
9
  title: t.title,
10
+ project: (t.payload && t.payload.project) || null,
10
11
  status: t.status,
11
12
  createdAt: t.createdAt,
12
13
  startedAt: t.startedAt,
13
14
  finishedAt: t.finishedAt,
15
+ openedAt: t.openedAt || null,
14
16
  workerId: t.workerId,
15
17
  targetWorker: t.targetWorker || null,
16
18
  logCount: t.logs.length,
package/lib/util.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 服务端通用小工具:常量时间比较、JSON 响应、请求体解析、任务公开视图。
2
+ * 服务端通用小工具:常量时间比较、JSON 响应、请求体解析、会话公开视图。
3
3
  */
4
4
 
5
5
  import crypto from 'node:crypto';
@@ -30,8 +30,8 @@ export function parseBody(rawBody) {
30
30
  }
31
31
  }
32
32
 
33
- export function publicTask(task) {
34
- // 浅拷贝切断与内部状态的引用别名,防止响应侧改动波及 state.tasks
35
- // 任务现有字段(含 logs/result/payload)均为控制台可见,无需裁剪
36
- return { ...task };
33
+ export function publicSession(session) {
34
+ // 浅拷贝切断与内部状态的引用别名,防止响应侧改动波及 state.sessions
35
+ // 会话现有字段(含 logs/result/payload)均为控制台可见,无需裁剪
36
+ return { ...session };
37
37
  }
package/lib/websocket.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * WebSocket 连接管理:/ws 控制台实时广播 + pty 终端输入上行;
3
- * /worker/ws Worker 任务通知与 pty 会话 IO 中继。
3
+ * /worker/ws Worker 会话通知与 pty 会话 IO 中继。
4
4
  * 鉴权走连接后的第一条消息(Token 不进 URL,不落日志)。
5
5
  */
6
6
 
@@ -57,7 +57,7 @@ export function createWebSockets(ctx) {
57
57
  ws.send(
58
58
  JSON.stringify({
59
59
  type: 'hello',
60
- tasks: [...ctx.state.tasks].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(ctx.taskSummary),
60
+ sessions: [...ctx.state.sessions].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(ctx.sessionSummary),
61
61
  workers: workersSnapshot(ctx.state.heartbeats),
62
62
  }),
63
63
  );
@@ -71,32 +71,32 @@ export function createWebSockets(ctx) {
71
71
  } catch {
72
72
  return;
73
73
  }
74
- if (!msg || typeof msg.taskId !== 'string') return;
75
- const taskId = msg.taskId.slice(0, 64);
74
+ if (!msg || typeof msg.sessionId !== 'string') return;
75
+ const sessionId = msg.sessionId.slice(0, 64);
76
76
 
77
77
  if (msg.type === 'pty.attach') {
78
78
  // 打开终端:先补回看缓冲,再告知 Worker 侧会话是否仍活;
79
79
  // 不活时附带 reason,控制台据此区分等待指引:
80
- // not_claimed 任务还在 pending,等 Worker 上线领取
80
+ // not_claimed 会话还在 pending,等 Worker 上线领取
81
81
  // worker_offline 会话在册但 Worker 通道断开,重连后自动重新挂载
82
82
  // starting 已领取但 session.started 未到(或已退出待 finish 上报)
83
- // ended 任务已终态或不存在,会话已结束/被回收
84
- const s = ctx.ptySessions.get(taskId);
83
+ // ended 会话已终态或不存在,会话已结束/被回收
84
+ const s = ctx.ptySessions.get(sessionId);
85
85
  const live = Boolean(s && s.ws && s.ws.readyState === s.ws.OPEN);
86
86
  let reason;
87
87
  if (!live) {
88
88
  if (s) {
89
89
  reason = 'worker_offline';
90
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';
91
+ const session = ctx.state.sessions.find((t) => t.id === sessionId);
92
+ if (!session || ['done', 'failed', 'canceled'].includes(session.status)) reason = 'ended';
93
+ else if (session.status === 'pending') reason = 'not_claimed';
94
94
  else reason = 'starting';
95
95
  }
96
96
  }
97
97
  ctx.wsSendJson(ws, {
98
98
  type: 'pty.attach',
99
- id: taskId,
99
+ id: sessionId,
100
100
  tail: s ? s.tail : '',
101
101
  live,
102
102
  reason,
@@ -104,26 +104,26 @@ export function createWebSockets(ctx) {
104
104
  return;
105
105
  }
106
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: '会话已结束' });
107
+ const s = ctx.ptySessions.get(sessionId);
108
+ if (!s) return ctx.wsSendJson(ws, { type: 'pty.exited', id: sessionId, reason: '会话已结束' });
109
109
  s.lastActiveAt = Date.now();
110
- ctx.wsSendJson(s.ws, { type: 'session.input', taskId, data: String(msg.data || '').slice(0, 65536) });
110
+ ctx.wsSendJson(s.ws, { type: 'session.input', sessionId, data: String(msg.data || '').slice(0, 65536) });
111
111
  return;
112
112
  }
113
113
  if (msg.type === 'session.resize') {
114
- const s = ctx.ptySessions.get(taskId);
114
+ const s = ctx.ptySessions.get(sessionId);
115
115
  if (!s) return;
116
116
  ctx.wsSendJson(s.ws, {
117
117
  type: 'session.resize',
118
- taskId,
118
+ sessionId,
119
119
  cols: Number(msg.cols) || 80,
120
120
  rows: Number(msg.rows) || 24,
121
121
  });
122
122
  return;
123
123
  }
124
124
  if (msg.type === 'session.close') {
125
- const s = ctx.ptySessions.get(taskId);
126
- if (s) ctx.wsSendJson(s.ws, { type: 'session.close', taskId });
125
+ const s = ctx.ptySessions.get(sessionId);
126
+ if (s) ctx.wsSendJson(s.ws, { type: 'session.close', sessionId });
127
127
  }
128
128
  });
129
129
 
@@ -137,7 +137,7 @@ export function createWebSockets(ctx) {
137
137
  ws.on('error', () => {});
138
138
  });
139
139
 
140
- // --- Worker 通道 /worker/ws:鉴权方式同 /ws;鉴权后下发 task.available,
140
+ // --- Worker 通道 /worker/ws:鉴权方式同 /ws;鉴权后下发 session.available,
141
141
  // 并承载 pty 会话上行消息(session.started/output/exited)---
142
142
  const workerWss = new WebSocketServer(WS_OPTS);
143
143
 
@@ -161,7 +161,7 @@ export function createWebSockets(ctx) {
161
161
  ws.close(4002, 'bad auth message');
162
162
  return;
163
163
  }
164
- // Worker 通道只认 Worker 侧密钥:控制台密钥泄露不能冒充 Worker 领任务。
164
+ // Worker 通道只认 Worker 侧密钥:控制台密钥泄露不能冒充 Worker 领会话。
165
165
  // 逐一尝试 WORKER_AUTH 条目;命中 workerSecrets 绑定密钥时身份以绑定
166
166
  // 为准(自报 workerId 不一致即拒绝),共享密钥为兼容路径(身份自报)
167
167
  let result = { ok: false, reason: '缺少签名头' };
@@ -197,9 +197,9 @@ export function createWebSockets(ctx) {
197
197
  ws.workerId = boundId || claimedId;
198
198
  ctx.wsWorkers.add(ws);
199
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' }));
200
+ // 断线窗口内可能已有积压会话,连接建立即补一次通知(只推指名本 Worker 的)
201
+ if (ctx.state.sessions.some((t) => t.status === 'pending' && t.targetWorker === ws.workerId)) {
202
+ ws.send(JSON.stringify({ type: 'session.available' }));
203
203
  }
204
204
  return;
205
205
  }
@@ -211,34 +211,40 @@ export function createWebSockets(ctx) {
211
211
  } catch {
212
212
  return;
213
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);
214
+ if (!msg || typeof msg.sessionId !== 'string') return;
215
+ const sessionId = msg.sessionId.slice(0, 64);
216
+ const session = ctx.state.sessions.find((t) => t.id === sessionId);
217
217
 
218
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, {
219
+ // 无记录(已被控制台关闭删除)或已终态:拒绝重挂并通知 Worker 回收残留 PTY,
220
+ // 避免孤儿进程常驻
221
+ if (!session || ['done', 'failed', 'canceled'].includes(session.status)) {
222
+ ctx.wsSendJson(ws, { type: 'session.close', sessionId });
223
+ return;
224
+ }
225
+ // 会话注册(含 Worker 通道重连后的重新挂载);只认该 Worker 自己领取的会话
226
+ if (session.type !== 'pty' || session.workerId !== ws.workerId) return;
227
+ const prev = ctx.ptySessions.get(sessionId);
228
+ ctx.ptySessions.set(sessionId, {
223
229
  ws,
224
230
  tail: prev ? prev.tail : '',
225
231
  lastActiveAt: Date.now(),
226
232
  });
227
- ctx.broadcast({ type: 'pty.started', id: taskId, pid: Number(msg.pid) || 0 });
233
+ ctx.broadcast({ type: 'pty.started', id: sessionId, pid: Number(msg.pid) || 0 });
228
234
  return;
229
235
  }
230
236
  if (msg.type === 'session.output') {
231
- const s = ctx.ptySessions.get(taskId);
237
+ const s = ctx.ptySessions.get(sessionId);
232
238
  if (!s) return;
233
239
  const data = String(msg.data || '');
234
240
  s.lastActiveAt = Date.now();
235
241
  s.tail = (s.tail + data).slice(-PTY_TAIL_BYTES); // 回看缓冲,重连可重放
236
- ctx.broadcast({ type: 'pty.data', id: taskId, data });
242
+ ctx.broadcast({ type: 'pty.data', id: sessionId, data });
237
243
  return;
238
244
  }
239
245
  if (msg.type === 'session.exited') {
240
- // 只清会话映射;任务终态由 Worker 随后的 HTTP finish 上报负责
241
- ctx.closePtySession(taskId, {});
246
+ // 只清会话映射;会话终态由 Worker 随后的 HTTP finish 上报负责
247
+ ctx.closePtySession(sessionId, {});
242
248
  }
243
249
  });
244
250
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jacksontian/kite-server",
3
- "version": "0.4.0",
4
- "description": "kite 云端信箱 API:HMAC 签名鉴权、任务队列、WebSocket 实时推送、手机端 Web 控制台",
3
+ "version": "0.5.0",
4
+ "description": "kite 云端信箱 API:HMAC 签名鉴权、会话队列、WebSocket 实时推送、手机端 Web 控制台",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {