diff --git a/hosts/javascript/platform.py b/hosts/javascript/platform.py index dbf0af6..d35467c 100644 --- a/hosts/javascript/platform.py +++ b/hosts/javascript/platform.py @@ -1175,6 +1175,18 @@ PRIMITIVES_JS_MODULES: dict[str, str] = { lst[idx] = val; return NIL; }; + // Cookie access — isomorphic state persistence + PRIMITIVES["set-cookie"] = function(name, value, days) { + var d = days || 365; + var expires = new Date(Date.now() + d * 864e5).toUTCString(); + document.cookie = name + "=" + encodeURIComponent(value) + ";expires=" + expires + ";path=/;SameSite=Lax"; + return NIL; + }; + PRIMITIVES["get-cookie"] = function(name) { + var m = document.cookie.match(new RegExp("(?:^|;\\\\s*)" + name + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : NIL; + }; + PRIMITIVES["env-parent"] = function(env) { if (env && Object.getPrototypeOf(env) !== Object.prototype && Object.getPrototypeOf(env) !== null) diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index ccd38af..be38e02 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -127,6 +127,23 @@ let io_counter = ref 0 (** Module-level scope stacks — shared between make_server_env (aser scope-push!/pop!) and step-sf-context (via get-primitive "scope-peek"). *) +(** Request cookies — set by Python bridge before each page render. + get-cookie reads from here on the server; set-cookie is a no-op + (server can't set response cookies from SX — that's the framework's job). *) +let _request_cookies : (string, string) Hashtbl.t = Hashtbl.create 8 + +let () = Sx_primitives.register "get-cookie" (fun args -> + match args with + | [String name] -> + (match Hashtbl.find_opt _request_cookies name with + | Some v -> String v + | None -> Nil) + | _ -> Nil) + +let () = Sx_primitives.register "set-cookie" (fun _args -> + (* No-op on server — cookies are set via HTTP response headers *) + Nil) + let _scope_stacks : (string, value list) Hashtbl.t = Hashtbl.create 8 let () = Sx_primitives.register "scope-push!" (fun args -> @@ -1205,6 +1222,15 @@ let rec dispatch env cmd = jit_allowlist := StringSet.add name !jit_allowlist; send_ok () + | List [Symbol "set-request-cookies"; Dict cookies] -> + (* Set request cookies for get-cookie primitive. + Called by Python bridge before each page render. *) + Hashtbl.clear _request_cookies; + Hashtbl.iter (fun k v -> + match v with String s -> Hashtbl.replace _request_cookies k s | _ -> () + ) cookies; + send_ok () + | List [Symbol "aser-slot"; String src] -> (* Expand ALL components server-side. Uses batch IO mode. Calls aser via CEK — the JIT hook compiles it on first call. *) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index da8d59d..92243d3 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-24T02:56:45Z"; + var SX_VERSION = "2026-03-24T04:10:36Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -555,6 +555,18 @@ lst[idx] = val; return NIL; }; + // Cookie access — isomorphic state persistence + PRIMITIVES["set-cookie"] = function(name, value, days) { + var d = days || 365; + var expires = new Date(Date.now() + d * 864e5).toUTCString(); + document.cookie = name + "=" + encodeURIComponent(value) + ";expires=" + expires + ";path=/;SameSite=Lax"; + return NIL; + }; + PRIMITIVES["get-cookie"] = function(name) { + var m = document.cookie.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : NIL; + }; + PRIMITIVES["env-parent"] = function(env) { if (env && Object.getPrototypeOf(env) !== Object.prototype && Object.getPrototypeOf(env) !== null) diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 184eb3e..85b56cf 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -205,6 +205,27 @@ class OcamlBridge: self._shell_statics_injected = True _logger.info("Injected shell statics into OCaml kernel") + async def _inject_request_cookies_locked(self) -> None: + """Send current request cookies to kernel for get-cookie primitive.""" + try: + from quart import request + cookies = request.cookies + except Exception: + return # no request context (CLI mode, tests) + if not cookies: + return + # Build SX dict: {:name1 "val1" :name2 "val2"} + pairs = [] + for k, v in cookies.items(): + pairs.append(f':{k} "{_escape(str(v))}"') + if pairs: + cmd = f'(set-request-cookies {{{" ".join(pairs)}}})' + try: + await self._send(cmd) + await self._read_until_ok(ctx=None) + except OcamlBridgeError as e: + _logger.debug("Cookie inject failed: %s", e) + async def sx_page_full( self, page_source: str, @@ -221,6 +242,8 @@ class OcamlBridge: async with self._lock: await self._inject_helpers_locked() await self._inject_shell_statics_locked() + # Send request cookies so get-cookie works during SSR + await self._inject_request_cookies_locked() # Large/complex blobs use placeholders — OCaml renders the shell # with short tokens; Python splices in the real values post-render. # This avoids piping large strings or strings with special chars diff --git a/sx/sx/home-stepper.sx b/sx/sx/home-stepper.sx index 9eb9884..95ee512 100644 --- a/sx/sx/home-stepper.sx +++ b/sx/sx/home-stepper.sx @@ -1,62 +1,10 @@ -;; steps-to-preview — pure function: replay step machine as SX expression tree. -;; Given a list of steps and a target index, build the SX that represents -;; the partial render at that step. Works on both server and client. -(define steps-to-preview - (fn (all-steps target) - (if (or (empty? all-steps) (<= target 0)) - nil - ;; Use mutable lists for the stacks so append!/init work correctly - (let ((stack (list (list))) - (tag-stack (list))) - ;; Replay steps 0..target-1 - (for-each (fn (step) - (let ((step-type (get step "type"))) - (cond - (= step-type "open") - (do (append! stack (list)) - (append! tag-stack (get step "tag"))) - (= step-type "close") - (when (> (len stack) 1) - (let ((children (last stack)) - (tag (last tag-stack)) - (attrs (or (get step "open-attrs") (list))) - (spreads (or (get step "open-spreads") (list))) - (expr (concat (list (make-symbol tag)) attrs spreads children))) - ;; Pop stack: remove last, append expr to new last - (set! stack (init stack)) - (set! tag-stack (init tag-stack)) - (append! (last stack) expr))) - (= step-type "leaf") - (when (not (empty? stack)) - (append! (last stack) (get step "expr"))) - (= step-type "expr") - (when (not (empty? stack)) - (append! (last stack) (get step "expr")))))) - (slice all-steps 0 (min target (len all-steps)))) - ;; Close any unclosed elements - (let close-loop () - (when (> (len stack) 1) - (let ((children (last stack)) - (tag (last tag-stack)) - (expr (concat (list (make-symbol tag)) children))) - (set! stack (init stack)) - (set! tag-stack (init tag-stack)) - (append! (last stack) expr) - (close-loop)))) - ;; Return root content - (let ((root (first stack))) - (cond - (= (len root) 1) (first root) - (empty? root) nil - :else (concat (list (make-symbol "<>")) root))))))) - (defisland ~home/stepper () (let ((source "(div (~cssx/tw :tokens \"text-center\")\n (h1 (~cssx/tw :tokens \"text-3xl font-bold mb-2\")\n (span (~cssx/tw :tokens \"text-rose-500\") \"the \")\n (span (~cssx/tw :tokens \"text-amber-500\") \"joy \")\n (span (~cssx/tw :tokens \"text-emerald-500\") \"of \")\n (span (~cssx/tw :tokens \"text-violet-600 text-4xl\") \"sx\")))") (steps (signal (list))) (step-idx (signal 9)) (dom-stack-sig (signal (list))) (code-tokens (signal (list))) - (code-spans (list))) +) (letrec ((split-tag (fn (expr result) (cond @@ -147,7 +95,7 @@ (dict-set! step-ref "v" (+ (get step-ref "v") 1))))) :else nil))) (get-preview (fn () (dom-query "[data-sx-lake=\"home-preview\"]"))) - (get-code-view (fn () (dom-query "[data-sx-lake=\"code-view\"]"))) + (get-code-view (fn () (dom-query "[data-code-view]"))) (get-stack (fn () (deref dom-stack-sig))) (set-stack (fn (v) (reset! dom-stack-sig v))) (push-stack (fn (el) (reset! dom-stack-sig (append (deref dom-stack-sig) (list el))))) @@ -155,35 +103,33 @@ (let ((s (deref dom-stack-sig))) (when (> (len s) 1) (reset! dom-stack-sig (slice s 0 (- (len s) 1))))))) - (build-code-dom (fn () - (when (and (empty? code-spans) (not (empty? (deref code-tokens)))) - (let ((code-el (get-code-view))) - (when code-el - (dom-set-prop code-el "innerHTML" "") - (for-each (fn (tok) - (let ((sp (dom-create-element "span" nil))) - (dom-set-attr sp "class" (get tok "cls")) - (dom-set-prop sp "textContent" (get tok "text")) - (dom-append code-el sp) - (append! code-spans (dict "el" sp "step" (get tok "step") "cls" (get tok "cls") "spread" (get tok "spread"))))) - (deref code-tokens))))))) + ;; build-code-dom and update-code-highlight removed — + ;; the code view is now reactive DOM bound to step-idx signal. + ;; No imperative DOM manipulation needed. + (build-code-dom (fn () nil)) (update-code-highlight (fn () - (let ((cur (deref step-idx))) - (for-each (fn (s) - (let ((step-num (get s "step")) - (el (get s "el")) - (base (get s "cls"))) - (when (not (= step-num -1)) - (dom-set-attr el "class" - (str base - (let ((is-spread (get s "spread"))) - (cond - (and (= step-num cur) is-spread) " opacity-60" - (= step-num cur) " bg-amber-100 rounded px-0.5 font-bold text-sm" - (and (< step-num cur) is-spread) " opacity-60" - (< step-num cur) " font-bold text-xs" - :else " opacity-40"))))))) - code-spans)))) + (let ((code-el (get-code-view)) + (cur (deref step-idx)) + (tokens (deref code-tokens))) + (when (and code-el (not (empty? tokens))) + (let ((spans (dom-query-all code-el "span"))) + (for-each (fn (i) + (when (< i (len tokens)) + (let ((sp (nth spans i)) + (tok (nth tokens i)) + (step-num (get tok "step")) + (base (get tok "cls")) + (is-spread (get tok "spread"))) + (when (not (= step-num -1)) + (dom-set-attr sp "class" + (str base + (cond + (and (= step-num cur) is-spread) " opacity-60" + (= step-num cur) " bg-amber-100 rounded px-0.5 font-bold text-sm" + (and (< step-num cur) is-spread) " opacity-60" + (< step-num cur) " font-bold text-xs" + :else " opacity-40"))))))) + (range 0 (min (len spans) (len tokens))))))))) (do-step (fn () (build-code-dom) (when (< (deref step-idx) (len (deref steps))) @@ -227,7 +173,7 @@ (dom-append parent rendered))))) (swap! step-idx inc) (update-code-highlight) - (local-storage-set "sx-home-stepper" (freeze-to-sx "home-stepper"))))) + (set-cookie "sx-home-stepper" (freeze-to-sx "home-stepper"))))) (do-back (fn () (when (> (deref step-idx) 0) (let ((target (- (deref step-idx) 1)) @@ -236,15 +182,14 @@ (set-stack (list (get-preview))) (reset! step-idx 0) (for-each (fn (_) (do-step)) (slice (deref steps) 0 target)) - (local-storage-set "sx-home-stepper" (freeze-to-sx "home-stepper"))))))) - ;; Freeze scope for persistence + (set-cookie "sx-home-stepper" (freeze-to-sx "home-stepper"))))))) + ;; Freeze scope for persistence — same mechanism, cookie storage (freeze-scope "home-stepper" (fn () (freeze-signal "step" step-idx))) - ;; Restore from localStorage on mount - (let ((saved (local-storage-get "sx-home-stepper"))) + ;; Restore from cookie on mount (server reads cookie too for SSR) + (let ((saved (get-cookie "sx-home-stepper"))) (when saved (thaw-from-sx saved) - ;; Validate — reset to default if out of range (when (or (< (deref step-idx) 0) (> (deref step-idx) 16)) (reset! step-idx 9)))) ;; Parse source eagerly (pure computation — works in SSR and client) @@ -272,10 +217,10 @@ (run-post-render-hooks))))))) (div :class "space-y-4" ;; Code view lake — SSR renders tokenized code with highlighting - (div (~cssx/tw :tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap") + (div :data-code-view true + (~cssx/tw :tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap") :style "font-size:0.5rem" - (lake :id "code-view" - (map (fn (tok) + (map (fn (tok) (let ((step (get tok "step")) (cur (deref step-idx)) (is-spread (get tok "spread")) @@ -288,7 +233,7 @@ (< step cur) " font-bold text-xs" :else " opacity-40")))) (span :class cls (get tok "text")))) - (deref code-tokens)))) + (deref code-tokens))) ;; Controls (div :class "flex items-center justify-center gap-2 md:gap-3" (button :on-click (fn (e) (do-back)) @@ -305,8 +250,14 @@ "text-violet-600 hover:text-violet-800 hover:bg-violet-50" "text-violet-300 cursor-not-allowed")) "\u25b6")) - ;; Live preview — declarative: same SX rendered by server (HTML) and client (DOM). - ;; steps-to-preview replays the stack machine as SX expressions. + ;; Live preview lake — client builds incrementally via do-step effect. + ;; SSR shows the full result; client effect replays 0→N for animation. (lake :id "home-preview" - (steps-to-preview (deref steps) (deref step-idx)))))))) + (div (~cssx/tw :tokens "text-center") + (h1 (~cssx/tw :tokens "text-3xl font-bold mb-2") + (span (~cssx/tw :tokens "text-rose-500") "the ") + (span (~cssx/tw :tokens "text-amber-500") "joy ") + (span (~cssx/tw :tokens "text-emerald-500") "of ") + (span (~cssx/tw :tokens "text-violet-600 text-4xl") "sx"))))))))) +