@kne/fastify-app-manager 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -126,6 +126,7 @@ deploy(versionId)
126
126
  | 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
127
127
  | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
128
128
  | 部署进度 | `deploy` 按「准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
129
+ | 部署记录 | 每次部署(含父应用启动时补回部署目录)落库:状态 `running`/`success`/`failed`、阶段事件、部署日志(依赖安装输出、就绪失败时进程 `err.log` 末尾);父应用重启后未结束的标为中断;`GET .../app/deploy/list`、`.../app/deploy/detail` 查询 |
129
130
  | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
130
131
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
131
132
  | 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
@@ -213,6 +214,9 @@ await fastify.listen({ port: 3000 });
213
214
  | `maxZipSize` | number | 否 | `209715200` | zip 最大字节(200MB);同步 multipart 限制 |
214
215
  | `maxZipEntries` | number | 否 | `20000` | zip 最大条目数(不含被丢弃的 `node_modules` 条目) |
215
216
  | `npmInstallTimeoutMs` | number | 否 | `600000` | `server` 目录 `npm install` 超时 |
217
+ | `deployRecordKeep` | number | 否 | `20` | 每个应用保留的部署记录条数 |
218
+ | `deployLogMaxLines` | number | 否 | `2000` | 单次部署日志最多保留行数(超出丢弃最早的) |
219
+ | `deployErrorLogTailLines` | number | 否 | `50` | 就绪失败时附加到部署日志的进程 `err.log` 末尾行数 |
216
220
  | `sseReplayLines` | number | 否 | `100` | SSE 连接时回放最近行数 |
217
221
  | `sseHeartbeatMs` | number | 否 | `15000` | SSE 心跳间隔 |
218
222
  | `logMaxSize` | number | 否 | `52428800` | 单日志文件上限(50MB),达到即切分;`0` 关闭按大小切分 |
@@ -459,7 +463,7 @@ await fastify.listen({ port: 3000 });
459
463
 
460
464
  ##### GET `{prefix}/app/deploy/stream`
461
465
 
462
- SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署中匹配 `progressId` 的已有事件,便于断线重连),之后每个阶段变化推送 `progress`。每个应用只在内存中保留最近一次部署的事件。
466
+ SSE 部署进度。连接时先推送 `snapshot`(该次部署已有事件,便于断线重连;内存中没有时从部署记录回放,`deploy` 为该部署记录含最近 `sseReplayLines` 行日志,无记录为 `null`),之后每个阶段变化推送 `progress`,每行部署日志推送 `log`(`{ progressId, line }`)。
463
467
 
464
468
  | 参数名 | 类型 | 必填 | 说明 |
465
469
  |--------|------|------|------|
@@ -480,7 +484,7 @@ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署
480
484
 
481
485
  ```text
482
486
  event: snapshot
483
- data: {"steps":["package","env","migrate","start","ready"],"events":[]}
487
+ data: {"steps":["package","env","migrate","start","ready"],"events":[],"deploy":null}
484
488
 
485
489
  event: progress
486
490
  data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
@@ -489,6 +493,27 @@ event: progress
489
493
  data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message":null,"at":"2026-10-10T10:00:05.000Z"}
490
494
  ```
491
495
 
496
+ ##### 部署记录
497
+
498
+ 每次部署(含父应用启动时补回部署目录,`source: 'restore'`)都会落库,每个应用保留最近 `deployRecordKeep` 条。父应用重启时,未结束的记录标为 `failed`(`message: deploy interrupted: app manager restarted`)。应用详情返回 `deployment`(进行中的部署,无则 `null`)与 `lastDeployment`(最近一次部署记录摘要)。
499
+
500
+ | 方法 | 路径 | 参数 | 说明 |
501
+ |------|------|------|------|
502
+ | GET | `{prefix}/app/deploy/list` | `name`, `perPage?`, `currentPage?` | 部署记录分页(不含日志与事件),按开始时间倒序 |
503
+ | GET | `{prefix}/app/deploy/detail` | `name`, `progressId?`, `logLines?` | 部署记录详情;`progressId` 为空取最近一次;`logLines` 只取最后 N 行(`0` 全部) |
504
+
505
+ 部署记录字段:
506
+
507
+ | 字段 | 说明 |
508
+ |------|------|
509
+ | `progressId` / `versionId` / `version` | 部署标识与版本 |
510
+ | `source` | `deploy` 手动部署 / `restore` 启动时补回部署目录 |
511
+ | `status` | `running` / `success` / `failed` |
512
+ | `step` / `message` | 最后到达的阶段 / 失败原因 |
513
+ | `startedAt` / `finishedAt` | 起止时间 |
514
+ | `events` | 阶段事件(仅详情) |
515
+ | `log` / `logTotalLines` | 部署日志:各阶段结果、依赖安装输出、就绪失败时进程 `err.log` 末尾(仅详情) |
516
+
492
517
  ##### 版本迁移脚本管理
493
518
 
