diff --git a/src/app/services/tournament-auth.service.ts b/src/app/services/tournament-auth.service.ts new file mode 100644 index 0000000..c84a7d7 --- /dev/null +++ b/src/app/services/tournament-auth.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; + +interface RegisterResponse { + id: string; + token: string; +} + +@Injectable({ providedIn: 'root' }) +export class TournamentAuthService { + private readonly inflight = new Map>(); + + async getToken(serverUrl: string): Promise { + const key = this.cacheKey(serverUrl); + const cached = localStorage.getItem(key); + if (cached) return cached; + + const existing = this.inflight.get(key); + if (existing) return existing; + + const promise = this.register(serverUrl) + .then(token => { + localStorage.setItem(key, token); + this.inflight.delete(key); + return token; + }) + .catch(err => { + this.inflight.delete(key); + throw err; + }); + + this.inflight.set(key, promise); + return promise; + } + + clearToken(serverUrl: string): void { + localStorage.removeItem(this.cacheKey(serverUrl)); + } + + private async register(serverUrl: string): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const localName = localStorage.getItem('username') ?? 'viewer'; + const res = await fetch(`${base}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `${localName}-watch`, isBot: false }) + }); + if (!res.ok) throw new Error(`tournament-server register failed: ${res.status}`); + const body = (await res.json()) as RegisterResponse; + return body.token; + } + + private cacheKey(serverUrl: string): string { + return `tournament-token:${serverUrl.replace(/\/+$/, '')}`; + } +} diff --git a/src/app/services/tournament-stream.service.ts b/src/app/services/tournament-stream.service.ts index d6458bd..6fae402 100644 --- a/src/app/services/tournament-stream.service.ts +++ b/src/app/services/tournament-stream.service.ts @@ -1,36 +1,38 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models'; +import { TournamentAuthService } from './tournament-auth.service'; @Injectable({ providedIn: 'root' }) export class TournamentStreamService { + private readonly auth = inject(TournamentAuthService); + streamTournament(serverUrl: string, tournamentId: string): Observable { return this.ndjson( - this.url(serverUrl, `/api/tournament/${tournamentId}/stream`) + serverUrl, + `/api/tournament/${tournamentId}/stream` ); } streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable { return this.ndjson( - this.url(serverUrl, `/api/tournament/${tournamentId}/game/${gameId}/stream`) + serverUrl, + `/api/tournament/${tournamentId}/game/${gameId}/stream` ); } - private url(base: string, path: string): string { + private fullUrl(base: string, path: string): string { if (!base) return path; return `${base.replace(/\/+$/, '')}${path}`; } - private ndjson(url: string): Observable { + private ndjson(serverUrl: string, path: string): Observable { return new Observable(subscriber => { const controller = new AbortController(); - const token = localStorage.getItem('token'); - const headers: Record = { Accept: 'application/x-ndjson' }; - if (token) headers['Authorization'] = `Bearer ${token}`; (async () => { try { - const res = await fetch(url, { headers, signal: controller.signal }); + const res = await this.openWithRetry(serverUrl, path, controller.signal); if (!res.ok || !res.body) { subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`)); return; @@ -63,4 +65,20 @@ export class TournamentStreamService { return () => controller.abort(); }); } + + private async openWithRetry(serverUrl: string, path: string, signal: AbortSignal): Promise { + const url = this.fullUrl(serverUrl, path); + const token = await this.auth.getToken(serverUrl); + const res = await fetch(url, { + headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${token}` }, + signal, + }); + if (res.status !== 401) return res; + this.auth.clearToken(serverUrl); + const fresh = await this.auth.getToken(serverUrl); + return fetch(url, { + headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${fresh}` }, + signal, + }); + } }