Skip to content
Taliesin Internals

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
Figure 1.1: Thin clients talk to the server over a websocket; all rendering lives in the Rust core.
ComponentCrate / pathResponsibility
Corecrates/coreParsing (comrak + sourcepos), the block model, HTML rendering
Servercrates/serverWebsocket dev server, file watcher, Jupyter kernel pool
Web clientweb-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)
Figure 1.2: A save, module by module: only changed blocks (and re-run cells) cross the wire.

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
Figure 1.3: Module dependencies across the three layers. A solid arrow is ‘uses / calls’; the dashed exec→diff arrow is data flow (exec produces the blocks diff consumes), not a call.

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
Figure 1.4: The core data model (key fields shown): what flows from the parser to the client.

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.