@kne/fastify-app-manager 0.1.8 → 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 +198 -21
- package/index.js +17 -0
- package/libs/controllers/app.js +167 -0
- package/libs/controllers/gateway.js +16 -2
- package/libs/services/app.js +175 -28
- package/libs/services/bootstrap.js +160 -3
- package/libs/services/dbops.js +2 -10
- package/libs/utils/dbIdentity.js +16 -7
- package/libs/utils/env.js +39 -7
- package/libs/utils/loadMonitor.js +73 -0
- package/libs/utils/logFiles.js +168 -20
- package/libs/utils/logRotate.js +162 -0
- package/libs/utils/logTime.js +132 -0
- package/libs/utils/pathRewrite.js +28 -3
- package/libs/utils/pm2.js +7 -0
- package/libs/utils/requestMetrics.js +132 -0
- package/package.json +3 -2
|
@@ -27,9 +27,11 @@ const rewriteSetCookiePath = (setCookie, mountPrefix) => {
|
|
|
27
27
|
});
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
const TEXTUAL_TYPES = /text\/|
|
|
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/libs/utils/pm2.js
CHANGED
|
@@ -82,6 +82,12 @@ const launchBus = () =>
|
|
|
82
82
|
pm2.launchBus((err, bus) => (err ? reject(err) : resolve(bus)));
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// Reopens out_file/error_file for every PM2 process; needed after renaming log files.
|
|
86
|
+
const reloadLogs = () =>
|
|
87
|
+
new Promise((resolve, reject) => {
|
|
88
|
+
pm2.reloadLogs(err => (err ? reject(err) : resolve()));
|
|
89
|
+
});
|
|
90
|
+
|
|
85
91
|
module.exports = {
|
|
86
92
|
connect,
|
|
87
93
|
disconnect,
|
|
@@ -92,5 +98,6 @@ module.exports = {
|
|
|
92
98
|
restartApp,
|
|
93
99
|
list,
|
|
94
100
|
launchBus,
|
|
101
|
+
reloadLogs,
|
|
95
102
|
raw: pm2
|
|
96
103
|
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const ROLLING_MS = 60 * 1000;
|
|
2
|
+
const BUCKET_FACTOR = 1.05;
|
|
3
|
+
const LOG_FACTOR = Math.log(BUCKET_FACTOR);
|
|
4
|
+
const MAX_BUCKET = Math.ceil(Math.log(60 * 1000) / LOG_FACTOR);
|
|
5
|
+
|
|
6
|
+
// 对数分桶:桶上界为 1.05^i 毫秒,内存与请求量无关,分位数误差约 5%
|
|
7
|
+
const bucketOf = ms => (ms <= 1 ? 0 : Math.min(MAX_BUCKET, Math.ceil(Math.log(ms) / LOG_FACTOR)));
|
|
8
|
+
|
|
9
|
+
const percentile = (hist, total, maxMs, q) => {
|
|
10
|
+
if (!total) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const rank = Math.ceil(q * total);
|
|
14
|
+
let seen = 0;
|
|
15
|
+
for (const index of [...hist.keys()].sort((a, b) => a - b)) {
|
|
16
|
+
seen += hist.get(index);
|
|
17
|
+
if (seen >= rank) {
|
|
18
|
+
return Math.round(Math.min(Math.pow(BUCKET_FACTOR, index), maxMs) * 10) / 10;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Math.round(maxMs * 10) / 10;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const round = (value, digits) => {
|
|
25
|
+
const base = Math.pow(10, digits);
|
|
26
|
+
return Math.round(value * base) / base;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createWindow = peak => ({ count: 0, errors5xx: 0, upstreamErrors: 0, sumMs: 0, maxMs: 0, hist: new Map(), peak });
|
|
30
|
+
|
|
31
|
+
const createRequestMetrics = ({ intervalMs }) => {
|
|
32
|
+
const windowMs = intervalMs > 0 ? intervalMs : 5000;
|
|
33
|
+
const rollingWindows = Math.max(1, Math.ceil(ROLLING_MS / windowMs));
|
|
34
|
+
const states = new Map();
|
|
35
|
+
|
|
36
|
+
const stateOf = name => {
|
|
37
|
+
if (!states.has(name)) {
|
|
38
|
+
states.set(name, { inFlight: 0, window: createWindow(0), recent: [], lastSnapshotAt: null });
|
|
39
|
+
}
|
|
40
|
+
return states.get(name);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const begin = name => {
|
|
44
|
+
const state = stateOf(name);
|
|
45
|
+
state.inFlight += 1;
|
|
46
|
+
state.window.peak = Math.max(state.window.peak, state.inFlight);
|
|
47
|
+
const startedAt = process.hrtime.bigint();
|
|
48
|
+
let upstreamError = false;
|
|
49
|
+
let finished = false;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
markUpstreamError: () => {
|
|
53
|
+
upstreamError = true;
|
|
54
|
+
},
|
|
55
|
+
end: ({ statusCode } = {}) => {
|
|
56
|
+
if (finished) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
finished = true;
|
|
60
|
+
state.inFlight = Math.max(0, state.inFlight - 1);
|
|
61
|
+
const ms = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
62
|
+
const { window } = state;
|
|
63
|
+
const bucket = bucketOf(ms);
|
|
64
|
+
window.count += 1;
|
|
65
|
+
window.sumMs += ms;
|
|
66
|
+
window.maxMs = Math.max(window.maxMs, ms);
|
|
67
|
+
window.hist.set(bucket, (window.hist.get(bucket) || 0) + 1);
|
|
68
|
+
if (statusCode >= 500) {
|
|
69
|
+
window.errors5xx += 1;
|
|
70
|
+
}
|
|
71
|
+
if (upstreamError) {
|
|
72
|
+
window.upstreamErrors += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const snapshot = (name, { now = Date.now() } = {}) => {
|
|
79
|
+
const state = stateOf(name);
|
|
80
|
+
const elapsed = state.lastSnapshotAt == null ? windowMs : now - state.lastSnapshotAt;
|
|
81
|
+
const durationMs = elapsed > 0 ? elapsed : windowMs;
|
|
82
|
+
const window = state.window;
|
|
83
|
+
state.lastSnapshotAt = now;
|
|
84
|
+
state.recent.push({ durationMs, count: window.count, errors5xx: window.errors5xx, sumMs: window.sumMs, maxMs: window.maxMs, hist: window.hist });
|
|
85
|
+
if (state.recent.length > rollingWindows) {
|
|
86
|
+
state.recent.splice(0, state.recent.length - rollingWindows);
|
|
87
|
+
}
|
|
88
|
+
state.window = createWindow(state.inFlight);
|
|
89
|
+
|
|
90
|
+
const rolling = { durationMs: 0, count: 0, errors5xx: 0, sumMs: 0, maxMs: 0, hist: new Map() };
|
|
91
|
+
for (const item of state.recent) {
|
|
92
|
+
rolling.durationMs += item.durationMs;
|
|
93
|
+
rolling.count += item.count;
|
|
94
|
+
rolling.errors5xx += item.errors5xx;
|
|
95
|
+
rolling.sumMs += item.sumMs;
|
|
96
|
+
rolling.maxMs = Math.max(rolling.maxMs, item.maxMs);
|
|
97
|
+
for (const [index, count] of item.hist) {
|
|
98
|
+
rolling.hist.set(index, (rolling.hist.get(index) || 0) + count);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
qps: round(window.count / (durationMs / 1000), 2),
|
|
104
|
+
rpm: Math.round((rolling.count * ROLLING_MS) / rolling.durationMs),
|
|
105
|
+
avgRt: rolling.count ? round(rolling.sumMs / rolling.count, 1) : null,
|
|
106
|
+
p95: percentile(rolling.hist, rolling.count, rolling.maxMs, 0.95),
|
|
107
|
+
p99: percentile(rolling.hist, rolling.count, rolling.maxMs, 0.99),
|
|
108
|
+
errorRate: rolling.count ? round(rolling.errors5xx / rolling.count, 4) : null,
|
|
109
|
+
errors5xx: window.errors5xx,
|
|
110
|
+
upstreamErrors: window.upstreamErrors,
|
|
111
|
+
concurrency: state.inFlight,
|
|
112
|
+
peakConcurrency: Math.max(window.peak, state.inFlight)
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const retain = names => {
|
|
117
|
+
const keep = new Set(names);
|
|
118
|
+
for (const name of states.keys()) {
|
|
119
|
+
if (!keep.has(name)) {
|
|
120
|
+
states.delete(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return { begin, snapshot, retain, rollingWindows };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
createRequestMetrics,
|
|
130
|
+
bucketOf,
|
|
131
|
+
percentile
|
|
132
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kne/fastify-app-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"jsdom": "^29.1.1",
|
|
74
74
|
"lodash": "^4.18.1",
|
|
75
75
|
"pm2": "^7.0.4",
|
|
76
|
-
"sequelize": "^6.37.7"
|
|
76
|
+
"sequelize": "^6.37.7",
|
|
77
|
+
"yazl": "^3.3.1"
|
|
77
78
|
}
|
|
78
79
|
}
|