21 KiB
E36 — WebSocket + socket feature + RPC proxy
Design for cluster 36 of plans/hs-conformance-to-100.md. Scope: +16 tests in hs-upstream-socket, all currently SKIP (untranslated).
1. Failing tests (all SKIP'd — untranslated)
Exact names from spec/tests/test-hyperscript-behavioral.sx:11354:
converts relative URL to ws:// on http pagesconverts relative URL to wss:// on https pagesdispatchEvent sends JSON-encoded event over the socketnamespaced sockets workon message as JSON handler decodes JSON payloadon message as JSON throws on non-JSON payloadon message handler fires on incoming text messageparses socket with absolute ws:// URLrpc proxy blacklists then/catch/length/toJSONrpc proxy default timeout rejects the promiserpc proxy noTimeout avoids timeout rejectionrpc proxy reply with throw rejects the promiserpc proxy sends a message and resolves the replyrpc proxy timeout(n) rejects after a custom windowrpc reconnects after the underlying socket closeswith timeout parses and uses the configured timeout
2. Upstream semantics (stock _hyperscript)
Verified against the JSON fixture and hyperscript.org/features/socket/. The prompt's "with proxy { send, receive }" wording is not part of the upstream socket feature — there is no with proxy clause. The shape actually under test is:
socket <Name[.dotted.path]> <url> [with timeout <ms>]
[on message [as JSON]
<cmd-list>]
end
URL scheme normalisation. Bare paths get a scheme prepended from location.protocol: http: → ws://host[:port]<path>, https: → wss://…. Absolute ws:// / wss:// URLs pass through unchanged.
Named binding. socket Foo url end assigns window.Foo = socketObj. A dotted name socket MyApp.chat url end walks/creates window.MyApp then sets window.MyApp.chat = socketObj.
Socket object shape (what the tests assert against):
{ raw: WebSocket, // underlying ws
rpc: <Proxy>, // see below
dispatchEvent(evt), // JSON-encodes the event and sends over ws
// 'on message' handler registered internally via ws.onmessage
}
dispatchEvent protocol. On send NAME(a:1) to Foo or Foo.dispatchEvent(evt):
- Outgoing payload:
JSON.stringify({ type: evt.type, ...evt.detail })withsenderand_namedArgList_stripped.
on message [as JSON]. ws.onmessage({data}) fires the cmd-list. as JSON calls JSON.parse(data) first and the parsed object becomes the event; non-JSON throws "Received non-JSON message".
RPC proxy (socket.rpc). ES6 Proxy over an object. Accessing .anyName normally returns a callable; four property names are blacklisted and return null: then, catch, length, toJSON (so the proxy isn't mistaken for a thenable and JSON-serialises cleanly). Two reserved chainable properties:
.noTimeout— returns a proxy that will never time out..timeout(n)— returns a proxy that times out afternms.
Calling proxy.fnName(arg1, arg2) performs:
- Allocate a unique
iid(string). - Send
JSON.stringify({iid, function: "fnName", args: [arg1, arg2]})over ws. - Return a Promise. The promise is tracked by
iid. - Incoming messages with matching
iidare routed:{iid, return: v}resolves withv;{iid, throw: e}rejects withe. - Default/custom timeout (from
with timeout N, default per stock is ~0 i.e. "Timed out" reject; override via.timeout(n)or.noTimeout).
Reconnect. When the underlying socket fires close, the socket wrapper marks itself dead. The next RPC (or dispatchEvent) call lazily reconstructs a fresh new WebSocket(url). Test 15 asserts created.length === 2 after one close + one RPC.
3. Proposed user-facing shape
3a. Tokens
No new tokens needed. socket is a bare identifier that becomes a keyword in parse-feat. end is already known. with, timeout, on, message, as, JSON are already existing keyword/identifier tokens. Dotted names (MyApp.chat) already tokenise as local + dot + local.
3b. Parse tree
A new feature AST node, peer to (def ...) / (on ...):
(socket <name-path> <url-expr> :timeout <expr-or-nil> :on-message <handler-or-nil>)
<name-path>is a list of symbol strings:["Foo"]or["MyApp" "chat"].<url-expr>is any parseable expression (usually a string literal).:timeoutis the parsed ms expression, ornilif absent.:on-messageisnil,(on-message :json? false <body>), or(on-message :json? true <body>).
Parser function: parse-socket-feat dispatched from parse-feat on val == "socket". Reuses parse-cmd-list for handler bodies. as JSON is detected inline before collecting the body.
3c. Compile output
The compiler emits a call to a runtime registration primitive that carries everything the runtime needs:
(hs-socket-register!
(list "MyApp" "chat") ; name path
<url-compiled> ; usually a string
<timeout-ms-or-nil> ; number or nil
(fn (event) <on-message-body>) ; handler closure or nil
:json-message? true|false)
The runtime owns all WebSocket construction, URL normalisation, window binding, promise/timeout bookkeeping, and the RPC proxy — keeping the compiler output small and re-usable.
3d. Worked example
Source (from fixture test 13, simplified):
socket RpcSocket ws://localhost/ws end
Tokens: socket RpcSocket ws : //localhost/ws end (URL tokenising is already handled by the string-like path reader; see how fetch URLs parse today).
AST:
(socket ("RpcSocket") "ws://localhost/ws" :timeout nil :on-message nil)
Compiled SX (emitted into the feature body):
(hs-socket-register!
(list "RpcSocket")
"ws://localhost/ws"
nil
nil
:json-message? false)
Fuller example:
socket MyApp.chat /ws with timeout 1500
on message as JSON
set window.got to the event
end
→
(socket ("MyApp" "chat") "/ws"
:timeout 1500
:on-message (on-message :json? true
((set! window.got event))))
→
(hs-socket-register!
(list "MyApp" "chat")
"/ws"
1500
(fn (event) (host-set! (host-global "window") "got" event))
:json-message? true)
4. Runtime architecture
All in lib/hyperscript/runtime.sx. One big primitive plus small helpers.
4a. hs-socket-register! — lifecycle
- Normalise URL: if starts with
ws:///wss://use as-is; else derive scheme from(host-get (host-global "location") "protocol")and prependws://host[:port]orwss://…. Preserves path. - Construct
(host-new "WebSocket" url). - Build wrapper object (SX dict):
{:raw ws :url url :timeout <ms> :pending <dict-of-iid->resolver> :handler <closure> :json? <bool> :closed? false}. - Install
ws.onmessageviahost-set!to a native callback (host-callback) that:- Reads
data = event.data. - If JSON-looking (starts with
{or[) triesjson-parse; if parse OK and hasiidfield → RPC reply dispatch (see 4c). - Otherwise → fire user's
on messagehandler withevent(optionally JSON-parsing first if:json? true; non-JSON there →(error "Received non-JSON message")).
- Reads
- Install
ws.addEventListener("close", …)(viahost-call) that sets:closed? trueso next RPC reconnects. - Install
.dispatchEventmethod on wrapper viahost-set!: takes anEvent, builds{type, ...detail minus sender,_namedArgList_},JSON.stringifys,ws.sends. - Install
.rpc— the proxy (see 4b). - Bind on
window: walkname-pathcreating intermediate{}dicts as needed; final segment assigned the wrapper.
4b. RPC proxy
Implemented as a native JS object constructed via a new FFI primitive we already have: host-call to a global helper we register inside the test mock and inside the real runtime. Concretely the runtime calls (host-call (host-global "_hs_make_rpc_proxy") "call" nil wrapper) → returns a Proxy. In prod _hs_make_rpc_proxy is shipped in shared/static/scripts/sx-platform-*.js; in the test mock it's inlined into tests/hs-run-filtered.js.
Pseudo-JS for the proxy factory (this is what lives in the platform/mock, not in .sx):
function makeRpcProxy(wrapper, overrides = {}){
return new Proxy({}, {
get(_, name) {
if (['then','catch','length','toJSON'].includes(name)) return null;
if (name === 'noTimeout') return makeRpcProxy(wrapper, {...overrides, timeout: Infinity});
if (name === 'timeout') return (n) => makeRpcProxy(wrapper, {...overrides, timeout: n});
return (...args) => hsRpcCall(wrapper, name, args, overrides.timeout);
}
});
}
hsRpcCall returns a native Promise whose resolver is stored in wrapper.pending[iid]. A setTimeout (skipped when Infinity) rejects with "Timed out". The sx runtime never calls hsRpcCall directly — the proxy does, in JS, because tests read .then(...) on the returned promise.
Why this split: the test fixtures all go through .then/.catch in JS, so the proxy and its promise plumbing must be real JS. The SX runtime only wires up message routing.
4c. Inbound dispatch pipeline
onmessage in the runtime (SX):
(fn (event)
(let ((data (host-get event "data")))
(let ((parsed (hs-try-json-parse data)))
(cond
;; RPC reply: has :iid
((and parsed (host-get parsed "iid"))
(hs-socket-resolve-rpc! wrapper parsed))
;; User 'on message as JSON'
(user-handler-json?
(if parsed
(user-handler parsed)
(error "Received non-JSON message")))
;; User 'on message' plain
(user-handler
(user-handler event))
(true nil)))))
hs-socket-resolve-rpc! pulls the resolver/rejecter pair out of :pending by iid and calls the JS-side resolve(ret) or reject(thrown).
4d. send NAME(a:1) to Foo integration
Today emit-send in compiler.sx always emits dom-dispatch. We widen it to hs-dispatch-to (new runtime helper) which dispatches:
- If target exposes
_hs_socket? === true→ JSON-encode +ws.send. - Else → falls through to
dom-dispatch(existing behaviour).
This keeps backwards compat — all non-socket send targets continue to use DOM events.
4e. call Foo.rpc.greet("world") integration
No compiler changes needed. call already compiles property+method chains via method-call. Foo.rpc.greet resolves to the proxy method which returns a Promise. Hyperscript's existing await semantics (via io-* suspension or then absorption in result) then wait on it. For the non-Playwright test harness, the promise is resolved synchronously inside the mock's _driveAsync loop — see §5.
4f. IO suspension
Sockets do not need a new io-* operation for the basic tests: onmessage is a callback that fires user cmd-lists directly via host-callback → K.callFn → _driveAsync (this already works for hs-on-every). RPC promise resolution similarly runs inside a native callback. The only place we might want IO suspension is if someone writes wait for message in Foo — not in this cluster. Keep hs-socket-register! synchronous and callback-driven; defer any (perform …) integration to a later cluster.
5. Test mock strategy
Minimum surface in tests/hs-run-filtered.js:
5a. WebSocket mock (test-keyed)
Each test self-describes via its test name. We key behaviour off _current-test-name. A single globalThis.WebSocket constructor looks up the script-driven scenario. But the fixture's tests all re-mock window.WebSocket in-test — they are self-contained. That means the mock runner merely needs a default that records constructions and lets test code override it.
globalThis.WebSocket = function(url) {
const sock = { url, onmessage: null, _listeners: {},
send(msg){ sock._sent = sock._sent || []; sock._sent.push(msg); },
addEventListener(t, h){ (sock._listeners[t]=sock._listeners[t]||[]).push(h); },
close(){} };
globalThis.__hs_ws_created = globalThis.__hs_ws_created || [];
globalThis.__hs_ws_created.push(sock);
return sock;
};
5b. _hs_make_rpc_proxy in the mock
Ship the factory from §4b as a top-level function in hs-run-filtered.js. It must use the test-runtime Promise (real JS Promise), and setTimeout (real host — Node's, already global). Timeout rejection must fire inside _driveAsync's execution window; a short setImmediate/microtask tick suffices because the tests all await evaluate(() => new Promise(resolve => …)).
5c. Generator patterns (tests/playwright/generate-sx-tests.py)
The socket fixtures are unusual: they directly evaluate browser-side JS that attaches a <script type="text/hyperscript"> then calls _hyperscript.processNode. We do not try to translate that path verbatim. Instead the generator gets a _socket_setup_ops recogniser (peer to _hs_config_setup_ops) that:
- Recognises the pattern
var OrigWS = window.WebSocket; … window.WebSocket = function() {...}; … script.textContent = 'socket …'; …. - Extracts the inner hyperscript source from the
script.textContentstring. - Emits an SX test body that:
- Pre-installs a matching WS mock (
host-set! window "WebSocket" ...) — parameterised by which behaviour the test needs: (a) record URL, (b) record sent messages, (c) record close handlers, (d) provide anonmessagehook. - Activates the hyperscript on a
div(so the socket feature runs through the existinghs-activate!pipeline). - Invokes whatever the test asserted against: reads
urls[0], calls.dispatchEvent, drivesmockSocket.onmessage(…), etc. - Asserts the final value (translated from
expect(…).toBe(…)via the existingpw_assertion_to_sx).
- Pre-installs a matching WS mock (
A single shared socket test mini-DSL — 5 behaviour codes — covers all 16 tests:
| Code | Records | Injects | Asserts |
|---|---|---|---|
url-capture |
constructor URL | — | URL string |
send-capture |
outgoing frames | — | JSON payload fields |
inbound-text |
— | mockSocket.onmessage({data:"…"}) |
window flag set |
inbound-json |
— | mockSocket.onmessage({data:JSON…}) |
window flag set |
rpc-roundtrip |
outgoing frames | onmessage with {iid,return:v} or {iid,throw:e} |
promise resolves/rejects |
rpc-timeout |
— | timer advance | promise rejects with "Timed out" |
close-reconnect |
close handlers + construction count | fire all close handlers then call rpc | created.length === 2 |
The generator dispatches tests 1,2,8 → url-capture; 3 → send-capture; 5,6,7 → inbound; 10,11,12,13,14 → rpc; 15 → close-reconnect; 4,9,16 → small probes of wrapper shape (.rpc typeof === "object", namespaced binding exists).
6. Test delta estimate
- Best case: 16/16. All tests are self-contained scenarios; with the WebSocket mock, proxy factory, and generator recogniser they all translate.
- Realistic: 12–14. Timeout/reconnect tests (10, 11, 14, 15) need real-time behaviour from the Node mock; if
setTimeoutbehaviour is flaky under the step-limited driver, they may go partial. - Worst case: 8. Only the synchronous URL/shape/inbound tests land (1, 2, 4, 5, 6, 7, 8, 9, 16); the five full RPC round-trip tests stay SKIP and get handled in a follow-up.
Target commit should aim for +12 tests, partial on RPC timeout/reconnect. Mark any stragglers partial per rule 7.
7. Risks / open questions (human decisions)
- Reconnect scope. The fixture only tests lazy reconnect on next RPC. Do we also reconnect on
dispatchEventafter close? (Stock yes; tests don't cover it. Decision: yes, keep parity.) - Buffering while disconnected. Between
closeand first reconnecting call, doesdispatchEventdrop the frame or queue it? Stock drops. Recommendation: drop, log once. - Binary frames. None of the 16 tests exercise
Blob/ArrayBuffer. Explicitly out of scope — document asnot yet supportedin runtime. - Close-code handling.
close(code, reason)/ CloseEvent — no test exercises it. Recommendation: ignore code/reason, just mark closed. - Interaction with IO suspension. RPC promises live in JS, not SX; a hyperscript
call Foo.rpc.x()under the current runtime returns the JS Promise and hyperscript'sthenabsorption takes over. Verify that the CEK/VMhs-safe-callpath doesn't eagerly(host-await). - Proxy implementation language. The proxy must be JS (ES6
Proxy) — SX has no equivalent and the tests read.thenetc. as literal JS property accesses. Means the proxy factory lives both in the test mock and in a shipped platform JS file. Do we want to factor out a sharedplatform/sockets.js? (Recommendation: yes, land it inshared/static/scripts/sx-platform-sockets.jsin a follow-up commit after the mock lands; the mock inline version stays as the reference.) - Namespace dotted binding collisions. If
window.MyAppalready exists as a non-dict, the runtime should refuse overwriting it (tests only use fresh names). Recommendation: silently overwrite with a dict — matches stock. emit-sendwidening. Changing send to branch on_hs_socket?touches a code path used by every existing send test. Needs a smoke-regression check againsths-upstream-sendbefore landing.
8. Implementation checklist (one commit per step; must land +N)
Each step is scoped to be independently testable. sync-wasm means cp lib/hyperscript/X.sx shared/static/wasm/sx/hs-X.sx.
- Mock groundwork (0 tests). Add
WebSocketconstructor +_hs_make_rpc_proxyfactory totests/hs-run-filtered.js. No runtime/compiler changes. Verify no regressions on smoke 0–195. Commit:HS-prep: WebSocket + RPC proxy mock. - Parser:
socketfeature (0 tests). Addparse-socket-featinparser.sxdispatched fromparse-feat. Parses name-path, URL,with timeout N,on message [as JSON] … end, trailingend. Emits(socket …)AST. Unit-verify viahs-compileeval trace. sync-wasm. Commit:HS: parse socket feature. - Compiler + runtime: basic socket registration (+3 tests: 1, 2, 8). Implement
hs-socket-register!runtime primitive (URL normalise, construct WS, window bind — no RPC/on-message yet). Compiler emitshs-socket-register!call from(socket …). sync-wasm. Land tests:converts relative URL to ws://,…to wss://,parses socket with absolute ws:// URL. Commit:HS: socket URL binding (+3). - Namespaced binding + wrapper shape (+2 tests: 4, 16). Walk name-path assigning intermediate dicts. Ensure wrapper exposes
.rawand.rpcobjects (rpc can be the bare proxy for now). sync-wasm. Land:namespaced sockets work,with timeout parses and uses the configured timeout. Commit:HS: socket namespaced names + timeout plumbing (+2). - Inbound
on messagedispatch (+3 tests: 5, 6, 7). Wirews.onmessage→ SX handler. Supportas JSONparse branch + non-JSON error. Generator:inbound-text+inbound-jsondispatch codes. sync-wasm. Land:on message handler fires…,on message as JSON … decodes…,…throws on non-JSON. Commit:HS: socket on-message + as JSON (+3). - RPC proxy — happy path (+2 tests: 9, 13). Wire
.rpcproxy with blacklist,iidallocation, send JSON, resolve on matchingiid.return. Uses real JSPromisefrom the mock. Generatorrpc-roundtripcode. sync-wasm. Land:rpc proxy blacklists …,rpc proxy sends a message and resolves…. Commit:HS: socket RPC proxy (+2). - RPC error + reconnect (+3 tests: 12, 15, plus
dispatchEvent=3). Add{iid, throw: e}path. Add close-handler tracking + lazy reconnect on next RPC/dispatch. AdddispatchEventJSON-encode (stripssender/_namedArgList_). Generatorclose-reconnect+send-capture. sync-wasm. Land:rpc proxy reply with throw rejects,rpc reconnects after … closes,dispatchEvent sends JSON-encoded event. Commit:HS: socket dispatchEvent + reconnect + throw (+3). - RPC timeouts (+3 tests: 10, 11, 14).
.timeout(n)/.noTimeoutchainable; default timeout fromwith timeout N.setTimeoutreject with"Timed out". Generatorrpc-timeoutcode — may need a virtual-clock helper in the mock if realsetTimeoutis flaky under the step limiter. sync-wasm. Land:…default timeout rejects,…noTimeout avoids timeout rejection,…timeout(n) rejects after a custom window. Commit:HS: socket RPC timeouts (+3). - Polish + scoreboard (0 tests). Update
plans/hs-conformance-to-100.mdrow 36 todone (+N), bump scoreboard. Smoke 0–195 +hs-upstream-sendregression check. Commit:HS-plan: E36 complete (+N).
Total runway: 8 feature commits to move cluster 36 from blocked → done (+12–16). No commit may land a regression in smoke 0–195 or in hs-upstream-send.