From a05d642461b7a074897bc8b9be6702acca80d6fb Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 17:34:10 +0000 Subject: [PATCH] =?UTF-8?q?Phase=206:=20Streaming=20&=20Suspense=20?= =?UTF-8?q?=E2=80=94=20chunked=20HTML=20with=20suspense=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server streams HTML shell with ~suspense placeholders immediately, then sends resolution """ + +_SX_STREAMING_BOOTSTRAP = """\ +""" + + +def sx_page_streaming_parts(ctx: dict, page_sx: str, *, + meta_html: str = "") -> tuple[str, str]: + """Split the page into shell (before scripts) and tail (scripts). + + Returns (shell, tail) where: + shell = everything up to and including the page SX mount script + tail = the suspense bootstrap + sx-browser.js + body.js scripts + + For streaming, the caller yields shell first, then resolution chunks, + then tail to close the document. + """ + from .jinja_bridge import components_for_page, css_classes_for_page + from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash + + from quart import current_app as _ca + component_defs, component_hash = components_for_page(page_sx, service=_ca.name) + + client_hash = _get_sx_comp_cookie() + if not _is_dev_mode() and client_hash and client_hash == component_hash: + component_defs = "" + + sx_css = "" + sx_css_classes = "" + if registry_loaded(): + classes = css_classes_for_page(page_sx, service=_ca.name) + classes.update(["bg-stone-50", "text-stone-900"]) + rules = lookup_rules(classes) + sx_css = get_preamble() + rules + sx_css_classes = store_css_hash(classes) + + asset_url = get_asset_url(ctx) + title = ctx.get("base_title", "Rose Ash") + csrf = _get_csrf_token() + + if _is_dev_mode() and page_sx and page_sx.startswith("("): + from .parser import parse as _parse, serialize as _serialize + try: + page_sx = _serialize(_parse(page_sx), pretty=True) + except Exception: + pass + + styles_hash = _get_style_dict_hash() + client_styles_hash = _get_sx_styles_cookie() + styles_json = "" if (not _is_dev_mode() and client_styles_hash == styles_hash) else _build_style_dict_json() + + import logging + from quart import current_app + pages_sx = _build_pages_sx(current_app.name) + + sx_js_hash = _script_hash("sx-browser.js") + body_js_hash = _script_hash("body.js") + + # Shell: everything up to and including the page SX + shell = ( + '\n\n\n' + '\n' + '\n' + '\n' + '\n' + f'{_html_escape(title)}\n' + f'{meta_html}' + '\n' + f'\n' + f'\n' + f'\n' + '\n' + '\n' + '\n' + '\n' + '\n' + "\n" + "\n" + '\n' + '\n' + '\n' + f'\n' + f'\n' + f'\n' + f'\n' + ) + + # Tail: bootstrap suspense resolver + scripts + close + tail = ( + _SX_STREAMING_BOOTSTRAP + '\n' + f'\n' + f'\n' + ) + + return shell, tail + + +def sx_streaming_resolve_script(suspension_id: str, sx_source: str) -> str: + """Build a +;; --------------------------------------------------------------------------- + +(defcomp ~suspense (&key id fallback &rest children) + (div :id (str "sx-suspense-" id) + :data-suspense id + :style "display:contents" + (if children children fallback))) + (defcomp ~error-page (&key title message image asset-url) (~base-shell :title title :asset-url asset-url (div :class "text-center p-8 max-w-lg mx-auto" diff --git a/shared/sx/types.py b/shared/sx/types.py index 380f2ce..ee31f4f 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -241,6 +241,8 @@ class PageDef: filter_expr: Any aside_expr: Any menu_expr: Any + stream: bool = False # enable streaming response + fallback_expr: Any = None # fallback content while streaming closure: dict[str, Any] = field(default_factory=dict) def __repr__(self): diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index fb5dd2a..232ffad 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -69,3 +69,8 @@ :params (spec-name) :returns "dict" :service "sx") + +(define-page-helper "streaming-demo-data" + :params () + :returns "dict" + :service "sx") diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 631973b..c5f8091 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -120,7 +120,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" diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index ef9c8f7..0656062 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -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: " "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 diff --git a/sx/sx/streaming-demo.sx b/sx/sx/streaming-demo.sx new file mode 100644 index 0000000..25d0f08 --- /dev/null +++ b/sx/sx/streaming-demo.sx @@ -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" "") + " 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 "")) + (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"))))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 23416c8..6fee3a7 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -457,6 +457,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/" diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index fc264df..8a25df9 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -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,