diff --git a/hosts/ocaml/shared/static/wasm/sx-platform.js b/hosts/ocaml/shared/static/wasm/sx-platform.js index e17ef12b..971274a7 100644 --- a/hosts/ocaml/shared/static/wasm/sx-platform.js +++ b/hosts/ocaml/shared/static/wasm/sx-platform.js @@ -40,7 +40,12 @@ var obj = args[0], prop = args[1]; if (obj == null) return null; var v = obj[prop]; - return v === undefined ? null : v; + if (v === undefined) return null; + // Functions can't cross the WASM boundary — return true as a truthy + // sentinel so (host-get el "getAttribute") works as a guard. + // Use host-call to actually invoke the method. + if (typeof v === "function") return true; + return v; }); K.registerNative("host-set!", function(args) { @@ -79,64 +84,47 @@ } }); + // IO suspension driver — resumes suspended callFn results (wait, fetch, etc.) + if (!window._driveAsync) { + window._driveAsync = function driveAsync(result) { + if (!result || !result.suspended) return; + var req = result.request; + var items = req && (req.items || req); + var op = items && items[0]; + var opName = typeof op === "string" ? op : (op && op.name) || String(op); + var arg = items && items[1]; + if (opName === "io-sleep" || opName === "wait") { + setTimeout(function() { + try { driveAsync(result.resume(null)); } catch(e) { console.error("[sx] driveAsync:", e.message); } + }, typeof arg === "number" ? arg : 0); + } else if (opName === "io-fetch") { + fetch(typeof arg === "string" ? arg : "").then(function(r) { return r.text(); }).then(function(t) { + try { driveAsync(result.resume({ok: true, text: t})); } catch(e) { console.error("[sx] driveAsync:", e.message); } + }); + } else if (opName === "io-navigate") { + // navigation — don't resume + } else { + console.warn("[sx] unhandled IO:", opName); + } + }; + } + K.registerNative("host-callback", function(args) { var fn = args[0]; - // Native JS function (not SX-origin) — pass through - if (typeof fn === "function" && fn.__sx_handle === undefined) return fn; - // SX callable (has __sx_handle) — wrap as JS function with suspension handling + // Native JS function — pass through + if (typeof fn === "function") return fn; + // SX callable (has __sx_handle) — wrap as JS function if (fn && fn.__sx_handle !== undefined) { return function() { var a = Array.prototype.slice.call(arguments); - var result = K.callFn(fn, a); - // Handle IO suspension chain (e.g. wait, fetch, navigate) - _driveAsync(result); - return result; + var r = K.callFn(fn, a); + if (window._driveAsync) window._driveAsync(r); + return r; }; } return function() {}; }); - /** - * Drive an async suspension chain to completion. - * When K.callFn returns {suspended: true, request: ..., resume: fn}, - * handle the IO operation and resume the VM. - */ - function _driveAsync(result) { - if (!result || !result.suspended) return; - console.log("[sx] IO suspension:", JSON.stringify(result.request, null, 2)); - var req = result.request; - if (!req) return; - - // req is an SX list — extract items. K returns SX values. - var items = req.items || req; - var op = (items && items[0]) || req; - // Normalize: op might be a string or {name: "..."} symbol - var opName = (typeof op === "string") ? op : (op && op.name) || String(op); - - if (opName === "wait" || opName === "io-sleep") { - // (wait ms) or (io-sleep ms) — resume after timeout - var ms = (items && items[1]) || 0; - if (typeof ms !== "number") ms = parseFloat(ms) || 0; - console.log("[sx] IO wait: " + ms + "ms, resuming after timeout"); - setTimeout(function() { - try { - var resumed = result.resume(null); - console.log("[sx] IO resumed:", typeof resumed, resumed && resumed.suspended ? "suspended-again" : "done", JSON.stringify(resumed)); - _driveAsync(resumed); - } catch(e) { - console.error("[sx] IO resume error:", e); - } - }, ms); - } else if (opName === "navigate") { - // (navigate url) — browser navigation - var url = (items && items[1]) || "/"; - if (typeof url !== "string") url = String(url); - window.location.href = url; - } else { - console.warn("[sx] Unhandled IO suspension in callback:", opName, req); - } - } - K.registerNative("host-typeof", function(args) { var obj = args[0]; if (obj == null) return "nil"; @@ -518,7 +506,7 @@ // will see it as already loaded and skip rather than infinite-looping. _loadedLibs[name] = true; - // Load this module + // Load this module (bytecode first, fallback to source) var ok = loadBytecodeFile("sx/" + info.file); if (!ok) { var sxFile = info.file.replace(/\.sxbc$/, '.sx'); @@ -570,10 +558,7 @@ "sx/adapter-html.sx", "sx/adapter-sx.sx", "sx/adapter-dom.sx", "sx/boot-helpers.sx", "sx/hypersx.sx", "sx/harness.sx", "sx/harness-reactive.sx", "sx/harness-web.sx", - "sx/engine.sx", "sx/orchestration.sx", - "sx/hs-tokenizer.sx", "sx/hs-parser.sx", "sx/hs-compiler.sx", - "sx/hs-runtime.sx", "sx/hs-integration.sx", - "sx/boot.sx", + "sx/engine.sx", "sx/orchestration.sx", "sx/boot.sx", ]; if (K.beginModuleLoad) K.beginModuleLoad(); for (var i = 0; i < files.length; i++) { @@ -691,19 +676,32 @@ "hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"], "children:", islands[j].children.length); } - // Activate _hyperscript compat on elements with _ attribute - if (document.querySelector('[_]')) { - if (K.beginModuleLoad) K.beginModuleLoad(); - loadLibrary("hyperscript integration", {}); - if (K.endModuleLoad) K.endModuleLoad(); - K.eval("(hs-boot!)"); - } // Register popstate handler for back/forward navigation window.addEventListener("popstate", function(e) { var state = e.state; var scrollY = (state && state.scrollY) ? state.scrollY : 0; K.eval("(handle-popstate " + scrollY + ")"); }); + // Define resolveSuspense now that boot is complete and web stack is loaded. + // Must happen AFTER boot — resolve-suspense needs dom-query, render-to-dom etc. + Sx.resolveSuspense = function(id, sx) { + try { + K.eval('(resolve-suspense ' + JSON.stringify(id) + ' ' + JSON.stringify(sx) + ')'); + } catch (e) { + console.error("[sx] resolveSuspense error for id=" + id, e); + } + }; + // Process any streaming suspense resolutions that arrived before boot + if (globalThis.__sxPending && globalThis.__sxPending.length > 0) { + for (var pi = 0; pi < globalThis.__sxPending.length; pi++) { + try { + Sx.resolveSuspense(globalThis.__sxPending[pi].id, globalThis.__sxPending[pi].sx); + } catch(e) { console.error("[sx] pending resolve error:", e); } + } + globalThis.__sxPending = null; + } + // Set up direct resolution for future streaming chunks + globalThis.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); }; // Signal boot complete document.documentElement.setAttribute("data-sx-ready", "true"); console.log("[sx] boot done"); diff --git a/shared/static/wasm/sx-platform.js b/shared/static/wasm/sx-platform.js index ab51b599..975f200a 100644 --- a/shared/static/wasm/sx-platform.js +++ b/shared/static/wasm/sx-platform.js @@ -103,6 +103,23 @@ }); } else if (opName === "io-navigate") { // navigation — don't resume + } else if (opName === "text-measure") { + // Pretext: measure text using offscreen canvas + var font = arg; + var size = items && items[2]; + var text = items && items[3]; + var canvas = document.createElement("canvas"); + var ctx = canvas.getContext("2d"); + ctx.font = (size || 16) + "px " + (font || "serif"); + var m = ctx.measureText(text || ""); + try { + driveAsync(result.resume({ + width: m.width, + height: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent, + ascent: m.actualBoundingBoxAscent, + descent: m.actualBoundingBoxDescent + })); + } catch(e) { console.error("[sx] driveAsync:", e.message); } } else { console.warn("[sx] unhandled IO:", opName); } @@ -635,13 +652,6 @@ renderToHtml: function(expr) { return K.renderToHtml(expr); }, callFn: function(fn, args) { return K.callFn(fn, args); }, engine: function() { return K.engine(); }, - resolveSuspense: function(id, sx) { - try { - K.eval('(resolve-suspense ' + JSON.stringify(id) + ' ' + JSON.stringify(sx) + ')'); - } catch (e) { - console.error("[sx] resolveSuspense error for id=" + id, e); - } - }, // Boot entry point (called by auto-init or manually) init: function() { if (typeof K.eval === "function") { @@ -697,26 +707,15 @@ }; // ================================================================ - // Auto-init: load web stack eagerly, boot on DOMContentLoaded + // Auto-init: load web stack and boot on DOMContentLoaded // ================================================================ if (typeof document !== "undefined") { - loadWebStack(); - // Pre-process component scripts eagerly so resolve-suspense doesn't - // hit the 'Undefined symbol: default' error on first call. - // The error occurs during component loading but is non-fatal. - try { K.eval("(process-sx-scripts nil)"); } catch(e) {} - var _doInit = function() { + loadWebStack(); Sx.init(); - // Drain streaming resolves that arrived before boot or were deferred on error - if (window.__sxPending) { - for (var pi = 0; pi < window.__sxPending.length; pi++) { - Sx.resolveSuspense(window.__sxPending[pi].id, window.__sxPending[pi].sx); - } - window.__sxPending = null; - } - window.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); }; + // Enable JIT after all boot code has run. + // Lazy-load the compiler first — JIT needs it to compile functions. setTimeout(function() { if (K.beginModuleLoad) K.beginModuleLoad(); loadLibrary("sx compiler", {}); diff --git a/sx/sx/streaming-demo.sx b/sx/sx/streaming-demo.sx index 4696dfe3..4646ad22 100644 --- a/sx/sx/streaming-demo.sx +++ b/sx/sx/streaming-demo.sx @@ -8,16 +8,57 @@ ;; at 1s, 3s, and 5s. Each chunk resolves a different ~shared:pages/suspense slot. ;; Color map for stream chunk styling (all string keys for get compatibility) -(define stream-colors - {"green" {"border" "border-green-200" "bg" "bg-green-50" "title" "text-green-900" - "text" "text-green-800" "sub" "text-green-700" "code" "bg-green-100" - "dot" "bg-green-400"} - "blue" {"border" "border-blue-200" "bg" "bg-blue-50" "title" "text-blue-900" - "text" "text-blue-800" "sub" "text-blue-700" "code" "bg-blue-100" - "dot" "bg-blue-400"} - "amber" {"border" "border-amber-200" "bg" "bg-amber-50" "title" "text-amber-900" - "text" "text-amber-800" "sub" "text-amber-700" "code" "bg-amber-100" - "dot" "bg-amber-400"}}) +(define + stream-colors + (dict + "emerald" + (dict + "border" + "border-emerald-200" + "bg" + "bg-emerald-50" + "dot" + "bg-emerald-400" + "title" + "text-emerald-900" + "text" + "text-emerald-800" + "sub" + "text-emerald-700" + "code" + "bg-emerald-100") + "amber" + (dict + "border" + "border-amber-200" + "bg" + "bg-amber-50" + "dot" + "bg-amber-400" + "title" + "text-amber-900" + "text" + "text-amber-800" + "sub" + "text-amber-700" + "code" + "bg-amber-100") + "violet" + (dict + "border" + "border-violet-200" + "bg" + "bg-violet-50" + "dot" + "bg-violet-400" + "title" + "text-violet-900" + "text" + "text-violet-800" + "sub" + "text-violet-700" + "code" + "bg-violet-100"))) ;; Generic streamed content chunk — rendered once per yield from the ;; async generator. The :content expression receives different bindings