@kne/fastify-app-manager 0.1.9 → 0.1.10

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
 
@@ -106,7 +106,7 @@ deploy(versionId)
106
106
  | `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
107
107
  | 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
108
108
  | 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
109
- | 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated`) |
109
+ | 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
110
110
  | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
111
111
  | 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
112
112
 
@@ -191,6 +191,9 @@ await fastify.listen({ port: 3000 });
191
191
  | `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
192
192
  | `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
193
193
  | `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
194
+ | `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
195
+ | `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
196
+ | `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
194
197
  | `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
195
198
  | `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
196
199
  | `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
@@ -253,11 +256,12 @@ await fastify.listen({ port: 3000 });
253
256
  | description | string | 否 | - | 描述 |
254
257
  | category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
255
258
  | isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
259
+ | entries | array | 否 | `[]` | 应用入口 `[{ label, path }]`,写入 `options.entries`;`path` 相对应用根(缺前导 `/` 自动补),缺 `label` 或 `path` 的项丢弃,同 `path` 只保留第一项 |
256
260
  | env | object | 否 | `{}` | 应用自有环境变量 |
257
261
  | pm2Config | object | 否 | `{}` | PM2 覆盖项 |
258
262
  | options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
259
263
 
260
- 返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
264
+ 返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`entries`(每项附 `url` = 挂载前缀 + `path`)、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
261
265
 
262
266
  ##### POST `{prefix}/app/save`
263
267
 
@@ -269,6 +273,7 @@ await fastify.listen({ port: 3000 });
269
273
  | label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
270
274
  | category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
271
275
  | isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
276
+ | entries | array / null | 否 | 整体替换 `options.entries`(规则同创建),不影响 `options` 其它键;传 `[]` / `null` 清空入口 |
272
277
 
273
278
  ##### POST `{prefix}/app/save-env`
274
279
 
@@ -426,7 +431,11 @@ await fastify.listen({ port: 3000 });
426
431
  "description": "d",
427
432
  "category": { "code": "tools", "name": "工具" },
428
433
  "isPublic": true,
429
- "pathUrl": "/app/demo/"
434
+ "pathUrl": "/app/demo/",
435
+ "entries": [
436
+ { "label": "PC", "path": "/", "url": "/app/demo/" },
437
+ { "label": "移动端", "path": "/mobile", "url": "/app/demo/mobile" }
438
+ ]
430
439
  }
431
440
  ],
432
441
  "totalCount": 1
@@ -685,7 +694,7 @@ data: {"name":"demo","ts":1791456608533,...}
685
694
  | description | string / null | 描述 |
686
695
  | env | object | 自有环境变量 |
687
696
  | pm2Config | object | PM2 覆盖 |
688
- | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开) |
697
+ | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开),`entries` 为应用入口 `[{ label, path }]` |
689
698
  | port | number | 分配端口 |
690
699
  | status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
691
700
  | currentVersionId | string / null | 当前部署版本 id |
@@ -775,7 +784,7 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
775
784
  ##### 环境合并顺序
776
785
 
777
786
  ```
