add hono backend: steam, gog, igdb api proxy, fix gitignore overrides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:40:43 +01:00
parent 17b52173c7
commit 5ebd9dba16
16 changed files with 535 additions and 0 deletions

5
.gitignore vendored
View File

@@ -2,6 +2,11 @@ node_modules
.DS_Store
.claude
# Override global gitignore exclusions
!/server/
!/src/
!**/lib/
# Secrets
.env
.env.*

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "whattoplay-server",
"private": true,
"version": "2026.03.01",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
},
"dependencies": {
"@hono/zod-validator": "^0.5.0",
"hono": "^4.7.0",
"zod": "^3.24.0"
}
}

26
server/src/app.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { gogRouter } from "./features/gog/router.ts"
import { igdbRouter } from "./features/igdb/router.ts"
import { steamRouter } from "./features/steam/router.ts"
import { env } from "./shared/lib/env.ts"
const app = new Hono()
app.use(
"/api/*",
cors({
origin: env.ALLOWED_ORIGIN,
allowMethods: ["GET", "POST"],
allowHeaders: ["Content-Type"],
}),
)
const apiRoutes = app
.get("/api/health", (c) => c.json({ status: "ok" }))
.route("/api/steam", steamRouter)
.route("/api/gog", gogRouter)
.route("/api/igdb", igdbRouter)
export type AppType = typeof apiRoutes
export default app

View File

@@ -0,0 +1,16 @@
import { zValidator } from "@hono/zod-validator"
import { Hono } from "hono"
import { gogAuthInput, gogGamesInput } from "./schema.ts"
import { exchangeGogCode, fetchGogGames } from "./service.ts"
export const gogRouter = new Hono()
.post("/auth", zValidator("json", gogAuthInput), async (c) => {
const { code } = c.req.valid("json")
const result = await exchangeGogCode(code)
return c.json(result)
})
.post("/games", zValidator("json", gogGamesInput), async (c) => {
const { accessToken, refreshToken } = c.req.valid("json")
const result = await fetchGogGames(accessToken, refreshToken)
return c.json(result)
})

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
export const gogAuthInput = z.object({
code: z.string().min(1),
})
export const gogGamesInput = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
})
export const gogGameSchema = z.object({
id: z.string(),
title: z.string(),
source: z.literal("gog"),
sourceId: z.string(),
platform: z.literal("PC"),
url: z.string().optional(),
})

View File

@@ -0,0 +1,116 @@
const CLIENT_ID = "46899977096215655"
const CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
const REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
export async function exchangeGogCode(code: string) {
const url = new URL("https://auth.gog.com/token")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("client_secret", CLIENT_SECRET)
url.searchParams.set("grant_type", "authorization_code")
url.searchParams.set("code", code)
url.searchParams.set("redirect_uri", REDIRECT_URI)
const response = await fetch(url)
if (!response.ok) {
const text = await response.text()
throw new Error(`GOG Token Exchange Error: ${response.status} ${text}`)
}
const data = await response.json()
return {
access_token: data.access_token as string,
refresh_token: data.refresh_token as string,
user_id: data.user_id as string,
expires_in: data.expires_in as number,
}
}
async function refreshAccessToken(refreshToken: string) {
const url = new URL("https://auth.gog.com/token")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("client_secret", CLIENT_SECRET)
url.searchParams.set("grant_type", "refresh_token")
url.searchParams.set("refresh_token", refreshToken)
const response = await fetch(url)
if (!response.ok) {
const text = await response.text()
throw new Error(`GOG Token Refresh Error: ${response.status} ${text}`)
}
const data = await response.json()
return {
access_token: data.access_token as string,
refresh_token: data.refresh_token as string,
expires_in: data.expires_in as number,
}
}
interface GogProduct {
id: number
title?: string
url?: string
}
export async function fetchGogGames(accessToken: string, refreshToken: string) {
let token = accessToken
let newTokens: Awaited<ReturnType<typeof refreshAccessToken>> | null = null
let page = 1
let totalPages = 1
const allProducts: GogProduct[] = []
while (page <= totalPages) {
const url = `https://embed.gog.com/account/getFilteredProducts?mediaType=1&page=${page}`
let response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.status === 401 && !newTokens) {
newTokens = await refreshAccessToken(refreshToken)
token = newTokens.access_token
response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
}
if (!response.ok) {
throw new Error(`GOG API Error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
totalPages = data.totalPages || 1
allProducts.push(...(data.products || []))
page++
}
const games = allProducts
.filter((product): product is GogProduct & { title: string } => Boolean(product.title))
.map((product) => ({
id: `gog-${product.id}`,
title: product.title,
source: "gog" as const,
sourceId: String(product.id),
platform: "PC" as const,
url: product.url ? `https://www.gog.com${product.url}` : undefined,
}))
return {
games,
count: games.length,
...(newTokens && {
newAccessToken: newTokens.access_token,
newRefreshToken: newTokens.refresh_token,
}),
}
}
export function getGogAuthUrl() {
const url = new URL("https://auth.gog.com/auth")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("redirect_uri", REDIRECT_URI)
url.searchParams.set("response_type", "code")
url.searchParams.set("layout", "client2")
return url.toString()
}

