From 446076cb84bd553f4bb9c1ee220515b8f2d96f60 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Sun, 17 May 2026 14:41:55 +0200 Subject: [PATCH] fix: keep sse streams alive behind proxies --- .env.example | 1 + README.md | 15 +++++++++++++++ crucix.config.mjs | 1 + server.mjs | 16 +++++++++++++++- test/fetch-utils.test.mjs | 12 ++++++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a862e47..411d08c 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ AUTO_OPEN_BROWSER=false STALE_DATA_MAX_AGE_MINUTES=60 TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +SSE_HEARTBEAT_INTERVAL_MS=25000 BRIEF_VERBOSITY=standard # LLM layer diff --git a/README.md b/README.md index 74e69f3..355cf74 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ AUTO_OPEN_BROWSER=false STALE_DATA_MAX_AGE_MINUTES=60 TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +SSE_HEARTBEAT_INTERVAL_MS=25000 BRIEF_VERBOSITY=standard LLM_PROVIDER=openrouter @@ -190,6 +191,20 @@ For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-ter The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`. +#### Reverse Proxy SSE + +The dashboard receives live sweep updates from `GET /events` using Server-Sent Events. The server sends `retry: 10000` reconnect guidance and lightweight heartbeat comments every `SSE_HEARTBEAT_INTERVAL_MS` milliseconds so reverse proxies do not close an otherwise idle stream between 15-minute sweeps. + +Recommended proxy settings: + +| Proxy | Setting | +| --- | --- | +| Pangolin / Traefik-style frontends | Keep response streaming enabled and set idle timeouts above `SSE_HEARTBEAT_INTERVAL_MS`. | +| Nginx | Disable proxy buffering for `/events`, keep `proxy_read_timeout` above the heartbeat interval, and preserve `Connection: keep-alive`. | +| Cloudflare-style proxies | Keep the heartbeat below common idle cutoffs; the default 25s is intentionally conservative. | + +If you raise the heartbeat interval, keep it shorter than the lowest idle timeout in the proxy chain. + #### Build And Publish Your Gitea Image ```bash diff --git a/crucix.config.mjs b/crucix.config.mjs index 19bbce2..fbf0ef1 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -25,6 +25,7 @@ export default { staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60), sweepToken: process.env.SWEEP_TOKEN || null, terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true), + sseHeartbeatIntervalMs: intEnv('SSE_HEARTBEAT_INTERVAL_MS', 25000), llm: { provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok diff --git a/server.mjs b/server.mjs index 95949f0..6399018 100644 --- a/server.mjs +++ b/server.mjs @@ -328,10 +328,24 @@ app.get('/events', (req, res) => { 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', }); + res.write('retry: 10000\n'); res.write('data: {"type":"connected"}\n\n'); + const heartbeatMs = Math.max(5000, config.sseHeartbeatIntervalMs || 25000); + const heartbeat = setInterval(() => { + try { + res.write(`: heartbeat ${new Date().toISOString()}\n\n`); + } catch { + clearInterval(heartbeat); + sseClients.delete(res); + } + }, heartbeatMs); sseClients.add(res); - req.on('close', () => sseClients.delete(res)); + req.on('close', () => { + clearInterval(heartbeat); + sseClients.delete(res); + }); }); function broadcast(data) { diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 2dcee45..b8b125c 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs'; test('safeFetch reports HTML as degraded JSON response', async () => { @@ -34,3 +35,14 @@ test('safeFetchText returns text and byte count', async () => { globalThis.fetch = originalFetch; } }); + +test('SSE endpoint sends reconnect guidance and clears heartbeat timer', () => { + const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8'); + const config = readFileSync(new URL('../crucix.config.mjs', import.meta.url), 'utf8'); + assert.match(config, /sseHeartbeatIntervalMs/); + assert.match(server, /retry: 10000\\n/); + assert.match(server, /setInterval\(\(\) =>/); + assert.match(server, /: heartbeat/); + assert.match(server, /clearInterval\(heartbeat\)/); + assert.match(server, /X-Accel-Buffering/); +});