Files
rose-ash/plans/designs/e36-websocket.md
giles 3587443742 HS-design: E36 WebSocket + socket + RPC proxy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:08:02 +00:00

318 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
1. `converts relative URL to ws:// on http pages`
2. `converts relative URL to wss:// on https pages`
3. `dispatchEvent sends JSON-encoded event over the socket`
4. `namespaced sockets work`
5. `on message as JSON handler decodes JSON payload`
6. `on message as JSON throws on non-JSON payload`
7. `on message handler fires on incoming text message`
8. `parses socket with absolute ws:// URL`
9. `rpc proxy blacklists then/catch/length/toJSON`
10. `rpc proxy default timeout rejects the promise`
11. `rpc proxy noTimeout avoids timeout rejection`
12. `rpc proxy reply with throw rejects the promise`
13. `rpc proxy sends a message and resolves the reply`
14. `rpc proxy timeout(n) rejects after a custom window`
15. `rpc reconnects after the underlying socket closes`
16. `with 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 })` with `sender` and `_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 after `n` ms.
Calling `proxy.fnName(arg1, arg2)` performs:
1. Allocate a unique `iid` (string).
2. Send `JSON.stringify({iid, function: "fnName", args: [arg1, arg2]})` over ws.
3. Return a Promise. The promise is tracked by `iid`.
4. Incoming messages with matching `iid` are routed: `{iid, return: v}` resolves with `v`; `{iid, throw: e}` rejects with `e`.
5. 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).
- `:timeout` is the parsed ms expression, or `nil` if absent.
- `:on-message` is `nil`, `(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
1. **Normalise URL**: if starts with `ws://`/`wss://` use as-is; else derive scheme from `(host-get (host-global "location") "protocol")` and prepend `ws://host[:port]` or `wss://…`. Preserves path.
2. **Construct** `(host-new "WebSocket" url)`.
3. **Build wrapper object** (SX dict): `{:raw ws :url url :timeout <ms> :pending <dict-of-iid->resolver> :handler <closure> :json? <bool> :closed? false}`.
4. **Install `ws.onmessage`** via `host-set!` to a native callback (`host-callback`) that:
- Reads `data = event.data`.
- If JSON-looking (starts with `{` or `[`) tries `json-parse`; if parse OK and has `iid` field → RPC reply dispatch (see 4c).
- Otherwise → fire user's `on message` handler with `event` (optionally JSON-parsing first if `:json? true`; non-JSON there → `(error "Received non-JSON message")`).
5. **Install `ws.addEventListener("close", …)`** (via `host-call`) that sets `:closed? true` so next RPC reconnects.
6. **Install `.dispatchEvent`** method on wrapper via `host-set!`: takes an `Event`, builds `{type, ...detail minus sender,_namedArgList_}`, `JSON.stringify`s, `ws.send`s.
7. **Install `.rpc`** — the proxy (see 4b).
8. **Bind** on `window`: walk `name-path` creating 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`):
```js
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**.
```js
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:
1. Recognises the pattern `var OrigWS = window.WebSocket; … window.WebSocket = function() {...}; … script.textContent = 'socket …'; …`.
2. Extracts the inner hyperscript source from the `script.textContent` string.
3. 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 an `onmessage` hook.
- Activates the hyperscript on a `div` (so the socket feature runs through the existing `hs-activate!` pipeline).
- Invokes whatever the test asserted against: reads `urls[0]`, calls `.dispatchEvent`, drives `mockSocket.onmessage(…)`, etc.
- Asserts the final value (translated from `expect(…).toBe(…)` via the existing `pw_assertion_to_sx`).
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:** **1214**. Timeout/reconnect tests (10, 11, 14, 15) need real-time behaviour from the Node mock; if `setTimeout` behaviour 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)
1. **Reconnect scope.** The fixture only tests lazy reconnect on next RPC. Do we also reconnect on `dispatchEvent` after close? (Stock yes; tests don't cover it. Decision: yes, keep parity.)
2. **Buffering while disconnected.** Between `close` and first reconnecting call, does `dispatchEvent` drop the frame or queue it? Stock *drops*. Recommendation: drop, log once.
3. **Binary frames.** None of the 16 tests exercise `Blob`/`ArrayBuffer`. Explicitly out of scope — document as `not yet supported` in runtime.
4. **Close-code handling.** `close(code, reason)` / CloseEvent — no test exercises it. Recommendation: ignore code/reason, just mark closed.
5. **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's `then` absorption takes over. Verify that the CEK/VM `hs-safe-call` path doesn't eagerly `(host-await)`.
6. **Proxy implementation language.** The proxy *must* be JS (ES6 `Proxy`) — SX has no equivalent and the tests read `.then` etc. 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 shared `platform/sockets.js`? (Recommendation: yes, land it in `shared/static/scripts/sx-platform-sockets.js` in a follow-up commit after the mock lands; the mock inline version stays as the reference.)
7. **Namespace dotted binding collisions.** If `window.MyApp` already exists as a non-dict, the runtime should refuse overwriting it (tests only use fresh names). Recommendation: silently overwrite with a dict — matches stock.
8. **`emit-send` widening.** Changing send to branch on `_hs_socket?` touches a code path used by every existing send test. Needs a smoke-regression check against `hs-upstream-send` before 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`.
1. **Mock groundwork** *(0 tests)*. Add `WebSocket` constructor + `_hs_make_rpc_proxy` factory to `tests/hs-run-filtered.js`. No runtime/compiler changes. Verify no regressions on smoke 0195. Commit: `HS-prep: WebSocket + RPC proxy mock`.
2. **Parser: `socket` feature** *(0 tests)*. Add `parse-socket-feat` in `parser.sx` dispatched from `parse-feat`. Parses name-path, URL, `with timeout N`, `on message [as JSON] … end`, trailing `end`. Emits `(socket …)` AST. Unit-verify via `hs-compile` eval trace. sync-wasm. Commit: `HS: parse socket feature`.
3. **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 emits `hs-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)`.
4. **Namespaced binding + wrapper shape** *(+2 tests: 4, 16)*. Walk name-path assigning intermediate dicts. Ensure wrapper exposes `.raw` and `.rpc` objects (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)`.
5. **Inbound `on message` dispatch** *(+3 tests: 5, 6, 7)*. Wire `ws.onmessage` → SX handler. Support `as JSON` parse branch + non-JSON error. Generator: `inbound-text` + `inbound-json` dispatch 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)`.
6. **RPC proxy — happy path** *(+2 tests: 9, 13)*. Wire `.rpc` proxy with blacklist, `iid` allocation, send JSON, resolve on matching `iid.return`. Uses real JS `Promise` from the mock. Generator `rpc-roundtrip` code. sync-wasm. Land: `rpc proxy blacklists …`, `rpc proxy sends a message and resolves…`. Commit: `HS: socket RPC proxy (+2)`.
7. **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. Add `dispatchEvent` JSON-encode (strips `sender`/`_namedArgList_`). Generator `close-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)`.
8. **RPC timeouts** *(+3 tests: 10, 11, 14)*. `.timeout(n)` / `.noTimeout` chainable; default timeout from `with timeout N`. `setTimeout` reject with `"Timed out"`. Generator `rpc-timeout` code — may need a virtual-clock helper in the mock if real `setTimeout` is 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)`.
9. **Polish + scoreboard** *(0 tests)*. Update `plans/hs-conformance-to-100.md` row 36 to `done (+N)`, bump scoreboard. Smoke 0195 + `hs-upstream-send` regression check. Commit: `HS-plan: E36 complete (+N)`.
Total runway: **8 feature commits** to move cluster 36 from `blocked``done (+1216)`. No commit may land a regression in smoke 0195 or in `hs-upstream-send`.