Restore stashed WIP: live streaming plan, forms, CI pipeline, streaming demo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 22:07:59 +00:00
parent df1aa4e1d1
commit 5a68046bd8
21 changed files with 1463 additions and 120 deletions

View File

@@ -72,5 +72,5 @@
(define-page-helper "streaming-demo-data"
:params ()
:returns "dict"
:returns "async-generator<dict>"
:service "sx")

View File

@@ -147,7 +147,9 @@
(dict :label "SX CI Pipeline" :href "/plans/sx-ci"
:summary "Build, test, and deploy in s-expressions — CI pipelines as SX components.")
(dict :label "CSSX Components" :href "/plans/cssx-components"
:summary "Styling as components — replace the style dictionary with regular defcomps that apply classes, respond to data, and compose naturally.")))
:summary "Styling as components — replace the style dictionary with regular defcomps that apply classes, respond to data, and compose naturally.")
(dict :label "Live Streaming" :href "/plans/live-streaming"
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")))
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/")

View File

@@ -2314,3 +2314,115 @@
(~doc-section :title "Philosophy" :id "philosophy"
(p "The web has spent two decades building increasingly complex CSS tooling: preprocessors, CSS-in-JS, atomic CSS, utility frameworks, design tokens, style dictionaries. Each solves a real problem but adds a new system with its own caching, bundling, and mental model.")
(p "CSSX components collapse all of this back to the simplest possible thing: " (strong "a function that takes data and returns markup with classes.") " That's what a component already is. There is no separate styling system because there doesn't need to be."))))
;; ---------------------------------------------------------------------------
;; Live Streaming — SSE & WebSocket
;; ---------------------------------------------------------------------------
(defcomp ~plan-live-streaming-content ()
(~doc-page :title "Live Streaming"
(~doc-section :title "Context" :id "context"
(p "SX streaming currently uses chunked transfer encoding: the server sends an HTML shell with "
(code "~suspense") " placeholders, then resolves each one via inline "
(code "<script>__sxResolve(id, sx)</script>") " 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")))))))

View File

@@ -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" "<script>__sxResolve(...)</script>")
" 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 "<script>__sxResolve(\"stream-content\", ...)</script>"))
(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")))))

View File

@@ -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)))
;; ---------------------------------------------------------------------------

View File

@@ -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, <script> chunk replaces placeholder"},
{"label": "Done", "detail": "Page fully rendered — all suspense resolved"},
],
await asyncio.sleep(1)
yield {
"stream-id": "stream-fast",
"stream-label": "Fast API",
"stream-color": "green",
"stream-message": "Responded in ~1 second",
"stream-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
await asyncio.sleep(2) # 3s total
yield {
"stream-id": "stream-medium",
"stream-label": "Database Query",
"stream-color": "blue",
"stream-message": "Query completed in ~3 seconds",
"stream-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
await asyncio.sleep(2) # 5s total
yield {
"stream-id": "stream-slow",
"stream-label": "ML Inference",
"stream-color": "amber",
"stream-message": "Model inference completed in ~5 seconds",
"stream-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}