Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Adds two new top-level SXTP message types alongside
request/response/condition/event, modelled on Datastar's
datastar-patch-elements and datastar-patch-signals SSE events:
(patch :target "#x" :mode outer :body (~card)) - DOM fragment
morph. Subsumes HTMX swap modes. Mode is outer (default) |
inner | replace | prepend | append | before | after | remove.
(signals :values {:n 3} :only-if-missing false) - reactive
state patch. nil value removes the signal. only-if-missing
skips existing signals (lazy init).
A server response stream can mix both freely; clients dispatch
by head symbol, ordering preserved. Cleaner than HTMX's
swap-mode-per-trigger because the patch shape is decoupled from
the triggering element/attribute.
Spec at applications/sxtp/spec.sx (patch-fields, signals-fields,
patch-modes, example-patch-stream). Constructors / predicates /
accessors / serialise / parse in lib/host/sxtp.sx. 25 new tests
in lib/host/tests/sxtp.sx (predicates, mode normalisation, fixed
field order, remove-without-body, signals round-trip). Host
conformance 129/129 (was 104/104).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
209 lines
8.6 KiB
Plaintext
209 lines
8.6 KiB
Plaintext
(define
|
|
request-fields
|
|
(quote
|
|
((:verb "Symbol — the action to perform (required)")
|
|
(:path "String — resource path (required)")
|
|
(:headers "Dict — structured request metadata (optional)")
|
|
(:cookies "Dict — client state, values can be any SX type (optional)")
|
|
(:params "Dict — query parameters as typed values (optional)")
|
|
(:capabilities "List — capabilities this request requires (optional)")
|
|
(:body "Any SX value — request payload (optional)"))))
|
|
|
|
(define
|
|
response-fields
|
|
(quote
|
|
((:status "Symbol or condition — result status (required)")
|
|
(:headers "Dict — structured response metadata (optional)")
|
|
(:set-cookie
|
|
"Dict — cookies to set, values are dicts with :value :max-age :path (optional)")
|
|
(:body "Any SX value — response payload (optional)")
|
|
(:stream "Boolean — if true, body is a sequence of chunks (optional)"))))
|
|
|
|
(define
|
|
core-verbs
|
|
(quote
|
|
((navigate "Retrieve a page for display — analogous to GET for documents")
|
|
(fetch "Retrieve data — analogous to GET for APIs")
|
|
(query "Structured query — body contains a query expression")
|
|
(mutate "Change state — analogous to POST/PUT/PATCH")
|
|
(create "Create a new resource — analogous to POST")
|
|
(delete "Remove a resource — analogous to DELETE")
|
|
(subscribe "Open a streaming channel for real-time updates")
|
|
(inspect "Retrieve metadata about a resource (capabilities, schema)")
|
|
(ping "Liveness check — server responds with (response :status ok)"))))
|
|
|
|
(define
|
|
standard-headers
|
|
(quote
|
|
((:accept "List of acceptable response types")
|
|
(:language "String or list — preferred languages")
|
|
(:if-match "String — content hash for conditional requests")
|
|
(:capabilities "List — capabilities the client holds")
|
|
(:origin "String — requesting origin for CORS-like checks")
|
|
(:content-type "String — always text/sx in pure SXTP")
|
|
(:content-hash "String — SHA3-256 of the body expression")
|
|
(:cache "Symbol — :immutable, :revalidate, :none")
|
|
(:vary "List of header keys that affect caching")
|
|
(:link "Dict — related resources"))))
|
|
|
|
(define
|
|
cookie-options
|
|
(quote
|
|
((:value "Any SX value — the cookie payload (required)")
|
|
(:max-age "Number — seconds until expiry (optional)")
|
|
(:path "String — path scope (optional, default /)")
|
|
(:domain "String — domain scope (optional)")
|
|
(:secure "Boolean — require secure transport (optional)")
|
|
(:same-site "Symbol — :strict, :lax, or :none (optional)")
|
|
(:delete "Boolean — if true, remove this cookie (optional)"))))
|
|
|
|
(define
|
|
status-symbols
|
|
(quote
|
|
((ok "Success — body contains the result")
|
|
(created "Resource created — body contains the new resource")
|
|
(accepted "Request accepted for async processing")
|
|
(no-content "Success with no body")
|
|
(redirect "See :headers :location for target")
|
|
(not-modified "Cached version is current based on :if-match")
|
|
(error "General error — see :body for condition")
|
|
(not-found "Resource does not exist")
|
|
(forbidden "Insufficient capabilities")
|
|
(invalid "Malformed request or invalid params")
|
|
(conflict "State conflict — concurrent edit")
|
|
(unavailable "Service temporarily unavailable"))))
|
|
|
|
(define
|
|
condition-fields
|
|
(quote
|
|
((:type "Symbol — condition type (required)")
|
|
(:message "String — human-readable description (optional)")
|
|
(:path "String — resource that caused the error (optional)")
|
|
(:retry "Boolean — whether retrying may succeed (optional)")
|
|
(:detail "Any SX value — domain-specific detail (optional)"))))
|
|
|
|
(define
|
|
chunk-fields
|
|
(quote
|
|
((:seq "Number — sequence index for ordered chunks")
|
|
(:body "Any SX value — the chunk content")
|
|
(:done "Boolean — signals end of stream"))))
|
|
|
|
(define
|
|
event-fields
|
|
(quote
|
|
((:type "Symbol — event type (required)")
|
|
(:id "String — event or resource identifier (optional)")
|
|
(:body "Any SX value — event payload (optional)")
|
|
(:time "Number — unix timestamp (optional)"))))
|
|
|
|
;; ── patch (DOM fragment patch — borrowed from Datastar) ───────────
|
|
;; A server-driven instruction to morph a region of the client DOM.
|
|
;; Subsumes HTMX swap modes; the :body is an SX subtree that the client
|
|
;; renders to DOM nodes before applying the mode at the target.
|
|
(define
|
|
patch-fields
|
|
(quote
|
|
((:target "String — CSS selector for the element to patch (required)")
|
|
(:mode "Symbol — patch mode (optional, default outer)")
|
|
(:body "SX tree — the new content (omitted for mode remove)")
|
|
(:transition "Boolean — use a view transition (optional, default false)"))))
|
|
|
|
(define
|
|
patch-modes
|
|
(quote
|
|
((outer "Replace the target's outerHTML (default; the morph target)")
|
|
(inner "Replace the target's innerHTML, preserving the wrapper")
|
|
(replace "Hard-replace without morphing (no diff, plain swap)")
|
|
(prepend "Insert the body as the target's first child")
|
|
(append "Insert the body as the target's last child")
|
|
(before "Insert the body before the target")
|
|
(after "Insert the body after the target")
|
|
(remove "Detach the target; :body MUST be absent"))))
|
|
|
|
;; ── signals (reactive state patch — borrowed from Datastar) ──────
|
|
;; A server-driven update to client-side reactive signals. :values is a
|
|
;; dict of signal-name -> new-value; setting a value to nil REMOVES the
|
|
;; signal. With :only-if-missing true, existing signals are not touched
|
|
;; (use this to lazily initialise signal state without clobbering).
|
|
(define
|
|
signals-fields
|
|
(quote
|
|
((:values "Dict — signal-name -> new-value (required)")
|
|
(:only-if-missing
|
|
"Boolean — only set signals that don't yet exist (optional, default false)"))))
|
|
|
|
(define
|
|
example-navigate
|
|
(quote
|
|
((request :verb navigate :path "/geography/capabilities" :headers {:host "sx.rose-ash.com" :accept "text/sx"})
|
|
(response
|
|
:status ok
|
|
:headers {:content-type "text/sx" :content-hash "sha3-9f2a"}
|
|
:body (page
|
|
:title "Capabilities"
|
|
(h1 "Geography Capabilities")
|
|
(~capability-list :domain "geography"))))))
|
|
|
|
(define
|
|
example-query
|
|
(quote
|
|
((request :verb query :path "/events" :capabilities (fetch db:read) :params {:after "2026-03-01" :limit 10} :body (filter (events) (fn (e) (> (:attendees e) 50))))
|
|
(response
|
|
:status ok
|
|
:headers {:cache :revalidate}
|
|
:body ((event :id "evt-42" :title "Jazz Night" :attendees 87)
|
|
(event :id "evt-55" :title "Art Walk" :attendees 120))))))
|
|
|
|
(define
|
|
example-mutate
|
|
(quote
|
|
((request :verb create :path "/blog/posts" :capabilities (mutate blog:publish) :cookies {:session "tok_abc123"} :body {:tags ("protocol" "sx" "web") :body (article (h1 "SXTP") (p "Everything is SX.")) :title "SXTP Protocol"})
|
|
(response :status created :headers {:location "/blog/posts/sxtp-protocol" :content-hash "sha3-ff01"} :body {:created-at 1711612800 :id "post-789" :path "/blog/posts/sxtp-protocol"}))))
|
|
|
|
(define
|
|
example-subscribe
|
|
(quote
|
|
((request :verb subscribe :path "/events/live" :capabilities (fetch) :headers {:host "events.rose-ash.com"})
|
|
(response :status ok :stream true)
|
|
(event
|
|
:type new-event
|
|
:id "evt-99"
|
|
:body (div :class "event-card" (h3 "Poetry Slam")))
|
|
(event :type heartbeat :time 1711612860))))
|
|
|
|
(define
|
|
example-error
|
|
(quote
|
|
((request :verb fetch :path "/blog/nonexistent")
|
|
(response
|
|
:status not-found
|
|
:body (condition
|
|
:type resource-not-found
|
|
:path "/blog/nonexistent"
|
|
:message "No such post"
|
|
:retry false)))))
|
|
|
|
;; A streaming response intermixing patch + signals: the server pushes
|
|
;; DOM updates AND signal updates over the same channel. The client
|
|
;; dispatches each message by its head symbol; ordering is preserved.
|
|
(define
|
|
example-patch-stream
|
|
(quote
|
|
((request :verb subscribe :path "/cart/live" :capabilities (fetch))
|
|
(response :status ok :stream true)
|
|
(signals :values {:cart/count 3 :cart/loading false})
|
|
(patch
|
|
:target "#cart-mini"
|
|
:mode outer
|
|
:body (~cart-mini :count 3 :total 47.50))
|
|
(patch :target "#flash" :mode inner :body (p "Item added."))
|
|
(signals :values {:cart/loading true})
|
|
(patch :target "#cart-loading-spinner" :mode remove))))
|
|
|
|
(define
|
|
example-inspect
|
|
(quote
|
|
((request :verb inspect :path "/cart/checkout")
|
|
(response :status ok :body {:available-verbs (inspect mutate) :params-schema {:payment-method "symbol" :shipping-address "dict"} :required-capabilities (mutate cart:checkout)}))))
|