feat: hand-written WebSocket client (createSocket) — v1.6.0#16
Draft
vcarl wants to merge 10 commits into
Draft
Conversation
9ee54bb to
6177f9a
Compare
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>
6177f9a to
6492a7d
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a hand-written, dependency-free WebSocket client (
createSocket) to the package, mirroring the existing hand-written RESTcreateSession. New public surface:createSocketplus ~20 socket types exported fromsrc/index.ts.What's in it
createSocket(opts)— connects, awaitswelcome, sends the login frame, awaitslogged_in, then resolves. Framework-agnostic; the WebSocket impl is injected or discovered (globalWebSocket, else a lazyimport("ws")), so there's no hard runtime dependency onws.{type,payload}vs{tool,action,payload}, selectable per endpoint (/ws,/ws/v2).1000on deploy/idle, and observed4001/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(frame)assigns arequest_idand resolves on the matching terminal frame, skipping theok {pending:true}ack for queued mutations.for await (const ev of socket)) and an emitter (socket.on('chat_message', …), plus connection eventsopen/close/reconnect/error).subscribeMarket/subscribeObservationand their inverses.wsOptionspassthrough + defaultUser-Agent— callers can now pass options (headers/protocols/TLS) into socket creation; every connection carriesUser-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 ascripts/ws-smoke.tslive probe.Commits
feat(socket)— add the hand-written WebSocket client (createSocket)fix(session)— mint a session before login so X-Session-Id is setfix(socket)— correlate v2resultframes inrequest()docs(socket)— record observed close codes; add live smoke probefix(socket)— close theServerEventunion so it narrows under TypeScript 6docs(socket)— document the createSocket client in the READMEfeat(socket)—wsOptionspassthrough + default User-Agentv1.6.0— release markerTesting
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-sidewsupgrade request) andsdk-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.0full client resync,1.5.0shipping the CLI bin), not a routine additive change.mainby 3 spec-sync commits (1.5.20–1.5.22).package.jsonandopenapi.jsonwill conflict — this branch needs a rebase/merge ontomainfirst.src/socket-types.ts:7,16import 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