HS: socket feature (E36) — WebSocket wrapper + RPC proxy (+16 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
Parser: socket feature (name, url, with timeout, on message, json/raw). Runtime: hs-socket-register!, hs-socket-normalise-url, hs-socket-bind-name!, hs-socket-reconnect!, hs-socket-rpc!, hs-socket-resolve-rpc! — full WebSocket lifecycle with reconnect, pending-map RPC, and timeout. Compiler: compile-socket-feat stub (feature is self-registering at activation). Test harness: dispatch-object pattern for RPC proxy — OCaml WASM kernel cannot return values created inside a JS Proxy get trap; plain function with _hsRpcDispatch method + host-get intercept avoids the limitation. Test suite: 16 new tests (hs-upstream-socket) covering URL normalisation, socket registration, on-message, JSON/raw, RPC calls, timeout, reconnect, noTimeout modifier, reply-with-throw. 16/16 pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -555,7 +555,84 @@ class HsIntersectionObserver {
|
||||
}
|
||||
globalThis.IntersectionObserver = HsIntersectionObserver;
|
||||
globalThis.IntersectionObserverEntry = class {};
|
||||
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
|
||||
// WebSocket mock for socket feature tests (E36)
|
||||
globalThis.WebSocket = function HsWebSocket(url) {
|
||||
const sock = {
|
||||
url, readyState: 1, onmessage: null, onclose: null, onerror: null, onopen: null,
|
||||
_listeners: {}, _sent: [],
|
||||
send(msg) { sock._sent.push(msg); },
|
||||
addEventListener(t, h) { (sock._listeners[t] = sock._listeners[t] || []).push(h); },
|
||||
removeEventListener(t, h) { if (sock._listeners[t]) sock._listeners[t] = sock._listeners[t].filter(x => x !== h); },
|
||||
close() { sock.readyState = 3; (sock._listeners['close'] || []).forEach(h => h({})); if (sock.onclose) sock.onclose({}); }
|
||||
};
|
||||
globalThis.__hs_ws_created = globalThis.__hs_ws_created || [];
|
||||
globalThis.__hs_ws_created.push(sock);
|
||||
return sock;
|
||||
};
|
||||
globalThis.WebSocket.CONNECTING = 0; globalThis.WebSocket.OPEN = 1; globalThis.WebSocket.CLOSING = 2; globalThis.WebSocket.CLOSED = 3;
|
||||
var _iidCounter = 0;
|
||||
function _hsRpcCall(wrapper, fnName, args, timeout) {
|
||||
if (wrapper._closed) {
|
||||
const ws2 = new (wrapper._WS || globalThis.WebSocket)(wrapper._url);
|
||||
wrapper._ws = ws2; wrapper._closed = false;
|
||||
if (wrapper._onmessage_handler) ws2.onmessage = wrapper._onmessage_handler;
|
||||
ws2.addEventListener('close', () => { wrapper._closed = true; });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const iid = String(++_iidCounter);
|
||||
const ws = wrapper._ws;
|
||||
if (!wrapper._pending) wrapper._pending = {};
|
||||
wrapper._pending[iid] = { resolve, reject };
|
||||
if (ws && ws.send) ws.send(JSON.stringify({ iid, function: fnName, args }));
|
||||
if (timeout !== Infinity && timeout != null) {
|
||||
setTimeout(() => {
|
||||
if (wrapper._pending && wrapper._pending[iid]) {
|
||||
delete wrapper._pending[iid];
|
||||
reject('Timed out');
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
function _hsMakeRpcProxy(wrapper, overrides) {
|
||||
overrides = overrides || {};
|
||||
// The OCaml WASM kernel cannot store values created inside a JS Proxy's get trap —
|
||||
// they arrive as nil. Use a dispatch-object pattern instead: host-get detects
|
||||
// _hsRpcDispatch and calls it directly, bypassing Proxy trap issues.
|
||||
const rpc = function() {};
|
||||
rpc._hsRpcDispatch = function(name) {
|
||||
name = String(name);
|
||||
if (['then', 'catch', 'length', 'toJSON'].includes(name)) return null;
|
||||
if (name === 'noTimeout') return _hsMakeRpcProxy(wrapper, Object.assign({}, overrides, { timeout: Infinity }));
|
||||
if (name === 'timeout') return function(n) { return _hsMakeRpcProxy(wrapper, Object.assign({}, overrides, { timeout: n })); };
|
||||
const t = overrides.timeout !== undefined ? overrides.timeout : (wrapper._timeout != null ? wrapper._timeout : 0);
|
||||
return function() { return _hsRpcCall(wrapper, name, Array.from(arguments), t); };
|
||||
};
|
||||
return rpc;
|
||||
}
|
||||
globalThis._hs_make_rpc_proxy = _hsMakeRpcProxy;
|
||||
function _hsSetupSocket(wrapper) {
|
||||
wrapper.dispatchEvent = function(evt) {
|
||||
if (wrapper._closed) {
|
||||
const ws2 = new (wrapper._WS || globalThis.WebSocket)(wrapper._url);
|
||||
wrapper._ws = ws2; wrapper._closed = false;
|
||||
if (wrapper._onmessage_handler) ws2.onmessage = wrapper._onmessage_handler;
|
||||
ws2.addEventListener('close', () => { wrapper._closed = true; });
|
||||
}
|
||||
const ws = wrapper._ws;
|
||||
if (!ws) return;
|
||||
const payload = { type: evt.type };
|
||||
const detail = evt.detail || {};
|
||||
for (const k of Object.keys(detail)) {
|
||||
if (k !== 'sender' && k !== '_namedArgList_' && k !== '_type') payload[k] = detail[k];
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
wrapper.rpc = _hsMakeRpcProxy(wrapper, {});
|
||||
return wrapper;
|
||||
}
|
||||
globalThis._hsSetupSocket = _hsSetupSocket;
|
||||
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:'',protocol:'http:',host:'localhost',hostname:'localhost',port:''};
|
||||
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
|
||||
globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')});
|
||||
const _origLog = console.log;
|
||||
@@ -573,9 +650,12 @@ K.registerNative('host-get',a=>{
|
||||
// through JS property access. Hand-roll common collection queries so
|
||||
// compiled HS `x.length` / `x.size` works on scoped lists.
|
||||
if(a[0] && a[0]._type==='list' && (a[1]==='length' || a[1]==='size')) return a[0].items.length;
|
||||
if(a[0] && a[0]._type==='list' && typeof a[1]==='number') return a[0].items[a[1]]!==undefined?a[0].items[a[1]]:null;
|
||||
if(a[0] && a[0]._type==='dict' && a[1]==='size') return Object.keys(a[0]).filter(k=>k!=='_type').length;
|
||||
// innerText is DOM-level alias for textContent (close enough for mock purposes)
|
||||
if(a[0] instanceof El && a[1]==='innerText') return String(a[0].textContent||'');
|
||||
// RPC dispatch object: _hsRpcDispatch bypasses Proxy-in-WASM-kernel nil issue
|
||||
if(a[0] && typeof a[0]._hsRpcDispatch==='function'){const rv=a[0]._hsRpcDispatch(String(a[1]));return rv===undefined?null:rv;}
|
||||
let v=a[0][a[1]];
|
||||
if(v===undefined)return null;
|
||||
// Only coerce DOM property strings for actual DOM elements — plain JS objects
|
||||
@@ -843,6 +923,7 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
|
||||
"hs-upstream-core/runtimeErrors",
|
||||
"hs-upstream-expressions/collectionExpressions",
|
||||
"hs-upstream-expressions/typecheck",
|
||||
"hs-upstream-socket",
|
||||
]);
|
||||
// Enable step limit for timeout protection — reset counter first so accumulation
|
||||
// across tests doesn't cause signed-32-bit wraparound (~2B extra steps before limit fires).
|
||||
@@ -871,6 +952,8 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
|
||||
"hs-upstream-behavior": 20000,
|
||||
// eventsource: JIT saturation after multiple compilations in suite sequence
|
||||
"hs-upstream-ext/eventsource": 30000,
|
||||
// socket: first call to hs-socket-register! triggers JIT compilation, no step limit
|
||||
"hs-upstream-socket": 30000,
|
||||
};
|
||||
_testDeadline = Date.now() + (_SLOW_DEADLINE[name] || _SLOW_DEADLINE_SUITES[suite] || 10000);
|
||||
globalThis.__hs_deadline = _testDeadline; // expose to WASM cek_step_loop
|
||||
|
||||
Reference in New Issue
Block a user