# Live Recipe Reload **Date:** 2026-05-20 **Status:** Approved ## Problem The server watches the recipe directory for file changes and updates the in-memory recipe index live (via `fsnotify` + `IORef`), but users must manually refresh their browser to see the new content. The recipe index page and recipe detail pages should reload automatically when their relevant content changes on disk. ## Goal Push recipe change notifications to the browser using Server-Sent Events (SSE), triggering an automatic page reload when appropriate: - **Index page:** reloads when any recipe is added, modified, or removed. - **Recipe detail page:** reloads only when the specific recipe being viewed changes on disk. ## Non-Goals - In-place content swapping (full page reload is acceptable). - Bidirectional communication (WebSocket is overkill for one-way push). - Inline edit of recipes from the browser. - Persisting checkbox or scroll state across reloads. ## Architecture ### Change Log — Shared State A new `ChangeLog` `IORef` alongside the existing recipe map `IORef` tracks every recipe change with a monotonic version number: ```haskell type ChangeLog = IORef (Int, [(Int, FilePath)]) -- (currentVersion, [(version, changedFile), ...]) ``` - **`currentVersion`** — monotonically increasing `Int`, bumped on every add/modify/delete event. - **Change list** — newest-first, capped at the last 100 entries (`take 99` after prepending). Enough for any reasonable burst of recipe edits. - **Concurrency** — updated with `atomicModifyIORef'` alongside the recipe map update in the watcher callback. No new concurrency primitives (`Chan`, `TVar`, `TChan`) needed — `IORef` is sufficient for this scale. ### Watcher Integration The existing `watchRecipes` function gains a `ChangeLog` parameter. After each successful recipe change (add/modify/delete), it calls: ```haskell recordChange :: ChangeLog -> FilePath -> IO () recordChange changeLog path = atomicModifyIORef' changeLog $ \(version, entries) -> let newVersion = version + 1 newEntries = (newVersion, path) : take 99 entries in ((newVersion, newEntries), ()) ``` ### SSE Endpoint — `/events` A new WAI route at `["events"]` returns a streaming `text/event-stream` response. Each connected client runs a 500ms polling loop: 1. Read the `ChangeLog`'s current version. 2. If `currentVersion > lastSentVersion`, push `recipe-changed` events for all entries with `v > lastSentVersion`. 3. Every 30 seconds, push a `ping` event as a keepalive. **Response format:** ``` event: recipe-changed data: FriedRice.cook event: recipe-changed data: PadThai.cook event: ping data: keepalive ``` **Connection lifecycle:** - `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` - Client disconnect causes the write action to throw → `finally` cleans up the polling loop. - Warp handles client disconnect detection natively. - Browser `EventSource` reconnects automatically on drop. ### Router — Dispatching to SSE vs Recipe Handlers A new `router` function replaces the direct use of `handleRequest`: ```haskell router :: IORef (Map FilePath RecipeInfo) -> ChangeLog -> Wai.Application router state changeLog request respond = case Wai.pathInfo request of ["events"] -> sseHandler changeLog request respond _ -> handleRequest state request respond ``` ### Client-Side JavaScript **Index page** — appended to the existing inline `