@kne/fastify-app-manager 0.1.1

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.
@@ -0,0 +1,274 @@
1
+ const fp = require('fastify-plugin');
2
+ const { createSseReply } = require('../utils/logStream');
3
+
4
+ module.exports = fp(async (fastify, options) => {
5
+ const { services } = fastify[options.name];
6
+ const auth = () => options.createAuthenticate();
7
+
8
+ fastify.post(
9
+ `${options.prefix}/app/create`,
10
+ {
11
+ onRequest: auth(),
12
+ schema: {
13
+ summary: '创建应用',
14
+ body: {
15
+ type: 'object',
16
+ required: ['name', 'label'],
17
+ properties: {
18
+ name: { type: 'string' },
19
+ label: { type: 'string' },
20
+ domain: { type: 'string' },
21
+ icon: { type: 'string' },
22
+ description: { type: 'string' },
23
+ env: { type: 'object' },
24
+ pm2Config: { type: 'object' },
25
+ options: { type: 'object' }
26
+ }
27
+ }
28
+ }
29
+ },
30
+ async request => services.app.create(request.body)
31
+ );
32
+
33
+ fastify.post(
34
+ `${options.prefix}/app/save`,
35
+ {
36
+ onRequest: auth(),
37
+ schema: {
38
+ summary: '更新应用元信息',
39
+ body: {
40
+ type: 'object',
41
+ required: ['name'],
42
+ properties: {
43
+ name: { type: 'string' },
44
+ label: { type: 'string' },
45
+ domain: { type: 'string' },
46
+ icon: { type: 'string' },
47
+ description: { type: 'string' },
48
+ env: { type: 'object' },
49
+ pm2Config: { type: 'object' },
50
+ options: { type: 'object' }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ async request => services.app.save(request.body)
56
+ );
57
+
58
+ fastify.post(
59
+ `${options.prefix}/app/save-env`,
60
+ {
61
+ onRequest: auth(),
62
+ schema: {
63
+ summary: '合并更新应用环境变量',
64
+ body: {
65
+ type: 'object',
66
+ required: ['name', 'env'],
67
+ properties: {
68
+ name: { type: 'string' },
69
+ env: { type: 'object' }
70
+ }
71
+ }
72
+ }
73
+ },
74
+ async request => services.app.saveEnv(request.body)
75
+ );
76
+
77
+ fastify.post(
78
+ `${options.prefix}/app/version/upload`,
79
+ {
80
+ onRequest: auth(),
81
+ schema: {
82
+ summary: '上传应用版本 zip'
83
+ }
84
+ },
85
+ async request => {
86
+ const parts = request.parts();
87
+ let name;
88
+ let version;
89
+ let label;
90
+ let zipBuffer;
91
+ for await (const part of parts) {
92
+ if (part.type === 'file') {
93
+ zipBuffer = await part.toBuffer();
94
+ } else {
95
+ const val = part.value;
96
+ if (part.fieldname === 'name') name = val;
97
+ if (part.fieldname === 'version') version = val;
98
+ if (part.fieldname === 'label') label = val;
99
+ }
100
+ }
101
+ if (!zipBuffer) {
102
+ throw fastify.httpErrors.badRequest('file is required');
103
+ }
104
+ return services.app.uploadVersion({ name, version, label, zipBuffer });
105
+ }
106
+ );
107
+
108
+ fastify.get(
109
+ `${options.prefix}/app/version/list`,
110
+ {
111
+ onRequest: auth(),
112
+ schema: {
113
+ summary: '应用版本列表',
114
+ query: {
115
+ type: 'object',
116
+ required: ['name'],
117
+ properties: {
118
+ name: { type: 'string' },
119
+ perPage: { type: 'number', default: 20 },
120
+ currentPage: { type: 'number', default: 1 }
121
+ }
122
+ }
123
+ }
124
+ },
125
+ async request => services.app.listVersions(request.query)
126
+ );
127
+
128
+ fastify.post(
129
+ `${options.prefix}/app/deploy`,
130
+ {
131
+ onRequest: auth(),
132
+ schema: {
133
+ summary: '部署指定版本',
134
+ body: {
135
+ type: 'object',
136
+ required: ['name'],
137
+ properties: {
138
+ name: { type: 'string' },
139
+ versionId: { type: 'string' },
140
+ version: { type: 'string' },
141
+ runMigration: { type: 'boolean', default: true }
142
+ }
143
+ }
144
+ }
145
+ },
146
+ async request => services.app.deploy(request.body)
147
+ );
148
+
149
+ fastify.get(
150
+ `${options.prefix}/app/list`,
151
+ {
152
+ onRequest: auth(),
153
+ schema: {
154
+ summary: '应用列表',
155
+ query: {
156
+ type: 'object',
157
+ properties: {
158
+ filter: { type: 'object', default: {} },
159
+ perPage: { type: 'number', default: 20 },
160
+ currentPage: { type: 'number', default: 1 }
161
+ }
162
+ }
163
+ }
164
+ },
165
+ async request => services.app.list(request.query)
166
+ );
167
+
168
+ fastify.get(
169
+ `${options.prefix}/app/detail`,
170
+ {
171
+ onRequest: auth(),
172
+ schema: {
173
+ summary: '应用详情',
174
+ query: {
175
+ type: 'object',
176
+ required: ['name'],
177
+ properties: {
178
+ name: { type: 'string' }
179
+ }
180
+ }
181
+ }
182
+ },
183
+ async request => services.app.detail(request.query)
184
+ );
185
+
186
+ for (const action of ['start', 'stop', 'restart', 'remove']) {
187
+ fastify.post(
188
+ `${options.prefix}/app/${action}`,
189
+ {
190
+ onRequest: auth(),
191
+ schema: {
192
+ summary: `${action} 应用`,
193
+ body: {
194
+ type: 'object',
195
+ required: ['name'],
196
+ properties: { name: { type: 'string' } }
197
+ }
198
+ }
199
+ },
200
+ async request => services.app[action](request.body)
201
+ );
202
+ }
203
+
204
+ fastify.get(
205
+ `${options.prefix}/app/logs`,
206
+ {
207
+ onRequest: auth(),
208
+ schema: {
209
+ summary: '读取应用日志文件',
210
+ query: {
211
+ type: 'object',
212
+ required: ['name'],
213
+ properties: {
214
+ name: { type: 'string' },
215
+ stream: { type: 'string', enum: ['out', 'err'], default: 'out' },
216
+ perPage: { type: 'number', default: 100 },
217
+ currentPage: { type: 'number', default: 1 }
218
+ }
219
+ }
220
+ }
221
+ },
222
+ async request => services.app.logs(request.query)
223
+ );
224
+
225
+ fastify.get(
226
+ `${options.prefix}/app/logs/stream`,
227
+ {
228
+ onRequest: auth(),
229
+ schema: {
230
+ summary: 'SSE 实时日志',
231
+ query: {
232
+ type: 'object',
233
+ required: ['name'],
234
+ properties: {
235
+ name: { type: 'string' },
236
+ stream: { type: 'string', enum: ['out', 'err'] }
237
+ }
238
+ }
239
+ }
240
+ },
241
+ async (request, reply) => {
242
+ const { name, stream } = request.query;
243
+ await services.app.detail({ name });
244
+ const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
245
+ const hub = services.app.getLogHub();
246
+
247
+ const replay = await services.app.readLastLines(name, stream || 'out', options.sseReplayLines);
248
+ for (const line of replay) {
249
+ if (stream && line.stream && line.stream !== stream) {
250
+ continue;
251
+ }
252
+ sse.writeEvent('log', {
253
+ appName: name,
254
+ stream: stream || 'out',
255
+ content: line.content,
256
+ line: line.line
257
+ });
258
+ }
259
+
260
+ const onLog = payload => {
261
+ if (stream && payload.stream !== stream) {
262
+ return;
263
+ }
264
+ sse.writeEvent('log', payload);
265
+ };
266
+ const eventName = `log:${name}`;
267
+ hub?.on(eventName, onLog);
268
+ request.raw.on('close', () => {
269
+ hub?.off(eventName, onLog);
270
+ sse.close();
271
+ });
272
+ }
273
+ );
274
+ });
@@ -0,0 +1,88 @@
1
+ const fp = require('fastify-plugin');
2
+ const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
3
+
4
+ module.exports = fp(async (fastify, options) => {
5
+ const ns = options.name;
6
+ const pathPrefix = options.pathPrefix.replace(/\/$/, '') || '/app';
7
+
8
+ const proxyToApp = async (request, reply, app, { stripPrefix } = {}) => {
9
+ let targetPath = request.raw.url || '/';
10
+ if (stripPrefix) {
11
+ const mount = `${pathPrefix}/${app.name}`;
12
+ if (targetPath === mount || targetPath.startsWith(mount + '/') || targetPath.startsWith(mount + '?')) {
13
+ targetPath = targetPath.slice(mount.length) || '/';
14
+ if (!targetPath.startsWith('/')) {
15
+ targetPath = `/${targetPath}`;
16
+ }
17
+ }
18
+ }
19
+
20
+ const dest = `http://127.0.0.1:${app.port}${targetPath}`;
21
+ const mountPrefix = `${pathPrefix}/${app.name}`;
22
+
23
+ return reply.from(dest, {
24
+ rewriteRequestHeaders: (req, headers) => {
25
+ const next = { ...headers };
26
+ delete next['content-length'];
27
+ return next;
28
+ },
29
+ rewriteHeaders: headers => {
30
+ const next = { ...headers };
31
+ if (stripPrefix) {
32
+ if (next.location) {
33
+ next.location = rewriteLocation(next.location, mountPrefix);
34
+ }
35
+ if (next['set-cookie']) {
36
+ next['set-cookie'] = rewriteSetCookiePath(next['set-cookie'], mountPrefix);
37
+ }
38
+ }
39
+ // body rewrite changes length
40
+ if (stripPrefix && shouldRewriteBody(next['content-type'] || '') && !next['content-encoding']) {
41
+ delete next['content-length'];
42
+ }
43
+ return next;
44
+ },
45
+ onResponse: async (request, reply, res) => {
46
+ const contentType = res.headers['content-type'] || '';
47
+ if (stripPrefix && shouldRewriteBody(contentType) && !res.headers['content-encoding']) {
48
+ const chunks = [];
49
+ for await (const chunk of res.stream) {
50
+ chunks.push(chunk);
51
+ }
52
+ const buf = Buffer.concat(chunks.map(c => (Buffer.isBuffer(c) ? c : Buffer.from(c))));
53
+ const text = rewriteBody(buf.toString('utf8'), mountPrefix);
54
+ return reply.send(text);
55
+ }
56
+ return reply.send(res.stream);
57
+ }
58
+ });
59
+ };
60
+
61
+ // Domain + path gateway: run early, but skip management API and let other routes try first for non-matches.
62
+ fastify.addHook('onRequest', async (request, reply) => {
63
+ const url = request.raw.url || '';
64
+ if (url.startsWith(options.prefix)) {
65
+ return;
66
+ }
67
+
68
+ const { services } = fastify[ns];
69
+ if (!services?.app) {
70
+ return;
71
+ }
72
+
73
+ const host = request.headers.host;
74
+ const byHost = await services.app.findByHost(host);
75
+ if (byHost) {
76
+ return proxyToApp(request, reply, byHost, { stripPrefix: false });
77
+ }
78
+
79
+ const match = url.match(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
80
+ if (match) {
81
+ const appName = match[1];
82
+ const app = await services.app.findByPathName(appName);
83
+ if (app) {
84
+ return proxyToApp(request, reply, app, { stripPrefix: true });
85
+ }
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,88 @@
1
+ module.exports = ({ DataTypes }) => {
2
+ return {
3
+ name: 'app',
4
+ model: {
5
+ name: {
6
+ type: DataTypes.STRING,
7
+ allowNull: false,
8
+ unique: true,
9
+ comment: '应用 slug / appId'
10
+ },
11
+ label: {
12
+ type: DataTypes.STRING,
13
+ allowNull: false,
14
+ comment: '用户可见应用名'
15
+ },
16
+ domain: {
17
+ type: DataTypes.STRING,
18
+ allowNull: true,
19
+ unique: true,
20
+ comment: '绑定域名 Host'
21
+ },
22
+ icon: {
23
+ type: DataTypes.STRING,
24
+ allowNull: true,
25
+ comment: '应用图标'
26
+ },
27
+ description: {
28
+ type: DataTypes.TEXT,
29
+ allowNull: true,
30
+ comment: '应用描述'
31
+ },
32
+ env: {
33
+ type: DataTypes.JSON,
34
+ allowNull: false,
35
+ defaultValue: {},
36
+ comment: '应用自有环境变量'
37
+ },
38
+ pm2Config: {
39
+ type: DataTypes.JSON,
40
+ allowNull: false,
41
+ defaultValue: {},
42
+ comment: 'PM2 覆盖配置'
43
+ },
44
+ options: {
45
+ type: DataTypes.JSON,
46
+ allowNull: false,
47
+ defaultValue: {},
48
+ comment: '扩展字段'
49
+ },
50
+ port: {
51
+ type: DataTypes.INTEGER,
52
+ allowNull: false,
53
+ comment: '分配端口'
54
+ },
55
+ status: {
56
+ type: DataTypes.STRING,
57
+ allowNull: false,
58
+ defaultValue: 'idle',
59
+ comment: 'idle|deploying|running|stopped|error'
60
+ },
61
+ currentVersionId: {
62
+ type: DataTypes.STRING,
63
+ allowNull: true,
64
+ comment: '当前部署版本 id'
65
+ },
66
+ rootPath: {
67
+ type: DataTypes.STRING,
68
+ allowNull: false,
69
+ comment: '应用根目录'
70
+ },
71
+ pm2Name: {
72
+ type: DataTypes.STRING,
73
+ allowNull: false,
74
+ unique: true,
75
+ comment: 'PM2 进程名'
76
+ },
77
+ message: {
78
+ type: DataTypes.TEXT,
79
+ allowNull: true,
80
+ comment: '最近状态说明'
81
+ }
82
+ },
83
+ options: {
84
+ comment: '托管应用',
85
+ indexes: [{ fields: ['status'] }, { fields: ['domain'] }]
86
+ }
87
+ };
88
+ };
@@ -0,0 +1,43 @@
1
+ module.exports = ({ DataTypes }) => {
2
+ return {
3
+ name: 'appVersion',
4
+ model: {
5
+ appName: {
6
+ type: DataTypes.STRING,
7
+ allowNull: false,
8
+ comment: '所属应用 name'
9
+ },
10
+ version: {
11
+ type: DataTypes.STRING,
12
+ allowNull: false,
13
+ comment: '版本号'
14
+ },
15
+ label: {
16
+ type: DataTypes.STRING,
17
+ allowNull: true,
18
+ comment: '版本说明'
19
+ },
20
+ artifactPath: {
21
+ type: DataTypes.STRING,
22
+ allowNull: false,
23
+ comment: '版本解压目录'
24
+ },
25
+ hasMigration: {
26
+ type: DataTypes.BOOLEAN,
27
+ allowNull: false,
28
+ defaultValue: false,
29
+ comment: '是否存在 server/sql/*.sql'
30
+ },
31
+ migrationPath: {
32
+ type: DataTypes.STRING,
33
+ allowNull: true,
34
+ defaultValue: 'sql',
35
+ comment: '相对 server 的 sql 目录'
36
+ }
37
+ },
38
+ options: {
39
+ comment: '应用版本',
40
+ indexes: [{ fields: ['app_name'] }, { unique: true, fields: ['app_name', 'version'] }]
41
+ }
42
+ };
43
+ };