Files
roux/docs/specs/2026-05-19-live-recipe-watching-design.md
T

4.4 KiB

Live Recipe Watching

Date: 2026-05-19 Status: Approved

Problem

The server loads all recipes once at startup and serves them read-only for the duration of the server process. Since the recipe directory is the source of truth, any edits to .cook files on disk require a server restart to be reflected on the site.

Goal

Watch the recipe directory for file changes (additions, modifications, deletions) and update the in-memory recipe index live, without a restart.

Non-Goals

  • Real-time push to open browser tabs (a page refresh will show new content).
  • Watching subdirectories (all recipes are flat in one directory).
  • Watcher restart on failure (log and let it die; restart the container if needed).

Architecture

State

Replace the current [RecipeInfo] closure with an IORef (Map FilePath RecipeInfo):

  • IORef — provides atomic reads and swaps; in base, no extra dependency.
  • MapData.Map.Strict, already a project dependency.
  • Key — lowercased filename (e.g., "pad-thai.cook") for O(log n) case-insensitive lookups, matching the current lookupRecipe behavior.
  • Value — the existing RecipeInfo, which includes the original (mixed-case) filename in riFilename.

Watcher Thread

A forkIO'd thread uses the fsnotify package to subscribe to all events on the recipe directory. Three event types are handled:

Event Action
Added Read & parse the file → upsert in the Map
Modified Read & parse the file → upsert in the Map
Removed Delete from the Map

All mutations go through atomicModifyIORef' so HTTP handlers always see a consistent snapshot.

Debouncing

Text editors fire multiple filesystem events per save (e.g., delete+create for atomic saves, multiple metadata events). A simple 200ms debounce collects events from fsnotify's callback into a set, then processes them after 200ms of silence. This prevents redundant re-parses.

Startup Flow

  1. Scan the recipe directory with Idx.scanRecipes (same as today).
  2. Build the initial IORef (Map FilePath RecipeInfo).
  3. forkIO the watcher thread.
  4. Return the WAI Application (now closing over the IORef).

Request Handling

  • handleRequest reads from the IORef to get the current [RecipeInfo] (via Map.elems) or a single RecipeInfo (via Map.lookup).
  • The index page continues to receive [RecipeInfo] and re-renders on each request — no caching changes needed.
  • lookupRecipe becomes a Map.lookup with a normalized key instead of a linear scan.

Error Handling

  • Parse error on modification: log the error (with [roux] prefix), keep the old recipe in the Map. The last valid version stays accessible.
  • File read error (permissions, race): log and skip.
  • Watcher thread crash: log and let it die. The server continues serving the last known state. A container restart would restore watching.

Dependencies

Add fsnotify to package.yaml (and the .cabal file via hpack).

No new concurrency libraries needed — IORef and forkIO are in base.

Changes by File

package.yaml

  • Add fsnotify to library dependencies.

src/Roux/RecipeIndex.hs

  • No changes (the scanning/loading functions are already correct).

src/Roux/Server.hs

  • app :: FilePath -> IO Wai.Application — same signature, new internals:
    • Return type is already IO Wai.Application, so signature stays the same.
    • Scan recipes → populate IORef.
    • Fork watcher thread (with debounce).
    • Return handler closing over the IORef.
  • handleRequest :: IORef (Map FilePath RecipeInfo) -> Wai.Application — updated to accept the IORef.
  • lookupRecipe :: FilePath -> Map FilePath RecipeInfo -> Maybe RecipeInfo — simplified to a Map.lookup with normalized key.
  • New module-level imports: Data.IORef, Control.Concurrent (forkIO), System.FSNotify, Data.Map.Strict.

app/Main.hs

  • No changes needed (it just calls Roux.app as before).

Testing

  • Manual test: edit a .cook file while the server runs and confirm the change is reflected on a page refresh.
  • Invalid recipe test: introduce a parse error and confirm the old version stays live.
  • Delete test: remove a .cook file and confirm a 404 on refresh.