diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index e71bdf29..2feeba5a 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -21,8 +21,15 @@ export const BATCH_FLUSH_THRESHOLD = 32 * 1024; /** Task event batching interval (ms) */ export const TASK_UPDATE_BATCH_INTERVAL = 100; -/** SSE client health check interval (ms) */ -export const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000; +/** SSE heartbeat interval — sends padded keepalive to flush proxy buffers (ms). + * 15s is fast enough to keep Cloudflare tunnel buffers flushed while avoiding + * excessive bandwidth. Also serves as dead-client detection. */ +export const SSE_HEARTBEAT_INTERVAL = 15 * 1000; + +/** SSE padding size (bytes). Cloudflare quick tunnels buffer small SSE events; + * appending ~8KB of SSE comment padding forces the proxy to flush immediately. + * SSE comments (lines starting with ':') are silently ignored by EventSource. */ +export const SSE_PADDING_SIZE = 8 * 1024; // ============================================================================ // State Persistence diff --git a/src/web/server.ts b/src/web/server.ts index 3dbf527e..d56d2061 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -96,7 +96,8 @@ import { SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, - SSE_HEALTH_CHECK_INTERVAL, + SSE_HEARTBEAT_INTERVAL, + SSE_PADDING_SIZE, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, @@ -110,6 +111,12 @@ import { const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen) +// SSE padding for Cloudflare tunnel buffer flushing. +// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events. +// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush. +// Pre-computed once at startup to avoid repeated string allocation. +const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n'; + /** * Get or generate a self-signed TLS certificate for HTTPS. * Certs are stored in ~/.codeman/certs/ and reused across restarts. @@ -574,6 +581,9 @@ export class WebServer extends EventEmitter { // Use light state for SSE init to avoid sending 2MB+ terminal buffers // Buffers are fetched on-demand when switching tabs this.sendSSE(reply, 'init', this.getLightState()); + // Flush Cloudflare tunnel buffer with padding — ensures the init event + // (and any immediately following events) are delivered without proxy delay. + try { reply.raw.write(SSE_PADDING); } catch { /* client gone */ } req.raw.on('close', () => { this.sseClients.delete(reply); @@ -1855,10 +1865,12 @@ export class WebServer extends EventEmitter { this.cachedLightState = null; this.cachedSessionsList = null; } - // Performance optimization: serialize JSON once for all clients + // Performance optimization: serialize JSON once for all clients. + // Append padding to flush Cloudflare tunnel buffers for all non-terminal events. + // Terminal data is high-volume and already exceeds buffer thresholds naturally. let message: string; try { - message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + SSE_PADDING; } catch (err) { // Handle circular references or non-serializable values console.error(`[Server] Failed to serialize SSE event "${event}":`, err); @@ -2128,8 +2140,9 @@ export class WebServer extends EventEmitter { if (!socket || socket.destroyed || !socket.writable) { deadClients.push(client); } else { - // Send SSE comment as keep-alive (comments start with ':') - client.raw.write(':keepalive\n\n'); + // Send padded SSE comment as keep-alive — the padding flushes + // Cloudflare tunnel buffers so subsequent events arrive promptly. + client.raw.write(':keepalive\n' + SSE_PADDING); } } catch { // Error accessing socket means client is dead @@ -2213,8 +2226,8 @@ export class WebServer extends EventEmitter { () => { this.cleanupDeadSSEClients(); }, - SSE_HEALTH_CHECK_INTERVAL, - { description: 'SSE client health check' } + SSE_HEARTBEAT_INTERVAL, + { description: 'SSE heartbeat + dead client cleanup' } ); // Start token recording timer (every 5 minutes for long-running sessions)