diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 5f8b003..0a17aba 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -71,6 +71,22 @@ let send_ok_value v = send (Printf.sprintf "(ok %s)" (serialize_value v)) let send_ok_string s = send (Printf.sprintf "(ok \"%s\")" (escape_sx_string s)) let send_error msg = send (Printf.sprintf "(error \"%s\")" (escape_sx_string msg)) +(** Send ok-raw, ensuring single-line output. + SX wire format from aser may contain newlines inside string literals. + We must escape those to prevent pipe desync (Python reads one line + at a time), but we can't blindly replace newlines in the raw SX + because that would break string content. + + Strategy: wrap as a properly escaped string literal. + Python side will unescape it. *) +let send_ok_raw s = + (* If the result has no newlines, send as-is for backward compat *) + if not (String.contains s '\n') then + send (Printf.sprintf "(ok-raw %s)" s) + else + (* Wrap as escaped string so newlines are preserved *) + send (Printf.sprintf "(ok \"%s\")" (escape_sx_string s)) + (* ====================================================================== *) (* IO bridge — primitives that yield to Python *) @@ -806,7 +822,7 @@ let dispatch env cmd = | RawHTML s -> "\"" ^ escape_sx_string s ^ "\"" | _ -> "nil" in - send (Printf.sprintf "(ok-raw %s)" (raw_serialize result)) + send_ok_raw (raw_serialize result) with | Eval_error msg -> send_error msg | exn -> send_error (Printexc.to_string exn)) @@ -830,7 +846,7 @@ let dispatch env cmd = (* Send raw SX wire format without re-escaping. Use (ok-raw ...) so Python knows not to unescape. *) (match result with - | String s | SxExpr s -> send (Printf.sprintf "(ok-raw %s)" s) + | String s | SxExpr s -> send_ok_raw s | _ -> send_ok_value result) with | Eval_error msg -> send_error msg @@ -891,7 +907,7 @@ let dispatch env cmd = let t2 = Unix.gettimeofday () in Printf.eprintf "[aser-slot] eval=%.1fs io_flush=%.1fs batched=%d result=%d chars\n%!" (t1 -. t0) (t2 -. t1) n_batched (String.length final); - send (Printf.sprintf "(ok-raw %s)" final) + send_ok_raw final with | Eval_error msg -> io_batch_mode := false; diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 4de41d4..cbedeaa 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -170,23 +170,20 @@ class OcamlBridge: static = _get_shell_static() except Exception: return # not ready yet (no app context) - # Define small values as kernel variables. - # Large blobs (component_defs, pages_sx, init_sx) use placeholders - # at render time — NOT injected here. - for key in ("sx_css", "component_hash", "sx_css_classes", "asset_url", + # Only inject small, safe values as kernel variables. + # Large/complex blobs use placeholder tokens at render time. + for key in ("component_hash", "sx_css_classes", "asset_url", "sx_js_hash", "body_js_hash"): - val = static.get(key, "") - if val is None: - val = "" + val = static.get(key) or "" var = f"__shell-{key.replace('_', '-')}" defn = f'(define {var} "{_escape(str(val))}")' try: await self._send(f'(load-source "{_escape(defn)}")') await self._read_until_ok(ctx=None) - except OcamlBridgeError: - pass - # Also inject list/nil values - for key in ("head_scripts", "inline_css", "inline_head_js", "body_scripts"): + except OcamlBridgeError as e: + _logger.warning("Shell static inject failed for %s: %s", key, e) + # List/nil values + for key in ("head_scripts", "body_scripts"): val = static.get(key) var = f"__shell-{key.replace('_', '-')}" if val is None: @@ -199,8 +196,8 @@ class OcamlBridge: try: await self._send(f'(load-source "{_escape(defn)}")') await self._read_until_ok(ctx=None) - except OcamlBridgeError: - pass + except OcamlBridgeError as e: + _logger.warning("Shell static inject failed for %s: %s", key, e) self._shell_statics_injected = True _logger.info("Injected shell statics into OCaml kernel") @@ -220,14 +217,16 @@ class OcamlBridge: async with self._lock: await self._inject_helpers_locked() await self._inject_shell_statics_locked() - # Large blobs (component_defs, pages_sx, init_sx) use placeholders. - # OCaml renders the shell with short tokens; Python splices in - # the real values. This avoids piping ~1MB through stdin/stdout. - PLACEHOLDER_KEYS = {"component_defs", "pages_sx", "init_sx"} + # 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 + # through the SX parser. + PLACEHOLDER_KEYS = {"component_defs", "pages_sx", "init_sx", + "sx_css", "inline_css", "inline_head_js"} placeholders = {} static_keys = {"component_hash", "sx_css_classes", "asset_url", - "sx_js_hash", "body_js_hash", "sx_css", - "head_scripts", "inline_css", "inline_head_js", "body_scripts"} + "sx_js_hash", "body_js_hash", + "head_scripts", "body_scripts"} parts = [f'(sx-page-full "{_escape(page_source)}"'] for key, val in shell_kwargs.items(): k = key.replace("_", "-") diff --git a/sx/app.py b/sx/app.py index 71ebc29..6a774ce 100644 --- a/sx/app.py +++ b/sx/app.py @@ -49,6 +49,11 @@ async def sx_standalone_context() -> dict: ctx["cart_mini"] = "" ctx["auth_menu"] = "" ctx["nav_tree"] = "" + # Generate CSRF token — standalone has no account service but still + # needs CSRF for mutation handlers (DELETE etc.) + from shared.browser.app.csrf import generate_csrf_token + from quart import g + g.csrf_token = generate_csrf_token() return ctx @@ -57,8 +62,8 @@ def create_app() -> "Quart": extra_kw = {} if SX_STANDALONE: - extra_kw["no_oauth"] = True extra_kw["no_db"] = True + extra_kw["no_oauth"] = True app = create_base_app( "sx",