@kne/fastify-app-manager 0.1.12 → 0.1.14
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/libs/controllers/gateway.js +80 -11
- package/libs/services/app.js +3 -3
- package/libs/utils/deployProgress.js +20 -3
- package/package.json +2 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const http = require('node:http');
|
|
2
3
|
const { Readable } = require('node:stream');
|
|
4
|
+
const httpProxy = require('http-proxy');
|
|
3
5
|
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
|
|
4
6
|
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
5
7
|
|
|
@@ -7,17 +9,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
7
9
|
const ns = options.name;
|
|
8
10
|
const pathPrefix = options.pathPrefix.replace(/\/$/, '') || '/app';
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
targetPath = `/${targetPath}`;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
12
|
+
const pathMatcher = new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`);
|
|
13
|
+
|
|
14
|
+
const stripMountPrefix = (url, appName) => {
|
|
15
|
+
const mount = `${pathPrefix}/${appName}`;
|
|
16
|
+
if (url === mount || url.startsWith(mount + '/') || url.startsWith(mount + '?')) {
|
|
17
|
+
const rest = url.slice(mount.length) || '/';
|
|
18
|
+
return rest.startsWith('/') ? rest : `/${rest}`;
|
|
20
19
|
}
|
|
20
|
+
return url;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
|
|
24
|
+
const rawUrl = request.raw.url || '/';
|
|
25
|
+
const targetPath = stripPrefix ? stripMountPrefix(rawUrl, app.name) : rawUrl;
|
|
21
26
|
|
|
22
27
|
const dest = `http://127.0.0.1:${app.port}${targetPath}`;
|
|
23
28
|
const mountPrefix = `${pathPrefix}/${app.name}`;
|
|
@@ -128,7 +133,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
128
133
|
return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
const match = url.match(
|
|
136
|
+
const match = url.match(pathMatcher);
|
|
132
137
|
if (match) {
|
|
133
138
|
const appName = decodeName(match[1]);
|
|
134
139
|
const app = await services.app.findByPathName(appName, { running: false });
|
|
@@ -138,4 +143,68 @@ module.exports = fp(async (fastify, options) => {
|
|
|
138
143
|
return replyUnavailable(request, reply, { name: appName, status: 'missing' });
|
|
139
144
|
}
|
|
140
145
|
});
|
|
146
|
+
|
|
147
|
+
const wsProxy = httpProxy.createProxyServer({ changeOrigin: true });
|
|
148
|
+
|
|
149
|
+
const rejectUpgrade = (socket, statusCode) => {
|
|
150
|
+
if (!socket.writable) {
|
|
151
|
+
return socket.destroy();
|
|
152
|
+
}
|
|
153
|
+
socket.end(`HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const resolveUpgradeTarget = async req => {
|
|
157
|
+
const url = req.url || '/';
|
|
158
|
+
if (url.startsWith(options.prefix)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const services = fastify[ns]?.services;
|
|
162
|
+
if (!services?.app) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const byHost = await services.app.findByHost(req.headers.host, { running: false });
|
|
166
|
+
if (byHost) {
|
|
167
|
+
return { app: byHost, targetPath: url };
|
|
168
|
+
}
|
|
169
|
+
const match = url.match(pathMatcher);
|
|
170
|
+
if (!match) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const appName = decodeName(match[1]);
|
|
174
|
+
const app = await services.app.findByPathName(appName, { running: false });
|
|
175
|
+
return { app: app || { name: appName, status: 'missing' }, targetPath: stripMountPrefix(url, appName) };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// reply-from only speaks plain HTTP, so WebSocket handshakes under an app go through http-proxy instead.
|
|
179
|
+
const onUpgrade = async (req, socket, head) => {
|
|
180
|
+
socket.on('error', () => socket.destroy());
|
|
181
|
+
let target;
|
|
182
|
+
try {
|
|
183
|
+
target = await resolveUpgradeTarget(req);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
fastify.log.error(error);
|
|
186
|
+
return rejectUpgrade(socket, 502);
|
|
187
|
+
}
|
|
188
|
+
if (!target) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Node servers allow half-open sockets; once our side has finished writing (tunnel ended or upgrade declined) drop the connection.
|
|
192
|
+
socket.once('finish', () => socket.destroy());
|
|
193
|
+
const { app, targetPath } = target;
|
|
194
|
+
if (app.status !== 'running') {
|
|
195
|
+
return rejectUpgrade(socket, app.status === 'missing' ? 404 : 503);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
req.url = targetPath;
|
|
199
|
+
wsProxy.ws(req, socket, head, { target: `http://127.0.0.1:${app.port}` }, error => {
|
|
200
|
+
fastify.log.warn({ err: error, app: app.name }, 'websocket upgrade proxy failed');
|
|
201
|
+
rejectUpgrade(socket, 502);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
fastify.server.on('upgrade', onUpgrade);
|
|
206
|
+
fastify.addHook('onClose', async () => {
|
|
207
|
+
fastify.server.removeListener('upgrade', onUpgrade);
|
|
208
|
+
wsProxy.close();
|
|
209
|
+
});
|
|
141
210
|
});
|
package/libs/services/app.js
CHANGED
|
@@ -241,7 +241,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
241
241
|
order: [['createdAt', 'DESC']]
|
|
242
242
|
});
|
|
243
243
|
return {
|
|
244
|
-
pageData: rows.map(toPublicApp),
|
|
244
|
+
pageData: rows.map(row => Object.assign(toPublicApp(row), { deployment: deployProgress.active(row.name) })),
|
|
245
245
|
totalCount: count
|
|
246
246
|
};
|
|
247
247
|
};
|
|
@@ -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 toPublicApp(app);
|
|
268
|
+
return Object.assign(toPublicApp(app), { deployment: deployProgress.active(app.name) });
|
|
269
269
|
};
|
|
270
270
|
|
|
271
271
|
const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
|
|
@@ -938,7 +938,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
938
938
|
httpError(404, 'version not found');
|
|
939
939
|
}
|
|
940
940
|
|
|
941
|
-
const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID());
|
|
941
|
+
const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID(), { versionId: String(ver.id), version: ver.version });
|
|
942
942
|
let step = 'package';
|
|
943
943
|
progress.report(step, 'running');
|
|
944
944
|
try {
|
|
@@ -12,8 +12,8 @@ const createDeployProgress = () => {
|
|
|
12
12
|
emitter.setMaxListeners(0);
|
|
13
13
|
const latest = new Map();
|
|
14
14
|
|
|
15
|
-
const begin = (name, progressId) => {
|
|
16
|
-
const state = { progressId, events: [] };
|
|
15
|
+
const begin = (name, progressId, { versionId = null, version = null } = {}) => {
|
|
16
|
+
const state = { progressId, versionId, version, startedAt: new Date().toISOString(), events: [] };
|
|
17
17
|
latest.set(name, state);
|
|
18
18
|
const report = (step, status, { detail = null, message = null } = {}) => {
|
|
19
19
|
const event = { progressId, step, status, detail, message, at: new Date().toISOString() };
|
|
@@ -30,6 +30,23 @@ const createDeployProgress = () => {
|
|
|
30
30
|
|
|
31
31
|
const isFinished = event => event.status === 'error' || (event.step === 'ready' && event.status !== 'running');
|
|
32
32
|
|
|
33
|
+
/** 尚未结束的最近一次部署;准备部署包阶段应用仍是旧状态与旧版本,需靠它判断哪个版本正在部署 */
|
|
34
|
+
const active = name => {
|
|
35
|
+
const state = latest.get(name);
|
|
36
|
+
if (!state || state.events.some(isFinished)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const last = state.events[state.events.length - 1];
|
|
40
|
+
return {
|
|
41
|
+
progressId: state.progressId,
|
|
42
|
+
versionId: state.versionId,
|
|
43
|
+
version: state.version,
|
|
44
|
+
startedAt: state.startedAt,
|
|
45
|
+
step: last ? last.step : null,
|
|
46
|
+
detail: last ? last.detail : null
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
/** 等到该次部署出错或就绪阶段结束(或超时);返回 { progressId, ok, timedOut, events } */
|
|
34
51
|
const wait = (name, progressId, { timeoutMs = 120000 } = {}) =>
|
|
35
52
|
new Promise(resolve => {
|
|
@@ -57,7 +74,7 @@ const createDeployProgress = () => {
|
|
|
57
74
|
timer = setTimeout(() => finish(true), timeoutMs);
|
|
58
75
|
});
|
|
59
76
|
|
|
60
|
-
return { emitter, begin, snapshot, clear, wait };
|
|
77
|
+
return { emitter, begin, snapshot, active, clear, wait };
|
|
61
78
|
};
|
|
62
79
|
|
|
63
80
|
module.exports = {
|
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.14",
|
|
4
4
|
"description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"adm-zip": "^0.6.0",
|
|
72
72
|
"ajv": "^8.17.1",
|
|
73
73
|
"fs-extra": "^11.4.0",
|
|
74
|
+
"http-proxy": "^1.18.1",
|
|
74
75
|
"jsdom": "^29.1.1",
|
|
75
76
|
"lodash": "^4.18.1",
|
|
76
77
|
"pm2": "^7.0.4",
|