htmx: server-rendered HTML with push
Part I built the inspector as resources and never said where they would be shown. This chapter
puts them in a browser: a host that serves the inspector’s pages over HTTP, with htmx swapping
views in place and the push stream keeping them current. It is the first delivery target, and
it is deliberately thin. Everything a page shows is the inspector crate’s; this crate adds
only what a door owns.
If you played the board in Tic-tac-toe, V: a board you can play, you have seen the pattern: htmx markup served as a resource, and a door that maps a URL to a name. The inspector follows the same pattern, with the rules this book has collected.
Running it
From a checkout of this book’s repository:
cargo run -p inspector-htmx
It prints the address it is serving on (inspector: http://127.0.0.1:8071/); open it. The
cache view is the front page. Open the note in a second tab, write it, and watch the card’s
row in the first tab turn cut. Follow a thread’s link, then press Back.
--http 127.0.0.1:0 picks a free port and prints it. --artifacts DIR serves the inspector’s
artifacts from a directory instead of the compiled-in copies: copy crates/inspector/artifacts
somewhere, change cache.xsl, and reload. The next chapter of Part I to be written,
Artifacts: from a directory or compiled in, is about that.
What the door owns
The route table. Each page and each fragment has a URL, ikigai-ui’s shared artifacts are at
/ui/{name}, and nothing else is public: the table is the whole surface (routes_only), so the
kernel’s own names are not reachable by accident.
/// The route table: every URL this door answers, and the resource behind it. First match
/// wins, so the fixed paths come before the patterns that would also fit them.
pub fn routes() -> RouteTable {
const B: &str = "urn:iki:tutorial:inspect:";
RouteTable::new(vec![
route("/", &format!("{B}page:cache")),
route("/ui/{name}", "urn:iki:ui:{name}"),
route("/inspect/live.js", &format!("{B}live.js")),
route("/inspect/inspector.css", &format!("{B}inspector.css")),
route("/inspect/card", &format!("{B}demo:card")),
route("/inspect/note/write", &format!("{B}demo:note")),
route("/inspect/entry/{id}", &format!("{B}page:entry:{{id}}")),
route(
"/inspect/thread/{+name}",
&format!("{B}page:thread:{{+name}}"),
),
route(
"/inspect/fragment/entry/{id}",
&format!("{B}fragment:entry:{{id}}"),
),
route(
"/inspect/fragment/thread/{+name}",
&format!("{B}fragment:thread:{{+name}}"),
),
route("/inspect/fragment/{view}", &format!("{B}fragment:{{view}}")),
route("/inspect/{view}", &format!("{B}page:{{view}}")),
])
}
A thread’s name is an IRI, so its route takes the segment raw ({+name}) and its link carries
it percent-encoded. Every other variable is strict: a value with a : or a / in it is refused
before it can name some other resource.
The door’s defaults. EdgeConfig::html_door is the cli door library’s one call for an
HTML door: a page CSP that allows scripts and styles from this origin only, never inline; an
Origin check that refuses any unsafe method (a form’s POST) whose Origin is not this door’s;
and the security headers (nosniff, no referrer, Cross-Origin-Opener-Policy). An artifact can
change what a page says, never what the door allows.
Who is asking. Every request is issued under the viewer’s capability, never root: inspect the kernel, hear its cuts, write the note. A door that grants less shows less, and the kernel, not the inspector, enforces it.
The push stream at /_ikigai/push, which the inspector’s live.js listens to.
In Rust
The door, started in this process on a free port and asked for the front page, compiled and run
by mdbook test:
#![allow(unused)]
fn main() {
extern crate inspector_htmx;
extern crate tokio;
use std::io::{Read, Write};
let runtime = tokio::runtime::Runtime::new().unwrap();
let listener = runtime.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap();
let addr = listener.local_addr().unwrap();
runtime.spawn(inspector_htmx::serve(listener, None));
let mut c = std::net::TcpStream::connect(addr).unwrap();
write!(c, "GET / HTTP/1.1\r\nHost: {addr}\r\n\r\n").unwrap();
let mut answer = String::new();
c.read_to_string(&mut answer).unwrap();
assert!(answer.starts_with("HTTP/1.1 200"));
assert!(answer.contains("script-src 'self'; style-src 'self'"));
assert!(answer.contains("<title>Cache · Inspector</title>"));
}
How it is checked
- Snapshots. Every page is held to a file in the repository (ikigai-ui’s test kit), so a change to a page is a change someone reviews.
- axe, through ikigai-ui’s reusable workflow: every page, in every theme, under light, dark and more-contrast media, served with the door’s CSP, with no WCAG 2.2 A or AA violation, page error or console error.
- In a real browser (
a11y/inspector.mjs): every page at 320 pixels wide without sideways scrolling; the first Tab reaches the skip link and the skip link reaches the view; a nav link swaps the view, pushes its URL and moves focus to the new heading; Back restores the view; and a cut refetches the view without moving focus or speaking in the status region. - The door itself (
crates/inspector-htmx/tests): the real binary, every route, a write from another origin refused, and a write from this one heard on the push stream.
What it waits on
The form that writes the note works without a script, but answers a bare line of text:
redirecting to the note’s page after a write is not something the door library does for a
plain form yet. And the push mapping lives in the inspector’s own live.js until ikigai-ui’s
shell script carries it.