Problem
The client currently polls two endpoints on a tight interval (sub-second) to stay in sync with workspace state:
GET /workspace/:id/events
GET /workspace/:id/session-groups/events
Sample from local dev logs (~1 second window):
GET /workspace/ws_8901851c2c65/events 200 5ms
OPTIONS /workspace/ws_8901851c2c65/session-groups/events 204 0ms
GET /workspace/ws_8901851c2c65/session-groups/events 200 0ms
OPTIONS /workspace/ws_8901851c2c65/events 204 0ms
GET /workspace/ws_8901851c2c65/events 200 2ms
This pattern repeats continuously for the lifetime of an open workspace, generating roughly 6–8 requests/second per client (each GET preceded by an OPTIONS preflight).
Why this is a problem:
- Wasted round trips - most polls return no meaningful state change, so the majority of requests do nothing but consume server and network resources.
- CORS preflight overhead - every
GET is preceded by an OPTIONS request, doubling actual request volume for zero payload gain. This suggests Access-Control-Max-Age isn't set/cached properly on the client side, but even with that fixed, polling itself remains wasteful.
- Scales linearly with active workspaces - each open client sustains this load indefinitely. In multi-tenant or hosted environments this compounds quickly.
- No backoff when idle - polling continues at the same rate regardless of whether anything has changed, or whether the tab/window is even in the foreground.
- Endpoint naming already implies push - both endpoints are literally named
events, suggesting the original intent was a streaming interface that ended up implemented as poll-based, likely as a stopgap.
Proposed solution
Migrate both endpoints from polling to Server-Sent Events:
- Auth cannot use native
EventSource. All requests currently require Authorization: Bearer <token> (openwork-server.ts). Native EventSource can't send custom headers, and passing the token as a query param is a non-starter (leaks into logs/history/referrer). Implementation must use a fetch()-based readable stream reader instead which also means losing EventSource's built-in auto-reconnect; reconnect-with-backoff and Last-Event-ID tracking need to be hand-rolled.
- Server idle timeout will kill long-lived connections.
server.ts sets idleTimeout: 120s, mirrored in serve-node.ts's keepAliveTimeout. Any SSE stream idle for 2 minutes gets silently dropped by the Node HTTP layer. Requires either server-sent heartbeat comments (:\n\n) every ~30s, or a separate timeout policy for streaming routes.
- No pub/sub layer exists yet.
ReloadEventStore and SessionGroupEventStore (events.ts, session-groups.ts) are in-memory ring buffers with no subscribe/notify mechanism. This needs to be built, including per-workspace connection tracking and cleanup on client disconnect, before any streaming can happen this is new infrastructure, not a refactor of existing code.
- Desktop/Tauri routing dependency.
openwork-server.ts already has an OPENWORK_STREAM_URL_RE regex that routes /events-matching URLs to window.fetch instead of Tauri's IPC-based desktopFetch, because Tauri's fetch blocks until the body closes (which would freeze the desktop webview on an infinite stream). /events and session-groups/events both appear to match this regex already, but this needs explicit testing confirmation, not just static analysis verify in a real Tauri build, not just reading the regex.
Problem
The client currently polls two endpoints on a tight interval (sub-second) to stay in sync with workspace state:
GET /workspace/:id/eventsGET /workspace/:id/session-groups/eventsSample from local dev logs (~1 second window):
This pattern repeats continuously for the lifetime of an open workspace, generating roughly 6–8 requests/second per client (each
GETpreceded by anOPTIONSpreflight).Why this is a problem:
GETis preceded by anOPTIONSrequest, doubling actual request volume for zero payload gain. This suggestsAccess-Control-Max-Ageisn't set/cached properly on the client side, but even with that fixed, polling itself remains wasteful.events, suggesting the original intent was a streaming interface that ended up implemented as poll-based, likely as a stopgap.Proposed solution
Migrate both endpoints from polling to Server-Sent Events:
EventSource. All requests currently requireAuthorization: Bearer <token>(openwork-server.ts). NativeEventSourcecan't send custom headers, and passing the token as a query param is a non-starter (leaks into logs/history/referrer). Implementation must use afetch()-based readable stream reader instead which also means losingEventSource's built-in auto-reconnect; reconnect-with-backoff andLast-Event-IDtracking need to be hand-rolled.server.tssetsidleTimeout: 120s, mirrored inserve-node.ts'skeepAliveTimeout. Any SSE stream idle for 2 minutes gets silently dropped by the Node HTTP layer. Requires either server-sent heartbeat comments (:\n\n) every ~30s, or a separate timeout policy for streaming routes.ReloadEventStoreandSessionGroupEventStore(events.ts,session-groups.ts) are in-memory ring buffers with no subscribe/notify mechanism. This needs to be built, including per-workspace connection tracking and cleanup on client disconnect, before any streaming can happen this is new infrastructure, not a refactor of existing code.openwork-server.tsalready has anOPENWORK_STREAM_URL_REregex that routes/events-matching URLs towindow.fetchinstead of Tauri's IPC-baseddesktopFetch, because Tauri's fetch blocks until the body closes (which would freeze the desktop webview on an infinite stream)./eventsandsession-groups/eventsboth appear to match this regex already, but this needs explicit testing confirmation, not just static analysis verify in a real Tauri build, not just reading the regex.