diff --git a/docker-compose.dev-sx.yml b/docker-compose.dev-sx.yml index 7273494..5d5c5a7 100644 --- a/docker-compose.dev-sx.yml +++ b/docker-compose.dev-sx.yml @@ -17,6 +17,8 @@ services: SX_OCAML_BIN: "/app/bin/sx_server" SX_BOUNDARY_STRICT: "1" SX_DEV: "1" + ports: + - "8013:8000" volumes: - /root/rose-ash/_config/dev-sh-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared diff --git a/hosts/javascript/platform.py b/hosts/javascript/platform.py index 3139b59..4ba2fe6 100644 --- a/hosts/javascript/platform.py +++ b/hosts/javascript/platform.py @@ -1130,8 +1130,10 @@ PRIMITIVES_JS_MODULES: dict[str, str] = { PRIMITIVES["emit!"] = sxEmit; PRIMITIVES["emitted"] = sxEmitted; // Aliases for aser adapter (avoids CEK special form conflict on server) - PRIMITIVES["scope-emit!"] = sxEmit; - PRIMITIVES["scope-peek"] = sxEmitted; + var scopeEmit = sxEmit; + var scopePeek = sxEmitted; + PRIMITIVES["scope-emit!"] = scopeEmit; + PRIMITIVES["scope-peek"] = scopePeek; ''', } # Modules to include by default (all) @@ -1170,6 +1172,7 @@ PLATFORM_JS_PRE = ''' if (x._spread) return "spread"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._sx_expr) return "sx-expr"; if (typeof Node !== "undefined" && x instanceof Node) return "dom-node"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; @@ -1441,6 +1444,7 @@ PLATFORM_JS_POST = ''' // escape-html and escape-attr are now library functions defined in render.sx function rawHtmlContent(r) { return r.html; } function makeRawHtml(s) { return { _raw: true, html: s }; } + function makeSxExpr(s) { return { _sx_expr: true, source: s }; } function sxExprSource(x) { return x && x.source ? x.source : String(x); } // Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx @@ -1669,7 +1673,8 @@ CEK_FIXUPS_JS = ''' PRIMITIVES["island?"] = isIsland; PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); }; PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; }; - PRIMITIVES["make-env"] = function() { return merge(componentEnv, PRIMITIVES); }; + function makeEnv() { return merge(componentEnv, PRIMITIVES); } + PRIMITIVES["make-env"] = makeEnv; // localStorage — defined here (before boot) so islands can use at hydration PRIMITIVES["local-storage-get"] = function(key) { @@ -1792,7 +1797,7 @@ PLATFORM_PARSER_JS = r""" function escapeString(s) { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t"); } - function sxExprSource(e) { return typeof e === "string" ? e : String(e); } + function sxExprSource(e) { return typeof e === "string" ? e : (e && e.source ? e.source : String(e)); } var charFromCode = PRIMITIVES["char-from-code"]; """ @@ -2298,7 +2303,10 @@ PLATFORM_ORCHESTRATION_JS = """ } } }); - }).catch(function() { location.reload(); }); + }).catch(function(err) { + logWarn("sx:popstate fetch error " + url + " — " + (err && err.message ? err.message : err)); + location.reload(); + }); } function fetchStreaming(target, url, headers) { @@ -2436,7 +2444,9 @@ PLATFORM_ORCHESTRATION_JS = """ return resp.text().then(function(text) { preloadCacheSet(cache, url, text, ct); }); - }).catch(function() { /* ignore */ }); + }).catch(function(err) { + logInfo("sx:preload error " + url + " — " + (err && err.message ? err.message : err)); + }); } // --- Request body building --- @@ -2725,6 +2735,8 @@ PLATFORM_ORCHESTRATION_JS = """ var liveAction = form.getAttribute("action") || _action || location.href; executeRequest(form, { method: liveMethod, url: liveAction }).then(function() { try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {} + }).catch(function(err) { + logWarn("sx:boost form error " + liveMethod + " " + liveAction + " — " + (err && err.message ? err.message : err)); }); }); } diff --git a/hosts/javascript/run_tests.js b/hosts/javascript/run_tests.js index d5e676f..98cac29 100644 --- a/hosts/javascript/run_tests.js +++ b/hosts/javascript/run_tests.js @@ -218,6 +218,19 @@ env["component-has-children"] = function(c) { return c && c.has_children ? c.has_children : false; }; +// Aser test helper: parse SX source, evaluate via aser, return wire format string +env["render-sx"] = function(source) { + const exprs = Sx.parse(source); + const parts = []; + for (const expr of exprs) { + const result = Sx.renderToSx(expr, env); + if (result !== null && result !== undefined && result !== Sx.NIL) { + parts.push(typeof result === "string" ? result : Sx.serialize(result)); + } + } + return parts.join(""); +}; + // Platform test functions env["try-call"] = function(thunk) { try { diff --git a/hosts/javascript/transpiler.sx b/hosts/javascript/transpiler.sx index 32557e1..0fc612e 100644 --- a/hosts/javascript/transpiler.sx +++ b/hosts/javascript/transpiler.sx @@ -54,6 +54,8 @@ "make-action-def" "makeActionDef" "make-page-def" "makePageDef" "make-symbol" "makeSymbol" + "make-env" "makeEnv" + "make-sx-expr" "makeSxExpr" "make-keyword" "makeKeyword" "lambda-params" "lambdaParams" "lambda-body" "lambdaBody" @@ -163,6 +165,7 @@ "aser-special" "aserSpecial" "eval-case-aser" "evalCaseAser" "sx-serialize" "sxSerialize" + "sx-serialize-dict" "sxSerializeDict" "sx-expr-source" "sxExprSource" "sf-if" "sfIf" @@ -620,6 +623,8 @@ "cond-scheme?" "condScheme_p" "scope-push!" "scopePush" "scope-pop!" "scopePop" + "scope-emit!" "scopeEmit" + "scope-peek" "scopePeek" "provide-push!" "providePush" "provide-pop!" "providePop" "context" "sxContext" diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index cbbf350..a7e05ae 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -97,6 +97,29 @@ let read_line_blocking () = try Some (input_line stdin) with End_of_file -> None +(** Read exactly N bytes from stdin (blocking). *) +let read_exact_bytes n = + let buf = Bytes.create n in + really_input stdin buf 0 n; + Bytes.to_string buf + +(** Read a length-prefixed blob from stdin. + Expects the next line to be "(blob N)" where N is byte count, + followed by exactly N bytes of raw data, then a newline. *) +let read_blob () = + match read_line_blocking () with + | None -> raise (Eval_error "read_blob: stdin closed") + | Some line -> + let line = String.trim line in + match Sx_parser.parse_all line with + | [List [Symbol "blob"; Number n]] -> + let len = int_of_float n in + let data = read_exact_bytes len in + (* consume trailing newline *) + (try ignore (input_line stdin) with End_of_file -> ()); + data + | _ -> raise (Eval_error ("read_blob: expected (blob N), got: " ^ line)) + (** Batch IO mode — collect requests during aser-slot, resolve after. *) let io_batch_mode = ref false let io_queue : (int * string * value list) list ref = ref [] @@ -120,8 +143,8 @@ let io_request name args = incr io_counter; let id = !io_counter in io_queue := (id, name, args) :: !io_queue; - (* Placeholder starts with ( so aser inlines it as pre-serialized SX *) - String (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id) + (* Return SxExpr so serialize/inspect passes it through unquoted *) + SxExpr (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id) end else begin let args_str = String.concat " " (List.map serialize_value args) in send (Printf.sprintf "(io-request \"%s\" %s)" name args_str); @@ -539,6 +562,17 @@ let make_server_env () = | [a; b] -> Bool (a == b) | _ -> raise (Eval_error "identical?: expected 2 args")); + bind "make-sx-expr" (fun args -> + match args with + | [String s] -> SxExpr s + | _ -> raise (Eval_error "make-sx-expr: expected string")); + + bind "sx-expr-source" (fun args -> + match args with + | [SxExpr s] -> String s + | [String s] -> String s + | _ -> raise (Eval_error "sx-expr-source: expected sx-expr or string")); + bind "make-continuation" (fun args -> match args with | [f] -> @@ -760,7 +794,7 @@ let compile_adapter env = (* Command dispatch *) (* ====================================================================== *) -let dispatch env cmd = +let rec dispatch env cmd = match cmd with | List [Symbol "ping"] -> send_ok_string "ocaml-cek" @@ -792,6 +826,10 @@ let dispatch env cmd = | Eval_error msg -> send_error msg | exn -> send_error (Printexc.to_string exn)) + | List [Symbol "eval-blob"] -> + let src = read_blob () in + dispatch env (List [Symbol "eval"; String src]) + | List [Symbol "eval"; String src] -> (try let exprs = Sx_parser.parse_all src in @@ -827,6 +865,16 @@ let dispatch env cmd = | Eval_error msg -> send_error msg | exn -> send_error (Printexc.to_string exn)) + | List [Symbol "aser-blob"] -> + (* Like aser but reads source as a binary blob. *) + let src = read_blob () in + dispatch env (List [Symbol "aser"; String src]) + + | List [Symbol "aser-slot-blob"] -> + (* Like aser-slot but reads source as a binary blob. *) + let src = read_blob () in + dispatch env (List [Symbol "aser-slot"; String src]) + | List [Symbol "aser"; String src] -> (* Evaluate and serialize as SX wire format. Calls the SX-defined aser function from adapter-sx.sx. @@ -920,6 +968,12 @@ let dispatch env cmd = Hashtbl.remove env.bindings "expand-components?"; send_error (Printexc.to_string exn)) + | List (Symbol "sx-page-full-blob" :: shell_kwargs) -> + (* Like sx-page-full but reads page source as a length-prefixed blob + from the next line(s), avoiding string-escape round-trip issues. *) + let page_src = read_blob () in + dispatch env (List (Symbol "sx-page-full" :: String page_src :: shell_kwargs)) + | List (Symbol "sx-page-full" :: String page_src :: shell_kwargs) -> (* Full page render: aser-slot body + render-to-html shell in ONE call. shell_kwargs are keyword pairs: :title "..." :csrf "..." etc. @@ -1167,6 +1221,10 @@ let cli_mode mode = let result = Sx_ref.eval_expr call (Env env) in (match result with | String s | SxExpr s -> print_string s + | Dict d when Hashtbl.mem d "__aser_sx" -> + (match Hashtbl.find d "__aser_sx" with + | String s | SxExpr s -> print_string s + | v -> print_string (serialize_value v)) | _ -> print_string (serialize_value result)); flush stdout | "aser-slot" -> @@ -1180,6 +1238,10 @@ let cli_mode mode = let result = Sx_ref.eval_expr call (Env env) in (match result with | String s | SxExpr s -> print_string s + | Dict d when Hashtbl.mem d "__aser_sx" -> + (match Hashtbl.find d "__aser_sx" with + | String s | SxExpr s -> print_string s + | v -> print_string (serialize_value v)) | _ -> print_string (serialize_value result)); flush stdout | _ -> diff --git a/hosts/ocaml/lib/sx_types.ml b/hosts/ocaml/lib/sx_types.ml index 79eea77..f96dc41 100644 --- a/hosts/ocaml/lib/sx_types.ml +++ b/hosts/ocaml/lib/sx_types.ml @@ -393,7 +393,18 @@ let rec inspect = function | Number n -> if Float.is_integer n then Printf.sprintf "%d" (int_of_float n) else Printf.sprintf "%g" n - | String s -> Printf.sprintf "%S" s + | String s -> + let buf = Buffer.create (String.length s + 2) in + Buffer.add_char buf '"'; + String.iter (function + | '"' -> Buffer.add_string buf "\\\"" + | '\\' -> Buffer.add_string buf "\\\\" + | '\n' -> Buffer.add_string buf "\\n" + | '\r' -> Buffer.add_string buf "\\r" + | '\t' -> Buffer.add_string buf "\\t" + | c -> Buffer.add_char buf c) s; + Buffer.add_char buf '"'; + Buffer.contents buf | Symbol s -> s | Keyword k -> ":" ^ k | List items | ListRef { contents = items } -> diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 8116c81..478af93 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-19T14:05:23Z"; + var SX_VERSION = "2026-03-22T20:34:05Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -111,6 +111,7 @@ if (x._spread) return "spread"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._sx_expr) return "sx-expr"; if (typeof Node !== "undefined" && x instanceof Node) return "dom-node"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; @@ -510,8 +511,10 @@ PRIMITIVES["emit!"] = sxEmit; PRIMITIVES["emitted"] = sxEmitted; // Aliases for aser adapter (avoids CEK special form conflict on server) - PRIMITIVES["scope-emit!"] = sxEmit; - PRIMITIVES["scope-peek"] = sxEmitted; + var scopeEmit = sxEmit; + var scopePeek = sxEmitted; + PRIMITIVES["scope-emit!"] = scopeEmit; + PRIMITIVES["scope-peek"] = scopePeek; function isPrimitive(name) { return name in PRIMITIVES; } @@ -592,6 +595,7 @@ // escape-html and escape-attr are now library functions defined in render.sx function rawHtmlContent(r) { return r.html; } function makeRawHtml(s) { return { _raw: true, html: s }; } + function makeSxExpr(s) { return { _sx_expr: true, source: s }; } function sxExprSource(x) { return x && x.source ? x.source : String(x); } // Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx @@ -848,7 +852,7 @@ function escapeString(s) { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t"); } - function sxExprSource(e) { return typeof e === "string" ? e : String(e); } + function sxExprSource(e) { return typeof e === "string" ? e : (e && e.source ? e.source : String(e)); } var charFromCode = PRIMITIVES["char-from-code"]; @@ -1646,7 +1650,7 @@ PRIMITIVES["step-sf-deref"] = stepSfDeref; // cek-call var cekCall = function(f, args) { return (function() { var a = (isSxTruthy(isNil(args)) ? [] : args); - return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, {}, a, [])) : NIL)); + return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, makeEnv(), a, [])) : NIL)); })(); }; PRIMITIVES["cek-call"] = cekCall; @@ -2655,7 +2659,7 @@ PRIMITIVES["serialize-island-state"] = serializeIslandState; // render-to-sx var renderToSx = function(expr, env) { return (function() { var result = aser(expr, env); - return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result)); + return (isSxTruthy((typeOf(result) == "sx-expr")) ? sxExprSource(result) : (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result))); })(); }; PRIMITIVES["render-to-sx"] = renderToSx; @@ -2665,8 +2669,8 @@ return (function() { var result = (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { var name = symbolName(expr); return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); -})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (scopeEmit_b("element-attrs", spreadAttrs(expr)), NIL); return expr; })(); - return (isSxTruthy(isSpread(result)) ? (scopeEmit_b("element-attrs", spreadAttrs(result)), NIL) : result); +})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (scopeEmit("element-attrs", spreadAttrs(expr)), NIL); return expr; })(); + return (isSxTruthy(isSpread(result)) ? (scopeEmit("element-attrs", spreadAttrs(result)), NIL) : result); })(); }; PRIMITIVES["aser"] = aser; @@ -2694,9 +2698,9 @@ PRIMITIVES["aser-list"] = aserList; var parts = []; { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { var result = aser(c, env); - return (isSxTruthy(isNil(result)) ? NIL : (isSxTruthy((isSxTruthy((typeOf(result) == "string")) && isSxTruthy((stringLength(result) > 0)) && startsWith(result, "("))) ? append_b(parts, result) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(parts, item) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result))))); + return (isSxTruthy(isNil(result)) ? NIL : (isSxTruthy((typeOf(result) == "sx-expr")) ? append_b(parts, sxExprSource(result)) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(parts, sxExprSource(item)) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result))))); })(); } } - return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? first(parts) : (String("(<> ") + String(join(" ", parts)) + String(")")))); + return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? makeSxExpr(first(parts)) : makeSxExpr((String("(<> ") + String(join(" ", parts)) + String(")"))))); })(); }; PRIMITIVES["aser-fragment"] = aserFragment; @@ -2711,14 +2715,14 @@ PRIMITIVES["aser-fragment"] = aserFragment; var val = aser(nth(args, (i + 1)), env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { attrParts.push((String(":") + String(keywordName(arg)))); - (isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(attrParts, val) : append_b(attrParts, serialize(val))); + (isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(attrParts, sxExprSource(val)) : append_b(attrParts, serialize(val))); } skip = true; return (i = (i + 1)); })() : (function() { var val = aser(arg, env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { - (isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(childParts, val) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(childParts, item) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val)))); + (isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(childParts, sxExprSource(val)) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(childParts, sxExprSource(item)) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val)))); } return (i = (i + 1)); })())); } } @@ -2730,7 +2734,7 @@ PRIMITIVES["aser-fragment"] = aserFragment; scopePop("element-attrs"); return (function() { var parts = concat([name], attrParts, childParts); - return (String("(") + String(join(" ", parts)) + String(")")); + return makeSxExpr((String("(") + String(join(" ", parts)) + String(")"))); })(); })(); }; PRIMITIVES["aser-call"] = aserCall; @@ -3952,7 +3956,7 @@ PRIMITIVES["execute-request"] = executeRequest; domAddClass(el, "sx-request"); domSetAttr(el, "aria-busy", "true"); domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method}); - return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? (logWarn((String("sx:fetch error ") + String(method) + String(" ") + String(finalUrl) + String(" — ") + String(err))), domDispatch(el, "sx:requestError", {["error"]: err})) : NIL)); }); })(); })(); })(); @@ -5866,7 +5870,8 @@ PRIMITIVES["resource"] = resource; PRIMITIVES["island?"] = isIsland; PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); }; PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; }; - PRIMITIVES["make-env"] = function() { return merge(componentEnv, PRIMITIVES); }; + function makeEnv() { return merge(componentEnv, PRIMITIVES); } + PRIMITIVES["make-env"] = makeEnv; // localStorage — defined here (before boot) so islands can use at hydration PRIMITIVES["local-storage-get"] = function(key) { @@ -6393,7 +6398,10 @@ PRIMITIVES["resource"] = resource; } } }); - }).catch(function() { location.reload(); }); + }).catch(function(err) { + logWarn("sx:popstate fetch error " + url + " — " + (err && err.message ? err.message : err)); + location.reload(); + }); } function fetchStreaming(target, url, headers) { @@ -6531,7 +6539,9 @@ PRIMITIVES["resource"] = resource; return resp.text().then(function(text) { preloadCacheSet(cache, url, text, ct); }); - }).catch(function() { /* ignore */ }); + }).catch(function(err) { + logInfo("sx:preload error " + url + " — " + (err && err.message ? err.message : err)); + }); } // --- Request body building --- @@ -6820,6 +6830,8 @@ PRIMITIVES["resource"] = resource; var liveAction = form.getAttribute("action") || _action || location.href; executeRequest(form, { method: liveMethod, url: liveAction }).then(function() { try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {} + }).catch(function(err) { + logWarn("sx:boost form error " + liveMethod + " " + liveAction + " — " + (err && err.message ? err.message : err)); }); }); } diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 5ebdfc7..b28accd 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -41,6 +41,7 @@ class OcamlBridge: self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN self._proc: asyncio.subprocess.Process | None = None self._lock = asyncio.Lock() + self._in_io_handler = False # re-entrancy guard self._started = False self._components_loaded = False self._helpers_injected = False @@ -123,7 +124,8 @@ class OcamlBridge: """ await self._ensure_components() async with self._lock: - await self._send(f'(eval "{_escape(source)}")') + await self._send('(eval-blob)') + await self._send_blob(source) return await self._read_until_ok(ctx) async def render( @@ -141,7 +143,8 @@ class OcamlBridge: """Evaluate SX and return SX wire format, handling io-requests.""" await self._ensure_components() async with self._lock: - await self._send(f'(aser "{_escape(source)}")') + await self._send('(aser-blob)') + await self._send_blob(source) return await self._read_until_ok(ctx) async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str: @@ -156,7 +159,8 @@ class OcamlBridge: # a separate lock acquisition could let another coroutine # interleave commands between injection and aser-slot. await self._inject_helpers_locked() - await self._send(f'(aser-slot "{_escape(source)}")') + await self._send('(aser-slot-blob)') + await self._send_blob(source) return await self._read_until_ok(ctx) _shell_statics_injected: bool = False @@ -227,7 +231,10 @@ class OcamlBridge: static_keys = {"component_hash", "sx_css_classes", "asset_url", "sx_js_hash", "body_js_hash", "head_scripts", "body_scripts"} - parts = [f'(sx-page-full "{_escape(page_source)}"'] + # page_source is SX wire format that may contain \" escapes. + # Send via binary blob protocol to avoid double-escaping + # through the SX string parser round-trip. + parts = ['(sx-page-full-blob'] for key, val in shell_kwargs.items(): k = key.replace("_", "-") if key in PLACEHOLDER_KEYS: @@ -248,6 +255,8 @@ class OcamlBridge: parts.append(")") cmd = "".join(parts) await self._send(cmd) + # Send page source as binary blob (avoids string-escape issues) + await self._send_blob(page_source) html = await self._read_until_ok(ctx) # Splice in large blobs for token, blob in placeholders.items(): @@ -473,11 +482,30 @@ class OcamlBridge: async def _send(self, line: str) -> None: """Write a line to the subprocess stdin and flush.""" + if self._in_io_handler: + raise OcamlBridgeError( + f"Re-entrant bridge call from IO handler: {line[:80]}. " + f"IO handlers must not call the bridge — use Python-only code." + ) assert self._proc and self._proc.stdin _logger.debug("SEND: %s", line[:120]) self._proc.stdin.write((line + "\n").encode()) await self._proc.stdin.drain() + async def _send_blob(self, data: str) -> None: + """Send a length-prefixed binary blob to the subprocess. + + Protocol: sends "(blob N)\\n" followed by exactly N bytes, then "\\n". + The OCaml side reads the length, then reads exactly N bytes. + This avoids string-escape round-trip issues for SX wire format. + """ + assert self._proc and self._proc.stdin + encoded = data.encode() + self._proc.stdin.write(f"(blob {len(encoded)})\n".encode()) + self._proc.stdin.write(encoded) + self._proc.stdin.write(b"\n") + await self._proc.stdin.drain() + async def _readline(self) -> str: """Read a line from the subprocess stdout.""" assert self._proc and self._proc.stdout @@ -574,7 +602,24 @@ class OcamlBridge: line: str, ctx: dict[str, Any] | None, ) -> Any: - """Dispatch an io-request to the appropriate Python handler.""" + """Dispatch an io-request to the appropriate Python handler. + + IO handlers MUST NOT call the bridge (eval/aser/render) — doing so + would deadlock since the lock is already held. The _in_io_handler + flag triggers an immediate error if this rule is violated. + """ + self._in_io_handler = True + try: + return await self._dispatch_io(line, ctx) + finally: + self._in_io_handler = False + + async def _dispatch_io( + self, + line: str, + ctx: dict[str, Any] | None, + ) -> Any: + """Inner dispatch for IO requests.""" from .parser import parse_all # Parse the io-request diff --git a/shared/sx/pages.py b/shared/sx/pages.py index c88c96b..509abe4 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -181,6 +181,47 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]: # Page execution # --------------------------------------------------------------------------- +def _wrap_with_env(expr: Any, env: dict) -> str: + """Serialize an expression wrapped with let-bindings from env. + + Injects page env values (URL params, data results) as let-bindings + so the OCaml kernel can evaluate the expression with those bindings. + Only injects non-component, non-callable values that pages add dynamically. + """ + from .parser import serialize + from .ocaml_bridge import _serialize_for_ocaml + from .types import Symbol, Keyword, NIL + + body = serialize(expr) + bindings = [] + for k, v in env.items(): + # Skip component definitions — already loaded in kernel + if k.startswith("~") or callable(v): + continue + # Skip env keys that are component-env infrastructure + if isinstance(v, (type, type(None))) and v is not None: + continue + # Serialize the value + if v is NIL or v is None: + sv = "nil" + elif isinstance(v, bool): + sv = "true" if v else "false" + elif isinstance(v, (int, float)): + sv = str(int(v)) if isinstance(v, float) and v == int(v) else str(v) + elif isinstance(v, str): + sv = _serialize_for_ocaml(v) + elif isinstance(v, (list, dict)): + sv = _serialize_for_ocaml(v) + else: + # Component, Lambda, etc — skip, already in kernel + continue + bindings.append(f"({k} {sv})") + + if not bindings: + return body + return f"(let ({' '.join(bindings)}) {body})" + + async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str: """Evaluate a page slot expression and return an sx source string. @@ -188,6 +229,15 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str: the result as SX wire format, not HTML. """ import os + if os.environ.get("SX_USE_OCAML") == "1": + from .ocaml_bridge import get_bridge + from .parser import serialize + bridge = await get_bridge() + # Wrap expression with let-bindings for env values that pages + # inject (URL params, data results, etc.) + sx_text = _wrap_with_env(expr, env) + service = ctx.get("_helper_service", "") if isinstance(ctx, dict) else "" + return await bridge.aser_slot(sx_text, ctx={"_helper_service": service}) if os.environ.get("SX_USE_REF") == "1": from .ref.async_eval_ref import async_eval_slot_to_sx else: @@ -248,12 +298,19 @@ async def execute_page( 6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request() """ from .jinja_bridge import get_component_env, _get_request_context - from .async_eval import async_eval from .page import get_template_context from .helpers import full_page_sx, oob_page_sx, sx_response from .layouts import get_layout from shared.browser.app.utils.htmx import is_htmx_request + _use_ocaml = os.environ.get("SX_USE_OCAML") == "1" + if _use_ocaml: + from .ocaml_bridge import get_bridge + from .parser import serialize, parse_all + from .ocaml_bridge import _serialize_for_ocaml + else: + from .async_eval import async_eval + if url_params is None: url_params = {} @@ -275,7 +332,19 @@ async def execute_page( # Evaluate :data expression if present _multi_stream_content = None if page_def.data_expr is not None: - data_result = await async_eval(page_def.data_expr, env, ctx) + if _use_ocaml: + bridge = await get_bridge() + sx_text = _wrap_with_env(page_def.data_expr, env) + ocaml_ctx = {"_helper_service": service_name} + raw = await bridge.eval(sx_text, ctx=ocaml_ctx) + # Parse result back to Python dict/value + if raw: + parsed = parse_all(raw) + data_result = parsed[0] if parsed else {} + else: + data_result = {} + else: + data_result = await async_eval(page_def.data_expr, env, ctx) if hasattr(data_result, '__aiter__'): # Multi-stream: consume generator, eval :content per chunk, # combine into shell with resolved suspense slots. @@ -358,7 +427,18 @@ async def execute_page( k = raw[i] if isinstance(k, SxKeyword) and i + 1 < len(raw): raw_val = raw[i + 1] - resolved = await async_eval(raw_val, env, ctx) + if _use_ocaml: + bridge = await get_bridge() + sx_text = _wrap_with_env(raw_val, env) + ocaml_ctx = {"_helper_service": service_name} + raw_result = await bridge.eval(sx_text, ctx=ocaml_ctx) + if raw_result: + parsed = parse_all(raw_result) + resolved = parsed[0] if parsed else None + else: + resolved = None + else: + resolved = await async_eval(raw_val, env, ctx) layout_kwargs[k.name.replace("-", "_")] = resolved i += 2 else: diff --git a/shared/sx/query_executor.py b/shared/sx/query_executor.py index aa59e48..6a69414 100644 --- a/shared/sx/query_executor.py +++ b/shared/sx/query_executor.py @@ -21,10 +21,6 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any: """ from .jinja_bridge import get_component_env, _get_request_context import os - if os.environ.get("SX_USE_REF") == "1": - from .ref.async_eval_ref import async_eval - else: - from .async_eval import async_eval env = dict(get_component_env()) env.update(query_def.closure) @@ -38,6 +34,26 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any: val = int(val) env[param] = val + if os.environ.get("SX_USE_OCAML") == "1": + from .ocaml_bridge import get_bridge + from .parser import serialize, parse_all + from .pages import _wrap_with_env + bridge = await get_bridge() + sx_text = _wrap_with_env(query_def.body, env) + ctx = {"_helper_service": ""} + raw = await bridge.eval(sx_text, ctx=ctx) + if raw: + parsed = parse_all(raw) + result = parsed[0] if parsed else None + else: + result = None + return _normalize(result) + + if os.environ.get("SX_USE_REF") == "1": + from .ref.async_eval_ref import async_eval + else: + from .async_eval import async_eval + ctx = _get_request_context() result = await async_eval(query_def.body, env, ctx) return _normalize(result) @@ -50,10 +66,6 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any: """ from .jinja_bridge import get_component_env, _get_request_context import os - if os.environ.get("SX_USE_REF") == "1": - from .ref.async_eval_ref import async_eval - else: - from .async_eval import async_eval env = dict(get_component_env()) env.update(action_def.closure) @@ -64,6 +76,26 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any: val = payload.get(param, payload.get(snake, NIL)) env[param] = val + if os.environ.get("SX_USE_OCAML") == "1": + from .ocaml_bridge import get_bridge + from .parser import serialize, parse_all + from .pages import _wrap_with_env + bridge = await get_bridge() + sx_text = _wrap_with_env(action_def.body, env) + ctx = {"_helper_service": ""} + raw = await bridge.eval(sx_text, ctx=ctx) + if raw: + parsed = parse_all(raw) + result = parsed[0] if parsed else None + else: + result = None + return _normalize(result) + + if os.environ.get("SX_USE_REF") == "1": + from .ref.async_eval_ref import async_eval + else: + from .async_eval import async_eval + ctx = _get_request_context() result = await async_eval(action_def.body, env, ctx) return _normalize(result) diff --git a/sx/sx/geography/index.sx b/sx/sx/geography/index.sx new file mode 100644 index 0000000..54b3b92 --- /dev/null +++ b/sx/sx/geography/index.sx @@ -0,0 +1,148 @@ +;; Geography index — architecture overview +;; Describes the rendering pipeline: OCaml evaluator → wire formats → client + +(defcomp ~geography/index-content () :affinity :server + (<> + (h2 :class "text-3xl font-bold text-stone-800 mb-4" "Geography") + (p :class "text-lg text-stone-600 mb-8" + "Where code runs and how it gets there. Geography maps the rendering pipeline from server-side evaluation through wire formats to client-side hydration.") + + ;; Architecture diagram + (div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 mb-8" + (h3 :class "text-xl font-semibold text-stone-700 mb-4" "Rendering Pipeline") + (div :class "space-y-4" + ;; Server + (div :class "flex items-start gap-4" + (div :class "w-28 shrink-0 font-mono text-sm font-semibold text-sky-700 bg-sky-50 rounded px-2 py-1 text-center" "OCaml kernel") + (div + (p :class "text-stone-700" + "The evaluator is a CEK machine written in SX and bootstrapped to OCaml. It evaluates page definitions, expands components, resolves IO (helpers, queries), and serializes the result as SX wire format.") + (p :class "text-sm text-stone-500 mt-1" + "spec/evaluator.sx → hosts/ocaml/ → aser-slot with batch IO"))) + + ;; Wire format + (div :class "flex items-start gap-4" + (div :class "w-28 shrink-0 font-mono text-sm font-semibold text-amber-700 bg-amber-50 rounded px-2 py-1 text-center" "Wire format") + (div + (p :class "text-stone-700" + "The aser (async-serialize) mode produces SX text — HTML tags and component calls serialized as s-expressions. Components with server affinity are expanded; client components stay as calls. The wire format is placed in a " + (code :class "text-sm" "