494
519
  管理某版本的 `.sql` 文件。脚本内容存于版本记录(数据库),版本目录不在磁盘时也可查看和编辑;目录在磁盘上(当前版本)时同步写入 `server/{migrationPath}`,部署时以数据库为准覆盖。执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
@@ -547,6 +572,8 @@ data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message"
547
572
  |--------|------|------|------|
548
573
  | name | string | 是 | 应用 slug |
549
574
 
575
+ 返回脱敏后的应用对象,另带 `deployment`:最近一次部署尚未结束(未出错、就绪阶段未完成)时为 `{ progressId, versionId, version, startedAt, step, detail }`,否则为 `null`。准备部署包阶段应用仍是旧的 `status` 与 `currentVersionId`,用它判断哪个版本正在部署。`app/list` 的每个元素同样带该字段。
576
+
550
577
  ##### POST `{prefix}/app/start` | `stop` | `restart` | `remove`
551
578
 
552
579
  | 参数名 | 类型 | 必填 | 说明 |
@@ -731,7 +758,11 @@ data: {"name":"demo","ts":1791456608533,...}
731
758
  | `restoreMissingArtifacts()` | 当前版本目录(`artifactPath/server`)缺失时,按版本 `fileId` 经 `resolveVersionFile` 取回 zip 重建并恢复 `current` 链接(不跑 SQL 迁移);返回 `[{ name, version, action: 'restored' \| 'skipped' \| 'failed', reason? }]`,无 `fileId` 的记 `skipped` |
732
759
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
733
760
  | `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
734
- | `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
761
+ | `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`、日志 `deploy-log:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, status, events, ... }`、`active(name)` 进行中的部署 |
762
+ | `deployList({ name, perPage?, currentPage? })` | 同 `GET .../app/deploy/list` |
763
+ | `deployDetail({ name, progressId?, logLines? })` | 同 `GET .../app/deploy/detail` |
764
+ | `lastDeployment(name)` | 最近一次部署记录摘要(无日志),无记录为 `null` |
765
+ | `markInterruptedDeploys()` | 把库里仍 `running`、但本进程未在跟踪的部署记录标为 `failed`(启动时自动调用),返回条数 |
735
766
  | `waitDeployProgress({ name, progressId, timeoutMs? })` | 等到该次部署出错或 `ready` 结束(默认超时 120000ms),返回 `{ progressId, ok, timedOut, events }`;供 MCP 等同步调用方使用 |
736
767
  | `getEnvDeclaration({ name, versionId? })` | 同 `GET .../app/env/schema` |
737
768
  | `listInjectedEnvKeys(app)` | 应用中心注入(或在其它页签维护)的变量名 `Set`,环境变量声明中不展示 |
@@ -841,7 +872,7 @@ data: {"name":"demo","ts":1791456608533,...}
841
872
  | 应用不存在 | `/app/{name}` 下找不到应用时返回 404「应用不存在」页(带返回首页)或 JSON(`appStatus: 'missing'`);`pathPrefix` 之外以及未绑定的域名仍交给宿主路由 |
842
873
  | Location | 相对路径前缀补上 `/app/{name}` |
843
874
  | Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
844
- | Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
875
+ | Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过;JS 脚本与 `text/event-stream`(SSE 不结束的流)原样流式透传 |
845
876
 
846
877
  ##### 日志切分与归档
847
878
 
