1 Architecture
The logical map: how a save flows from the file watcher to a repainted block in a few milliseconds, and where each crate sits.
Saving post.tmd updates the browser in a few milliseconds: no reload, no lost
scroll, no cold start. That is possible because of one decision, drawn in
Figure 1.1: all of the intelligence lives in the Rust core behind a stable
websocket protocol, and the browser preview is a thin client.
flowchart LR
BR["Browser preview<br/>(vanilla JS)"]
subgraph Server["taliesin server"]
direction TB
WS["Websocket<br/>+ file watcher"]
subgraph Core["taliesin-core"]
direction TB
P["Parser<br/>comrak + sourcepos"] --> M["Block model<br/>+ diff"] --> R["Render<br/>page / site / book"]
end
K["Warm Jupyter kernel"]
end
F[(".tmd files")]
BR <-->|JSON protocol| WS
WS --> P
R -->|changed blocks| WS
F -.->|watch| WS
M -->|code cells| K --> M
| Component | Crate / path | Responsibility |
|---|---|---|
| Core | crates/core | Parsing (comrak + sourcepos), the block model, HTML rendering |
| Server | crates/server | Websocket dev server, file watcher, Jupyter kernel pool |
| Web client | web-client/ | Browser preview client (vanilla JS) |
The protocol is open, so an embedded editor client is a new thin client, not a rewrite: the in-repo VS Code companion (covered in extending Taliesin) is exactly that.
1.1 How a save flows
Change tau from 10 to 12 in post.tmd and save. Only the changed blocks reach
the preview: the heading and the paragraph stay put, and only the plot the cell
redraws crosses the wire.
The order matters, and it is easy to get wrong: a save is re-render, then
execute, then diff, not re-render then diff. The server re-runs the render
pipeline (render/) to get a fresh block list, then hands that list to the
executor (exec.rs), which runs the changed code cells (plus everything
downstream) and splices each cell’s output back into the list as a derived
output block. Only then does diff.rs compare the resulting list against the
live one. Execution sits squarely between render and diff: the diff never sees
the bare render output, it sees the render output after outputs have been
spliced in. ({js} cells are the exception: they run in the browser, never
through exec.rs, so a {js} block crosses the wire as-is. See
Interactive cells.)
The same save as an interaction over time, module by module, is Figure 1.2:
sequenceDiagram autonumber participant FS as .tmd file participant SV as serve_site/mod.rs (watcher) participant RN as render/ (core) participant EX as exec + kernel participant DF as diff.rs participant CL as client.js FS->>SV: changed (notify) SV->>RN: re-render (debounced) RN->>EX: run first changed cell + downstream EX-->>RN: output blocks RN-->>SV: new block list SV->>DF: new blocks vs live blocks DF-->>SV: BlockOps (changed only) SV->>CL: ws update / insert / remove CL->>CL: swap blocks in place (scroll + live state kept)
The round trip between render/ and exec + kernel in that diagram is the execution
step, not a render loop: the block list diff.rs finally sees is the post-execution one.
One server handles a project and a single document alike (a document resolves to
the project it belongs to, or becomes a project of itself): navigation between
pages is an ordinary full page load (no SPA), and warm kernels are pooled.
serve_site/exec_pool.rs keeps the six most-recently-built pages’ executors alive
(MAX_WARM_PAGES) in a deterministic LRU and drops the rest, so revisiting a recent
chapter reuses its kernel while an unbounded map cannot grow one 80 to 150 MB kernel
per page visited; an evicted page pays a cold start on its next edit, and nothing
else. The multi-page subsystem (page discovery, chrome, cross-page references) lives
a layer up, in crates/core/src/site/, whose mod.rs header is the entry point to
read.
Three parts of that pipeline are worth a closer look: the watcher that starts it, the isolation that keeps one bad build from taking down the session, and the guards that keep the whole thing reachable only from this machine.
1.1.1 The watcher
spawn_watcher runs notify on its own thread (notify is synchronous) and watches the
project’s directory. Events are pumped through a channel so that thread
owns the watcher and can register watches for directories created after startup: the
recursive-watch model added an inotify descriptor per directory, including
node_modules and .git, which a large project uses to exhaust max_user_watches and
kill hot reload outright.
Skipping _freeze matters: the executor writes its own cache there on every run, and
without the skip each write would kick a redundant rebuild. The skip list is matched
against the path relative to the project root, never the whole path: the watcher deals
in absolute event paths, and a project can perfectly well live under a directory someone
else called _site or .git. Scanning the whole path vetoed every event in such a
project, which is hot reload dead, silently, with each page still serving 200. Unrelated
saves (an editor swap file, a build-output write) are filtered out, and a burst of
saves is debounced.
1.1.2 Panic isolation
The render → execute → diff pipeline above runs inside a spawned task per page, so a
panic anywhere along it would otherwise silently kill hot reload for the rest of the
session. build_page_guarded wraps each build in catch_unwind (via
AssertUnwindSafe over the awaited future, so the panic is caught rather than
propagated): on a caught panic it pulls a human message off the payload (panic_msg
downcasts the Box<dyn Any> to &str/String), logs it, and broadcasts a
protocol::error so every connected client shows an error overlay. The worker
survives, so the next good save re-renders and clears the overlay (the errored flag
makes that recovery a full re-mount even when the diff is empty). Because the state
lives behind a parking_lot::Mutex (not std::sync::Mutex), a panic while the lock is
held releases it cleanly rather than poisoning it.
1.2 Binding, and the two guards
The server binds 127.0.0.1 and nothing else: there is no flag that puts it on a
network. That is the whole exposure story, and it is why the two guards in
serve/security.rs are both about a local peer rather than a remote one.
ws_origin_ok gates the websocket upgrade on Origin against Host. A page on
another site must not be able to open the control channel and send restart_kernel,
which would destroy the warm kernel this whole design exists to keep. A loopback
Origin is trusted (a second dev server, the editor companion); anything else that is
not same-origin gets a 403.
with_host_guard applies host_allowed to every HTTP response: the Host header
must name a loopback address. This is the DNS-rebinding defence, and it cannot be
folded into the origin check, because under a rebind both headers are the attacker’s
domain and compare equal. A request with no Host is allowed: only a browser can
mount a rebind, and a browser always sends one.
Reading a draft on another device is a build and a static file server, not a flag on
the preview.
1.3 The codebase map
The dependency shape first, who leans on whom (Figure 1.3): the server orchestrates and owns the runtime, the core does all the rendering and never touches the network, and the browser client only mounts blocks and applies ops.
flowchart TB
subgraph client["web-client/ · browser (vanilla JS)"]
CL["client.js<br/>mount · apply ops · click-to-source"]
end
subgraph server["crates/server · Taliesin (binary)"]
direction TB
MAIN["main.rs · CLI"]
SITE["serve_site/<br/>THE dev server"]
SRV["serve/<br/>shared HTTP + guards"]
BLD["build.rs"]
LSP["lsp*.rs<br/>offline, kernel-free"]
PR["protocol.rs<br/>shared ws contract"]
EX["exec.rs · executor"]
KR["kernel.rs<br/>warm ZMQ kernel"]
MAIN --> SITE
MAIN --> BLD
MAIN --> LSP
SITE --> SRV
SITE --> EX
SITE --> PR
EX --> KR
end
subgraph core["crates/core · taliesin-core (library)"]
direction TB
RN["render/<br/>parse → block model → HTML"]
DF["diff.rs"]
SM["site/<br/>multi-page projects"]
INC["includes.rs"]
FM["frontmatter.rs"]
CI["cite/"]
MA["math.rs"]
HL["highlight.rs"]
RN --> INC
RN --> FM
RN --> CI
RN --> MA
RN --> HL
SM --> RN
end
CL <-->|ws JSON| SITE
SITE --> RN
SITE --> SM
SITE --> DF
BLD --> RN
LSP --> RN
EX -.->|"output blocks<br/>(data flow, not a call)"| DF
The data those modules pass around is small (Figure 1.4): a RenderedDoc is
a list of Blocks, an executable block carries a Cell, and diff.rs turns two
block lists into a stream of BlockOps.
classDiagram
class RenderedDoc {
Option~String~ title
bool toc
Vec~Block~ blocks
}
class Block {
String id
String sourcepos
Option~String~ source_file
String html
Option~Cell~ cell
}
class Cell {
String lang
String code
bool echo
bool include
}
class BlockOp {
<<enumeration>>
Update
Insert
Remove
SetMeta
}
RenderedDoc "1" *-- "many" Block : blocks
Block "1" o-- "0..1" Cell : cell
Block ..> BlockOp : diff emits
The file-by-file detail is not restated here, because a table of module
responsibilities is a second copy of something each file’s own header already says,
and a second copy is what goes stale. Start at crates/core/src/render/mod.rs for
the pipeline, crates/core/src/site/mod.rs for multi-page projects, and
crates/server/src/serve_site/mod.rs for the dev server. Each opens with a header
describing what it owns.