diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js
index ac5573c..d24b459 100644
--- a/shared/static/scripts/sx-browser.js
+++ b/shared/static/scripts/sx-browser.js
@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
- var SX_VERSION = "2026-03-07T20:18:37Z";
+ var SX_VERSION = "2026-03-07T21:45:27Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -2046,7 +2046,7 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
if (isSxTruthy(hasIo)) {
registerIoDeps(ioDeps);
}
- return (isSxTruthy(get(match, "has-data")) ? (function() {
+ return (isSxTruthy(get(match, "stream")) ? (logInfo((String("sx:route streaming ") + String(pathname))), fetchStreaming(target, pathname, buildRequestHeaders(target, loadedComponentNames(), _cssHash)), true) : (isSxTruthy(get(match, "has-data")) ? (function() {
var cacheKey = pageDataCacheKey(pageName, params);
var cached = pageDataCacheGet(cacheKey);
return (isSxTruthy(cached) ? (function() {
@@ -2067,7 +2067,7 @@ return (function() {
var env = merge(closure, params);
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
-})()));
+})())));
})()));
})());
})());
@@ -3033,6 +3033,134 @@ callExpr.push(dictGet(kwargs, k)); } }
}).catch(function() { location.reload(); });
}
+ function fetchStreaming(target, url, headers) {
+ // Streaming fetch for multi-stream pages.
+ // First chunk = OOB SX swap (shell with skeletons).
+ // Subsequent chunks = __sxResolve script tags filling suspense slots.
+ var opts = { headers: headers };
+ try {
+ var h = new URL(url, location.href).hostname;
+ if (h !== location.hostname &&
+ (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) {
+ opts.credentials = "include";
+ }
+ } catch (e) {}
+
+ fetch(url, opts).then(function(resp) {
+ if (!resp.ok || !resp.body) {
+ // Fallback: non-streaming
+ return resp.text().then(function(text) {
+ text = stripComponentScripts(text);
+ text = extractResponseCss(text);
+ text = text.trim();
+ if (text.charAt(0) === "(") {
+ var dom = sxRender(text);
+ var container = document.createElement("div");
+ container.appendChild(dom);
+ processOobSwaps(container, function(t, oob, s) {
+ swapDomNodes(t, oob, s);
+ sxHydrate(t);
+ processElements(t);
+ });
+ var newMain = container.querySelector("#main-panel");
+ morphChildren(target, newMain || container);
+ postSwap(target);
+ }
+ });
+ }
+
+ var reader = resp.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = "";
+ var initialSwapDone = false;
+ // Regex to match __sxResolve script tags
+ var RESOLVE_START = "";
+
+ function processResolveScripts() {
+ // Strip and load any extra component defs before resolve scripts
+ buffer = stripSxScripts(buffer);
+ var idx;
+ while ((idx = buffer.indexOf(RESOLVE_START)) >= 0) {
+ var endIdx = buffer.indexOf(RESOLVE_END, idx);
+ if (endIdx < 0) break; // incomplete, wait for more data
+ var argsStr = buffer.substring(idx + RESOLVE_START.length, endIdx);
+ buffer = buffer.substring(endIdx + RESOLVE_END.length);
+ // argsStr is: "stream-id","sx source"
+ var commaIdx = argsStr.indexOf(",");
+ if (commaIdx >= 0) {
+ try {
+ var id = JSON.parse(argsStr.substring(0, commaIdx));
+ var sx = JSON.parse(argsStr.substring(commaIdx + 1));
+ if (typeof Sx !== "undefined" && Sx.resolveSuspense) {
+ Sx.resolveSuspense(id, sx);
+ }
+ } catch (e) {
+ console.error("[sx-ref] resolve parse error:", e);
+ }
+ }
+ }
+ }
+
+ function pump() {
+ return reader.read().then(function(result) {
+ buffer += decoder.decode(result.value || new Uint8Array(), { stream: !result.done });
+
+ if (!initialSwapDone) {
+ // Look for the first resolve script — everything before it is OOB content
+ var scriptIdx = buffer.indexOf(" (without data-components).
+ // These contain extra component defs from streaming resolve chunks.
+ var SxObj = typeof Sx !== "undefined" ? Sx : null;
+ return text.replace(/\n{body}')
+
+ from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded
+ if registry_loaded():
+ new_classes = scan_classes_from_sx(oob_body)
+ if comp_defs:
+ new_classes.update(scan_classes_from_sx(comp_defs))
+ known_raw = request.headers.get("SX-Css", "")
+ if known_raw:
+ from .css_registry import lookup_css_hash
+ if len(known_raw) <= 16:
+ looked_up = lookup_css_hash(known_raw)
+ known_classes = looked_up if looked_up is not None else set()
+ else:
+ known_classes = set(known_raw.split(","))
+ new_classes -= known_classes
+ if new_classes:
+ new_rules = lookup_rules(new_classes)
+ if new_rules:
+ body = f'\n{body}'
+
+ # Capture component env for extra defs in resolve chunks
+ from .jinja_bridge import components_for_page as _comp_scan
+ _base_scan = oob_body
+
+ def _extra_defs(sx_source: str) -> str:
+ from .deps import components_needed
+ comp_env = dict(get_component_env())
+ base_needed = components_needed(_base_scan, comp_env)
+ resolve_needed = components_needed(sx_source, comp_env)
+ extra = resolve_needed - base_needed
+ if not extra:
+ return ""
+ from .parser import serialize
+ from .types import Component
+ parts = []
+ for key, val in comp_env.items():
+ if isinstance(val, Component) and (f"~{val.name}" in extra or key in extra):
+ param_strs = ["&key"] + list(val.params)
+ if val.has_children:
+ param_strs.extend(["&rest", "children"])
+ params_sx = "(" + " ".join(param_strs) + ")"
+ body_sx = serialize(val.body, pretty=True)
+ parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
+ return "\n".join(parts)
+
+ # Yield chunks
+ async def _stream_oob_chunks():
+ # First chunk: OOB swap with skeletons
+ yield body
+
+ # Drain queue for resolve scripts
+ remaining = 2 # headers + data
+ while remaining > 0:
+ item = await _stream_queue.get()
+ kind = item[0]
+ try:
+ if kind == "headers":
+ _, oob_hdr = item
+ remaining -= 1
+ # Headers don't need resolve scripts for OOB — they're
+ # handled by OOB swap attributes in the SX content itself.
+ # But if we have header content, send a resolve for it.
+ if oob_hdr:
+ extras = _extra_defs(oob_hdr)
+ yield sx_streaming_resolve_script("stream-headers", oob_hdr, extras)
+ elif kind == "data":
+ _, stream_id, content_sx = item
+ extras = _extra_defs(content_sx)
+ yield sx_streaming_resolve_script(stream_id, content_sx, extras)
+ elif kind == "data-done":
+ remaining -= 1
+ except Exception as e:
+ logger.error("Streaming OOB resolve failed for %s: %s", kind, e)
+
+ return _stream_oob_chunks()
+
+
# ---------------------------------------------------------------------------
# Blueprint mounting
# ---------------------------------------------------------------------------
@@ -556,19 +886,17 @@ def _mount_one_page(bp: Any, service_name: str, page_def: PageDef) -> None:
from quart import make_response, Response
if page_def.stream:
- # Streaming response: yields HTML chunks as IO resolves
+ # Streaming response: yields chunks as IO resolves
async def page_view(**kwargs: Any) -> Any:
from shared.browser.app.utils.htmx import is_htmx_request
current = get_page(service_name, page_def.name) or page_def
- # Only stream for full page loads (not SX/HTMX requests)
if is_htmx_request():
- result = await execute_page(current, service_name, url_params=kwargs)
- if hasattr(result, "status_code"):
- return result
- return await make_response(result, 200)
- # execute_page_streaming does all context-dependent setup as a
- # regular async function (while request context is live), then
- # returns an async generator that only yields strings.
+ # Streaming OOB: shell with skeletons first, then resolve scripts
+ gen = await execute_page_streaming_oob(
+ current, service_name, url_params=kwargs,
+ )
+ return Response(gen, content_type="text/sx; charset=utf-8")
+ # Full page streaming: HTML document with inline resolve scripts
gen = await execute_page_streaming(
current, service_name, url_params=kwargs,
)
@@ -696,7 +1024,14 @@ async def evaluate_page_data(
data_result = await async_eval(page_def.data_expr, env, ctx)
- # Kebab-case dict keys (matching execute_page line 214-215)
+ # Multi-stream: async generator can't be serialized as a single dict.
+ # Return nil to signal the client to fall back to server-side rendering.
+ if hasattr(data_result, '__aiter__'):
+ # Close the generator cleanly
+ await data_result.aclose()
+ return "nil"
+
+ # Kebab-case dict keys (matching execute_page)
if isinstance(data_result, dict):
data_result = {
k.replace("_", "-"): v for k, v in data_result.items()
diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py
index d3c3aaa..88d2073 100644
--- a/shared/sx/ref/bootstrap_js.py
+++ b/shared/sx/ref/bootstrap_js.py
@@ -361,6 +361,7 @@ class JSEmitter:
"fetch-request": "fetchRequest",
"fetch-location": "fetchLocation",
"fetch-and-restore": "fetchAndRestore",
+ "fetch-streaming": "fetchStreaming",
"fetch-preload": "fetchPreload",
"dom-query-by-id": "domQueryById",
"dom-matches?": "domMatches",
@@ -3123,6 +3124,134 @@ PLATFORM_ORCHESTRATION_JS = """
}).catch(function() { location.reload(); });
}
+ function fetchStreaming(target, url, headers) {
+ // Streaming fetch for multi-stream pages.
+ // First chunk = OOB SX swap (shell with skeletons).
+ // Subsequent chunks = __sxResolve script tags filling suspense slots.
+ var opts = { headers: headers };
+ try {
+ var h = new URL(url, location.href).hostname;
+ if (h !== location.hostname &&
+ (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) {
+ opts.credentials = "include";
+ }
+ } catch (e) {}
+
+ fetch(url, opts).then(function(resp) {
+ if (!resp.ok || !resp.body) {
+ // Fallback: non-streaming
+ return resp.text().then(function(text) {
+ text = stripComponentScripts(text);
+ text = extractResponseCss(text);
+ text = text.trim();
+ if (text.charAt(0) === "(") {
+ var dom = sxRender(text);
+ var container = document.createElement("div");
+ container.appendChild(dom);
+ processOobSwaps(container, function(t, oob, s) {
+ swapDomNodes(t, oob, s);
+ sxHydrate(t);
+ processElements(t);
+ });
+ var newMain = container.querySelector("#main-panel");
+ morphChildren(target, newMain || container);
+ postSwap(target);
+ }
+ });
+ }
+
+ var reader = resp.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = "";
+ var initialSwapDone = false;
+ // Regex to match __sxResolve script tags
+ var RESOLVE_START = "";
+
+ function processResolveScripts() {
+ // Strip and load any extra component defs before resolve scripts
+ buffer = stripSxScripts(buffer);
+ var idx;
+ while ((idx = buffer.indexOf(RESOLVE_START)) >= 0) {
+ var endIdx = buffer.indexOf(RESOLVE_END, idx);
+ if (endIdx < 0) break; // incomplete, wait for more data
+ var argsStr = buffer.substring(idx + RESOLVE_START.length, endIdx);
+ buffer = buffer.substring(endIdx + RESOLVE_END.length);
+ // argsStr is: "stream-id","sx source"
+ var commaIdx = argsStr.indexOf(",");
+ if (commaIdx >= 0) {
+ try {
+ var id = JSON.parse(argsStr.substring(0, commaIdx));
+ var sx = JSON.parse(argsStr.substring(commaIdx + 1));
+ if (typeof Sx !== "undefined" && Sx.resolveSuspense) {
+ Sx.resolveSuspense(id, sx);
+ }
+ } catch (e) {
+ console.error("[sx-ref] resolve parse error:", e);
+ }
+ }
+ }
+ }
+
+ function pump() {
+ return reader.read().then(function(result) {
+ buffer += decoder.decode(result.value || new Uint8Array(), { stream: !result.done });
+
+ if (!initialSwapDone) {
+ // Look for the first resolve script — everything before it is OOB content
+ var scriptIdx = buffer.indexOf(" (without data-components).
+ // These contain extra component defs from streaming resolve chunks.
+ var SxObj = typeof Sx !== "undefined" ? Sx : null;
+ return text.replace(/") " chunks as IO completes. "
+ "Once the response finishes, the connection closes. Each slot resolves exactly once.")
+ (p "This is powerful for initial page load but doesn't support live updates "
+ "— dashboard metrics, chat messages, collaborative editing, real-time notifications. "
+ "For that we need a persistent transport: " (strong "SSE") " (Server-Sent Events) or " (strong "WebSockets") ".")
+ (p "The key insight: the client already has " (code "Sx.resolveSuspense(id, sxSource)") " which replaces "
+ "DOM content by suspense ID. A persistent connection just needs to keep calling it."))
+
+ (~doc-section :title "Design" :id "design"
+
+ (~doc-subsection :title "Transport Hierarchy"
+ (p "Three tiers, progressively more capable:")
+ (ol :class "list-decimal list-inside space-y-2 text-stone-700 text-sm"
+ (li (strong "Chunked streaming") " (done) — single HTTP response, each suspense resolves once. "
+ "Best for: initial page load with slow IO.")
+ (li (strong "SSE") " — persistent one-way connection, server pushes resolve events. "
+ "Best for: dashboards, notifications, progress bars, any read-only live data.")
+ (li (strong "WebSocket") " — bidirectional, client can send events back. "
+ "Best for: chat, collaborative editing, interactive applications.")))
+
+ (~doc-subsection :title "SSE Protocol"
+ (p "A " (code "~live") " component declares a persistent connection to an SSE endpoint:")
+ (~doc-code :code (highlight "(~live :src \"/api/stream/dashboard\"\n (~suspense :id \"cpu\" :fallback (span \"Loading...\"))\n (~suspense :id \"memory\" :fallback (span \"Loading...\"))\n (~suspense :id \"requests\" :fallback (span \"Loading...\")))" "lisp"))
+ (p "The server SSE endpoint yields SX resolve events:")
+ (~doc-code :code (highlight "async def dashboard_stream():\n while True:\n stats = await get_system_stats()\n yield sx_sse_event(\"cpu\", f'(~stat-badge :value \"{stats.cpu}%\")')\n yield sx_sse_event(\"memory\", f'(~stat-badge :value \"{stats.mem}%\")')\n await asyncio.sleep(1)" "python"))
+ (p "SSE wire format — each event is a suspense resolve:")
+ (~doc-code :code (highlight "event: sx-resolve\ndata: {\"id\": \"cpu\", \"sx\": \"(~stat-badge :value \\\"42%\\\")\"}\n\nevent: sx-resolve\ndata: {\"id\": \"memory\", \"sx\": \"(~stat-badge :value \\\"68%\\\")\"}" "text")))
+
+ (~doc-subsection :title "WebSocket Protocol"
+ (p "A " (code "~ws") " component establishes a bidirectional channel:")
+ (~doc-code :code (highlight "(~ws :src \"/ws/chat\"\n :on-message handle-chat-message\n (~suspense :id \"messages\" :fallback (div \"Connecting...\"))\n (~suspense :id \"typing\" :fallback (span)))" "lisp"))
+ (p "Client can send SX expressions back:")
+ (~doc-code :code (highlight ";; Client sends:\n(sx-send ws-conn '(chat-message :text \"hello\" :user \"alice\"))\n\n;; Server receives, broadcasts to all connected clients:\n;; event: sx-resolve for \"messages\" suspense" "lisp")))
+
+ (~doc-subsection :title "Shared Resolution Mechanism"
+ (p "All three transports use the same client-side resolution:")
+ (ul :class "list-disc list-inside space-y-1 text-stone-600 text-sm"
+ (li (code "Sx.resolveSuspense(id, sxSource)") " — already exists, parses SX and renders to DOM")
+ (li "SSE: " (code "EventSource") " → " (code "onmessage") " → " (code "resolveSuspense()"))
+ (li "WS: " (code "WebSocket") " → " (code "onmessage") " → " (code "resolveSuspense()"))
+ (li "The component env (defs needed for rendering) can be sent once on connection open")
+ (li "Subsequent events only need the SX expression — lightweight wire format"))))
+
+ (~doc-section :title "Implementation" :id "implementation"
+
+ (~doc-subsection :title "Phase 1: SSE Infrastructure"
+ (ol :class "list-decimal list-inside space-y-2 text-stone-700 text-sm"
+ (li "Add " (code "~live") " component to " (code "shared/sx/templates/") " — renders child suspense placeholders, "
+ "emits " (code "data-sx-live") " attribute with SSE endpoint URL")
+ (li "Add " (code "sx-live.js") " client module — on boot, finds " (code "[data-sx-live]") " elements, "
+ "opens EventSource, routes events to " (code "resolveSuspense()"))
+ (li "Add " (code "sx_sse_event(id, sx)") " helper for Python SSE endpoints — formats SSE wire protocol")
+ (li "Add " (code "sse_stream()") " Quart helper — returns async generator Response with correct headers")))
+
+ (~doc-subsection :title "Phase 2: Defpage Integration"
+ (ol :class "list-decimal list-inside space-y-2 text-stone-700 text-sm"
+ (li "New " (code ":live") " defpage slot — declares SSE endpoint + suspense bindings")
+ (li "Auto-mount SSE endpoint alongside the page route")
+ (li "Component defs sent as first SSE event on connection open")
+ (li "Automatic reconnection with exponential backoff")))
+
+ (~doc-subsection :title "Phase 3: WebSocket"
+ (ol :class "list-decimal list-inside space-y-2 text-stone-700 text-sm"
+ (li "Add " (code "~ws") " component — bidirectional channel with send/receive")
+ (li "Add " (code "sx-ws.js") " client module — WebSocket management, message routing")
+ (li "Server-side: Quart WebSocket handlers that receive and broadcast SX events")
+ (li "Client-side: " (code "sx-send") " primitive for sending SX expressions to server")))
+
+ (~doc-subsection :title "Phase 4: Spec & Boundary"
+ (ol :class "list-decimal list-inside space-y-2 text-stone-700 text-sm"
+ (li "Spec " (code "~live") " and " (code "~ws") " in " (code "render.sx") " (how they render in each mode)")
+ (li "Add SSE/WS IO primitives to " (code "boundary.sx"))
+ (li "Bootstrap SSE/WS connection management into " (code "sx-ref.js"))
+ (li "Spec-level tests for resolve, reconnection, and message routing"))))
+
+ (~doc-section :title "Files" :id "files"
+ (table :class "w-full text-left border-collapse"
+ (thead
+ (tr :class "border-b border-stone-200"
+ (th :class "px-3 py-2 font-medium text-stone-600" "File")
+ (th :class "px-3 py-2 font-medium text-stone-600" "Purpose")))
+ (tbody
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/templates/live.sx")
+ (td :class "px-3 py-2 text-stone-700" "~live component definition"))
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/static/scripts/sx-live.js")
+ (td :class "px-3 py-2 text-stone-700" "SSE client — EventSource → resolveSuspense"))
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/sse.py")
+ (td :class "px-3 py-2 text-stone-700" "SSE helpers — event formatting, stream response"))
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/static/scripts/sx-ws.js")
+ (td :class "px-3 py-2 text-stone-700" "WebSocket client — bidirectional SX channel"))
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/render.sx")
+ (td :class "px-3 py-2 text-stone-700" "Spec: ~live and ~ws rendering in all modes"))
+ (tr :class "border-b border-stone-100"
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
+ (td :class "px-3 py-2 text-stone-700" "SSE/WS IO primitive declarations")))))))
+
diff --git a/sx/sx/streaming-demo.sx b/sx/sx/streaming-demo.sx
index 25d0f08..9274a3e 100644
--- a/sx/sx/streaming-demo.sx
+++ b/sx/sx/streaming-demo.sx
@@ -2,30 +2,57 @@
;;
;; This page uses :stream true to enable chunked transfer encoding.
;; The browser receives the HTML shell immediately with loading skeletons,
-;; then the content fills in when the (deliberately slow) data resolves.
+;; then content fills in as each IO resolves at staggered intervals.
;;
-;; The :data expression simulates 1.5s IO delay. Without streaming, the
-;; browser would wait the full 1.5s before seeing anything. With streaming,
-;; the page skeleton appears instantly.
+;; The :data expression is an async generator that yields three chunks
+;; at 1s, 3s, and 5s. Each chunk resolves a different ~suspense slot.
-(defcomp ~streaming-demo-content (&key streamed-at message items)
+;; Color map for stream chunk styling (all string keys for get compatibility)
+(define stream-colors
+ {"green" {"border" "border-green-200" "bg" "bg-green-50" "title" "text-green-900"
+ "text" "text-green-800" "sub" "text-green-700" "code" "bg-green-100"
+ "dot" "bg-green-400"}
+ "blue" {"border" "border-blue-200" "bg" "bg-blue-50" "title" "text-blue-900"
+ "text" "text-blue-800" "sub" "text-blue-700" "code" "bg-blue-100"
+ "dot" "bg-blue-400"}
+ "amber" {"border" "border-amber-200" "bg" "bg-amber-50" "title" "text-amber-900"
+ "text" "text-amber-800" "sub" "text-amber-700" "code" "bg-amber-100"
+ "dot" "bg-amber-400"}})
+
+;; Generic streamed content chunk — rendered once per yield from the
+;; async generator. The :content expression receives different bindings
+;; each time, and the _stream_id determines which ~suspense slot it fills.
+(defcomp ~streaming-demo-chunk (&key stream-label stream-color stream-message stream-time)
+ (let ((colors (get stream-colors stream-color)))
+ (div :class (str "rounded-lg border p-5 space-y-3 " (get colors "border") " " (get colors "bg"))
+ (div :class "flex items-center gap-2"
+ (div :class (str "w-3 h-3 rounded-full " (get colors "dot")))
+ (h2 :class (str "text-lg font-semibold " (get colors "title")) stream-label))
+ (p :class (get colors "text") stream-message)
+ (p :class (str "text-sm " (get colors "sub"))
+ "Resolved at: " (code :class (str "px-1 rounded " (get colors "code")) stream-time)))))
+
+;; Skeleton placeholder for a stream slot
+(defcomp ~stream-skeleton ()
+ (div :class "rounded-lg border border-stone-200 bg-stone-50 p-5 space-y-3 animate-pulse"
+ (div :class "flex items-center gap-2"
+ (div :class "w-3 h-3 rounded-full bg-stone-300")
+ (div :class "h-6 bg-stone-200 rounded w-1/3"))
+ (div :class "h-4 bg-stone-200 rounded w-2/3")
+ (div :class "h-4 bg-stone-200 rounded w-1/2")))
+
+;; Static layout — takes &rest children where the three suspense slots go.
+(defcomp ~streaming-demo-layout (&rest children)
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Streaming & Suspense Demo")
(p :class "mt-2 text-stone-600"
"This page uses " (code :class "bg-stone-100 px-1 rounded text-violet-700" ":stream true")
" in its defpage declaration. The browser receives the page skeleton instantly, "
- "then content fills in as IO resolves."))
+ "then three IO sources resolve at staggered intervals (1s, 3s, 5s)."))
- ;; Timestamp proves this was streamed
- (div :class "rounded-lg border border-green-200 bg-green-50 p-5 space-y-3"
- (h2 :class "text-lg font-semibold text-green-900" "Streamed Content")
- (p :class "text-green-800" message)
- (p :class "text-green-700 text-sm"
- "Data resolved at: " (code :class "bg-green-100 px-1 rounded" streamed-at))
- (p :class "text-green-700 text-sm"
- "This content arrived via a " (code :class "bg-green-100 px-1 rounded" "")
- " chunk streamed after the initial HTML shell."))
+ ;; Slot: suspense placeholders (or resolved content)
+ (div :class "grid gap-4" children)
;; Flow diagram
(div :class "space-y-4"
@@ -36,25 +63,31 @@
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 flex items-center justify-center text-violet-700 font-bold text-sm"
(get item "label"))
(p :class "text-stone-700 text-sm pt-1" (get item "detail"))))
- items)))
+ (list
+ {:label "Shell" :detail "HTML shell with three suspense placeholders sent immediately"}
+ {:label "Boot" :detail "sx-browser.js loads, renders fallback skeletons"}
+ {:label "1s" :detail "Fast API responds — first skeleton replaced with green box"}
+ {:label "3s" :detail "Database query completes — second skeleton replaced with blue box"}
+ {:label "5s" :detail "ML inference finishes — third skeleton replaced with amber box"}))))
;; How it works
- (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
- (h2 :class "text-lg font-semibold text-blue-900" "How Streaming Works")
- (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
- (li "Server starts data fetch and header fetch " (em "concurrently"))
- (li "HTML shell with " (code "~suspense") " placeholders is sent immediately")
- (li "Browser loads sx-browser.js, renders the page with loading skeletons")
- (li "Data IO completes — server sends " (code ""))
- (li "sx.js calls " (code "Sx.resolveSuspense()") " — replaces skeleton with real content")
- (li "Header IO completes — same process for header area")))
+ (div :class "rounded-lg border border-violet-200 bg-violet-50 p-5 space-y-3"
+ (h2 :class "text-lg font-semibold text-violet-900" "How Multi-Stream Works")
+ (ol :class "list-decimal list-inside text-violet-800 space-y-2 text-sm"
+ (li "Server evaluates " (code ":data") " — gets an " (em "async generator"))
+ (li "HTML shell with three " (code "~suspense") " placeholders sent immediately")
+ (li "Generator yields first chunk after 1s — server sends " (code "__sxResolve(\"stream-fast\", ...)"))
+ (li "Generator yields second chunk after 3s — " (code "__sxResolve(\"stream-medium\", ...)"))
+ (li "Generator yields third chunk after 5s — " (code "__sxResolve(\"stream-slow\", ...)"))
+ (li "Each resolve replaces its skeleton independently")))
;; Technical details
- (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
- (p :class "font-semibold text-amber-800" "Implementation details")
- (ul :class "list-disc list-inside text-amber-700 space-y-1"
- (li (code "defpage :stream true") " — opts the page into streaming response")
- (li (code "~suspense :id \"...\" :fallback (...)") " — renders loading skeleton until resolved")
- (li "Quart async generator response — yields chunks as they become available")
- (li "Resolution via " (code "__sxResolve(id, sx)") " inline scripts in the stream")
- (li "Falls back to standard (non-streaming) response for SX/HTMX requests")))))
+ (div :class "rounded-lg border border-stone-200 bg-stone-50 p-4 text-sm space-y-2"
+ (p :class "font-semibold text-stone-800" "Implementation details")
+ (ul :class "list-disc list-inside text-stone-600 space-y-1"
+ (li (code "defpage :stream true") " — opts the page into chunked transfer encoding")
+ (li (code ":data") " helper is an async generator — each " (code "yield") " resolves a different suspense slot")
+ (li "Each yield includes " (code "_stream_id") " matching a " (code "~suspense :id") " in the shell")
+ (li (code ":content") " expression is re-evaluated with each yield's bindings")
+ (li "Headers stream concurrently — independent of the data generator")
+ (li "Future: SSE/WebSocket for re-resolving slots after initial page load")))))
diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx
index e1a992d..957c766 100644
--- a/sx/sxc/pages/docs.sx
+++ b/sx/sxc/pages/docs.sx
@@ -468,15 +468,16 @@
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Streaming")
:selected "Streaming")
- :fallback (div :class "p-8 space-y-4 animate-pulse"
- (div :class "h-8 bg-stone-200 rounded w-1/3")
- (div :class "h-4 bg-stone-200 rounded w-2/3")
- (div :class "h-64 bg-stone-200 rounded"))
+ :shell (~streaming-demo-layout
+ (~suspense :id "stream-fast" :fallback (~stream-skeleton))
+ (~suspense :id "stream-medium" :fallback (~stream-skeleton))
+ (~suspense :id "stream-slow" :fallback (~stream-skeleton)))
:data (streaming-demo-data)
- :content (~streaming-demo-content
- :streamed-at streamed-at
- :message message
- :items items))
+ :content (~streaming-demo-chunk
+ :stream-label stream-label
+ :stream-color stream-color
+ :stream-message stream-message
+ :stream-time stream-time))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page
@@ -534,6 +535,7 @@
"social-sharing" (~plan-social-sharing-content)
"sx-ci" (~plan-sx-ci-content)
"cssx-components" (~plan-cssx-components-content)
+ "live-streaming" (~plan-live-streaming-content)
:else (~plans-index-content)))
;; ---------------------------------------------------------------------------
diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py
index d578fbd..7d5e413 100644
--- a/sx/sxc/pages/helpers.py
+++ b/sx/sxc/pages/helpers.py
@@ -838,19 +838,40 @@ def _data_test_data() -> dict:
}
-async def _streaming_demo_data() -> dict:
- """Simulate slow IO for streaming demo — 1.5s delay."""
+async def _streaming_demo_data():
+ """Multi-stream demo — yields three chunks at staggered intervals.
+
+ Each yield is a dict with _stream_id (matching a ~suspense :id in the
+ shell) plus bindings for the :content expression. The streaming
+ infrastructure detects the async generator and resolves each suspense
+ placeholder as each chunk arrives.
+ """
import asyncio
- await asyncio.sleep(1.5)
from datetime import datetime, timezone
- return {
- "streamed-at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
- "message": "This content was streamed after a 1.5 second delay.",
- "items": [
- {"label": "Shell", "detail": "HTML shell with suspense placeholders sent immediately"},
- {"label": "Bootstrap", "detail": "sx-browser.js loads, renders fallback skeletons"},
- {"label": "IO Start", "detail": "Data fetch and header fetch run concurrently"},
- {"label": "Resolve", "detail": "As each IO completes,