All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m49s
Three-layer architecture:
spec/ — Core language (19 files): evaluator, parser, primitives,
CEK machine, types, continuations. Host-independent.
web/ — Web framework (20 files): signals, adapters, engine,
orchestration, boot, router, CSSX. Built on core spec.
sx/ — Application (sx-docs website). Built on web framework.
Split boundary.sx into boundary-core.sx (type-of, make-env, identical?)
and boundary-web.sx (IO primitives, signals, spreads, page helpers).
Bootstrappers search spec/ → web/ → shared/sx/ref/ for .sx files.
Original files remain in shared/sx/ref/ as fallback during transition.
All 63 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
415 lines
12 KiB
Plaintext
415 lines
12 KiB
Plaintext
;; ==========================================================================
|
|
;; boundary-web.sx — Web platform boundary contract
|
|
;;
|
|
;; I/O primitives, signals, spreads, scopes, and page helpers
|
|
;; required by the SX web framework. Built on the core spec.
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 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: Scoped effects — unified render-time dynamic scope
|
|
;;
|
|
;; `scope` is the general primitive. `provide` is sugar for scope-with-value.
|
|
;; Both `provide` and `scope` are special forms in the evaluator.
|
|
;;
|
|
;; The platform must implement per-name stacks. Each entry has a value,
|
|
;; an emitted list, and a dedup flag. `scope-push!`/`scope-pop!` manage
|
|
;; the stack. `provide-push!`/`provide-pop!` are aliases.
|
|
;;
|
|
;; `collect!`/`collected`/`clear-collected!` (Tier 4) are backed by scopes:
|
|
;; collect! lazily creates a root scope with dedup=true, then emits into it.
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(declare-tier :scoped-effects :source "eval.sx")
|
|
|
|
(declare-spread-primitive "scope-push!"
|
|
:params (name value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Push a scope with name and value. General form — provide-push! is an alias.")
|
|
|
|
(declare-spread-primitive "scope-pop!"
|
|
:params (name)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Pop the most recent scope for name. General form — provide-pop! is an alias.")
|
|
|
|
(declare-spread-primitive "provide-push!"
|
|
:params (name value)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Alias for scope-push!. Push a scope with name and value.")
|
|
|
|
(declare-spread-primitive "provide-pop!"
|
|
:params (name)
|
|
:returns "nil"
|
|
:effects [mutation]
|
|
:doc "Alias for scope-pop!. Pop the most recent scope for name.")
|
|
|
|
(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.")
|