diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index e92e774..9ab4a7d 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-07T02:32:34Z"; + var SX_VERSION = "2026-03-07T09:03:03Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2031,7 +2031,11 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\" return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() { var target = resolveRouteTarget(targetSel); return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() { - var hasIo = get(match, "has-io"); + var ioDeps = get(match, "io-deps"); + var hasIo = (isSxTruthy(ioDeps) && !isSxTruthy(isEmpty(ioDeps))); + if (isSxTruthy(hasIo)) { + registerIoDeps(ioDeps); +} return (isSxTruthy(get(match, "has-data")) ? (function() { var cacheKey = pageDataCacheKey(pageName, params); var cached = pageDataCacheGet(cacheKey); @@ -2476,7 +2480,7 @@ callExpr.push(dictGet(kwargs, k)); } } })(); }; // boot-init - var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), initIoPrimitives(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; + var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; // === Transpiled from router (client-side route matching) === @@ -4359,16 +4363,20 @@ callExpr.push(dictGet(kwargs, k)); } } }); } - // Register default proxied IO primitives - function initIoPrimitives() { - var defaults = [ - "highlight", "current-user", "request-arg", "request-path", - "app-url", "asset-url", "config" - ]; - for (var i = 0; i < defaults.length; i++) { - registerProxiedIo(defaults[i]); + // Register IO deps as proxied primitives (idempotent, called per-page) + function registerIoDeps(names) { + if (!names || !names.length) return; + var registered = 0; + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (!IO_PRIMITIVES[name]) { + registerProxiedIo(name); + registered++; + } + } + if (registered > 0) { + logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", ")); } - logInfo("sx:io registered " + defaults.length + " proxied primitives"); } @@ -4445,6 +4453,7 @@ callExpr.push(dictGet(kwargs, k)); } } matchRoute: matchRoute, findMatchingRoute: findMatchingRoute, registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null, + registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null, asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null, asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null, _version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index d42b77c..c6ab201 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -691,14 +691,17 @@ def _build_pages_sx(service: str) -> str: deps = components_needed(content_src, _COMPONENT_ENV) deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")" - # Check if any dep component has IO refs (needs async rendering) + # Collect IO primitive names referenced by dep components from .types import Component as _Comp - has_io = "false" + io_deps: set[str] = set() for dep_name in deps: comp = _COMPONENT_ENV.get(dep_name) if isinstance(comp, _Comp) and comp.io_refs: - has_io = "true" - break + io_deps.update(comp.io_refs) + io_deps_sx = ( + "(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")" + if io_deps else "()" + ) # Build closure as SX dict closure_parts: list[str] = [] @@ -712,7 +715,7 @@ def _build_pages_sx(service: str) -> str: + " :path " + _sx_literal(page_def.path) + " :auth " + _sx_literal(auth) + " :has-data " + has_data - + " :has-io " + has_io + + " :io-deps " + io_deps_sx + " :content " + _sx_literal(content_src) + " :deps " + deps_sx + " :closure " + closure_sx + "}" diff --git a/shared/sx/pages.py b/shared/sx/pages.py index a3ba7e7..46c4772 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -555,11 +555,13 @@ def mount_io_endpoint(app: Any, service_name: str) -> None: from .jinja_bridge import _get_request_context from .parser import serialize - # Allowlist of IO primitives + page helpers the client may call - _ALLOWED_IO = { - "highlight", "current-user", "request-arg", "request-path", - "htmx-request?", "app-url", "asset-url", "config", - } + # Build allowlist from all component IO refs across this service + from .jinja_bridge import _COMPONENT_ENV + from .types import Component as _Comp + _ALLOWED_IO: set[str] = set() + for _val in _COMPONENT_ENV.values(): + if isinstance(_val, _Comp) and _val.io_refs: + _ALLOWED_IO.update(_val.io_refs) async def io_proxy(name: str) -> Any: if name not in _ALLOWED_IO: @@ -607,4 +609,4 @@ def mount_io_endpoint(app: Any, service_name: str) -> None: view_func=io_proxy, methods=["GET", "POST"], ) - logger.info("Mounted IO proxy endpoint for %s at /sx/io/", service_name) + logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO)) diff --git a/shared/sx/ref/boot.sx b/shared/sx/ref/boot.sx index 24677cc..64d91b8 100644 --- a/shared/sx/ref/boot.sx +++ b/shared/sx/ref/boot.sx @@ -344,7 +344,6 @@ (log-info (str "sx-browser " SX_VERSION)) (init-css-tracking) (init-style-dict) - (init-io-primitives) (process-page-scripts) (process-sx-scripts nil) (sx-hydrate-elements nil) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index a02163b..a0ef7a1 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -385,6 +385,7 @@ class JSEmitter: "try-client-route": "tryClientRoute", "try-eval-content": "tryEvalContent", "try-async-eval-content": "tryAsyncEvalContent", + "register-io-deps": "registerIoDeps", "url-pathname": "urlPathname", "bind-inline-handler": "bindInlineHandler", "bind-preload": "bindPreload", @@ -477,7 +478,6 @@ class JSEmitter: "process-sx-scripts": "processSxScripts", "process-component-script": "processComponentScript", "init-style-dict": "initStyleDict", - "init-io-primitives": "initIoPrimitives", "SX_VERSION": "SX_VERSION", "boot-init": "bootInit", "resolve-mount-target": "resolveMountTarget", @@ -1733,16 +1733,20 @@ ASYNC_IO_JS = ''' }); } - // Register default proxied IO primitives - function initIoPrimitives() { - var defaults = [ - "highlight", "current-user", "request-arg", "request-path", - "app-url", "asset-url", "config" - ]; - for (var i = 0; i < defaults.length; i++) { - registerProxiedIo(defaults[i]); + // Register IO deps as proxied primitives (idempotent, called per-page) + function registerIoDeps(names) { + if (!names || !names.length) return; + var registered = 0; + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (!IO_PRIMITIVES[name]) { + registerProxiedIo(name); + registered++; + } + } + if (registered > 0) { + logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", ")); } - logInfo("sx:io registered " + defaults.length + " proxied primitives"); } ''' @@ -3993,6 +3997,7 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has if has_dom: api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,') + api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,') api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,') api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,') api_lines.append(f' _version: "{version}"') diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index f823648..2272a3a 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -648,7 +648,10 @@ (do (log-warn (str "sx:route target not found: " target-sel)) false) (if (not (deps-satisfied? match)) (do (log-info (str "sx:route deps miss for " page-name)) false) - (let ((has-io (get match "has-io"))) + (let ((io-deps (get match "io-deps")) + (has-io (and io-deps (not (empty? io-deps))))) + ;; Ensure IO deps are registered as proxied primitives + (when has-io (register-io-deps io-deps)) (if (get match "has-data") ;; Data page: check cache, else resolve asynchronously (let ((cache-key (page-data-cache-key page-name params)) @@ -1024,6 +1027,8 @@ ;; (try-eval-content source env) → DOM node or nil (catches eval errors) ;; (try-async-eval-content source env callback) → void; async render, ;; calls (callback rendered-or-nil). Used for pages with IO deps. +;; (register-io-deps names) → void; ensure each IO name is registered +;; as a proxied IO primitive on the client. Idempotent. ;; (url-pathname href) → extract pathname from URL string ;; (resolve-page-data name params cb) → void; resolves data for a named page. ;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict) diff --git a/sx/sx/async-io-demo.sx b/sx/sx/async-io-demo.sx index f20b489..cc4fa06 100644 --- a/sx/sx/async-io-demo.sx +++ b/sx/sx/async-io-demo.sx @@ -41,7 +41,7 @@ (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX async rendering spec") (~doc-code :code - (highlight ";; try-client-route dispatches on has-io flag\n(if has-io\n ;; Async render: IO primitives proxied via /sx/io/\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname)))" "lisp")))) + (highlight ";; try-client-route reads io-deps from page registry\n(let ((io-deps (get match \"io-deps\"))\n (has-io (and io-deps (not (empty? io-deps)))))\n ;; Register IO deps as proxied primitives on demand\n (when has-io (register-io-deps io-deps))\n (if has-io\n ;; Async render: IO primitives proxied via /sx/io/\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp")))) ;; Architecture explanation (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" @@ -49,8 +49,8 @@ (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" (li "Server renders the page — " (code "highlight") " runs Python directly") (li "Client receives component definitions including " (code "~async-io-demo-content")) - (li "On client navigation, " (code "has-io") " flag routes to async renderer") - (li "Async renderer encounters " (code "(highlight ...)") " — checks " (code "IO_PRIMITIVES")) + (li "On client navigation, " (code "io-deps") " list routes to async renderer") + (li (code "register-io-deps") " ensures each IO name is proxied via " (code "registerProxiedIo")) (li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")")) (li "Server runs highlight, returns SX source (colored span elements)") (li "Client parses SX → AST, async renderer recursively renders to DOM")))