live update docs
Build and Deploy / build-and-deploy (push) Successful in 1m22s

This commit is contained in:
2026-05-20 11:57:58 -04:00
parent 795a99ae2b
commit 2628fcf6d9
2 changed files with 542 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
# 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"
```