6.0 KiB
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:
type ChangeLog = IORef (Int, [(Int, FilePath)])
-- (currentVersion, [(version, changedFile), ...])
currentVersion— monotonically increasingInt, bumped on every add/modify/delete event.- Change list — newest-first, capped at the last 100 entries
(
take 99after 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:
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:
- Read the
ChangeLog's current version. - If
currentVersion > lastSentVersion, pushrecipe-changedevents for all entries withv > lastSentVersion. - Every 30 seconds, push a
pingevent 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 →
finallycleans up the polling loop. - Warp handles client disconnect detection natively.
- Browser
EventSourcereconnects automatically on drop.
Router — Dispatching to SSE vs Recipe Handlers
A new router function replaces the direct use of handleRequest:
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:
(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:
(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 →
EventSourcereconnects automatically when the connection returns. - Manual: invalid recipe → watcher logs the error, keeps the old version, no SSE event fired → no page reload.