View File

@@ -0,0 +1,47 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const CACHE_FILE = join(__dirname, "../../data/igdb-cache.json")
interface CacheEntry {
igdbId: number | null
}
const cache = new Map<string, CacheEntry>()
export function loadCache() {
try {
const data = readFileSync(CACHE_FILE, "utf-8")
const entries: Record<string, CacheEntry> = JSON.parse(data)
for (const [key, value] of Object.entries(entries)) {
cache.set(key, value)
}
console.log(`[IGDB] Cache loaded: ${cache.size} entries`)
} catch {
console.log("[IGDB] No cache file found, starting fresh")
}
}
export function saveCache() {
try {
mkdirSync(join(__dirname, "../../data"), { recursive: true })
const obj = Object.fromEntries(cache)
writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2))
} catch (err) {
console.error("[IGDB] Failed to save cache:", (err as Error).message)
}
}
export function getCacheEntry(key: string): CacheEntry | undefined {
return cache.get(key)
}
export function setCacheEntry(key: string, value: CacheEntry) {
cache.set(key, value)
}
export function getCacheSize() {
return cache.size
}

View File

@@ -0,0 +1,21 @@
import { zValidator } from "@hono/zod-validator"
import { Hono } from "hono"
import { z } from "zod"
import { enrichGamesWithIgdb } from "./service.ts"
const enrichInput = z.object({
games: z.array(
z
.object({
source: z.string(),
sourceId: z.string(),
})
.passthrough(),
),
})
export const igdbRouter = new Hono().post("/enrich", zValidator("json", enrichInput), async (c) => {
const { games } = c.req.valid("json")
const enriched = await enrichGamesWithIgdb(games)
return c.json({ games: enriched })
})

View File