778
- pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
787
+ pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制) → resolveSystemEnv 返回值(覆盖)
779
788
  ```
780
789
 
781
790
  API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
package/index.js CHANGED
@@ -36,6 +36,12 @@ 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,
39
45
  secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
40
46
  sqlPath: 'sql',
41
47
  migrateBeforeStart: false,
@@ -26,6 +26,10 @@ module.exports = fp(async (fastify, options) => {
26
26
  description: { type: 'string' },
27
27
  category: { type: ['object', 'string', 'null'] },
28
28
  isPublic: { type: 'boolean' },
29
+ entries: {
30
+ type: ['array', 'null'],
31
+ items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
32
+ },
29
33
  env: { type: 'object' },
30
34
  pm2Config: { type: 'object' },
31
35
  options: { type: 'object' }
@@ -53,6 +57,10 @@ module.exports = fp(async (fastify, options) => {
53
57
  description: { type: 'string' },
54
58
  category: { type: ['object', 'string', 'null'] },
55
59
  isPublic: { type: 'boolean' },
60
+ entries: {
61
+ type: ['array', 'null'],
62
+ items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
63
+ },
56
64
  env: { type: 'object' },
57
65
  pm2Config: { type: 'object' },
58
66
  options: { type: 'object' }
@@ -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
  }
@@ -45,6 +45,27 @@ module.exports = fp(async (fastify, options) => {
45
45
  }
46
46
  return category.code ? { code: category.code, name: category.name || category.code } : null;
47
47
  };
48
+ // 应用入口:label + 相对应用根的 path;去空、补前导 /、按 path 去重
49
+ const normalizeEntries = entries => {
50
+ const seen = new Set();
51
+ return (Array.isArray(entries) ? entries : []).reduce((result, item) => {
52
+ const label = String(item?.label || '').trim();
53
+ const rawPath = String(item?.path || '').trim();
54
+ if (!label || !rawPath) {
55
+ return result;
56
+ }
57
+ const entryPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
58
+ if (!seen.has(entryPath)) {
59
+ seen.add(entryPath);
60
+ result.push({ label, path: entryPath });
61
+ }
62
+ return result;
63
+ }, []);
64
+ };
65
+ const resolveEntries = appLike => {
66
+ const base = mountPrefix(appLike.name);
67
+ return normalizeEntries(appLike.options?.entries).map(entry => Object.assign({}, entry, { url: `${base}${entry.path}` }));
68
+ };
48
69
 
49
70
  const toPublicApp = app => {
50
71
  if (!app) {
@@ -59,6 +80,7 @@ module.exports = fp(async (fastify, options) => {
59
80
  json.pathUrl = `${mountPrefix(json.name)}/`;
60
81
  json.category = json.options?.category || null;
61
82
  json.isPublic = isAppPublic(json);
83
+ json.entries = resolveEntries(json);
62
84
  return json;
63
85
  };
64
86
 
@@ -71,7 +93,7 @@ module.exports = fp(async (fastify, options) => {
71
93
  };
72
94
 
73
95
  const create = async data => {
74
- const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
96
+ const { name, label, domain, icon, description, category, isPublic, entries, env, pm2Config, options: appOptions } = data;
75
97
  if (!name || !NAME_RE.test(name)) {
76
98
  httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
77
99
  }
@@ -111,6 +133,9 @@ module.exports = fp(async (fastify, options) => {
111
133
  if (isPublic !== undefined) {
112
134
  normalizedOptions.isPublic = !!isPublic;
113
135
  }
136
+ if (entries !== undefined) {
137
+ normalizedOptions.entries = normalizeEntries(entries);
138
+ }
114
139
 
115
140
  const app = await models.app.create({
116
141
  name,
@@ -118,7 +143,7 @@ module.exports = fp(async (fastify, options) => {
118
143
  domain: domain || null,
119
144
  icon: icon || null,
120
145
  description: description || null,
121
- env: omitSystemEnv(env),
146
+ env: omitSystemEnv(env, options.systemEnvKeys),
122
147
  pm2Config: pm2Config || {},
123
148
  options: normalizedOptions,
124
149
  port,
@@ -130,7 +155,7 @@ module.exports = fp(async (fastify, options) => {
130
155
  return toPublicApp(app);
131
156
  };
132
157
 
133
- const save = async ({ name, category, isPublic, ...data }) => {
158
+ const save = async ({ name, category, isPublic, entries, ...data }) => {
134
159
  const app = await getByName(name);
135
160
  const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
136
161
  const patch = {};
@@ -161,6 +186,9 @@ module.exports = fp(async (fastify, options) => {
161
186
  if (isPublic !== undefined) {
162
187
  patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
163
188
  }
189
+ if (entries !== undefined) {
190
+ patch.options = Object.assign({}, patch.options || app.options || {}, { entries: normalizeEntries(entries) });
191
+ }
164
192
  await app.update(patch);
165
193
  return toPublicApp(app);
166
194
  };
@@ -170,7 +198,7 @@ module.exports = fp(async (fastify, options) => {
170
198
  // 密钥类型不可撤销,只随变量删除而移除
171
199
  const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
172
200
  const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
173
- const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key) && Object.prototype.hasOwnProperty.call(nextEnv, key));
201
+ const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
174
202
  await app.update({
175
203
  env: nextEnv,
176
204
  options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
@@ -211,7 +239,8 @@ module.exports = fp(async (fastify, options) => {
211
239
  description: app.description,
212
240
  category: app.options?.category || null,
213
241
  isPublic: isAppPublic(app),
214
- pathUrl: `${mountPrefix(app.name)}/`
242
+ pathUrl: `${mountPrefix(app.name)}/`,
243
+ entries: resolveEntries(app)
215
244
  }));
216
245
  return { pageData, totalCount: pageData.length };
217
246
  };
@@ -480,6 +509,26 @@ module.exports = fp(async (fastify, options) => {
480
509
  await app.reload();
481
510
  }
482
511
 
512
+ if (typeof options.resolveSystemEnv === 'function') {
513
+ const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
514
+ if (systemEnv && typeof systemEnv === 'object') {
515
+ const persisted = {};
516
+ for (const [key, value] of Object.entries(systemEnv)) {
517
+ if (value == null) {
518
+ continue;
519
+ }
520
+ env[key] = String(value);
521
+ if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
522
+ persisted[key] = env[key];
523
+ }
524
+ }
525
+ if (Object.keys(persisted).length) {
526
+ await app.update({ env: Object.assign({}, app.env || {}, persisted) });
527
+ await app.reload();
528
+ }
529
+ }
530
+ }
531
+
483
532
  const pathBase = mountPrefix(app.name);
484
533
  // Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
485
534
  // Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
@@ -663,9 +712,17 @@ module.exports = fp(async (fastify, options) => {
663
712
  }
664
713
  }
665
714
 
715
+ const snapshot = app.toJSON();
666
716
  await models.appVersion.destroy({ where: { appName: name } });
667
717
  await fs.remove(app.rootPath).catch(() => {});
668
718
  await app.destroy();
719
+ if (typeof options.onAppRemoved === 'function') {
720
+ try {
721
+ await options.onAppRemoved({ app: snapshot });
722
+ } catch (e) {
723
+ fastify.log.warn({ err: e, appName: name }, 'onAppRemoved hook failed');
724
+ }
725
+ }
669
726
  return {
670
727
  removed: true,
671
728
  export: exportResult,
@@ -4,7 +4,7 @@ const fs = require('fs-extra');
4
4
  const AdmZip = require('adm-zip');
5
5
  const { createSequelizeFromEnv, SQL_MIGRATIONS_TABLE } = require('../utils/migrate');
6
6
  const { resolveAppDbEnv, hasAppDbConfig } = require('../utils/env');
7
- const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix } = require('../utils/dbIdentity');
7
+ const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix, filterOwnedTables } = require('../utils/dbIdentity');
8
8
  const { assertReadOnlyQuerySql } = require('../utils/sqlQueryGuard');
9
9
 
10
10
  module.exports = fp(async (fastify, options) => {
@@ -83,15 +83,7 @@ module.exports = fp(async (fastify, options) => {
83
83
  return { table, columns, primaryKey };
84
84
  };
85
85
 
86
- const getOwnedTables = (app, allTables) => {
87
- const scope = resolveDbScope(app.env || {}, app.options || {});
88
- const existing = new Set(allTables);
89
- if (scope === 'dedicated') {
90
- return allTables.filter(t => !isSystemTable(t));
91
- }
92
- const owned = normalizeOwnedTables(app.options?.ownedTables);
93
- return owned.filter(t => existing.has(t));
94
- };
86
+ const getOwnedTables = (app, allTables) => filterOwnedTables({ appEnv: app.env || {}, ownedTables: app.options?.ownedTables, allTables });
95
87
 
96
88
  const assertTableOwned = (app, allTables, table) => {
97
89
  const owned = getOwnedTables(app, allTables);
@@ -1,5 +1,5 @@
1
1
  const path = require('node:path');
2
- const { hasAppDbConfig } = require('./env');
2
+ const { isAppDbEnabled } = require('./env');
3
3
 
4
4
  const SYSTEM_TABLES = new Set(['_fs_sql_migrations']);
5
5
 
@@ -69,12 +69,8 @@ const assertDefaultAppDbSeparated = (defaultAppDb, hostSequelize) => {
69
69
  }
70
70
  };
71
71
 
72
- const resolveDbScope = (appEnv = {}, options = {}) => {
73
- if (options.dbScope === 'dedicated' || options.dbScope === 'shared') {
74
- return options.dbScope;
75
- }
76
- return hasAppDbConfig(appEnv) ? 'dedicated' : 'shared';
77
- };
72
+ // 与 startProcess 注入的连接一致:只看 env(独立库配置 + DB_DEDICATED 开关)。options.dbScope 只是认领表时写入的缓存,env 变化后会过期
73
+ const resolveDbScope = (appEnv = {}) => (isAppDbEnabled(appEnv) ? 'dedicated' : 'shared');
78
74
 
79
75
  const normalizeOwnedTables = tables => [...new Set((Array.isArray(tables) ? tables : []).map(t => String(t || '').trim()).filter(Boolean))];
80
76
 
@@ -104,8 +100,21 @@ const resolveTablePrefix = (appName, appEnv = {}) => {
104
100
  return buildTablePrefix(appName);
105
101
  };
106
102
 
103
+ /**
104
+ * 共享库:只认登记过的表;独立库:库内业务表全归本应用,但显式配了 DB_TABLE_PREFIX 说明该库与其它系统共用,只认本前缀的表
105
+ */
106
+ const filterOwnedTables = ({ appEnv = {}, ownedTables, allTables = [] }) => {
107
+ if (resolveDbScope(appEnv) === 'dedicated') {
108
+ const prefix = String(appEnv.DB_TABLE_PREFIX || '').trim();
109
+ return allTables.filter(t => !isSystemTable(t) && (!prefix || String(t).startsWith(prefix)));
110
+ }
111
+ const existing = new Set(allTables);
112
+ return normalizeOwnedTables(ownedTables).filter(t => existing.has(t));
113
+ };
114
+
107
115
  module.exports = {
108
116
  SYSTEM_TABLES,
117
+ filterOwnedTables,
109
118
  fingerprintDbConfig,
110
119
  fingerprintFromEnv,
111
120
  assertDefaultAppDbSeparated,
package/libs/utils/env.js CHANGED
@@ -23,6 +23,22 @@ const hasAppDbConfig = (appEnv = {}) => {
23
23
  return !!appEnv.DB_DATABASE;
24
24
  };
25
25
 
26
+ const DB_DEDICATED_KEY = 'DB_DEDICATED';
27
+ const DB_CONNECTION_KEYS = ['DB_DIALECT', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'DB_STORAGE'];
28
+
29
+ // 配好独立库后默认启用;DB_DEDICATED=false 时保留配置但回落共享库
30
+ const isAppDbEnabled = (appEnv = {}) =>
31
+ hasAppDbConfig(appEnv) &&
32
+ String(appEnv[DB_DEDICATED_KEY] ?? '')
33
+ .trim()
34
+ .toLowerCase() !== 'false';
35
+
36
+ const omitDbConnection = (appEnv = {}) => {
37
+ const next = Object.assign({}, appEnv);
38
+ DB_CONNECTION_KEYS.forEach(key => delete next[key]);
39
+ return next;
40
+ };
41
+
26
42
  const buildDefaultAppDbEnv = (defaultAppDb = {}) => {
27
43
  const dialect = defaultAppDb.dialect || 'sqlite';
28
44
  const env = { DB_DIALECT: dialect };
@@ -59,10 +75,11 @@ const buildDefaultAppDbEnv = (defaultAppDb = {}) => {
59
75
  */
60
76
  const resolveAppDbEnv = ({ passthroughEnvKeys = [], appEnv = {}, defaultAppDb = null, port = null, includePort = false, appName = null, injectTablePrefix = true } = {}) => {
61
77
  const base = Object.assign({}, pickHostEnv(passthroughEnvKeys));
62
- if (defaultAppDb && !hasAppDbConfig(appEnv)) {
78
+ const useAppDb = isAppDbEnabled(appEnv || {});
79
+ if (defaultAppDb && !useAppDb) {
63
80
  Object.assign(base, buildDefaultAppDbEnv(defaultAppDb));
64
81
  }
65
- Object.assign(base, appEnv || {});
82
+ Object.assign(base, useAppDb ? appEnv || {} : omitDbConnection(appEnv || {}));
66
83
  if (includePort && port != null) {
67
84
  base.PORT = String(port);
68
85
  }
@@ -105,17 +122,17 @@ const isSecretKey = (key, pattern, secretKeys = []) => {
105
122
  return isPatternSecretKey(key, pattern);
106
123
  };
107
124
 
108
- // 由系统写入维护:不在接口中返回,也不接受外部新增 / 修改 / 删除
125
+ // 由系统写入维护:不在接口中返回,也不接受外部新增 / 修改 / 删除;宿主可通过 systemEnvKeys 选项追加
109
126
  const SYSTEM_ENV_KEYS = ['DB_TABLE_PREFIX'];
110
127
 
111
- const isSystemEnvKey = key => SYSTEM_ENV_KEYS.includes(key);
128
+ const isSystemEnvKey = (key, systemEnvKeys = []) => SYSTEM_ENV_KEYS.includes(key) || (systemEnvKeys || []).includes(key);
112
129
 
113
- const omitSystemEnv = (appEnv = {}) => Object.fromEntries(Object.entries(appEnv || {}).filter(([key]) => !isSystemEnvKey(key)));
130
+ const omitSystemEnv = (appEnv = {}, systemEnvKeys = []) => Object.fromEntries(Object.entries(appEnv || {}).filter(([key]) => !isSystemEnvKey(key, systemEnvKeys)));
114
131
 
115
- const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
132
+ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
116
133
  const masked = {};
117
134
  const explicit = normalizeSecretKeys(secretEnvKeys);
118
- for (const [key, value] of Object.entries(omitSystemEnv(appEnv))) {
135
+ for (const [key, value] of Object.entries(omitSystemEnv(appEnv, systemEnvKeys))) {
119
136
  if (isSecretKey(key, secretEnvKeyPattern, explicit)) {
120
137
  masked[key] = value == null || value === '' ? null : SECRET_MASK;
121
138
  } else {
@@ -131,11 +148,11 @@ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys =
131
148
  * SECRET_MASK => keep existing (when key is secret by pattern or explicit list)
132
149
  * other => set
133
150
  */
134
- const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
151
+ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
135
152
  const next = Object.assign({}, current);
136
153
  const explicit = normalizeSecretKeys(secretEnvKeys);
137
154
  for (const [key, value] of Object.entries(patch || {})) {
138
- if (isSystemEnvKey(key)) {
155
+ if (isSystemEnvKey(key, systemEnvKeys)) {
139
156
  continue;
140
157
  }
141
158
  if (value === null) {
@@ -153,10 +170,10 @@ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEn
153
170
  /**
154
171
  * 响应中的密钥键列表 = 显式列表 ∪ 当前 env 中匹配 pattern 的键
155
172
  */
156
- const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
173
+ const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
157
174
  const explicit = normalizeSecretKeys(secretEnvKeys);
158
175
  const fromPattern = Object.keys(appEnv || {}).filter(key => isPatternSecretKey(key, secretEnvKeyPattern));
159
- return normalizeSecretKeys([...explicit, ...fromPattern]).filter(key => !isSystemEnvKey(key));
176
+ return normalizeSecretKeys([...explicit, ...fromPattern]).filter(key => !isSystemEnvKey(key, systemEnvKeys));
160
177
  };
161
178
 
162
179
  const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
@@ -176,6 +193,8 @@ module.exports = {
176
193
  normalizeSecretKeys,
177
194
  pickHostEnv,
178
195
  hasAppDbConfig,
196
+ DB_DEDICATED_KEY,
197
+ isAppDbEnabled,
179
198
  buildDefaultAppDbEnv,
180
199
  resolveAppDbEnv,
181
200
  mergeEnv,
@@ -27,9 +27,11 @@ const rewriteSetCookiePath = (setCookie, mountPrefix) => {
27
27
  });
28
28
  };
29
29
 
30
- const TEXTUAL_TYPES = /text\/|javascript|json|xml|svg/i;
30
+ const TEXTUAL_TYPES = /text\/|json|xml|svg/i;
31
+ // 前端脚本已按注入的 runtimePublicUrl / runtimeApiUrl 自行拼前缀(如 axios baseURL + '/api/...'),再改写会得到 /app/x/app/x/api
32
+ const SCRIPT_TYPES = /javascript|ecmascript/i;
31
33
 
32
- const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '');
34
+ const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '') && !SCRIPT_TYPES.test(contentType || '');
33
35
 
34
36
  const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account']) => {
35
37
  if (!body || !mountPrefix) {
@@ -47,9 +49,32 @@ const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account
47
49
  return text;
48
50
  };
49
51
 
52
+ // 路径模式响应体经网关改写,ETag 带上改写规则版本,与子应用原始文件及旧规则下的缓存区分;改写规则变化时递增
53
+ const ETAG_SUFFIX = '-gw2';
54
+
55
+ const tagEtag = etag => (typeof etag === 'string' && etag.endsWith('"') ? `${etag.slice(0, -1)}${ETAG_SUFFIX}"` : etag);
56
+
57
+ // 只有本网关签发的 ETag 才还原后交给子应用协商缓存;其它(旧规则缓存)去掉条件头,强制返回完整内容
58
+ const restoreConditionalHeaders = headers => {
59
+ const next = { ...headers };
60
+ const tags = String(next['if-none-match'] || '')
61
+ .split(',')
62
+ .map(tag => tag.trim())
63
+ .filter(Boolean);
64
+ if (tags.length && tags.every(tag => tag.endsWith(`${ETAG_SUFFIX}"`))) {
65
+ next['if-none-match'] = tags.map(tag => `${tag.slice(0, -(ETAG_SUFFIX.length + 1))}"`).join(', ');
66
+ } else {
67
+ delete next['if-none-match'];
68
+ delete next['if-modified-since'];
69
+ }
70
+ return next;
71
+ };
72
+
50
73
  module.exports = {
51
74
  rewriteLocation,
52
75
  rewriteSetCookiePath,
53
76
  shouldRewriteBody,
54
- rewriteBody
77
+ rewriteBody,
78
+ tagEtag,
79
+ restoreConditionalHeaders
55
80
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {