@kne/fastify-app-manager 0.1.9 → 0.1.11

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
@@ -22,7 +22,7 @@ npm i --save @kne/fastify-app-manager
22
22
 
23
23
  适用场景:内网/堡垒机上的业务应用集中托管;不负责多机编排、硬磁盘配额或 WebSocket 升级。
24
24
 
25
- > **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
25
+ > **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底(JS 脚本不改写,避免与 runtimeApiUrl 重复拼前缀)。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
26
26
  >
27
27
  > **存储隔离**:同时注入 `window.__LOCAL_STORAGE_PREFIX="<应用名>"`,`@kne/local-storage`(≥0.1.2,`@kne/token-storage` 底层依赖)读到后自动给 key 加 `<应用名>:` 前缀,避免同源挂载的子应用与宿主、其它子应用互相覆盖登录 token 等本地存储。
28
28
 
@@ -51,18 +51,20 @@ Client / Admin UI
51
51
  | Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
52
52
  | 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
53
53
  | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 由 PM2 落 `appsRoot/{name}/logs`,插件按大小/按天切分归档 |
54
- | Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
54
+ | Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**并行:目录完好的托管应用立即 `syncAllStatuses`,缺当前版本目录的应用按版本 `fileId` 逐个补回后再单独拉起,最后清理非当前版本目录(均不阻塞父应用 ready) |
55
55
 
56
56
  ##### 部署时序
57
57
 
58
58
  ```
59
- uploadVersion (zip)
59
+ uploadVersion (zip / fileId)
60
60
  ↓
61
- 校验包格式 → 解压 → npm install --production(server)
61
+ 校验包格式 → SQL 写入版本记录;multipart 解压并 npm install 后保留目录,fileId 不保留目录
62
62
  ↓
63
63
  deploy(versionId)
64
64
  ↓
65
- status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start
65
+ 目录缺失则按 fileId 解压 + npm install → 数据库 SQL 覆盖写回
66
+ ↓
67
+ status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
66
68
  ↓(立即返回)
67
69
  后台 healthCheck → running / error
68
70
  ```
@@ -74,7 +76,7 @@ deploy(versionId)
74
76
  | 概念 | 说明 |
75
77
  |------|------|
76
78
  | **App** | 元信息、分配端口、env、pm2Config、状态机;落盘目录 `appsRoot/{name}` |
77
- | **AppVersion** | 一次 zip 上传产物;`artifactPath` 存解压后的包;部署时按 id 或 version 选择 |
79
+ | **AppVersion** | 一次 zip 上传产物;`artifactPath` 为解压目录(有 `fileId` 的版本只在当前部署时常驻磁盘),SQL 脚本存于 `migrationFiles`;部署时按 id 或 version 选择 |
78
80
  | **端口池** | 默认 `4000–7999`,创建时分配未占用端口;`PORT` 强制写入子进程 env |
79
81
 
80
82
  ##### 状态机
@@ -106,7 +108,7 @@ deploy(versionId)
106
108
  | `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
107
109
  | 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
108
110
  | 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
109
- | 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated`) |
111
+ | 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
110
112
  | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
111
113
  | 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
112
114
 
@@ -120,6 +122,7 @@ deploy(versionId)
120
122
  |------|------|
121
123
  | 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
122
124
  | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
125
+ | 部署进度 | `deploy` 按「准备部署包 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
123
126
  | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
124
127
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
125
128
  | 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
@@ -191,6 +194,10 @@ await fastify.listen({ port: 3000 });
191
194
  | `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
192
195
  | `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
193
196
  | `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
197
+ | `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
198
+ | `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
199
+ | `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
200
+ | `resolveVersionFile` | function | 否 | `null` | `async ({ fileId, name, version, request }) => Buffer`,版本上传传 `fileId`(而非 multipart 文件)时调用,返回 zip 内容;未配置时传 `fileId` 返回 400。版本记录保存 `fileId`;这类版本只在当前部署时常驻磁盘,部署非当前版本或启动时当前版本目录缺失会再次调用以重建 |
194
201
  | `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
195
202
  | `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
