Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
CLERK_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# Services
# Steam Web API key — required for Steam App ID search (get one at https://steamcommunity.com/dev/apikey)
STEAM_API_KEY="your-steam-web-api-key-here"
RAWG_API_KEY="RAWG-API-KEY"
THE_GAMES_DB_API_KEY="The-Games-DB-API-KEY"
NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY"
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const GetBestSteamAppIdMobileSchema = z.object({
gameName: z.string().min(2, 'Game name must be at least 2 characters'),
})

export const GetSteamGamesStatsMobileSchema = z.object({}).optional()
export const GetSteamGamesStatsMobileSchema = z.object({}).nullish()

export const BatchBySteamAppIdsSchema = z.object({
steamAppIds: z
Expand Down
99 changes: 73 additions & 26 deletions src/server/utils/steamGameSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ interface SteamAppEntry {
name: string
}

interface SteamApiResponse {
applist: {
apps: SteamAppEntry[]
interface SteamStoreApiResponse {
response: {
apps: (SteamAppEntry & { last_modified?: number; price_change_number?: number })[]
have_more_results?: boolean
last_appid?: number
}
}

Expand Down Expand Up @@ -50,34 +52,71 @@ const FUSE_OPTIONS = {
findAllMatches: true,
}

const STEAM_API_URL = 'https://api.steampowered.com/ISteamApps/GetAppList/v2/'
// ISteamApps/GetAppList/v2 was removed by Valve. The replacement requires a key.
const STEAM_STORE_API_URL = 'https://api.steampowered.com/IStoreService/GetAppList/v1/'
const FETCH_TIMEOUT_MS = 30_000 // 30s — avoids hanging on serverless cold starts
const PAGE_SIZE = 50_000 // max allowed by the API

// In-flight deduplication: prevents concurrent cold starts from each firing a fetch
let inflightFetch: Promise<SteamAppEntry[]> | null = null

/**
* Fetches Steam games data from Steam Web API
* Fetches Steam games data from IStoreService/GetAppList/v1 with pagination.
* Requires STEAM_API_KEY env var.
*/
async function fetchSteamGamesData(): Promise<SteamAppEntry[]> {
const response = await fetch(STEAM_API_URL, {
headers: { 'User-Agent': 'EmuReady-GameSearch/1.0' },
})

if (!response.ok) {
throw new Error(`Failed to fetch Steam games data: ${response.status} ${response.statusText}`)
const apiKey = process.env.STEAM_API_KEY
if (!apiKey) {
throw new Error('STEAM_API_KEY environment variable is not set')
}

const data = (await response.json()) as SteamApiResponse
const allApps: SteamAppEntry[] = []
let lastAppId: number | undefined

do {
const url = new URL(STEAM_STORE_API_URL)
url.searchParams.set('key', apiKey)
url.searchParams.set('include_games', '1')
url.searchParams.set('include_dlc', '0')
url.searchParams.set('include_software', '0')
url.searchParams.set('include_videos', '0')
url.searchParams.set('include_hardware', '0')
url.searchParams.set('max_results', String(PAGE_SIZE))
if (lastAppId !== undefined) {
url.searchParams.set('last_appid', String(lastAppId))
}

if (!data.applist || !Array.isArray(data.applist.apps)) {
throw new Error('Invalid Steam games data format: expected applist.apps array')
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)

// Validate data structure
for (const entry of data.applist.apps.slice(0, 5)) {
if (typeof entry.appid !== 'number' || typeof entry.name !== 'string') {
throw new Error('Invalid Steam app entry structure')
let response: Response
try {
response = await fetch(url, {
headers: { 'User-Agent': 'EmuReady-GameSearch/1.0' },
signal: controller.signal,
})
} finally {
clearTimeout(timeoutId)
}
}

return data.applist.apps
if (!response.ok) {
throw new Error(`Failed to fetch Steam games data: ${response.status} ${response.statusText}`)
}

const data = (await response.json()) as SteamStoreApiResponse

if (!data.response || !Array.isArray(data.response.apps)) {
throw new Error('Invalid Steam games data format: expected response.apps array')
}

for (const entry of data.response.apps) {
if (entry.name) allApps.push({ appid: entry.appid, name: entry.name })
}

lastAppId = data.response.have_more_results ? data.response.last_appid : undefined
} while (lastAppId !== undefined)

return allApps
}

/**
Expand All @@ -86,18 +125,25 @@ async function fetchSteamGamesData(): Promise<SteamAppEntry[]> {
export async function getSteamGamesData(): Promise<SteamAppEntry[]> {
const cacheKey = 'steam-games-data'

// Try to get from cache first
const cachedData = steamGamesDataCache.get(cacheKey)
if (cachedData) return cachedData

// Fetch fresh data and cache it
// Deduplicate concurrent fetches so only one in-flight request runs at a time
if (!inflightFetch) {
inflightFetch = fetchSteamGamesData().finally(() => {
inflightFetch = null
})
}

try {
const freshData = await fetchSteamGamesData()
const freshData = await inflightFetch
steamGamesDataCache.set(cacheKey, freshData)
return freshData
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('[steamGameSearch] fetchSteamGamesData failed:', message)
if (error instanceof Error) {
error.message = `Steam games data fetch failed: ${error.message}`
error.message = `Steam games data fetch failed: ${message}`
}
throw error
}
Comment on lines +131 to 149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Concurrent awaiters of inflightFetch will mutate the same Error and double-log.

With the new dedup, multiple callers share the same rejected promise, so on failure every caller runs this catch: each one logs [steamGameSearch] fetchSteamGamesData failed: and each one prepends "Steam games data fetch failed: " to the same Error instance (the message getter now already contains the previous prefix). Net effect: duplicate error logs per cold start and a nested message like Steam games data fetch failed: Steam games data fetch failed: <original>. Mutating a caller-visible Error is also generally risky (loses original stack context for downstream callers like getSteamAppInfo and getSteamGamesStats).

Prefer wrapping in a fresh Error and logging only once (either in fetchSteamGamesData itself, or by checking a flag).

🛠️ Proposed fix — wrap instead of mutate
   try {
     const freshData = await inflightFetch
     steamGamesDataCache.set(cacheKey, freshData)
     return freshData
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error)
     console.error('[steamGameSearch] fetchSteamGamesData failed:', message)
-    if (error instanceof Error) {
-      error.message = `Steam games data fetch failed: ${message}`
-    }
-    throw error
+    throw new Error(`Steam games data fetch failed: ${message}`, {
+      cause: error instanceof Error ? error : undefined,
+    })
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Deduplicate concurrent fetches so only one in-flight request runs at a time
if (!inflightFetch) {
inflightFetch = fetchSteamGamesData().finally(() => {
inflightFetch = null
})
}
try {
const freshData = await fetchSteamGamesData()
const freshData = await inflightFetch
steamGamesDataCache.set(cacheKey, freshData)
return freshData
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('[steamGameSearch] fetchSteamGamesData failed:', message)
if (error instanceof Error) {
error.message = `Steam games data fetch failed: ${error.message}`
error.message = `Steam games data fetch failed: ${message}`
}
throw error
}
// Deduplicate concurrent fetches so only one in-flight request runs at a time
if (!inflightFetch) {
inflightFetch = fetchSteamGamesData().finally(() => {
inflightFetch = null
})
}
try {
const freshData = await inflightFetch
steamGamesDataCache.set(cacheKey, freshData)
return freshData
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('[steamGameSearch] fetchSteamGamesData failed:', message)
throw new Error(`Steam games data fetch failed: ${message}`, {
cause: error instanceof Error ? error : undefined,
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/utils/steamGameSearch.ts` around lines 131 - 149, The catch block
mutates and re-logs the same Error instance shared by concurrent awaiters of
inflightFetch, causing duplicated logs and nested messages; instead create and
throw a new Error (wrapping the original) and log only once: capture the
original error from fetchSteamGamesData in the catch, call
processLogger/console.error with a single descriptive message plus the original
error, then throw a new Error like `new Error("Steam games data fetch failed: "
+ originalMessage)` (or preserve original via Error.cause if available) rather
than modifying error.message; keep inflightFetch, fetchSteamGamesData, and
steamGamesDataCache usage unchanged.

Expand Down Expand Up @@ -288,7 +334,8 @@ export async function getSteamGamesStats(): Promise<{
cacheStatus: 'miss',
lastUpdated: lastUpdated || undefined,
}
} catch {
} catch (error) {
console.error('[steamGameSearch] getSteamGamesStats failed to load data:', error)
return {
totalGames: 0,
cacheStatus: 'empty',
Expand Down
Loading