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

@@ -3,6 +3,13 @@
;; http-listen host reads files with the `file-read` primitive (no perform), so
;; GET /static/** maps to a file under the static root (default "shared/static",
;; resolved against the server cwd — mount ./shared/static there in the container).
;;
;; Also wires the CONTENT-ADDRESSED module cache the SX client expects: GET
;; /sx/h/{hash} serves a web-stack .sxbc by its content hash (immutable, never
;; stale — a deploy changes the content → changes the hash → a fresh URL), and a
;; <script data-sx-manifest> mapping {file -> hash} makes the client's
;; loadBytecodeFile take the content-addressed branch (localStorage + immutable)
;; instead of the path + max-age=3600 branch.
;; Depends on lib/dream/types.sx (dream-response/-html-status/-param) + router.
(define host/static-root "shared/static")
@@ -23,6 +30,14 @@
((ends-with? path ".wasm") "application/wasm")
(true "application/octet-stream"))))
;; A content-hashed filename (e.g. js_of_ocaml-651f6707.wasm, or anything under
;; /sx/h/) is immutable; everything else gets a modest max-age (mutable bundle).
(define host/static--cache-control
(fn (rel)
(if (ends-with? rel ".wasm")
"public, max-age=31536000, immutable"
"public, max-age=3600")))
;; reject empty, absolute, or traversal paths.
(define host/static--safe?
(fn (rel)
@@ -41,12 +56,56 @@
(dream-html-status 404 "Not Found")
(dream-response 200
{:content-type (host/static--ctype rel)
:cache-control "public, max-age=3600"}
:cache-control (host/static--cache-control rel)}
(file-read path)))))))
;; Route group: GET /static/** -> file under the static root. A plain route LIST
;; (like host/feed-routes); host/serve combines + flattens the groups itself.
;; ── content-addressed module cache (/sx/h/{hash}) ───────────────────
;; Each web-stack .sxbc carries its content hash in its head: (sxbc 1 "HASH" ...).
;; Index every .sxbc by that hash at startup so the client can fetch each module
;; immutably + localStorage-cached, and never stale.
(define host/static--sxh->path (dict)) ;; hash -> filepath
(define host/static--file->hash (dict)) ;; "dom.sxbc" -> hash
;; the embedded hash from a .sxbc head: (sxbc 1 "HASH" ... -> "HASH"
(define host/static--sxbc-hash
(fn (head) (nth (split head "\"") 1)))
(define host/static-build-sxh-index!
(fn ()
(for-each
(fn (path)
(let ((h (host/static--sxbc-hash (substr (file-read path) 0 60)))
(base (last (split path "/"))))
(dict-set! host/static--sxh->path h path)
(dict-set! host/static--file->hash base h)))
(file-glob (str host/static-root "/wasm/sx/*.sxbc")))))
;; GET /sx/h/{hash} -> the .sxbc content, immutable (content-addressed).
(define host/static-sxh-serve
(fn (hash)
(let ((path (get host/static--sxh->path hash)))
(if (nil? path)
(dream-html-status 404 "Not Found")
(dream-response 200
{:content-type "text/sx; charset=utf-8"
:cache-control "public, max-age=31536000, immutable"}
(file-read path))))))
;; the data-sx-manifest JSON for the shell: {"modules": {"dom.sxbc": "hash", ...}}.
;; The client's loadBytecodeFile reads manifest.modules[file] -> hash -> /sx/h/.
(define host/static-manifest-json
(fn ()
(str "{\"v\":1,\"boot\":[],\"defs\":{},\"modules\":{"
(join ","
(map (fn (k) (str "\"" k "\":\"" (get host/static--file->hash k) "\""))
(keys host/static--file->hash)))
"}}")))
;; Route group: GET /static/** (path) + GET /sx/h/** (content-addressed). A plain
;; route LIST (like host/feed-routes); host/serve combines + flattens the groups.
(define host/static-routes
(list
(dream-get "/static/**"
(fn (req) (host/static-serve (dream-param req "**"))))))
(fn (req) (host/static-serve (dream-param req "**"))))
(dream-get "/sx/h/**"
(fn (req) (host/static-sxh-serve (dream-param req "**"))))))