|
| 1 | +## Context |
| 2 | + |
| 3 | +The frontend is a Vue 2 + TypeScript SPA (Vuetify) controlling ~300 home-automation appliances via a Java backend. All state fetching currently uses polling (`setInterval` + `appliancesService.getList()`) at 1–3 second intervals from multiple views independently. On slow clients (kiosk tablets), this causes request pileup and stale-state races, which led to the `EchoGate` workaround. |
| 4 | + |
| 5 | +The backend is adding an SSE endpoint with a subscription-based model (see `ai/spec-sse-draft.md`). This design covers the frontend client that consumes it. |
| 6 | + |
| 7 | +Key constraints: |
| 8 | +- Vue 2.6 / TypeScript, no Composition API |
| 9 | +- Project is pinned to Node.js v14.15.0 (build tooling only — `EventSource` is a browser API, no Node polyfill needed) |
| 10 | +- No authentication in use — Keycloak token getter returns empty string |
| 11 | +- Existing singleton pattern (`private static instanceField` + `getInstance()`) used throughout the codebase |
| 12 | +- Existing `AxiosUtils` singleton handles all REST calls and resolves the server base URL from the Vuex `rest/config` store |
| 13 | + |
| 14 | +## Goals / Non-Goals |
| 15 | + |
| 16 | +**Goals:** |
| 17 | +- Single shared SSE connection across the entire app |
| 18 | +- Components subscribe to specific appliance IDs and receive only those updates |
| 19 | +- Server-side debounce via `minInterval` so slow clients control their own update rate |
| 20 | +- Automatic reconnection with transparent re-registration of all active subscriptions |
| 21 | +- Visual connection-state indicator (red border when disconnected) |
| 22 | +- Drop-in replacement for the current polling pattern — components switch from `setInterval`/`clearInterval` to `subscribe`/`unsubscribe` in `mounted`/`beforeDestroy` |
| 23 | + |
| 24 | +**Non-Goals:** |
| 25 | +- Replacing polling for non-appliance resources (plans, switches, window contacts, weather, waste disposal) — those stay as-is for now |
| 26 | +- WebSocket or any bidirectional protocol — commands remain REST `POST /execute` |
| 27 | +- Vuex store integration — the SSE client owns its own state (subscription map + cache). Pushing into Vuex would add complexity without benefit since components already receive updates via callbacks. |
| 28 | +- Offline/queue support — if the connection drops, we show the red border and wait for reconnect |
| 29 | + |
| 30 | +## Decisions |
| 31 | + |
| 32 | +### 1. New file `src/utils/sseClient.ts` as a singleton service |
| 33 | + |
| 34 | +The SSE client lives alongside the existing service singletons (`appliancesService.ts`, `axiosUtils.ts`) and follows the same `getInstance()` pattern. It is not a Vue component, not a Vuex module, and not a mixin — it's a plain TypeScript class that components import. |
| 35 | + |
| 36 | +**Why not Vuex:** The subscription map and appliance cache are internal bookkeeping. No component needs to read "all active subscriptions" from the store. Each component gets its updates via its own callback. Vuex would add boilerplate (mutations, actions, getters) for no consumer. |
| 37 | + |
| 38 | +**Why not a mixin:** Mixins couple lifecycle hooks to the component. The SSE client's lifecycle (connect, reconnect, re-register) is independent of any single component. A mixin would also require every consuming component to declare the same boilerplate. |
| 39 | + |
| 40 | +### 2. Lazy connection — open on first `subscribe()`, not on app boot |
| 41 | + |
| 42 | +The `EventSource` is created only when the first component calls `subscribe()`. If no component ever subscribes (e.g., a settings-only page), no connection is opened. |
| 43 | + |
| 44 | +**Why:** Avoids holding an idle SSE connection on routes that don't display appliance state. Also avoids racing with the `connected` event before any subscription is ready to register. |
| 45 | + |
| 46 | +### 3. Use `AxiosUtils` for register/deregister REST calls |
| 47 | + |
| 48 | +The SSE client delegates `POST /sse/appliances/register` and `DELETE /sse/appliances/register/{id}` to the existing `AxiosUtils` singleton. This keeps server base-URL resolution and error handling consistent. |
| 49 | + |
| 50 | +**Requires:** Adding the new SSE endpoint paths to `store/rest.ts` config: |
| 51 | +```typescript |
| 52 | +endpoint: { |
| 53 | + // existing... |
| 54 | + sseAppliances: '/sse/appliances', |
| 55 | + sseAppliancesRegister: '/sse/appliances/register' |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +The SSE `EventSource` URL is also built from the same `rest/config` server config, so it resolves correctly in both dev (`localhost:8080`) and prod (`overmindserver.unterrainer.info:443`). |
| 60 | + |
| 61 | +### 4. One server subscription per `subscribe()` call (1:1 mapping) |
| 62 | + |
| 63 | +Each component `subscribe()` call creates exactly one server-side subscription via `POST /register`. The server handles overlap (tightest `minInterval` wins per appliance per connection). The client does not merge or deduplicate subscriptions locally. |
| 64 | + |
| 65 | +**Why:** Keeps the client simple. The server already implements interval merging (spec section 4.3). Client-side merging would require tracking which local handles share which server subscription, and re-splitting when one unsubscribes — complex for negligible savings (register/deregister are infrequent REST calls, not hot-path). |
| 66 | + |
| 67 | +**Trade-off:** Slightly more register calls on mount (one per component rather than one merged call). Acceptable since mount happens once, not in a loop. |
| 68 | + |
| 69 | +### 5. Parse `state` and `config` strings in the client, not in callbacks |
| 70 | + |
| 71 | +The SSE client calls `overmindUtils.parseState(entry)` and `overmindUtils.parseConfig(entry)` on each incoming entry before routing to callbacks. Components receive ready-to-use objects, identical to what `getAppliances()` gives them today. |
| 72 | + |
| 73 | +**Why:** Every consuming component currently does this parsing. Centralizing it in the client avoids duplication and ensures consistency. |
| 74 | + |
| 75 | +### 6. Connection indicator via CSS class on `#main` / `.v-app` |
| 76 | + |
| 77 | +The `App.vue` template wraps everything in `<div id="main"><v-app>`. The connection indicator adds a CSS class (`sse-disconnected`) to `<v-app>` when the SSE connection is down. The styling: |
| 78 | + |
| 79 | +```css |
| 80 | +.v-app.sse-disconnected { |
| 81 | + border: 3px solid red; |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +The existing `.v-app` style already has `border: 1px solid rgba(#000, 0.12)`. The disconnected state overrides this with a red border. |
| 86 | + |
| 87 | +**Implementation:** `App.vue` reads `SseClient.getInstance().connected` in a computed property and binds the class. Since `connected` is not a Vue reactive property (it's a plain boolean on a singleton), `App.vue` sets up a small `setInterval` (every 2 seconds) to check and update a local `sseConnected` data property. This is the only remaining polling — and it's purely cosmetic, not data-fetching. |
| 88 | + |
| 89 | +**Alternative considered:** Making `SseClient` extend `Vue` or use `Vue.observable()` for reactivity. Rejected because it would couple the client to Vue and make it harder to test or reuse. |
| 90 | + |
| 91 | +### 7. `subscribe()` is synchronous, registration is fire-and-forget |
| 92 | + |
| 93 | +`subscribe()` returns the handle immediately. The REST registration call (`POST /register`) happens asynchronously in the background. The component doesn't need to `await` it because: |
| 94 | +- The server pushes initial state over SSE as soon as registration succeeds |
| 95 | +- The callback receives it like any other update |
| 96 | +- If registration fails (e.g., connection not ready yet), the client queues it and retries after the `connected` event |
| 97 | + |
| 98 | +**Why:** Components call `subscribe()` in `mounted()`. Making it async would require `async mounted()` and error handling in every component. The synchronous API is simpler and matches the existing `setInterval` pattern (set-and-forget in mounted, clean up in beforeDestroy). |
| 99 | + |
| 100 | +### 8. Reconnection: re-register all, don't persist subscriptions |
| 101 | + |
| 102 | +On `EventSource` reconnect (new `connected` event with new `connectionId`): |
| 103 | +1. Update stored `connectionId` |
| 104 | +2. Iterate all entries in the local `subscriptions` map |
| 105 | +3. Call `POST /register` for each with the new `connectionId` |
| 106 | +4. Update each subscription's `serverSubscriptionId` with the new response |
| 107 | + |
| 108 | +The server's stale-subscription cleanup (spec section 4.5) guarantees the old connection's subscriptions are already gone. The client re-registers from scratch. |
| 109 | + |
| 110 | +**Why not persist to localStorage:** Subscriptions are tied to mounted components. If the app refreshes, components remount and re-subscribe naturally. There's nothing to persist. |
| 111 | + |
| 112 | +### 9. Cache survives reconnect |
| 113 | + |
| 114 | +The internal `cache: Map<number, Appliance>` is not cleared on reconnect. Stale entries are overwritten when fresh data arrives from re-registration. This means `getLatest()` returns slightly-stale data during the brief reconnect window rather than `null`. |
| 115 | + |
| 116 | +**Why:** Components that call `getLatest()` for synchronous initialization shouldn't break just because the SSE connection hiccupped for 3 seconds. |
| 117 | + |
| 118 | +## Risks / Trade-offs |
| 119 | + |
| 120 | +**[Subscribe before connected]** — If a component calls `subscribe()` before the SSE connection has delivered its `connected` event (no `connectionId` yet), the register REST call can't be made. |
| 121 | +Mitigation: The client queues pending subscriptions and processes them when `connected` fires. This is the natural path since the connection is lazy (opened on first subscribe). |
| 122 | + |
| 123 | +**[Rapid mount/unmount]** — A component that mounts, subscribes, then immediately unmounts (e.g., fast route navigation) fires a register + deregister in quick succession. The server may push initial state after the client has already unsubscribed locally. |
| 124 | +Mitigation: The client ignores updates for handles that no longer exist in the subscription map. Stray updates are harmless (just extra data that nobody reads). |
| 125 | + |
| 126 | +**[EventSource on HTTP (not HTTPS)]** — In dev mode the server runs on `http://localhost:8080`. Some browsers limit the number of concurrent HTTP/1.1 connections per host (6 in Chrome). The SSE connection consumes one permanently. |
| 127 | +Mitigation: Acceptable for dev. Production uses HTTPS. If it becomes a problem in dev, the backend can add HTTP/2 support. |
| 128 | + |
| 129 | +**[2-second polling for connection indicator]** — `App.vue` polls `sseClient.connected` every 2 seconds, which is technically polling. But it's a single boolean read, no network call, and the worst case is a 2-second delay before the red border appears/disappears. |
| 130 | +Mitigation: Acceptable. Can be replaced with a callback or `Vue.observable` wrapper later if the delay matters. |
0 commit comments