196
203
  | `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
@@ -253,11 +260,12 @@ await fastify.listen({ port: 3000 });
253
260
  | description | string | 否 | - | 描述 |
254
261
  | category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
255
262
  | isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
263
+ | entries | array | 否 | `[]` | 应用入口 `[{ label, path }]`,写入 `options.entries`;`path` 相对应用根(缺前导 `/` 自动补),缺 `label` 或 `path` 的项丢弃,同 `path` 只保留第一项 |
256
264
  | env | object | 否 | `{}` | 应用自有环境变量 |
257
265
  | pm2Config | object | 否 | `{}` | PM2 覆盖项 |
258
266
  | options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
259
267
 
260
- 返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
268
+ 返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`entries`(每项附 `url` = 挂载前缀 + `path`)、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
261
269
 
262
270
  ##### POST `{prefix}/app/save`
263
271
 
@@ -269,6 +277,7 @@ await fastify.listen({ port: 3000 });
269
277
  | label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
270
278
  | category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
271
279
  | isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
280
+ | entries | array / null | 否 | 整体替换 `options.entries`(规则同创建),不影响 `options` 其它键;传 `[]` / `null` 清空入口 |
272
281
 
273
282
  ##### POST `{prefix}/app/save-env`
274
283
 
@@ -318,16 +327,17 @@ await fastify.listen({ port: 3000 });
318
327
 
319
328
  ##### POST `{prefix}/app/version/upload`
320
329
 
321
- `multipart/form-data` 上传 zip。
330
+ `multipart/form-data` 上传 zip;或 JSON 传 `fileId`,由 `resolveVersionFile` 读取宿主文件(如文件管理中已上传的 zip)。
322
331
 
323
332
  | 字段名 | 类型 | 必填 | 说明 |
324
333
  |--------|------|------|------|
325
- | file | file | 是 | zip 二进制(字段名任意 file part) |
334
+ | file | file | 否 | zip 二进制(字段名任意 file part);与 `fileId` 二选一,同时存在时以 `file` 为准 |
335
+ | fileId | string | 否 | 宿主文件 id,交给 `resolveVersionFile` 读取;与 `file` 二选一 |
326
336
  | name | string | 是 | 应用 slug(须已 create) |
327
337
  | version | string | 是 | 版本号;同应用内唯一 |
328
338
  | label | string | 否 | 版本说明 |
329
339
 
330
- 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration` 等)。包在临时目录完成解压、校验与 `npm install` 后才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
340
+ 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容写入版本记录(数据库)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
331
341
 
332
342
  ##### GET `{prefix}/app/version/list`
333
343
 
@@ -356,9 +366,20 @@ await fastify.listen({ port: 3000 });
356
366
 
357
367
  `hasMigration` 为 true 时额外附带 `sqlFiles` 列表。
358
368
 
369
+ ##### GET `{prefix}/app/version/download`
370
+
371
+ 下载版本部署包,文件名 `{name}-{version}.zip`。
372
+
373
+ | 参数名 | 类型 | 必填 | 说明 |
374
+ |--------|------|------|------|
375
+ | name | string | 是 | 应用 slug |
376
+ | versionId | string | 是 | 版本 id;不属于该应用返回 404 |
377
+
378
+ 版本有 `fileId` 且配置了 `resolveVersionFile` 时返回原始 zip;否则(或读取失败时)由版本目录流式打包,排除部署时生成的 `server/node_modules` 与 `server/build`。版本目录也不存在时返回 404。
379
+
359
380
  ##### POST `{prefix}/app/deploy`
360
381
 
361
- 选择版本部署。立即返回 `deploying`,后台做健康检查。
382
+ 选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
362
383
 
363
384
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
364
385
  |--------|------|------|--------|------|
@@ -367,6 +388,7 @@ await fastify.listen({ port: 3000 });
367
388
  | version | string | 否 | - | 版本号 |
368
389
  | runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
369
390
  | migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
391
+ | progressId | string | 否 | 随机 UUID | 进度标识;前端先用它连 `GET .../app/deploy/stream` 再发起部署,可收到完整进度 |
370
392
 
371
393
  `migrations[].action`:
372
394
 
@@ -383,15 +405,47 @@ await fastify.listen({ port: 3000 });
383
405
  "name": "demo",
384
406
  "status": "deploying",
385
407
  "versionId": "354173577374729216",
408
+ "progressId": "c0a8...",
386
409
  "port": 4001,
387
410
  "pathUrl": "/app/demo/",
388
411
  "domain": null
389
412
  }
