181 lines
6.0 KiB
Markdown
181 lines
6.0 KiB
Markdown
# 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.
|