Served Shell
Note
Status: implemented. ddown visualize serves the report from one loopback server: a single
page with the Explorer, a runtime View ⇄ Edit toggle, and hot reload. -o writes an offline
static export instead.
Table of contents
- Goal and scope
- Ubiquitous language
- Command-line surface
- Architecture
- HTTP surface
- Key design decisions
- Error and boundary cases
- Security
- Testability
Goal and scope
A writer iterating on a script wants the report to keep up: save the file, see the new stages, open the next script without restarting anything. The served shell is that loop. It owns the command line, the one server behind every served report, the route table, hosting, and file watching.
Related notes own the rest:
| Concern | Owner |
|---|---|
| The editor buffer, Save, autosave, conflicts | Live Edit and Autosave |
| The file tree, the Files toggle, opening a script in place | Explorer |
| Zen, the narrow layout, the Problems panel | Chrome and Layout |
| Linked, cached client assets and on-demand Mermaid | Served Client Packaging |
Ubiquitous language
| Term | Meaning |
|---|---|
| Served shell | The one report page the server renders: tabs, views, and the Explorer sidebar. |
| Empty shell | The served shell with no active document: the Explorer and an "open or create a script" call to action. |
| Active document | The script the server is currently serving, held as one LiveSession. At most one at a time. |
| Pinned document | The script a ddown visualize <script> run starts on. / redirects to it. |
| Browse root | The folder the Explorer lists and every path is confined to (BrowseRoot). The security boundary. |
| Serve root | The folder hosted as static files so a report's relative images resolve (ServeRoot). For a served run it equals the browse root. |
| View | Read-only and auto-updating: the report hot-reloads on disk changes. |
| Edit | Editable: the session owns the buffer and saves it to disk. |
| Static export | A self-contained, offline, read-only report file with no server and no toggle. |
| Hot reload | Recompiling after a disk change and pushing the new report to every open tab. |
Command-line surface
| Command | Result |
|---|---|
ddown visualize |
The empty shell over --root, or the current folder. |
ddown visualize <script> |
A served session on that script, in View. |
ddown visualize <script> --edit |
The same, starting in Edit. |
ddown visualize <script> -o <out.html> |
A static export; no server. Opens the file unless --no-open. |
--root <dir> |
Pins the browse root (and so the serve root). |
--config <path> |
The dialogue.toml to apply. Default: the nearest one above the script, within the root. |
--port <n>, --no-open |
A fixed loopback port (default: ephemeral), and no browser launch. |
The command prints the URL and serves until Ctrl+C. --edit only chooses the starting side of the
toggle; the reader can flip it at any time.
Architecture
flowchart LR
CLI["ddown visualize"] --> Runner["ServedShellRunner"]
CLI -->|"-o"| Static["StaticMode<br/>(self-contained file)"]
Runner --> Server["ServedShellServer<br/>(ASP.NET minimal API, loopback)"]
Server --> Root["BrowseRoot<br/>(confine + list)"]
Server --> Session["LiveSession<br/>(compile, save, broadcast)"]
Server --> Watches["TreeWatches<br/>(one watcher per folder)"]
Watches -->|"debounced change"| Session
Session --> Sse["SseBroadcaster"]
Sse -->|"SSE"| Browser["Browser<br/>(served shell)"]
Browser -->|"GET / POST"| Server
ServedShellRunner resolves the root, builds the server, and either starts the pinned document
(StartInitialDocument) or serves the empty shell. The server keeps one active LiveSession;
opening another script replaces it.
A disk change flows through one path:
sequenceDiagram
participant Disk as Script on disk
participant Server as ServedShellServer
participant Browser
Browser->>Server: GET /api/events?doc=<path> (stays open)
Disk-->>Server: change (debounced 150 ms)
Server->>Server: recompile the active document
alt readable
Server-->>Browser: reload {report}
else missing or unreadable
Server-->>Browser: problem {message, target}
end
HTTP surface
Loopback only. Every path argument is root-relative and confined by BrowseRoot.
| Route | Purpose |
|---|---|
GET / |
The empty shell, or a 303 to the pinned document's report. |
GET /browse |
Always the empty shell: the way back to the files from a pinned run. |
GET /r/<path>/ |
The active document's report page (no-store). |
GET /r/<file> |
A static file under the serve root, such as an image the script links. |
GET /assets/<name> |
A hashed client asset (immutable); see Served Client Packaging. |
GET /api/browse?path= |
One folder's sub-folders and scripts (BrowseListing). |
POST /api/open |
{ source, mode }: make a script active; 303 to its report. |
POST /api/create |
{ path }: write an empty script and open it in Edit; 409 if the name exists. |
POST /api/create-folder |
{ path }: create a folder; 409 if the name exists. |
POST /api/rename |
{ from, to }: move a script or folder; reports the active document's new path when the move carries it. |
GET /api/document |
The active document's current payload. |
POST /api/save |
Save the source or config with a typed outcome; see Live Edit and Autosave. |
POST /api/reload |
Re-read the source or config from disk after a conflict. |
POST /api/create-config |
Create or adopt dialogue.toml at the root; the path never comes from the request. |
GET /api/events?doc= |
The SSE stream: reload, reload-config, problem, displaced. |
Key design decisions
D1 — One server, reached two ways
ServedShellServer is the only live server. ddown visualize <script> starts it on a pinned
document; ddown visualize starts it on the empty shell. Both give every report the Explorer from
one code path. A pinned run redirects / to its report; a browse-only run keeps / on the empty
shell even after a script is opened, so / stays a place to browse from. /browse is always the
shell, whichever way the run started.
D2 — ASP.NET minimal API and Server-Sent Events
A minimal API gives JSON, static files, and streaming idiomatically, and runs on an ephemeral
loopback port so the routes get real HTTP integration tests. The server only ever pushes
("recompiled — here is the report"); everything the client sends is an ordinary request. SSE is
exactly that one-way channel, over plain HTTP, and the browser's EventSource reconnects on its
own. A WebSocket's second direction would go unused. The stream ends on client disconnect or on
host shutdown, so an open tab never holds Ctrl+C open.
D3 — View and Edit are a client toggle over one server
The server always watches, serves, and accepts saves; the client decides whether to edit and how to react to a push. That makes the toggle instant and the backend one type.
- View re-renders on
reload. Edit owns its buffer and turns an external change into a conflict instead. - The toggle is a native segmented control (
[ View | Edit ],role="group",aria-pressed) in the status bar. A static export shows a read-only badge instead. - The toggle reconfigures one CodeMirror
Compartment(read-only state,aria-readonly, and the authoring aids), so the buffer, cursor, scroll, and undo history survive the switch. - Leaving Edit with unsaved work goes through the same save guard as navigation.
- The reader's last choice is remembered in
localStorage(dd-served-mode) and offered for the next script.
The report payload's mode is static, view, or edit; the last two only choose the starting
side. /api/save is reachable while the client is in View, which is acceptable on a single-user
loopback tool.
D4 — The offline report is an export, not a mode
A read-only report that does not keep up with the file adds nothing over View, so it is not an
interactive mode. The portable, serverless file is still worth having, so it is -o: rendered by
StaticMode, with every asset inlined.
D5 — The server browses the filesystem, not a native file dialog
A browser <input type="file"> yields a file's contents and name but never its path, and the
session needs the path to watch the file and resolve its images. The Explorer therefore lists the
server's folders through GET /api/browse, which also lets the server enforce the root and the
.dialogue.md filter.
D6 — Create eagerly, confined, and never overwriting
POST /api/create writes an empty file at once and opens it, so the session, watcher, and report
always read a real file. The path must end in .dialogue.md, sit inside the root, and name an
existing folder. A name already in use is a 409 with the file untouched, so the client offers to
open it instead. The same create-and-open flow is why there is no Save As: a copy is a new file or
a copy on disk.
D7 — Host the smallest folder that covers the script and its images
A script can link images above its own folder (). For a pinned run,
ServeRootResolver picks the serve root:
| Situation | Serve root |
|---|---|
--root <dir> given |
That folder (it must contain the script). No prompt. |
| All linked images inside the script's folder | The script's folder. |
| Images above it, reader consents | The nearest common ancestor of the script and those images. |
| Images above it, reader declines or no terminal | The script's folder; those images do not load. |
The report is served at the script's sub-path under the root (/r/proj/), so relative ../ links
resolve. A launched symlink is resolved to its real file first (SymlinkResolver), so saves replace
the real file and the watcher sees its changes; the report still shows the launched path. A broken
or cyclic link exits with a message.
D8 — One operating-system watcher per folder
Registering a FileSystemWatcher is what an open costs — on macOS about 110–180 ms, against 2–4 ms
to compile and serialize a small script. TreeWatches registers each folder once and routes events
to the watches on each path, so a later open in a known folder registers nothing.
| Open | Watcher per document | TreeWatches |
|---|---|---|
POST /api/open, folder already watched |
127–178 ms | 0.8–4 ms |
POST /api/open, first script in a folder |
127–178 ms | ~100–125 ms, once |
- Folders are watched shallowly. A recursive watch would make every open free but report every change beneath the root; on Linux a busy subfolder overflows the kernel buffer.
- On overflow every watch is told: a spurious reload is cheaper than a report that never updates.
PathComparisonmatches paths the way the platform does (case-insensitive on macOS and Windows).PhysicalFileProviderwas measured and rejected: it hides dotfiles by default, and it delivers one save as several notifications up to 0.8 s apart, which breaks the session's one-shot suppression of its own writes. Static files keep their own provider, which never watches.- Each watch is debounced (150 ms) and serialized: an editor's write burst yields one recompile, and a change during a slow recompile schedules exactly one follow-up.
The active document and its dialogue.toml each get a watch. A config created at runtime gets one
when it is created.
D9 — A tab names the document it shows
/api/events?doc= binds a stream to one script. When another script becomes active, the server
sends displaced to the old stream and drops its watches; a reconnect that names a script no
longer active gets displaced at once. The client shows a banner rather than applying another
script's reloads.
Error and boundary cases
| Case | Behavior |
|---|---|
Missing file, wrong extension, invalid --root |
The command exits with a message before serving. |
| Editor save burst (temp write + rename) | Debounced into one recompile of the final content. |
| Compile errors after a change | Still a reload; diagnostics ride the payload to the overlay and Problems panel. |
| Script deleted or unreadable | problem { target }, routed to that document's save controller; a later save recovers. |
| Several open tabs | All receive each push. |
--port already in use |
Binding fails and the process reports the error. |
| Rename that carries the active document | Its watch is dropped (no spurious "deleted"); the client reopens it at the new path, which watches it again. |
Path escapes the root (.., absolute, symlink out) |
Rejected with 400 or 404; nothing outside is listed or served. |
Not a .dialogue.md file |
Not listed, not opened. |
Security
This is a local development tool:
- The server binds
127.0.0.1only. No authentication. - A requested path is rejected when it is absolute or contains
..; an accepted path is canonicalized (following a terminal symlink) and rejected unless it lies inside the root. - Hosting above a script's folder needs consent or an explicit
--root(D7). - Save writes the session's own document path, never a path from the request body. Create and
rename are confined like every other path;
create-configcomposes its path server-side. - Served HTML is
no-store. Pages are compressed (gzip preferred); the event stream is not.
Testability
- Server (.NET, real loopback): each test starts
ServedShellServeron an ephemeral port and drives every route withHttpClient, including confinement,409conflicts, the pinned-run redirect, and SSEreload. BrowseRoot: rejects.., absolute paths, and symlink escapes.TreeWatches: against a real temporary folder — a watched path fires, an unwatched one does not, a burst fires once, several scripts in one folder share one watcher, and a dotfile script still reloads.Debouncer: timing tested apart from the filesystem, with an injectable window and an injectable clock. The coalescing test waits only for the operating system to deliver a write, then advances a fake clock itself, so a slow machine cannot turn one save into several reports.- CLI: routing of script,
--root,--edit, and-oto the right runner. - Browser (Playwright, live): the CLI's built DLL serves a temp tree; the specs edit and delete
files on disk, flip View ⇄ Edit with the buffer preserved, and open a script whose images sit
above its folder under
--root.