390
413
  ```
391
414
 
415
+ ##### GET `{prefix}/app/deploy/stream`
416
+
417
+ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署中匹配 `progressId` 的已有事件,便于断线重连),之后每个阶段变化推送 `progress`。每个应用只在内存中保留最近一次部署的事件。
418
+
419
+ | 参数名 | 类型 | 必填 | 说明 |
420
+ |--------|------|------|------|
421
+ | name | string | 是 | 应用 slug |
422
+ | progressId | string | 否 | 只推送该次部署的事件;不传则推送该应用所有部署事件 |
423
+
424
+ 阶段(`steps`,依次推进):
425
+
426
+ | step | 说明 | `detail`(`status=running` 时) |
427
+ |------|------|------|
428
+ | `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
429
+ | `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
430
+ | `start` | PM2 拉起进程 | - |
431
+ | `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
432
+
433
+ `status`:`running` / `done` / `skipped` / `error`。任一阶段 `error` 即部署失败;`ready` 为 `done` / `error` 时部署结束,前端可断开连接。
434
+
435
+ ```text
436
+ event: snapshot
437
+ data: {"steps":["package","migrate","start","ready"],"events":[]}
438
+
439
+ event: progress
440
+ data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
441
+
442
+ event: progress
443
+ data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message":null,"at":"2026-10-10T10:00:05.000Z"}
444
+ ```
445
+
392
446
  ##### 版本迁移脚本管理
393
447
 
394
- 管理某版本 `server/{migrationPath}` 下的 `.sql` 文件,执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
448
+ 管理某版本的 `.sql` 文件。脚本内容存于版本记录(数据库),版本目录不在磁盘时也可查看和编辑;目录在磁盘上(当前版本)时同步写入 `server/{migrationPath}`,部署时以数据库为准覆盖。执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
395
449
 
396
450
  | 方法 | 路径 | 参数 | 说明 |
397
451
  |------|------|------|------|
@@ -426,7 +480,11 @@ await fastify.listen({ port: 3000 });
426
480
  "description": "d",
427
481
  "category": { "code": "tools", "name": "工具" },
428
482
  "isPublic": true,
429
- "pathUrl": "/app/demo/"
483
+ "pathUrl": "/app/demo/",
484
+ "entries": [
485
+ { "label": "PC", "path": "/", "url": "/app/demo/" },
486
+ { "label": "移动端", "path": "/mobile", "url": "/app/demo/mobile" }
487
+ ]
430
488
  }
431
489
  ],
432
490
  "totalCount": 1
@@ -618,12 +676,21 @@ data: {"name":"demo","ts":1791456608533,...}
618
676
  | `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
619
677
  | `list({ filter, perPage, currentPage })` | 列表 |
620
678
  | `detail({ name })` | 详情(脱敏) |
621
- | `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
679
+ | `uploadVersion({ name, version, label, zipBuffer, fileId? })` | 上传版本;`fileId` 记入版本记录供后续重建 |
680
+ | `resolveVersionPackage({ name, versionId })` | 下载用:返回 `{ fileName, buffer }`(原始 zip)或 `{ fileName, artifactPath, files }`(版本目录内待打包的相对路径) |
681
+ | `getMigrationFiles(ver)` | 读取版本 SQL `[{ name, content, updatedAt }]`;旧版本记录为空时从版本目录回填 |
682
+ | `saveMigrationFiles(ver, files)` | 写入版本 SQL 并同步 `hasMigration`,版本目录在磁盘上时同步覆盖 `.sql` |
683
+ | `pruneVersionArtifacts(app)` | 删除该应用非当前、且有 `fileId` 的版本目录(删前回填 SQL),返回被删版本号 |
684
+ | `pruneAllVersionArtifacts()` | 对所有应用执行 `pruneVersionArtifacts` |
685
+ | `restoreMissingArtifacts()` | 当前版本目录(`artifactPath/server`)缺失时,按版本 `fileId` 经 `resolveVersionFile` 取回 zip 重建并恢复 `current` 链接(不跑 SQL 迁移);返回 `[{ name, version, action: 'restored' \| 'skipped' \| 'failed', reason? }]`,无 `fileId` 的记 `skipped` |
622
686
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
623
- | `deploy({ name, versionId, version, runMigration, migrations? })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
687
+ | `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
688
+ | `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
624
689
  | `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
