Skip to content

feat: hand-written WebSocket client (createSocket) — v1.6.0#16

Draft
vcarl wants to merge 10 commits into
mainfrom
feat/ws-socket-types
Draft

feat: hand-written WebSocket client (createSocket) — v1.6.0#16
vcarl wants to merge 10 commits into
mainfrom
feat/ws-socket-types

Conversation

@vcarl

@vcarl vcarl commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Adds a hand-written, dependency-free WebSocket client (createSocket) to the package, mirroring the existing hand-written REST createSession. New public surface: createSocket plus ~20 socket types exported from src/index.ts.

What's in it

  • createSocket(opts) — connects, awaits welcome, sends the login frame, awaits logged_in, then resolves. Framework-agnostic; the WebSocket impl is injected or discovered (global WebSocket, else a lazy import("ws")), so there's no hard runtime dependency on ws.
  • Auth modes — username/password credentials, single-use login token, or anonymous (welcome-only).
  • v1 and v2 framing{type,payload} vs {tool,action,payload}, selectable per endpoint (/ws, /ws/v2).
  • Transparent reconnect — after the first successful auth, an unexpected close (including the server's normal 1000 on deploy/idle, and observed 4001/4002) triggers exponential-backoff reconnect that re-runs the full handshake. The event stream stays live across reconnects; a first-connection failure or rejected credential is terminal.
  • Request/response correlationrequest(frame) assigns a request_id and resolves on the matching terminal frame, skipping the ok {pending:true} ack for queued mutations.
  • Two consumption surfaces — async iterator (for await (const ev of socket)) and an emitter (socket.on('chat_message', …), plus connection events open/close/reconnect/error).
  • Subscription helperssubscribeMarket / subscribeObservation and their inverses.
  • wsOptions passthrough + default User-Agent — callers can now pass options (headers/protocols/TLS) into socket creation; every connection carries User-Agent: @spacemolt/client-v2/<VERSION>, with a caller-supplied UA prepended.

Also includes a session fix (mint a session before login so X-Session-Id is set) and a scripts/ws-smoke.ts live probe.

Commits

  • feat(socket) — add the hand-written WebSocket client (createSocket)
  • fix(session) — mint a session before login so X-Session-Id is set
  • fix(socket) — correlate v2 result frames in request()
  • docs(socket) — record observed close codes; add live smoke probe
  • fix(socket) — close the ServerEvent union so it narrows under TypeScript 6
  • docs(socket) — document the createSocket client in the README
  • feat(socket)wsOptions passthrough + default User-Agent
  • v1.6.0 — release marker

Testing

Full suite green: 302 pass / 0 fail. New coverage: sdk-socket.runtime.test.ts (handshake, auth modes, request correlation, header/User-Agent behavior asserted against a real server-side ws upgrade request) and sdk-socket.reconnect.test.ts (backoff, terminal-vs-recoverable closes).

Versioning

Minor → 1.6.0. This ships a whole new public client module — a new capability on par with the repo's prior minors (1.4.0 full client resync, 1.5.0 shipping the CLI bin), not a routine additive change.

⚠️ Reconciliation needed before merge

  • Behind main by 3 spec-sync commits (1.5.201.5.22). package.json and openapi.json will conflict — this branch needs a rebase/merge onto main first.
  • Pre-existing typecheck errors (not introduced here): src/socket-types.ts:7,16 import generated names (NotificationCombatUpdate, NotificationScanResult) that a spec sync renamed/removed. The socket client's generated-type references need reconciling to the current spec — a separate task. This diff adds zero new type errors.

🤖 Generated with Claude Code

@vcarl vcarl marked this pull request as draft July 6, 2026 19:48
@vcarl vcarl force-pushed the feat/ws-socket-types branch from 9ee54bb to 6177f9a Compare July 6, 2026 19:55
@vcarl vcarl changed the title v1.5.23: WebSocket wsOptions passthrough + default User-Agent feat: hand-written WebSocket client (createSocket) — v1.6.0 Jul 6, 2026
vcarl and others added 8 commits July 6, 2026 15:58
Framework-agnostic, dependency-free socket module mirroring the existing
createSession REST wrapper. Lives beside src/sdk-session.ts (outside
src/generated/, which `generate` clobbers).

- createSocket(opts): connect -> welcome -> login -> logged_in handshake,
  with v1 ({type,payload}) and v2 ({tool,action,payload}) outbound framing
  behind one API. One complete JSON object per message.
- Auth by {username,password} | {loginToken} | {anonymous}; credentials are
  passed in explicitly and never read from env/disk, never logged.
- ServerEvent union reuses the 13 generated Notification* types for game-event
  payloads; control frames (welcome/logged_in/registered/ok/error/
  action_result/action_error) are hand-written. A trailing open union member
  keeps unknown/future frames flowing (never dropped).
- Consumption via AsyncIterable<ServerEvent> and an additive .on() emitter.
- Transparent reconnect after first auth (backoff + re-login; stream stays
  live across the server's normal close=1000); request_id correlation helper
  request() that resolves on the terminal response.
- WebSocket impl injected via WebSocketImpl, defaulting to global WebSocket
  then a lazy import("ws"); ws is a devDependency only (mock server in tests).
- Type test (tsconfig.socket-types.test.json + `typecheck:socket`) pins the
  §7 payload mapping; runtime + reconnect suites cover handshake, framing,
  parsing, subscriptions, reconnect, and correlation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createSession called spacemoltAuthLogin before any session existed, and the
request interceptor was registered only after that first login — so the login
went out with no X-Session-Id header. The server requires it on the login
endpoint, rejected the request with `session_required`, returned no session id,
and createSession threw "no session id returned".

Fix: authenticate() now POSTs /api/v2/session first, publishes the minted id
(so the request interceptor stamps it on the login call), then logs in; login
may rotate the id. An `authenticating` guard stops the response interceptor
from re-entering auth on the mint/login traffic it generates, and the auto
re-auth path re-mints a fresh session. Adds a regression test asserting login
is sent with the minted X-Session-Id header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2 synchronous query/ack responses arrive as a `result` frame
({type:"result",request_id,payload:{result,structuredContent}}), and queued v2
mutations send a `result` {pending:true} ack before the post-tick action_result
(api-docs §"WebSocket v2"). request() only recognized v1 `ok`, so correlation
hung on the v2 endpoint. Add `result` to the ServerEvent union and treat it like
`ok` in resolvePending (skip pending acks, resolve on the terminal result).
Adds v2 query + mutation correlation tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- sdk-socket.ts header now documents the close codes seen live against
  game.spacemolt.com: 1000 (normal), 4001 session_replaced (a 2nd connection
  for the same account kicks the old one), 4002 auth_timeout. Only 1000 is in
  the api-docs; 4001/4002 are observed-but-undocumented. Notes that reconnect
  keys off "non-user, non-1000 close" so a 4001 kick reconnects cleanly.
- scripts/ws-smoke.ts: a manual live probe driving createSocket (anonymous /
  login / login_token), with masked prompt or --password-file, and optional
  --subscribe / --request / --kick-after to exercise subscriptions, request_id
  correlation, and reconnect. NOT a unit test and NOT run in CI. Header notes
  how it could become the canonical contract probe (design doc §9.4/§9.5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trailing open `{ type: string; payload?: unknown }` catch-all member made
`ev.payload` collapse to `unknown` in every `case`/`if (ev.type === '...')`
branch under TypeScript 6.x — breaking the primary `for await (const ev of
socket) { switch (ev.type) … }` consumption pattern (verified against a packed
tarball consumed by a TS 6.0.3 project). TS 5 narrowed it; TS 6 does not, and
`string & {}` doesn't help — only a closed discriminated union narrows.

Close the union (drop the catch-all) and add an exported `RawServerFrame` type
for the forward-compat escape hatch: the runtime still delivers any frame with a
string `type` (never drops), and unknown frames are handled in a `default:`
branch as RawServerFrame. Widen the stale `typescript` peer range to `^5 || ^6`.
ws-smoke.ts's welcome-branch cast updated (payload now narrows to WelcomePayload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SocketOptions.wsOptions: passthrough to the WebSocket ctor's 2nd arg
  (Bun global WS / `ws` form), carrying headers/protocols/TLS opts.
- Default User-Agent `@spacemolt/client-v2/<VERSION>`; a caller-supplied
  UA (any casing) prepends to it. One canonical header on the wire;
  non-string values fall back to the bare default.
- Widen WebSocketCtor's 2nd arg to accept the options object.
- Tests assert real server-side request.headers end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release the hand-written, dependency-free WebSocket client: public
createSocket + socket type exports, transparent reconnect, request/response
correlation, and wsOptions/User-Agent support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vcarl vcarl force-pushed the feat/ws-socket-types branch from 6177f9a to 6492a7d Compare July 6, 2026 19:59
vcarl and others added 2 commits July 6, 2026 16:03
The spec renamed the combat domain to battle_* and dropped the
scan_result notification. Map combat_update -> battle_update
(NotificationBattleUpdate) and remove the scan_result union member
(no successor; scan_detected already covers scans).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The socket User-Agent needs the package version, but importing it from
./config pulled config.ts (node process/path) into the library
declaration build (tsconfig.lib.json runs with `types: []`), breaking
`bun run pack:lib`. Extract VERSION into a node-free ./version module
that both config.ts and sdk-socket.ts import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant