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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
15
server/package.json
Normal 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
26
server/src/app.ts
Normal 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
|
||||
16
server/src/features/gog/router.ts
Normal file
16
server/src/features/gog/router.ts
Normal 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)
|
||||
})
|
||||
19
server/src/features/gog/schema.ts
Normal file
19
server/src/features/gog/schema.ts
Normal 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(),
|
||||
})
|
||||
116
server/src/features/gog/service.ts
Normal file
116
server/src/features/gog/service.ts
Normal 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()
|
||||
}
|
||||
47
server/src/features/igdb/cache.ts
Normal file
47
server/src/features/igdb/cache.ts
Normal 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
|
||||
}
|
||||
21
server/src/features/igdb/router.ts
Normal file
21
server/src/features/igdb/router.ts
Normal 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 })
|
||||
})
|
||||
154
server/src/features/igdb/service.ts
Normal file
154
server/src/features/igdb/service.ts
Normal 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
|
||||
})
|
||||
}
|
||||
14
server/src/features/steam/router.ts
Normal file
14
server/src/features/steam/router.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
22
server/src/features/steam/schema.ts
Normal file
22
server/src/features/steam/schema.ts
Normal 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(),
|
||||
})
|
||||
37
server/src/features/steam/service.ts
Normal file
37
server/src/features/steam/service.ts
Normal 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
12
server/src/index.ts
Normal 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,
|
||||
}
|
||||
10
server/src/shared/lib/env.ts
Normal file
10
server/src/shared/lib/env.ts
Normal 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
15
server/tsconfig.json
Normal 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
6
src/shared/lib/utils.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user