package/index.js CHANGED
@@ -23,6 +23,12 @@ module.exports = fp(
23
23
  maxZipSize: 200 * 1024 * 1024,
24
24
  maxZipEntries: 20000,
25
25
  npmInstallTimeoutMs: 10 * 60 * 1000,
26
+ /** 每个应用保留的部署记录条数 */
27
+ deployRecordKeep: 20,
28
+ /** 单次部署日志最多保留的行数(超出丢弃最早的) */
29
+ deployLogMaxLines: 2000,
30
+ /** 就绪失败时附加到部署日志的进程错误日志行数 */
31
+ deployErrorLogTailLines: 50,
26
32
  logRetentionMaxRows: 10000,
27
33
  sseReplayLines: 100,
28
34
  sseHeartbeatMs: 15000,
@@ -277,25 +277,77 @@ module.exports = fp(async (fastify, options) => {
277
277
  async (request, reply) => {
278
278
  const { name, progressId } = request.query;
279
279
  await services.app.detail({ name });
280
- const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
281
280
  const progress = services.app.getDeployProgress();
282
281
  const matches = event => !progressId || event.progressId === progressId;
283
- const snapshot = progress.snapshot(name);
282
+ const memory = progress.snapshot(name);
283
+ let record = null;
284
+ if (progressId && (!memory || memory.progressId !== progressId)) {
285
+ // 不在本进程内(已结束被新部署覆盖或主进程重启过):从部署记录回放
286
+ record = await services.app.deployDetail({ name, progressId, logLines: options.sseReplayLines }).catch(() => null);
287
+ } else if (memory) {
288
+ record = await services.app.deployDetail({ name, progressId: memory.progressId, logLines: options.sseReplayLines }).catch(() => null);
289
+ }
290
+ const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
284
291
  sse.writeEvent('snapshot', {
285
292
  steps: DEPLOY_STEPS,
286
- events: snapshot ? snapshot.events.filter(matches) : []
293
+ events: record ? (record.events || []).filter(matches) : memory ? memory.events.filter(matches) : [],
294
+ deploy: record ? Object.assign({}, record, { events: undefined }) : null
287
295
  });
288
296
 
289
297
  const onProgress = event => matches(event) && sse.writeEvent('progress', event);
298
+ const onLog = event => matches(event) && sse.writeEvent('log', event);
290
299
  const eventName = `deploy:${name}`;
300
+ const logEventName = `deploy-log:${name}`;
291
301
  progress.emitter.on(eventName, onProgress);
302
+ progress.emitter.on(logEventName, onLog);
292
303
  request.raw.on('close', () => {
293
304
  progress.emitter.off(eventName, onProgress);
305
+ progress.emitter.off(logEventName, onLog);
294
306
  sse.close();
295
307
  });
296
308
  }
297
309
  );
298
310
 
311
+ fastify.get(
312
+ `${options.prefix}/app/deploy/list`,
313
+ {
314
+ onRequest: auth(),
315
+ schema: {
316
+ summary: '部署记录列表(不含日志)',
317
+ query: {
318
+ type: 'object',
319
+ required: ['name'],
320
+ properties: {
321
+ name: { type: 'string' },
322
+ perPage: { type: 'number', default: 20 },
323
+ currentPage: { type: 'number', default: 1 }
324
+ }
325
+ }
326
+ }
327
+ },
328
+ async request => services.app.deployList(request.query)
329
+ );
330
+
331
+ fastify.get(
332
+ `${options.prefix}/app/deploy/detail`,
333
+ {
334
+ onRequest: auth(),
335
+ schema: {
336
+ summary: '部署记录详情(含阶段事件与部署日志);progressId 为空取最近一次',
337
+ query: {
338
+ type: 'object',
339
+ required: ['name'],
340
+ properties: {
341
+ name: { type: 'string' },
342
+ progressId: { type: 'string' },
343
+ logLines: { type: 'number', default: 0, description: '只返回最后 N 行日志;0 返回全部' }
344
+ }
345
+ }
346
+ }
347
+ },
348
+ async request => services.app.deployDetail(request.query)
349
+ );
350
+
299
351
  fastify.get(
300
352
  `${options.prefix}/app/list`,
301
353
  {
@@ -0,0 +1,71 @@
1
+ module.exports = ({ DataTypes }) => {
2
+ return {
3
+ name: 'appDeploy',
4
+ model: {
5
+ appName: {
6
+ type: DataTypes.STRING,
7
+ allowNull: false,
8
+ comment: '所属应用 name'
9
+ },
10
+ progressId: {
11
+ type: DataTypes.STRING,
12
+ allowNull: false,
13
+ comment: '部署进度 id,与 SSE 进度订阅一致'
14
+ },
15
+ versionId: {
16
+ type: DataTypes.STRING,
17
+ allowNull: true,
18
+ comment: '部署的版本 id'
19
+ },
20
+ version: {
21
+ type: DataTypes.STRING,
22
+ allowNull: true,
23
+ comment: '部署的版本号'
24
+ },
25
+ source: {
26
+ type: DataTypes.STRING,
27
+ allowNull: false,
28
+ defaultValue: 'deploy',
29
+ comment: 'deploy 手动部署 | restore 启动时补回部署目录'
30
+ },
31
+ status: {
32
+ type: DataTypes.STRING,
33
+ allowNull: false,
34
+ defaultValue: 'running',
35
+ comment: 'running | success | failed'
36
+ },
37
+ step: {
38
+ type: DataTypes.STRING,
39
+ allowNull: true,
40
+ comment: '最后到达的阶段'
41
+ },
42
+ message: {
43
+ type: DataTypes.TEXT,
44
+ allowNull: true,
45
+ comment: '失败原因'
46
+ },
47
+ events: {
48
+ type: DataTypes.JSON,
49
+ allowNull: true,
50
+ comment: '进度事件 [{ step, status, detail, message, at }]'
51
+ },
52
+ log: {
53
+ type: DataTypes.TEXT,
54
+ allowNull: true,
55
+ comment: '部署日志(依赖安装输出、各阶段结果、启动失败时的进程错误日志)'
56
+ },
57
+ startedAt: {
58
+ type: DataTypes.DATE,
59
+ allowNull: true
60
+ },
61
+ finishedAt: {
62
+ type: DataTypes.DATE,
63
+ allowNull: true
64
+ }
65
+ },
66
+ options: {
67
+ comment: '应用部署记录',
68
+ indexes: [{ fields: ['app_name'] }, { unique: true, fields: ['progress_id'] }]
69
+ }
70
+ };
71
+ };
@@ -265,7 +265,7 @@ module.exports = fp(async (fastify, options) => {
265
265
 
266
266
  const detail = async ({ name }) => {
267
267
  const app = await getByName(name);
268
- return Object.assign(toPublicApp(app), { deployment: deployProgress.active(app.name) });
268
+ return Object.assign(toPublicApp(app), { deployment: deployProgress.active(app.name), lastDeployment: await lastDeployment(app.name) });
269
269
  };
270
270
 
271
271
  const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
@@ -486,7 +486,150 @@ module.exports = fp(async (fastify, options) => {
486
486
 
487
487
  /** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
488
488
  const statusSyncInflight = new Map();
489
- const deployProgress = createDeployProgress();
489
+
490
+ const pruneDeployRecords = async appName => {
491
+ const stale = await models.appDeploy.findAll({
492
+ where: { appName },
493
+ attributes: ['id'],
494
+ order: [['createdAt', 'DESC']],
495
+ offset: options.deployRecordKeep
496
+ });
497
+ if (stale.length) {
498
+ await models.appDeploy.destroy({ where: { id: stale.map(row => row.id) } });
499
+ }
500
+ };
501
+
502
+ const persistDeploy = async state => {
503
+ const values = {
504
+ appName: state.name,
505
+ progressId: state.progressId,
506
+ versionId: state.versionId,
507
+ version: state.version,
508
+ source: state.source,
509
+ status: state.status,
510
+ step: state.step,
511
+ message: state.message,
512
+ events: state.events,
513
+ log: state.logs.join('\n'),
514
+ startedAt: state.startedAt,
515
+ finishedAt: state.finishedAt
516
+ };
517
+ const record = await models.appDeploy.findOne({ where: { progressId: state.progressId } });
518
+ if (record) {
519
+ await record.update(values);
520
+ return;
521
+ }
522
+ await models.appDeploy.create(values);
523
+ await pruneDeployRecords(state.name);
524
+ };
525
+
526
+ const deployProgress = createDeployProgress({
527
+ persist: persistDeploy,
528
+ logMaxLines: options.deployLogMaxLines,
529
+ onPersistError: (err, state) => fastify.log.warn({ err, app: state.name, progressId: state.progressId }, 'persist deploy record failed')
530
+ });
531
+
532
+ const tailLines = (lines, n) => (n > 0 && lines.length > n ? lines.slice(-n) : lines);
533
+
534
+ /** 部署记录:进程内仍在跟踪的以内存为准(日志更新),否则读库 */
535
+ const toDeployRecord = (row, { withLog = false, logLines = 0 } = {}) => {
536
+ const memory = deployProgress.snapshot(row.appName);
537
+ const live = memory && memory.progressId === row.progressId ? memory : null;
538
+ const base = live || {
539
+ name: row.appName,
540
+ progressId: row.progressId,
541
+ versionId: row.versionId,
542
+ version: row.version,
543
+ source: row.source,
544
+ status: row.status,
545
+ step: row.step,
546
+ message: row.message,
547
+ startedAt: row.startedAt ? new Date(row.startedAt).toISOString() : null,
548
+ finishedAt: row.finishedAt ? new Date(row.finishedAt).toISOString() : null,
549
+ events: Array.isArray(row.events) ? row.events : []
550
+ };
551
+ const record = Object.assign({}, base, { name: row.appName });
552
+ if (!withLog) {
553
+ delete record.events;
554
+ return record;
555
+ }
556
+ const lines = live ? deployProgress.logsOf(row.appName, row.progressId) || [] : row.log ? row.log.split('\n') : [];
557
+ return Object.assign(record, { logTotalLines: lines.length, log: tailLines(lines, logLines).join('\n') });
558
+ };
559
+
560
+ const deployList = async ({ name, perPage = 20, currentPage = 1 } = {}) => {
561
+ const app = await getByName(name);
562
+ const { rows, count } = await models.appDeploy.findAndCountAll({
563
+ where: { appName: app.name },
564
+ attributes: { exclude: ['log', 'events'] },
565
+ offset: perPage * (currentPage - 1),
566
+ limit: perPage,
567
+ order: [['createdAt', 'DESC']]
568
+ });
569
+ return { pageData: rows.map(row => toDeployRecord(row)), totalCount: count };
570
+ };
571
+
572
+ /** progressId 为空取最近一次;logLines 为 0 返回全部已保存日志 */
573
+ const deployDetail = async ({ name, progressId, logLines = 0 } = {}) => {
574
+ const app = await getByName(name);
575
+ const where = { appName: app.name };
576
+ if (progressId) {
577
+ where.progressId = progressId;
578
+ }
579
+ let row = await models.appDeploy.findOne({ where, order: [['createdAt', 'DESC']] });
580
+ const memory = deployProgress.snapshot(app.name);
581
+ // 刚开始的部署可能还没落库
582
+ if (memory && (progressId ? memory.progressId === progressId : !row || row.progressId !== memory.progressId)) {
583
+ row = { appName: app.name, progressId: memory.progressId };
584
+ }
585
+ if (!row) {
586
+ httpError(404, 'deploy record not found');
587
+ }
588
+ return toDeployRecord(row, { withLog: true, logLines: Number(logLines) || 0 });
589
+ };
590
+
591
+ const lastDeployment = async name => {
592
+ const row = await models.appDeploy.findOne({ where: { appName: name }, attributes: { exclude: ['log', 'events'] }, order: [['createdAt', 'DESC']] });
593
+ return row ? toDeployRecord(row) : null;
594
+ };
595
+
596
+ /** 父进程重启后,内存里的部署进度已丢失:库里仍 running 的记录无法再结束,标记为中断 */
597
+ const markInterruptedDeploys = async () => {
598
+ const rows = await models.appDeploy.findAll({ where: { status: 'running' } });
599
+ for (const row of rows) {
600
+ if (deployProgress.active(row.appName)?.progressId === row.progressId) {
601
+ continue;
602
+ }
603
+ const at = new Date();
604
+ const message = 'deploy interrupted: app manager restarted';
605
+ await row.update({
606
+ status: 'failed',
607
+ message,
608
+ finishedAt: at,
609
+ events: (Array.isArray(row.events) ? row.events : []).concat({ progressId: row.progressId, step: row.step || 'package', status: 'error', detail: null, message, at: at.toISOString() }),
610
+ log: [row.log, `${at.toISOString()} ${message}`].filter(Boolean).join('\n')
611
+ });
612
+ }
613
+ return rows.length;
614
+ };
615
+
616
+ /** 就绪阶段收尾:失败时把进程错误日志末尾写进部署日志,便于定位启动失败 */
617
+ const settleDeployReady = async (app, progress, result) => {
618
+ if (result?.status === 'running') {
619
+ progress.report('ready', 'done');
620
+ return;
621
+ }
622
+ try {
623
+ const lines = await readLastLines(currentLogFile(app, 'err'), options.deployErrorLogTailLines, { timezone: options.logTimezone });
624
+ if (lines.length) {
625
+ progress.log(`---- ${app.name} err.log (last ${lines.length} lines) ----`);
626
+ lines.forEach(item => progress.log(item.content));
627
+ }
628
+ } catch (e) {
629
+ progress.log(`read err.log failed: ${e.message}`);
630
+ }
631
+ progress.report('ready', 'error', { message: result?.message || result?.status || null });
632
+ };
490
633
 
491
634
  /**
492
635
  * 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
@@ -840,7 +983,7 @@ module.exports = fp(async (fastify, options) => {
840
983
  const canResolveFile = ver => !!ver.fileId && typeof options.resolveVersionFile === 'function';
841
984
 
842
985
  /** 版本目录不在磁盘时按 fileId 取回 zip 解压并装依赖;再以数据库中的 SQL 覆盖目录内 .sql */
843
- const materializeArtifact = async (app, ver, { onProgress } = {}) => {
986
+ const materializeArtifact = async (app, ver, { onProgress, onLog } = {}) => {
844
987
  if (await hasArtifact(ver)) {
845
988
  onProgress && onProgress('reuse');
846
989
  } else {
@@ -860,7 +1003,8 @@ module.exports = fp(async (fastify, options) => {
860
1003
  sqlPath: options.sqlPath,
861
1004
  // 老版本可能没有 env.schema.json:照常还原目录,由启动前的环境变量校验给出提示
862
1005
  requireEnvSchema: false,
863
- onProgress
1006
+ onProgress,
1007
+ onLog
864
1008
  });
865
1009
  await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
866
1010
  } catch (e) {
@@ -901,20 +1045,27 @@ module.exports = fp(async (fastify, options) => {
901
1045
  return results;
902
1046
  };
903
1047
 
904
- /** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移 */
905
- const restoreArtifact = async ({ app, ver }) => {
1048
+ /** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移。progress 存在时记录到部署记录 */
1049
+ const restoreArtifact = async ({ app, ver, progress }) => {
906
1050
  if (!canResolveFile(ver)) {
907
1051
  fastify.log.warn({ app: app.name, version: ver.version }, 'current version artifact missing and cannot be restored without fileId');
1052
+ progress && progress.report('package', 'error', { message: 'current version artifact missing and cannot be restored without fileId' });
908
1053
  return { name: app.name, version: ver.version, action: 'skipped', reason: 'no fileId' };
909
1054
  }
910
1055
  try {
911
- await materializeArtifact(app, ver);
1056
+ progress && progress.report('package', 'running');
1057
+ await materializeArtifact(app, ver, {
1058
+ onProgress: progress ? detail => progress.report('package', 'running', { detail }) : undefined,
1059
+ onLog: progress ? progress.log : undefined
1060
+ });
912
1061
  await linkCurrent(app, ver.artifactPath);
1062
+ progress && progress.report('package', 'done');
913
1063
  fastify.log.info({ app: app.name, version: ver.version }, 'current version artifact restored from file');
914
- return { name: app.name, version: ver.version, action: 'restored' };
1064
+ return Object.assign({ name: app.name, version: ver.version, action: 'restored' }, progress ? { progressId: progress.progressId } : {});
915
1065
  } catch (e) {
916
1066
  fastify.log.warn({ err: e, app: app.name, version: ver.version }, 'restore current version artifact failed');
917
- return { name: app.name, version: ver.version, action: 'failed', reason: e.message };
1067
+ progress && progress.report('package', 'error', { message: e.message });
1068
+ return Object.assign({ name: app.name, version: ver.version, action: 'failed', reason: e.message }, progress ? { progressId: progress.progressId } : {});
918
1069
  }
919
1070
  };
920
1071
 
@@ -942,7 +1093,7 @@ module.exports = fp(async (fastify, options) => {
942
1093
  let step = 'package';
943
1094
  progress.report(step, 'running');
944
1095
  try {
945
- await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }) });
1096
+ await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }), onLog: progress.log });
946
1097
  } catch (e) {
947
1098
  progress.report(step, 'error', { message: e.message });
948
1099
  httpError(400, e.message);
@@ -962,15 +1113,15 @@ module.exports = fp(async (fastify, options) => {
962
1113
  }
963
1114
  progress.report(step, 'done');
964
1115
 
965
- await app.update({
966
- status: 'deploying',
967
- currentVersionId: String(ver.id),
968
- message: null
969
- });
1116
+ try {
1117
+ await app.update({
1118
+ status: 'deploying',
1119
+ currentVersionId: String(ver.id),
1120
+ message: null
1121
+ });
970
1122
 
971
- await linkCurrent(app, ver.artifactPath);
1123
+ await linkCurrent(app, ver.artifactPath);
972
1124
 
973
- try {
974
1125
  step = 'migrate';
975
1126
  if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
976
1127
  progress.report(step, 'running');
@@ -1027,7 +1178,13 @@ module.exports = fp(async (fastify, options) => {
1027
1178
  progress.report(step, 'running');
1028
1179
  scheduleStatusSync(app.id, {
1029
1180
  beforeTables: beforeForReady,
1030
- onSettled: result => (result?.status === 'running' ? progress.report('ready', 'done') : progress.report('ready', 'error', { message: result?.message || result?.status || null }))
1181
+ onSettled: result =>
1182
+ settleDeployReady(app, progress, result).catch(err => {
1183
+ fastify.log.warn({ err, app: app.name }, 'settle deploy ready failed');
1184
+ if (!progress.finished()) {
1185
+ progress.report('ready', 'error', { message: err.message });
1186
+ }
1187
+ })
1031
1188
  });
1032
1189
  await pruneVersionArtifacts(app).catch(err => fastify.log.warn({ err, app: app.name }, 'prune version artifacts failed'));
1033
1190
  return {
@@ -1300,6 +1457,11 @@ module.exports = fp(async (fastify, options) => {
1300
1457
  getLoadStore,
1301
1458
  getDeployProgress: () => deployProgress,
1302
1459
  waitDeployProgress: ({ name, progressId, timeoutMs }) => deployProgress.wait(name, progressId, { timeoutMs }),
1460
+ deployList,
1461
+ deployDetail,
1462
+ lastDeployment,
1463
+ markInterruptedDeploys,
1464
+ settleDeployReady,
1303
1465
  getEnvDeclaration,
1304
1466
  listInjectedEnvKeys,
1305
1467
  toPublicApp,
@@ -1,5 +1,6 @@
1
1
  const fp = require('fastify-plugin');
2
2
  const path = require('node:path');
3
+ const crypto = require('node:crypto');
3
4
  const fs = require('fs-extra');
4
5
  const { EventEmitter } = require('node:events');
5
6
  const pm2Util = require('../utils/pm2');
@@ -136,17 +137,28 @@ module.exports = fp(async (fastify, options) => {
136
137
  const { services } = fastify[options.name];
137
138
  const results = [];
138
139
  for (const item of missing) {
139
- const result = await services.app.restoreArtifact(item);
140
+ const { app, ver } = item;
141
+ const progress = services.app.getDeployProgress().begin(app.name, crypto.randomUUID(), { versionId: String(ver.id), version: ver.version, source: 'restore' });
142
+ const result = await services.app.restoreArtifact(Object.assign({}, item, { progress }));
140
143
  results.push(result);
141
- const { app } = item;
142
144
  const wasActive = app.status === 'running' || app.status === 'deploying';
143
145
  try {
144
146
  const synced = await services.app.syncStatus({ name: app.name, recoverIfMissing: result.action === 'restored' && wasActive });
145
147
  if (result.action !== 'restored' && wasActive && synced?.status !== 'running') {
146
148
  await app.update({ status: 'error', message: `current version artifact missing: ${result.reason}` });
147
149
  }
150
+ if (!progress.finished()) {
151
+ if (!wasActive) {
152
+ progress.report('ready', 'skipped');
153
+ } else {
154
+ await services.app.settleDeployReady(app, progress, synced);
155
+ }
156
+ }
148
157
  } catch (e) {
149
158
  fastify.log.warn({ err: e, app: app.name }, 'sync status after artifact restore failed');
159
+ if (!progress.finished()) {
160
+ progress.report('ready', 'error', { message: e.message });
161
+ }
150
162
  }
151
163
  }
152
164
  return results;
@@ -154,6 +166,7 @@ module.exports = fp(async (fastify, options) => {
154
166
 
155
167
  const recoverOnStartup = async () => {
156
168
  const { services } = fastify[options.name];
169
+ await services.app.markInterruptedDeploys().catch(err => fastify.log.warn({ err }, 'mark interrupted deploys failed'));
157
170
  const missing = await services.app.listMissingArtifacts();
158
171
  // 两路并行:部署目录完好的应用不必等其它应用重装依赖
159
172
  const [reconciled, restored] = await Promise.all([
@@ -3,37 +3,125 @@ const { EventEmitter } = require('node:events');
3
3
  /** 部署阶段,按顺序推进:准备部署包 → 校验环境变量 → SQL 迁移 → 启动进程 → 等待就绪 */
4
4
  const DEPLOY_STEPS = ['package', 'env', 'migrate', 'start', 'ready'];
5
5
 
6
+ const isFinished = event => event.status === 'error' || (event.step === 'ready' && event.status !== 'running');
7
+
8
+ const toPublicState = state => ({
9
+ name: state.name,
10
+ progressId: state.progressId,
11
+ versionId: state.versionId,
12
+ version: state.version,
13
+ source: state.source,
14
+ status: state.status,
15
+ step: state.step,
16
+ message: state.message,
17
+ startedAt: state.startedAt,
18
+ finishedAt: state.finishedAt,
19
+ events: state.events.slice()
20
+ });
21
+
6
22
  /**
7
23
  * 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
8
24
  * 事件:{ progressId, step, status: 'running' | 'done' | 'skipped' | 'error', detail?, message?, at }
25
+ * persist(state):每次状态变化后按 progressId 串行调用,state 含 logs(日志行数组);日志变化合并 flushDelayMs 再写
9
26
  */
10
- const createDeployProgress = () => {
27
+ const createDeployProgress = ({ persist = null, logMaxLines = 2000, flushDelayMs = 1000, onPersistError = null } = {}) => {
11
28
  const emitter = new EventEmitter();
12
29
  emitter.setMaxListeners(0);
13
30
  const latest = new Map();
14
31
 
15
- const begin = (name, progressId, { versionId = null, version = null } = {}) => {
16
- const state = { progressId, versionId, version, startedAt: new Date().toISOString(), events: [] };
32
+ const begin = (name, progressId, { versionId = null, version = null, source = 'deploy' } = {}) => {
33
+ const state = {
34
+ name,
35
+ progressId,
36
+ versionId,
37
+ version,
38
+ source,
39
+ status: 'running',
40
+ step: null,
41
+ message: null,
42
+ startedAt: new Date().toISOString(),
43
+ finishedAt: null,
44
+ events: [],
45
+ logs: []
46
+ };
17
47
  latest.set(name, state);
48
+
49
+ let chain = Promise.resolve();
50
+ let timer = null;
51
+ const flush = () => {
52
+ clearTimeout(timer);
53
+ timer = null;
54
+ if (!persist) {
55
+ return chain;
56
+ }
57
+ const data = Object.assign(toPublicState(state), { logs: state.logs.slice() });
58
+ chain = chain.then(() => persist(data)).catch(err => onPersistError && onPersistError(err, data));
59
+ return chain;
60
+ };
61
+ const scheduleFlush = () => {
62
+ if (timer || !persist) {
63
+ return;
64
+ }
65
+ timer = setTimeout(flush, flushDelayMs);
66
+ timer.unref && timer.unref();
67
+ };
68
+
69
+ const log = text => {
70
+ if (text === undefined || text === null) {
71
+ return;
72
+ }
73
+ const at = new Date().toISOString();
74
+ String(text)
75
+ .split(/\r?\n/)
76
+ .filter(line => line.trim())
77
+ .forEach(line => {
78
+ state.logs.push(`${at} ${line}`);
79
+ emitter.emit(`deploy-log:${name}`, { progressId, line: `${at} ${line}` });
80
+ });
81
+ if (state.logs.length > logMaxLines) {
82
+ state.logs.splice(0, state.logs.length - logMaxLines);
83
+ }
84
+ scheduleFlush();
85
+ };
86
+
18
87
  const report = (step, status, { detail = null, message = null } = {}) => {
19
88
  const event = { progressId, step, status, detail, message, at: new Date().toISOString() };
20
89
  state.events.push(event);
90
+ state.step = step;
91
+ log(`[${step}] ${status}${detail ? ` ${detail}` : ''}${message ? `: ${message}` : ''}`);
92
+ if (state.status === 'running' && isFinished(event)) {
93
+ state.status = event.status === 'error' ? 'failed' : 'success';
94
+ state.message = message;
95
+ state.finishedAt = event.at;
96
+ }
21
97
  emitter.emit(`deploy:${name}`, event);
98
+ flush();
22
99
  return event;
23
100
  };
24
- return { progressId, report };
101
+
102
+ const finished = () => state.status !== 'running';
103
+
104
+ flush();
105
+ return { progressId, report, log, finished, flush };
25
106
  };
26
107
 
27
- const snapshot = name => latest.get(name) || null;
108
+ const snapshot = name => {
109
+ const state = latest.get(name);
110
+ return state ? toPublicState(state) : null;
111
+ };
28
112
 
29
- const clear = name => latest.delete(name);
113
+ /** 当前进程内某次部署的日志行(含时间戳前缀) */
114
+ const logsOf = (name, progressId) => {
115
+ const state = latest.get(name);
116
+ return state && state.progressId === progressId ? state.logs.slice() : null;
117
+ };
30
118
 
31
- const isFinished = event => event.status === 'error' || (event.step === 'ready' && event.status !== 'running');
119
+ const clear = name => latest.delete(name);
32
120
 
33
121
  /** 尚未结束的最近一次部署;准备部署包阶段应用仍是旧状态与旧版本,需靠它判断哪个版本正在部署 */
34
122
  const active = name => {
35
123
  const state = latest.get(name);
36
- if (!state || state.events.some(isFinished)) {
124
+ if (!state || state.status !== 'running') {
37
125
  return null;
38
126
  }
39
127
  const last = state.events[state.events.length - 1];
@@ -41,6 +129,7 @@ const createDeployProgress = () => {
41
129
  progressId: state.progressId,
42
130
  versionId: state.versionId,
43
131
  version: state.version,
132
+ source: state.source,
44
133
  startedAt: state.startedAt,
45
134
  step: last ? last.step : null,
46
135
  detail: last ? last.detail : null
@@ -74,10 +163,11 @@ const createDeployProgress = () => {
74
163
  timer = setTimeout(() => finish(true), timeoutMs);
75
164
  });
76
165
 
77
- return { emitter, begin, snapshot, active, clear, wait };
166
+ return { emitter, begin, snapshot, logsOf, active, clear, wait };
78
167
  };
79
168
 
80
169
  module.exports = {
81
170
  DEPLOY_STEPS,
171
+ isFinished,
82
172
  createDeployProgress
83
173
  };
@@ -31,7 +31,10 @@ const TEXTUAL_TYPES = /text\/|json|xml|svg/i;
31
31
  // 前端脚本已按注入的 runtimePublicUrl / runtimeApiUrl 自行拼前缀(如 axios baseURL + '/api/...'),再改写会得到 /app/x/app/x/api
32
32
  const SCRIPT_TYPES = /javascript|ecmascript/i;
33
33
 
34
- const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '') && !SCRIPT_TYPES.test(contentType || '');
34
+ // SSE 是不结束的流,读完再改写会让客户端一直收不到响应
35
+ const STREAM_TYPES = /text\/event-stream/i;
36
+
37
+ const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '') && !SCRIPT_TYPES.test(contentType || '') && !STREAM_TYPES.test(contentType || '');
35
38
 
36
39
  const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account']) => {
37
40
  if (!body || !mountPrefix) {
@@ -4,7 +4,7 @@ const { spawn } = require('node:child_process');
4
4
  const { extractZipSafe, resolvePackageRoot } = require('./zip');
5
5
  const { validatePackageRoot, listSqlFiles } = require('./validatePackage');
6
6
 
7
- const runNpmInstall = (cwd, timeoutMs) =>
7
+ const runNpmInstall = (cwd, timeoutMs, onLog) =>
8
8
  new Promise((resolve, reject) => {
9
9
  const child = spawn('npm', ['install', '--production', '--no-fund', '--no-audit'], {
10
10
  cwd,
@@ -12,8 +12,12 @@ const runNpmInstall = (cwd, timeoutMs) =>
12
12
  stdio: ['ignore', 'pipe', 'pipe']
13
13
  });
14
14
  let stderr = '';
15
+ child.stdout?.on('data', chunk => {
16
+ onLog && onLog(chunk.toString());
17
+ });
15
18
  child.stderr.on('data', chunk => {
16
19
  stderr += chunk.toString();
20
+ onLog && onLog(chunk.toString());
17
21
  });
18
22
  const timer = setTimeout(() => {
19
23
  child.kill('SIGKILL');
@@ -34,7 +38,7 @@ const runNpmInstall = (cwd, timeoutMs) =>
34
38
  });
35
39
 
36
40
  /** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
37
- const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, requireEnvSchema = true, onProgress }) => {
41
+ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, requireEnvSchema = true, onProgress, onLog }) => {
38
42
  await fs.emptyDir(artifactPath);
39
43
  onProgress && onProgress('extract');
40
44
  const extractDir = path.join(artifactPath, '_extract');
@@ -64,7 +68,7 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
64
68
  const serverDir = path.join(artifactPath, 'server');
65
69
  if (install) {
66
70
  onProgress && onProgress('install');
67
- await runNpmInstall(serverDir, npmInstallTimeoutMs);
71
+ await runNpmInstall(serverDir, npmInstallTimeoutMs, onLog);
68
72
  }
69
73
 
70
74
  const sqlDir = path.join(serverDir, sqlPath || 'sql');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {