From 614f4d274e9bf2ae383dfebd4b1213565aac63c1 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 21 May 2026 15:43:56 +0000 Subject: [PATCH] Reconnect SSE connection if we miss heartbeats for 30 seconds Issue: #11 --- ts/SSEManager.ts | 34 ++++++++++++++++++++++++++++++---- ts/log.ts | 22 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 ts/log.ts diff --git a/ts/SSEManager.ts b/ts/SSEManager.ts index dc304b0f..a7ebf418 100644 --- a/ts/SSEManager.ts +++ b/ts/SSEManager.ts @@ -1,3 +1,5 @@ +import { log } from "@/log"; + // Define types for the SSE data structure export interface SSEMessageBase { type: string; @@ -35,8 +37,10 @@ declare global { } */ +const HEARTBEAT_TIMEOUT_MILLISECONDS = 30000; export const SSEManager: SSEManagerType = (function (): SSEManagerType { let connectionPromise: Promise | null = null; + let heartbeatTimeout: number | null = null; let isConnected: boolean = false; let eventSource: EventSource | null = null; let serverUrl: string = ""; @@ -55,17 +59,18 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { eventSource.onopen = function (): void { isConnected = true; + heartbeatTimeoutSet(); eventSource!.addEventListener("message", (message: MessageEvent) => { const data: SSEMessageBase = JSON.parse(message.data); handleMessage(data); }); - console.log("SSE connected"); + log.info("SSE connected"); resolve(eventSource!); }; eventSource.onerror = function (err: Event): void { - console.error("SSE error:", err); + log.error("SSE error:", err); isConnected = false; // Close old connection @@ -75,7 +80,7 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { // Reconnect after delay setTimeout(() => { - console.log("SSE reconnecting"); + log.info("SSE reconnecting"); connectionPromise = null; connect(url); }, 5000); @@ -93,14 +98,16 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { if (eventSource) { eventSource.close(); eventSource = null; + heartbeatTimeoutClear(); isConnected = false; connectionPromise = null; - console.log("SSE disconnected"); + log.info("SSE disconnected"); } } function handleMessage(msg: SSEMessageBase) { if (msg.type == "heartbeat") { + heartbeatTimeoutReset(); return; } else if (msg.type == "status") { subscribersStatus.forEach((handler: SSEHandlerStatus, _: string) => { @@ -112,6 +119,24 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { }); } } + function heartbeatTimeoutClear() { + if (heartbeatTimeout) { + clearTimeout(heartbeatTimeout); + } + heartbeatTimeout = 0; + } + function heartbeatTimeoutReset() { + heartbeatTimeoutClear(); + heartbeatTimeoutSet(); + } + function heartbeatTimeoutSet() { + if (heartbeatTimeout) { + throw new Error("can't set heartbeat timeout - already set"); + } + heartbeatTimeout = setTimeout(function () { + reconnect(0); + }, HEARTBEAT_TIMEOUT_MILLISECONDS); + } function ready(callback: (eventSource: EventSource) => void): void { if (connectionPromise) { @@ -128,6 +153,7 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { } function reconnect(delay: number) { + log.info("Reconnecting SSEManager to", serverUrl); disconnect(); setTimeout(() => { connect(serverUrl); diff --git a/ts/log.ts b/ts/log.ts new file mode 100644 index 00000000..ec77ee2c --- /dev/null +++ b/ts/log.ts @@ -0,0 +1,22 @@ +// log.ts +const pageLoadTime = performance.now(); + +function getTimestamp(): string { + const elapsed = performance.now() - pageLoadTime; + + const hours = Math.floor(elapsed / 3600000); + const minutes = Math.floor((elapsed % 3600000) / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export const log = { + info(...args: any[]): void { + console.log(`[${getTimestamp()}]`, ...args); + }, + + error(...args: any[]): void { + console.error(`[${getTimestamp()}]`, ...args); + }, +};