HTML5 target: subprotocol/header quirks #8

Closed
opened 2026-08-19 06:49:31 +00:00 by yuris · 2 comments
Owner

extension-websocket ignores custom headers on HTML5, and subprotocol negotiation has historical bugs there. The SDK is broken on HTML5 until verified.

  • Verify protocol=v1.json.spacetimedb negotiates on HTML5 build
  • If headers unusable: pass token via query param instead
  • Test in browser
extension-websocket ignores custom headers on HTML5, and subprotocol negotiation has historical bugs there. The SDK is broken on HTML5 until verified. - [ ] Verify protocol=v1.json.spacetimedb negotiates on HTML5 build - [ ] If headers unusable: pass token via query param instead - [ ] Test in browser
Author
Owner

Resolved on the SDK side; HTML5 is blocked on an upstream extension bug

Both checkboxes are answered, and the full smoke client now passes in a real headless Chromium. Two distinct failures, not one.

1. Headers are unusable, and ?token= is the replacement

Confirmed against a live clockworklabs/spacetime:v2.8.2, four transports, same URL:

anonymous          : 0xc200ce6548a60903bad0a8435039ae632297cd592cbdcd32d7da6c67c952c06a
Authorization hdr  : 0xc200ffda267249f07a0aa1274f63ec8683790a3ff00cb74acbebdcbbd5aa90e4
?token= query param: 0xc200ffda267249f07a0aa1274f63ec8683790a3ff00cb74acbebdcbbd5aa90e4
Sec-WebSocket-Proto: 0xc2001cd4489f2660ac9c75c78884b36173dfd2a943875a4a8c423424bbc1dd8a

?token= authenticates to the same identity as the header. Stuffing the token into the subprotocol (as some clients do) does not — it yields a fresh identity, so that trick is out.

Why this mattered more than it looked: a dropped header does not fail. The connection succeeds as a brand new anonymous identity, so an HTML5 build silently loses the player's identity with no error anywhere.

connect now picks the transport per platform — query parameter on HTML5, Authorization header everywhere else, since a token in a URL can land in server access logs. stdb.with_token is pure and unit-tested (test/test_init.lua).

2. extension-websocket 4.2.3 drops the subprotocol on HTML5

With the token fixed, the handshake still failed:

[handshake] -> subprotocol=<ABSENT>
[handshake] <- 400 Bad Request        # "no valid protocol selected"

A node probe pins the cause exactly — 400 appears only when no subprotocol is sent; Origin and permessage-deflate are irrelevant.

Root cause is in the extension, websocket/src/websocket.cpp:

  • line 427: CreateConnection() calls CreateConnectionEmscripten(conn)
  • line 510: LuaConnect() then assigns conn->m_Protocol = strdup(protocol)

The socket is opened before the protocol is recorded. conn was memset to zero, so emscripten_websocket_new sees a null protocols pointer and the glue takes its new WebSocket(url) branch:

var socket = protocols ? new WebSocket(url, UTF8ToString(protocols).split(",")) : new WebSocket(url);

Native builds are unaffected — wslay writes the handshake later, from the update loop, when m_Protocol is set. There is no Lua-side workaround: SpacetimeDB requires a subprotocol.

Six-line fix (move socket creation to the end of LuaConnect) is committed as patches/extension-websocket-html5-subprotocol.patch. Verified by vendoring a patched copy and rebuilding:

[handshake] -> subprotocol=v1.json.spacetimedb
[handshake] <- 101 Switching Protocols
[html5] SMOKE: identity 0xc200a6f2becf36e586cad015fdeb9baa64e1d8e4394b473b282214cddbc30b3d
[html5] SMOKE: PHASE1 OK cached row id=1
[html5] phase 1 passed — restarting stdb-test to force a reconnect
[html5] SMOKE: reconnected, replayed 1 subscription(s)
[html5] SMOKE: PHASE2 OK survived restart; row id=1 restored
[html5] SMOKE: PHASE3 OK unsubscribed query set 1; cache drained
[html5] SMOKE: PASS procedure round trip; failure surfaced as an error
[html5] PASS

The identity matches the supplied token, so ?token= re-auth works on reconnect too.

New: HTML5 conformance harness