@@ -0,0 +1,154 @@
import { env } from "../../shared/lib/env.ts"
import { getCacheEntry, getCacheSize, saveCache, setCacheEntry } from "./cache.ts"
const CATEGORY_STEAM = 1
const CATEGORY_GOG = 2
const SOURCE_TO_CATEGORY: Record<string, number> = {
steam: CATEGORY_STEAM,
gog: CATEGORY_GOG,
}
let twitchToken: string | null = null
let tokenExpiry = 0
async function getIgdbToken(): Promise<string | null> {
if (twitchToken && Date.now() < tokenExpiry) {
return twitchToken
}
if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) {
return null
}
const url = new URL("https://id.twitch.tv/oauth2/token")
url.searchParams.set("client_id", env.TWITCH_CLIENT_ID)
url.searchParams.set("client_secret", env.TWITCH_CLIENT_SECRET)
url.searchParams.set("grant_type", "client_credentials")
const response = await fetch(url, { method: "POST" })
if (!response.ok) {
console.error(`[IGDB] Twitch auth failed: ${response.status} ${response.statusText}`)
return null
}
const data = await response.json()
twitchToken = data.access_token
tokenExpiry = Date.now() + (data.expires_in - 300) * 1000
console.log("[IGDB] Twitch token acquired")
return twitchToken
}
async function igdbRequest(endpoint: string, query: string): Promise<unknown[]> {
const token = await getIgdbToken()
if (!token) return []
const response = await fetch(`https://api.igdb.com/v4${endpoint}`, {
method: "POST",
headers: {
"Client-ID": env.TWITCH_CLIENT_ID,
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body: query,
})
if (!response.ok) {
const text = await response.text()
console.error(`[IGDB] API error: ${response.status} ${text}`)
return []
}
return response.json()
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
async function batchResolve(category: number, sourceIds: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>()
const BATCH_SIZE = 500
for (let i = 0; i < sourceIds.length; i += BATCH_SIZE) {
const batch = sourceIds.slice(i, i + BATCH_SIZE)
const uids = batch.map((id) => `"${id}"`).join(",")
const query = `fields game,uid; where category = ${category} & uid = (${uids}); limit ${BATCH_SIZE};`
const data = (await igdbRequest("/external_games", query)) as Array<{
game?: number
uid?: string
}>
for (const entry of data) {
if (entry.game && entry.uid) {
results.set(entry.uid, entry.game)
}
}
if (i + BATCH_SIZE < sourceIds.length) {
await sleep(260)
}
}
return results
}
interface GameForEnrichment {
source: string
sourceId: string
[key: string]: unknown
}
export async function enrichGamesWithIgdb<T extends GameForEnrichment>(
games: T[],
): Promise<(T & { canonicalId?: string })[]> {
if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) {
return games
}
const uncachedBySource: Record<string, string[]> = {}
for (const game of games) {
const cacheKey = `${game.source}:${game.sourceId}`
if (!getCacheEntry(cacheKey) && SOURCE_TO_CATEGORY[game.source]) {
if (!uncachedBySource[game.source]) {
uncachedBySource[game.source] = []
}
uncachedBySource[game.source].push(game.sourceId)
}
}
let newEntries = 0
try {
for (const [source, sourceIds] of Object.entries(uncachedBySource)) {
const category = SOURCE_TO_CATEGORY[source]
console.log(`[IGDB] Resolving ${sourceIds.length} ${source} games...`)
const resolved = await batchResolve(category, sourceIds)
for (const [uid, igdbId] of resolved) {
setCacheEntry(`${source}:${uid}`, { igdbId })
newEntries++
}
for (const uid of sourceIds) {
if (!resolved.has(uid)) {
setCacheEntry(`${source}:${uid}`, { igdbId: null })
}
}
}
if (newEntries > 0) {
console.log(`[IGDB] Resolved ${newEntries} new games, cache: ${getCacheSize()} entries`)
saveCache()
}
} catch (err) {
console.error("[IGDB] Enrichment failed (non-fatal):", (err as Error).message)
}
return games.map((game) => {
const cached = getCacheEntry(`${game.source}:${game.sourceId}`)
if (cached?.igdbId) {
return { ...game, canonicalId: String(cached.igdbId) }
}
return game
})
}

View File

@@ -0,0 +1,14 @@
import { zValidator } from "@hono/zod-validator"
import { Hono } from "hono"
import { steamRefreshInput } from "./schema.ts"
import { fetchSteamGames } from "./service.ts"
export const steamRouter = new Hono().post(
"/games",
zValidator("json", steamRefreshInput),
async (c) => {
const { apiKey, steamId } = c.req.valid("json")
const result = await fetchSteamGames(apiKey, steamId)
return c.json(result)
},
)

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
export const steamRefreshInput = z.object({
apiKey: z.string().min(1),
steamId: z.string().min(1),
})
export const steamGameSchema = z.object({
id: z.string(),
title: z.string(),
source: z.literal("steam"),
sourceId: z.string(),
platform: z.literal("PC"),
lastPlayed: z.string().nullable(),
playtimeHours: z.number(),
url: z.string(),
})
export const steamGamesOutput = z.object({
games: z.array(steamGameSchema),
count: z.number(),
})

View File

@@ -0,0 +1,37 @@
interface SteamGame {
appid: number
name: string
playtime_forever: number
rtime_last_played?: number
}
export async function fetchSteamGames(apiKey: string, steamId: string) {
const url = new URL("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/")
url.searchParams.set("key", apiKey)
url.searchParams.set("steamid", steamId)
url.searchParams.set("include_appinfo", "true")
url.searchParams.set("include_played_free_games", "true")
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Steam API Error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const rawGames: SteamGame[] = data.response?.games ?? []
const games = rawGames.map((game) => ({
id: `steam-${game.appid}`,
title: game.name,
source: "steam" as const,
sourceId: String(game.appid),
platform: "PC" as const,
lastPlayed: game.rtime_last_played
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
: null,
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
url: `https://store.steampowered.com/app/${game.appid}`,
}))
return { games, count: games.length }
}

12
server/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import app from "./app.ts"
import { loadCache } from "./features/igdb/cache.ts"
import { env } from "./shared/lib/env.ts"
loadCache()
console.log(`[server] listening on http://localhost:${env.PORT}`)
export default {
port: env.PORT,
fetch: app.fetch,
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
const envSchema = z.object({
PORT: z.coerce.number().default(3001),
ALLOWED_ORIGIN: z.string().default("http://localhost:5173"),
TWITCH_CLIENT_ID: z.string().default(""),
TWITCH_CLIENT_SECRET: z.string().default(""),
})
export const env = envSchema.parse(process.env)

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}

6
src/shared/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}