From 768771d6afc815e69e69d8a5ba097b0335c2b2b9 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 25 Jun 2026 14:35:37 -0400 Subject: [PATCH 01/10] feat(socket): add hand-written WebSocket client (createSocket) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- bun.lock | 3 + package.json | 4 +- src/index.ts | 25 ++ src/sdk-socket.ts | 467 +++++++++++++++++++++++++++++ src/socket-types.ts | 227 ++++++++++++++ tests/library-exports.test.ts | 4 + tests/sdk-socket.reconnect.test.ts | 367 +++++++++++++++++++++++ tests/sdk-socket.runtime.test.ts | 454 ++++++++++++++++++++++++++++ tests/sdk-socket.test.ts | 73 +++++ tsconfig.lib.json | 2 +- tsconfig.socket-types.test.json | 11 + 11 files changed, 1635 insertions(+), 2 deletions(-) create mode 100644 src/sdk-socket.ts create mode 100644 src/socket-types.ts create mode 100644 tests/sdk-socket.reconnect.test.ts create mode 100644 tests/sdk-socket.runtime.test.ts create mode 100644 tests/sdk-socket.test.ts create mode 100644 tsconfig.socket-types.test.json diff --git a/bun.lock b/bun.lock index 5fe7205..3e2fe20 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@hey-api/openapi-ts": "^0.73.0", "@types/bun": "^1.3.9", + "ws": "^8.18.0", }, "peerDependencies": { "typescript": "^5", @@ -130,6 +131,8 @@ "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], diff --git a/package.json b/package.json index a262431..ddc13ef 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "fetch-spec": "bash scripts/fetch-spec.sh", "generate": "bun run openapi-ts && bun run scripts/build-commands.ts", "typecheck": "bun x tsc --noEmit", + "typecheck:socket": "bun x tsc --noEmit -p tsconfig.socket-types.test.json", "test": "bun test", "build:lib": "rm -rf dist && bun build src/index.ts --outfile dist/index.js --target node --format esm && bun x tsc -p tsconfig.lib.json && bun run scripts/fix-dts-extensions.ts", "build:cli": "bun build src/main.ts --target bun --outfile dist/cli.js && bun run scripts/prepend-shebang.ts", @@ -38,7 +39,8 @@ "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "^0.73.0", - "@types/bun": "^1.3.9" + "@types/bun": "^1.3.9", + "ws": "^8.18.0" }, "peerDependencies": { "typescript": "^5" diff --git a/src/index.ts b/src/index.ts index 14b62e2..0d977a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,29 @@ export * from './generated'; // createClient may not be in the generated barrel; re-export it explicitly to be safe. export { createClient } from './generated/client'; export { createSession } from './sdk-session'; +export { createSocket } from './sdk-socket'; export type { SessionOptions, SpacemoltSession } from './sdk-session'; + +// WebSocket socket module — types only (createSocket runtime lands in step 3). +export type { + SpacemoltSocket, + SocketOptions, + SocketEndpoint, + SocketAuth, + SocketCredentials, + SocketLoginToken, + SocketAnonymous, + ServerEvent, + OutboundFrame, + OutboundFrameV1, + OutboundFrameV2, + WelcomePayload, + LoggedInPayload, + RegisteredPayload, + ErrorPayload, + ReconnectOptions, + ConnectionEvent, + SocketStatus, + WebSocketCtor, + WebSocketLike, +} from './socket-types'; diff --git a/src/sdk-socket.ts b/src/sdk-socket.ts new file mode 100644 index 0000000..a2d8948 --- /dev/null +++ b/src/sdk-socket.ts @@ -0,0 +1,467 @@ +// Hand-written, framework-agnostic WebSocket runtime for @spacemolt/client-v2, +// mirroring the hand-written REST `createSession` in sdk-session.ts. Lives OUTSIDE +// src/generated/. Dependency-free: the WebSocket impl is injected or discovered +// (global WebSocket, else a lazy `import("ws")`); no hard runtime dep on `ws`. +// +// Reconnect: after the FIRST successful auth, an unexpected close (including the +// server's normal close=1000 on deploy/idle) triggers a backoff reconnect that +// re-runs the full handshake (welcome -> login -> logged_in). The event stream +// stays live across reconnects; it only ends on an explicit close() or when the +// reconnect policy is exhausted/disabled. A failure on the FIRST connection (or a +// rejected credential at any time) is terminal and rejects/ends rather than looping. +// +// Keepalive: there is no app-level heartbeat frame (the server pushes none); we rely +// on the transport answering protocol pings (automatic with `ws` and browsers). +import type { + SocketOptions, + SocketAuth, + SocketEndpoint, + SocketStatus, + SpacemoltSocket, + ServerEvent, + OutboundFrame, + ConnectionEvent, + ReconnectOptions, + WebSocketCtor, + WebSocketLike, +} from './socket-types'; + +/** Default API origin (mirrors sdk-session.ts; duplicated to avoid coupling the modules). */ +const DEFAULT_BASE_URL = 'https://game.spacemolt.com'; + +const WS_PATH: Record = { v1: '/ws', v2: '/ws/v2' }; + +/** Names routed to the connection-lifecycle emitter rather than the frame emitter. */ +const CONNECTION_EVENT_NAMES = new Set(['open', 'close', 'reconnect', 'error']); + +interface ResolvedReconnect { + initialDelayMs: number; + maxDelayMs: number; + factor: number; + jitter: boolean; + maxAttempts: number; +} +function normalizeReconnect(r: ReconnectOptions | false | undefined): ResolvedReconnect | null { + if (r === false) return null; + const o = r ?? {}; + return { + initialDelayMs: o.initialDelayMs ?? 500, + maxDelayMs: o.maxDelayMs ?? 30_000, + factor: o.factor ?? 2, + jitter: o.jitter ?? true, + maxAttempts: o.maxAttempts ?? Infinity, + }; +} +function backoffDelay(r: ResolvedReconnect, attempt: number): number { + const raw = Math.min(r.maxDelayMs, r.initialDelayMs * Math.pow(r.factor, Math.max(0, attempt - 1))); + // full jitter in [raw/2, raw] + return r.jitter ? raw / 2 + Math.random() * (raw / 2) : raw; +} + +function deriveWsUrl(baseUrl: string, endpoint: SocketEndpoint): string { + // http->ws, https->wss; then append the endpoint path. + return `${baseUrl.replace(/^http/, 'ws')}${WS_PATH[endpoint]}`; +} + +async function resolveWebSocketImpl(injected?: WebSocketCtor): Promise { + if (injected) return injected; + const globalWs = (globalThis as { WebSocket?: unknown }).WebSocket; + if (typeof globalWs === 'function') return globalWs as unknown as WebSocketCtor; + try { + // Indirect the specifier through a variable so tsc does not statically resolve + // `ws` (we ship neither @types/ws nor a runtime dep). Loaded only if present. + const spec = 'ws'; + const mod = (await import(spec)) as { default?: unknown; WebSocket?: unknown }; + const ctor = mod.default ?? mod.WebSocket ?? mod; + return ctor as unknown as WebSocketCtor; + } catch (err) { + throw new Error( + 'No WebSocket implementation available. Pass opts.WebSocketImpl, run on a ' + + 'runtime with a global WebSocket, or install the "ws" package. ' + + `(${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +type AuthMode = 'credentials' | 'login_token' | 'anonymous'; +function authModeOf(auth: SocketAuth): AuthMode { + if ('anonymous' in auth) return 'anonymous'; + if ('loginToken' in auth) return 'login_token'; + return 'credentials'; +} + +/** Build the endpoint-specific login frame. Never called for anonymous auth. */ +function buildLoginFrame(endpoint: SocketEndpoint, auth: SocketAuth): string { + if ('loginToken' in auth) { + const payload = { token: auth.loginToken }; + return endpoint === 'v1' + ? JSON.stringify({ type: 'login_token', payload }) + : JSON.stringify({ tool: 'spacemolt_auth', action: 'login_token', payload }); + } + const creds = auth as { username: string; password: string }; + const payload = { username: creds.username, password: creds.password }; + return endpoint === 'v1' + ? JSON.stringify({ type: 'login', payload }) + : JSON.stringify({ tool: 'spacemolt_auth', action: 'login', payload }); +} + +/** Push-based async queue feeding the iterator; ends on terminal close. */ +class EventQueue { + private buffer: ServerEvent[] = []; + private waiters: Array<(r: IteratorResult) => void> = []; + private ended = false; + push(value: ServerEvent): void { + if (this.ended) return; + const waiter = this.waiters.shift(); + if (waiter) waiter({ value, done: false }); + else this.buffer.push(value); + } + end(): void { + if (this.ended) return; + this.ended = true; + for (const waiter of this.waiters.splice(0)) { + waiter({ value: undefined as never, done: true }); + } + } + next(): Promise> { + const queued = this.buffer.shift(); + if (queued !== undefined) return Promise.resolve({ value: queued, done: false }); + if (this.ended) return Promise.resolve({ value: undefined as never, done: true }); + return new Promise((resolve) => this.waiters.push(resolve)); + } +} + +interface PendingRequest { + resolve: (e: ServerEvent) => void; + reject: (e: Error) => void; + timer: ReturnType | undefined; +} + +/** + * Open a SpacemoltSocket: connect, await `welcome`, send the login frame (unless + * anonymous), await `logged_in`, then resolve. Reconnects transparently after the + * first success (see file header). + */ +export async function createSocket(opts: SocketOptions): Promise { + const endpoint: SocketEndpoint = opts.endpoint ?? 'v1'; + const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL; + const wsUrl = opts.wsUrl ?? deriveWsUrl(baseUrl, endpoint); + const authMode = authModeOf(opts.auth); + const reconnectPolicy = normalizeReconnect(opts.reconnect); + const Ctor = await resolveWebSocketImpl(opts.WebSocketImpl); + + const queue = new EventQueue(); + const frameListeners = new Map void>>(); + const connListeners = new Map void>>(); + const pending = new Map(); + const closeResolvers: Array<() => void> = []; + + let status: SocketStatus = 'connecting'; + let currentWs: WebSocketLike | undefined; + let userClosed = false; + let everReady = false; + let attempt = 0; + let reqCounter = 0; + let reconnectTimer: ReturnType | undefined; + + let resolveFirst!: (h: SpacemoltSocket) => void; + let rejectFirst!: (e: Error) => void; + let firstSettled = false; + const firstHandshake = new Promise((res, rej) => { + resolveFirst = (h) => { + if (!firstSettled) { firstSettled = true; res(h); } + }; + rejectFirst = (e) => { + if (!firstSettled) { firstSettled = true; rej(e); } + }; + }); + + function emitFrame(type: string, ev: ServerEvent): void { + const set = frameListeners.get(type); + if (set) for (const cb of [...set]) cb(ev); + } + function emitConn(name: string, info: ConnectionEvent): void { + const set = connListeners.get(name); + if (set) for (const cb of [...set]) cb(info); + } + function rejectAllPending(err: Error): void { + for (const [id, p] of pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(err); + pending.delete(id); + } + } + + function send(frame: OutboundFrame): void { + if (status === 'closed') throw new Error('Cannot send on a closed socket.'); + if (!currentWs) throw new Error('Socket is not connected.'); + currentWs.send(JSON.stringify(frame)); + } + + function makeIterator(): AsyncIterator & AsyncIterable { + return { + next: () => queue.next(), + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + function resolvePending(rid: string, ev: ServerEvent): void { + const t = ev.type; + const payload = (ev as { payload?: { pending?: unknown } }).payload; + const isPendingAck = t === 'ok' && !!payload && (payload as { pending?: unknown }).pending === true; + const terminal = + t === 'action_result' || t === 'action_error' || t === 'error' || (t === 'ok' && !isPendingAck); + if (!terminal) return; // pending ack: keep waiting for the post-tick result + const entry = pending.get(rid); + if (!entry) return; + pending.delete(rid); + if (entry.timer) clearTimeout(entry.timer); + entry.resolve(ev); + } + + const socket: SpacemoltSocket = { + [Symbol.asyncIterator]: () => makeIterator(), + events: () => ({ [Symbol.asyncIterator]: () => makeIterator() }), + on(type: string, cb: (arg: any) => void): () => void { + const map = CONNECTION_EVENT_NAMES.has(type) ? connListeners : frameListeners; + let set = map.get(type); + if (!set) { + set = new Set(); + map.set(type, set); + } + set.add(cb); + return () => { + set!.delete(cb); + }; + }, + send, + request(frame: OutboundFrame, reqOpts?: { timeoutMs?: number }): Promise { + const existing = typeof (frame as { request_id?: unknown }).request_id === 'string' + ? ((frame as { request_id?: string }).request_id as string) + : undefined; + const rid = existing ?? `req-${(reqCounter += 1)}`; + const outbound = { ...frame, request_id: rid } as OutboundFrame; + return new Promise((resolve, reject) => { + const timeoutMs = reqOpts?.timeoutMs ?? 600_000; + let timer: ReturnType | undefined; + if (timeoutMs && timeoutMs !== Infinity) { + timer = setTimeout(() => { + pending.delete(rid); + reject(new Error(`request ${rid} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + } + pending.set(rid, { resolve, reject, timer }); + try { + send(outbound); + } catch (e) { + pending.delete(rid); + if (timer) clearTimeout(timer); + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + }, + subscribeMarket(): void { + send( + endpoint === 'v1' + ? { type: 'subscribe_market' } + : { tool: 'spacemolt_market', action: 'subscribe_market' }, + ); + }, + unsubscribeMarket(): void { + send( + endpoint === 'v1' + ? { type: 'unsubscribe_market' } + : { tool: 'spacemolt_market', action: 'unsubscribe_market' }, + ); + }, + subscribeObservation(o?: { activeScan?: boolean }): void { + const active = o?.activeScan === true; + if (endpoint === 'v1') { + send(active ? { type: 'subscribe_observation', payload: { active_scan: true } } : { type: 'subscribe_observation' }); + } else { + send( + active + ? { tool: 'spacemolt', action: 'subscribe_observation', payload: { active_scan: true } } + : { tool: 'spacemolt', action: 'subscribe_observation' }, + ); + } + }, + unsubscribeObservation(): void { + send( + endpoint === 'v1' + ? { type: 'unsubscribe_observation' } + : { tool: 'spacemolt', action: 'unsubscribe_observation' }, + ); + }, + get status(): SocketStatus { + return status; + }, + close(code = 1000, reason?: string): Promise { + return new Promise((resolve) => { + userClosed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + if (status === 'closed') { + resolve(); + return; + } + // Between connections (reconnecting) there is no live socket whose close + // event will fire — settle synchronously. + if (status === 'reconnecting' || !currentWs) { + status = 'closed'; + queue.end(); + rejectAllPending(new Error('socket closed')); + emitConn('close', { type: 'close', code, reason }); + resolve(); + return; + } + closeResolvers.push(resolve); + try { + currentWs.close(code, reason); + } catch { + status = 'closed'; + queue.end(); + resolve(); + } + }); + }, + }; + + function markReady(isReconnect: boolean): void { + status = authMode === 'anonymous' ? 'open' : 'authenticated'; + const tookAttempts = attempt; + attempt = 0; + if (!everReady) { + everReady = true; + resolveFirst(socket); + } else if (isReconnect) { + emitConn('reconnect', { type: 'reconnect', attempt: tookAttempts }); + } + } + + function handleAuthFailure(ws: WebSocketLike, err: Error): void { + // Server rejected the credential. Terminal — do not reconnect. + if (!everReady) rejectFirst(err); + userClosed = true; // suppress reconnect on the close that follows + try { + ws.close(1000, 'auth failed'); + } catch { + /* already closing */ + } + } + + function scheduleReconnect(): void { + if (!reconnectPolicy || attempt >= reconnectPolicy.maxAttempts) { + status = 'closed'; + queue.end(); + rejectAllPending(new Error('socket closed')); + return; + } + status = 'reconnecting'; + attempt += 1; + const delay = backoffDelay(reconnectPolicy, attempt); + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; + if (!userClosed) connectOnce(true); + }, delay); + } + + function connectOnce(isReconnect: boolean): void { + let welcomed = false; + let authed = false; + let ws: WebSocketLike; + try { + ws = new Ctor(wsUrl); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (!everReady) rejectFirst(err); + else scheduleReconnect(); + return; + } + currentWs = ws; + + ws.addEventListener('open', () => { + if (status === 'connecting' || status === 'reconnecting') status = 'open'; + emitConn('open', { type: 'open' }); + }); + + ws.addEventListener('message', (event: any) => { + let value: unknown; + try { + value = JSON.parse(String(event?.data)); + } catch { + return; // non-JSON frame: ignore (one complete JSON object per message) + } + if (typeof value !== 'object' || value === null || typeof (value as { type?: unknown }).type !== 'string') { + return; + } + const ev = value as ServerEvent; + const t = ev.type; + const rid = (value as { request_id?: unknown }).request_id; + + if (!welcomed && t === 'welcome') { + welcomed = true; + if (authMode === 'anonymous') { + markReady(isReconnect); + } else { + try { + ws.send(buildLoginFrame(endpoint, opts.auth)); + } catch (e) { + handleAuthFailure(ws, e instanceof Error ? e : new Error(String(e))); + } + } + } else if (!authed && t === 'logged_in') { + authed = true; + markReady(isReconnect); + } else if (!authed && authMode !== 'anonymous' && t === 'error') { + const p = (ev as { payload?: { code?: unknown; message?: unknown } }).payload ?? {}; + handleAuthFailure( + ws, + new Error(`socket authentication failed (${String(p.code ?? 'unknown')}): ${String(p.message ?? '')}`.trim()), + ); + } + + if (typeof rid === 'string' && pending.has(rid)) resolvePending(rid, ev); + + queue.push(ev); + emitFrame(t, ev); + }); + + ws.addEventListener('close', (event: any) => { + const code = typeof event?.code === 'number' ? (event.code as number) : undefined; + const reason = typeof event?.reason === 'string' ? (event.reason as string) : undefined; + + if (userClosed) { + status = 'closed'; + queue.end(); + emitConn('close', { type: 'close', code, reason }); + for (const resolve of closeResolvers.splice(0)) resolve(); + rejectAllPending(new Error('socket closed')); + return; + } + emitConn('close', { type: 'close', code, reason }); + if (!everReady) { + status = 'closed'; + queue.end(); + rejectAllPending(new Error('socket closed before authentication')); + rejectFirst( + new Error(`socket closed before authentication (code=${code ?? '?'}${reason ? `, reason=${reason}` : ''})`), + ); + return; + } + scheduleReconnect(); + }); + + ws.addEventListener('error', (event: any) => { + emitConn('error', { type: 'error', error: event }); + // The transport emits 'close' after 'error'; reconnect/reject is driven there. + }); + } + + connectOnce(false); + return firstHandshake; +} diff --git a/src/socket-types.ts b/src/socket-types.ts new file mode 100644 index 0000000..202ad03 --- /dev/null +++ b/src/socket-types.ts @@ -0,0 +1,227 @@ +// Hand-written WebSocket type surface for @spacemolt/client-v2, mirroring the +// hand-written REST `sdk-session.ts`. Lives OUTSIDE src/generated/ (which is +// clobbered by `bun run generate`). Game-event payloads reuse the generated +// Notification* types; connection/auth control frames are defined by hand. +import type { + NotificationChatMessage, + NotificationCombatUpdate, + NotificationCraftingUpdate, + NotificationMarketUpdate, + NotificationMiningYield, + NotificationObservationUpdate, + NotificationPilotlessShip, + NotificationPlayerDied, + NotificationReconnected, + NotificationScanDetected, + NotificationScanResult, + NotificationSkillLevelUp, + NotificationTradeOfferReceived, +} from './generated'; + +/** Which outbound framing to send. Server->client frames are identical on both. */ +export type SocketEndpoint = 'v1' | 'v2'; + +/** Authenticate the socket with an account username + password. */ +export interface SocketCredentials { + username: string; + /** Held only for the single login frame; never persisted or logged by the package. */ + password: string; +} +/** Authenticate the socket with a single-use, ~5-min login token. */ +export interface SocketLoginToken { + loginToken: string; + username?: string; +} +/** Connect passively: welcome only, send no credential. */ +export interface SocketAnonymous { + anonymous: true; +} +/** Exactly one of: credentials | loginToken | anonymous. */ +export type SocketAuth = SocketCredentials | SocketLoginToken | SocketAnonymous; + +/** Exponential-backoff reconnect policy (defaults applied by the factory). */ +export interface ReconnectOptions { + initialDelayMs?: number; + maxDelayMs?: number; + factor?: number; + jitter?: boolean; + maxAttempts?: number; +} + +/** + * Minimal structural WebSocket instance the factory drives. Satisfiable by both + * the global DOM `WebSocket` and the `ws` package (both expose addEventListener). + */ +export interface WebSocketLike { + send(data: string): void; + close(code?: number, reason?: string): void; + readonly readyState: number; + addEventListener(type: string, listener: (event: any) => void): void; + removeEventListener(type: string, listener: (event: any) => void): void; +} +/** Injected WebSocket constructor (defaults to global WebSocket, then lazy `import("ws")`). */ +export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike; + +export interface SocketOptions { + /** How to authenticate the socket. */ + auth: SocketAuth; + /** Outbound framing. Default: 'v1'. */ + endpoint?: SocketEndpoint; + /** API origin. Default https://game.spacemolt.com. */ + baseUrl?: string; + /** Full ws url override (else derived: baseUrl http->ws + /ws or /ws/v2). */ + wsUrl?: string; + /** Reconnect policy. Default: enabled with exponential backoff. */ + reconnect?: ReconnectOptions | false; + /** Injected WebSocket constructor (testing / runtime choice). */ + WebSocketImpl?: WebSocketCtor; +} + +// --- Hand-written control-frame payloads (no generated counterpart) --- + +/** First frame after connect; the client logs in after receiving it. */ +export interface WelcomePayload { + version: string; + release_date?: string; + release_notes?: string[]; + tick_rate: number; + current_tick: number; + server_time: number; + motd?: string; + game_info?: string; + website?: string; + help_text?: string; + terms?: string; +} + +/** Auth success; createSocket resolves here. Game-state blobs kept loose by design. */ +export interface LoggedInPayload { + player: Record; + ship: Record; + modules?: unknown; + system: Record; + poi: Record; + pending_trades?: unknown[]; + recent_chat?: unknown[]; + unread_chat?: number; +} + +/** Registration only (out of normal package scope; register stays CLI-only). */ +export interface RegisteredPayload { + /** 256-bit hex account password — the caller must persist it. */ + password: string; + player_id: string; +} + +/** Synchronous failure / rejected frame, or post-tick action_error. */ +export interface ErrorPayload { + code: string; + message: string; + wait_seconds?: number; +} + +/** Connection-lifecycle event surfaced via `.on('open'|'close'|'reconnect'|'error')`. */ +export interface ConnectionEvent { + type: 'open' | 'close' | 'reconnect' | 'error'; + /** Close code (on 'close'). */ + code?: number; + reason?: string; + /** Reconnect attempt count (on 'reconnect'). */ + attempt?: number; + error?: unknown; +} + +export type SocketStatus = + | 'connecting' + | 'open' + | 'authenticated' + | 'reconnecting' + | 'closed'; + +/** + * The discriminated union the caller consumes. Notification payloads reuse the + * generated Notification* types; control frames are hand-written above. The + * trailing open member keeps unknown/future frames flowing (never dropped). + */ +export type ServerEvent = + // connection / auth control + | { type: 'welcome'; payload: WelcomePayload } + | { type: 'logged_in'; payload: LoggedInPayload } + | { type: 'registered'; payload: RegisteredPayload } + | { type: 'ok'; payload: Record; request_id?: string } + | { type: 'error'; payload: ErrorPayload; request_id?: string } + | { type: 'action_result'; payload: Record; request_id?: string } + | { type: 'action_error'; payload: ErrorPayload; request_id?: string } + // game-event pushes (payloads = generated Notification* types) + | { type: 'combat_update'; payload: NotificationCombatUpdate } + | { type: 'player_died'; payload: NotificationPlayerDied } + | { type: 'scan_result'; payload: NotificationScanResult } + | { type: 'scan_detected'; payload: NotificationScanDetected } + | { type: 'pilotless_ship'; payload: NotificationPilotlessShip } + | { type: 'reconnected'; payload: NotificationReconnected } + | { type: 'mining_yield'; payload: NotificationMiningYield } + | { type: 'chat_message'; payload: NotificationChatMessage } + | { type: 'trade_offer_received'; payload: NotificationTradeOfferReceived } + | { type: 'skill_level_up'; payload: NotificationSkillLevelUp } + | { type: 'market_update'; payload: NotificationMarketUpdate } + | { type: 'observation_update'; payload: NotificationObservationUpdate } + | { type: 'crafting_update'; payload: NotificationCraftingUpdate } + // forward-compat: unknown/future types pass through untyped, never dropped + | { type: string; payload?: unknown; request_id?: string }; + +/** v1 flat outbound frame: {type, payload?, request_id?}. */ +export interface OutboundFrameV1 { + type: string; + payload?: Record; + request_id?: string; +} +/** v2 tool/action outbound frame: {tool, action, payload?, request_id?}. */ +export interface OutboundFrameV2 { + tool: string; + action: string; + payload?: Record; + request_id?: string; +} +/** A raw outbound frame; the factory sends it as-is (framing per configured endpoint). */ +export type OutboundFrame = OutboundFrameV1 | OutboundFrameV2; + +/** The socket handle returned by `createSocket` (runtime impl lands in step 3). */ +export interface SpacemoltSocket { + /** Primary consumption surface: a typed async stream of server events. */ + [Symbol.asyncIterator](): AsyncIterator; + events(): AsyncIterable; + + /** Emitter surface (additive to the iterator). Connection events first, then frame types. */ + on( + type: 'open' | 'close' | 'reconnect' | 'error', + cb: (info: ConnectionEvent) => void, + ): () => void; + on( + type: T, + cb: (e: Extract) => void, + ): () => void; + + /** Send a raw outbound frame (framing chosen by the configured endpoint). */ + send(frame: OutboundFrame): void; + + /** + * Send a frame with request_id correlation and resolve on the matching terminal + * response. A `request_id` is generated if the frame lacks one. The `ok {pending:true}` + * ack for a queued mutation is skipped; the promise resolves on the post-tick + * action_result/action_error (or, for a query, the synchronous ok/error). + * Default timeout 600_000ms; pass `timeoutMs: 0` or `Infinity` to disable. + */ + request(frame: OutboundFrame, opts?: { timeoutMs?: number }): Promise; + + /** Convenience wrappers over send() for the documented subscriptions. */ + subscribeMarket(): void; + unsubscribeMarket(): void; + subscribeObservation(opts?: { activeScan?: boolean }): void; + unsubscribeObservation(): void; + + /** Current connection + auth status, for diagnostics. */ + readonly status: SocketStatus; + + /** Close cleanly (default code 1000). Resolves once the underlying socket is closed. */ + close(code?: number, reason?: string): Promise; +} diff --git a/tests/library-exports.test.ts b/tests/library-exports.test.ts index de7e66d..e44e024 100644 --- a/tests/library-exports.test.ts +++ b/tests/library-exports.test.ts @@ -13,4 +13,8 @@ describe('library entry exports', () => { test('exports the session helper', () => { expect(typeof lib.createSession).toBe('function'); }); + + test('exports the socket helper', () => { + expect(typeof lib.createSocket).toBe('function'); + }); }); diff --git a/tests/sdk-socket.reconnect.test.ts b/tests/sdk-socket.reconnect.test.ts new file mode 100644 index 0000000..d36d74a --- /dev/null +++ b/tests/sdk-socket.reconnect.test.ts @@ -0,0 +1,367 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import { WebSocketServer, WebSocket as WsClient } from 'ws'; +import { createSocket } from '../src/sdk-socket.ts'; +import type { SpacemoltSocket } from '../src/socket-types.ts'; + +type Json = Record; +type OnMessage = ( + frame: Json, + send: (obj: Json) => void, + received: Json[], + sock: WsClient, + connIndex: number, +) => void; +type OnConnection = ( + send: (obj: Json) => void, + sock: WsClient, + connIndex: number, +) => void; + +const WELCOME: Json = { + type: 'welcome', + payload: { version: '0.0.0-test', tick_rate: 10, current_tick: 1, server_time: 0 }, +}; +const LOGGED_IN: Json = { + type: 'logged_in', + payload: { player: {}, ship: {}, system: {}, poi: {} }, +}; + +interface Harness { + url: string; + received: Json[]; + connectionCount: () => number; + close(): Promise; +} + +interface ServerHooks { + onConnection?: OnConnection; + onMessage?: OnMessage; +} + +async function startServer(hooks: ServerHooks = {}): Promise { + const wss = new WebSocketServer({ port: 0 }); + await new Promise((resolve) => wss.once('listening', () => resolve())); + const addr = wss.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const received: Json[] = []; + let connCount = 0; + + wss.on('connection', (sock) => { + const connIndex = connCount; + connCount += 1; + const send = (obj: Json) => sock.send(JSON.stringify(obj)); + send(WELCOME); + if (hooks.onConnection) hooks.onConnection(send, sock as unknown as WsClient, connIndex); + sock.on('message', (data) => { + let frame: Json; + try { + frame = JSON.parse(String(data)); + } catch { + return; + } + received.push(frame); + if (hooks.onMessage) { + hooks.onMessage(frame, send, received, sock as unknown as WsClient, connIndex); + } + }); + }); + + return { + url: `ws://127.0.0.1:${port}`, + received, + connectionCount: () => connCount, + close: () => + new Promise((resolve) => { + for (const c of wss.clients) { + try { + c.terminate(); + } catch { + /* ignore */ + } + } + // Under Bun, wss.close()'s callback can hang while sockets tear down; + // fire close but never block the suite on its callback. + let done = false; + const finish = () => { + if (!done) { + done = true; + resolve(); + } + }; + wss.close(finish); + setTimeout(finish, 100); + }), + }; +} + +function isLogin(frame: Json): boolean { + return frame.type === 'login' || (frame.tool === 'spacemolt_auth' && frame.action === 'login'); +} + +function withTimeout(p: Promise, ms = 2000, label = 'timeout'): Promise { + return Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(label)), ms)), + ]); +} + +async function waitFor(cond: () => boolean, ms = 2000): Promise { + await withTimeout( + (async () => { + while (!cond()) await new Promise((r) => setTimeout(r, 5)); + })(), + ms, + ); +} + +const SMALL_RECONNECT = { initialDelayMs: 10, maxDelayMs: 20, jitter: false }; + +let activeSocket: SpacemoltSocket | undefined; +let activeHarness: Harness | undefined; + +afterEach(async () => { + if (activeSocket) { + try { + await activeSocket.close(1000); + } catch { + /* ignore */ + } + activeSocket = undefined; + } + if (activeHarness) { + await activeHarness.close(); + activeHarness = undefined; + } +}); + +describe('createSocket reconnect + request correlation', () => { + test('reconnect after server close=1000 keeps the stream live', async () => { + const harness = await startServer({ + onMessage: (frame, send, _received, sock, connIndex) => { + if (isLogin(frame)) { + send(LOGGED_IN); + if (connIndex === 0) { + // Server-initiated normal close after auth. + setTimeout(() => { + try { + sock.close(1000); + } catch { + /* ignore */ + } + }, 10); + } else { + send({ type: 'combat_update', payload: { seq: 99 } }); + } + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + let reconnectAttempt = -1; + socket.on('reconnect', (info) => { + reconnectAttempt = info.attempt ?? 0; + }); + + // The post-reconnect combat_update must arrive through the SAME live iterator, + // proving the stream did not end at the close=1000. + const got = await withTimeout( + (async () => { + for await (const ev of socket) { + if (ev.type === 'combat_update') return ev; + } + return undefined; + })(), + 3000, + ); + + expect(got).toBeDefined(); + expect((got as any).payload).toEqual({ seq: 99 }); + expect(reconnectAttempt).toBeGreaterThanOrEqual(1); + expect(socket.status).toBe('authenticated'); + // Server saw a login on BOTH connections (transparent re-login). + expect(harness.connectionCount()).toBeGreaterThanOrEqual(2); + expect(harness.received.filter(isLogin).length).toBeGreaterThanOrEqual(2); + }); + + test('reconnect disabled: stream ends and no retry', async () => { + const harness = await startServer({ + onMessage: (frame, send, _received, sock, connIndex) => { + if (isLogin(frame) && connIndex === 0) { + send(LOGGED_IN); + setTimeout(() => { + try { + sock.close(1000); + } catch { + /* ignore */ + } + }, 10); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: false, + }), + ); + activeSocket = socket; + + // Drain the iterator: with reconnect disabled, close=1000 ends the stream. + const result = await withTimeout( + (async () => { + const iter = socket[Symbol.asyncIterator](); + let r = await iter.next(); + while (!r.done) r = await iter.next(); + return r; + })(), + 3000, + ); + expect(result.done).toBe(true); + expect(socket.status).toBe('closed'); + + // Give any (erroneous) retry a window to land, then assert exactly one connection. + await new Promise((r) => setTimeout(r, 60)); + expect(harness.connectionCount()).toBe(1); + activeSocket = undefined; + }); + + test('request(): query resolves on synchronous ok', async () => { + let echoedRid: string | undefined; + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + } else if (typeof frame.request_id === 'string') { + echoedRid = frame.request_id; + send({ type: 'ok', payload: { ack: true }, request_id: frame.request_id }); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + const res = await withTimeout(socket.request({ type: 'get_status' })); + expect(res.type).toBe('ok'); + expect((res as any).request_id).toBe(echoedRid); + expect(typeof echoedRid).toBe('string'); + }); + + test('request(): mutation skips pending ack, resolves on action_result', async () => { + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + } else if (typeof frame.request_id === 'string') { + const rid = frame.request_id; + send({ type: 'ok', payload: { pending: true, command: 'travel' }, request_id: rid }); + setTimeout(() => { + send({ type: 'action_result', payload: { command: 'travel', tick: 5 }, request_id: rid }); + }, 20); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + const res = await withTimeout( + socket.request({ type: 'travel', payload: { target_poi: 'x' } }), + ); + expect(res.type).toBe('action_result'); + expect((res as any).payload).toEqual({ command: 'travel', tick: 5 }); + }); + + test('request(): times out when server stays silent', async () => { + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + // ignore everything else + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + await expect(socket.request({ type: 'get_status' }, { timeoutMs: 50 })).rejects.toThrow(); + }); + + test('request(): generates request_id when absent, reuses when provided', async () => { + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + } else if (typeof frame.request_id === 'string') { + send({ type: 'ok', payload: { ack: true }, request_id: frame.request_id }); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + // Explicit id is reused verbatim. + await withTimeout(socket.request({ type: 'get_status', request_id: 'my-id' })); + await waitFor(() => harness.received.some((f) => f.request_id === 'my-id')); + const explicit = harness.received.find((f) => f.type === 'get_status' && f.request_id === 'my-id'); + expect(explicit).toBeDefined(); + + // Absent id -> some non-empty string is attached. + await withTimeout(socket.request({ type: 'get_status', payload: { n: 2 } })); + await waitFor(() => + harness.received.some( + (f) => f.type === 'get_status' && f.payload?.n === 2 && typeof f.request_id === 'string', + ), + ); + const generated = harness.received.find((f) => f.type === 'get_status' && f.payload?.n === 2); + expect(generated).toBeDefined(); + expect(typeof generated!.request_id).toBe('string'); + expect((generated!.request_id as string).length).toBeGreaterThan(0); + }); +}); diff --git a/tests/sdk-socket.runtime.test.ts b/tests/sdk-socket.runtime.test.ts new file mode 100644 index 0000000..1ea168d --- /dev/null +++ b/tests/sdk-socket.runtime.test.ts @@ -0,0 +1,454 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import { WebSocketServer, WebSocket as WsClient } from 'ws'; +import { createSocket } from '../src/sdk-socket.ts'; +import type { SpacemoltSocket } from '../src/socket-types.ts'; + +type Json = Record; +type OnMessage = ( + frame: Json, + send: (obj: Json) => void, + received: Json[], + sock: WsClient, +) => void; + +const WELCOME: Json = { + type: 'welcome', + payload: { version: '0.0.0-test', tick_rate: 10, current_tick: 1, server_time: 0 }, +}; + +interface Harness { + url: string; + received: Json[]; + close(): Promise; +} + +async function startServer(onMessage?: OnMessage): Promise { + const wss = new WebSocketServer({ port: 0 }); + await new Promise((resolve) => wss.once('listening', () => resolve())); + const addr = wss.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const received: Json[] = []; + + wss.on('connection', (sock) => { + const send = (obj: Json) => sock.send(JSON.stringify(obj)); + send(WELCOME); + sock.on('message', (data) => { + let frame: Json; + try { + frame = JSON.parse(String(data)); + } catch { + return; + } + received.push(frame); + if (onMessage) onMessage(frame, send, received, sock as unknown as WsClient); + }); + }); + + return { + url: `ws://127.0.0.1:${port}`, + received, + close: () => + new Promise((resolve) => { + for (const c of wss.clients) { + try { + c.terminate(); + } catch { + /* ignore */ + } + } + // Under Bun, wss.close()'s callback can hang while sockets tear down; + // fire close but never block the suite on its callback. + let done = false; + const finish = () => { + if (!done) { + done = true; + resolve(); + } + }; + wss.close(finish); + setTimeout(finish, 100); + }), + }; +} + +function isLogin(frame: Json): boolean { + return frame.type === 'login' || (frame.tool === 'spacemolt_auth' && frame.action === 'login'); +} + +function withTimeout(p: Promise, ms = 2000, label = 'timeout'): Promise { + return Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(label)), ms)), + ]); +} + +const LOGGED_IN: Json = { + type: 'logged_in', + payload: { player: {}, ship: {}, system: {}, poi: {} }, +}; + +let activeSocket: SpacemoltSocket | undefined; +let activeHarness: Harness | undefined; + +afterEach(async () => { + if (activeSocket) { + try { + await activeSocket.close(1000); + } catch { + /* ignore */ + } + activeSocket = undefined; + } + if (activeHarness) { + await activeHarness.close(); + activeHarness = undefined; + } +}); + +describe('createSocket runtime', () => { + test('v1 credentials handshake + outbound framing', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v1', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + expect(socket.status).toBe('authenticated'); + expect(harness.received[0]).toEqual({ + type: 'login', + payload: { username: 'alice', password: 'pw' }, + }); + }); + + test('v2 credentials framing', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v2', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + expect(socket.status).toBe('authenticated'); + expect(harness.received[0]).toEqual({ + tool: 'spacemolt_auth', + action: 'login', + payload: { username: 'alice', password: 'pw' }, + }); + }); + + test('login_token (v1)', async () => { + const harness = await startServer((frame, send) => { + if (frame.type === 'login_token') send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { loginToken: 'tok-123' }, + endpoint: 'v1', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + expect(socket.status).toBe('authenticated'); + expect(harness.received[0]).toEqual({ + type: 'login_token', + payload: { token: 'tok-123' }, + }); + }); + + test('anonymous resolves on welcome alone', async () => { + const harness = await startServer(); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { anonymous: true }, + endpoint: 'v1', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + expect(socket.status).toBe('open'); + // give any erroneous outbound frame a tick to arrive + await new Promise((r) => setTimeout(r, 50)); + expect(harness.received).toEqual([]); + }); + + test('event stream via async iterator', async () => { + const combat = { type: 'combat_update', payload: { attacker: 'x', damage: 42 } }; + const harness = await startServer((frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + send(combat); + } + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + const got = await withTimeout( + (async () => { + for await (const ev of socket) { + if (ev.type === 'welcome' || ev.type === 'logged_in') continue; + if (ev.type === 'combat_update') return ev; + } + return undefined; + })(), + ); + + expect(got).toBeDefined(); + expect(got!.payload).toEqual(combat.payload); + }); + + test("emitter on('combat_update')", async () => { + const combat = { type: 'combat_update', payload: { attacker: 'y', damage: 7 } }; + const harness = await startServer((frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + send(combat); + } + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + const fired = new Promise((resolve) => { + socket.on('combat_update', (e) => resolve(e)); + }); + const e = await withTimeout(fired); + expect((e as any).payload).toEqual(combat.payload); + }); + + test('one JSON object per message (no concatenation)', async () => { + const first = { type: 'combat_update', payload: { seq: 1 } }; + const second = { type: 'combat_update', payload: { seq: 2 } }; + const harness = await startServer((frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + send(first); + send(second); + } + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + const seen = await withTimeout( + (async () => { + const out: any[] = []; + for await (const ev of socket) { + if (ev.type !== 'combat_update') continue; + out.push(ev.payload); + if (out.length === 2) return out; + } + return out; + })(), + ); + + expect(seen).toEqual([{ seq: 1 }, { seq: 2 }]); + }); + + test('subscribe framing (v1)', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v1', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + socket.subscribeMarket(); + socket.subscribeObservation({ activeScan: true }); + + await withTimeout( + (async () => { + while (harness.received.length < 3) await new Promise((r) => setTimeout(r, 10)); + })(), + ); + + expect(harness.received).toContainEqual({ type: 'subscribe_market' }); + expect(harness.received).toContainEqual({ + type: 'subscribe_observation', + payload: { active_scan: true }, + }); + }); + + test('subscribe framing (v2)', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v2', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + socket.subscribeMarket(); + socket.subscribeObservation({ activeScan: true }); + + await withTimeout( + (async () => { + while (harness.received.length < 3) await new Promise((r) => setTimeout(r, 10)); + })(), + ); + + expect(harness.received).toContainEqual({ + tool: 'spacemolt_market', + action: 'subscribe_market', + }); + expect(harness.received).toContainEqual({ + tool: 'spacemolt', + action: 'subscribe_observation', + payload: { active_scan: true }, + }); + }); + + test('clean close ends the stream', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + + await withTimeout(socket.close(1000)); + expect(socket.status).toBe('closed'); + + // The stream drains any buffered frames (welcome/logged_in) and then ends: + // a subsequent next() must report done:true rather than hanging. + const iter = socket[Symbol.asyncIterator](); + const result = await withTimeout( + (async () => { + let r = await iter.next(); + while (!r.done) r = await iter.next(); + return r; + })(), + ); + expect(result.done).toBe(true); + // already closed/cleaned by the test body + activeSocket = undefined; + }); + + test('handshake rejects on pre-auth close', async () => { + const harness = await startServer((frame, _send, _received, sock) => { + if (isLogin(frame)) { + // close the connection instead of replying + try { + sock.close(4002); + } catch { + /* ignore */ + } + } + }); + activeHarness = harness; + + await expect( + withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ), + ).rejects.toThrow(); + activeSocket = undefined; + }); + + test('password is never logged', async () => { + const secret = 'super-secret-pw'; + const captured: string[] = []; + const methods = ['log', 'info', 'warn', 'error', 'debug'] as const; + const originals = methods.map((m) => console[m]); + for (const m of methods) { + (console as any)[m] = (...args: any[]) => { + captured.push(args.map((a) => String(a)).join(' ')); + }; + } + + try { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: secret }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + } finally { + methods.forEach((m, i) => { + (console as any)[m] = originals[i]; + }); + } + + for (const line of captured) { + expect(line.includes(secret)).toBe(false); + } + }); +}); diff --git a/tests/sdk-socket.test.ts b/tests/sdk-socket.test.ts new file mode 100644 index 0000000..b0592b3 --- /dev/null +++ b/tests/sdk-socket.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from 'bun:test'; +import type { + ServerEvent, + WelcomePayload, + LoggedInPayload, + RegisteredPayload, + ErrorPayload, +} from '../src/socket-types.ts'; +import type { + NotificationChatMessage, + NotificationCombatUpdate, + NotificationCraftingUpdate, + NotificationMarketUpdate, + NotificationMiningYield, + NotificationObservationUpdate, + NotificationPilotlessShip, + NotificationPlayerDied, + NotificationReconnected, + NotificationScanDetected, + NotificationScanResult, + NotificationSkillLevelUp, + NotificationTradeOfferReceived, +} from '../src/generated'; + +// --- compile-time exact-equality helpers (no deps; mirrors tsd's IsExact) --- +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +type PayloadOf = Extract['payload']; + +// §7 game-event mapping: each payload is EXACTLY the generated Notification* type. +// A drift in the generated types turns these into `bun run typecheck:socket` errors. +type _combat = Expect, NotificationCombatUpdate>>; +type _died = Expect, NotificationPlayerDied>>; +type _scanResult = Expect, NotificationScanResult>>; +type _scanDetected = Expect, NotificationScanDetected>>; +type _pilotless = Expect, NotificationPilotlessShip>>; +type _reconnected = Expect, NotificationReconnected>>; +type _mining = Expect, NotificationMiningYield>>; +type _chat = Expect, NotificationChatMessage>>; +type _trade = Expect, NotificationTradeOfferReceived>>; +type _skill = Expect, NotificationSkillLevelUp>>; +type _market = Expect, NotificationMarketUpdate>>; +type _observation = Expect, NotificationObservationUpdate>>; +type _crafting = Expect, NotificationCraftingUpdate>>; + +// control frames resolve to the hand-written payload shapes. +type _welcome = Expect, WelcomePayload>>; +type _loggedIn = Expect, LoggedInPayload>>; +type _registered = Expect, RegisteredPayload>>; +type _error = Expect, ErrorPayload>>; +type _actionError = Expect, ErrorPayload>>; + +// forward-compat: an unknown frame type is still a valid ServerEvent (never dropped). +type _forwardCompat = Expect< + { type: 'totally_new_frame_type'; payload?: unknown } extends ServerEvent ? true : false +>; + +// Reference the alias types so tsc treats them as used (they are the real test). +export type _SocketTypeAssertions = [ + _combat, _died, _scanResult, _scanDetected, _pilotless, _reconnected, _mining, + _chat, _trade, _skill, _market, _observation, _crafting, + _welcome, _loggedIn, _registered, _error, _actionError, _forwardCompat, +]; + +describe('socket-types', () => { + test('§7 payload mapping is enforced at compile time (run: bun run typecheck:socket)', () => { + // The real assertions are the Expect> aliases above. They fail + // `bun run typecheck:socket` if a generated Notification* type drifts from §7. + // This runtime case just keeps the file a valid `bun test` target. + expect(true).toBe(true); + }); +}); diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 2aa8c66..b9d0eb9 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -10,7 +10,7 @@ "types": [], "lib": ["ESNext", "DOM", "DOM.Iterable"] }, - "include": ["src/index.ts", "src/sdk-session.ts", "src/generated/**/*.ts"], + "include": ["src/index.ts", "src/sdk-session.ts", "src/sdk-socket.ts", "src/socket-types.ts", "src/generated/**/*.ts"], "exclude": [ "node_modules", "dist", diff --git a/tsconfig.socket-types.test.json b/tsconfig.socket-types.test.json new file mode 100644 index 0000000..e29aba6 --- /dev/null +++ b/tsconfig.socket-types.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/socket-types.ts", + "src/generated/**/*.ts", + "tests/sdk-socket.test.ts" + ] +} From ef0a5793936be537f6ccd4feea3d01fafcd73f5f Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 25 Jun 2026 14:41:26 -0400 Subject: [PATCH 02/10] fix(session): mint a session before login so X-Session-Id is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/sdk-session.ts | 52 ++++++++++++++++++++++++++++----------- tests/sdk-session.test.ts | 38 ++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/sdk-session.ts b/src/sdk-session.ts index 3da0177..d0b8ed9 100644 --- a/src/sdk-session.ts +++ b/src/sdk-session.ts @@ -1,4 +1,4 @@ -import { spacemoltAuthLogin } from './generated'; +import { spacemoltAuthLogin, createSession as createApiSession } from './generated'; import { createClient } from './generated/client'; /** Default API origin. Generated request URLs already include the /api/v2 prefix. */ @@ -38,28 +38,45 @@ export async function createSession(opts: SessionOptions): Promise { - const res = await spacemoltAuthLogin({ - client, - body: { username: opts.username, password: opts.password }, - }); - const id = extractSessionId(res.data); - if (!id) { + let authenticating = false; + + // Mint a fresh API session, then authenticate against it. The login endpoint + // requires the X-Session-Id header of a freshly-minted session + // (POST /api/v2/session); without it the server replies `session_required` and + // returns no session id. We publish the minted id before logging in so the + // request interceptor stamps it on the login call. + async function authenticate(): Promise { + authenticating = true; + try { + const created = await createApiSession({ client }); + const minted = extractSessionId(created.data); + if (!minted) { + const code = (created.data as { error?: { code?: string } } | undefined)?.error?.code; + throw new Error(`Session creation failed${code ? ` (${code})` : ''}: no session id returned`); + } + sessionId = minted; + + const res = await spacemoltAuthLogin({ + client, + body: { username: opts.username, password: opts.password }, + }); const code = (res.data as { error?: { code?: string } } | undefined)?.error?.code; - throw new Error(`Login failed${code ? ` (${code})` : ''}: no session id returned`); + if (code) { + throw new Error(`Login failed (${code}): no session id returned`); + } + // Login may rotate the session id; otherwise keep the freshly minted one. + sessionId = extractSessionId(res.data) ?? minted; + } finally { + authenticating = false; } - return id; } - sessionId = await login(); - // Pristine request clones, captured before the body is consumed, keyed by the live request. const pendingClones = new WeakMap(); client.interceptors.request.use((request: Request) => { pendingClones.set(request, request.clone()); - request.headers.set('X-Session-Id', sessionId); + if (sessionId) request.headers.set('X-Session-Id', sessionId); return request; }); @@ -67,6 +84,9 @@ export async function createSession(opts: SessionOptions): Promise { sessionHeader: req.headers.get('X-Session-Id'), body: await req.clone().text(), }); + if (req.url.includes('/api/v2/session') && !req.url.includes('login')) { + return jsonResponse({ session: { id: 'sess-123' } }); + } return jsonResponse({ session: { id: 'sess-123' }, structuredContent: {} }); }) as typeof fetch; const session = await createSession({ username: 'alice', password: 'pw' }); expect(session.sessionId).toBe('sess-123'); - expect(calls[0].url).toContain('/api/v2/spacemolt_auth/login'); - expect(JSON.parse(calls[0].body)).toEqual({ username: 'alice', password: 'pw' }); + const loginCall = calls.find((c) => c.url.includes('/login'))!; + expect(JSON.parse(loginCall.body)).toEqual({ username: 'alice', password: 'pw' }); + }); + + test('mints a session before login (login requires X-Session-Id header)', async () => { + const calls: Array<{ url: string; sessionHeader: string | null }> = []; + globalThis.fetch = mock(async (input: Request) => { + const req = input as Request; + const sessionHeader = req.headers.get('X-Session-Id'); + calls.push({ url: req.url, sessionHeader }); + if (req.url.includes('/api/v2/session') && !req.url.includes('login')) { + return jsonResponse({ session: { id: 'minted-1' } }); + } + if (req.url.includes('/login')) { + // Server rejects a login that arrives without a session header. + if (!sessionHeader) return jsonResponse({ error: { code: 'session_required' } }, 401); + return jsonResponse({ session: { id: 'minted-1' } }); + } + return jsonResponse({ result: 'ok' }); + }) as typeof fetch; + + const session = await createSession({ username: 'alice', password: 'pw' }); + + expect(session.sessionId).toBe('minted-1'); + expect(calls[0].url).toContain('/api/v2/session'); + const loginCall = calls.find((c) => c.url.includes('/login')); + expect(loginCall?.sessionHeader).toBe('minted-1'); }); test('injects X-Session-Id on subsequent requests', async () => { @@ -39,6 +67,9 @@ describe('createSession', () => { globalThis.fetch = mock(async (input: Request) => { const req = input as Request; lastSessionHeader = req.headers.get('X-Session-Id'); + if (req.url.includes('/api/v2/session') && !req.url.includes('login')) { + return jsonResponse({ session: { id: 'sess-abc' } }); + } if (req.url.includes('/login')) { return jsonResponse({ session: { id: 'sess-abc' } }); } @@ -58,6 +89,9 @@ describe('createSession', () => { let statusCalls = 0; globalThis.fetch = mock(async (input: Request) => { const req = input as Request; + if (req.url.includes('/api/v2/session') && !req.url.includes('login')) { + return jsonResponse({ session: { id: `minted-${loginCount + 1}` } }); + } if (req.url.includes('/login')) { loginCount += 1; return jsonResponse({ session: { id: `sess-${loginCount}` } }); From 3eab4171b8180383a704d2a2df31061640f63641 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 25 Jun 2026 16:02:29 -0400 Subject: [PATCH 03/10] fix(socket): correlate v2 `result` frames in request() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/sdk-socket.ts | 4 +- src/socket-types.ts | 1 + tests/sdk-socket.reconnect.test.ts | 69 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/sdk-socket.ts b/src/sdk-socket.ts index a2d8948..b628940 100644 --- a/src/sdk-socket.ts +++ b/src/sdk-socket.ts @@ -210,9 +210,9 @@ export async function createSocket(opts: SocketOptions): Promise; request_id?: string } + | { type: 'result'; payload: Record; request_id?: string } | { type: 'error'; payload: ErrorPayload; request_id?: string } | { type: 'action_result'; payload: Record; request_id?: string } | { type: 'action_error'; payload: ErrorPayload; request_id?: string } diff --git a/tests/sdk-socket.reconnect.test.ts b/tests/sdk-socket.reconnect.test.ts index d36d74a..a4308d2 100644 --- a/tests/sdk-socket.reconnect.test.ts +++ b/tests/sdk-socket.reconnect.test.ts @@ -302,6 +302,75 @@ describe('createSocket reconnect + request correlation', () => { expect((res as any).payload).toEqual({ command: 'travel', tick: 5 }); }); + test('request(): v2 query resolves on result frame', async () => { + let echoedRid: string | undefined; + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + } else if (typeof frame.request_id === 'string') { + echoedRid = frame.request_id; + send({ + type: 'result', + payload: { structuredContent: { ok: true } }, + request_id: frame.request_id, + }); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v2', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + const res = await withTimeout(socket.request({ tool: 'spacemolt', action: 'get_status' })); + expect(res.type).toBe('result'); + expect((res as any).request_id).toBe(echoedRid); + expect(typeof echoedRid).toBe('string'); + }); + + test('request(): v2 mutation skips pending result, resolves on action_result', async () => { + const harness = await startServer({ + onMessage: (frame, send) => { + if (isLogin(frame)) { + send(LOGGED_IN); + } else if (typeof frame.request_id === 'string') { + const rid = frame.request_id; + send({ type: 'result', payload: { pending: true, command: 'jump' }, request_id: rid }); + setTimeout(() => { + send({ type: 'action_result', payload: { command: 'jump', tick: 7 }, request_id: rid }); + }, 20); + } + }, + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + endpoint: 'v2', + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + reconnect: SMALL_RECONNECT, + }), + ); + activeSocket = socket; + + const res = await withTimeout( + socket.request({ tool: 'spacemolt', action: 'jump', payload: { target_system: 'sol' } }), + ); + expect(res.type).toBe('action_result'); + expect((res as any).payload).toEqual({ command: 'jump', tick: 7 }); + }); + test('request(): times out when server stays silent', async () => { const harness = await startServer({ onMessage: (frame, send) => { From 52d58cb6db372e0f041d85f1bb22acefc4fdcac3 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 27 Jun 2026 14:44:25 -0400 Subject: [PATCH 04/10] docs(socket): record observed close codes; add live smoke probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- scripts/ws-smoke.ts | 191 ++++++++++++++++++++++++++++++++++++++++++++ src/sdk-socket.ts | 6 ++ 2 files changed, 197 insertions(+) create mode 100644 scripts/ws-smoke.ts diff --git a/scripts/ws-smoke.ts b/scripts/ws-smoke.ts new file mode 100644 index 0000000..3ddf4e8 --- /dev/null +++ b/scripts/ws-smoke.ts @@ -0,0 +1,191 @@ +/** + * ws-smoke.ts — manual live smoke probe for createSocket against the real SpaceMolt + * server. NOT a unit test (those are tests/sdk-socket.*.test.ts) and NOT run in CI — + * it opens a real connection and needs real credentials. The masked password prompt + * lives HERE in the harness, never in the package (which never touches TTY/env/disk). + * + * Usage (run from a real terminal so prompts/secrets stay local): + * bun run scripts/ws-smoke.ts --auth anonymous [--endpoint v1|v2] [--duration 8] + * bun run scripts/ws-smoke.ts --auth login --user [--endpoint v2] [--subscribe] [--request] [--kick-after 8] [--duration 30] + * bun run scripts/ws-smoke.ts --auth login --user --password-file # non-TTY fallback + * bun run scripts/ws-smoke.ts --auth token --token [--duration 20] + * + * The password (for --auth login) is read via a masked TTY prompt (or --password-file + * in non-TTY contexts), held in memory for the single login frame, never logged. + * + * Reusable-probe potential (intentionally NOT built out yet — see design doc §9.4/§9.5): + * this could become the canonical "is the live WS contract still true?" probe by + * gating it behind an explicit flag/env so it can't run in CI, and by folding in a + * frame tally + exit code. The throwaway scripts/monitor-ws.ts in the sm-cli repo + * should eventually be refactored to drive createSocket too, so probe and library + * can't drift. Keep it small until there's a reason to grow it. + */ +import { createInterface } from 'node:readline'; +import { Writable } from 'node:stream'; +import { createSocket } from '../src/sdk-socket.ts'; +import type { SocketAuth, ServerEvent } from '../src/socket-types.ts'; + +function arg(name: string, fallback?: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback; +} +function flag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +const promptMasked = (label: string): Promise => + new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + reject(new Error('stdin is not a TTY — run this directly in your terminal (no piping).')); + return; + } + let muted = false; + const out = new Writable({ + write(chunk, _enc, cb) { + if (!muted) process.stdout.write(chunk as Buffer); + cb(); + }, + }); + const rl = createInterface({ input: process.stdin, output: out, terminal: true }); + process.stdout.write(label); + muted = true; + rl.question('', (answer) => { + muted = false; + process.stdout.write('\n'); + rl.close(); + resolve(answer); + }); + }); + +async function main(): Promise { + const endpoint = (arg('endpoint', 'v1') as 'v1' | 'v2'); + const mode = arg('auth', 'anonymous'); + const durationMs = Number(arg('duration', '8')) * 1000; + const baseUrl = arg('base-url', 'https://game.spacemolt.com')!; + + let auth: SocketAuth; + let who = ''; + if (mode === 'anonymous') { + auth = { anonymous: true }; + who = 'anonymous (no credential)'; + } else if (mode === 'token') { + const token = arg('token'); + if (!token) throw new Error('--auth token requires --token '); + auth = { loginToken: token }; + who = 'login_token (redacted)'; + } else if (mode === 'login') { + const username = arg('user'); + if (!username) throw new Error('--auth login requires --user '); + console.log( + `\n⚠️ Connecting as "${username}" will DISCONNECT any other live session for this account.\n`, + ); + // Prefer a masked TTY prompt; fall back to a file (for non-TTY contexts) so the + // secret stays off the command line. The file is read here, in the throwaway + // harness — the package itself never touches disk. + const pwFile = arg('password-file'); + let password: string; + if (pwFile) { + const { readFileSync } = await import('node:fs'); + password = readFileSync(pwFile, 'utf8').replace(/\r?\n$/, ''); + console.log(`(read password from ${pwFile} — delete it when done)`); + } else { + password = await promptMasked(`Password for "${username}" (hidden): `); + } + if (!password) throw new Error('empty password — aborting'); + auth = { username, password }; + who = `login as "${username}" (password redacted)`; + } else { + throw new Error(`--auth must be anonymous | login | token (got ${mode})`); + } + + console.log(`endpoint: ${endpoint}`); + console.log(`baseUrl: ${baseUrl}`); + console.log(`auth: ${who}`); + console.log(`duration: ${durationMs / 1000}s`); + console.log(`connecting…\n`); + + const t0 = Date.now(); + const socket = await createSocket({ auth, endpoint, baseUrl }); + console.log(`✓ resolved in ${Date.now() - t0}ms — status=${socket.status}`); + + socket.on('close', (i) => console.log(`[conn] close code=${i.code ?? '?'} reason=${i.reason ?? ''}`)); + socket.on('reconnect', (i) => console.log(`[conn] reconnected after ${i.attempt} attempt(s)`)); + + if (flag('subscribe')) { + socket.subscribeMarket(); + socket.subscribeObservation({ activeScan: false }); + console.log('sent subscribe_market + subscribe_observation (market depth needs you DOCKED)'); + } + + if (flag('request')) { + // get_status is a query (no tick); resolves on the synchronous ok (v1) / result (v2). + const frame = + endpoint === 'v1' + ? { type: 'get_status', request_id: 'smoke-req-1' } + : { tool: 'spacemolt', action: 'get_status', request_id: 'smoke-req-1' }; + console.log(`request(): sending get_status with request_id=smoke-req-1 …`); + socket + .request(frame, { timeoutMs: 15_000 }) + .then((r) => + console.log( + `✓ request() resolved: type=${r.type} request_id=${String((r as { request_id?: string }).request_id)}`, + ), + ) + .catch((e) => console.log(`✗ request() failed: ${e instanceof Error ? e.message : String(e)}`)); + } + + const kickAfter = arg('kick-after'); + if (kickAfter !== undefined) { + if (mode === 'anonymous') { + console.log('(--kick-after ignored for anonymous: no account to contend for)'); + } else { + const kickMs = Number(kickAfter) * 1000; + setTimeout(() => { + void (async () => { + console.log(`\n[kick] opening a 2nd connection for the same account to force-drop the primary…`); + try { + // reconnect:false so the kicker doesn't fight back; the server's + // one-connection-per-account rule closes the primary, which then + // transparently reconnects (watch for the [conn] reconnected line). + const kicker = await createSocket({ auth, endpoint, baseUrl, reconnect: false }); + console.log('[kick] 2nd connection authenticated; closing it so the primary keeps the slot'); + setTimeout(() => void kicker.close(1000, 'kick done'), 1500); + } catch (e) { + console.log(`[kick] kicker failed: ${e instanceof Error ? e.message : String(e)}`); + } + })(); + }, kickMs); + } + } + + const tally = new Map(); + const deadline = setTimeout(() => { + void socket.close(1000, 'smoke done'); + }, durationMs); + + for await (const ev of socket as AsyncIterable) { + tally.set(ev.type, (tally.get(ev.type) ?? 0) + 1); + if (ev.type === 'welcome') { + const p = (ev.payload ?? {}) as Record; + console.log(`welcome: version=${String(p.version)} tick_rate=${String(p.tick_rate)} current_tick=${String(p.current_tick)}`); + } else if (ev.type === 'logged_in') { + console.log('logged_in: ✓ feed is live'); + } else if (ev.type === 'error') { + console.log(`error frame: ${JSON.stringify(ev.payload)}`); + } else { + const preview = JSON.stringify(ev.payload ?? {}); + console.log(`${ev.type} ${preview.length > 160 ? preview.slice(0, 160) + '…' : preview}`); + } + } + clearTimeout(deadline); + + console.log(`\n── tally (status=${socket.status}) ──`); + const rows = [...tally.entries()].sort((a, b) => b[1] - a[1]); + if (rows.length === 0) console.log(' (no frames)'); + for (const [k, n] of rows) console.log(` ${String(n).padStart(4)} ${k}`); +} + +main().catch((e) => { + console.error(`\n[ws-smoke] error: ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); +}); diff --git a/src/sdk-socket.ts b/src/sdk-socket.ts index b628940..2e1602e 100644 --- a/src/sdk-socket.ts +++ b/src/sdk-socket.ts @@ -10,6 +10,12 @@ // reconnect policy is exhausted/disabled. A failure on the FIRST connection (or a // rejected credential at any time) is terminal and rejects/ends rather than looping. // +// Observed close codes (game.spacemolt.com 0.436.x): 1000 normal (deploy/idle/logout), +// 4001 session_replaced (a second connection for the same account kicked this one), and +// 4002 auth_timeout (no valid credential in time). Only 1000 is documented in api-docs; +// 4001/4002 are observed-but-undocumented. Reconnect keys off "non-user, non-1000 close" +// rather than a specific code, so e.g. a 4001 kick reconnects cleanly. +// // Keepalive: there is no app-level heartbeat frame (the server pushes none); we rely // on the transport answering protocol pings (automatic with `ws` and browsers). import type { From 358365e6d4579cf99d2da976f971cc38697ed9f0 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 27 Jun 2026 15:05:50 -0400 Subject: [PATCH 05/10] fix(socket): close ServerEvent union so it narrows under TypeScript 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 2 +- scripts/ws-smoke.ts | 2 +- src/index.ts | 1 + src/socket-types.ts | 22 ++++++++++++++++++---- tests/sdk-socket.test.ts | 11 ++++++++--- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index ddc13ef..a647822 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "ws": "^8.18.0" }, "peerDependencies": { - "typescript": "^5" + "typescript": "^5 || ^6" } } diff --git a/scripts/ws-smoke.ts b/scripts/ws-smoke.ts index 3ddf4e8..aad788a 100644 --- a/scripts/ws-smoke.ts +++ b/scripts/ws-smoke.ts @@ -166,7 +166,7 @@ async function main(): Promise { for await (const ev of socket as AsyncIterable) { tally.set(ev.type, (tally.get(ev.type) ?? 0) + 1); if (ev.type === 'welcome') { - const p = (ev.payload ?? {}) as Record; + const p = (ev.payload ?? {}) as unknown as Record; console.log(`welcome: version=${String(p.version)} tick_rate=${String(p.tick_rate)} current_tick=${String(p.current_tick)}`); } else if (ev.type === 'logged_in') { console.log('logged_in: ✓ feed is live'); diff --git a/src/index.ts b/src/index.ts index 0d977a8..26b11ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export type { SocketLoginToken, SocketAnonymous, ServerEvent, + RawServerFrame, OutboundFrame, OutboundFrameV1, OutboundFrameV2, diff --git a/src/socket-types.ts b/src/socket-types.ts index 683fbd2..2764ad6 100644 --- a/src/socket-types.ts +++ b/src/socket-types.ts @@ -141,7 +141,8 @@ export type SocketStatus = /** * The discriminated union the caller consumes. Notification payloads reuse the * generated Notification* types; control frames are hand-written above. The - * trailing open member keeps unknown/future frames flowing (never dropped). + * union is closed for clean discriminated-union narrowing; unknown/future + * frames fall outside it and are typed via `RawServerFrame` (see below). */ export type ServerEvent = // connection / auth control @@ -166,9 +167,22 @@ export type ServerEvent = | { type: 'skill_level_up'; payload: NotificationSkillLevelUp } | { type: 'market_update'; payload: NotificationMarketUpdate } | { type: 'observation_update'; payload: NotificationObservationUpdate } - | { type: 'crafting_update'; payload: NotificationCraftingUpdate } - // forward-compat: unknown/future types pass through untyped, never dropped - | { type: string; payload?: unknown; request_id?: string }; + | { type: 'crafting_update'; payload: NotificationCraftingUpdate }; + +/** + * The raw shape of any frame the socket may yield, including unknown/future `type`s. + * The runtime never drops a frame with a string `type`; unknown ones are still + * delivered through the event stream, but they fall OUTSIDE the discriminated + * `ServerEvent` union above — handle them in a `default:` branch by treating the + * value as `RawServerFrame`. A `{ type: string }` member cannot live inside + * `ServerEvent` itself: under TypeScript 6 it collapses every case's `payload` to + * `unknown` and breaks discriminated-union narrowing. + */ +export interface RawServerFrame { + type: string; + payload?: unknown; + request_id?: string; +} /** v1 flat outbound frame: {type, payload?, request_id?}. */ export interface OutboundFrameV1 { diff --git a/tests/sdk-socket.test.ts b/tests/sdk-socket.test.ts index b0592b3..0e258f0 100644 --- a/tests/sdk-socket.test.ts +++ b/tests/sdk-socket.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'bun:test'; import type { ServerEvent, + RawServerFrame, WelcomePayload, LoggedInPayload, RegisteredPayload, @@ -51,16 +52,20 @@ type _registered = Expect, RegisteredPayload>>; type _error = Expect, ErrorPayload>>; type _actionError = Expect, ErrorPayload>>; -// forward-compat: an unknown frame type is still a valid ServerEvent (never dropped). +// forward-compat: unknown frame types are typed via RawServerFrame (the runtime still +// delivers them); they are intentionally NOT part of the closed ServerEvent union. type _forwardCompat = Expect< - { type: 'totally_new_frame_type'; payload?: unknown } extends ServerEvent ? true : false + { type: 'totally_new_frame_type'; payload?: unknown } extends RawServerFrame ? true : false +>; +type _closedUnion = Expect< + { type: 'totally_new_frame_type'; payload?: unknown } extends ServerEvent ? false : true >; // Reference the alias types so tsc treats them as used (they are the real test). export type _SocketTypeAssertions = [ _combat, _died, _scanResult, _scanDetected, _pilotless, _reconnected, _mining, _chat, _trade, _skill, _market, _observation, _crafting, - _welcome, _loggedIn, _registered, _error, _actionError, _forwardCompat, + _welcome, _loggedIn, _registered, _error, _actionError, _forwardCompat, _closedUnion, ]; describe('socket-types', () => { From af7be5b2089f05d8122df3aef582abf3a1bf51df Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 6 Jul 2026 15:54:45 -0400 Subject: [PATCH 06/10] docs(socket): document the createSocket WebSocket client in README --- README.md | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/README.md b/README.md index ee6fd2e..066bce0 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,195 @@ const client = createClient({ baseUrl: 'https://game.spacemolt.com' }); The SDK is ESM-only and targets Node 18+ (native fetch). The default base URL is `https://game.spacemolt.com`. +## WebSocket client (`createSocket`) + +A framework-agnostic, dependency-free WebSocket client that mirrors `createSession`. +It opens a connection, runs the handshake, and gives you a typed stream of server +events plus helpers for sending and request/response correlation. + +The socket is a **separate API from the REST `X-Session-Id` session**: it does not +reuse a REST session token. It authenticates directly with the account password (or a +single-use login token). There is **one connection per account** — connecting kicks +any other live connection for that account (you'll see a `4001 session_replaced` close +on the connection that was kicked). + +### Quick start + +```ts +import { createSocket, type RawServerFrame } from '@spacemolt/client-v2'; + +const socket = await createSocket({ + auth: { username: 'you', password: 'secret' }, + endpoint: 'v1', // 'v1' (default) | 'v2' — only OUTBOUND framing differs +}); + +for await (const ev of socket) { + switch (ev.type) { + case 'combat_update': + // ev.payload is the generated NotificationCombatUpdate + console.log('combat', ev.payload); + break; + case 'market_update': + // ev.payload is the generated NotificationMarketUpdate + console.log('market', ev.payload); + break; + default: + // unknown/future frame types are still delivered — see "Unknown frames" + console.log('frame', (ev as RawServerFrame).type); + } +} +``` + +`createSocket` resolves once the connection is open AND `logged_in` (or on `welcome` +for anonymous auth). Server→client frames are **identical on `v1` and `v2`**; only the +shape of frames you send out differs. + +### `createSocket(opts)` options + +`SocketOptions`: + +| Option | Type | Default | Notes | +| --------------- | ----------------------------- | ----------------------------- | ----- | +| `auth` | `SocketAuth` | _(required)_ | Exactly one of `{ username, password }`, `{ loginToken, username? }`, or `{ anonymous: true }`. | +| `endpoint?` | `'v1' \| 'v2'` | `'v1'` | Selects outbound framing only. | +| `baseUrl?` | `string` | `https://game.spacemolt.com` | API origin; `http(s)` is rewritten to `ws(s)`. | +| `wsUrl?` | `string` | derived from `baseUrl` | Full WS URL override (else `baseUrl` + `/ws` or `/ws/v2`). | +| `reconnect?` | `ReconnectOptions \| false` | enabled (exponential backoff) | `false` disables; see "Reconnect". | +| `WebSocketImpl?`| `WebSocketCtor` | global `WebSocket`, else `ws` | Inject a constructor (testing / older runtimes). | + +`ReconnectOptions`: `initialDelayMs` (500), `maxDelayMs` (30_000), `factor` (2), +`jitter` (true), `maxAttempts` (Infinity). + +The returned promise rejects if the **first** connection fails or the credential is +rejected. + +### Two consumption surfaces + +**Async iterable (primary).** `for await (const ev of socket)`, or `socket.events()` +for an explicit `AsyncIterable`: + +```ts +for await (const ev of socket.events()) { + if (ev.type === 'mining_yield') console.log(ev.payload); +} +``` + +**Emitter.** `socket.on(type, cb)` is additive to the iterator and returns an +unsubscribe function. Frame-type listeners narrow `e.payload`: + +```ts +const off = socket.on('combat_update', (e) => { + console.log(e.payload); // narrowed to NotificationCombatUpdate +}); +off(); // unsubscribe + +// Connection-lifecycle names deliver a ConnectionEvent instead of a frame: +socket.on('open', (info) => console.log(info.type)); +socket.on('close', (info) => console.log('closed', info.code, info.reason)); +socket.on('reconnect', (info) => console.log('reconnected after', info.attempt)); +socket.on('error', (info) => console.error(info.error)); +``` + +### Sending & subscriptions + +`socket.send(frame)` sends a raw `OutboundFrame`. The caller picks the shape per the +configured `endpoint` — v1 `{ type, payload?, request_id? }` vs v2 +`{ tool, action, payload?, request_id? }`: + +```ts +// v1 +socket.send({ type: 'subscribe_market' }); +// v2 +socket.send({ tool: 'spacemolt_market', action: 'subscribe_market' }); +``` + +Convenience wrappers build the right framing for the configured endpoint automatically: + +```ts +socket.subscribeMarket(); // NOTE: requires being DOCKED +socket.unsubscribeMarket(); +socket.subscribeObservation({ activeScan: true }); +socket.unsubscribeObservation(); +``` + +### Request/response correlation + +`socket.request(frame, { timeoutMs? })` attaches (or echoes) a `request_id` and +resolves on the **terminal** response. It skips the `ok`/`result` `{ pending: true }` +ack and resolves on the post-tick `action_result` / `action_error` for mutations, or on +the synchronous `ok` (v1) / `result` (v2) for queries. + +```ts +const res = await socket.request({ tool: 'spacemolt', action: 'get_status' }); +console.log(res.type, res.payload); +``` + +Default timeout is `600_000` ms; pass `timeoutMs: 0` or `Infinity` to disable it. + +### Reconnect & close behavior + +After the **first** successful auth, an unexpected close triggers a transparent +backoff reconnect that re-runs the full handshake (`welcome` → login → `logged_in`). +The event stream **stays live** across reconnects — it only ends on `close()` or when +the reconnect policy is exhausted/disabled. + +Reconnect keys off "any non-user, non-`1000` close" rather than a specific code. +Observed close codes: + +| Code | Meaning | Notes | +| ------ | ------------------- | ----- | +| `1000` | normal | documented in api-docs (deploy / idle / logout). | +| `4001` | `session_replaced` | a second connection for the account kicked this one — observed but undocumented. | +| `4002` | `auth_timeout` | no valid credential in time — observed but undocumented. | + +A failure on the **first** connection, or a rejected credential at any time, is +terminal — the `createSocket` promise rejects rather than looping. + +```ts +await socket.close(); // clean shutdown (default code 1000), ends the stream +console.log(socket.status); // 'connecting' | 'open' | 'authenticated' | 'reconnecting' | 'closed' +``` + +### Unknown frames (forward-compat) + +`ServerEvent` is a **closed** discriminated union, so `switch (ev.type)` narrows +`ev.payload` cleanly under TypeScript 6. Unknown / future frame `type`s are **never +dropped** — the runtime still delivers them; they simply fall outside the union. +Handle them in a `default:` branch by treating the value as the exported +`RawServerFrame`: + +```ts +for await (const ev of socket) { + switch (ev.type) { + case 'scan_result': + handleScan(ev.payload); + break; + default: { + const raw = ev as RawServerFrame; // { type: string; payload?: unknown; request_id?: string } + console.log('unhandled frame', raw.type, raw.payload); + } + } +} +``` + +### Runtime requirements + +The socket is dependency-free. It uses `opts.WebSocketImpl` if given, else a global +`WebSocket` (Node ≥ 22 / browsers), else a lazy `import("ws")` if the package is +installed. So Node 22+ needs nothing; on older Node, pass `WebSocketImpl` or install +`ws`: + +```ts +import WebSocket from 'ws'; +const socket = await createSocket({ + auth: { loginToken: '…' }, + WebSocketImpl: WebSocket, +}); +``` + +Credentials are passed in explicitly — the package **never** reads env/disk, **never** +prompts, and **never** logs the secret. + ## License [MIT](./LICENSE) From d03ec2a02a31ea76a88b053986610278098460ba Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 6 Jul 2026 15:54:45 -0400 Subject: [PATCH 07/10] feat(socket): wsOptions passthrough + default User-Agent - 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/`; 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) --- src/sdk-socket.ts | 30 ++++++++- src/socket-types.ts | 9 ++- tests/sdk-socket.runtime.test.ts | 105 ++++++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/sdk-socket.ts b/src/sdk-socket.ts index 2e1602e..cf05279 100644 --- a/src/sdk-socket.ts +++ b/src/sdk-socket.ts @@ -31,10 +31,14 @@ import type { WebSocketCtor, WebSocketLike, } from './socket-types'; +import { VERSION } from './config'; /** Default API origin (mirrors sdk-session.ts; duplicated to avoid coupling the modules). */ const DEFAULT_BASE_URL = 'https://game.spacemolt.com'; +/** Identifies the package on the wire; prepended to any caller-supplied User-Agent. */ +const DEFAULT_USER_AGENT = `@spacemolt/client-v2/${VERSION}`; + const WS_PATH: Record = { v1: '/ws', v2: '/ws/v2' }; /** Names routed to the connection-lifecycle emitter rather than the frame emitter. */ @@ -69,6 +73,29 @@ function deriveWsUrl(baseUrl: string, endpoint: SocketEndpoint): string { return `${baseUrl.replace(/^http/, 'ws')}${WS_PATH[endpoint]}`; } +/** + * Build the WebSocket ctor's 2nd-arg options, always setting a canonical + * `User-Agent`. A caller-supplied UA (any header casing) is prepended to the + * package default; other headers/options pass through. Pure: neither the passed + * wsOptions nor its nested headers object is mutated. + */ +function buildWsCtorOptions(wsOptions: Record | undefined): Record { + const cloned = { ...(wsOptions ?? {}) }; + const rawHeaders = cloned.headers; + const headers: Record = + typeof rawHeaders === 'object' && rawHeaders !== null ? { ...(rawHeaders as Record) } : {}; + let callerUA: string | undefined; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'user-agent') { + const v = headers[key]; + if (typeof v === 'string' && v.length > 0) callerUA = v; + delete headers[key]; + } + } + headers['User-Agent'] = callerUA ? `${callerUA} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT; + return { ...cloned, headers }; +} + async function resolveWebSocketImpl(injected?: WebSocketCtor): Promise { if (injected) return injected; const globalWs = (globalThis as { WebSocket?: unknown }).WebSocket; @@ -155,6 +182,7 @@ export async function createSocket(opts: SocketOptions): Promise void>>(); @@ -381,7 +409,7 @@ export async function createSocket(opts: SocketOptions): Promise void): void; } /** Injected WebSocket constructor (defaults to global WebSocket, then lazy `import("ws")`). */ -export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike; +export type WebSocketCtor = new ( + url: string, + optionsOrProtocols?: string | string[] | Record, +) => WebSocketLike; export interface SocketOptions { /** How to authenticate the socket. */ @@ -75,6 +78,10 @@ export interface SocketOptions { reconnect?: ReconnectOptions | false; /** Injected WebSocket constructor (testing / runtime choice). */ WebSocketImpl?: WebSocketCtor; + /** Options passed to the underlying WebSocket constructor's 2nd argument + * (Bun global WebSocket / `ws` package form: `new WebSocket(url, options)`). + * Carries `headers`, `protocols`, TLS opts, etc. Browser WebSocket ignores it. */ + wsOptions?: Record; } // --- Hand-written control-frame payloads (no generated counterpart) --- diff --git a/tests/sdk-socket.runtime.test.ts b/tests/sdk-socket.runtime.test.ts index 1ea168d..af630fe 100644 --- a/tests/sdk-socket.runtime.test.ts +++ b/tests/sdk-socket.runtime.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, afterEach } from 'bun:test'; import { WebSocketServer, WebSocket as WsClient } from 'ws'; import { createSocket } from '../src/sdk-socket.ts'; import type { SpacemoltSocket } from '../src/socket-types.ts'; +import { VERSION } from '../src/config.ts'; type Json = Record; type OnMessage = ( @@ -19,6 +20,8 @@ const WELCOME: Json = { interface Harness { url: string; received: Json[]; + /** On-the-wire upgrade request headers per connection (ws lowercases header names). */ + headers: Record[]; close(): Promise; } @@ -28,8 +31,10 @@ async function startServer(onMessage?: OnMessage): Promise { const addr = wss.address(); const port = typeof addr === 'object' && addr ? addr.port : 0; const received: Json[] = []; + const headers: Record[] = []; - wss.on('connection', (sock) => { + wss.on('connection', (sock, request) => { + headers.push(request.headers); const send = (obj: Json) => sock.send(JSON.stringify(obj)); send(WELCOME); sock.on('message', (data) => { @@ -47,6 +52,7 @@ async function startServer(onMessage?: OnMessage): Promise { return { url: `ws://127.0.0.1:${port}`, received, + headers, close: () => new Promise((resolve) => { for (const c of wss.clients) { @@ -451,4 +457,101 @@ describe('createSocket runtime', () => { expect(line.includes(secret)).toBe(false); } }); + + test('default User-Agent header when no wsOptions', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + }), + ); + activeSocket = socket; + + expect(harness.headers[0]?.['user-agent']).toBe(`@spacemolt/client-v2/${VERSION}`); + }); + + test('caller User-Agent prepends the default', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + wsOptions: { headers: { 'User-Agent': 'roci' } }, + }), + ); + activeSocket = socket; + + expect(harness.headers[0]?.['user-agent']).toBe(`roci @spacemolt/client-v2/${VERSION}`); + }); + + test('caller User-Agent match is case-insensitive (no duplicate header)', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + wsOptions: { headers: { 'user-agent': 'roci' } }, + }), + ); + activeSocket = socket; + + // A single, correctly prepended UA (not an array of two values). + expect(harness.headers[0]?.['user-agent']).toBe(`roci @spacemolt/client-v2/${VERSION}`); + }); + + test('arbitrary caller headers pass through alongside the default UA', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + wsOptions: { headers: { 'X-Trace-Id': 'abc' } }, + }), + ); + activeSocket = socket; + + expect(harness.headers[0]?.['x-trace-id']).toBe('abc'); + expect(harness.headers[0]?.['user-agent']).toBe(`@spacemolt/client-v2/${VERSION}`); + }); + + test('non-string caller User-Agent falls back to the bare default', async () => { + const harness = await startServer((frame, send) => { + if (isLogin(frame)) send(LOGGED_IN); + }); + activeHarness = harness; + + const socket = await withTimeout( + createSocket({ + auth: { username: 'alice', password: 'pw' }, + wsUrl: harness.url, + WebSocketImpl: WsClient as any, + // e.g. `process.env.CUSTOM_UA` when unset — must not become "undefined ". + wsOptions: { headers: { 'User-Agent': undefined } }, + }), + ); + activeSocket = socket; + + expect(harness.headers[0]?.['user-agent']).toBe(`@spacemolt/client-v2/${VERSION}`); + }); }); From 6492a7d93231f9c59af2481cc41a891559b4c985 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 6 Jul 2026 15:54:45 -0400 Subject: [PATCH 08/10] v1.6.0: WebSocket client (createSocket) 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) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a647822..dcd3750 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@spacemolt/client-v2", - "version": "1.5.22", + "version": "1.6.0", "description": "Typed CLI client for the SpaceMolt v2 REST API. Command registry and types are auto-generated from the live OpenAPI spec.", "type": "module", "private": true, From 59e55aa6454627e6b0593ad8b17c9bd00a08cde1 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 6 Jul 2026 16:03:21 -0400 Subject: [PATCH 09/10] fix(socket): reconcile game-event types with current spec 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) --- src/socket-types.ts | 6 ++---- tests/sdk-socket.test.ts | 8 +++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/socket-types.ts b/src/socket-types.ts index bf868d4..5a232a8 100644 --- a/src/socket-types.ts +++ b/src/socket-types.ts @@ -4,7 +4,7 @@ // Notification* types; connection/auth control frames are defined by hand. import type { NotificationChatMessage, - NotificationCombatUpdate, + NotificationBattleUpdate, NotificationCraftingUpdate, NotificationMarketUpdate, NotificationMiningYield, @@ -13,7 +13,6 @@ import type { NotificationPlayerDied, NotificationReconnected, NotificationScanDetected, - NotificationScanResult, NotificationSkillLevelUp, NotificationTradeOfferReceived, } from './generated'; @@ -162,9 +161,8 @@ export type ServerEvent = | { type: 'action_result'; payload: Record; request_id?: string } | { type: 'action_error'; payload: ErrorPayload; request_id?: string } // game-event pushes (payloads = generated Notification* types) - | { type: 'combat_update'; payload: NotificationCombatUpdate } + | { type: 'battle_update'; payload: NotificationBattleUpdate } | { type: 'player_died'; payload: NotificationPlayerDied } - | { type: 'scan_result'; payload: NotificationScanResult } | { type: 'scan_detected'; payload: NotificationScanDetected } | { type: 'pilotless_ship'; payload: NotificationPilotlessShip } | { type: 'reconnected'; payload: NotificationReconnected } diff --git a/tests/sdk-socket.test.ts b/tests/sdk-socket.test.ts index 0e258f0..bbdf534 100644 --- a/tests/sdk-socket.test.ts +++ b/tests/sdk-socket.test.ts @@ -9,7 +9,7 @@ import type { } from '../src/socket-types.ts'; import type { NotificationChatMessage, - NotificationCombatUpdate, + NotificationBattleUpdate, NotificationCraftingUpdate, NotificationMarketUpdate, NotificationMiningYield, @@ -18,7 +18,6 @@ import type { NotificationPlayerDied, NotificationReconnected, NotificationScanDetected, - NotificationScanResult, NotificationSkillLevelUp, NotificationTradeOfferReceived, } from '../src/generated'; @@ -31,9 +30,8 @@ type PayloadOf = Extract['payload']; // §7 game-event mapping: each payload is EXACTLY the generated Notification* type. // A drift in the generated types turns these into `bun run typecheck:socket` errors. -type _combat = Expect, NotificationCombatUpdate>>; +type _battle = Expect, NotificationBattleUpdate>>; type _died = Expect, NotificationPlayerDied>>; -type _scanResult = Expect, NotificationScanResult>>; type _scanDetected = Expect, NotificationScanDetected>>; type _pilotless = Expect, NotificationPilotlessShip>>; type _reconnected = Expect, NotificationReconnected>>; @@ -63,7 +61,7 @@ type _closedUnion = Expect< // Reference the alias types so tsc treats them as used (they are the real test). export type _SocketTypeAssertions = [ - _combat, _died, _scanResult, _scanDetected, _pilotless, _reconnected, _mining, + _battle, _died, _scanDetected, _pilotless, _reconnected, _mining, _chat, _trade, _skill, _market, _observation, _crafting, _welcome, _loggedIn, _registered, _error, _actionError, _forwardCompat, _closedUnion, ]; From ae8c19ee504e6fa16509c41c665c7ad5f17d315b Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 6 Jul 2026 23:48:20 -0400 Subject: [PATCH 10/10] fix(build): keep VERSION node-free so the library d.ts build packs 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) --- src/config.ts | 6 +++--- src/sdk-socket.ts | 2 +- src/version.ts | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 src/version.ts diff --git a/src/config.ts b/src/config.ts index ab81911..94c160c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ import { join } from 'path'; -// Version inlined at build time from package.json (single source of truth) -import pkg from '../package.json'; -export const VERSION: string = pkg.version; +// Version inlined from package.json — single source of truth in ./version +// (kept node-free so the library declaration build can include it). +export { VERSION } from './version'; export const API_BASE = process.env.SPACEMOLT_URL || 'https://game.spacemolt.com/api/v2'; diff --git a/src/sdk-socket.ts b/src/sdk-socket.ts index cf05279..7422f43 100644 --- a/src/sdk-socket.ts +++ b/src/sdk-socket.ts @@ -31,7 +31,7 @@ import type { WebSocketCtor, WebSocketLike, } from './socket-types'; -import { VERSION } from './config'; +import { VERSION } from './version'; /** Default API origin (mirrors sdk-session.ts; duplicated to avoid coupling the modules). */ const DEFAULT_BASE_URL = 'https://game.spacemolt.com'; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..e80494b --- /dev/null +++ b/src/version.ts @@ -0,0 +1,7 @@ +// Single source of truth for the package version, inlined from package.json. +// Deliberately node-free (no path/process) so the library declaration build +// (tsconfig.lib.json, which runs with `types: []`) can include it via the +// import graph — see sdk-socket.ts, which needs VERSION for the socket User-Agent. +import pkg from '../package.json'; + +export const VERSION: string = pkg.version;