fix: NCWF-2 bugs and desing fixes (#7)

Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-05-15 02:16:43 +02:00
parent 70a4debb40
commit c02414ea40
45 changed files with 3167 additions and 1277 deletions
+4 -11
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models';
@@ -42,16 +42,9 @@ export class AuthService {
email
})
.pipe(
tap((response) => {
localStorage.setItem('username', response.username);
localStorage.setItem('userId', response.id);
this.currentUserSubject.next({
id: response.id,
username: response.username,
rating: response.rating,
createdAt: response.createdAt
});
})
switchMap((response) =>
this.login(username, password).pipe(map(() => response))
)
);
}
@@ -61,4 +61,18 @@ export class ChallengeEventService {
removeChallenge(challengeId: string): void {
this.onChallengeRemoved(challengeId);
}
/**
* Replace the full incoming list (used by HTTP polling)
*/
setIncomingChallenges(challenges: Challenge[]): void {
this.incomingChallenges$.next(challenges);
}
/**
* Clear all incoming challenges (used on logout)
*/
clear(): void {
this.incomingChallenges$.next([]);
}
}
+68 -88
View File
@@ -1,135 +1,115 @@
import { Injectable, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ChallengeEventService } from './challenge-event.service';
import { Challenge } from '../models/challenge.models';
import { ChallengeService } from './challenge.service';
/**
* Service to handle WebSocket connections for challenge events
* Listens for incoming challenge notifications and emits them to ChallengeEventService
*/
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
/**
* Initialize WebSocket connection for challenge events
*/
connect(): void {
if (this.ws) {
return; // Already connected
}
if (this.ws) return;
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${wsProtocol}://${window.location.host}/ws/challenges`;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try {
this.ws = new WebSocket(wsUrl);
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('Challenge WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
this.handleMessage(event.data as string);
};
this.ws.onerror = (error) => {
console.error('Challenge WebSocket error:', error);
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
console.log('Challenge WebSocket disconnected');
this.ws = null;
this.attemptReconnect();
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
} catch {
this.attemptReconnect();
}
}
/**
* Close the WebSocket connection
*/
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Send a message through WebSocket
*/
send(message: any): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
const message = JSON.parse(data);
if (!message.type) {
return;
}
switch (message.type) {
case 'challenge.received':
if (message.challenge) {
this.challengeEventService.onChallengeReceived(message.challenge as Challenge);
}
break;
case 'challenge.accepted':
if (message.challenge) {
this.challengeEventService.onChallengeAccepted(message.challenge as Challenge);
}
break;
case 'challenge.declined':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
case 'challenge.expired':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
default:
console.debug('Unknown challenge message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}
/**
* Attempt to reconnect to WebSocket
*/
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnection attempts reached');
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
this.reconnectAttempts++;
console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
switch (message['type']) {
case 'CONNECTED':
break;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
}
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => { this.connect(); }, this.reconnectDelay);
}
}
+2 -3
View File
@@ -77,8 +77,7 @@ export class GameApiService {
}
streamGame(gameId: string): Observable<GameStreamEvent> {
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId);
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
return this.streamHandler.createGameStream(wsUrl, gameId);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
const STORAGE_KEY = 'nowchess.games';
const MAX_ENTRIES = 50;
interface GameEntry {
id: string;
addedAt: number;
}
@Injectable({ providedIn: 'root' })
export class GameHistoryService {
recordGame(gameId: string): void {
const entries = this.load().filter((e) => e.id !== gameId);
entries.unshift({ id: gameId, addedAt: Date.now() });
this.save(entries.slice(0, MAX_ENTRIES));
}
getGameIds(): string[] {
return this.load().map((e) => e.id);
}
removeGame(gameId: string): void {
this.save(this.load().filter((e) => e.id !== gameId));
}
private load(): GameEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as GameEntry[]) : [];
} catch {
return [];
}
}
private save(entries: GameEntry[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
}
+18 -84
View File
@@ -2,26 +2,14 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, ErrorEvent } from '../models/game.models';
const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' })
export class StreamHandlerService {
createGameStream(wsUrl: string, fallbackUrl: string, gameId: string): Observable<GameStreamEvent> {
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl);
const abortController = new AbortController();
let connected = false;
let fallbackActive = false;
const parseEvent = (raw: string): GameStreamEvent | null => {
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw) as GameStreamEvent;
} catch {
return null;
}
};
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
@@ -31,67 +19,18 @@ export class StreamHandlerService {
observer.next(errorEvent);
};
const startNdjsonFallback = async (): Promise<void> => {
if (fallbackActive) {
return;
}
fallbackActive = true;
console.log(`[StreamHandler] NDJSON fallback started for ${gameId}, URL:`, fallbackUrl);
try {
const response = await fetch(fallbackUrl, {
headers: { Accept: 'application/x-ndjson' },
signal: abortController.signal
});
if (!response.ok || !response.body) {
console.error(`[StreamHandler] NDJSON fetch failed: HTTP ${response.status}`);
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete();
return;
}
console.log(`[StreamHandler] NDJSON stream connected for ${gameId}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = parseEvent(line);
if (event) {
observer.next(event);
}
}
}
observer.complete();
} catch (error) {
if ((error as Error).name !== 'AbortError') {
emitErrorEvent((error as Error).message);
observer.error(error);
}
}
const failAndComplete = (reason: string): void => {
console.warn(`[StreamHandler] WebSocket failed for ${gameId}: ${reason}`);
emitErrorEvent(reason);
observer.complete();
};
// Set timeout to fallback if WebSocket doesn't connect quickly
const connectionTimeoutId = setTimeout(() => {
if (!connected && !fallbackActive) {
console.warn(`[StreamHandler] WebSocket timeout for ${gameId}, attempting NDJSON fallback`);
if (!connected) {
ws.close();
void startNdjsonFallback();
failAndComplete('WebSocket connection timed out — falling back to polling');
}
}, 3000);
}, WS_CONNECT_TIMEOUT_MS);
ws.onopen = () => {
connected = true;
@@ -101,35 +40,30 @@ export class StreamHandlerService {
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
const event = parseEvent(payload);
if (event) {
if (!payload.trim()) return;
try {
const event = JSON.parse(payload) as GameStreamEvent;
observer.next(event);
} catch {
// ignore malformed frames
}
};
ws.onerror = (error) => {
console.warn(`[StreamHandler] WebSocket error for ${gameId}:`, error);
ws.onerror = () => {
clearTimeout(connectionTimeoutId);
if (!connected && !fallbackActive) {
void startNdjsonFallback();
if (!connected) {
failAndComplete('WebSocket connection error — falling back to polling');
}
};
ws.onclose = () => {
clearTimeout(connectionTimeoutId);
console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`);
if (connected) {
// Connection was established but closed, stream is complete
observer.complete();
} else if (!fallbackActive) {
// Connection never established, try fallback
console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback();
}
};
return () => {
abortController.abort();
ws.close();
};
});