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>
112 lines
4.9 KiB
Plaintext
112 lines
4.9 KiB
Plaintext
;; lib/host/static.sx — serve the client kernel + assets so the blog can boot the
|
|
;; SX-htmx hypermedia engine (web/engine.sx) and run as a SPA. The native
|
|
;; 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")
|
|
(define host/static-use-root! (fn (r) (set! host/static-root r)))
|
|
|
|
;; content-type by file extension; default to octet-stream.
|
|
(define host/static--ctype
|
|
(fn (path)
|
|
(cond
|
|
((ends-with? path ".js") "application/javascript; charset=utf-8")
|
|
((ends-with? path ".mjs") "application/javascript; charset=utf-8")
|
|
((ends-with? path ".css") "text/css; charset=utf-8")
|
|
((ends-with? path ".json") "application/json; charset=utf-8")
|
|
((ends-with? path ".map") "application/json; charset=utf-8")
|
|
((ends-with? path ".svg") "image/svg+xml")
|
|
((ends-with? path ".png") "image/png")
|
|
((ends-with? path ".woff2") "font/woff2")
|
|
((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)
|
|
(and (> (len rel) 0)
|
|
(not (starts-with? rel "/"))
|
|
(not (string-contains? rel "..")))))
|
|
|
|
;; Serve one asset by its path relative to the static root. file-read THROWS on a
|
|
;; missing file, so gate on file-exists? first and return a 404 instead.
|
|
(define host/static-serve
|
|
(fn (rel)
|
|
(if (not (host/static--safe? rel))
|
|
(dream-html-status 403 "Forbidden")
|
|
(let ((path (str host/static-root "/" rel)))
|
|
(if (not (file-exists? path))
|
|
(dream-html-status 404 "Not Found")
|
|
(dream-response 200
|
|
{:content-type (host/static--ctype rel)
|
|
:cache-control (host/static--cache-control rel)}
|
|
(file-read path)))))))
|
|
|
|
;; ── 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 "**"))))
|
|
(dream-get "/sx/h/**"
|
|
(fn (req) (host/static-sxh-serve (dream-param req "**"))))))
|