From 2628fcf6d9950f22c34460c3dc4f47ce211880c4 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 20 May 2026 11:57:58 -0400 Subject: [PATCH] live update docs --- docs/plans/2026-05-20-live-recipe-reload.md | 362 ++++++++++++++++++ .../2026-05-20-live-recipe-reload-design.md | 180 +++++++++ 2 files changed, 542 insertions(+) create mode 100644 docs/plans/2026-05-20-live-recipe-reload.md create mode 100644 docs/specs/2026-05-20-live-recipe-reload-design.md diff --git a/docs/plans/2026-05-20-live-recipe-reload.md b/docs/plans/2026-05-20-live-recipe-reload.md new file mode 100644 index 0000000..db62268 --- /dev/null +++ b/docs/plans/2026-05-20-live-recipe-reload.md @@ -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" +``` diff --git a/docs/specs/2026-05-20-live-recipe-reload-design.md b/docs/specs/2026-05-20-live-recipe-reload-design.md new file mode 100644 index 0000000..d48fb03 --- /dev/null +++ b/docs/specs/2026-05-20-live-recipe-reload-design.md @@ -0,0 +1,180 @@ +# 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 `