625
690
  | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
626
- | `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
691
+ | `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
692
+ | `listMissingArtifacts()` | 列出当前版本目录(`artifactPath/server`)缺失的应用:`[{ app, ver }]` |
693
+ | `restoreArtifact({ app, ver })` | 补回单个应用的当前版本目录,返回同 `restoreMissingArtifacts` 的单项 |
627
694
  | `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
628
695
  | `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
629
696
  | `logFiles({ name })` | 列出日志文件 |
@@ -663,11 +730,13 @@ data: {"name":"demo","ts":1791456608533,...}
663
730
 
664
731
  | 方法签名 | 说明 |
665
732
  |----------|------|
666
- | `onReady()` | 连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步 `reconcile`、一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
733
+ | `onReady()` | `ensureSchema` 后连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步执行 `recoverOnStartup`,以及一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
734
+ | `ensureSchema()` | 宿主 `sync()` 不给已有表加列:为老库补齐后续新增的可空列(当前为 `appVersion.fileId`、`appVersion.migrationFiles`),可重复执行 |
735
+ | `recoverOnStartup()` | 两路并行:部署目录完好的应用立即 `reconcile`;目录缺失的应用逐个 `restoreArtifact`(需重装依赖,较慢),每补完一个即单独 `syncStatus` 并按需拉起;补不回且原为 `running`/`deploying` 的标记 `error`;两路结束后 `pruneAllVersionArtifacts`。返回 `{ reconciled, restored, pruned }` |
667
736
  | `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
668
737
  | `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
669
738
  | `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
670
- | `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
739
+ | `reconcile({ excludeIds? })` | 调用 `app.syncAllStatuses({ recoverIfMissing: true, excludeIds })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
671
740
 
672
741
  #### 数据模型
673
742
 
@@ -685,7 +754,7 @@ data: {"name":"demo","ts":1791456608533,...}
685
754
  | description | string / null | 描述 |
686
755
  | env | object | 自有环境变量 |
687
756
  | pm2Config | object | PM2 覆盖 |
688
- | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开) |
757
+ | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开),`entries` 为应用入口 `[{ label, path }]` |
689
758
  | port | number | 分配端口 |
690
759
  | status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
691
760
  | currentVersionId | string / null | 当前部署版本 id |
@@ -706,6 +775,8 @@ data: {"name":"demo","ts":1791456608533,...}
706
775
  | artifactPath | string | 解压目录 |
707
776
  | hasMigration | boolean | 是否存在 sql 文件 |
708
777
  | migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
778
+ | fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
779
+ | migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
709
780
 
710
781
  唯一约束:`(app_name, version)`。
711
782
 
@@ -775,7 +846,7 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
775
846
  ##### 环境合并顺序
776
847
 
777
848
  ```
