host: content-addressed SPA cache + declarative SX-htmx relate picker + SIGPIPE hardening
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s

Three composing pieces that make the blog SPA correct and resilient.

Content-addressed module cache (lib/host/static.sx, serve.sh, blog.sx shell,
conformance.sh): index each web-stack .sxbc by the content hash in its head,
serve GET /sx/h/{hash} immutable text/sx, and emit <script data-sx-manifest>
{file->hash} so the WASM client loads modules content-addressed (localStorage +
immutable) instead of path + max-age. serve.sh builds the index at boot;
conformance.sh now loads static.sx before blog.sx (the shell calls
host/static-manifest-json).

Declarative relate picker (lib/host/blog.sx, lib/dream/form.sx): replace the
inline /relate-picker.js blob — which never ran on swapped-in content, so the
candidate list was empty after a boosted nav to /<slug>/edit — with a declarative
SX-htmx form: sx-get relate-options on "load" + debounced "input", innerHTML-swap
the results ul; infinite scroll via a server-emitted "load more" sentinel
(sx-trigger revealed, sx-swap outerHTML) that pages the rest, q preserved via a
new symmetric dr/url-encode. The engine re-binds these triggers on swapped
content, so the picker populates on full load AND boosted SPA nav. Candidate
relate forms get :sx-disable (plain POST->303->reload, their original behavior;
the engine would otherwise boost them and swap the redirect unreliably).
sx-retry "exponential:1000:30000" on the form+sentinel retries a dropped/offline
fetch forever (the cap bounds the interval, not the attempts).

SIGPIPE hardening (hosts/ocaml/bin/sx_server.ml): the native http-listen server
had no SIGPIPE handler, so a client aborting an in-flight fetch (the engine
cancels superseded requests on a debounced filter/fast nav) closed the socket
mid-write and killed the whole process (exit 141). Ignore SIGPIPE so the failed
write becomes a catchable Sys_error the per-connection handler already swallows.

Tests: host conformance 272/272; relate-picker.spec.js 5/5 incl. a boosted-nav
populate regression; spa-check 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:30:17 +00:00
parent b9a24d5870
commit bdc7e02fbc
8 changed files with 257 additions and 50 deletions

View File

