Files
roux/docs/plans/2026-05-20-live-recipe-reload.md
jbrechtel 2628fcf6d9
Build and Deploy / build-and-deploy (push) Successful in 1m22s
live update docs
2026-05-20 11:57:58 -04:00

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 ChangeLog type alias and recordChange function

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 app to 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 watchRecipes signature and body to accept ChangeLog

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 reReadRecipe signature and body to accept and use ChangeLog

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"