// ABOUTME: Rails API client for Minesweeper endpoints.
// ABOUTME: Handles HTTP requests, timeouts, and error translation.
import { Config, requireBearerToken } from "../config.js";
export class RailsHttpError extends Error {
status: number;
rawBody?: unknown;
constructor(status: number, message: string, rawBody?: unknown) {
super(message);
this.name = "RailsHttpError";
this.status = status;
this.rawBody = rawBody;
}
}
export type GameState = {
game: {
status: string;
updated_at: string;
board: string[][];
};
};
export type StartResponse = {
public_id: string;
game_url: string;
};
export type GamesResponse = {
games: unknown[];
};
function joinUrl(baseUrl: string, path: string): string {
if (!path.startsWith("/")) {
throw new Error("path must start with /");
}
return `${baseUrl}${path}`;
}
async function parseJsonSafe(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
export function formatRailsError(status: number, body: unknown): string {
if (body && typeof body === "object" && "error" in body) {
const errorValue = (body as { error?: unknown }).error;
if (typeof errorValue === "string" && errorValue.trim()) {
return errorValue;
}
}
return `Request failed (status=${status})`;
}
export class RailsClient {
private config: Config;
constructor(config: Config) {
this.config = config;
}
async startGame(userSlug: string): Promise<StartResponse> {
return this.requestJson<StartResponse>(
"POST",
`/users/${encodeURIComponent(userSlug)}/start`,
undefined,
true
);
}
async getState(publicId: string): Promise<GameState> {
return this.requestJson<GameState>("GET", `/games/${encodeURIComponent(publicId)}/state`);
}
async openCell(publicId: string, x: number, y: number): Promise<GameState> {
return this.requestJson<GameState>(
"POST",
`/games/${encodeURIComponent(publicId)}/open`,
{ x, y },
true
);
}
async flagCell(publicId: string, x: number, y: number): Promise<GameState> {
return this.requestJson<GameState>(
"POST",
`/games/${encodeURIComponent(publicId)}/flag`,
{ x, y },
true
);
}
async chordCell(publicId: string, x: number, y: number): Promise<GameState> {
return this.requestJson<GameState>(
"POST",
`/games/${encodeURIComponent(publicId)}/chord`,
{ x, y },
true
);
}
async endGame(publicId: string): Promise<GameState> {
return this.requestJson<GameState>(
"POST",
`/games/${encodeURIComponent(publicId)}/end`,
undefined,
true
);
}
async listGames(userSlug: string): Promise<GamesResponse> {
return this.requestJson<GamesResponse>("GET", `/users/${encodeURIComponent(userSlug)}/games`);
}
private async requestJson<T>(
method: string,
path: string,
body?: Record<string, unknown>,
needsAuth = false
): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
if (body) {
headers["Content-Type"] = "application/json";
}
if (needsAuth) {
const token = requireBearerToken(this.config);
headers.Authorization = `Bearer ${token}`;
}
const url = joinUrl(this.config.baseUrl, path);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const parsed = await parseJsonSafe(response);
if (!response.ok) {
const message = formatRailsError(response.status, parsed);
throw new RailsHttpError(response.status, message, parsed);
}
return parsed as T;
} finally {
clearTimeout(timeout);
}
}
}
export function buildUrl(baseUrl: string, path: string): string {
return joinUrl(baseUrl, path);
}