@@ -363,7 +363,11 @@
(fn (slug p kind)
(quasiquote
(li :style "border-bottom:1px solid #eee"
(form :method "post" :style "margin:0"
;; sx-disable: this relate form is a plain POST -> 303 -> full reload (the
;; engine swaps the picker rows in, which would otherwise boost this form;
;; a boosted POST+redirect into #content swaps unreliably). A relate is a
;; deliberate action, so a clean reload that re-renders the editor is right.
(form :method "post" :style "margin:0" :sx-disable "true"
:action (unquote (str "/" slug "/relate"))
(input :type "hidden" :name "other" :value (unquote (get p :slug)))
(input :type "hidden" :name "kind" :value (unquote kind))
@@ -371,9 +375,29 @@
:style "width:100%;text-align:left;background:none;border:none;padding:0.5em;cursor:pointer"
(unquote (get p :title))))))))
;; The infinite-scroll "load more" sentinel: an <li> that, when scrolled into view
;; (sx-trigger "revealed"), GETs the NEXT page and replaces ITSELF (sx-swap
;; outerHTML, default self-target) with those rows + the next sentinel. This is the
;; SX-htmx engine doing the paging — no client JS. q is %-encoded back into the URL
;; so the filter is preserved across pages.
(define host/blog--picker-more
(fn (slug kind q next)
(quasiquote
(li :class "rp-more"
:style "list-style:none;padding:0.5em;text-align:center;opacity:0.6"
:sx-get (unquote (str "/" slug "/relate-options?kind=" kind
"&q=" (dr/url-encode q) "&offset=" next))
:sx-trigger "revealed"
:sx-swap "outerHTML"
;; a dropped/offline page-fetch retries with exponential backoff (1s→30s)
;; until it succeeds, so a flaky connection self-heals as you scroll.
:sx-retry "exponential:1000:30000"
"Loading more…"))))
;; GET /<slug>/relate-options?kind=&q=&offset= — one page of candidate rows for a
;; kind as an HTML fragment (the <li>s the picker script appends). Public read; the
;; relate action stays guarded.
;; kind as an HTML fragment, swapped into the picker by the SX-htmx engine. A full
;; page is followed by a "load more" sentinel (above); the last page is not. Public
;; read; the relate action stays guarded.
(define host/blog-relate-options
(fn (req)
(let ((slug (dream-param req "slug"))
@@ -384,20 +408,12 @@
(offset (host/query-int req "offset" 0)))
(let ((page (take (drop (host/blog--relate-candidates slug q kind) offset)
host/blog--picker-limit)))
(dream-html
(join "" (map (fn (p) (render-page (host/blog--picker-item slug p kind))) page)))))))
(let ((rows (join "" (map (fn (p) (render-page (host/blog--picker-item slug p kind))) page)))
(more (if (= (len page) host/blog--picker-limit)
(render-page (host/blog--picker-more slug kind q (+ offset host/blog--picker-limit)))
"")))
(dream-html (str rows more)))))))
;; GET /relate-picker.js — progressive-enhancement glue. MULTI-INSTANCE: wires
;; every .relate-picker box on the page (a Related picker + a Tags picker can
;; coexist), reading data-slug + data-kind from each. Debounced live filter +
;; scroll-to-load-more against /<slug>/relate-options. The host serves static HTML
;; (no SX hydration), so the interactive layer is this small cached script.
(define host/blog-picker-js-src
"(function(){function wire(box){var f=box.querySelector('.rp-filter');if(!f)return;var r=box.querySelector('.rp-results');var slug=box.getAttribute('data-slug'),kind=box.getAttribute('data-kind')||'related',off=0,q='',busy=false,done=false,pending=false,t;function load(reset){if(busy){if(reset)pending=true;return;}if(!reset&&done)return;busy=true;if(reset){off=0;done=false;}fetch('/'+slug+'/relate-options?kind='+encodeURIComponent(kind)+'&q='+encodeURIComponent(q)+'&offset='+off).then(function(x){return x.text();}).then(function(h){var d=document.createElement('div');d.innerHTML=h;var n=d.children.length;if(reset)r.innerHTML='';while(d.firstChild)r.appendChild(d.firstChild);off+=n;done=n<20;busy=false;if(pending){pending=false;load(true);}}).catch(function(){busy=false;if(pending){pending=false;load(true);}});}f.addEventListener('input',function(){clearTimeout(t);t=setTimeout(function(){q=f.value.trim();load(true);},200);});r.addEventListener('scroll',function(){if(r.scrollTop+r.clientHeight>=r.scrollHeight-40){load(false);}});load(true);}var boxes=document.querySelectorAll('.relate-picker');for(var i=0;i<boxes.length;i++){wire(boxes[i]);}})();")
(define host/blog-picker-js
(fn (req)
(dream-response 200 {:content-type "application/javascript; charset=utf-8"}
host/blog-picker-js-src)))
;; ── page shell ──────────────────────────────────────────────────────
;; A page is an SX element tree, rendered via render-page (5.1). The handler
@@ -427,6 +443,12 @@
(quasiquote
(html
(head (meta :charset "utf-8") (title (unquote title))
;; content-addressed module manifest: {file -> hash}. The client's
;; loadBytecodeFile reads this and fetches each web-stack module
;; immutably from /sx/h/{hash} (localStorage-cached, never stale)
;; instead of /static/wasm/sx/*.sxbc with max-age.
(script :type "application/json" :data-sx-manifest "1"
(raw! (unquote (host/static-manifest-json))))
(script :src "/static/wasm/sx_browser.bc.wasm.js")
(script :src "/static/wasm/sx-platform.js"))
(body
@@ -536,9 +558,9 @@
(else "")))))
;; Kind-aware relation editor for the edit page: current links (each with a
;; kind-scoped remove), plus a filterable picker (a .relate-picker box the shared
;; /relate-picker.js wires by data-kind). The picker's candidates come from the
;; kind's registry :candidates ("all" / tags / types). One editor per kind.
;; kind-scoped remove), plus a filterable picker (a declarative SX-htmx form, one
;; per kind). The picker's candidates come from the kind's registry :candidates
;; ("all" / tags / types).
(define host/blog--relation-editor
(fn (slug kind)
;; current edges read up front (a perform) — NOT inside the quasiquote, where
@@ -561,11 +583,25 @@
(button :type "submit" "remove")))))
current))
(quote (p :style "opacity:0.7" "None yet."))))
(div :class "relate-picker" :data-slug (unquote slug) :data-kind (unquote kind)
(input :type "text" :class "rp-filter" :placeholder "filter…" :autocomplete "off"
;; Declarative SX-htmx picker (no client JS). The form GETs relate-options
;; serialising its inputs (kind + the filter q) into the query: on initial
;; "load" and on a debounced "input" it innerHTML-swaps the results ul.
;; Paging is driven by the "load more" sentinel each page carries. The
;; SX engine re-binds these triggers on swapped-in content, so the picker
;; works whether the edit page is a full load or a boosted SPA nav.
(form :class "relate-picker" :data-slug (unquote slug) :data-kind (unquote kind)
:sx-get (unquote (str "/" slug "/relate-options"))
:sx-trigger "input delay:200ms, load"
:sx-target (unquote (str "#rp-" kind "-results"))
:sx-swap "innerHTML"
;; a failed initial/filter fetch retries with backoff (1s→30s)
:sx-retry "exponential:1000:30000"
:style "margin:0"
(input :type "hidden" :name "kind" :value (unquote kind))
(input :type "text" :name "q" :class "rp-filter" :placeholder "filter…" :autocomplete "off"
:style "width:100%;padding:0.4em;box-sizing:border-box")
(ul :class "rp-results"
:style "list-style:none;padding:0;margin:0.5em 0;max-height:240px;overflow:auto;border:1px solid #ddd")))))))
(ul :id (unquote (str "rp-" kind "-results")) :class "rp-results"
:style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd")))))))
;; "Is this post a tag?" toggle — marking a post a tag is just an is-a edge to the
;; "tag" type-post, so it reuses the relate/unrelate routes (no new endpoint).
@@ -889,8 +925,6 @@
(div :style "margin-top:2em;border-top:1px solid #ccc;padding-top:1em"
(unquote tag-toggle))
(unquote relation-editors)
;; one shared picker script wires every .relate-picker box
(raw! "<script src=\"/relate-picker.js\"></script>")
(p :style "margin-top:1.5em"
(a :href (unquote (str "/" slug "/")) "view post")
" · "
@@ -935,7 +969,6 @@
(dream-get "/posts" host/blog-index)
(dream-get "/new" host/blog-new-form)
(dream-get "/tags" host/blog-tags-index)
(dream-get "/relate-picker.js" host/blog-picker-js)
(dream-get "/:slug/source" host/blog-source)
(dream-get "/:slug/relate-options" host/blog-relate-options)
(dream-get "/:slug" host/blog-post)))