This commit is contained in:
@@ -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"
|
||||||
|
```
|
||||||
@@ -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 `<script>` in `Html.hs`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
(function() {
|
||||||
|
if (window.EventSource) {
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
es.addEventListener('recipe-changed', function() { location.reload(); });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recipe detail page** — new inline `<script>` added by `recipePage`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
(function() {
|
||||||
|
if (window.EventSource) {
|
||||||
|
const currentRecipe = document.getElementById('roux-current-recipe').textContent;
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
es.addEventListener('recipe-changed', function(e) {
|
||||||
|
if (e.data === currentRecipe) location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
A hidden `<span id="roux-current-recipe" style="display:none;">`
|
||||||
|
containing the filename (e.g. `"FriedRice.cook"`) is injected into the
|
||||||
|
recipe detail page HTML, allowing the JS to match SSE event data against
|
||||||
|
the currently viewed recipe.
|
||||||
|
|
||||||
|
## Changes by File
|
||||||
|
|
||||||
|
### `src/Roux/Server.hs`
|
||||||
|
|
||||||
|
| Change | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| New import | `Data.ByteString.Lazy.Char8 (pack)` — for SSE event payloads |
|
||||||
|
| New type alias | `type ChangeLog = IORef (Int, [(Int, FilePath)])` |
|
||||||
|
| New function | `recordChange :: ChangeLog -> FilePath -> IO ()` |
|
||||||
|
| New function | `sseHandler :: ChangeLog -> Wai.Application` |
|
||||||
|
| New function | `router :: IORef (Map FilePath RecipeInfo) -> ChangeLog -> Wai.Application` |
|
||||||
|
| Modify `app` | Create `ChangeLog`, pass to watcher, return `router` instead of `handleRequest` |
|
||||||
|
| Modify `watchRecipes` | Accept `ChangeLog`, call `recordChange` on each event |
|
||||||
|
| Keep | `handleRequest`, `lookupRecipe`, `stripCookExt`, `urlDecodePath`, `htmlResponse`, `notFound` unchanged |
|
||||||
|
|
||||||
|
### `src/Roux/Html.hs`
|
||||||
|
|
||||||
|
| Change | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Modify `renderRecipe` | Add hidden `<span id="roux-current-recipe">` with the filename |
|
||||||
|
| Modify `recipePage` | Add inline `<script>` for SSE listener on recipe detail page |
|
||||||
|
| Modify `indexPage` | Add a separate `<script>` block after the existing JS with the SSE listener IIFE |
|
||||||
|
|
||||||
|
### `app/Main.hs`
|
||||||
|
|
||||||
|
No changes needed — `Roux.app` signature stays the same.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Manual: modify a recipe** while viewing its detail page → page reloads
|
||||||
|
within ~1 second showing the new content.
|
||||||
|
- **Manual: add a recipe** while viewing the index page → index page reloads
|
||||||
|
within ~1 second showing the new entry.
|
||||||
|
- **Manual: delete a recipe** while viewing it → page reloads to a 404.
|
||||||
|
- **Manual: disconnect network** → `EventSource` reconnects automatically
|
||||||
|
when the connection returns.
|
||||||
|
- **Manual: invalid recipe** → watcher logs the error, keeps the old version,
|
||||||
|
no SSE event fired → no page reload.
|
||||||
Reference in New Issue
Block a user