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

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 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:

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:

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 networkEventSource reconnects automatically when the connection returns.
  • Manual: invalid recipe → watcher logs the error, keeps the old version, no SSE event fired → no page reload.