mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat(gateway,cli): GET /state/:bot; sdk/cli.ts state dumps over HTTP first
The chat CLI moved to gateway HTTP endpoints (98b28b1ca) but the state
CLI still opened a fresh observer WebSocket per invocation - the last
per-call TLS handshake in the split benchmark topology, and the source
of the remaining 'Failed to connect to wss://' errors there.
- gateway: GET /state/:bot answers from the last held frame with
stateAge + botStatus (404 JSON when nothing has registered).
- cli.ts: try /state first with a bounded fetch (+1 retry), fall back
to the observer socket only for gateways that predate the endpoint.
Output unchanged.
- tests: endpoint + CLI end-to-end (no observer session left behind).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
d80066c78a
commit
ff60e8dee6
3 files changed
+160
-17
No files matched your search
+78
-17
@@ -12,9 +12,64 @@
|
||||
|
||||
import { BotSDK, deriveGatewayUrl } from './index';
|
||||
import { formatWorldState } from './formatter';
|
||||
import type { BotWorldState } from './types';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/** ws://host -> http://host, wss://host/path -> https://host/path (the gateway serves both on one port). */
|
||||
function gatewayHttpBase(gatewayUrl: string): string {
|
||||
return gatewayUrl.replace(/^ws(s?):\/\//, 'http$1://').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary path: one bounded HTTP GET against the gateway's /state/:bot, which
|
||||
* answers from the last frame it holds. No per-invocation WebSocket handshake
|
||||
* (the thing that flakes over TLS tunnels), and a hard per-attempt timeout
|
||||
* with one retry on network failure. Returns null when the gateway predates
|
||||
* the endpoint (old gateways answer unknown paths with a text banner), so the
|
||||
* caller can fall back to the observer socket.
|
||||
*/
|
||||
async function fetchStateViaHttp(
|
||||
gatewayUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
timeout: number
|
||||
): Promise<{ state: BotWorldState; stateAge: number } | null> {
|
||||
const params = new URLSearchParams();
|
||||
if (password) params.set('password', password);
|
||||
const qs = params.toString();
|
||||
const url = `${gatewayHttpBase(gatewayUrl)}/state/${encodeURIComponent(username)}${qs ? `?${qs}` : ''}`;
|
||||
|
||||
let res: Response | null = null;
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < 2 && !res; attempt++) {
|
||||
try {
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
if (!res) {
|
||||
console.error(`Error: Failed to reach gateway at ${url}`);
|
||||
console.error(` ${(lastErr as any)?.message ?? lastErr}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!(res.headers.get('content-type') ?? '').includes('json')) return null;
|
||||
|
||||
const body: any = await res.json().catch(() => null);
|
||||
if (res.status === 404 || !body?.state) {
|
||||
console.error(`Error: No game state for '${username}' (gateway is fine - answered OK)`);
|
||||
console.error(` Nothing is logged into the game as '${username}', so there's no state to report.`);
|
||||
console.error(` This is also what you see after a server restart or when an idle client goes stale.`);
|
||||
console.error(` Fix: open the web client and log in as '${username}', then re-run.`);
|
||||
console.error(` (Scripts via sdk/runner.ts launch the client for you.)`);
|
||||
if (body?.error && res.status !== 404) console.error(` ${body.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { state: body.state as BotWorldState, stateAge: Number(body.stateAge) || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect, then wait for game state - reporting the two failure modes separately.
|
||||
*
|
||||
@@ -154,25 +209,31 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
|
||||
|
||||
const gatewayUrl = deriveGatewayUrl(server);
|
||||
|
||||
// 'observe', not the BotSDK default of 'control': this is a read-only dump, and the
|
||||
// gateway pre-empts existing controllers when a new one connects. Connecting as a
|
||||
// controller here would kill whatever script currently owns the bot — and pre-emption
|
||||
// disables its auto-reconnect, so it would die permanently on a mere state check.
|
||||
const sdk = new BotSDK({
|
||||
botUsername: username,
|
||||
password,
|
||||
gatewayUrl,
|
||||
connectionMode: 'observe',
|
||||
autoReconnect: false,
|
||||
autoLaunchBrowser: false,
|
||||
readyTimeout: 0
|
||||
});
|
||||
|
||||
const { state, stateAge } = await connectAndAwaitState(sdk, gatewayUrl, username, timeout);
|
||||
// Try the gateway's HTTP state endpoint first; only gateways that predate
|
||||
// it fall through to the observer WebSocket below.
|
||||
let result = await fetchStateViaHttp(gatewayUrl, username, password, timeout);
|
||||
let sdk: BotSDK | null = null;
|
||||
if (!result) {
|
||||
// 'observe', not the BotSDK default of 'control': this is a read-only dump, and the
|
||||
// gateway pre-empts existing controllers when a new one connects. Connecting as a
|
||||
// controller here would kill whatever script currently owns the bot — and pre-emption
|
||||
// disables its auto-reconnect, so it would die permanently on a mere state check.
|
||||
sdk = new BotSDK({
|
||||
botUsername: username,
|
||||
password,
|
||||
gatewayUrl,
|
||||
connectionMode: 'observe',
|
||||
autoReconnect: false,
|
||||
autoLaunchBrowser: false,
|
||||
readyTimeout: 0
|
||||
});
|
||||
result = await connectAndAwaitState(sdk, gatewayUrl, username, timeout);
|
||||
}
|
||||
const { state, stateAge } = result;
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(state, null, 2));
|
||||
sdk.disconnect();
|
||||
sdk?.disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -188,7 +249,7 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
|
||||
|
||||
console.log(formatWorldState(state, stateAge));
|
||||
|
||||
sdk.disconnect();
|
||||
sdk?.disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,24 @@ describe('gateway HTTP chat endpoints', () => {
|
||||
expect(Date.now() - start).toBeLessThan(5000);
|
||||
}, 10000);
|
||||
|
||||
test('GET /state/:bot returns the last frame with its age', async () => {
|
||||
const res = await fetch(`${BASE}/state/tbot`);
|
||||
expect(res.status).toBe(200);
|
||||
const body: any = await res.json();
|
||||
expect(body.state.player.name).toBe('tbot');
|
||||
expect(body.state.inGame).toBe(true);
|
||||
expect(typeof body.stateAge).toBe('number');
|
||||
expect(body.botStatus).toBe('active');
|
||||
});
|
||||
|
||||
test('GET /state for an unknown bot answers 404 JSON, not a banner', async () => {
|
||||
const res = await fetch(`${BASE}/state/nobody`);
|
||||
expect(res.status).toBe(404);
|
||||
const body: any = await res.json();
|
||||
expect(body.state).toBeNull();
|
||||
expect(body.botStatus).toBe('dead');
|
||||
});
|
||||
|
||||
test('POST /say rejects a missing text body without dispatching', async () => {
|
||||
const res = await fetch(`${BASE}/say/tbot`, {
|
||||
method: 'POST',
|
||||
@@ -217,3 +235,37 @@ describe('chat CLI over HTTP', () => {
|
||||
expect(Date.now() - start).toBeLessThan(20000);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('state CLI over HTTP', () => {
|
||||
async function runStateCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ['bun', join(REPO_ROOT, 'sdk', 'cli.ts'), ...args, '--server', `localhost:${PORT}`],
|
||||
cwd: REPO_ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
test('dumps state end-to-end without an observer socket', async () => {
|
||||
const before = ((await (await fetch(`${BASE}/status/tbot`)).json()) as any).observers.length;
|
||||
const { exitCode, stdout } = await runStateCli(['tbot', 'state', '--json']);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(stdout).player.name).toBe('tbot');
|
||||
const after = ((await (await fetch(`${BASE}/status/tbot`)).json()) as any).observers.length;
|
||||
expect(after).toBe(before);
|
||||
}, 20000);
|
||||
|
||||
test('explains a missing bot instead of hanging', async () => {
|
||||
const start = Date.now();
|
||||
const { exitCode, stderr } = await runStateCli(['nobody', 'state', '--timeout', '3000']);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("No game state for 'nobody'");
|
||||
expect(Date.now() - start).toBeLessThan(15000);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -846,6 +846,36 @@ const server = Bun.serve({
|
||||
});
|
||||
}
|
||||
|
||||
// Read the bot's last world state: GET /state/:username
|
||||
//
|
||||
// Same rationale as /chat: `sdk/cli.ts` state dumps used to open a
|
||||
// fresh observer WebSocket per invocation, which is the last remaining
|
||||
// per-call handshake over TLS tunnels ("Failed to connect to wss://…"
|
||||
// in the split market runs). The gateway already holds the latest
|
||||
// frame, so answer from it. `stateAge` lets the caller flag staleness;
|
||||
// 404 when nothing has ever registered as this bot.
|
||||
const stateMatch = url.pathname.match(/^\/state\/(.+)$/);
|
||||
if (stateMatch && stateMatch[1] && req.method === 'GET') {
|
||||
const username = decodeURIComponent(stateMatch[1]);
|
||||
const auth = await authenticateHttpRequest(req, url, username);
|
||||
if (!auth.success) {
|
||||
return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401);
|
||||
}
|
||||
const botSession = botSessions.get(username);
|
||||
if (!botSession || !botSession.lastState) {
|
||||
return jsonResponse({
|
||||
error: `No game state for '${username}' - nothing is logged into the game as that bot`,
|
||||
botStatus: botSession ? getSessionStatus(botSession) : 'dead',
|
||||
state: null
|
||||
}, 404);
|
||||
}
|
||||
return jsonResponse({
|
||||
state: botSession.lastState,
|
||||
stateAge: Date.now() - botSession.lastStateReceivedAt,
|
||||
botStatus: getSessionStatus(botSession)
|
||||
});
|
||||
}
|
||||
|
||||
// Send chat: POST /say/:username body: {"text": "...", "password"?: "...", "timeoutMs"?: n}
|
||||
//
|
||||
// Relays through the bot's game client like observer 'say' does, but as
|
||||
|
||||
Reference in new issue
Block a user