# Live Recipe Reload Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an SSE endpoint that pushes recipe-change events to browsers, triggering automatic page reload on the index and recipe detail pages. **Architecture:** A `ChangeLog` `IORef (Int, [(Int, FilePath)])` records every recipe change with a monotonic version. The watcher appends to it. A new `/events` route returns a streaming `text/event-stream` response — each connection polls the ChangeLog every 500ms and pushes new events. Client-side JS uses `EventSource` to listen and `location.reload()` on match. **Tech Stack:** Haskell, WAI streaming responses, native browser `EventSource` API. --- ## File Map | File | Action | Purpose | |---|---|---| | `src/Roux/Server.hs` | Modify | Add ChangeLog, SSE handler, router; thread ChangeLog through watcher | | `src/Roux/Html.hs` | Modify | Add SSE client JS to index page and recipe detail page | --- ### Task 1: Add SSE infrastructure to `Server.hs` **Files:** - Modify: `src/Roux/Server.hs` This is the main implementation task. All server-side changes are in one file. - [ ] **Step 1: Add required imports** Add to the import block in `src/Roux/Server.hs`: ```haskell import Control.Exception (IOException, SomeException, catch, finally, try) import qualified Data.ByteString.Lazy.Char8 as LB8 import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime) ``` Replace the existing `import Control.Exception (IOException, SomeException, catch, try)` and `import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)` lines with the above (adds `finally`, `writeIORef`, `Data.Time.Clock` imports, and `LB8`). - [ ] **Step 2: Add the `ChangeLog` type alias and `recordChange` function** Add after the `where` clause of `app` (before `watchRecipes`): ```haskell -- | A change log with a monotonic version counter and -- the last N changed filenames (newest first). type ChangeLog = IORef (Int, [(Int, FilePath)]) -- | Record a recipe file change in the change log. recordChange :: ChangeLog -> FilePath -> IO () recordChange changeLog path = atomicModifyIORef' changeLog $ \(version, entries) -> let newVersion = version + 1 newEntries = (newVersion, path) : take 99 entries in ((newVersion, newEntries), ()) ``` - [ ] **Step 3: Add the SSE handler** Add after `recordChange` (before `handleRequest`): ```haskell {- | SSE endpoint — streams recipe-change events to connected clients. Polls 'ChangeLog' every 500ms and pushes any new events since the client's last seen version. Sends a @ping@ event every 30s as a keepalive. Client disconnect is detected by Warp when the write action throws. -} sseHandler :: ChangeLog -> Wai.Application sseHandler changeLog _request respond = do respond $ Wai.responseStream HTTP.status200 [ ("Content-Type", "text/event-stream") , ("Cache-Control", "no-cache") , ("Connection", "keep-alive") ] $ \write flush -> do lastSent <- newIORef 0 lastPing <- newIORef 0 let sendPing = do write "event: ping\ndata: keepalive\n\n" flush let go = do now <- getCurrentTime (version, entries) <- readIORef changeLog sent <- readIORef lastSent when (version > sent) $ do let newEntries = takeWhile (\(v, _) -> v > sent) entries forM_ newEntries $ \(_v, path) -> do write $ LB8.unlines [ "event: recipe-changed" , "data: " <> LB8.pack path , "" , "" ] writeIORef lastSent version flush pingTime <- readIORef lastPing when (diffUTCTime now pingTime > 30) $ do writeIORef lastPing now sendPing threadDelay 500_000 -- 500ms poll interval go -- Send an initial ping so the client detects connection immediately pingTime <- getCurrentTime writeIORef lastPing pingTime sendPing go `finally` pure () ``` - [ ] **Step 4: Add the router function** Add after `sseHandler` (before `handleRequest`): ```haskell -- | Route requests — SSE endpoint or recipe pages. router :: IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application router state changeLog request respond = case Wai.pathInfo request of ["events"] -> sseHandler changeLog request respond _ -> handleRequest state request respond ``` - [ ] **Step 5: Update `app` to create ChangeLog and use router** Change the end of `app` — replace: ```haskell pure (handleRequest state) ``` with: ```haskell changeLog <- newIORef (0, []) _ <- forkIO $ watchRecipes absDir state changeLog `catch` \e -> putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) pure (router state changeLog) ``` Note: remove the existing `forkIO` for `watchRecipes` — the new one above replaces both `state` and `changeLog` setup. The full `app` function body after the change should look like: ```haskell app :: FilePath -> IO Wai.Application app recipeDir = do putStrLn $ "[roux] scanning recipes from " <> recipeDir recipes <- Idx.scanRecipes recipeDir let count = length recipes ok = length [() | r <- recipes, isRight (Idx.riRecipe r)] errs = count - ok putStrLn $ "[roux] found " <> show count <> " recipe(s)" when (errs > 0) $ putStrLn $ "[roux] " <> show errs <> " had parse errors" -- Build initial map keyed by lowercased filename (without .cook extension) let recipeMap = Map.fromList [(map toLower (stripCookExt (Idx.riFilename r)), r) | r <- recipes] state <- newIORef recipeMap -- Resolve to absolute path so makeRelative works with fsnotify events absDir <- makeAbsolute recipeDir -- Change log for SSE notifications changeLog <- newIORef (0, []) -- Start the filesystem watcher _ <- forkIO $ watchRecipes absDir state changeLog `catch` \e -> putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) pure (router state changeLog) ``` - [ ] **Step 6: Update `watchRecipes` signature and body to accept `ChangeLog`** Replace the `watchRecipes` signature and body: ```haskell watchRecipes :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> IO () ``` And update the callback to pass `changeLog` to `reReadRecipe`: ```haskell watchRecipes dir state changeLog = do putStrLn $ "[roux] watching " <> dir <> " for changes" let cfg = defaultConfig{confWatchMode = WatchModeOS} withManagerConf cfg $ \mgr -> do _stopListening <- watchDir mgr dir (const True) $ \ev -> catch ( do when (eventIsDirectory ev == IsFile && ".cook" `isSuffixOf` eventPath ev) $ do let path = makeRelative dir (eventPath ev) reReadRecipe dir path state changeLog ) ( \e -> putStrLn $ "[roux] watchDir callback error: " <> show (e :: SomeException) ) -- Keep the thread alive (watchDir runs the callback on the manager thread) forever $ threadDelay 1000000 ``` - [ ] **Step 7: Update `reReadRecipe` signature and body to accept and use `ChangeLog`** Replace the `reReadRecipe` signature: ```haskell reReadRecipe :: FilePath -> FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> IO () ``` And update the two mutation sites to also record the change: In the delete case (`Right False`), replace: ```haskell atomicModifyIORef' state $ \m -> (Map.delete key m, ()) ``` with: ```haskell atomicModifyIORef' state $ \m -> (Map.delete key m, ()) recordChange changeLog path ``` In the successful update case (`Right _`), replace: ```haskell atomicModifyIORef' state $ \m -> (Map.insert key info m, ()) ``` with: ```haskell atomicModifyIORef' state $ \m -> (Map.insert key info m, ()) recordChange changeLog path ``` - [ ] **Step 8: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 9: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 10: Commit** ```bash git add src/Roux/Server.hs git commit -m "feat: add SSE endpoint and ChangeLog for live recipe reload" ``` --- ### Task 2: Add SSE client JS to the recipe pages **Files:** - Modify: `src/Roux/Html.hs` - [ ] **Step 1: Add SSE JavaScript constant for the recipe detail page** Add a new top-level constant near `searchJs` in `src/Roux/Html.hs`: ```haskell -- | Inline JavaScript for SSE live-reload on the recipe detail page. sseRecipeJs :: Text sseRecipeJs = T.unlines [ "(function(){" , "'use strict';" , "if (!window.EventSource) return;" , "const currentRecipe = document.getElementById('roux-current-recipe').textContent;" , "if (!currentRecipe) return;" , "const es = new EventSource('/events');" , "es.addEventListener('recipe-changed', function(e) {" , " if (e.data === currentRecipe) location.reload();" , "});" , "})();" ] -- | Inline JavaScript for SSE live-reload on the index page. sseIndexJs :: Text sseIndexJs = T.unlines [ "(function(){" , "'use strict';" , "if (!window.EventSource) return;" , "const es = new EventSource('/events');" , "es.addEventListener('recipe-changed', function() { location.reload(); });" , "})();" ] ``` - [ ] **Step 2: Add hidden filename span and SSE script to `recipePage`** In `recipePage`, wrap the content passed to `page` to include the hidden span and SSE script. Replace the current body of `recipePage`: ```haskell recipePage :: Idx.RecipeInfo -> ByteString recipePage info = case Idx.riRecipe info of Left err -> page "Parse Error" $ do H.nav $ H.ul $ H.li $ H.a ! A.href "/" $ "← Back" H.h2 "Parse Error" H.p $ H.toHtml (Idx.riTitle info <> ": " <> T.pack err) hiddenRecipeSpan info sseScript Right recipe -> page (titleText recipe) $ do renderRecipe recipe hiddenRecipeSpan info sseScript where hiddenRecipeSpan rInfo = H.span ! A.id "roux-current-recipe" ! A.style "display:none;" $ H.toHtml (T.pack (Idx.riFilename rInfo)) sseScript = H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs ``` - [ ] **Step 3: Add SSE script to `indexPage`** In `indexPage`, after the existing `searchJs` script block, add a new script block for the SSE listener. Find this line in `indexPage`: ```haskell H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs ``` And add a second script block right after it: ```haskell -- Inline JS for SSE live-reload H.script ! A.type_ "text/javascript" $ H.preEscapedText sseIndexJs ``` - [ ] **Step 4: Build to verify compilation** Run: `./hs stack build` Expected: compiles without errors. - [ ] **Step 5: Run tests to verify nothing broke** Run: `./hs stack test` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add src/Roux/Html.hs git commit -m "feat: add SSE client JS for live recipe reload on detail and index pages" ```