diff --git a/lib/hyperscript/runtime.sx b/lib/hyperscript/runtime.sx index 8b71b93e..06b5b3a4 100644 --- a/lib/hyperscript/runtime.sx +++ b/lib/hyperscript/runtime.sx @@ -172,7 +172,7 @@ (n thunk) (define do-repeat - (fn (i) (when (< i n) (thunk) (do-repeat (+ i 1))))) + (fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1)))))) (do-repeat 0))) ;; Repeat forever (until break — relies on exception/continuation). diff --git a/shared/static/wasm/sx/hs-runtime.sx b/shared/static/wasm/sx/hs-runtime.sx new file mode 100644 index 00000000..c277c351 --- /dev/null +++ b/shared/static/wasm/sx/hs-runtime.sx @@ -0,0 +1,316 @@ +;; _hyperscript runtime shims +;; +;; Thin wrappers over web/lib/dom.sx and web/lib/browser.sx primitives +;; that implement hyperscript-specific semantics (async transparency, +;; class toggling, event waiting, iteration, type coercion). +;; +;; These are the functions that hs-to-sx (compiler.sx) emits calls to. +;; Each is a pure define — no platform dependency beyond the DOM/browser +;; primitives already available in the SX web platform. + +;; ── Event handling ────────────────────────────────────────────── + +;; Register an event listener. Returns unlisten function. +;; (hs-on target event-name handler) → unlisten-fn +(define + hs-on + (fn (target event-name handler) (dom-listen target event-name handler))) + +;; Register for every occurrence (no queuing — each fires independently). +;; Stock hyperscript queues by default; "every" disables queuing. +(define + hs-on-every + (fn (target event-name handler) (dom-listen target event-name handler))) + +;; Run an initializer function immediately. +;; (hs-init thunk) — called at element boot time +(define hs-init (fn (thunk) (thunk))) + +;; ── Async / timing ────────────────────────────────────────────── + +;; Wait for a duration in milliseconds. +;; In hyperscript, wait is async-transparent — execution pauses. +;; Here we use perform/IO suspension for true pause semantics. +(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms)))) + +;; Wait for a DOM event on a target. +;; (hs-wait-for target event-name) — suspends until event fires +(define + hs-wait-for + (fn + (target event-name) + (perform (list (quote io-wait-event) target event-name)))) + +;; Wait for CSS transitions/animations to settle on an element. +(define hs-settle (fn (target) (perform (list (quote io-settle) target)))) + +;; ── Class manipulation ────────────────────────────────────────── + +;; Toggle a single class on an element. +(define + hs-toggle-class! + (fn + (target cls) + (if + (dom-has-class? target cls) + (dom-remove-class target cls) + (dom-add-class target cls)))) + +;; Toggle between two classes — exactly one is active at a time. +(define + hs-toggle-between! + (fn + (target cls1 cls2) + (if + (dom-has-class? target cls1) + (do (dom-remove-class target cls1) (dom-add-class target cls2)) + (do (dom-remove-class target cls2) (dom-add-class target cls1))))) + +;; Take a class from siblings — add to target, remove from others. +;; (hs-take! target cls) — like radio button class behavior +(define + hs-take! + (fn + (target cls) + (let + ((parent (dom-parent target))) + (when + parent + (for-each + (fn (child) (dom-remove-class child cls)) + (dom-child-list parent))) + (dom-add-class target cls)))) + +;; ── DOM insertion ─────────────────────────────────────────────── + +;; Put content at a position relative to a target. +;; pos: "into" | "before" | "after" +(define + hs-put! + (fn + (value pos target) + (cond + ((= pos "into") (dom-set-inner-html target value)) + ((= pos "before") + (dom-insert-adjacent-html target "beforebegin" value)) + ((= pos "after") (dom-insert-adjacent-html target "afterend" value))))) + +;; ── Navigation / traversal ────────────────────────────────────── + +;; Navigate to a URL. +(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url)))) + +;; Find next sibling matching a selector (or any sibling). +(define + hs-next + (fn + (target sel) + (if + (= sel "*") + (dom-next-sibling target) + (let + ((sibling (dom-next-sibling target))) + (define + find-next + (fn + (el) + (cond + ((nil? el) nil) + ((dom-matches? el sel) el) + (true (find-next (dom-next-sibling el)))))) + (find-next sibling))))) + +;; Find previous sibling matching a selector. +(define + hs-previous + (fn + (target sel) + (if + (= sel "*") + (dom-get-prop target "previousElementSibling") + (let + ((sibling (dom-get-prop target "previousElementSibling"))) + (define + find-prev + (fn + (el) + (cond + ((nil? el) nil) + ((dom-matches? el sel) el) + (true (find-prev (dom-get-prop el "previousElementSibling")))))) + (find-prev sibling))))) + +;; First element matching selector within a scope. +(define hs-query-first (fn (sel) (dom-query sel))) + +;; Last element matching selector. +(define + hs-query-last + (fn + (sel) + (let + ((all (dom-query-all (dom-body) sel))) + (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) + +;; First/last within a specific scope. +(define hs-first (fn (scope sel) (dom-query-all scope sel))) + +(define + hs-last + (fn + (scope sel) + (let + ((all (dom-query-all scope sel))) + (if (> (len all) 0) (nth all (- (len all) 1)) nil)))) + +;; ── Iteration ─────────────────────────────────────────────────── + +;; Repeat a thunk N times. +(define + hs-repeat-times + (fn + (n thunk) + (define + do-repeat + (fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1)))))) + (do-repeat 0))) + +;; Repeat forever (until break — relies on exception/continuation). +(define + hs-repeat-forever + (fn + (thunk) + (define do-forever (fn () (thunk) (do-forever))) + (do-forever))) + +;; ── Fetch ─────────────────────────────────────────────────────── + +;; Fetch a URL, parse response according to format. +;; (hs-fetch url format) — format is "json" | "text" | "html" +(define + hs-fetch + (fn + (url format) + (let + ((response (perform (list (quote io-fetch) url)))) + (cond + ((= format "json") (perform (list (quote io-parse-json) response))) + ((= format "text") (perform (list (quote io-parse-text) response))) + ((= format "html") (perform (list (quote io-parse-html) response))) + (true response))))) + +;; ── Type coercion ─────────────────────────────────────────────── + +;; Coerce a value to a type by name. +;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc. +(define + hs-coerce + (fn + (value type-name) + (cond + ((= type-name "Int") (floor (+ value 0))) + ((= type-name "Integer") (floor (+ value 0))) + ((= type-name "Float") (+ value 0)) + ((= type-name "Number") (+ value 0)) + ((= type-name "String") (str value)) + ((= type-name "Boolean") (if value true false)) + ((= type-name "Array") (if (list? value) value (list value))) + (true value)))) + +;; ── Object creation ───────────────────────────────────────────── + +;; Make a new object of a given type. +;; (hs-make type-name) — creates empty object/collection +(define + hs-add + (fn (a b) (if (or (string? a) (string? b)) (str a b) (+ a b)))) + +;; ── Behavior installation ─────────────────────────────────────── + +;; Install a behavior on an element. +;; A behavior is a function that takes (me ...params) and sets up features. +;; (hs-install behavior-fn me ...args) +(define + hs-make + (fn + (type-name) + (cond + ((= type-name "Object") (dict)) + ((= type-name "Array") (list)) + ((= type-name "Set") (list)) + ((= type-name "Map") (dict)) + (true (dict))))) + +;; ── Measurement ───────────────────────────────────────────────── + +;; Measure an element's bounding rect, store as local variables. +;; Returns a dict with x, y, width, height, top, left, right, bottom. +(define hs-install (fn (behavior-fn) (behavior-fn me))) + +;; ── Transition ────────────────────────────────────────────────── + +;; Transition a CSS property to a value, optionally with duration. +;; (hs-transition target prop value duration) +(define + hs-measure + (fn (target) (perform (list (quote io-measure) target)))) + +(define + hs-transition + (fn + (target prop value duration) + (when + duration + (dom-set-style + target + "transition" + (str prop " " (/ duration 1000) "s"))) + (dom-set-style target prop value) + (when duration (hs-settle target)))) + +(define + hs-type-check + (fn + (value type-name) + (if + (nil? value) + true + (cond + ((= type-name "Number") (number? value)) + ((= type-name "String") (string? value)) + ((= type-name "Boolean") (or (= value true) (= value false))) + ((= type-name "Array") (list? value)) + ((= type-name "Object") (dict? value)) + (true true))))) + +(define + hs-type-check! + (fn + (value type-name) + (if (nil? value) false (hs-type-check value type-name)))) + +(define + hs-strict-eq + (fn (a b) (and (= (type-of a) (type-of b)) (= a b)))) + +(define + hs-falsy? + (fn (v) (or (nil? v) (= v false) (and (string? v) (= v ""))))) + +(define + hs-matches? + (fn + (target pattern) + (if + (string? target) + (if (= pattern ".*") true (string-contains? target pattern)) + false))) + +(define + hs-contains? + (fn + (collection item) + (cond + ((list? collection) (some (fn (x) (= x item)) collection)) + ((string? collection) (string-contains? collection item)) + (true false)))) \ No newline at end of file diff --git a/tests/playwright/sx-inspect.js b/tests/playwright/sx-inspect.js index 93c7dab9..c0fe7aa7 100644 --- a/tests/playwright/sx-inspect.js +++ b/tests/playwright/sx-inspect.js @@ -1296,6 +1296,339 @@ async function modeEvalAt(browser, url, phase, expr) { return { url, phase, expr, result: evalResult, bootLog: bootLogs }; } +// --------------------------------------------------------------------------- +// Mode: sandbox — offline WASM kernel in a blank page, no server needed +// +// General-purpose browser test environment. Injects the real WASM kernel +// into about:blank with full FFI, IO suspension, and DOM. +// +// Predefined stacks (via stack param): +// "core" — kernel + FFI only (default) +// "web" — full web stack (render, signals, DOM, adapters, boot) +// "hs" — web + hyperscript modules +// "test" — web + test framework +// +// Custom files loaded after the stack. Setup expression runs before eval. +// All IO suspensions are traced. Results formatted via SX inspect. +// --------------------------------------------------------------------------- + +const SANDBOX_STACKS = { + // Paths relative to shared/static/wasm/sx/ + web: [ + 'render', 'core-signals', 'signals', 'deps', 'router', + 'page-helpers', 'freeze', 'dom', 'browser', + 'adapter-html', 'adapter-sx', 'adapter-dom', + 'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot', + ], + hs: [ + // web stack first, then hyperscript from lib/ + '_web_', + 'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration', + ], + test: [ + '_web_', + 'harness', 'harness-reactive', 'harness-web', + ], +}; + +async function modeSandbox(page, expr, files, setup, stack) { + const fs = require('fs'); + const path = require('path'); + const PROJECT_ROOT = path.resolve(__dirname, '../..'); + const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm'); + const SX_DIR = path.join(WASM_DIR, 'sx'); + + const consoleLogs = []; + page.on('console', msg => { + consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) }); + }); + + // 1. Navigate to blank page + await page.goto('about:blank'); + + // 2. Inject WASM kernel + const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'); + await page.addScriptTag({ content: kernelSrc }); + await page.waitForFunction('!!window.SxKernel', { timeout: 10000 }); + + // 3. Register FFI primitives + IO suspension driver + await page.evaluate(() => { + const K = window.SxKernel; + + K.registerNative('host-global', args => { + const n = args[0]; + return (n in globalThis) ? globalThis[n] : null; + }); + K.registerNative('host-get', args => { + if (args[0] == null) return null; + const v = args[0][args[1]]; + return v === undefined ? null : v; + }); + K.registerNative('host-set!', args => { + if (args[0] != null) args[0][args[1]] = args[2]; + return args[2]; + }); + K.registerNative('host-call', args => { + const [obj, method, ...rest] = args; + if (obj == null) { + const fn = globalThis[method]; + if (typeof fn === 'function') return fn.apply(null, rest); + return null; + } + if (typeof obj[method] !== 'function') return null; + try { const r = obj[method].apply(obj, rest); return r === undefined ? null : r; } + catch(e) { console.error('[sandbox] host-call error:', method, e.message); return null; } + }); + K.registerNative('host-new', args => { + const name = args[0]; + const cArgs = args.slice(1); + const Ctor = typeof name === 'string' ? globalThis[name] : name; + if (typeof Ctor !== 'function') return null; + return new Ctor(...cArgs); + }); + K.registerNative('host-callback', args => { + const fn = args[0]; + if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn; + if (fn && fn.__sx_handle !== undefined) { + return function() { + const r = K.callFn(fn, Array.from(arguments)); + window._driveAsync(r); + return r; + }; + } + return function() {}; + }); + K.registerNative('host-typeof', args => { + const 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'; + return typeof obj; + }); + K.registerNative('host-await', args => { + const [promise, callback] = args; + if (promise && typeof promise.then === 'function') { + const cb = (callback && callback.__sx_handle !== undefined) + ? v => K.callFn(callback, [v]) + : () => {}; + promise.then(cb); + } + }); + K.registerNative('load-library!', args => false); + + // IO suspension driver — traces every suspension/resume + // _asyncPending tracks in-flight IO chains so the sandbox can wait for completion + window._ioTrace = []; + window._asyncPending = 0; + window._driveAsync = function driveAsync(result) { + if (!result || !result.suspended) { return; } + window._asyncPending++; + const req = result.request; + const items = req && (req.items || req); + const op = items && items[0]; + const opName = typeof op === 'string' ? op : (op && op.name) || String(op); + const arg = items && items[1]; + window._ioTrace.push({ op: opName, arg: arg }); + + function doResume(val, delay) { + setTimeout(() => { + try { + const resumed = result.resume(val); + window._asyncPending--; + driveAsync(resumed); + } catch(e) { + window._asyncPending--; + console.error('[sandbox] resume error:', e.message); + window._ioTrace.push({ op: 'ERROR', arg: e.message }); + } + }, delay); + } + + if (opName === 'io-sleep' || opName === 'wait') { + doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10)); + } else if (opName === 'io-navigate') { + window._asyncPending--; + } else if (opName === 'io-fetch') { + doResume({ ok: true, text: '' }, 1); + } else { + window._asyncPending--; + console.warn('[sandbox] unhandled IO:', opName); + window._ioTrace.push({ op: 'unhandled', arg: opName }); + } + }; + + K.eval('(define SX_VERSION "sandbox-1.0")'); + K.eval('(define SX_ENGINE "ocaml-vm-sandbox")'); + K.eval('(define parse sx-parse)'); + K.eval('(define serialize sx-serialize)'); + }); + + // 4. Load stack modules + const loadErrors = []; + const loadedModules = []; + + function resolveStack(name) { + const mods = SANDBOX_STACKS[name] || []; + const result = []; + for (const m of mods) { + if (m === '_web_') result.push(...resolveStack('web')); + else result.push(m); + } + return result; + } + + if (stack && stack !== 'core') { + const modules = resolveStack(stack); + if (modules.length > 0) { + // beginModuleLoad if available + await page.evaluate(() => { + if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad(); + }); + for (const mod of modules) { + // Try .sx file in wasm/sx/ dir + const sxPath = path.join(SX_DIR, mod + '.sx'); + // Also try lib/hyperscript/ for hs- prefixed modules + const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx'); + let src; + try { + src = fs.existsSync(sxPath) + ? fs.readFileSync(sxPath, 'utf8') + : fs.readFileSync(libPath, 'utf8'); + } catch(e) { + loadErrors.push(mod + ': file not found'); + continue; + } + const err = await page.evaluate(src => { + try { window.SxKernel.load(src); return null; } + catch(e) { return e.message; } + }, src); + if (err) loadErrors.push(mod + ': ' + err); + else loadedModules.push(mod); + } + await page.evaluate(() => { + if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); + }); + } + } + + // 5. Load custom files + for (const f of files) { + const abs = path.isAbsolute(f) ? f : path.join(PROJECT_ROOT, f); + try { + const src = fs.readFileSync(abs, 'utf8'); + const err = await page.evaluate(src => { + try { window.SxKernel.load(src); return null; } + catch(e) { return e.message; } + }, src); + if (err) loadErrors.push(f + ': ' + err); + else loadedModules.push(path.basename(f)); + } catch(e) { + loadErrors.push(f + ': ' + e.message); + } + } + + // 6. Run setup expression + if (setup) { + const setupErr = await page.evaluate(s => { + try { window.SxKernel.eval(s); return null; } + catch(e) { return e.message; } + }, setup); + if (setupErr) loadErrors.push('setup: ' + setupErr); + } + + // 7. Evaluate — wraps in thunk + callFn for IO suspension support. + // After synchronous eval, waits for all _driveAsync chains to settle. + const evalResult = await page.evaluate(expr => { + return new Promise(resolve => { + const K = window.SxKernel; + window._ioTrace = []; + window._asyncPending = 0; + let syncResult; + try { + K.eval('(define _sandbox_thunk (fn () ' + expr + '))'); + syncResult = K.callFn(K.eval('_sandbox_thunk'), []); + } catch(e) { + resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); + return; + } + + // If the thunk itself suspended (direct perform), drive it inline + if (syncResult && syncResult.suspended) { + function tracingDrive(r) { + if (!r || !r.suspended) { + waitAndResolve(r); + return; + } + const req = r.request; + const items = req && (req.items || req); + const op = items && items[0]; + const opName = typeof op === 'string' ? op : (op && op.name) || String(op); + const arg = items && items[1]; + window._ioTrace.push({ op: opName, arg: arg }); + if (opName === 'io-sleep' || opName === 'wait') { + setTimeout(() => { + try { tracingDrive(r.resume(null)); } + catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); } + }, Math.min(typeof arg === 'number' ? arg : 0, 10)); + } else if (opName === 'io-fetch') { + setTimeout(() => { + try { tracingDrive(r.resume({ ok: true, text: '' })); } + catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); } + }, 1); + } else { + resolve({ result: 'unhandled IO: ' + opName, ioTrace: window._ioTrace }); + } + } + tracingDrive(syncResult); + return; + } + + // Synchronous result — but there may be pending _driveAsync chains + // (e.g. from host-callback event handlers triggered during eval) + waitAndResolve(syncResult); + + function waitAndResolve(r) { + const display = (r === null || r === undefined) ? 'nil' + : typeof r === 'string' ? r + : typeof r === 'object' ? JSON.stringify(r) : String(r); + // Poll for _asyncPending to reach 0 (max 5s) + let waited = 0; + function check() { + if (window._asyncPending <= 0 || waited > 5000) { + resolve({ result: display, ioTrace: window._ioTrace }); + } else { + waited += 20; + setTimeout(check, 20); + } + } + // Give first tick a chance to start + setTimeout(check, 20); + } + }); + }, expr); + + // 8. Collect logs + const ioLogs = consoleLogs.filter(l => + l.text.includes('[sandbox]') || l.text.includes('[sx]') || + l.type === 'error' || l.type === 'warning' + ); + + return { + mode: 'sandbox', + stack: stack || 'core', + modules: loadedModules.length > 0 ? loadedModules : undefined, + files: files.length > 0 ? files.map(f => path.basename(f)) : undefined, + result: evalResult.result, + ioTrace: evalResult.ioTrace.length > 0 ? evalResult.ioTrace : undefined, + loadErrors: loadErrors.length > 0 ? loadErrors : undefined, + log: ioLogs.length > 0 ? ioLogs : undefined, + }; +} + async function main() { const argsJson = process.argv[2] || '{}'; let args; @@ -1354,6 +1687,9 @@ async function main() { case 'eval-at': result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)'); break; + case 'sandbox': + result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || ''); + break; default: result = { error: `Unknown mode: ${mode}` }; }