Merge branch 'worktree-iso-phase-4' into macros
This commit is contained in:
@@ -69,3 +69,8 @@
|
||||
:params (spec-name)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "streaming-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")))
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
|
||||
@@ -1386,12 +1386,12 @@
|
||||
(p :class "text-sm text-stone-600" "OAuth-based sharing to Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon via the account service. No models, blueprints, or platform clients created.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Remaining: SocialConnection model, social_crypto.py, platform OAuth clients (6), account/bp/social/ blueprint, share button fragment."))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "rounded border border-green-200 bg-green-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started")
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 6: Streaming & Suspense"))
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Requires async-aware delimited continuations for suspension.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Depends on: Phase 5 (IO proxy), continuations spec."))
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming")))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
@@ -1882,39 +1882,68 @@
|
||||
|
||||
(~doc-section :title "Phase 6: Streaming & Suspense" :id "phase-6"
|
||||
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
|
||||
(p :class "text-violet-900 font-medium" "What it enables")
|
||||
(p :class "text-violet-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations."))
|
||||
(div :class "rounded border border-green-200 bg-green-50 p-4 mb-4"
|
||||
(p :class "text-green-900 font-medium" "Status: Implemented")
|
||||
(p :class "text-green-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately with loading skeletons, fills in suspended parts as data arrives."))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mb-4"
|
||||
(p :class "text-amber-800 text-sm" (strong "Prerequisite: ") "Async-aware delimited continuations. The client solved IO suspension via JavaScript Promises (Phase 5), but the server needs continuations to suspend mid-evaluation when IO is encountered during streaming. Python's evaluator must capture the continuation at an IO call, emit a placeholder, schedule the IO, and resume the continuation when the result arrives."))
|
||||
(~doc-subsection :title "What was built"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "~suspense") " component — renders fallback content with a stable DOM ID, replaced when resolution arrives")
|
||||
(li (code "defpage :stream true") " — opts a page into streaming response mode")
|
||||
(li (code "defpage :fallback expr") " — custom loading skeleton for streaming pages")
|
||||
(li (code "execute_page_streaming()") " — Quart async generator response that yields HTML chunks")
|
||||
(li (code "sx_page_streaming_parts()") " — splits the HTML shell into streamable parts")
|
||||
(li (code "Sx.resolveSuspense(id, sx)") " — client-side function to replace suspense placeholders")
|
||||
(li (code "window.__sxResolve") " bootstrap — queues resolutions that arrive before sx.js loads")
|
||||
(li "Concurrent IO: data eval + header eval run in parallel via " (code "asyncio.create_task"))
|
||||
(li "Completion-order streaming: whichever IO finishes first gets sent first via " (code "asyncio.wait(FIRST_COMPLETED)"))))
|
||||
|
||||
|
||||
(~doc-subsection :title "Approach"
|
||||
(~doc-subsection :title "Architecture"
|
||||
|
||||
(div :class "space-y-4"
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "1. Continuation-based suspension")
|
||||
(p "When _aser encounters IO during slot evaluation, emit a placeholder with a suspension ID, schedule async resolution:")
|
||||
(~doc-code :code (highlight "(~suspense :id \"placeholder-123\"\n :fallback (div \"Loading...\"))" "lisp")))
|
||||
(h4 :class "font-semibold text-stone-700" "1. Suspense component")
|
||||
(p "When streaming, the server renders the page with " (code "~suspense") " placeholders instead of awaiting IO:")
|
||||
(~doc-code :code (highlight "(~app-body\n :header-rows (~suspense :id \"stream-headers\"\n :fallback (div :class \"h-12 bg-stone-200 animate-pulse\"))\n :content (~suspense :id \"stream-content\"\n :fallback (div :class \"p-8 animate-pulse\" ...)))" "lisp")))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "2. Chunked transfer")
|
||||
(p "Quart async generator responses:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "First chunk: HTML shell + synchronous content + placeholders")
|
||||
(li "Subsequent chunks: <script> tags replacing placeholders with resolved content")))
|
||||
(p "Quart async generator response yields chunks in order:")
|
||||
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
|
||||
(li "HTML shell + CSS + component defs + page registry + suspense page SX + scripts (immediate)")
|
||||
(li "Resolution " (code "<script>") " tags as each IO completes")
|
||||
(li "Closing " (code "</body></html>"))))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "3. Client suspension rendering")
|
||||
(p "~suspense component renders fallback, listens for resolution via inline script or SSE (existing SSE infrastructure in orchestration.sx)."))
|
||||
(h4 :class "font-semibold text-stone-700" "3. Client resolution")
|
||||
(p "Each resolution chunk is an inline script:")
|
||||
(~doc-code :code (highlight "<script>\n window.__sxResolve(\"stream-content\",\n \"(~article :title \\\"Hello\\\")\")\n</script>" "html"))
|
||||
(p "The client parses the SX, renders to DOM, and replaces the suspense placeholder's children."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "4. Priority-based IO")
|
||||
(p "Above-fold content resolves first. All IO starts concurrently (asyncio.create_task), results flushed in priority order."))))
|
||||
(h4 :class "font-semibold text-stone-700" "4. Concurrent IO")
|
||||
(p "Data evaluation and header construction run in parallel. " (code "asyncio.wait(FIRST_COMPLETED)") " yields resolution chunks in whatever order IO completes — no artificial sequencing."))))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
|
||||
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 5 (IO proxy for client rendering), async-aware delimited continuations (for server-side suspension), Phase 2 (IO analysis for priority).")))
|
||||
(~doc-subsection :title "Continuation foundation"
|
||||
(p "Delimited continuations (" (code "reset") "/" (code "shift") ") are implemented in the Python evaluator (async_eval.py lines 586-624) and available as special forms. Phase 6 uses the simpler pattern of concurrent IO + completion-order streaming, but the continuation machinery is in place for Phase 7's more sophisticated evaluation-level suspension."))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/templates/pages.sx — ~suspense component definition")
|
||||
(li "shared/sx/types.py — PageDef.stream, PageDef.fallback_expr fields")
|
||||
(li "shared/sx/evaluator.py — defpage :stream/:fallback parsing")
|
||||
(li "shared/sx/pages.py — execute_page_streaming(), streaming route mounting")
|
||||
(li "shared/sx/helpers.py — sx_page_streaming_parts(), sx_streaming_resolve_script()")
|
||||
(li "shared/static/scripts/sx.js — Sx.resolveSuspense(), __sxPending queue, __sxResolve bootstrap")
|
||||
(li "shared/sx/async_eval.py — reset/shift special forms (continuation foundation)")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Navigate to " (a :href "/isomorphism/streaming" "/isomorphism/streaming") " — the streaming demo page")
|
||||
(li "The page skeleton appears instantly (loading skeletons)")
|
||||
(li "After ~1.5 seconds, the content fills in (streamed from server)")
|
||||
(li "Open Network tab — observe chunked transfer encoding on the document response")
|
||||
(li "The document response should show multiple chunks arriving over time"))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Phase 7
|
||||
|
||||
60
sx/sx/streaming-demo.sx
Normal file
60
sx/sx/streaming-demo.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
;; Streaming & Suspense demo — Phase 6
|
||||
;;
|
||||
;; 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.
|
||||
;;
|
||||
;; 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.
|
||||
|
||||
(defcomp ~streaming-demo-content (&key streamed-at message items)
|
||||
(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."))
|
||||
|
||||
;; 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."))
|
||||
|
||||
;; Flow diagram
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Streaming Flow")
|
||||
(div :class "grid gap-3"
|
||||
(map (fn (item)
|
||||
(div :class "flex items-start gap-3 rounded-lg border border-stone-200 bg-white p-4"
|
||||
(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)))
|
||||
|
||||
;; 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")))
|
||||
|
||||
;; 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")))))
|
||||
@@ -458,6 +458,26 @@
|
||||
:selected "Async IO")
|
||||
:content (~async-io-demo-content))
|
||||
|
||||
(defpage streaming-demo
|
||||
:path "/isomorphism/streaming"
|
||||
:auth :public
|
||||
:stream true
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
: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"))
|
||||
:data (streaming-demo-data)
|
||||
:content (~streaming-demo-content
|
||||
:streamed-at streamed-at
|
||||
:message message
|
||||
:items items))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
|
||||
@@ -26,6 +26,7 @@ def _register_sx_helpers() -> None:
|
||||
"data-test-data": _data_test_data,
|
||||
"run-spec-tests": _run_spec_tests,
|
||||
"run-modular-tests": _run_modular_tests,
|
||||
"streaming-demo-data": _streaming_demo_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -791,3 +792,21 @@ def _data_test_data() -> dict:
|
||||
"phase": "Phase 4 — Client Async & IO Bridge",
|
||||
"transport": "SX wire format (text/sx)",
|
||||
}
|
||||
|
||||
|
||||
async def _streaming_demo_data() -> dict:
|
||||
"""Simulate slow IO for streaming demo — 1.5s delay."""
|
||||
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"},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user