@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,470 @@
1
+ const fp = require('fastify-plugin');
2
+ const path = require('node:path');
3
+ const fs = require('fs-extra');
4
+ const portPool = require('../utils/portPool');
5
+ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config } = require('../utils/env');
6
+ const { prepareVersionArtifact } = require('../utils/version');
7
+ const { injectEntryHtml } = require('../utils/entryInject');
8
+ const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
9
+ const { runSqlMigrations } = require('../utils/migrate');
10
+ const { listSqlFiles } = require('../utils/validatePackage');
11
+ const pm2Util = require('../utils/pm2');
12
+
13
+ const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
14
+
15
+ module.exports = fp(async (fastify, options) => {
16
+ const { models } = fastify[options.name];
17
+ const getLogHub = () => fastify[options.name].logHub;
18
+ const httpError = (statusCode, message) => {
19
+ if (fastify.httpErrors) {
20
+ if (statusCode === 404) throw fastify.httpErrors.notFound(message);
21
+ if (statusCode === 409) throw fastify.httpErrors.conflict(message);
22
+ if (statusCode === 400) throw fastify.httpErrors.badRequest(message);
23
+ }
24
+ const err = new Error(message);
25
+ err.statusCode = statusCode;
26
+ throw err;
27
+ };
28
+
29
+ const appRoot = name => path.join(options.appsRoot, name);
30
+ const mountPrefix = name => `${options.pathPrefix.replace(/\/$/, '')}/${name}`;
31
+
32
+ const toPublicApp = app => {
33
+ if (!app) {
34
+ return null;
35
+ }
36
+ const json = typeof app.toJSON === 'function' ? app.toJSON() : { ...app };
37
+ json.env = maskEnvForResponse(json.env || {}, options);
38
+ json.passthroughEnvKeys = options.passthroughEnvKeys || [];
39
+ json.pathUrl = `${mountPrefix(json.name)}/`;
40
+ return json;
41
+ };
42
+
43
+ const getByName = async name => {
44
+ const app = await models.app.findOne({ where: { name } });
45
+ if (!app) {
46
+ httpError(404, `app ${name} not found`);
47
+ }
48
+ return app;
49
+ };
50
+
51
+ const create = async data => {
52
+ const { name, label, domain, icon, description, env, pm2Config, options: appOptions } = data;
53
+ if (!name || !NAME_RE.test(name)) {
54
+ httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
55
+ }
56
+ if (!label) {
57
+ httpError(400, 'label is required');
58
+ }
59
+ const exists = await models.app.findOne({ where: { name } });
60
+ if (exists) {
61
+ httpError(409, `app ${name} already exists`);
62
+ }
63
+ if (domain) {
64
+ const domainTaken = await models.app.findOne({ where: { domain } });
65
+ if (domainTaken) {
66
+ httpError(409, `domain ${domain} already bound`);
67
+ }
68
+ }
69
+
70
+ await fs.ensureDir(options.appsRoot);
71
+ const rootPath = appRoot(name);
72
+ await fs.ensureDir(rootPath);
73
+ await fs.ensureDir(path.join(rootPath, 'versions'));
74
+ await ensureLogFiles(rootPath);
75
+
76
+ const port = await portPool.allocate({
77
+ models,
78
+ portMin: options.portMin,
79
+ portMax: options.portMax
80
+ });
81
+
82
+ const app = await models.app.create({
83
+ name,
84
+ label,
85
+ domain: domain || null,
86
+ icon: icon || null,
87
+ description: description || null,
88
+ env: env || {},
89
+ pm2Config: pm2Config || {},
90
+ options: appOptions || {},
91
+ port,
92
+ status: 'idle',
93
+ rootPath,
94
+ pm2Name: `app-manager__${name}`,
95
+ message: null
96
+ });
97
+ return toPublicApp(app);
98
+ };
99
+
100
+ const save = async ({ name, ...data }) => {
101
+ const app = await getByName(name);
102
+ const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
103
+ const patch = {};
104
+ for (const [k, v] of Object.entries(data)) {
105
+ if (omit.includes(k) || v === undefined) {
106
+ continue;
107
+ }
108
+ patch[k] = v;
109
+ }
110
+ if (patch.domain) {
111
+ const domainTaken = await models.app.findOne({ where: { domain: patch.domain } });
112
+ if (domainTaken && domainTaken.name !== name) {
113
+ httpError(409, `domain ${patch.domain} already bound`);
114
+ }
115
+ }
116
+ if (patch.env) {
117
+ patch.env = applyEnvPatch(app.env || {}, patch.env, options);
118
+ }
119
+ await app.update(patch);
120
+ return toPublicApp(app);
121
+ };
122
+
123
+ const saveEnv = async ({ name, env }) => {
124
+ const app = await getByName(name);
125
+ const next = applyEnvPatch(app.env || {}, env || {}, options);
126
+ await app.update({ env: next });
127
+ return toPublicApp(app);
128
+ };
129
+
130
+ const list = async ({ filter = {}, perPage = 20, currentPage = 1 } = {}) => {
131
+ const where = {};
132
+ if (filter.status) {
133
+ where.status = filter.status;
134
+ }
135
+ if (filter.keyword) {
136
+ const { Op } = fastify.sequelize.Sequelize;
137
+ where[Op.or] = [{ name: { [Op.like]: `%${filter.keyword}%` } }, { label: { [Op.like]: `%${filter.keyword}%` } }];
138
+ }
139
+ const { rows, count } = await models.app.findAndCountAll({
140
+ where,
141
+ offset: perPage * (currentPage - 1),
142
+ limit: perPage,
143
+ order: [['createdAt', 'DESC']]
144
+ });
145
+ return {
146
+ pageData: rows.map(toPublicApp),
147
+ totalCount: count
148
+ };
149
+ };
150
+
151
+ const detail = async ({ name }) => {
152
+ const app = await getByName(name);
153
+ return toPublicApp(app);
154
+ };
155
+
156
+ const uploadVersion = async ({ name, version, label, zipBuffer }) => {
157
+ if (!version) {
158
+ httpError(400, 'version is required');
159
+ }
160
+ let app = await models.app.findOne({ where: { name } });
161
+ if (!app) {
162
+ httpError(404, `app ${name} not found; create it first`);
163
+ }
164
+ const dup = await models.appVersion.findOne({ where: { appName: name, version } });
165
+ if (dup) {
166
+ httpError(409, `version ${version} already exists`);
167
+ }
168
+
169
+ const versionRow = await models.appVersion.create({
170
+ appName: name,
171
+ version,
172
+ label: label || null,
173
+ artifactPath: path.join(app.rootPath, 'versions', 'pending'),
174
+ hasMigration: false,
175
+ migrationPath: options.sqlPath
176
+ });
177
+
178
+ const artifactPath = path.join(app.rootPath, 'versions', String(versionRow.id));
179
+ try {
180
+ const prepared = await prepareVersionArtifact({
181
+ zipBuffer,
182
+ artifactPath,
183
+ maxZipSize: options.maxZipSize,
184
+ maxZipEntries: options.maxZipEntries,
185
+ npmInstallTimeoutMs: options.npmInstallTimeoutMs,
186
+ sqlPath: options.sqlPath
187
+ });
188
+ await versionRow.update({
189
+ artifactPath,
190
+ hasMigration: prepared.hasMigration,
191
+ migrationPath: prepared.migrationPath
192
+ });
193
+ return versionRow.toJSON();
194
+ } catch (e) {
195
+ await fs.remove(artifactPath).catch(() => {});
196
+ await versionRow.destroy().catch(() => {});
197
+ httpError(400, e.message);
198
+ }
199
+ };
200
+
201
+ const listVersions = async ({ name, perPage = 20, currentPage = 1 }) => {
202
+ await getByName(name);
203
+ const { rows, count } = await models.appVersion.findAndCountAll({
204
+ where: { appName: name },
205
+ offset: perPage * (currentPage - 1),
206
+ limit: perPage,
207
+ order: [['createdAt', 'DESC']]
208
+ });
209
+ const pageData = await Promise.all(
210
+ rows.map(async row => {
211
+ const json = row.toJSON();
212
+ if (json.hasMigration) {
213
+ json.sqlFiles = await listSqlFiles(path.join(json.artifactPath, 'server', json.migrationPath || 'sql'));
214
+ }
215
+ return json;
216
+ })
217
+ );
218
+ return { pageData, totalCount: count };
219
+ };
220
+
221
+ const healthCheck = async (port, { timeoutMs, intervalMs, healthPath }) => {
222
+ const started = Date.now();
223
+ const url = `http://127.0.0.1:${port}${healthPath || '/'}`;
224
+ while (Date.now() - started < timeoutMs) {
225
+ try {
226
+ const res = await fetch(url, { method: 'GET' });
227
+ if (res.status >= 200 && res.status < 500) {
228
+ return true;
229
+ }
230
+ } catch (e) {
231
+ // retry
232
+ }
233
+ await new Promise(r => setTimeout(r, intervalMs));
234
+ }
235
+ return false;
236
+ };
237
+
238
+ const startProcess = async app => {
239
+ const version = await models.appVersion.findByPk(app.currentVersionId);
240
+ if (!version) {
241
+ throw new Error('current version missing');
242
+ }
243
+ const serverDir = path.join(version.artifactPath, 'server');
244
+ const { outFile, errFile } = await ensureLogFiles(app.rootPath);
245
+ const env = mergeEnv({
246
+ passthroughEnvKeys: options.passthroughEnvKeys,
247
+ appEnv: app.env,
248
+ port: app.port
249
+ });
250
+
251
+ const pathBase = mountPrefix(app.name);
252
+ // Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
253
+ // Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
254
+ const publicUrl = app.domain ? '/' : pathBase;
255
+ await injectEntryHtml({
256
+ buildDir: path.join(serverDir, 'build'),
257
+ appName: app.name,
258
+ publicUrl,
259
+ apiUrl: publicUrl
260
+ });
261
+
262
+ await pm2Util.startApp({
263
+ pm2Name: app.pm2Name,
264
+ cwd: serverDir,
265
+ env,
266
+ pm2Defaults: options.pm2Defaults,
267
+ pm2Config: app.pm2Config,
268
+ outFile,
269
+ errorFile: errFile
270
+ });
271
+ };
272
+
273
+ const finishDeployAsync = async appId => {
274
+ const app = await models.app.findByPk(appId);
275
+ if (!app) {
276
+ return;
277
+ }
278
+ try {
279
+ const ok = await healthCheck(app.port, {
280
+ timeoutMs: options.healthCheckTimeoutMs,
281
+ intervalMs: options.healthCheckIntervalMs,
282
+ healthPath: options.healthCheckPath
283
+ });
284
+ if (ok) {
285
+ await app.update({ status: 'running', message: null });
286
+ } else {
287
+ await app.update({ status: 'error', message: 'health check timeout' });
288
+ }
289
+ } catch (e) {
290
+ await app.update({ status: 'error', message: e.message });
291
+ }
292
+ };
293
+
294
+ const deploy = async ({ name, versionId, version, runMigration = true }) => {
295
+ const app = await getByName(name);
296
+ let ver = null;
297
+ if (versionId) {
298
+ ver = await models.appVersion.findByPk(versionId);
299
+ } else if (version) {
300
+ ver = await models.appVersion.findOne({ where: { appName: name, version } });
301
+ }
302
+ if (!ver || ver.appName !== name) {
303
+ httpError(404, 'version not found');
304
+ }
305
+
306
+ await app.update({
307
+ status: 'deploying',
308
+ currentVersionId: String(ver.id),
309
+ message: null
310
+ });
311
+
312
+ const currentLink = path.join(app.rootPath, 'current');
313
+ await fs.remove(currentLink).catch(() => {});
314
+ await fs.symlink(ver.artifactPath, currentLink).catch(async () => {
315
+ // Windows fallback: copy marker file
316
+ await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), ver.artifactPath);
317
+ });
318
+
319
+ try {
320
+ if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
321
+ const env = mergeEnv({
322
+ passthroughEnvKeys: options.passthroughEnvKeys,
323
+ appEnv: app.env,
324
+ port: app.port
325
+ });
326
+ await runSqlMigrations({
327
+ serverDir: path.join(ver.artifactPath, 'server'),
328
+ sqlPath: ver.migrationPath || options.sqlPath,
329
+ env
330
+ });
331
+ }
332
+
333
+ if (runMigration === false) {
334
+ // child may honor RUN_SQL_ON_SYNC
335
+ app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
336
+ }
337
+
338
+ await startProcess(app);
339
+ setImmediate(() => finishDeployAsync(app.id));
340
+ return {
341
+ name: app.name,
342
+ status: 'deploying',
343
+ versionId: String(ver.id),
344
+ port: app.port,
345
+ pathUrl: `${mountPrefix(app.name)}/`,
346
+ domain: app.domain
347
+ };
348
+ } catch (e) {
349
+ await app.update({ status: 'error', message: e.message });
350
+ httpError(400, e.message);
351
+ }
352
+ };
353
+
354
+ const stop = async ({ name }) => {
355
+ const app = await getByName(name);
356
+ try {
357
+ await pm2Util.stopApp(app.pm2Name);
358
+ } catch (e) {
359
+ // ignore if not running
360
+ }
361
+ await app.update({ status: 'stopped' });
362
+ return toPublicApp(app);
363
+ };
364
+
365
+ const start = async ({ name }) => {
366
+ const app = await getByName(name);
367
+ if (!app.currentVersionId) {
368
+ httpError(400, 'no version deployed');
369
+ }
370
+ await app.update({ status: 'deploying', message: null });
371
+ try {
372
+ await startProcess(app);
373
+ setImmediate(() => finishDeployAsync(app.id));
374
+ return toPublicApp(await getByName(name));
375
+ } catch (e) {
376
+ await app.update({ status: 'error', message: e.message });
377
+ httpError(400, e.message);
378
+ }
379
+ };
380
+
381
+ const restart = async ({ name }) => {
382
+ const app = await getByName(name);
383
+ await app.update({ status: 'deploying', message: null });
384
+ try {
385
+ await pm2Util.restartApp(app.pm2Name);
386
+ setImmediate(() => finishDeployAsync(app.id));
387
+ return toPublicApp(await getByName(name));
388
+ } catch (e) {
389
+ try {
390
+ await startProcess(app);
391
+ setImmediate(() => finishDeployAsync(app.id));
392
+ return toPublicApp(await getByName(name));
393
+ } catch (e2) {
394
+ await app.update({ status: 'error', message: e2.message });
395
+ httpError(400, e2.message);
396
+ }
397
+ }
398
+ };
399
+
400
+ const remove = async ({ name }) => {
401
+ const app = await getByName(name);
402
+ try {
403
+ await pm2Util.deleteProcess(app.pm2Name);
404
+ } catch (e) {
405
+ // ignore
406
+ }
407
+ await models.appVersion.destroy({ where: { appName: name } });
408
+ await fs.remove(app.rootPath).catch(() => {});
409
+ await app.destroy();
410
+ return {};
411
+ };
412
+
413
+ const logs = async ({ name, stream = 'out', perPage = 100, currentPage = 1 }) => {
414
+ const app = await getByName(name);
415
+ const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
416
+ return readLogTail(file, { perPage, currentPage });
417
+ };
418
+
419
+ const findByHost = async host => {
420
+ if (!host) {
421
+ return null;
422
+ }
423
+ const hostname = String(host).split(':')[0].toLowerCase();
424
+ return models.app.findOne({ where: { domain: hostname, status: 'running' } });
425
+ };
426
+
427
+ const findByPathName = async name => {
428
+ return models.app.findOne({ where: { name, status: 'running' } });
429
+ };
430
+
431
+ Object.assign(fastify[options.name].services, {
432
+ app: {
433
+ create,
434
+ save,
435
+ saveEnv,
436
+ list,
437
+ detail,
438
+ uploadVersion,
439
+ listVersions,
440
+ deploy,
441
+ start,
442
+ stop,
443
+ restart,
444
+ remove,
445
+ logs,
446
+ toPublicApp,
447
+ mountPrefix,
448
+ findByHost,
449
+ findByPathName,
450
+ readLastLines: async (name, stream, n) => {
451
+ const app = await getByName(name);
452
+ const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
453
+ return readLastLines(file, n);
454
+ },
455
+ getLogHub,
456
+ appendAppLog: async (appName, stream, content) => {
457
+ const app = await models.app.findOne({ where: { name: appName } });
458
+ if (!app) {
459
+ return;
460
+ }
461
+ const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
462
+ await appendLog(file, content);
463
+ const hub = getLogHub();
464
+ if (hub) {
465
+ hub.emit(`log:${appName}`, { appName, stream, content, loggedAt: new Date().toISOString() });
466
+ }
467
+ }
468
+ }
469
+ });
470
+ });
@@ -0,0 +1,102 @@
1
+ const fp = require('fastify-plugin');
2
+ const { EventEmitter } = require('node:events');
3
+ const pm2Util = require('../utils/pm2');
4
+
5
+ module.exports = fp(async (fastify, options) => {
6
+ const logHub = new EventEmitter();
7
+ logHub.setMaxListeners(0);
8
+ fastify[options.name].logHub = logHub;
9
+
10
+ let bus = null;
11
+ let connected = false;
12
+
13
+ const mapPm2NameToApp = async pm2Name => {
14
+ const { models } = fastify[options.name];
15
+ return models.app.findOne({ where: { pm2Name } });
16
+ };
17
+
18
+ const onReady = async () => {
19
+ await fsEnsureAppsRoot();
20
+ try {
21
+ await pm2Util.connect();
22
+ connected = true;
23
+ } catch (e) {
24
+ fastify.log.warn({ err: e }, 'pm2 connect failed');
25
+ return;
26
+ }
27
+
28
+ try {
29
+ bus = await pm2Util.launchBus();
30
+ const handle = stream => async packet => {
31
+ try {
32
+ const processName = packet?.process?.name;
33
+ if (!processName) {
34
+ return;
35
+ }
36
+ const app = await mapPm2NameToApp(processName);
37
+ if (!app) {
38
+ return;
39
+ }
40
+ const content = typeof packet.data === 'string' ? packet.data : String(packet.data || '');
41
+ if (!content) {
42
+ return;
43
+ }
44
+ await fastify[options.name].services.app.appendAppLog(app.name, stream, content);
45
+ } catch (e) {
46
+ fastify.log.debug({ err: e }, 'log bus handler error');
47
+ }
48
+ };
49
+ bus.on('log:out', handle('out'));
50
+ bus.on('log:err', handle('err'));
51
+ } catch (e) {
52
+ fastify.log.warn({ err: e }, 'pm2 launchBus failed');
53
+ }
54
+
55
+ await reconcile();
56
+ };
57
+
58
+ const fsEnsureAppsRoot = async () => {
59
+ const fs = require('fs-extra');
60
+ await fs.ensureDir(options.appsRoot);
61
+ };
62
+
63
+ const reconcile = async () => {
64
+ const { models, services } = fastify[options.name];
65
+ const apps = await models.app.findAll({
66
+ where: { status: ['running', 'deploying'] }
67
+ });
68
+ let list = [];
69
+ try {
70
+ list = await pm2Util.list();
71
+ } catch (e) {
72
+ return;
73
+ }
74
+ const runningNames = new Set(list.filter(p => p.pm2_env?.status === 'online').map(p => p.name));
75
+ for (const app of apps) {
76
+ if (!runningNames.has(app.pm2Name)) {
77
+ try {
78
+ await services.app.start({ name: app.name });
79
+ } catch (e) {
80
+ await app.update({ status: 'error', message: `reconcile failed: ${e.message}` });
81
+ }
82
+ }
83
+ }
84
+ };
85
+
86
+ const onClose = async () => {
87
+ if (bus) {
88
+ try {
89
+ bus.close?.();
90
+ } catch (e) {
91
+ // ignore
92
+ }
93
+ }
94
+ if (connected) {
95
+ pm2Util.disconnect();
96
+ }
97
+ };
98
+
99
+ Object.assign(fastify[options.name].services, {
100
+ bootstrap: { onReady, onClose, reconcile }
101
+ });
102
+ });
@@ -0,0 +1,76 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { JSDOM } = require('jsdom');
4
+ const ensureSlash = require('@kne/ensure-slash');
5
+
6
+ const ENTRY_FILES = ['entry.html', 'entry-prod.html', 'index.html'];
7
+
8
+ const normalizeBase = url => {
9
+ if (url == null || url === '' || url === '/') {
10
+ return '/';
11
+ }
12
+ return ensureSlash(url, true).replace(/\/$/, '');
13
+ };
14
+
15
+ const injectEntryHtml = async ({ buildDir, appName, publicUrl, apiUrl }) => {
16
+ const normalizedPublic = normalizeBase(publicUrl);
17
+ const normalizedApi = apiUrl == null || apiUrl === '' ? normalizedPublic : normalizeBase(apiUrl);
18
+ const publicPath = normalizedPublic === '/' ? '/' : `${normalizedPublic}/`;
19
+
20
+ const replaceTasks = [
21
+ { origin: 'static/js', target: `${publicPath}static/js` },
22
+ { origin: '/static/js', target: `${publicPath}static/js` },
23
+ { origin: 'remoteEntry.js', target: `${publicPath}remoteEntry.js` },
24
+ { origin: '/remoteEntry.js', target: `${publicPath}remoteEntry.js` }
25
+ ];
26
+
27
+ let injected = 0;
28
+ for (const fileName of ENTRY_FILES) {
29
+ const filePath = path.join(buildDir, fileName);
30
+ if (!(await fs.pathExists(filePath))) {
31
+ continue;
32
+ }
33
+ const html = await fs.readFile(filePath, 'utf8');
34
+ const dom = new JSDOM(html);
35
+ const { window } = dom;
36
+ const doc = window.document;
37
+
38
+ [].forEach.call(doc.head.children, el => {
39
+ ['src', 'href'].forEach(attr => {
40
+ const attrValue = el.getAttribute(attr);
41
+ if (!attrValue) {
42
+ return;
43
+ }
44
+ replaceTasks.forEach(({ origin, target }) => {
45
+ if (attrValue.startsWith(origin)) {
46
+ el.setAttribute(attr, target + attrValue.slice(origin.length));
47
+ }
48
+ });
49
+ });
50
+ });
51
+
52
+ [].forEach.call(doc.head.querySelectorAll('script'), el => {
53
+ if ((el.textContent || '').includes('window.runtimePublicUrl')) {
54
+ el.remove();
55
+ }
56
+ });
57
+
58
+ const scriptEl = doc.createElement('script');
59
+ scriptEl.textContent =
60
+ `window.runtimeAppName=${JSON.stringify(appName || '')};` +
61
+ `window.runtimePublicUrl=${JSON.stringify(normalizedPublic)};` +
62
+ `window.runtimeApiUrl=${JSON.stringify(normalizedApi)};` +
63
+ `window.__webpack_public_path__=${JSON.stringify(publicPath)};`;
64
+ doc.head.insertBefore(scriptEl, doc.head.firstChild);
65
+
66
+ await fs.writeFile(filePath, dom.serialize());
67
+ injected += 1;
68
+ }
69
+ return injected;
70
+ };
71
+
72
+ module.exports = {
73
+ ENTRY_FILES,
74
+ normalizeBase,
75
+ injectEntryHtml
76
+ };