Step 17: streaming render — hyperscript enhancements, WASM builds, live server tests
Streaming chunked transfer with shell-first suspense and resolve scripts. Hyperscript parser/compiler/runtime expanded for conformance. WASM static assets added to OCaml host. Playwright streaming and page-level test suites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
(executables
|
||||
(names run_tests debug_set sx_server integration_tests)
|
||||
(libraries sx unix))
|
||||
(libraries sx unix threads.posix))
|
||||
|
||||
(executable
|
||||
(name mcp_tree)
|
||||
|
||||
@@ -151,6 +151,9 @@ let _app_config : (string, value) Hashtbl.t option ref = ref None
|
||||
let _defpage_paths : string list ref = ref []
|
||||
(* Streaming pages: path → page name, for pages with :stream true *)
|
||||
let _streaming_pages : (string, string) Hashtbl.t = Hashtbl.create 8
|
||||
(* Mutex to serialize streaming renders — OCaml threads share the runtime
|
||||
lock, and concurrent CEK evaluations corrupt shared state. *)
|
||||
let _stream_mutex = Mutex.create ()
|
||||
|
||||
let get_app_config key default =
|
||||
match !_app_config with
|
||||
@@ -1746,7 +1749,7 @@ let http_redirect url =
|
||||
let http_chunked_header ?(status=200) ?(content_type="text/html; charset=utf-8") () =
|
||||
let status_text = match status with
|
||||
| 200 -> "OK" | 404 -> "Not Found" | 500 -> "Internal Server Error" | _ -> "Unknown" in
|
||||
Printf.sprintf "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n"
|
||||
Printf.sprintf "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\nCache-Control: no-cache, no-transform\r\n\r\n"
|
||||
status status_text content_type
|
||||
|
||||
let write_chunk fd data =
|
||||
@@ -1755,13 +1758,14 @@ let write_chunk fd data =
|
||||
let bytes = Bytes.of_string chunk in
|
||||
let total = Bytes.length bytes in
|
||||
let written = ref 0 in
|
||||
(try
|
||||
try
|
||||
while !written < total do
|
||||
let n = Unix.write fd bytes !written (total - !written) in
|
||||
written := !written + n
|
||||
done
|
||||
with Unix.Unix_error _ -> ())
|
||||
end
|
||||
done;
|
||||
true
|
||||
with Unix.Unix_error _ -> false
|
||||
end else true
|
||||
|
||||
let end_chunked fd =
|
||||
(try ignore (Unix.write_substring fd "0\r\n\r\n" 0 5) with Unix.Unix_error _ -> ());
|
||||
@@ -2004,6 +2008,10 @@ let eval_with_io expr env =
|
||||
(* ====================================================================== *)
|
||||
|
||||
let http_render_page_streaming env path _headers fd page_name =
|
||||
(* No send timeout for streaming — the alive check in write_chunk handles
|
||||
broken pipe. Streaming clients may be slow to receive large shell chunks
|
||||
while busy parsing/downloading other resources. *)
|
||||
(try Unix.setsockopt_float fd Unix.SO_SNDTIMEO 30.0 with _ -> ());
|
||||
let t0 = Unix.gettimeofday () in
|
||||
let page_def = try
|
||||
match env_get env ("page:" ^ page_name) with Dict d -> d | _ -> raise Not_found
|
||||
@@ -2102,17 +2110,18 @@ let http_render_page_streaming env path _headers fd page_name =
|
||||
let header = http_chunked_header () in
|
||||
let header_bytes = Bytes.of_string header in
|
||||
(try ignore (Unix.write fd header_bytes 0 (Bytes.length header_bytes)) with _ -> ());
|
||||
write_chunk fd shell_body;
|
||||
let alive = ref true in
|
||||
alive := write_chunk fd shell_body;
|
||||
(* Bootstrap resolve script — must come after shell so suspense elements exist *)
|
||||
write_chunk fd _sx_streaming_bootstrap;
|
||||
if !alive then alive := write_chunk fd _sx_streaming_bootstrap;
|
||||
let t2 = Unix.gettimeofday () in
|
||||
|
||||
(* Phase 3: Evaluate :data, render :content, flush resolve scripts.
|
||||
Uses eval_with_io so :data expressions can perform IO (e.g. sleep, fetch).
|
||||
Each data item is resolved independently — IO in one item doesn't block others
|
||||
from being flushed as they complete. *)
|
||||
from being flushed as they complete. Bails out early on broken pipe. *)
|
||||
let resolve_count = ref 0 in
|
||||
if data_ast <> Nil && content_ast <> Nil then begin
|
||||
if !alive && data_ast <> Nil && content_ast <> Nil then begin
|
||||
(try
|
||||
let data_result = eval_with_io data_ast env in
|
||||
let t3_data = Unix.gettimeofday () in
|
||||
@@ -2140,6 +2149,7 @@ let http_render_page_streaming env path _headers fd page_name =
|
||||
Each item flushes its resolve script independently — the client sees
|
||||
content appear progressively as each IO completes. *)
|
||||
List.iter (fun (item, stream_id) ->
|
||||
if !alive then
|
||||
(try
|
||||
(* IO sleep if delay specified — demonstrates async streaming *)
|
||||
(match item with
|
||||
@@ -2170,7 +2180,7 @@ let http_render_page_streaming env path _headers fd page_name =
|
||||
let sx_source = match content_result with
|
||||
| String s | SxExpr s -> s | _ -> serialize_value content_result in
|
||||
let resolve_script = sx_streaming_resolve_script stream_id sx_source in
|
||||
write_chunk fd resolve_script;
|
||||
alive := write_chunk fd resolve_script;
|
||||
incr resolve_count
|
||||
with e ->
|
||||
(* Error boundary: emit error fallback for this slot *)
|
||||
@@ -2178,7 +2188,7 @@ let http_render_page_streaming env path _headers fd page_name =
|
||||
Printf.eprintf "[sx-stream] resolve error for %s: %s\n%!" stream_id msg;
|
||||
let error_sx = Printf.sprintf "(div :class \"text-rose-600 p-4 text-sm\" \"Error: %s\")"
|
||||
(String.map (fun c -> if c = '"' then '\'' else c) msg) in
|
||||
write_chunk fd (sx_streaming_resolve_script stream_id error_sx);
|
||||
alive := write_chunk fd (sx_streaming_resolve_script stream_id error_sx);
|
||||
incr resolve_count)
|
||||
) data_items;
|
||||
let t3 = Unix.gettimeofday () in
|
||||
@@ -2190,7 +2200,7 @@ let http_render_page_streaming env path _headers fd page_name =
|
||||
Printf.eprintf "[sx-stream] %s shell=%.3fs (no :data/:content)\n%!" path (t1 -. t0);
|
||||
|
||||
(* Phase 4: Send closing tags + end chunked response *)
|
||||
if shell_tail <> "" then write_chunk fd shell_tail;
|
||||
if !alive && shell_tail <> "" then ignore (write_chunk fd shell_tail);
|
||||
end_chunked fd
|
||||
|
||||
(* ====================================================================== *)
|
||||
@@ -2309,9 +2319,20 @@ let http_inject_shell_statics env static_dir sx_sxc =
|
||||
) env.bindings;
|
||||
let raw_defs = Buffer.contents buf in
|
||||
(* Component-defs are inlined in <script type="text/sx">.
|
||||
The escape_sx_string function handles </ → <\\/ inside string
|
||||
literals, preventing the HTML parser from matching </script>. *)
|
||||
let component_defs = raw_defs in
|
||||
Escape </ → <\/ to prevent HTML parser from matching </script>. *)
|
||||
let component_defs =
|
||||
let len = String.length raw_defs in
|
||||
let buf2 = Buffer.create (len + 64) in
|
||||
for i = 0 to len - 1 do
|
||||
if raw_defs.[i] = '<' && i + 1 < len && raw_defs.[i + 1] = '/' then begin
|
||||
Buffer.add_string buf2 "<\\/";
|
||||
end else if raw_defs.[i] = '/' && i > 0 && raw_defs.[i - 1] = '<' then
|
||||
() (* skip — already handled above *)
|
||||
else
|
||||
Buffer.add_char buf2 raw_defs.[i]
|
||||
done;
|
||||
Buffer.contents buf2
|
||||
in
|
||||
let component_hash = Digest.string component_defs |> Digest.to_hex in
|
||||
(* Compute per-file hashes for cache busting *)
|
||||
let wasm_hash = file_hash (static_dir ^ "/wasm/sx_browser.bc.wasm.js") in
|
||||
@@ -3428,11 +3449,14 @@ let http_mode port =
|
||||
in
|
||||
write_response fd response; true
|
||||
end else begin
|
||||
(* Full page streaming: chunked transfer encoding *)
|
||||
(try http_render_page_streaming env path [] fd sname
|
||||
with Exit -> () (* page def not found — already handled *)
|
||||
| e -> Printf.eprintf "[sx-stream] unexpected error for %s: %s\n%!" path (Printexc.to_string e);
|
||||
(try Unix.close fd with _ -> ()));
|
||||
(* Full page streaming: run in a thread so the accept loop
|
||||
stays unblocked for concurrent requests. *)
|
||||
let _t = Thread.create (fun () ->
|
||||
(try http_render_page_streaming env path [] fd sname
|
||||
with Exit -> ()
|
||||
| e -> Printf.eprintf "[sx-stream] unexpected error for %s: %s\n%!" path (Printexc.to_string e);
|
||||
(try Unix.close fd with _ -> ()))
|
||||
) () in
|
||||
true
|
||||
end
|
||||
end else
|
||||
|
||||
751
hosts/ocaml/shared/static/wasm/sx-platform.js
Normal file
751
hosts/ocaml/shared/static/wasm/sx-platform.js
Normal file
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* sx-platform.js — Browser platform layer for the SX WASM kernel.
|
||||
*
|
||||
* Registers the 8 FFI host primitives and loads web adapter .sx files.
|
||||
* This is the only JS needed beyond the WASM kernel itself.
|
||||
*
|
||||
* Usage:
|
||||
* <script src="sx_browser.bc.wasm.js"></script>
|
||||
* <script src="sx-platform.js"></script>
|
||||
*
|
||||
* Or for js_of_ocaml mode:
|
||||
* <script src="sx_browser.bc.js"></script>
|
||||
* <script src="sx-platform.js"></script>
|
||||
*/
|
||||
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
function boot(K) {
|
||||
|
||||
// ================================================================
|
||||
// FFI Host Primitives
|
||||
// ================================================================
|
||||
|
||||
// Lazy module loading — islands/components call this to declare dependencies
|
||||
K.registerNative("load-library!", function(args) {
|
||||
var name = args[0];
|
||||
if (!name) return false;
|
||||
return __sxLoadLibrary(name) || false;
|
||||
});
|
||||
|
||||
K.registerNative("host-global", function(args) {
|
||||
var name = args[0];
|
||||
if (typeof globalThis !== "undefined" && name in globalThis) return globalThis[name];
|
||||
if (typeof window !== "undefined" && name in window) return window[name];
|
||||
return null;
|
||||
});
|
||||
|
||||
K.registerNative("host-get", function(args) {
|
||||
var obj = args[0], prop = args[1];
|
||||
if (obj == null) return null;
|
||||
var v = obj[prop];
|
||||
return v === undefined ? null : v;
|
||||
});
|
||||
|
||||
K.registerNative("host-set!", function(args) {
|
||||
var obj = args[0], prop = args[1], val = args[2];
|
||||
if (obj != null) obj[prop] = val;
|
||||
});
|
||||
|
||||
K.registerNative("host-call", function(args) {
|
||||
var obj = args[0], method = args[1];
|
||||
var callArgs = [];
|
||||
for (var i = 2; i < args.length; i++) callArgs.push(args[i]);
|
||||
if (obj == null) {
|
||||
// Global function call
|
||||
var fn = typeof globalThis !== "undefined" ? globalThis[method] : window[method];
|
||||
if (typeof fn === "function") return fn.apply(null, callArgs);
|
||||
return null;
|
||||
}
|
||||
if (typeof obj[method] === "function") {
|
||||
try { return obj[method].apply(obj, callArgs); }
|
||||
catch(e) { console.error("[sx] host-call error:", e); return null; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
K.registerNative("host-new", function(args) {
|
||||
var name = args[0];
|
||||
var cArgs = args.slice(1);
|
||||
var Ctor = typeof globalThis !== "undefined" ? globalThis[name] : window[name];
|
||||
if (typeof Ctor !== "function") return null;
|
||||
switch (cArgs.length) {
|
||||
case 0: return new Ctor();
|
||||
case 1: return new Ctor(cArgs[0]);
|
||||
case 2: return new Ctor(cArgs[0], cArgs[1]);
|
||||
case 3: return new Ctor(cArgs[0], cArgs[1], cArgs[2]);
|
||||
default: return new Ctor(cArgs[0], cArgs[1], cArgs[2], cArgs[3]);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
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;
|
||||
};
|
||||
}
|
||||
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";
|
||||
if (obj instanceof Element) return "element";
|
||||
if (obj instanceof Text) return "text";
|
||||
if (obj instanceof DocumentFragment) return "fragment";
|
||||
if (obj instanceof Document) return "document";
|
||||
if (obj instanceof Event) return "event";
|
||||
if (obj instanceof Promise) return "promise";
|
||||
if (obj instanceof AbortController) return "abort-controller";
|
||||
return typeof obj;
|
||||
});
|
||||
|
||||
K.registerNative("host-await", function(args) {
|
||||
var promise = args[0], callback = args[1];
|
||||
if (promise && typeof promise.then === "function") {
|
||||
var cb;
|
||||
if (typeof callback === "function") cb = callback;
|
||||
else if (callback && callback.__sx_handle !== undefined)
|
||||
cb = function(v) { return K.callFn(callback, [v]); };
|
||||
else cb = function() {};
|
||||
promise.then(cb);
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Constants expected by .sx files
|
||||
// ================================================================
|
||||
|
||||
K.eval('(define SX_VERSION "wasm-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-wasm")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
|
||||
// ================================================================
|
||||
// DOM query helpers used by boot.sx / orchestration.sx
|
||||
// (These are JS-native in the transpiled bundle; here via FFI.)
|
||||
// ================================================================
|
||||
|
||||
K.registerNative("query-sx-scripts", function(args) {
|
||||
var root = (args[0] && args[0] !== null) ? args[0] : document;
|
||||
if (typeof root.querySelectorAll !== "function") root = document;
|
||||
return Array.prototype.slice.call(root.querySelectorAll('script[type="text/sx"]'));
|
||||
});
|
||||
|
||||
K.registerNative("query-page-scripts", function(args) {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('script[type="text/sx-pages"]'));
|
||||
});
|
||||
|
||||
K.registerNative("query-component-scripts", function(args) {
|
||||
var root = (args[0] && args[0] !== null) ? args[0] : document;
|
||||
if (typeof root.querySelectorAll !== "function") root = document;
|
||||
return Array.prototype.slice.call(root.querySelectorAll('script[type="text/sx"][data-components]'));
|
||||
});
|
||||
|
||||
// localStorage
|
||||
K.registerNative("local-storage-get", function(args) {
|
||||
try { var v = localStorage.getItem(args[0]); return v === null ? null : v; }
|
||||
catch(e) { return null; }
|
||||
});
|
||||
K.registerNative("local-storage-set", function(args) {
|
||||
try { localStorage.setItem(args[0], args[1]); } catch(e) {}
|
||||
});
|
||||
K.registerNative("local-storage-remove", function(args) {
|
||||
try { localStorage.removeItem(args[0]); } catch(e) {}
|
||||
});
|
||||
|
||||
// log-info/log-warn defined in browser.sx; log-error as native fallback
|
||||
K.registerNative("log-error", function(args) { console.error.apply(console, ["[sx]"].concat(args)); });
|
||||
|
||||
// Cookie access (browser-side)
|
||||
K.registerNative("get-cookie", function(args) {
|
||||
var name = args[0];
|
||||
var match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
});
|
||||
K.registerNative("set-cookie", function(args) {
|
||||
document.cookie = args[0] + "=" + encodeURIComponent(args[1] || "") + ";path=/;max-age=31536000;SameSite=Lax";
|
||||
});
|
||||
|
||||
// IntersectionObserver — native JS to avoid bytecode callback issues
|
||||
K.registerNative("observe-intersection", function(args) {
|
||||
var el = args[0], callback = args[1], once = args[2], delay = args[3];
|
||||
var obs = new IntersectionObserver(function(entries) {
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (entries[i].isIntersecting) {
|
||||
var d = (delay && delay !== null) ? delay : 0;
|
||||
setTimeout(function() { K.callFn(callback, []); }, d);
|
||||
if (once) obs.unobserve(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(el);
|
||||
return obs;
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Load SX web libraries and adapters
|
||||
// ================================================================
|
||||
|
||||
// Load order follows dependency graph:
|
||||
// 1. Core spec files (parser, render, primitives already compiled into WASM kernel)
|
||||
// 2. Spec modules: signals, deps, router, page-helpers
|
||||
// 3. Bytecode compiler + VM (for JIT in browser)
|
||||
// 4. Web libraries: dom.sx, browser.sx (built on 8 FFI primitives)
|
||||
// 5. Web adapters: adapter-html, adapter-sx, adapter-dom
|
||||
// 6. Web framework: engine, orchestration, boot
|
||||
|
||||
var _baseUrl = "";
|
||||
|
||||
// Detect base URL and cache-bust params from current script tag.
|
||||
// _cacheBust comes from the script's own ?v= query string (used for .sx source fallback).
|
||||
// _sxbcCacheBust comes from data-sxbc-hash attribute — a separate content hash
|
||||
// covering all .sxbc files so each file gets its own correct cache buster.
|
||||
var _cacheBust = "";
|
||||
var _sxbcCacheBust = "";
|
||||
(function() {
|
||||
if (typeof document !== "undefined") {
|
||||
var scripts = document.getElementsByTagName("script");
|
||||
for (var i = scripts.length - 1; i >= 0; i--) {
|
||||
var src = scripts[i].src || "";
|
||||
if (src.indexOf("sx-platform") !== -1) {
|
||||
_baseUrl = src.substring(0, src.lastIndexOf("/") + 1);
|
||||
var qi = src.indexOf("?");
|
||||
if (qi !== -1) _cacheBust = src.substring(qi);
|
||||
var sxbcHash = scripts[i].getAttribute("data-sxbc-hash");
|
||||
if (sxbcHash) _sxbcCacheBust = "?v=" + sxbcHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Deserialize type-tagged JSON constant back to JS value for loadModule.
|
||||
*/
|
||||
function deserializeConstant(c) {
|
||||
if (!c || !c.t) return null;
|
||||
switch (c.t) {
|
||||
case 's': return c.v;
|
||||
case 'n': return c.v;
|
||||
case 'b': return c.v;
|
||||
case 'nil': return null;
|
||||
case 'sym': return { _type: 'symbol', name: c.v };
|
||||
case 'kw': return { _type: 'keyword', name: c.v };
|
||||
case 'list': return { _type: 'list', items: (c.v || []).map(deserializeConstant) };
|
||||
case 'code': return {
|
||||
_type: 'dict',
|
||||
bytecode: { _type: 'list', items: c.v.bytecode },
|
||||
constants: { _type: 'list', items: (c.v.constants || []).map(deserializeConstant) },
|
||||
arity: c.v.arity || 0,
|
||||
'upvalue-count': c.v['upvalue-count'] || 0,
|
||||
locals: c.v.locals || 0,
|
||||
};
|
||||
case 'dict': {
|
||||
var d = { _type: 'dict' };
|
||||
for (var k in c.v) d[k] = deserializeConstant(c.v[k]);
|
||||
return d;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed SX code form ({_type:"list", items:[symbol"code", ...]})
|
||||
* into the dict format that K.loadModule / js_to_value expects.
|
||||
* Mirrors the OCaml convert_code/convert_const in sx_browser.ml.
|
||||
*/
|
||||
function convertCodeForm(form) {
|
||||
if (!form || form._type !== "list" || !form.items || !form.items.length) return null;
|
||||
var items = form.items;
|
||||
if (!items[0] || items[0]._type !== "symbol" || items[0].name !== "code") return null;
|
||||
|
||||
var d = { _type: "dict", arity: 0, "upvalue-count": 0 };
|
||||
for (var i = 1; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
if (item && item._type === "keyword" && i + 1 < items.length) {
|
||||
var val = items[i + 1];
|
||||
if (item.name === "arity" || item.name === "upvalue-count") {
|
||||
d[item.name] = (typeof val === "number") ? val : 0;
|
||||
} else if (item.name === "bytecode" && val && val._type === "list") {
|
||||
d.bytecode = val; // {_type:"list", items:[numbers...]}
|
||||
} else if (item.name === "constants" && val && val._type === "list") {
|
||||
d.constants = { _type: "list", items: (val.items || []).map(convertConst) };
|
||||
}
|
||||
i++; // skip value
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function convertConst(c) {
|
||||
if (!c || typeof c !== "object") return c; // number, string, boolean, null pass through
|
||||
if (c._type === "list" && c.items && c.items.length > 0) {
|
||||
var head = c.items[0];
|
||||
if (head && head._type === "symbol" && head.name === "code") {
|
||||
return convertCodeForm(c);
|
||||
}
|
||||
if (head && head._type === "symbol" && head.name === "list") {
|
||||
return { _type: "list", items: c.items.slice(1).map(convertConst) };
|
||||
}
|
||||
}
|
||||
return c; // symbols, keywords, etc. pass through
|
||||
}
|
||||
|
||||
/**
|
||||
* Try loading a pre-compiled .sxbc bytecode module (SX text format).
|
||||
* Uses K.loadModule which handles VM suspension (import requests).
|
||||
* Returns true on success, null on failure (caller falls back to .sx source).
|
||||
*/
|
||||
function loadBytecodeFile(path) {
|
||||
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
|
||||
var url = _baseUrl + sxbcPath + _sxbcCacheBust;
|
||||
try {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, false);
|
||||
xhr.send();
|
||||
if (xhr.status !== 200) return null;
|
||||
|
||||
// Parse the sxbc text to get the SX tree
|
||||
var parsed = K.parse(xhr.responseText);
|
||||
if (!parsed || !parsed.length) return null;
|
||||
var sxbc = parsed[0]; // (sxbc version hash (code ...))
|
||||
if (!sxbc || sxbc._type !== "list" || !sxbc.items) return null;
|
||||
|
||||
// Extract the code form — 3rd or 4th item (after sxbc, version, optional hash)
|
||||
var codeForm = null;
|
||||
for (var i = 1; i < sxbc.items.length; i++) {
|
||||
var item = sxbc.items[i];
|
||||
if (item && item._type === "list" && item.items && item.items.length > 0 &&
|
||||
item.items[0] && item.items[0]._type === "symbol" && item.items[0].name === "code") {
|
||||
codeForm = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!codeForm) return null;
|
||||
|
||||
// Convert the SX code form to a dict for loadModule
|
||||
var moduleDict = convertCodeForm(codeForm);
|
||||
if (!moduleDict) return null;
|
||||
|
||||
// Load via K.loadModule which handles VmSuspended
|
||||
var result = K.loadModule(moduleDict);
|
||||
|
||||
// Handle import suspensions — fetch missing libraries on demand
|
||||
while (result && result.suspended && result.op === "import") {
|
||||
var req = result.request;
|
||||
var libName = req && req.library;
|
||||
if (libName) {
|
||||
// Try to find and load the library from the manifest
|
||||
var loaded = handleImportSuspension(libName);
|
||||
if (!loaded) {
|
||||
console.warn("[sx-platform] lazy import: library not found:", libName);
|
||||
}
|
||||
}
|
||||
// Resume the suspended module (null = library is now in env)
|
||||
result = result.resume(null);
|
||||
}
|
||||
|
||||
if (typeof result === 'string' && result.indexOf('Error') === 0) {
|
||||
console.warn("[sx-platform] bytecode FAIL " + path + ":", result);
|
||||
return null;
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
console.warn("[sx-platform] bytecode FAIL " + path + ":", e.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an import suspension by finding and loading the library.
|
||||
* The library name may be an SX value (list/string) — normalize to manifest key.
|
||||
*/
|
||||
function handleImportSuspension(libSpec) {
|
||||
// libSpec from the kernel is the library name spec, e.g. {_type:"list", items:[{name:"sx"},{name:"dom"}]}
|
||||
// or a string like "sx dom"
|
||||
var key;
|
||||
if (typeof libSpec === "string") {
|
||||
key = libSpec;
|
||||
} else if (libSpec && libSpec._type === "list" && libSpec.items) {
|
||||
key = libSpec.items.map(function(item) {
|
||||
return (item && item.name) ? item.name : String(item);
|
||||
}).join(" ");
|
||||
} else if (libSpec && libSpec._type === "dict") {
|
||||
// Dict with key/name fields
|
||||
key = libSpec.key || libSpec.name || "";
|
||||
} else {
|
||||
key = String(libSpec);
|
||||
}
|
||||
|
||||
if (_loadedLibs[key]) return true; // already loaded
|
||||
|
||||
if (!_manifest) loadManifest();
|
||||
if (!_manifest || !_manifest[key]) {
|
||||
console.warn("[sx-platform] lazy import: unknown library key '" + key + "'");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load the library (and its deps) on demand
|
||||
return loadLibrary(key, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an .sx file synchronously via XHR (boot-time only).
|
||||
* Returns the number of expressions loaded, or an error string.
|
||||
*/
|
||||
function loadSxFile(path) {
|
||||
var url = _baseUrl + path + _cacheBust;
|
||||
try {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, false); // synchronous
|
||||
xhr.send();
|
||||
if (xhr.status === 200) {
|
||||
var result = K.load(xhr.responseText);
|
||||
if (typeof result === "string" && result.indexOf("Error") === 0) {
|
||||
console.error("[sx-platform] FAIL " + path + ":", result);
|
||||
return 0;
|
||||
}
|
||||
console.log("[sx-platform] ok " + path + " (" + result + " exprs)");
|
||||
return result;
|
||||
} else {
|
||||
console.error("[sx] Failed to fetch " + path + ": HTTP " + xhr.status);
|
||||
return null;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("[sx] Failed to load " + path + ":", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Manifest-driven module loader — only loads what's needed
|
||||
// ================================================================
|
||||
|
||||
var _manifest = null;
|
||||
var _loadedLibs = {};
|
||||
|
||||
/**
|
||||
* Fetch and parse the module manifest (library deps + file paths).
|
||||
*/
|
||||
function loadManifest() {
|
||||
if (_manifest) return _manifest;
|
||||
try {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", _baseUrl + "sx/module-manifest.json" + _cacheBust, false);
|
||||
xhr.send();
|
||||
if (xhr.status === 200) {
|
||||
_manifest = JSON.parse(xhr.responseText);
|
||||
return _manifest;
|
||||
}
|
||||
} catch(e) {}
|
||||
console.warn("[sx-platform] No manifest found, falling back to full load");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single library and all its dependencies (recursive).
|
||||
* Cycle-safe: tracks in-progress loads to break circular deps.
|
||||
* Functions in cyclic modules resolve symbols at call time via global env.
|
||||
*/
|
||||
function loadLibrary(name, loading) {
|
||||
if (_loadedLibs[name]) return true;
|
||||
if (loading[name]) return true; // cycle — skip
|
||||
loading[name] = true;
|
||||
|
||||
var info = _manifest[name];
|
||||
if (!info) {
|
||||
console.warn("[sx-platform] Unknown library: " + name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve deps first
|
||||
for (var i = 0; i < info.deps.length; i++) {
|
||||
loadLibrary(info.deps[i], loading);
|
||||
}
|
||||
|
||||
// Mark as loaded BEFORE executing — self-imports (define-library re-exports)
|
||||
// will see it as already loaded and skip rather than infinite-looping.
|
||||
_loadedLibs[name] = true;
|
||||
|
||||
// Load this module
|
||||
var ok = loadBytecodeFile("sx/" + info.file);
|
||||
if (!ok) {
|
||||
var sxFile = info.file.replace(/\.sxbc$/, '.sx');
|
||||
ok = loadSxFile("sx/" + sxFile);
|
||||
}
|
||||
return !!ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load web stack using the module manifest.
|
||||
* Only downloads libraries that the entry point transitively depends on.
|
||||
*/
|
||||
function loadWebStack() {
|
||||
var manifest = loadManifest();
|
||||
if (!manifest) return loadWebStackFallback();
|
||||
|
||||
var entry = manifest["_entry"];
|
||||
if (!entry) {
|
||||
console.warn("[sx-platform] No _entry in manifest, falling back");
|
||||
return loadWebStackFallback();
|
||||
}
|
||||
|
||||
var loading = {};
|
||||
var t0 = performance.now();
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
|
||||
// Load all entry point deps recursively
|
||||
for (var i = 0; i < entry.deps.length; i++) {
|
||||
loadLibrary(entry.deps[i], loading);
|
||||
}
|
||||
|
||||
// Load entry point itself (boot.sx — not a library, just defines + init)
|
||||
loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx'));
|
||||
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
|
||||
var dt = Math.round(performance.now() - t0);
|
||||
console.log("[sx-platform] Loaded " + count + " modules in " + dt + "ms (manifest-driven)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: load all files in hardcoded order (pre-manifest compat).
|
||||
*/
|
||||
function loadWebStackFallback() {
|
||||
var files = [
|
||||
"sx/render.sx", "sx/core-signals.sx", "sx/signals.sx", "sx/deps.sx",
|
||||
"sx/router.sx", "sx/page-helpers.sx", "sx/freeze.sx", "sx/highlight.sx",
|
||||
"sx/bytecode.sx", "sx/compiler.sx", "sx/vm.sx", "sx/dom.sx", "sx/browser.sx",
|
||||
"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",
|
||||
];
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
if (!loadBytecodeFile(files[i])) loadSxFile(files[i]);
|
||||
}
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
console.log("[sx-platform] Loaded " + files.length + " files (fallback)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional library on demand (e.g., highlight, harness).
|
||||
* Can be called after boot for pages that need extra modules.
|
||||
*/
|
||||
globalThis.__sxLoadLibrary = function(name) {
|
||||
if (!_manifest) loadManifest();
|
||||
if (!_manifest) return false;
|
||||
if (_loadedLibs[name]) return true;
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
var ok = loadLibrary(name, {});
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
return ok;
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Transparent lazy loading — symbol → library index
|
||||
//
|
||||
// When the VM hits an undefined symbol, the resolve hook checks this
|
||||
// index, loads the library that exports it, and returns the value.
|
||||
// The programmer just calls the function — loading is invisible.
|
||||
// ================================================================
|
||||
|
||||
var _symbolIndex = null; // symbol name → library key
|
||||
|
||||
function buildSymbolIndex() {
|
||||
if (_symbolIndex) return _symbolIndex;
|
||||
if (!_manifest) loadManifest();
|
||||
if (!_manifest) return null;
|
||||
_symbolIndex = {};
|
||||
for (var key in _manifest) {
|
||||
if (key.startsWith('_')) continue;
|
||||
var entry = _manifest[key];
|
||||
if (entry.exports) {
|
||||
for (var i = 0; i < entry.exports.length; i++) {
|
||||
_symbolIndex[entry.exports[i]] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return _symbolIndex;
|
||||
}
|
||||
|
||||
// Register the resolve hook — called by the VM when GLOBAL_GET fails
|
||||
K.registerNative("__resolve-symbol", function(args) {
|
||||
var name = args[0];
|
||||
if (!name) return null;
|
||||
var idx = buildSymbolIndex();
|
||||
if (!idx || !idx[name]) return null;
|
||||
var lib = idx[name];
|
||||
if (_loadedLibs[lib]) return null; // already loaded but symbol still missing — real error
|
||||
// Load the library
|
||||
__sxLoadLibrary(lib);
|
||||
// Return null — the VM will re-lookup in globals after the hook loads the module
|
||||
return null;
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Compatibility shim — expose Sx global matching current JS API
|
||||
// ================================================================
|
||||
|
||||
globalThis.Sx = {
|
||||
VERSION: "wasm-1.0",
|
||||
parse: function(src) { return K.parse(src); },
|
||||
eval: function(src) { return K.eval(src); },
|
||||
load: function(src) { return K.load(src); },
|
||||
renderToHtml: function(expr) { return K.renderToHtml(expr); },
|
||||
callFn: function(fn, args) { return K.callFn(fn, args); },
|
||||
engine: function() { return K.engine(); },
|
||||
// Boot entry point (called by auto-init or manually)
|
||||
init: function() {
|
||||
if (typeof K.eval === "function") {
|
||||
// Check boot-init exists
|
||||
// Step through boot manually
|
||||
console.log("[sx] init-css-tracking...");
|
||||
K.eval("(init-css-tracking)");
|
||||
console.log("[sx] process-page-scripts...");
|
||||
K.eval("(process-page-scripts)");
|
||||
console.log("[sx] routes after pages:", K.eval("(len _page-routes)"));
|
||||
console.log("[sx] process-sx-scripts...");
|
||||
K.eval("(process-sx-scripts nil)");
|
||||
console.log("[sx] sx-hydrate-elements...");
|
||||
K.eval("(sx-hydrate-elements nil)");
|
||||
console.log("[sx] sx-hydrate-islands...");
|
||||
K.eval("(sx-hydrate-islands nil)");
|
||||
console.log("[sx] process-elements...");
|
||||
K.eval("(process-elements nil)");
|
||||
// Debug islands
|
||||
console.log("[sx] ~home/stepper defined?", K.eval("(type-of ~home/stepper)"));
|
||||
console.log("[sx] ~layouts/header defined?", K.eval("(type-of ~layouts/header)"));
|
||||
// Island count (JS-side, avoids VM overhead)
|
||||
console.log("[sx] manual island query:", document.querySelectorAll("[data-sx-island]").length);
|
||||
// Try hydrating again
|
||||
console.log("[sx] retry hydrate-islands...");
|
||||
K.eval("(sx-hydrate-islands nil)");
|
||||
// Check if links are boosted
|
||||
var links = document.querySelectorAll("a[href]");
|
||||
var boosted = 0;
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if (links[i]._sxBoundboost) boosted++;
|
||||
}
|
||||
console.log("[sx] boosted links:", boosted, "/", links.length);
|
||||
// Check island state
|
||||
var islands = document.querySelectorAll("[data-sx-island]");
|
||||
console.log("[sx] islands:", islands.length);
|
||||
for (var j = 0; j < islands.length; j++) {
|
||||
console.log("[sx] island:", islands[j].getAttribute("data-sx-island"),
|
||||
"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 + ")");
|
||||
});
|
||||
// Signal boot complete
|
||||
document.documentElement.setAttribute("data-sx-ready", "true");
|
||||
console.log("[sx] boot done");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Auto-init: load web stack and boot on DOMContentLoaded
|
||||
// ================================================================
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
var _doInit = function() {
|
||||
loadWebStack();
|
||||
Sx.init();
|
||||
// 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", {});
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
K.eval('(enable-jit!)');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _doInit);
|
||||
} else {
|
||||
_doInit();
|
||||
}
|
||||
}
|
||||
|
||||
} // end boot
|
||||
|
||||
// SxKernel is available synchronously (js_of_ocaml) or after async
|
||||
// WASM init. Poll briefly to handle both cases.
|
||||
var K = globalThis.SxKernel;
|
||||
if (K) { boot(K); return; }
|
||||
var tries = 0;
|
||||
var poll = setInterval(function() {
|
||||
K = globalThis.SxKernel;
|
||||
if (K) { clearInterval(poll); boot(K); }
|
||||
else if (++tries > 100) { clearInterval(poll); console.error("[sx-platform] SxKernel not found after 5s"); }
|
||||
}, 50);
|
||||
})();
|
||||
60695
hosts/ocaml/shared/static/wasm/sx_browser.bc.js
Normal file
60695
hosts/ocaml/shared/static/wasm/sx_browser.bc.js
Normal file
File diff suppressed because one or more lines are too long
1821
hosts/ocaml/shared/static/wasm/sx_browser.bc.wasm.js
Normal file
1821
hosts/ocaml/shared/static/wasm/sx_browser.bc.wasm.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user