Files
rose-ash/lib/host/static.sx
giles b21ae05e8f
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
host: extract the relate picker into a content-addressed ~relate-picker component
The declarative picker markup is now a reusable SX component
(lib/host/sx/relate-picker.sx, defcomp ~relate-picker &key slug kind) instead of
inline markup in the editor. It is a CONTENT-ADDRESSED, CLIENT-EXPANDED component:

- Server: on a full page load render-page expands ~relate-picker server-side
  (SEO / no-JS), exactly as before.
- Client: on a boosted SPA nav the edit body serialises to the compact
  (~relate-picker :slug … :kind …), and the CLIENT expands it. The component
  module is compiled to a content-addressed .sxbc, served immutably from
  /sx/h/{hash}, and listed in the page's data-sx-manifest "boot" array so the
  client eager-loads it after the web stack — registering its defcomp before any
  boosted fragment references it.

Wiring:
- lib/host/sx/relate-picker.sx — the component.
- lib/host/blog.sx — editor emits (~relate-picker :slug s :kind k); the inline
  form markup is gone.
- lib/host/static.sx — host/static-manifest-json emits boot:["relate-picker.sxbc"]
  (the previously-empty boot array, now used as designed).
- hosts/ocaml/browser/sx-platform.js — loadWebStack eager-loads the page manifest's
  boot[] modules (content-addressed) after the web stack.
- bundle.sh + compile-modules.js — copy/compile the component to .sxbc.
- serve.sh + conformance.sh — load the component module server-side.

This gives the host an app-component system: app defcomps shipped to the client by
hash, the same machinery as the kernel modules — the picker is the first, and it's
the model for publishing components externally.

Tests: conformance 272/272 (server expansion); relate-picker.spec.js 6/6 incl. the
boosted-nav populate (proves client-side component load + expansion) and the
error/retry case. WASM stack rebuilt (relate-picker.sxbc @ 6818110a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:17:30 +00:00

119 lines
5.3 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/.
;; App components the client must eager-load (after the web stack) so their
;; defcomps are registered before a boosted fragment references them. Loaded
;; content-addressed via the modules map below, the same as any web-stack module.
(define host/static--boot-modules (list "relate-picker.sxbc"))
(define host/static-manifest-json
(fn ()
(str "{\"v\":1,\"boot\":["
(join "," (map (fn (m) (str "\"" m "\"")) host/static--boot-modules))
"],\"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 "**"))))))