Skip to content

Re-render and reload the browser on change with --watch - #32

Merged
ManuelRauber merged 16 commits into
mainfrom
feature/watch-option
Aug 21, 2026
Merged

ManuelRauber merged 16 commits into
mainfrom
feature/watch-option

Conversation

@ManuelRauber

@ManuelRauber ManuelRauber commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #31

mat README.md --watch (short -w) keeps rendering: every change to a rendered file re-renders it
and reloads the open tab. This implements the plan recorded on the issue, commit by commit in the
order it lists.

How it works

  • src/cli/reload-server.ts starts one Bun.serve per session on 127.0.0.1, on an ephemeral port,
    with a random 128-bit token as its path. It upgrades only on that exact path and answers 404
    otherwise. The page itself stays a plain file:// document.
  • src/html/reload-client.ts emits a classic inline <script>, injected only when a render is given
    a reload channel, so --output and every plain render structurally cannot carry it. It reconnects
    every second, at most 25 times, with the counter reset on open, so a tab left open goes quiet
    about 25 seconds after mat exits.
  • src/cli/file-watcher.ts watches the parent directories with fs.watch and filters by file name,
    with one shared 200 ms debounce. Directory watches rather than file watches, because an atomic
    save replaces the inode and leaves a file watch pointing at the one that is gone.
  • src/cli/watch.ts holds the session loop. It is driven by an AbortSignal, so tests never need
    process signals, and it queues exactly one follow-up for whatever lands during a render.
  • src/cli.ts gained a reusable renderDocuments() step, split out of runRender in a
    behaviour-neutral first commit, plus the main wiring for --watch.

Deviations from the plan

  • No manual heartbeat. Bun's WebSocket server already sends protocol-level pings by default
    (sendPings, against its 120 second idleTimeout), so the planned 30 second 'ping' interval was
    redundant. The server carries no timer of its own.
  • The watcher needs more than a name filter. Bun's inotify watcher reports a rename inside a
    directory under the name that vanished: an atomic save arrives as index.md.tmp, never as
    index.md. The watcher therefore keeps an inode, size and mtime baseline per watched file and
    treats a changed baseline as a hit.
  • The re-render log line is a plain re-rendered rather than naming the document, which kept the
    interface between runWatch and its render step down to what it actually needs.
  • A watch configuration key exists after all. The plan ruled one out; Manu asked for it, so
    {"watch": true} now makes --watch the default. Like followLinks, the flag became tristate so
    --watch=false can override the file, and the configured default is dropped rather than rejected
    where it cannot apply: with --output (no tab to reload) and with - (no path to watch). Worth
    knowing before enabling it: every plain mat call then becomes a long-running foreground process,
    including calls from scripts.
  • Commit types: the two user-facing commits are feat:, the building blocks are chore:, so
    release-please describes the feature in two changelog lines rather than seven.

Verification

bunx tsc --noEmit, bunx biome check . and bun test are clean (399 pass, 7 skip, 0 fail; 347
before). A compiled binary was built and driven by hand: render, save, re-render, Ctrl+C, exit 0.
Every new test was mutation-checked against the production line it claims to cover.

Beyond the plan's test list, tests/watch.test.ts drives runWatch with a render step the test
controls, which is the only way to reach the mid-render change queue: without it, deleting that
queue left every other watch test green.

Known limitations, deliberately not fixed here

  1. A watched directory replaced wholesale (rm -rf docs && mkdir docs, or a branch switch that
    removes and recreates it) leaves the fs.watch handle on the destroyed inode, and the session
    then silently stops noticing changes. Both candidate fixes are unsatisfying: the inode is handed
    straight back to the replacement on this filesystem, and birthtime equals ctime there, so an
    identity check would also re-arm whenever a sibling file appears; the robust alternative is
    periodic re-arming, which the plan deliberately avoided. Worth its own issue.
  2. A save that lands while the first render is running is missed until the next save. The obvious
    fix, comparing mtime against a Date.now() taken before the render, is not reliable: mtime and
    the wall clock run on different sources here, with mtime lagging by a fraction of a millisecond,
    so the comparison silently drops the change. Doing it properly means threading the mtime the
    render actually read out through the follow queue.
  3. With --follow-links, a link target that could not be rendered (missing, binary, too large)
    is not watched, so creating or repairing it is only picked up on the next change to a file that
    is watched.
  4. The reload channel authenticates the client to the server, not the server to the client. A
    local process that squats the ephemeral port after mat exits could drive a still-reconnecting tab
    into repeated reloads. Closing that means carrying a second secret in the reload message.

@ManuelRauber
ManuelRauber enabled auto-merge August 21, 2026 15:33
@ManuelRauber
ManuelRauber merged commit fdc0a31 into main Aug 21, 2026
7 checks passed
@ManuelRauber
ManuelRauber deleted the feature/watch-option branch August 21, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--watch option

1 participant