tools/html5-conformance.sh + tools/html5-conformance.mjs — builds a wasm-web bundle, serves it, drives Chromium over CDP, and reads the verdict off the console. Dependency-free (node's own global WebSocket talks to devtools). It reports handshake request/response lines, which is what made this bug findable at all.

Three environment traps it encodes, each a wasted run otherwise:

  • --variant release strips print, so the verdict never reaches the console. Must be debug.
  • Defold needs a WebGL context or it crashes in _glGetString before any script runs. Chromium 150+ requires --enable-unsafe-swiftshader, and --disable-gpu removes the context entirely.
  • Attach to the page target, not the browser target. The endpoint Chromium announces on stderr is the browser, where Runtime/Log are silently no-ops.

Remaining

Everything left is upstream or CI, tracked separately. The SDK code for HTML5 is done and proven.

## Resolved on the SDK side; HTML5 is blocked on an upstream extension bug Both checkboxes are answered, and the full smoke client now passes in a real headless Chromium. Two distinct failures, not one. ### 1. Headers are unusable, and `?token=` is the replacement Confirmed against a live `clockworklabs/spacetime:v2.8.2`, four transports, same URL: ``` anonymous : 0xc200ce6548a60903bad0a8435039ae632297cd592cbdcd32d7da6c67c952c06a Authorization hdr : 0xc200ffda267249f07a0aa1274f63ec8683790a3ff00cb74acbebdcbbd5aa90e4 ?token= query param: 0xc200ffda267249f07a0aa1274f63ec8683790a3ff00cb74acbebdcbbd5aa90e4 Sec-WebSocket-Proto: 0xc2001cd4489f2660ac9c75c78884b36173dfd2a943875a4a8c423424bbc1dd8a ``` `?token=` authenticates to the **same identity** as the header. Stuffing the token into the subprotocol (as some clients do) does not — it yields a fresh identity, so that trick is out. Why this mattered more than it looked: a dropped header does not fail. The connection succeeds as a brand new anonymous identity, so an HTML5 build silently loses the player's identity with no error anywhere. `connect` now picks the transport per platform — query parameter on HTML5, `Authorization` header everywhere else, since a token in a URL can land in server access logs. `stdb.with_token` is pure and unit-tested (`test/test_init.lua`). ### 2. extension-websocket 4.2.3 drops the subprotocol on HTML5 With the token fixed, the handshake still failed: ``` [handshake] -> subprotocol=<ABSENT> [handshake] <- 400 Bad Request # "no valid protocol selected" ``` A node probe pins the cause exactly — 400 appears **only** when no subprotocol is sent; Origin and permessage-deflate are irrelevant. Root cause is in the extension, `websocket/src/websocket.cpp`: - line 427: `CreateConnection()` calls `CreateConnectionEmscripten(conn)` - line 510: `LuaConnect()` then assigns `conn->m_Protocol = strdup(protocol)` The socket is opened *before* the protocol is recorded. `conn` was `memset` to zero, so `emscripten_websocket_new` sees a null `protocols` pointer and the glue takes its `new WebSocket(url)` branch: ```js var socket = protocols ? new WebSocket(url, UTF8ToString(protocols).split(",")) : new WebSocket(url); ``` Native builds are unaffected — wslay writes the handshake later, from the update loop, when `m_Protocol` is set. **There is no Lua-side workaround**: SpacetimeDB requires a subprotocol. Six-line fix (move socket creation to the end of `LuaConnect`) is committed as `patches/extension-websocket-html5-subprotocol.patch`. Verified by vendoring a patched copy and rebuilding: ``` [handshake] -> subprotocol=v1.json.spacetimedb [handshake] <- 101 Switching Protocols [html5] SMOKE: identity 0xc200a6f2becf36e586cad015fdeb9baa64e1d8e4394b473b282214cddbc30b3d [html5] SMOKE: PHASE1 OK cached row id=1 [html5] phase 1 passed — restarting stdb-test to force a reconnect [html5] SMOKE: reconnected, replayed 1 subscription(s) [html5] SMOKE: PHASE2 OK survived restart; row id=1 restored [html5] SMOKE: PHASE3 OK unsubscribed query set 1; cache drained [html5] SMOKE: PASS procedure round trip; failure surfaced as an error [html5] PASS ``` The identity matches the supplied token, so `?token=` re-auth works on reconnect too. ### New: HTML5 conformance harness `tools/html5-conformance.sh` + `tools/html5-conformance.mjs` — builds a `wasm-web` bundle, serves it, drives Chromium over CDP, and reads the verdict off the console. Dependency-free (node's own global `WebSocket` talks to devtools). It reports handshake request/response lines, which is what made this bug findable at all. Three environment traps it encodes, each a wasted run otherwise: - **`--variant release` strips `print`**, so the verdict never reaches the console. Must be `debug`. - **Defold needs a WebGL context** or it crashes in `_glGetString` before any script runs. Chromium 150+ requires `--enable-unsafe-swiftshader`, and `--disable-gpu` removes the context entirely. - **Attach to the page target, not the browser target.** The endpoint Chromium announces on stderr is the browser, where `Runtime`/`Log` are silently no-ops. ### Remaining Everything left is upstream or CI, tracked separately. The SDK code for HTML5 is done and proven.
Author
Owner

Closing: all three checkboxes are answered with evidence, and the remaining work is not SDK code.

  • Does v1.json.spacetimedb negotiate on HTML5? No — extension-websocket 4.2.3 never sends the subprotocol. Root-caused to a socket-creation ordering bug, patched, verified. Upstreaming tracked in #15.
  • Are headers unusable, and is a query parameter the answer? Yes to both. ?token= authenticates to the same identity as the header, and connect now uses it on HTML5 only.
  • Tested in a browser? Yes. tools/html5-conformance.sh runs the same smoke client as native conformance in headless Chromium; it passes all four phases with the patched extension.

Follow-ups: #15 (upstream the fix), #16 (this tier in CI), #17 (identity persistence, which sys.save on IDBFS makes genuinely doubtful).

Closing: all three checkboxes are answered with evidence, and the remaining work is not SDK code. - **Does `v1.json.spacetimedb` negotiate on HTML5?** No — extension-websocket 4.2.3 never sends the subprotocol. Root-caused to a socket-creation ordering bug, patched, verified. Upstreaming tracked in #15. - **Are headers unusable, and is a query parameter the answer?** Yes to both. `?token=` authenticates to the same identity as the header, and `connect` now uses it on HTML5 only. - **Tested in a browser?** Yes. `tools/html5-conformance.sh` runs the same smoke client as native conformance in headless Chromium; it passes all four phases with the patched extension. Follow-ups: #15 (upstream the fix), #16 (this tier in CI), #17 (identity persistence, which `sys.save` on IDBFS makes genuinely doubtful).
yuris closed this issue 2026-08-19 12:07:19 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/spacetimedb-defold#8
No description provided.