mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix: security hardening, SSE robustness, and API consistency
Security: - Add sanitizeHookData() to whitelist/truncate hook event fields before SSE broadcast - Add MAX_INPUT_LENGTH (64KB) validation on session input endpoint - Add MAX_TERMINAL_COLS/ROWS bounds on resize endpoint - Add MAX_SESSION_NAME_LENGTH on rename endpoint - Remove Access-Control-Allow-Origin: * from SSE (same-origin only) - Add X-Accel-Buffering: no header for nginx proxy compatibility Robustness: - Add SSE keep-alive comments in health check cycle (prevents proxy timeouts) - Guard broadcast() against JSON.stringify failures (circular refs, BigInt) - Prevent concurrent cleanup races with cleaningUp guard set - Clear stateUpdatePending on session cleanup API consistency: - Normalize error responses to use createErrorResponse() with error codes - Remove dead (session as any).ralphConfig assignment + unused destructuring Performance: - Pre-compile CLAUDE_BANNER_PATTERN, CTRL_L_PATTERN, LEADING_WHITESPACE_PATTERN Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2197,11 +2197,9 @@ class ClaudemanApp {
|
|||||||
// Populate Ralph Wiggum form with current session values
|
// Populate Ralph Wiggum form with current session values
|
||||||
const ralphState = this.ralphStates.get(sessionId);
|
const ralphState = this.ralphStates.get(sessionId);
|
||||||
this.populateRalphForm({
|
this.populateRalphForm({
|
||||||
enabled: ralphState?.loop?.enabled ?? session.ralphConfig?.enabled ?? false,
|
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
||||||
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
||||||
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
||||||
maxTodos: session.ralphConfig?.maxTodos || 50,
|
|
||||||
todoExpirationMinutes: session.ralphConfig?.todoExpirationMinutes || 60
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('sessionOptionsModal').classList.add('active');
|
document.getElementById('sessionOptionsModal').classList.add('active');
|
||||||
|
|||||||
+127
-45
@@ -80,6 +80,63 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
|||||||
const MAX_CONCURRENT_SESSIONS = 50;
|
const MAX_CONCURRENT_SESSIONS = 50;
|
||||||
// SSE client health check interval (every 30 seconds)
|
// SSE client health check interval (every 30 seconds)
|
||||||
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
||||||
|
// Maximum allowed input length for session write (64KB)
|
||||||
|
const MAX_INPUT_LENGTH = 64 * 1024;
|
||||||
|
// Maximum terminal resize dimensions
|
||||||
|
const MAX_TERMINAL_COLS = 500;
|
||||||
|
const MAX_TERMINAL_ROWS = 200;
|
||||||
|
// Maximum session name length
|
||||||
|
const MAX_SESSION_NAME_LENGTH = 128;
|
||||||
|
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||||
|
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||||
|
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||||
|
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||||||
|
const CTRL_L_PATTERN = /\x0c/g;
|
||||||
|
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes hook event data before broadcasting via SSE.
|
||||||
|
* Extracts only relevant fields and limits total size to prevent
|
||||||
|
* oversized payloads from being broadcast to all connected clients.
|
||||||
|
*/
|
||||||
|
function sanitizeHookData(data: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||||
|
if (!data || typeof data !== 'object') return {};
|
||||||
|
|
||||||
|
// Only forward known safe fields from Claude Code hook stdin
|
||||||
|
const safeFields: Record<string, unknown> = {};
|
||||||
|
const allowedKeys = [
|
||||||
|
'hook_event_name', 'tool_name', 'tool_input', 'session_id',
|
||||||
|
'cwd', 'permission_mode', 'stop_hook_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of allowedKeys) {
|
||||||
|
if (key in data && data[key] !== undefined) {
|
||||||
|
safeFields[key] = data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tool_input, extract only summary fields (not full file content)
|
||||||
|
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
|
||||||
|
const input = safeFields.tool_input as Record<string, unknown>;
|
||||||
|
const summary: Record<string, unknown> = {};
|
||||||
|
if (input.command) summary.command = String(input.command).slice(0, 500);
|
||||||
|
if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500);
|
||||||
|
if (input.description) summary.description = String(input.description).slice(0, 200);
|
||||||
|
if (input.query) summary.query = String(input.query).slice(0, 200);
|
||||||
|
if (input.url) summary.url = String(input.url).slice(0, 500);
|
||||||
|
if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200);
|
||||||
|
if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200);
|
||||||
|
safeFields.tool_input = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final size check - drop if serialized data exceeds limit
|
||||||
|
const serialized = JSON.stringify(safeFields);
|
||||||
|
if (serialized.length > MAX_HOOK_DATA_SIZE) {
|
||||||
|
return { tool_name: safeFields.tool_name, _truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeFields;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-configure Ralph tracker for a session.
|
* Auto-configure Ralph tracker for a session.
|
||||||
@@ -241,7 +298,7 @@ export class WebServer extends EventEmitter {
|
|||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'Connection': 'keep-alive',
|
'Connection': 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sseClients.add(reply);
|
this.sseClients.add(reply);
|
||||||
@@ -304,10 +361,11 @@ export class WebServer extends EventEmitter {
|
|||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { error: 'Session not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
session.name = body.name || '';
|
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||||
|
session.name = name;
|
||||||
// Also update the screen name if this session has a screen
|
// Also update the screen name if this session has a screen
|
||||||
this.screenManager.updateScreenName(id, session.name);
|
this.screenManager.updateScreenName(id, session.name);
|
||||||
this.persistSessionState(session);
|
this.persistSessionState(session);
|
||||||
@@ -394,12 +452,10 @@ export class WebServer extends EventEmitter {
|
|||||||
// Configure Ralph (Ralph Wiggum) settings
|
// Configure Ralph (Ralph Wiggum) settings
|
||||||
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } = req.body as {
|
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = req.body as {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
completionPhrase?: string;
|
completionPhrase?: string;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
maxTodos?: number;
|
|
||||||
todoExpirationMinutes?: number;
|
|
||||||
reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset
|
reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset
|
||||||
disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection
|
disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection
|
||||||
};
|
};
|
||||||
@@ -450,15 +506,6 @@ export class WebServer extends EventEmitter {
|
|||||||
session.ralphTracker.setMaxIterations(maxIterations || null);
|
session.ralphTracker.setMaxIterations(maxIterations || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store additional config on session for reference
|
|
||||||
(session as any).ralphConfig = {
|
|
||||||
enabled: enabled ?? session.ralphTracker.enabled,
|
|
||||||
completionPhrase: completionPhrase || '',
|
|
||||||
maxIterations: maxIterations || 0,
|
|
||||||
maxTodos: maxTodos || 50,
|
|
||||||
todoExpirationMinutes: todoExpirationMinutes || 60
|
|
||||||
};
|
|
||||||
|
|
||||||
// Persist and broadcast the update
|
// Persist and broadcast the update
|
||||||
this.persistSessionState(session);
|
this.persistSessionState(session);
|
||||||
this.broadcast('session:ralphLoopUpdate', {
|
this.broadcast('session:ralphLoopUpdate', {
|
||||||
@@ -470,17 +517,17 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Run prompt in session
|
// Run prompt in session
|
||||||
this.app.post('/api/sessions/:id/run', async (req): Promise<{ success?: boolean; message?: string; error?: string }> => {
|
this.app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { prompt } = req.body as RunPromptRequest;
|
const { prompt } = req.body as RunPromptRequest;
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { error: 'Session not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return { error: 'Session is busy' };
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run async, don't wait
|
// Run async, don't wait
|
||||||
@@ -489,20 +536,20 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.broadcast('session:running', { id, prompt });
|
this.broadcast('session:running', { id, prompt });
|
||||||
return { success: true, message: 'Prompt started' };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start interactive Claude session (persists even if browser disconnects)
|
// Start interactive Claude session (persists even if browser disconnects)
|
||||||
this.app.post('/api/sessions/:id/interactive', async (req) => {
|
this.app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { error: 'Session not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return { error: 'Session is busy' };
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -518,32 +565,32 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast('session:interactive', { id });
|
this.broadcast('session:interactive', { id });
|
||||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||||
|
|
||||||
return { success: true, message: 'Interactive session started' };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: getErrorMessage(err) };
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start a plain shell session (no Claude)
|
// Start a plain shell session (no Claude)
|
||||||
this.app.post('/api/sessions/:id/shell', async (req) => {
|
this.app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { error: 'Session not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return { error: 'Session is busy' };
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.startShell();
|
await session.startShell();
|
||||||
this.broadcast('session:interactive', { id, mode: 'shell' });
|
this.broadcast('session:interactive', { id, mode: 'shell' });
|
||||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||||
return { success: true, message: 'Shell session started' };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: getErrorMessage(err) };
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,7 +608,12 @@ export class WebServer extends EventEmitter {
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Input is required');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Input is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
session.write(String(input));
|
const inputStr = String(input);
|
||||||
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.write(inputStr);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -578,6 +630,9 @@ export class WebServer extends EventEmitter {
|
|||||||
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
|
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers');
|
||||||
}
|
}
|
||||||
|
if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`);
|
||||||
|
}
|
||||||
|
|
||||||
session.resize(cols, rows);
|
session.resize(cols, rows);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -599,22 +654,20 @@ export class WebServer extends EventEmitter {
|
|||||||
let cleanBuffer = session.terminalBuffer;
|
let cleanBuffer = session.terminalBuffer;
|
||||||
|
|
||||||
// Find where Claude banner starts (has color codes before "Claude")
|
// Find where Claude banner starts (has color codes before "Claude")
|
||||||
// Look for the bold escape sequence followed by "Claude"
|
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||||
const claudeMatch = cleanBuffer.match(/\x1b\[1mClaud/);
|
|
||||||
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
||||||
// Find the start of that line (look for line start or screen positioning before it)
|
// Find the start of that line
|
||||||
let lineStart = claudeMatch.index;
|
let lineStart = claudeMatch.index;
|
||||||
// Go back to find color/positioning sequences that are part of the banner
|
|
||||||
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
||||||
lineStart--;
|
lineStart--;
|
||||||
}
|
}
|
||||||
cleanBuffer = cleanBuffer.slice(lineStart);
|
cleanBuffer = cleanBuffer.slice(lineStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also remove any Ctrl+L and leading whitespace
|
// Remove Ctrl+L and leading whitespace
|
||||||
cleanBuffer = cleanBuffer
|
cleanBuffer = cleanBuffer
|
||||||
.replace(/\x0c/g, '')
|
.replace(CTRL_L_PATTERN, '')
|
||||||
.replace(/^[\s\r\n]+/, '');
|
.replace(LEADING_WHITESPACE_PATTERN, '');
|
||||||
|
|
||||||
// Optionally truncate to last N bytes for faster initial load
|
// Optionally truncate to last N bytes for faster initial load
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||||
@@ -1002,7 +1055,7 @@ export class WebServer extends EventEmitter {
|
|||||||
const run = this.scheduledRuns.get(id);
|
const run = this.scheduledRuns.get(id);
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
return { error: 'Scheduled run not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
@@ -1179,7 +1232,7 @@ export class WebServer extends EventEmitter {
|
|||||||
const casePath = join(casesDir, name);
|
const casePath = join(casesDir, name);
|
||||||
|
|
||||||
if (!existsSync(casePath)) {
|
if (!existsSync(casePath)) {
|
||||||
return { error: 'Case not found' };
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1324,7 +1377,7 @@ export class WebServer extends EventEmitter {
|
|||||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: getErrorMessage(err) };
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1476,7 +1529,9 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...data });
|
// Sanitize forwarded data: only include known safe fields, limit size
|
||||||
|
const safeData = sanitizeHookData(data);
|
||||||
|
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1546,7 +1601,22 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up all resources associated with a session
|
// Clean up all resources associated with a session
|
||||||
|
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||||||
|
private cleaningUp: Set<string> = new Set();
|
||||||
|
|
||||||
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
|
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
|
||||||
|
// Guard against concurrent cleanup of the same session
|
||||||
|
if (this.cleaningUp.has(sessionId)) return;
|
||||||
|
this.cleaningUp.add(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._doCleanupSession(sessionId, killScreen);
|
||||||
|
} finally {
|
||||||
|
this.cleaningUp.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise<void> {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
|
|
||||||
// Stop and remove respawn controller
|
// Stop and remove respawn controller
|
||||||
@@ -1566,10 +1636,11 @@ export class WebServer extends EventEmitter {
|
|||||||
this.respawnTimers.delete(sessionId);
|
this.respawnTimers.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear batches
|
// Clear batches and pending state updates
|
||||||
this.terminalBatches.delete(sessionId);
|
this.terminalBatches.delete(sessionId);
|
||||||
this.outputBatches.delete(sessionId);
|
this.outputBatches.delete(sessionId);
|
||||||
this.taskUpdateBatches.delete(sessionId);
|
this.taskUpdateBatches.delete(sessionId);
|
||||||
|
this.stateUpdatePending.delete(sessionId);
|
||||||
|
|
||||||
// Reset Ralph tracker on the session before cleanup
|
// Reset Ralph tracker on the session before cleanup
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -2077,7 +2148,14 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
private broadcast(event: string, data: unknown): void {
|
private broadcast(event: string, data: unknown): void {
|
||||||
// Performance optimization: serialize JSON once for all clients
|
// Performance optimization: serialize JSON once for all clients
|
||||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
let message: string;
|
||||||
|
try {
|
||||||
|
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
} catch (err) {
|
||||||
|
// Handle circular references or non-serializable values
|
||||||
|
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const client of this.sseClients) {
|
for (const client of this.sseClients) {
|
||||||
this.sendSSEPreformatted(client, message);
|
this.sendSSEPreformatted(client, message);
|
||||||
}
|
}
|
||||||
@@ -2199,8 +2277,9 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up dead SSE clients that may not have properly disconnected.
|
* Clean up dead SSE clients and send keep-alive comments.
|
||||||
* This prevents memory leaks from abruptly terminated connections.
|
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
||||||
|
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
||||||
*/
|
*/
|
||||||
private cleanupDeadSSEClients(): void {
|
private cleanupDeadSSEClients(): void {
|
||||||
const deadClients: FastifyReply[] = [];
|
const deadClients: FastifyReply[] = [];
|
||||||
@@ -2211,6 +2290,9 @@ export class WebServer extends EventEmitter {
|
|||||||
const socket = client.raw.socket || (client.raw as any).connection;
|
const socket = client.raw.socket || (client.raw as any).connection;
|
||||||
if (!socket || socket.destroyed || !socket.writable) {
|
if (!socket || socket.destroyed || !socket.writable) {
|
||||||
deadClients.push(client);
|
deadClients.push(client);
|
||||||
|
} else {
|
||||||
|
// Send SSE comment as keep-alive (comments start with ':')
|
||||||
|
client.raw.write(':keepalive\n\n');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Error accessing socket means client is dead
|
// Error accessing socket means client is dead
|
||||||
|
|||||||
Reference in New Issue
Block a user