Four new primitives for scoped downward value passing and upward accumulation through the render tree. Specced in .sx, bootstrapped to Python and JS across all adapters (eval, html, sx, dom, async). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
422 lines
12 KiB
Plaintext
422 lines
12 KiB
Plaintext
;; ==========================================================================
|
|
;; boundary.sx — SX language boundary contract
|
|
;;
|
|
;; Declares the core I/O primitives that any SX host must provide.
|
|
;; This is the LANGUAGE contract — not deployment-specific.
|
|
;;
|
|
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
|
;; Deployment-specific I/O lives in boundary-app.sx.
|
|
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
|
;;
|
|
;; Format:
|
|
;; (define-io-primitive "name"
|
|
;; :params (param1 param2 &key ...)
|
|
;; :returns "type"
|
|
;; :effects [io]
|
|
;; :async true
|
|
;; :doc "description"
|
|
;; :context :request)
|
|
;;
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Tier 1: Pure primitives — declared in primitives.sx
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(declare-tier :pure :source "primitives.sx")
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
|
|
;;
|
|
;; These are generic web-platform I/O that any SX web host would provide,
|
|
;; regardless of deployment architecture.
|
|
;; --------------------------------------------------------------------------
|
|
|
|
;; Request context
|
|
|
|
(define-io-primitive "current-user"
|
|
:params ()
|
|
:returns "dict?"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Current authenticated user dict, or nil."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-arg"
|
|
:params (name &rest default)
|
|
:returns "any"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Read a query string argument from the current request."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-path"
|
|
:params ()
|
|
:returns "string"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Current request path."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-view-args"
|
|
:params (key)
|
|
:returns "any"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Read a URL view argument from the current request."
|
|
:context :request)
|
|
|
|
(define-io-primitive "csrf-token"
|
|
:params ()
|
|
:returns "string"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Current CSRF token string."
|
|
:context :request)
|
|
|
|
(define-io-primitive "abort"
|
|
:params (status &rest message)
|
|
:returns "nil"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Raise HTTP error from SX."
|
|
:context :request)
|
|
|
|
;; Routing
|
|
|
|
(define-io-primitive "url-for"
|
|
:params (endpoint &key)
|
|
:returns "string"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Generate URL for a named endpoint."
|
|
:context :request)
|
|
|
|
(define-io-primitive "route-prefix"
|
|
:params ()
|
|
:returns "string"
|
|
:effects [io]
|
|
:async true
|
|
:doc "Service URL prefix for dev/prod routing."
|
|
:context :request)
|
|
|
|
;; Config and host context (sync — no await needed)
|
|
|
|
(define-io-primitive "app-url"
|
|
:params (service &rest path)
|
|
:returns "string"
|
|
:effects [io]
|
|
:async false
|
|
:doc "Full URL for a service: (app-url \"blog\" \"/my-post/\")."
|
|
:context :config)
|
|
|
|
(define-io-primitive "asset-url"
|
|
:params (&rest path)
|
|
:returns "string"
|
|
:effects [io]
|
|
:async false
|
|
:doc "Versioned static asset URL."
|
|
:context :config)
|
|
|
|
(define-io-primitive "config"
|
|
:params (key)
|
|
:returns "any"
|
|
:effects [io]
|
|
:async false
|
|
:doc "Read a value from host configuration."
|
|
:context :config)
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Boundary types — what's allowed to cross the host-SX boundary
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define-boundary-types
|
|
(list "number" "string" "boolean" "nil" "keyword"
|
|
"list" "dict" "sx-source"))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Web interop — reading non-SX request formats
|
|
;;
|
|
;; SX's native wire format is SX (text/sx). These primitives bridge to
|
|
;; legacy web formats: HTML form encoding, JSON bodies, HTTP headers.
|
|
;; They're useful for interop but not fundamental to SX-to-SX communication.
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define-io-primitive "now"
|
|
:params (&rest format)
|
|
:returns "string"
|
|
:async true
|
|
:doc "Current timestamp. Optional format string (strftime). Default ISO 8601."
|
|
:context :request)
|
|
|
|
(define-io-primitive "sleep"
|
|
:params (ms)
|
|
:returns "nil"
|
|
:async true
|
|
:doc "Pause execution for ms milliseconds. For demos and testing."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-form"
|
|
:params (name &rest default)
|
|
:returns "any"
|
|
:async true
|
|
:doc "Read a form field from a POST/PUT/PATCH request body."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-json"
|
|
:params ()
|
|
:returns "dict?"
|
|
:async true
|
|
:doc "Read JSON body from the current request, or nil if not JSON."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-header"
|
|
:params (name &rest default)
|
|
:returns "string?"
|
|
:async true
|
|
:doc "Read a request header value by name."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-content-type"
|
|
:params ()
|
|
:returns "string?"
|
|
:async true
|
|
:doc "Content-Type of the current request."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-args-all"
|
|
:params ()
|
|
:returns "dict"
|
|
:async true
|
|
:doc "All query string parameters as a dict."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-form-all"
|
|
:params ()
|
|
:returns "dict"
|
|
:async true
|
|
:doc "All form fields as a dict."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-form-list"
|
|
:params (field-name)
|
|
:returns "list"
|
|
:async true
|
|
:doc "All values for a multi-value form field as a list."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-headers-all"
|
|
:params ()
|
|
:returns "dict"
|
|
:async true
|
|
:doc "All request headers as a dict (lowercase keys)."
|
|
:context :request)
|
|
|
|
(define-io-primitive "request-file-name"
|
|
:params (field-name)
|
|
:returns "string?"
|
|
:async true
|
|
:doc "Filename of an uploaded file by field name, or nil."
|
|
:context :request)
|
|
|
|
;; Response manipulation
|
|
|
|
(define-io-primitive "set-response-header"
|
|
:params (name value)
|
|
:returns "nil"
|
|
:async true
|
|
:doc "Set a response header. Applied after handler returns."
|
|
:context :request)
|
|
|
|
(define-io-primitive "set-response-status"
|
|
:params (status)
|
|
:returns "nil"
|
|
:async true
|
|
:doc "Set the HTTP response status code. Applied after handler returns."
|
|
:context :request)
|
|
|
|
;; Ephemeral state — per-process, resets on restart
|
|
|
|
(define-io-primitive "state-get"
|
|
:params (key &rest default)
|
|
:returns "any"
|
|
:async true
|
|
:doc "Read from ephemeral per-process state dict."
|
|
:context :request)
|
|
|
|
(define-io-primitive "state-set!"
|
|
:params (key value)
|
|
:returns "nil"
|
|
:async true
|
|
:doc "Write to ephemeral per-process state dict."
|
|
:context :request)
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Tier 3: Signal primitives — reactive state for islands
|
|
;;
|
|
;; These are pure primitives (no IO) but are separated from primitives.sx
|
|
;; because they introduce a new type (signal) and depend on signals.sx.
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(declare-tier :signals :source "signals.sx")
|
|
|
|
(declare-signal-primitive "signal"
|
|
:params (initial-value)
|
|
:returns "signal"
|
|
:effects []
|
|
:doc "Create a reactive signal container with an initial value.")
|
|
|
|
(declare-signal-primitive "deref"
|
|
:params (signal)
|
|
:returns "any"
|
|
:effects []
|
|
:doc "Read a signal's current value. In a reactive context (inside an island),
|
|
subscribes the current DOM binding to the signal. Outside reactive
|
|
context, just returns the value.")
|
|
|
|
(declare-signal-primitive "reset!"
|
|
:params (signal value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Set a signal to a new value. Notifies all subscribers.")
|
|
|
|
(declare-signal-primitive "swap!"
|
|
:params (signal f &rest args)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Update a signal by applying f to its current value. (swap! s inc)
|
|
is equivalent to (reset! s (inc (deref s))) but atomic.")
|
|
|
|
(declare-signal-primitive "computed"
|
|
:params (compute-fn)
|
|
:returns "signal"
|
|
:effects []
|
|
:doc "Create a derived signal that recomputes when its dependencies change.
|
|
Dependencies are discovered automatically by tracking deref calls.")
|
|
|
|
(declare-signal-primitive "effect"
|
|
:params (effect-fn)
|
|
:returns "lambda"
|
|
:effects [mutation]
|
|
:doc "Run a side effect that re-runs when its signal dependencies change.
|
|
Returns a dispose function. If the effect function returns a function,
|
|
it is called as cleanup before the next run.")
|
|
|
|
(declare-signal-primitive "batch"
|
|
:params (thunk)
|
|
:returns "any"
|
|
:effects [mutation]
|
|
:doc "Group multiple signal writes. Subscribers are notified once at the end,
|
|
after all values have been updated.")
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Tier 4: Spread + Collect — render-time attribute injection and accumulation
|
|
;;
|
|
;; `spread` is a new type: a dict of attributes that, when returned as a child
|
|
;; of an HTML element, merges its attrs onto the parent element rather than
|
|
;; rendering as content. This enables components like `~cssx/tw` to inject
|
|
;; classes and styles onto their parent from inside the child list.
|
|
;;
|
|
;; `collect!` / `collected` are render-time accumulators. Values are collected
|
|
;; into named buckets (with deduplication) during rendering and retrieved at
|
|
;; flush points (e.g. a single <style> tag for all collected CSS rules).
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(declare-tier :spread :source "render.sx")
|
|
|
|
(declare-spread-primitive "make-spread"
|
|
:params (attrs)
|
|
:returns "spread"
|
|
:effects []
|
|
:doc "Create a spread value from an attrs dict. When this value appears as
|
|
a child of an HTML element, its attrs are merged onto the parent
|
|
element (class values joined, others overwritten).")
|
|
|
|
(declare-spread-primitive "spread?"
|
|
:params (x)
|
|
:returns "boolean"
|
|
:effects []
|
|
:doc "Test whether a value is a spread.")
|
|
|
|
(declare-spread-primitive "spread-attrs"
|
|
:params (s)
|
|
:returns "dict"
|
|
:effects []
|
|
:doc "Extract the attrs dict from a spread value.")
|
|
|
|
(declare-spread-primitive "collect!"
|
|
:params (bucket value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Add value to a named render-time accumulator bucket. Values are
|
|
deduplicated (no duplicates added). Buckets persist for the duration
|
|
of the current render pass.")
|
|
|
|
(declare-spread-primitive "collected"
|
|
:params (bucket)
|
|
:returns "list"
|
|
:effects []
|
|
:doc "Return all values collected in the named bucket during the current
|
|
render pass. Returns an empty list if the bucket doesn't exist.")
|
|
|
|
(declare-spread-primitive "clear-collected!"
|
|
:params (bucket)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Clear a named render-time accumulator bucket. Used at flush points
|
|
after emitting collected values (e.g. after writing a <style> tag).")
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Tier 5: Dynamic scope — render-time provide/context/emit!
|
|
;;
|
|
;; `provide` is a special form (not a primitive) that creates a named scope
|
|
;; with a value and an empty accumulator. `context` reads the value from the
|
|
;; nearest enclosing provider. `emit!` appends to the accumulator, `emitted`
|
|
;; reads the accumulated values.
|
|
;;
|
|
;; The platform must implement per-name stacks. Each entry has a value and
|
|
;; an emitted list. `provide-push!`/`provide-pop!` manage the stack.
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(declare-tier :dynamic-scope :source "eval.sx")
|
|
|
|
(declare-spread-primitive "provide-push!"
|
|
:params (name value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Push a provider scope with name and value (platform internal).")
|
|
|
|
(declare-spread-primitive "provide-pop!"
|
|
:params (name)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Pop the most recent provider scope for name (platform internal).")
|
|
|
|
(declare-spread-primitive "context"
|
|
:params (name &rest default)
|
|
:returns "any"
|
|
:effects []
|
|
:doc "Read value from nearest enclosing provide with matching name.
|
|
Errors if no provider and no default given.")
|
|
|
|
(declare-spread-primitive "emit!"
|
|
:params (name value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Append value to nearest enclosing provide's accumulator.
|
|
Errors if no matching provider. No deduplication.")
|
|
|
|
(declare-spread-primitive "emitted"
|
|
:params (name)
|
|
:returns "list"
|
|
:effects []
|
|
:doc "Return list of values emitted into nearest matching provider.
|
|
Empty list if no provider.")
|