Skip to content

Commit bf83be7

Browse files
committed
improvements
1 parent a48d4b0 commit bf83be7

12 files changed

Lines changed: 176 additions & 133 deletions

File tree

lib/service/deployRunner.js

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -352,11 +352,18 @@ export async function runDeploy(deployment, { isRedeploy }) {
352352
// git repository (e.g. the initial clone failed), we must fall back to a
353353
// fresh clone rather than attempting a pull into a non-existent or broken
354354
// directory.
355-
const hasGitDir = await fsp.stat(path.join(deploy_path, '.git')).then(() => true).catch(() => false);
355+
const hasGitDir = await fsp
356+
.stat(path.join(deploy_path, '.git'))
357+
.then(() => true)
358+
.catch(() => false);
356359
if (isRedeploy && hasGitDir) {
357360
// Check for tracked local changes that would cause git pull --rebase to fail.
358361
// Untracked files (lines starting with ??) do not affect the pull and are skipped.
359-
const statusOutput = await captureStep('git', ['-C', deploy_path, 'status', '--porcelain'], config.DEPLOY_BASE_DIR);
362+
const statusOutput = await captureStep(
363+
'git',
364+
['-C', deploy_path, 'status', '--porcelain'],
365+
config.DEPLOY_BASE_DIR,
366+
);
360367
const trackedChanges = statusOutput
361368
.split('\n')
362369
.filter((l) => l.length >= 2 && !l.startsWith('??'))
@@ -372,7 +379,13 @@ export async function runDeploy(deployment, { isRedeploy }) {
372379
}
373380

374381
broadcast('clone', `Pulling ${branch} from origin...\n`, 'running');
375-
await spawnStep('git', ['-C', deploy_path, 'pull', '--rebase', 'origin', branch], config.DEPLOY_BASE_DIR, id, 'clone');
382+
await spawnStep(
383+
'git',
384+
['-C', deploy_path, 'pull', '--rebase', 'origin', branch],
385+
config.DEPLOY_BASE_DIR,
386+
id,
387+
'clone',
388+
);
376389
} else {
377390
// Remove a leftover partial directory from a previously failed clone so
378391
// git does not refuse to clone into a non-empty destination.
@@ -391,14 +404,16 @@ export async function runDeploy(deployment, { isRedeploy }) {
391404
// 3. Install
392405
if (install_cmd && install_cmd !== 'skip') {
393406
broadcast('install', `Running ${install_cmd}...\n`, 'running');
394-
const [cmd, ...args] = install_cmd.split(' ');
407+
// Split on runs of whitespace so extra/leading spaces never produce empty
408+
// argv entries (which a process like npm would reject).
409+
const [cmd, ...args] = install_cmd.trim().split(/\s+/);
395410
await spawnStep(cmd, args, deploy_path, id, 'install');
396411
}
397412

398413
// 4. Build
399414
if (build_cmd) {
400415
broadcast('build', `Running ${build_cmd}...\n`, 'running');
401-
const [cmd, ...args] = build_cmd.split(' ');
416+
const [cmd, ...args] = build_cmd.trim().split(/\s+/);
402417
await spawnStep(cmd, args, deploy_path, id, 'build');
403418
}
404419

@@ -409,7 +424,11 @@ export async function runDeploy(deployment, { isRedeploy }) {
409424
}
410425

411426
// 6. Start or restart
412-
broadcast('start', isRedeploy ? `Restarting PM2 process ${pm2_name}...\n` : `Starting PM2 process ${pm2_name}...\n`, 'running');
427+
broadcast(
428+
'start',
429+
isRedeploy ? `Restarting PM2 process ${pm2_name}...\n` : `Starting PM2 process ${pm2_name}...\n`,
430+
'running',
431+
);
413432

414433
if (isRedeploy) {
415434
// Delete first so PM2 picks up updated options (env vars, interpreter
@@ -427,7 +446,10 @@ export async function runDeploy(deployment, { isRedeploy }) {
427446
// Verify the start script exists before handing off to PM2, so a missing
428447
// file produces a clear message rather than a silent PM2 failure.
429448
const scriptPath = path.join(pm2Opts.cwd, pm2Opts.script);
430-
const scriptExists = await fsp.stat(scriptPath).then(() => true).catch(() => false);
449+
const scriptExists = await fsp
450+
.stat(scriptPath)
451+
.then(() => true)
452+
.catch(() => false);
431453
if (!scriptExists) {
432454
throw new Error(`Start script not found: ${scriptPath}`);
433455
}

lib/service/logBus.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,25 @@ import { evaluateAndDispatch } from './alertingService.js';
2525
import logger from './logger.js';
2626

2727
const pm2Connect = promisify(pm2.connect.bind(pm2));
28-
const pm2LaunchBus = promisify(pm2.launchBus.bind(pm2));
28+
29+
/**
30+
* Launch the PM2 event bus, resolving with both the event emitter and the
31+
* underlying axon socket.
32+
*
33+
* `promisify` cannot be used here: it discards the second callback argument
34+
* (the socket), which carries the connection lifecycle events the reconnect
35+
* logging below relies on.
36+
*
37+
* @returns {Promise<{ bus: import('node:events').EventEmitter, busSocket: import('node:events').EventEmitter }>}
38+
*/
39+
function launchBus() {
40+
return new Promise((resolve, reject) => {
41+
pm2.launchBus((err, bus, busSocket) => {
42+
if (err) reject(err);
43+
else resolve({ bus, busSocket });
44+
});
45+
});
46+
}
2947

3048
/** Internal event emitter -- one 'log' event per line for all processes. */
3149
const emitter = new EventEmitter();
@@ -47,7 +65,7 @@ export async function startLogBus() {
4765
try {
4866
// pm2.connect is idempotent -- reuses existing connection from pm2Service.
4967
await pm2Connect();
50-
const bus = await pm2LaunchBus();
68+
const { bus, busSocket } = await launchBus();
5169

5270
/**
5371
* Handle a raw PM2 bus packet.
@@ -97,6 +115,20 @@ export async function startLogBus() {
97115
logger.warn(`[LOG_BUS] Bus error: ${err?.message ?? err}`);
98116
});
99117

118+
// The bus runs over an auto-reconnecting axon socket (retry with backoff,
119+
// indefinitely). The emitter is reused across reconnects, so the listeners
120+
// above keep working without re-subscribing -- re-launching the bus would
121+
// open a second socket and duplicate every log line. We only surface the
122+
// socket lifecycle so a daemon restart and its recovery are visible in logs.
123+
if (busSocket && typeof busSocket.on === 'function') {
124+
busSocket.on('reconnect attempt', () => {
125+
logger.warn('[LOG_BUS] PM2 connection lost - reconnecting...');
126+
});
127+
busSocket.on('connect', () => {
128+
logger.info('[LOG_BUS] Reconnected to the PM2 bus.');
129+
});
130+
}
131+
100132
logger.info('[LOG_BUS] Started');
101133
} catch (err) {
102134
logger.warn(`[LOG_BUS] Failed to start: ${err.message}`);

lib/service/logger.js

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55

66
/** @type {Record<string, string>} ANSI escape codes for coloring log levels in terminal output */
77
const COLORS = {
8-
debug: '\x1b[36m', // cyan
9-
info: '\x1b[32m', // green
10-
warn: '\x1b[33m', // yellow
11-
error: '\x1b[31m', // red
12-
reset: '\x1b[0m',
8+
debug: '\x1b[36m', // cyan
9+
info: '\x1b[32m', // green
10+
warn: '\x1b[33m', // yellow
11+
error: '\x1b[31m', // red
12+
reset: '\x1b[0m',
1313
};
1414

1515
const env = process.env.NODE_ENV || 'development';
@@ -20,15 +20,15 @@ const useColor = process.stdout.isTTY || process.stderr.isTTY;
2020
* Returns a formatted timestamp string.
2121
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SS` format
2222
*/
23-
function ts() {
24-
const d = new Date();
25-
const yyyy = d.getFullYear();
26-
const mm = String(d.getMonth() + 1).padStart(2, '0');
27-
const dd = String(d.getDate()).padStart(2, '0');
28-
const hh = String(d.getHours()).padStart(2, '0');
29-
const mi = String(d.getMinutes()).padStart(2, '0');
30-
const ss = String(d.getSeconds()).padStart(2, '0');
31-
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
23+
function ts() {
24+
const d = new Date();
25+
const yyyy = d.getFullYear();
26+
const mm = String(d.getMonth() + 1).padStart(2, '0');
27+
const dd = String(d.getDate()).padStart(2, '0');
28+
const hh = String(d.getHours()).padStart(2, '0');
29+
const mi = String(d.getMinutes()).padStart(2, '0');
30+
const ss = String(d.getSeconds()).padStart(2, '0');
31+
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
3232
}
3333

3434
/**
@@ -37,9 +37,9 @@ function ts() {
3737
* @returns {string} Uppercased level label, optionally colorized
3838
*/
3939
function lvl(level) {
40-
const upper = level.toUpperCase();
41-
if (!useColor) return upper;
42-
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
40+
const upper = level.toUpperCase();
41+
if (!useColor) return upper;
42+
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
4343
}
4444

4545
/* eslint-disable no-console */
@@ -50,27 +50,27 @@ function lvl(level) {
5050
* @param {...*} args - Values to log
5151
*/
5252
function log(level, ...args) {
53-
if (level === 'debug' && env !== 'development') {
54-
return;
55-
}
53+
if (level === 'debug' && env !== 'development') {
54+
return;
55+
}
5656

57-
const prefix = `[${ts()}] ${lvl(level)}:`;
58-
switch (level) {
59-
case 'debug':
60-
console.debug(prefix, ...args);
61-
break;
62-
case 'info':
63-
console.info(prefix, ...args);
64-
break;
65-
case 'warn':
66-
console.warn(prefix, ...args);
67-
break;
68-
case 'error':
69-
console.error(prefix, ...args);
70-
break;
71-
default:
72-
console.log(prefix, ...args);
73-
}
57+
const prefix = `[${ts()}] ${lvl(level)}:`;
58+
switch (level) {
59+
case 'debug':
60+
console.debug(prefix, ...args);
61+
break;
62+
case 'info':
63+
console.info(prefix, ...args);
64+
break;
65+
case 'warn':
66+
console.warn(prefix, ...args);
67+
break;
68+
case 'error':
69+
console.error(prefix, ...args);
70+
break;
71+
default:
72+
console.log(prefix, ...args);
73+
}
7474
}
7575

7676
/**
@@ -79,12 +79,12 @@ function log(level, ...args) {
7979
* @namespace logger
8080
*/
8181
export default {
82-
/** @param {...*} a - Values to log at debug level */
83-
debug: (...a) => log('debug', ...a),
84-
/** @param {...*} a - Values to log at info level */
85-
info: (...a) => log('info', ...a),
86-
/** @param {...*} a - Values to log at warn level */
87-
warn: (...a) => log('warn', ...a),
88-
/** @param {...*} a - Values to log at error level */
89-
error: (...a) => log('error', ...a),
82+
/** @param {...*} a - Values to log at debug level */
83+
debug: (...a) => log('debug', ...a),
84+
/** @param {...*} a - Values to log at info level */
85+
info: (...a) => log('info', ...a),
86+
/** @param {...*} a - Values to log at warn level */
87+
warn: (...a) => log('warn', ...a),
88+
/** @param {...*} a - Values to log at error level */
89+
error: (...a) => log('error', ...a),
9090
};

lib/service/updateChecker.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ const { version: CURRENT_VERSION } = require(path.join(__dirname, '..', '..', 'p
4040
* @returns {number[]|null} Tuple of three numbers, or null if unparseable.
4141
*/
4242
function parseSemver(v) {
43-
const m = String(v).replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
43+
const m = String(v)
44+
.replace(/^v/, '')
45+
.match(/^(\d+)\.(\d+)\.(\d+)/);
4446
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
4547
}
4648

lib/storage/deploymentStorage.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,7 @@ export function getDeploymentById(id) {
102102
* @returns {object[]}
103103
*/
104104
export function getAllDeployments() {
105-
return getDb()
106-
.prepare('SELECT * FROM deployments ORDER BY created_at DESC')
107-
.all()
108-
.map(parseRow);
105+
return getDb().prepare('SELECT * FROM deployments ORDER BY created_at DESC').all().map(parseRow);
109106
}
110107

111108
/**
@@ -126,9 +123,7 @@ export function setDeploying(id, deploying) {
126123
* @param {string} id
127124
*/
128125
export function updateLastDeployed(id) {
129-
getDb()
130-
.prepare('UPDATE deployments SET last_deployed_at = ? WHERE id = ?')
131-
.run(Date.now(), id);
126+
getDb().prepare('UPDATE deployments SET last_deployed_at = ? WHERE id = ?').run(Date.now(), id);
132127
}
133128

134129
/**

lib/transport/router.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -837,12 +837,10 @@ router.post('/api/deployments', requireAuth, async (req, res) => {
837837
.json({ error: 'Invalid app name. Use alphanumeric characters, dashes, and underscores (max 64 chars).' });
838838
}
839839
if (!validateRepoUrl(repoUrl)) {
840-
return res
841-
.status(400)
842-
.json({
843-
error:
844-
'Invalid repo URL. Accepted: HTTPS GitHub/GitLab URLs or SSH git URLs (e.g. git@github.com:user/repo.git).',
845-
});
840+
return res.status(400).json({
841+
error:
842+
'Invalid repo URL. Accepted: HTTPS GitHub/GitLab URLs or SSH git URLs (e.g. git@github.com:user/repo.git).',
843+
});
846844
}
847845

848846
let deployPath;
@@ -920,12 +918,10 @@ router.put('/api/deployments/:deploymentId', requireAuth, validateDeploymentId,
920918
} = req.body;
921919

922920
if (!validateRepoUrl(repoUrl)) {
923-
return res
924-
.status(400)
925-
.json({
926-
error:
927-
'Invalid repo URL. Accepted: HTTPS GitHub/GitLab URLs or SSH git URLs (e.g. git@github.com:user/repo.git).',
928-
});
921+
return res.status(400).json({
922+
error:
923+
'Invalid repo URL. Accepted: HTTPS GitHub/GitLab URLs or SSH git URLs (e.g. git@github.com:user/repo.git).',
924+
});
929925
}
930926

931927
try {

lib/transport/ws.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ let _wss = null;
3131

3232
// Helpers ──────────────────────────────────────────────────────────────────
3333

34-
/** Authenticate a WebSocket upgrade request via session cookie. */
35-
function authenticate(req) {
36-
const cookieHeader = req.headers['cookie'] || '';
37-
const cookies = parseCookies(cookieHeader);
38-
return getAuthenticatedSession({ cookies });
39-
}
40-
4134
/** Send a JSON message on a WebSocket if it is still open. */
4235
function send(ws, type, data) {
4336
if (ws.readyState === WebSocket.OPEN) {
@@ -128,8 +121,10 @@ async function sendProcessList(ws) {
128121
* Handle the single unified WebSocket stream.
129122
*
130123
* @param {import('ws').WebSocket} ws
124+
* @param {Record<string, string>} cookies - Parsed request cookies, used to
125+
* re-validate the backing session for the lifetime of the connection.
131126
*/
132-
function handleUnifiedStream(ws) {
127+
function handleUnifiedStream(ws, cookies) {
133128
// Per-connection selection state.
134129
let selectionGeneration = 0;
135130
let detailInterval = null;
@@ -149,8 +144,17 @@ function handleUnifiedStream(ws) {
149144
sendProcessList(ws);
150145
const processListInterval = setInterval(() => sendProcessList(ws), 3000);
151146

152-
// Heartbeat — keeps the connection alive through proxies / NAT.
153-
const heartbeatInterval = setInterval(() => send(ws, 'heartbeat', {}), 15000);
147+
// Heartbeat — keeps the connection alive through proxies / NAT, and re-checks
148+
// that the backing session is still valid. A WebSocket is authenticated only
149+
// once at upgrade; without this the connection would keep streaming after the
150+
// session expired or was destroyed (e.g. by logout).
151+
const heartbeatInterval = setInterval(() => {
152+
if (!getAuthenticatedSession({ cookies })) {
153+
ws.close(4001, 'session expired');
154+
return;
155+
}
156+
send(ws, 'heartbeat', {});
157+
}, 15000);
154158

155159
/**
156160
* Tear down the current per-process subscriptions.
@@ -321,7 +325,8 @@ export function attachWebSocketServer(server) {
321325
_wss.on('close', () => clearInterval(heartbeat));
322326

323327
server.on('upgrade', (req, socket, head) => {
324-
const session = authenticate(req);
328+
const cookies = parseCookies(req.headers['cookie'] || '');
329+
const session = getAuthenticatedSession({ cookies });
325330
if (!session) {
326331
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
327332
socket.destroy();
@@ -330,7 +335,7 @@ export function attachWebSocketServer(server) {
330335

331336
if (req.url === STREAM_PATH) {
332337
_wss.handleUpgrade(req, socket, head, (ws) => {
333-
handleUnifiedStream(ws);
338+
handleUnifiedStream(ws, cookies);
334339
});
335340
return;
336341
}

0 commit comments

Comments
 (0)