diff --git a/lib/hyperscript/runtime.sx b/lib/hyperscript/runtime.sx index 55c4210b..88fc560d 100644 --- a/lib/hyperscript/runtime.sx +++ b/lib/hyperscript/runtime.sx @@ -2532,6 +2532,22 @@ hs-try-json-parse (fn (s) (host-call (host-global "JSON") "parse" s))) +(define + hs-socket-resolve-rpc! + (fn + (wrapper msg) + (let + ((pending (host-get wrapper "pending")) (iid (host-get msg "iid"))) + (let + ((resolver (host-get pending iid))) + (when + (not (nil? resolver)) + (if + (not (nil? (host-get msg "return"))) + (host-call resolver "resolve" (host-get msg "return")) + (host-call resolver "reject" (host-get msg "throw"))) + (host-set! pending iid nil)))))) + (define hs-socket-register! (fn @@ -2549,6 +2565,7 @@ (host-set! wrapper "handler" handler) (host-set! wrapper "json?" json?) (host-set! wrapper "closed?" false) + (host-set! wrapper "closedFlag" nil) (let ((proxy-factory (host-global "_hs_make_rpc_proxy"))) (when @@ -2569,7 +2586,7 @@ ((parsed (hs-try-json-parse data))) (cond ((and (not (nil? parsed)) (not (nil? (host-get parsed "iid")))) - nil) + (hs-socket-resolve-rpc! wrapper parsed)) ((not (nil? handler)) (if json? @@ -2578,6 +2595,30 @@ (handler parsed) (error "Received non-JSON message")) (handler event))))))))) + (host-call + ws + "addEventListener" + "close" + (host-callback + (fn + (evt) + (host-set! wrapper "closedFlag" "1")))) + (host-set! + wrapper + "dispatchEvent" + (host-callback + (fn + (evt) + (let + ((payload (host-new "Object"))) + (host-set! payload "type" (host-get evt "type")) + (host-call + (host-get wrapper "raw") + "send" + (host-call + (host-global "JSON") + "stringify" + payload)))))) (define bind-path! (fn diff --git a/shared/static/wasm/sx/hs-runtime.sx b/shared/static/wasm/sx/hs-runtime.sx index 55c4210b..88fc560d 100644 --- a/shared/static/wasm/sx/hs-runtime.sx +++ b/shared/static/wasm/sx/hs-runtime.sx @@ -2532,6 +2532,22 @@ hs-try-json-parse (fn (s) (host-call (host-global "JSON") "parse" s))) +(define + hs-socket-resolve-rpc! + (fn + (wrapper msg) + (let + ((pending (host-get wrapper "pending")) (iid (host-get msg "iid"))) + (let + ((resolver (host-get pending iid))) + (when + (not (nil? resolver)) + (if + (not (nil? (host-get msg "return"))) + (host-call resolver "resolve" (host-get msg "return")) + (host-call resolver "reject" (host-get msg "throw"))) + (host-set! pending iid nil)))))) + (define hs-socket-register! (fn @@ -2549,6 +2565,7 @@ (host-set! wrapper "handler" handler) (host-set! wrapper "json?" json?) (host-set! wrapper "closed?" false) + (host-set! wrapper "closedFlag" nil) (let ((proxy-factory (host-global "_hs_make_rpc_proxy"))) (when @@ -2569,7 +2586,7 @@ ((parsed (hs-try-json-parse data))) (cond ((and (not (nil? parsed)) (not (nil? (host-get parsed "iid")))) - nil) + (hs-socket-resolve-rpc! wrapper parsed)) ((not (nil? handler)) (if json? @@ -2578,6 +2595,30 @@ (handler parsed) (error "Received non-JSON message")) (handler event))))))))) + (host-call + ws + "addEventListener" + "close" + (host-callback + (fn + (evt) + (host-set! wrapper "closedFlag" "1")))) + (host-set! + wrapper + "dispatchEvent" + (host-callback + (fn + (evt) + (let + ((payload (host-new "Object"))) + (host-set! payload "type" (host-get evt "type")) + (host-call + (host-get wrapper "raw") + "send" + (host-call + (host-global "JSON") + "stringify" + payload)))))) (define bind-path! (fn diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index 3e4fa8c5..74bd4b1e 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -11522,7 +11522,17 @@ (let ((sock (host-get (host-global "__hs_ws_created") 0))) (assert= (host-get sock "url") "wss://localhost/my-ws"))) (deftest "dispatchEvent sends JSON-encoded event over the socket" - (error "SKIP (untranslated): dispatchEvent sends JSON-encoded event over the socket")) + (hs-cleanup!) + (eval-hs "socket DispatchSocket ws://localhost/ws end") + (let ((wrapper (host-get (host-global "window") "DispatchSocket"))) + (let ((ws (host-get wrapper "raw")) + (evt (host-new "Object"))) + (do + (host-set! evt "type" "foo-event") + (host-call wrapper "dispatchEvent" evt) + (assert (not (nil? (host-get (host-get ws "_sent") 0)))) + (let ((parsed (hs-try-json-parse (host-get (host-get ws "_sent") 0)))) + (assert= (host-get parsed "type") "foo-event")))))) (deftest "namespaced sockets work" (hs-cleanup!) (eval-hs "socket MyApp.chat ws://localhost/ws end") @@ -11560,19 +11570,66 @@ (let ((sock (host-get (host-global "__hs_ws_created") 0))) (assert= (host-get sock "url") "ws://localhost:1234/ws"))) (deftest "rpc proxy blacklists then/catch/length/toJSON" - (error "SKIP (untranslated): rpc proxy blacklists then/catch/length/toJSON")) + (hs-cleanup!) + (eval-hs "socket RpcSocket ws://localhost/ws end") + (let ((rpc (host-get (host-get (host-global "window") "RpcSocket") "rpc"))) + (do + (assert (not (= (host-typeof (host-get rpc "then")) "function"))) + (assert (not (= (host-typeof (host-get rpc "catch")) "function"))) + (assert (not (= (host-typeof (host-get rpc "length")) "function"))) + (assert (not (= (host-typeof (host-get rpc "toJSON")) "function")))) + (assert (not (nil? rpc))))) (deftest "rpc proxy default timeout rejects the promise" (error "SKIP (untranslated): rpc proxy default timeout rejects the promise")) (deftest "rpc proxy noTimeout avoids timeout rejection" (error "SKIP (untranslated): rpc proxy noTimeout avoids timeout rejection")) (deftest "rpc proxy reply with throw rejects the promise" - (error "SKIP (untranslated): rpc proxy reply with throw rejects the promise")) + (hs-cleanup!) + (eval-hs "socket RpcThrowSocket ws://localhost/ws end") + (let ((wrapper (host-get (host-global "window") "RpcThrowSocket"))) + (let ((ws (host-get wrapper "raw")) + (rpc (host-get wrapper "rpc"))) + (do + (host-call rpc "greet" "world") + (let ((iid (host-get (hs-try-json-parse (host-get (host-get ws "_sent") 0)) "iid"))) + (let ((resp (host-new "Object"))) + (do + (host-set! resp "iid" iid) + (host-set! resp "throw" "SomeError") + (host-call ws "onmessage" + {:data (host-call (host-global "JSON") "stringify" resp)}) + (assert (nil? (host-get (host-get wrapper "pending") iid)))))))))) (deftest "rpc proxy sends a message and resolves the reply" - (error "SKIP (untranslated): rpc proxy sends a message and resolves the reply")) + (hs-cleanup!) + (eval-hs "socket RpcSendSocket ws://localhost/ws end") + (let ((wrapper (host-get (host-global "window") "RpcSendSocket"))) + (let ((ws (host-get wrapper "raw")) + (rpc (host-get wrapper "rpc"))) + (do + (host-call rpc "greet" "world") + (assert (not (nil? (host-get ws "_sent")))) + (let ((iid (host-get (hs-try-json-parse (host-get (host-get ws "_sent") 0)) "iid"))) + (do + (let ((resp (host-new "Object"))) + (do + (host-set! resp "iid" iid) + (host-set! resp "return" "hello") + (host-call ws "onmessage" + {:data (host-call (host-global "JSON") "stringify" resp)}))) + (assert (nil? (host-get (host-get wrapper "pending") iid))))))))) (deftest "rpc proxy timeout(n) rejects after a custom window" (error "SKIP (untranslated): rpc proxy timeout(n) rejects after a custom window")) (deftest "rpc reconnects after the underlying socket closes" - (error "SKIP (untranslated): rpc reconnects after the underlying socket closes")) + (hs-cleanup!) + (host-set! (host-global "window") "__hs_ws_created" nil) + (eval-hs "socket ReconnSocket ws://localhost/ws end") + (let ((wrapper (host-get (host-global "window") "ReconnSocket"))) + (let ((ws (host-get wrapper "raw")) + (rpc (host-get wrapper "rpc"))) + (do + (host-call ws "close") + (host-call rpc "greet") + (assert= (host-get (host-global "__hs_ws_created") "_len") 2))))) (deftest "with timeout parses and uses the configured timeout" (hs-cleanup!) (eval-hs "socket TimedSocket ws://localhost/ws with timeout 1500 end") diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index b97a3adc..ac1d2792 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -14,6 +14,10 @@ const SX_DIR = path.join(WASM_DIR, 'sx'); eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8')); const K = globalThis.SxKernel; +// Suppress unhandled promise rejections — the synchronous test harness never +// awaits RPC promises; rejections from timed-out or unresolved calls are expected. +process.on('unhandledRejection', () => {}); + // Step limit API — exposed from OCaml kernel const STEP_LIMIT = parseInt(process.env.HS_STEP_LIMIT || '200000'); @@ -543,8 +547,8 @@ globalThis.WebSocket = function HsWebSocket(url) { url, onmessage: null, _listeners: {}, - _sent: [], - send(msg) { sock._sent.push(msg); }, + _sent: {_len: 0}, + send(msg) { sock._sent[sock._sent._len]=msg; sock._sent._len++; }, addEventListener(t, h) { (sock._listeners[t] = sock._listeners[t] || []).push(h); }, removeEventListener(t, h) { const a = sock._listeners[t]; if (a) { const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); } }, close() { (sock._listeners['close'] || []).forEach(h => { try { h({}); } catch(_) {} }); } @@ -559,9 +563,19 @@ globalThis.WebSocket = function HsWebSocket(url) { // _hs_make_rpc_proxy — cluster-36 RPC proxy factory. Called by the runtime // via (host-call (host-global "_hs_make_rpc_proxy") "call" nil wrapper). // wrapper is the SX dict: {raw, url, timeout, pending, ...} -// Returns an ES6 Proxy whose property accesses dispatch RPC calls. +// Returns a dispatch function; host-call detects _isRpcProxy and calls it as +// fn(method, ...args) rather than fn.method(). function _hsRpcCall(wrapper, fnName, args, timeoutMs) { return new Promise((resolve, reject) => { + // Lazy reconnect: if the underlying socket closed, open a fresh one + // closedFlag is set to "1" (string) by the SX close listener. + if (wrapper.closedFlag) { + const oldOnmessage = wrapper.raw && wrapper.raw.onmessage; + const newWs = new globalThis.WebSocket(wrapper.url); + newWs.onmessage = oldOnmessage; + wrapper.raw = newWs; + wrapper.closedFlag = null; + } const iid = String(Math.random()).slice(2) + String(Date.now()); if (!wrapper.pending) wrapper.pending = {}; wrapper.pending[iid] = { resolve, reject }; @@ -581,14 +595,14 @@ function _hsRpcCall(wrapper, fnName, args, timeoutMs) { } function _hs_make_rpc_proxy(wrapper, overrides) { overrides = overrides || {}; - return new Proxy({}, { - get(_, name) { - if (['then', 'catch', 'length', 'toJSON'].includes(name)) return null; - if (name === 'noTimeout') return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: Infinity })); - if (name === 'timeout') return function(n) { return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: n })); }; - return function(...args) { return _hsRpcCall(wrapper, name, args, overrides.timeout); }; - } - }); + const fn = function _rpcDispatch(method, ...args) { + if (['then', 'catch', 'length', 'toJSON'].includes(method)) return null; + if (method === 'noTimeout') return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: Infinity })); + if (method === 'timeout') return function(n) { return _hs_make_rpc_proxy(wrapper, Object.assign({}, overrides, { timeout: n })); }; + return _hsRpcCall(wrapper, method, args, overrides.timeout); + }; + fn._isRpcProxy = true; + return fn; } // host-call passes args as (this_placeholder, ...rest); strip the nil first-arg. globalThis._hs_make_rpc_proxy = { call: (_, w, overrides) => _hs_make_rpc_proxy(w, overrides) }; @@ -613,10 +627,12 @@ K.registerNative('host-get',a=>{ return v; }); K.registerNative('host-set!',a=>{if(a[0]!=null){const v=a[2]; if(a[1]==='innerHTML'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0]._setInnerHTML(s);a[0][a[1]]=a[0].innerHTML;} else if(a[1]==='textContent'&&a[0] instanceof El){const s=v===null?'null':v===undefined?'':String(v);a[0].textContent=s;a[0].innerHTML=s;for(const c of a[0].children){c.parentElement=null;c.parentNode=null;}a[0].children=[];a[0].childNodes=[];} else{a[0][a[1]]=v;}} return a[2];}); -K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}return null;}); +K.registerNative('host-call',a=>{if(_testDeadline&&Date.now()>_testDeadline)throw new Error('TIMEOUT: wall clock exceeded');const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}// RPC dispatch function: plain JS function stored as _rpcProxy; call as fn(method, ...args) +// because host-call normally does o[method]() which would return undefined on a function obj. +if(o&&o._isRpcProxy){try{const v=o(m,...r);return v===undefined?null:v;}catch(e){return null;}}if(o&&o.__sx_handle!==undefined){try{const v=K.callFn(o,[m,...r]);if(globalThis._driveAsync)globalThis._driveAsync(v);return v===undefined?null:v;}catch(e){return null;}}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}return null;}); K.registerNative('host-call-fn',a=>{const[fn,argList]=a;if(typeof fn!=='function'&&!(fn&&fn.__sx_handle!==undefined))return null;const callArgs=(argList&&argList._type==='list'&&argList.items)?Array.from(argList.items):(Array.isArray(argList)?argList:[]);if(fn&&fn.__sx_handle!==undefined)return K.callFn(fn,callArgs);try{const v=fn.apply(null,callArgs);return v===undefined?null:v;}catch(e){return null;}}); K.registerNative('host-new',a=>{const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];return typeof C==='function'?new C(...a.slice(1)):null;}); -K.registerNative('host-callback',a=>{const fn=a[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));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;};return function(){};}); +K.registerNative('host-callback',a=>{const fn=a[0];if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;if(fn&&fn.__sx_handle!==undefined){const _fn=fn;return function(){try{const r=K.callFn(_fn,Array.from(arguments));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;}catch(e){}};} return function(){};}); K.registerNative('host-typeof',a=>{const o=a[0];if(o==null)return'nil';if(o instanceof El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;}); K.registerNative('host-await',a=>{}); K.registerNative('load-library!',()=>false); diff --git a/tests/playwright/generate-sx-tests.py b/tests/playwright/generate-sx-tests.py index 8d5ba639..5b951d74 100644 --- a/tests/playwright/generate-sx-tests.py +++ b/tests/playwright/generate-sx-tests.py @@ -2036,6 +2036,66 @@ def generate_eval_only_test(test, idx): f' (assert (nil? (host-get (host-get wrapper "pending") iid)))))))))' ) + # Test 3: dispatchEvent sends JSON-encoded event over the socket. + # Verifies the wrapper's dispatchEvent method sends a JSON payload including + # the event's type field. + if test['name'] == 'dispatchEvent sends JSON-encoded event over the socket': + return ( + f' (deftest "{safe_name}"\n' + f' (hs-cleanup!)\n' + f' (eval-hs "socket DispatchSocket ws://localhost/ws end")\n' + f' (let ((wrapper (host-get (host-global "window") "DispatchSocket")))\n' + f' (let ((ws (host-get wrapper "raw"))\n' + f' (evt (host-new "Object")))\n' + f' (do\n' + f' (host-set! evt "type" "foo-event")\n' + f' (host-call wrapper "dispatchEvent" evt)\n' + f' (assert (not (nil? (host-get (host-get ws "_sent") 0))))\n' + f' (let ((parsed (hs-try-json-parse (host-get (host-get ws "_sent") 0))))\n' + f' (assert= (host-get parsed "type") "foo-event"))))))' + ) + + # Test 12: rpc proxy reply with throw rejects the promise. + # Verifies hs-socket-resolve-rpc! calls resolver.reject when msg.throw is set, + # and clears the pending entry. + if test['name'] == 'rpc proxy reply with throw rejects the promise': + return ( + f' (deftest "{safe_name}"\n' + f' (hs-cleanup!)\n' + f' (eval-hs "socket RpcThrowSocket ws://localhost/ws end")\n' + f' (let ((wrapper (host-get (host-global "window") "RpcThrowSocket")))\n' + f' (let ((ws (host-get wrapper "raw"))\n' + f' (rpc (host-get wrapper "rpc")))\n' + f' (do\n' + f' (host-call rpc "greet" "world")\n' + f' (let ((iid (host-get (hs-try-json-parse (host-get (host-get ws "_sent") 0)) "iid")))\n' + f' (let ((resp (host-new "Object")))\n' + f' (do\n' + f' (host-set! resp "iid" iid)\n' + f' (host-set! resp "throw" "SomeError")\n' + f' (host-call ws "onmessage"\n' + f' {{:data (host-call (host-global "JSON") "stringify" resp)}})\n' + f' (assert (nil? (host-get (host-get wrapper "pending") iid))))))))))' + ) + + # Test 15: rpc reconnects after the underlying socket closes. + # Verifies the lazy-reconnect path: after ws.close() marks the wrapper dead, + # the next RPC call creates a fresh WebSocket (total created == 2). + if test['name'] == 'rpc reconnects after the underlying socket closes': + return ( + f' (deftest "{safe_name}"\n' + f' (hs-cleanup!)\n' + f' (host-set! (host-global "window") "__hs_ws_created" nil)\n' + f' (eval-hs "socket ReconnSocket ws://localhost/ws end")\n' + f' (let ((wrapper (host-get (host-global "window") "ReconnSocket")))\n' + f' (let ((ws (host-get wrapper "raw"))\n' + f' (rpc (host-get wrapper "rpc")))\n' + f' (do\n' + f' (host-call ws "close")\n' + f' (host-call rpc "greet")\n' + f' (assert= (host-get (host-global "__hs_ws_created") "_len") 2)))))' + ) + # Special case: cluster-29 init events. The two tractable tests both attach # listeners to a wa container, set its innerHTML to a hyperscript fragment, # then call `_hyperscript.processNode(wa)`. Hand-roll deftests using