feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s

This commit is contained in:
2026-04-27 05:46:33 +02:00
parent 3858beb28e
commit 66c6f34cd3
52 changed files with 2120 additions and 113 deletions

View File

@@ -19,7 +19,8 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
- The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
@@ -39,4 +40,4 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
## Notes
- `dist/` and `../dist-server/` are generated output.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.

View File

@@ -12,6 +12,7 @@ export interface LinkPreviewConfig {
export interface ServerVariablesConfig {
klipyApiKey: string;
rawgApiKey: string;
releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
@@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeRawgApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
@@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
const normalized = {
...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
@@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return {
klipyApiKey: normalized.klipyApiKey,
rawgApiKey: normalized.rawgApiKey,
releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
@@ -169,6 +176,14 @@ export function getKlipyApiKey(): string {
return getVariablesConfig().klipyApiKey;
}
export function getRawgApiKey(): string {
if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) {
return process.env.RAWG_API_KEY.trim();
}
return getVariablesConfig().rawgApiKey;
}
export function hasKlipyApiKey(): boolean {
return getKlipyApiKey().length > 0;
}

View File

@@ -14,7 +14,8 @@ import {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
ServerBanEntity,
GameMatchMissEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import {
@@ -48,8 +49,25 @@ const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE);
// SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0';
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
let applicationDataSource: DataSource | undefined;
let saveQueue: Promise<void> = Promise.resolve();
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableSaveError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
}
function restoreFromBackup(reason: string): Uint8Array | undefined {
if (!fs.existsSync(DB_BACKUP)) {
@@ -159,18 +177,47 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
* then rename it over the real file. rename() is atomic on the same
* filesystem, so a crash mid-write can never leave a half-written DB.
*/
async function atomicSave(data: Uint8Array): Promise<void> {
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
await fsp.rename(tmpPath, DB_FILE);
return;
} catch (error) {
const delay = SAVE_RETRY_DELAYS_MS[attempt];
if (!isRetryableSaveError(error) || delay === undefined) {
throw error;
}
await wait(delay);
}
}
}
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
try {
await fsp.writeFile(tmpPath, Buffer.from(data));
await fsp.rename(tmpPath, DB_FILE);
await fsp.writeFile(tmpPath, snapshot);
await replaceDatabaseFile(tmpPath);
} catch (err) {
await fsp.unlink(tmpPath).catch(() => {});
throw err;
}
}
async function atomicSave(data: Uint8Array): Promise<void> {
const snapshot = Buffer.from(data);
const saveTask = saveQueue.then(
() => writeDatabaseSnapshot(snapshot),
() => writeDatabaseSnapshot(snapshot)
);
saveQueue = saveTask.catch(() => {});
return saveTask;
}
export function getDataSource(): DataSource {
if (!applicationDataSource?.isInitialized) {
throw new Error('DataSource not initialised');
@@ -202,7 +249,8 @@ export async function initDatabase(): Promise<void> {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
ServerBanEntity,
GameMatchMissEntity
],
migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -0,0 +1,22 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
@Entity('game_match_misses')
export class GameMatchMissEntity {
@PrimaryColumn('text')
processKey!: string;
@Column('text')
processName!: string;
@Column('integer')
missedAt!: number;
@Index()
@Column('integer')
expiresAt!: number;
}

View File

@@ -9,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity';
export { GameMatchMissEntity } from './GameMatchMissEntity';

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GameMatchMisses1000000000006 implements MigrationInterface {
name = 'GameMatchMisses1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "game_match_misses" (
"processKey" TEXT PRIMARY KEY NOT NULL,
"processName" TEXT NOT NULL,
"missedAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
ON "game_match_misses" ("expiresAt")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
}
}

View File

@@ -4,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -11,5 +12,6 @@ export const serverMigrations = [
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006
];

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { matchRunningGames } from '../services/game-matching.service';
const router = Router();
router.post('/match', async (req, res) => {
try {
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
res.json(result);
} catch (error) {
console.error('[Games] Failed to match running games', error);
res.status(500).json({ error: 'Failed to match running games' });
}
});
export default router;

View File

@@ -2,6 +2,7 @@ import { Express } from 'express';
import healthRouter from './health';
import klipyRouter from './klipy';
import linkMetadataRouter from './link-metadata';
import gamesRouter from './games';
import proxyRouter from './proxy';
import usersRouter from './users';
import serversRouter from './servers';
@@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void {
app.use('/api', healthRouter);
app.use('/api', klipyRouter);
app.use('/api', linkMetadataRouter);
app.use('/api/games', gamesRouter);
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter);

View File

@@ -0,0 +1,591 @@
import { getRawgApiKey } from '../config/variables';
import { getDataSource } from '../db/database';
import { GameMatchMissEntity } from '../entities';
export interface MatchedGame {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
processName: string;
}
export interface GameStoreLink {
id?: string;
name: string;
slug?: string;
domain?: string;
url: string;
}
interface CacheEntry {
expiresAt: number;
game: Omit<MatchedGame, 'processName'> | null;
}
interface RawgSearchResponse {
results?: RawgGameResult[];
}
interface RawgGameResult {
id?: number;
name?: string;
background_image?: string | null;
slug?: string;
stores?: RawgStoreEntry[] | null;
}
interface RawgStoreEntry {
url?: string | null;
store?: RawgStore | null;
}
interface RawgStore {
id?: number;
name?: string;
slug?: string;
domain?: string | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
interface GameMatchResult {
games: MatchedGame[];
rateLimited?: boolean;
}
interface RawgLookupBudget {
used: number;
windowStartedAt: number;
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
const MAX_INCOMING_PROCESSES = 256;
const MAX_CANDIDATE_PROCESSES = 24;
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
const MIN_SEARCH_QUERY_LENGTH = 4;
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus-daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome-shell',
'init',
'kernel_task',
'metoyou',
'nvidia-settings',
'node',
'npm',
'obs',
'powershell',
'pulseaudio',
'services',
'steam',
'steamwebhelper',
'system',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
itch: (query) => `https://itch.io/search?q=${query}`,
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
};
const STORE_SEARCH_ALIASES = new Map<string, string>([
['steam', 'steam'],
['store.steampowered.com', 'steam'],
['epic-games', 'epic-games'],
['store.epicgames.com', 'epic-games'],
['gog', 'gog'],
['www.gog.com', 'gog'],
['gog.com', 'gog'],
['itch', 'itch'],
['itch.io', 'itch'],
['xbox-store', 'xbox-store'],
['www.xbox.com', 'xbox-store'],
['xbox.com', 'xbox-store'],
['playstation-store', 'playstation-store'],
['store.playstation.com', 'playstation-store'],
['nintendo', 'nintendo'],
['www.nintendo.com', 'nintendo'],
['nintendo.com', 'nintendo'],
['apple-appstore', 'apple-appstore'],
['apps.apple.com', 'apple-appstore'],
['google-play', 'google-play'],
['play.google.com', 'google-play']
]);
const STORE_PRIORITY = new Map<string, number>([
['steam', 0],
['gog', 10],
['epic-games', 20],
['itch', 30],
['xbox-store', 80],
['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
export async function matchRunningGames(
processNames: unknown,
requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
const matches: MatchedGame[] = [];
const seenGameIds = new Set<string>();
const requesterKey = normalizeRequesterKey(requester);
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
let uncachedLookups = 0;
let rateLimited = false;
for (const { processName } of candidates) {
const cacheKey = normalizeCacheKey(processName);
const cached = getCachedGame(cacheKey);
if (cached !== undefined) {
appendMatch(matches, seenGameIds, processName, cached);
continue;
}
if (persistedMisses.has(cacheKey)) {
setCachedGame(cacheKey, null);
continue;
}
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
rateLimited = true;
continue;
}
if (!tryConsumeRawgLookup(requesterKey)) {
rateLimited = true;
continue;
}
uncachedLookups += 1;
const game = await resolveRawgGame(processName);
setCachedGame(cacheKey, game);
if (!game) {
await rememberPersistedMiss(cacheKey, processName);
}
appendMatch(matches, seenGameIds, processName, game);
}
return {
games: matches,
rateLimited: rateLimited || undefined
};
}
function normalizeProcessList(value: unknown): CandidateProcess[] {
if (!Array.isArray(value)) {
return [];
}
const processes = new Map<string, CandidateProcess>();
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
const processName = normalizeProcessName(entry);
if (processName) {
const cacheKey = normalizeCacheKey(processName);
if (!processes.has(cacheKey)) {
processes.set(cacheKey, {
processName,
score: scoreCandidateProcess(String(entry), processName)
});
}
}
}
return Array.from(processes.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
function normalizeProcessName(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
const normalized = value
.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = normalizeCacheKey(normalized);
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
function shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
function normalizeRequesterKey(value: unknown): string {
if (typeof value !== 'string') {
return 'anonymous';
}
const normalized = value.trim().toLowerCase();
return normalized || 'anonymous';
}
function tryConsumeRawgLookup(requesterKey: string): boolean {
const now = Date.now();
const existing = rawgLookupBudgets.get(requesterKey);
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
rawgLookupBudgets.set(requesterKey, {
used: 1,
windowStartedAt: now
});
return true;
}
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
return false;
}
existing.used += 1;
return true;
}
function scoreCandidateProcess(rawValue: string, processName: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
score += 4;
}
if (/\d/.test(processName)) {
score += 1;
}
if (processName.length >= 5 && processName.length <= 32) {
score += 2;
}
if (processName.includes(' ')) {
score -= 2;
}
return score;
}
function normalizeCacheKey(value: string): string {
return value.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
const cached = cache.get(cacheKey);
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
cache.delete(cacheKey);
return undefined;
}
return cached.game;
}
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
cache.set(cacheKey, {
expiresAt: Date.now() + CACHE_TTL_MS,
game
});
}
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
if (cacheKeys.length === 0) {
return new Set();
}
try {
const repository = getDataSource().getRepository(GameMatchMissEntity);
const now = Date.now();
await repository.createQueryBuilder()
.delete()
.where('expiresAt <= :now', { now })
.execute();
const rows = await repository.createQueryBuilder('miss')
.select('miss.processKey')
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
.andWhere('miss.expiresAt > :now', { now })
.getMany();
return new Set(rows.map((row) => row.processKey));
} catch {
return new Set();
}
}
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
try {
const now = Date.now();
await getDataSource().getRepository(GameMatchMissEntity)
.save({
processKey: cacheKey,
processName,
missedAt: now,
expiresAt: now + PERSISTED_MISS_TTL_MS
});
} catch {
return;
}
}
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
const apiKey = getRawgApiKey();
if (!apiKey) {
return null;
}
const query = buildSearchQuery(processName);
if (!query) {
return null;
}
const url = new URL(RAWG_SEARCH_URL);
url.searchParams.set('key', apiKey);
url.searchParams.set('search', query);
url.searchParams.set('search_precise', 'true');
url.searchParams.set('exclude_additions', 'true');
url.searchParams.set('page_size', '1');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return null;
}
const body = await response.json() as RawgSearchResponse;
const result = body.results?.[0];
if (!isAcceptableRawgMatch(query, result)) {
return null;
}
return {
id: String(result.id),
name: result.name.trim(),
iconUrl: result.background_image || undefined,
store: selectPreferredStore(result, result.name.trim())
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
const stores = Array.isArray(result.stores) ? result.stores : [];
const usableStores = stores
.map((entry) => buildStoreLink(entry, gameName))
.filter((store): store is GameStoreLink => !!store);
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
}
function getStorePriority(store: GameStoreLink): number {
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
?? store.name.trim().toLowerCase();
return STORE_PRIORITY.get(storeKey) ?? 50;
}
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
const store = entry.store;
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
return undefined;
}
const slug = typeof store.slug === 'string' && store.slug.trim()
? store.slug.trim().toLowerCase()
: undefined;
const domain = typeof store.domain === 'string' && store.domain.trim()
? store.domain.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '')
: undefined;
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
if (!url) {
return undefined;
}
return {
id: typeof store.id === 'number' ? String(store.id) : undefined,
name: store.name.trim(),
slug,
domain,
url
};
}
function normalizeExternalUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value.trim()) {
return undefined;
}
const trimmed = value.trim();
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: undefined;
}
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
const query = encodeURIComponent(gameName);
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
}
function buildSearchQuery(processName: string): string {
const query = processName
.replace(/\.exe$/i, '')
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
}
function isAcceptableRawgMatch(
query: string,
result: RawgGameResult | undefined
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
return false;
}
const queryKey = normalizeComparableText(query);
const nameKey = normalizeComparableText(result.name);
const slugKey = normalizeComparableText(result.slug ?? '');
const queryTokens = tokenizeComparableText(queryKey);
const nameTokens = tokenizeComparableText(nameKey);
const slugTokens = tokenizeComparableText(slugKey);
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
return false;
}
if (queryKey === nameKey || queryKey === slugKey) {
return true;
}
if (queryTokens.length === 1) {
const [queryToken] = queryTokens;
return queryToken.length >= 5
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
}
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
}
function normalizeComparableText(value: string): string {
return value.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokenizeComparableText(value: string): string[] {
return value.split(' ')
.filter((token) => token.length >= 2);
}
function appendMatch(
matches: MatchedGame[],
seenGameIds: Set<string>,
processName: string,
game: Omit<MatchedGame, 'processName'> | null
): void {
if (!game || seenGameIds.has(game.id)) {
return;
}
seenGameIds.add(game.id);
matches.push({
...game,
processName
});
}

View File

@@ -71,25 +71,6 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt;
// Close stale connections from the same identity AND the same connection
// scope so offer routing always targets the freshest socket (e.g. after
// page refresh). Connections with a *different* scope (= a different
// signal URL that happens to route to this server) are left untouched so
// multi-signal-URL setups don't trigger an eviction loop.
connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId
&& existing.oderId === newOderId
&& existing.connectionScope === newScope) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
try {
existing.ws.close();
} catch { /* already closing */ }
connectedUsers.delete(existingId);
}
});
user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));

View File

@@ -13,6 +13,8 @@ import {
} from './broadcast';
import { handleWebSocketMessage } from './handler';
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
/** How often to ping all connected clients (ms). */
const PING_INTERVAL_MS = 30_000;
/** Maximum time a client can go without a pong before we consider it dead (ms). */
@@ -89,12 +91,20 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
});
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
let message: IncomingWebSocketMessage;
await handleWebSocketMessage(connectionId, message);
try {
message = JSON.parse(data.toString()) as IncomingWebSocketMessage;
} catch (err) {
console.error('Invalid WebSocket message:', err);
return;
}
try {
await handleWebSocketMessage(connectionId, message);
} catch (err) {
console.error('WebSocket message handler failed:', err);
}
});