12 KiB
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:
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
ChangeLogtype alias andrecordChangefunction
Add after the where clause of app (before watchRecipes):
-- | 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):
{- | 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):
-- | 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
appto create ChangeLog and use router
Change the end of app — replace:
pure (handleRequest state)
with:
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:
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
watchRecipessignature and body to acceptChangeLog
Replace the watchRecipes signature and body:
watchRecipes :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> IO ()
And update the callback to pass changeLog to reReadRecipe:
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
reReadRecipesignature and body to accept and useChangeLog
Replace the reReadRecipe signature:
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:
atomicModifyIORef' state $ \m ->
(Map.delete key m, ())
with:
atomicModifyIORef' state $ \m ->
(Map.delete key m, ())
recordChange changeLog path
In the successful update case (Right _), replace:
atomicModifyIORef' state $ \m ->
(Map.insert key info m, ())
with:
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
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:
-- | 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:
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:
H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
And add a second script block right after it:
-- 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
git add src/Roux/Html.hs
git commit -m "feat: add SSE client JS for live recipe reload on detail and index pages"