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:
@@ -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([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user