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; inbase, no extra dependency.Map—Data.Map.Strict, already a project dependency.- Key — lowercased filename (e.g.,
"pad-thai.cook") for O(log n) case-insensitive lookups, matching the currentlookupRecipebehavior. - Value — the existing
RecipeInfo, which includes the original (mixed-case) filename inriFilename.
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
- Scan the recipe directory with
Idx.scanRecipes(same as today). - Build the initial
IORef (Map FilePath RecipeInfo). forkIOthe watcher thread.- Return the WAI
Application(now closing over theIORef).
Request Handling
handleRequestreads from theIORefto get the current[RecipeInfo](viaMap.elems) or a singleRecipeInfo(viaMap.lookup).- The index page continues to receive
[RecipeInfo]and re-renders on each request — no caching changes needed. lookupRecipebecomes aMap.lookupwith 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
fsnotifyto 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.
- Return type is already
handleRequest :: IORef (Map FilePath RecipeInfo) -> Wai.Application— updated to accept theIORef.lookupRecipe :: FilePath -> Map FilePath RecipeInfo -> Maybe RecipeInfo— simplified to aMap.lookupwith 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.appas before).
Testing
- Manual test: edit a
.cookfile 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
.cookfile and confirm a 404 on refresh.