@kne/fastify-app-manager 0.1.12 → 0.1.13

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
  });
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.13",
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",