afterglow #
Suspense-style streaming HTML rendering for Rust servers. Render trees where
some subtrees are futures: the renderer emits a shell immediately with
placeholder markers for the pending parts, then streams filled-in content as
each future resolves. The wire format is the one from Chrome's
declarative partial updates
feature: <?marker name="N"> / <?start name="N">fallback<?end> placeholders,
filled later by <template for="N">...</template> blocks.
This is a one-shot streaming model — no client-side state, no re-renders. Write the parts you have now, backfill the rest as they arrive.
Example #
use afterglow::{html, render_stream};
async fn weather() -> Result<Node, &'static str> { /* slow fetch */ }
let page = html! {
<section>
<h2>"Weather"</h2>
@{weather()} else { <p>"checking the sky…"</p> }
</section>
};
// impl Stream<Item = io::Result<Bytes>> — feed it straight into
// axum::body::Body::from_stream with content-type text/html; charset=utf-8.
let body = render_stream(page);
@{future}renders a bare<?marker name="N">placeholder; addelse { markup }for fallback content between<?start name="N">and<?end>.- Async widgets return
Result<impl Render, E: Display>; anErrrenders as<span class="afterglow-error">…</span>in the slot instead of killing the stream. render_streamfills slots in completion order;render_stream_orderedfills in registration order (deterministic, for snapshot tests). Resolved subtrees may contain further@{...}widgets — nesting is unbounded.- Cancellation is free: dropping the stream (client disconnect) drops all
pending widget futures. For that to work, never
tokio::spawnwidget futures — let the render stream drive them. - Compile-time prerendering:
html!escapes and folds all static markup at macro expansion time into&'static strsegments stored in the binary; the only per-request work is the dynamic holes ({expr},@{...}, and elements withattr={expr}values). A fully static template expands to a single borrowed segment — it is const-constructible (const FOOTER: Node = html! { <footer>"© 2026"</footer> };) and its shell is streamed viaBytes::from_staticwithout copying. - Const interpolation:
const { expr }splices compile-time strings into the static fold viaconcat!, e.g.const { env!("CARGO_PKG_NAME") }— verbatim (no escaping), literals and built-in literal macros only, and the template stays const-constructible. - Streamed (live-updating) holes:
@*{source}wheresourceimplementsIntoNodeStream— anyStreamwhose items implementRender. Renders the same placeholder as@{...}(with the same optionalelse { fallback }), but instead of resolving once, each value from the source is streamed as its own<template for="N">patch — per the declarative partial updates format, repeated patches at the same marker are how a client expresses a live-updating region. The response stays open until every future has resolved and every stream has ended, so an infinite source means a held-open, forever-streaming response.IntoNodeStreamis the crate's single intake point for "many values over time" — kept separate fromfutures::Streamso thatstd::async_iter::AsyncIterator/async genblocks can plug in without changingNode, the driver, or the wire format, once they stabilize. - Async attributes:
attr=@{future}(optionallyelse "literal"/else {expr}for the value shown until it resolves). There's no attribute-level patch in the wire format, so oncefutureresolves, the whole element — not just the attribute — is replaced in one patch, exactly once. Nested@{...}/@*{...}holes inside such an element are never re-run or duplicated by that swap (an already-resolved one is inlined, a still- pending one reuses its marker id so its future/stream keeps a valid target) — but a nested@*{...}hole's history before the swap collapses to its latest value, and the swap itself is still a full DOM remount of the element and its descendants (lost focus/scroll/input state), which is inherent to the wire format and not something this crate can avoid. See the crate docs' "Async attributes" section for the full detail — scope this to small elements.
Streaming demo #
cargo run --example axum_demo
then, in another terminal:
curl --no-buffer localhost:3210/
--no-buffer makes curl print each chunk as it arrives. You will see the
shell immediately:
<html><head><title>afterglow demo</title></head><body><h1>Dashboard</h1>
<section><h2>Weather</h2><?start name="0"><p>checking the sky…</p><?end></section>
<section><h2>News</h2><?start name="1"><p>fetching headlines…</p><?end></section>
<section><h2>Stocks</h2><?marker name="2"></section></body></html>
followed by the widget fills as their (artificial) delays elapse, in completion order. Slot 3, the "Live price" section, patches five times as new ticks arrive instead of resolving once:
<template for="3"><span>$101.00</span></template> <!-- t+0.4s -->
<template for="0"><p>21 °C, clear skies</p></template> <!-- t+0.8s -->
<template for="3"><span>$102.01</span></template> <!-- t+0.8s -->
<template for="3"><span>$103.03</span></template> <!-- t+1.2s -->
<template for="2"><span class="afterglow-error">stock service unavailable</span></template> <!-- t+1.5s -->
<template for="3"><span>$104.06</span></template> <!-- t+1.6s -->
<template for="3"><span>$105.10</span></template> <!-- t+2.0s -->
<template for="1"><ul><li>Streaming HTML lands in afterglow</li>…</ul></template> <!-- t+2.5s -->