1. parse-trigger-spec: strip [condition] from event names, store as "filter" modifier 2. bind-event: native SX filter for key=='X' patterns (extracts key char and checks event.key + not-input guard) 3. bind-event from: modifier: resolve "body"/"document"/"window" to direct DOM references instead of dom-query 4. sx-platform-2.js: global keyboard dispatch — WASM host-callbacks on document/body don't fire, so keyboard triggers with from:body are handled from JS, calling execute-request via K.eval 5. bind-inline-handlers: map afterSwap/beforeRequest to sx: prefix, eval JS bodies via Function constructor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
453 lines
17 KiB
JavaScript
453 lines
17 KiB
JavaScript
/**
|
|
* 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) {
|
|
|
|
// ================================================================
|
|
// 8 FFI Host Primitives
|
|
// ================================================================
|
|
|
|
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 — 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);
|
|
return K.callFn(fn, a);
|
|
};
|
|
}
|
|
return function() {};
|
|
});
|
|
|
|
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";
|
|
});
|
|
|
|
// ================================================================
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try loading a pre-compiled .sxbc bytecode module (SX text format).
|
|
* 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;
|
|
|
|
window.__sxbcText = xhr.responseText;
|
|
var result = K.eval('(load-sxbc (first (parse (host-global "__sxbcText"))))');
|
|
delete window.__sxbcText;
|
|
if (typeof result === 'string' && result.indexOf('Error') === 0) {
|
|
console.warn("[sx-platform] bytecode FAIL " + path + ":", result);
|
|
return null;
|
|
}
|
|
return true;
|
|
} catch(e) {
|
|
delete window.__sxbcText;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load all web adapter .sx files in dependency order.
|
|
* Tries pre-compiled bytecode first, falls back to source.
|
|
*/
|
|
function loadWebStack() {
|
|
var files = [
|
|
// Spec modules
|
|
"sx/render.sx",
|
|
"sx/core-signals.sx",
|
|
"sx/signals.sx",
|
|
"sx/deps.sx",
|
|
"sx/router.sx",
|
|
"sx/page-helpers.sx",
|
|
// Freeze scope (signal persistence) + highlight (syntax coloring)
|
|
"sx/freeze.sx",
|
|
"sx/highlight.sx",
|
|
// Bytecode compiler + VM
|
|
"sx/bytecode.sx",
|
|
"sx/compiler.sx",
|
|
"sx/vm.sx",
|
|
// Web libraries (use 8 FFI primitives)
|
|
"sx/dom.sx",
|
|
"sx/browser.sx",
|
|
// Web adapters
|
|
"sx/adapter-html.sx",
|
|
"sx/adapter-sx.sx",
|
|
"sx/adapter-dom.sx",
|
|
// Boot helpers (platform functions in pure SX)
|
|
"sx/boot-helpers.sx",
|
|
"sx/hypersx.sx",
|
|
// Test harness (for inline test runners)
|
|
"sx/harness.sx",
|
|
"sx/harness-reactive.sx",
|
|
"sx/harness-web.sx",
|
|
// Web framework
|
|
"sx/engine.sx",
|
|
"sx/orchestration.sx",
|
|
"sx/boot.sx",
|
|
];
|
|
|
|
var loaded = 0, bcCount = 0, srcCount = 0;
|
|
if (K.beginModuleLoad) K.beginModuleLoad();
|
|
for (var i = 0; i < files.length; i++) {
|
|
var r = loadBytecodeFile(files[i]);
|
|
if (r) { bcCount++; continue; }
|
|
// Bytecode not available — end batch, load source, restart batch
|
|
if (K.endModuleLoad) K.endModuleLoad();
|
|
r = loadSxFile(files[i]);
|
|
if (typeof r === "number") { loaded += r; srcCount++; }
|
|
if (K.beginModuleLoad) K.beginModuleLoad();
|
|
}
|
|
if (K.endModuleLoad) K.endModuleLoad();
|
|
console.log("[sx-platform] Loaded " + files.length + " files (" + bcCount + " bytecode, " + srcCount + " source, " + loaded + " exprs)");
|
|
return loaded;
|
|
}
|
|
|
|
// ================================================================
|
|
// 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);
|
|
}
|
|
console.log("[sx] boot done");
|
|
|
|
// Global keyboard shortcut dispatch — WASM host-callbacks on
|
|
// document/body don't fire, so handle from:body keyboard
|
|
// triggers in JS and call execute-request via the SX engine.
|
|
document.addEventListener("keyup", function(e) {
|
|
if (e.target && e.target.matches && e.target.matches("input,textarea,select")) return;
|
|
var sel = '[sx-trigger*="key==\'' + e.key + '\'"]';
|
|
var els = document.querySelectorAll(sel);
|
|
for (var i = 0; i < els.length; i++) {
|
|
var el = els[i];
|
|
if (!el.id) el.id = "_sx_kbd_" + Math.random().toString(36).slice(2);
|
|
try {
|
|
K.eval('(execute-request (dom-query-by-id "' + el.id + '") nil nil)');
|
|
} catch(err) { console.warn("[sx] keyboard dispatch error:", err); }
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// ================================================================
|
|
// 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
|
|
setTimeout(function() { 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);
|
|
})();
|