Bytecode modules are now serialized as s-expressions (.sxbc) in addition to JSON (.sxbc.json). The .sxbc format is the canonical representation — content-addressable, parseable by the SX parser, and suitable for CID referencing. Annotation layers (source maps, variable names, tests, docs) can reference the bytecode CID without polluting the bytecode itself. Format: (sxbc version hash (code :arity N :bytecode (...) :constants (...))) The browser loader tries .sxbc first (via load-sxbc kernel primitive), falls back to .sxbc.json. Caddy needs .sxbc MIME type to serve the new format (currently 404s, JSON fallback works). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
456 lines
16 KiB
JavaScript
456 lines
16 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 from current script
|
|
var _cacheBust = "";
|
|
(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);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* Deserialize type-tagged JSON constant back to JS value for loadModule.
|
|
* (Legacy — used for .sxbc.json fallback)
|
|
*/
|
|
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 bytecode module.
|
|
* Tries .sxbc (s-expression) first, falls back to .sxbc.json (legacy).
|
|
* Returns true on success, null on failure (caller falls back to .sx source).
|
|
*/
|
|
function loadBytecodeFile(path) {
|
|
// Try .sxbc (s-expression format) first
|
|
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
|
|
var url = _baseUrl + sxbcPath + _cacheBust;
|
|
try {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("GET", url, false);
|
|
xhr.send();
|
|
if (xhr.status === 200 && xhr.responseText.indexOf("(sxbc") === 0) {
|
|
// Parse with the SX kernel — returns the (sxbc ...) form
|
|
var parsed = K.eval('(first (sx-parse ' + JSON.stringify(xhr.responseText) + '))');
|
|
// (sxbc version hash (code ...)) — third element is the module
|
|
var module = K.eval('(nth (sx-parse ' + JSON.stringify(xhr.responseText) + ') 0)');
|
|
// Use evalSxbc which understands the sxbc format
|
|
var result = K.eval('(load-sxbc (first (sx-parse ' + JSON.stringify(xhr.responseText) + ')))');
|
|
if (typeof result === 'string' && result.indexOf('Error') === 0) {
|
|
console.warn("[sx-platform] sxbc FAIL " + path + ":", result);
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
} catch(e) {
|
|
// Fall through to JSON
|
|
}
|
|
|
|
// Fallback: .sxbc.json (legacy format)
|
|
var bcPath = path.replace(/\.sx$/, '.sxbc.json');
|
|
url = _baseUrl + bcPath + _cacheBust;
|
|
try {
|
|
var xhr2 = new XMLHttpRequest();
|
|
xhr2.open("GET", url, false);
|
|
xhr2.send();
|
|
if (xhr2.status !== 200) return null;
|
|
|
|
var json = JSON.parse(xhr2.responseText);
|
|
if (!json.module || json.magic !== 'SXBC') return null;
|
|
|
|
var module2 = {
|
|
_type: 'dict',
|
|
bytecode: { _type: 'list', items: json.module.bytecode },
|
|
constants: { _type: 'list', items: json.module.constants.map(deserializeConstant) },
|
|
};
|
|
|
|
var result2 = K.loadModule(module2);
|
|
if (typeof result2 === 'string' && result2.indexOf('Error') === 0) {
|
|
console.warn("[sx-platform] bytecode FAIL " + path + ":", result2);
|
|
return null;
|
|
}
|
|
return true;
|
|
} catch(e) {
|
|
console.error("[sx-platform] bytecode EXCEPTION " + path + ":", e);
|
|
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)
|
|
"sx/freeze.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;
|
|
var t0 = performance.now();
|
|
var forceSrc = (typeof window !== "undefined" && window.location.search.indexOf("nosxbc") !== -1);
|
|
if (!forceSrc && K.beginModuleLoad) K.beginModuleLoad();
|
|
for (var i = 0; i < files.length; i++) {
|
|
if (!forceSrc) {
|
|
var r = loadBytecodeFile(files[i]);
|
|
if (r) { bcCount++; continue; }
|
|
}
|
|
// Bytecode not available — end batch, load source, restart batch
|
|
if (!forceSrc && K.endModuleLoad) K.endModuleLoad();
|
|
r = loadSxFile(files[i]);
|
|
if (typeof r === "number") { loaded += r; srcCount++; }
|
|
if (!forceSrc && K.beginModuleLoad) K.beginModuleLoad();
|
|
}
|
|
if (!forceSrc && K.endModuleLoad) K.endModuleLoad();
|
|
var elapsed = Math.round(performance.now() - t0);
|
|
console.log("[sx-platform] Loaded " + files.length + " files (" + bcCount + " bytecode, " + srcCount + " source, " + loaded + " exprs) in " + elapsed + "ms");
|
|
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") return;
|
|
var steps = [
|
|
'(log-info (str "sx-browser " SX_VERSION))',
|
|
'(init-css-tracking)',
|
|
'(process-page-scripts)',
|
|
'(process-sx-scripts nil)',
|
|
'(sx-hydrate-elements nil)',
|
|
'(sx-hydrate-islands nil)',
|
|
'(run-post-render-hooks)',
|
|
'(process-elements nil)',
|
|
'(dom-listen (dom-window) "popstate" (fn (e) (handle-popstate 0)))'
|
|
];
|
|
var failures = [];
|
|
for (var i = 0; i < steps.length; i++) {
|
|
try {
|
|
var r = K.eval(steps[i]);
|
|
if (typeof r === "string" && r.indexOf("Error") === 0) {
|
|
console.error("[sx] boot step " + i + " FAILED: " + steps[i].substring(0, 60), r);
|
|
failures.push({ step: i, expr: steps[i], error: r });
|
|
}
|
|
} catch(e) {
|
|
console.error("[sx] boot step " + i + " THREW: " + steps[i].substring(0, 60), e);
|
|
failures.push({ step: i, expr: steps[i], error: String(e) });
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
console.error("[sx] BOOT FAILED: " + failures.length + " step(s) errored:", failures.map(function(f) { return "step " + f.step + ": " + f.error; }).join("; "));
|
|
}
|
|
}
|
|
};
|
|
|
|
// ================================================================
|
|
// Auto-init: load web stack and boot on DOMContentLoaded
|
|
// ================================================================
|
|
|
|
if (typeof document !== "undefined") {
|
|
var _doInit = function() {
|
|
loadWebStack();
|
|
Sx.init();
|
|
// JIT disabled for debugging
|
|
// 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);
|
|
})();
|