@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.
@@ -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 proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
11
- let targetPath = request.raw.url || '/';
12
- if (stripPrefix) {
13
- const mount = `${pathPrefix}/${app.name}`;
14
- if (targetPath === mount || targetPath.startsWith(mount + '/') || targetPath.startsWith(mount + '?')) {
15
- targetPath = targetPath.slice(mount.length) || '/';
16
- if (!targetPath.startsWith('/')) {
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(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
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
  });
@@ -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.12",
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",