778
- pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
849
+ pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制) → resolveSystemEnv 返回值(覆盖)
779
850
  ```
780
851
 
781
852
  API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
package/index.js CHANGED
@@ -36,6 +36,14 @@ module.exports = fp(
36
36
  loadSampleIntervalMs: 5000,
37
37
  loadHistoryMinutes: 10,
38
38
  passthroughEnvKeys: [],
39
+ /** 宿主维护的系统变量:接口不返回、不可外部修改,resolveSystemEnv 返回的同名键会持久化 */
40
+ systemEnvKeys: [],
41
+ /** async ({ app, version, serverDir }) => env | null,每次启动进程前调用,返回值覆盖应用变量 */
42
+ resolveSystemEnv: null,
43
+ /** async ({ app }) => void,应用删除成功后调用,app 为删除前快照;报错只记日志 */
44
+ onAppRemoved: null,
45
+ /** async ({ fileId, name, version, request }) => Buffer,版本上传传 fileId 时读取宿主文件 */
46
+ resolveVersionFile: null,
39
47
  secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
40
48
  sqlPath: 'sql',
41
49
  migrateBeforeStart: false,
@@ -1,7 +1,9 @@
1
1
  const fp = require('fastify-plugin');
2
+ const path = require('node:path');
2
3
  const fs = require('fs-extra');
3
4
  const yazl = require('yazl');
4
5
  const { createSseReply } = require('../utils/logStream');
6
+ const { DEPLOY_STEPS } = require('../utils/deployProgress');
5
7
  const { formatInTz } = require('../utils/logTime');
6
8
 
7
9
  module.exports = fp(async (fastify, options) => {
@@ -26,6 +28,10 @@ module.exports = fp(async (fastify, options) => {
26
28
  description: { type: 'string' },
27
29
  category: { type: ['object', 'string', 'null'] },
28
30
  isPublic: { type: 'boolean' },
31
+ entries: {
32
+ type: ['array', 'null'],
33
+ items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
34
+ },
29
35
  env: { type: 'object' },
30
36
  pm2Config: { type: 'object' },
31
37
  options: { type: 'object' }
@@ -53,6 +59,10 @@ module.exports = fp(async (fastify, options) => {
53
59
  description: { type: 'string' },
54
60
  category: { type: ['object', 'string', 'null'] },
55
61
  isPublic: { type: 'boolean' },
62
+ entries: {
63
+ type: ['array', 'null'],
64
+ items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
65
+ },
56
66
  env: { type: 'object' },
57
67
  pm2Config: { type: 'object' },
58
68
  options: { type: 'object' }
@@ -109,29 +119,36 @@ module.exports = fp(async (fastify, options) => {
109
119
  {
110
120
  onRequest: auth(),
111
121
  schema: {
112
- summary: '上传应用版本 zip'
122
+ summary: '上传应用版本 zip(multipart 传 file,或 JSON 传宿主文件 fileId)'
113
123
  }
114
124
  },
115
125
  async request => {
116
- const parts = request.parts();
117
- let name;
118
- let version;
119
- let label;
126
+ let { name, version, label, fileId } = request.isMultipart() ? {} : request.body || {};
120
127
  let zipBuffer;
121
- for await (const part of parts) {
122
- if (part.type === 'file') {
123
- zipBuffer = await part.toBuffer();
124
- } else {
125
- const val = part.value;
126
- if (part.fieldname === 'name') name = val;
127
- if (part.fieldname === 'version') version = val;
128
- if (part.fieldname === 'label') label = val;
128
+ if (request.isMultipart()) {
129
+ for await (const part of request.parts()) {
130
+ if (part.type === 'file') {
131
+ zipBuffer = await part.toBuffer();
132
+ } else {
133
+ const val = part.value;
134
+ if (part.fieldname === 'name') name = val;
135
+ if (part.fieldname === 'version') version = val;
136
+ if (part.fieldname === 'label') label = val;
137
+ if (part.fieldname === 'fileId') fileId = val;
138
+ }
129
139
  }
130
140
  }
131
- if (!zipBuffer) {
132
- throw fastify.httpErrors.badRequest('file is required');
141
+ if (zipBuffer) {
142
+ return services.app.uploadVersion({ name, version, label, zipBuffer });
143
+ }
144
+ if (!fileId) {
145
+ throw fastify.httpErrors.badRequest('file or fileId is required');
133
146
  }
134
- return services.app.uploadVersion({ name, version, label, zipBuffer });
147
+ if (typeof options.resolveVersionFile !== 'function') {
148
+ throw fastify.httpErrors.badRequest('fileId upload is not supported: resolveVersionFile is not configured');
149
+ }
150
+ zipBuffer = await options.resolveVersionFile({ fileId, name, version, request });
151
+ return services.app.uploadVersion({ name, version, label, zipBuffer, fileId });
135
152
  }
136
153
  );
137
154
 
@@ -155,6 +172,39 @@ module.exports = fp(async (fastify, options) => {
155
172
  async request => services.app.listVersions(request.query)
156
173
  );
157
174
 
175
+ fastify.get(
176
+ `${options.prefix}/app/version/download`,
177
+ {
178
+ onRequest: auth(),
179
+ schema: {
180
+ summary: '下载版本部署包(有 fileId 返回原始 zip,否则由版本目录打包)',
181
+ query: {
182
+ type: 'object',
183
+ required: ['name', 'versionId'],
184
+ properties: {
185
+ name: { type: 'string' },
186
+ versionId: { type: 'string' }
187
+ }
188
+ }
189
+ }
190
+ },
191
+ async (request, reply) => {
192
+ const { fileName, buffer, artifactPath, files } = await services.app.resolveVersionPackage(request.query);
193
+ reply.header('Content-Type', 'application/zip');
194
+ reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
195
+ if (buffer) {
196
+ reply.header('Content-Length', buffer.length);
197
+ return reply.send(buffer);
198
+ }
199
+ const zipFile = new yazl.ZipFile();
200
+ for (const file of files) {
201
+ zipFile.addFile(path.join(artifactPath, file), file);
202
+ }
203
+ zipFile.end();
204
+ return reply.send(zipFile.outputStream);
205
+ }
206
+ );
207
+
158
208
  fastify.post(
159
209
  `${options.prefix}/app/deploy`,
160
210
  {
@@ -169,6 +219,7 @@ module.exports = fp(async (fastify, options) => {
169
219
  versionId: { type: 'string' },
170
220
  version: { type: 'string' },
171
221
  runMigration: { type: 'boolean', default: true },
222
+ progressId: { type: 'string', description: '进度标识;先用它订阅 deploy/stream 再发起部署' },
172
223
  migrations: {
173
224
  type: 'array',
174
225
  description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
@@ -188,6 +239,44 @@ module.exports = fp(async (fastify, options) => {
188
239
  async request => services.app.deploy(request.body)
189
240
  );
190
241
 
242
+ fastify.get(
243
+ `${options.prefix}/app/deploy/stream`,
244
+ {
245
+ onRequest: auth(),
246
+ schema: {
247
+ summary: 'SSE 部署进度',
248
+ query: {
249
+ type: 'object',
250
+ required: ['name'],
251
+ properties: {
252
+ name: { type: 'string' },
253
+ progressId: { type: 'string' }
254
+ }
255
+ }
256
+ }
257
+ },
258
+ async (request, reply) => {
259
+ const { name, progressId } = request.query;
260
+ await services.app.detail({ name });
261
+ const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
262
+ const progress = services.app.getDeployProgress();
263
+ const matches = event => !progressId || event.progressId === progressId;
264
+ const snapshot = progress.snapshot(name);
265
+ sse.writeEvent('snapshot', {
266
+ steps: DEPLOY_STEPS,
267
+ events: snapshot ? snapshot.events.filter(matches) : []
268
+ });
269
+
270
+ const onProgress = event => matches(event) && sse.writeEvent('progress', event);
271
+ const eventName = `deploy:${name}`;
272
+ progress.emitter.on(eventName, onProgress);
273
+ request.raw.on('close', () => {
274
+ progress.emitter.off(eventName, onProgress);
275
+ sse.close();
276
+ });
277
+ }
278
+ );
279
+
191
280
  fastify.get(
192
281
  `${options.prefix}/app/list`,
193
282
  {
@@ -1,6 +1,6 @@
1
1
  const fp = require('fastify-plugin');
2
2
  const { Readable } = require('node:stream');
3
- const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
3
+ const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
4
4
  const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
5
5
 
6
6
  module.exports = fp(async (fastify, options) => {
@@ -41,13 +41,16 @@ module.exports = fp(async (fastify, options) => {
41
41
  reply.send(error);
42
42
  },
43
43
  rewriteRequestHeaders: (req, headers) => {
44
- const next = { ...headers };
44
+ const next = stripPrefix ? restoreConditionalHeaders(headers) : { ...headers };
45
45
  delete next['content-length'];
46
46
  return next;
47
47
  },
48
48
  rewriteHeaders: headers => {
49
49
  const next = { ...headers };
50
50
  if (stripPrefix) {
51
+ if (next.etag) {
52
+ next.etag = tagEtag(next.etag);
53
+ }
51
54
  if (next.location) {
52
55
  next.location = rewriteLocation(next.location, mountPrefix);
53
56
  }
@@ -33,6 +33,16 @@ module.exports = ({ DataTypes }) => {
33
33
  allowNull: true,
34
34
  defaultValue: 'sql',
35
35
  comment: '相对 server 的 sql 目录'
36
+ },
37
+ fileId: {
38
+ type: DataTypes.STRING,
39
+ allowNull: true,
40
+ comment: '宿主文件 id;版本目录缺失时经 resolveVersionFile 重建'
41
+ },
42
+ migrationFiles: {
43
+ type: DataTypes.JSON,
44
+ allowNull: true,
45
+ comment: 'SQL 迁移文件 [{ name, content, updatedAt }];版本目录可随时删除,部署时写回磁盘'
36
46
  }
37
47
  },
38
48
  options: {