diff --git a/package-lock.json b/package-lock.json index 32b4bb5..e032211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5863,6 +5863,19 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" }, + "node_modules/@nestjs/event-emitter": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.3.tgz", + "integrity": "sha512-Pt7KAERrgK0OjvarSI3wfVhwZ8X1iLq1lXuodyRe+Zx3aLLP7fraFUHirASbFkB6KIQ1Zj+gZ1g8a9eu4GfFhw==", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/jwt": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", @@ -11555,6 +11568,11 @@ "integrity": "sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ==", "dev": true }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -23430,6 +23448,7 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^2.0.3", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/sequelize": "^10.0.0", diff --git a/packages/bridge-server/package.json b/packages/bridge-server/package.json index 63ba78d..340ebeb 100644 --- a/packages/bridge-server/package.json +++ b/packages/bridge-server/package.json @@ -22,6 +22,7 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^2.0.3", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/sequelize": "^10.0.0", diff --git a/packages/bridge-server/src/app.module.ts b/packages/bridge-server/src/app.module.ts index f221cca..0b625f4 100644 --- a/packages/bridge-server/src/app.module.ts +++ b/packages/bridge-server/src/app.module.ts @@ -3,6 +3,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { MulterModule } from '@nestjs/platform-express' +import { EventEmitterModule } from '@nestjs/event-emitter'; import { SequelizeModule } from '@nestjs/sequelize'; import { AuthModule } from './auth/auth.module'; @@ -22,6 +23,8 @@ import { RacesModule } from './races/races.module'; import { RacersModule } from './racers/racers.module'; import { SeasonStandingsModule } from './season-standings/season-standings.module'; import { UploadModule } from './upload/upload.module'; +import { SseService } from './sse/sse.service'; +import { SseModule } from './sse/sse.module'; @Module({ imports: [ @@ -31,6 +34,22 @@ import { UploadModule } from './upload/upload.module'; autoLoadModels: true, synchronize: true, }), + EventEmitterModule.forRoot({ + // set this to `true` to use wildcards + wildcard: false, + // the delimiter used to segment namespaces + delimiter: '.', + // set this to `true` if you want to emit the newListener event + newListener: false, + // set this to `true` if you want to emit the removeListener event + removeListener: false, + // the maximum amount of listeners that can be assigned to an event + maxListeners: 10, + // show event name in memory leak message when more than maximum amount of listeners is assigned + verboseMemoryLeak: false, + // disable throwing uncaughtException if an error event is emitted and it has no listeners + ignoreErrors: false, + }), AuthModule, UsersModule, MulterModule.register({ @@ -42,9 +61,10 @@ import { UploadModule } from './upload/upload.module'; RacersModule, SeasonStandingsModule, UploadModule, + SseModule, ], controllers: [AppController, SeasonsController, UploadController], - providers: [AppService, UsersService, RacersService, RacesService, SeasonStandingsService, RaceResultsService], + providers: [AppService, UsersService, RacersService, RacesService, SeasonStandingsService, RaceResultsService, SseService], }) export class AppModule { configure(consumer: MiddlewareConsumer) { diff --git a/packages/bridge-server/src/race-results/race-result-created.event.ts b/packages/bridge-server/src/race-results/race-result-created.event.ts new file mode 100644 index 0000000..557027a --- /dev/null +++ b/packages/bridge-server/src/race-results/race-result-created.event.ts @@ -0,0 +1,4 @@ +export class RaceResultCreated { + raceId: number; + racerId: number; +} \ No newline at end of file diff --git a/packages/bridge-server/src/race-results/race-results.controller.ts b/packages/bridge-server/src/race-results/race-results.controller.ts index cd37dea..3e772a5 100644 --- a/packages/bridge-server/src/race-results/race-results.controller.ts +++ b/packages/bridge-server/src/race-results/race-results.controller.ts @@ -1,14 +1,17 @@ -import { Controller, Get, Header, Param, Res, StreamableFile } from '@nestjs/common'; +import { Controller, Get, Header, Inject, Param, Req, Res, StreamableFile, forwardRef } from '@nestjs/common'; import { createReadStream } from 'fs'; import { join } from 'path'; -import type { Response } from 'express'; +import type { Request, Response } from 'express'; import { RaceResultsService } from './race-results.service'; +import { SseService } from 'src/sse/sse.service'; @Controller('race-results') export class RaceResultsController { constructor( - private raceResultsService: RaceResultsService + private raceResultsService: RaceResultsService, + @Inject(forwardRef(() => SseService)) + private sseService: SseService, ) {} @@ -32,4 +35,16 @@ export class RaceResultsController { const file = createReadStream(join(process.cwd(), result.replayPath)); file.pipe(res); } + + + @Get('/sub') + subscribe(@Req() req: Request, @Res() res: Response) { + const headers = { + 'Content-Type': 'text/event-stream', + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache' + }; + res.writeHead(200, headers); + this.sseService.createClient('race-results.updated', req, res); + } } diff --git a/packages/bridge-server/src/race-results/race-results.module.ts b/packages/bridge-server/src/race-results/race-results.module.ts index ef2849b..aeb8d59 100644 --- a/packages/bridge-server/src/race-results/race-results.module.ts +++ b/packages/bridge-server/src/race-results/race-results.module.ts @@ -1,11 +1,15 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { RaceResult } from './race-result.model'; import { RaceResultsService } from './race-results.service'; import { RaceResultsController } from './race-results.controller'; +import { SseModule } from 'src/sse/sse.module'; @Module({ - imports: [SequelizeModule.forFeature([RaceResult])], + imports: [ + SequelizeModule.forFeature([RaceResult]), + forwardRef(() => SseModule) + ], providers: [RaceResultsService], exports: [SequelizeModule, RaceResultsService], controllers: [RaceResultsController] diff --git a/packages/bridge-server/src/race-results/race-results.service.ts b/packages/bridge-server/src/race-results/race-results.service.ts index 64c490a..be6e465 100644 --- a/packages/bridge-server/src/race-results/race-results.service.ts +++ b/packages/bridge-server/src/race-results/race-results.service.ts @@ -3,13 +3,16 @@ import { InjectModel } from '@nestjs/sequelize'; import { RaceResult } from './race-result.model'; import { Sequelize } from 'sequelize-typescript'; import { Racer } from 'src/racers/racer.model'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { RaceResultCreated } from './race-result-created.event'; @Injectable() export class RaceResultsService { constructor( @InjectModel(RaceResult) private raceResultModel: typeof RaceResult, - private sequelize: Sequelize + private sequelize: Sequelize, + private eventEmitter: EventEmitter2 ) {} @@ -33,7 +36,7 @@ export class RaceResultsService { try { await this.sequelize.transaction( async t => { const transactionHost = { transaction: t }; - await this.raceResultModel.create({ + let newResult = await this.raceResultModel.create({ raceId: raceId, racerId: racerId, replayPath: replayPath, @@ -41,7 +44,16 @@ export class RaceResultsService { }, transactionHost ); - }); + + let event = new RaceResultCreated(); + event.raceId = raceId; + event.racerId = racerId; + + this.eventEmitter.emit( + 'race-result.created', + event, + ); + }); } catch (error) { } diff --git a/packages/bridge-server/src/sse/sse.module.ts b/packages/bridge-server/src/sse/sse.module.ts new file mode 100644 index 0000000..eda852c --- /dev/null +++ b/packages/bridge-server/src/sse/sse.module.ts @@ -0,0 +1,12 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SseService } from './sse.service'; +import { RaceResultsModule } from 'src/race-results/race-results.module'; + +@Module({ + imports:[ + forwardRef(() => RaceResultsModule) + ], + providers: [SseService], + exports: [SseService], + }) +export class SseModule {} diff --git a/packages/bridge-server/src/sse/sse.service.spec.ts b/packages/bridge-server/src/sse/sse.service.spec.ts new file mode 100644 index 0000000..1f6f7ea --- /dev/null +++ b/packages/bridge-server/src/sse/sse.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SseService } from './sse.service'; + +describe('SseService', () => { + let service: SseService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SseService], + }).compile(); + + service = module.get(SseService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/bridge-server/src/sse/sse.service.ts b/packages/bridge-server/src/sse/sse.service.ts new file mode 100644 index 0000000..2bce7a7 --- /dev/null +++ b/packages/bridge-server/src/sse/sse.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Response, Request } from 'express'; +import { RaceResultCreated } from 'src/race-results/race-result-created.event'; +import { RaceResultsService } from 'src/race-results/race-results.service'; + +export class SSEClient { + id: number; + connection: Response; +} + +@Injectable() +export class SseService { + clients: Map; + + constructor( + private raceResultsService: RaceResultsService + ) + { + this.clients = new Map; + } + + createClient(event: string, req: Request, res: Response) { + if(this.clients.has(event) == false) { + this.clients.set(event, []); + } + + let newClient = new SSEClient(); + newClient.connection = res; + newClient.id = Date.now(); // replace this. + + this.clients.get(event).push(newClient); + const clientId = newClient.id; + + req.on('close', () => { + console.log(`${clientId} Connection closed`); + for(let events of this.clients) + { + events[1] = events[1].filter(client => client.id !== clientId); + } + }) + } + + @OnEvent('race-result.created') + async handleRaceResultcreated(payload: RaceResultCreated) { + let updateResults = await this.raceResultsService.getFastestTimesForRace(payload.raceId); + this.raiseEvent('race-results.updated', {raceId: payload.raceId, newResults: updateResults}); + } + + raiseEvent(event:string, data:any) { + if(this.clients.has(event) == false) { + console.log("no clients for " + event) + return; + } + + let data_packet = `data: ${JSON.stringify(data)}\n\n`; + console.log(data_packet); + + let clients = this.clients.get(event); + for( let client of clients) { + client.connection.write(data_packet); + } + } +} diff --git a/packages/bridge-server/src/upload/upload.service.ts b/packages/bridge-server/src/upload/upload.service.ts index af5f2a6..e182233 100644 --- a/packages/bridge-server/src/upload/upload.service.ts +++ b/packages/bridge-server/src/upload/upload.service.ts @@ -44,6 +44,10 @@ export class UploadService { let race = await this.racesService.findOneBySeasonAndMapUID(body.seasonId, replay.mapUID) console.log(race); + if(race == undefined){ + console.log("No race for " + replay.mapUID); + } + if(race.endDate.getTime() < Date.now()) { console.log("Too Late!") return; diff --git a/packages/bridge-ui/src/app/components/race-details/race-details.component.ts b/packages/bridge-ui/src/app/components/race-details/race-details.component.ts index 4d8f47e..c21b566 100644 --- a/packages/bridge-ui/src/app/components/race-details/race-details.component.ts +++ b/packages/bridge-ui/src/app/components/race-details/race-details.component.ts @@ -64,6 +64,8 @@ export class RaceDetailsComponent implements AfterViewInit { @ViewChild('minutes') minutes?: ElementRef; @ViewChild('seconds') seconds?: ElementRef; + events?: EventSource; + constructor( private raceResultService: RaceResultService, private racesService: RacesService, @@ -88,19 +90,27 @@ export class RaceDetailsComponent implements AfterViewInit { this.racesService.getRaceResults(this.race.id).subscribe(data => { console.log(data) - this.raceResults = data; + this.updateRaceResults(data); + }) + + this.events = new EventSource("http://localhost:3000/race-results/sub"); + this.events.onmessage = (event) => { + const parsedData = JSON.parse(event.data); + console.log(parsedData); - for(let result of this.raceResults){ - this.sortedResults.push(result) + if(this.race != undefined && parsedData.raceId != this.race.id){ + return; } - - this.sortedResults.sort((a, b) => { - if( a.time == b.time ) { return 0 }; - if( a.time < b.time ) { return -1}; - return 1; - }) - this.sortedResults = [...this.sortedResults]; - }) + + this.updateRaceResults(parsedData.newResults); + } + } + + ngOnDestroy() { + if( this.race == undefined) { + return; + } + this.events?.close(); } ngAfterViewInit(): void { @@ -167,4 +177,20 @@ export class RaceDetailsComponent implements AfterViewInit { link.remove(); }); } + + updateRaceResults( data: any ) { + this.raceResults = data; + + let sorted = [] + for(let result of this.raceResults){ + sorted.push(result) + } + + sorted.sort((a, b) => { + if( a.time == b.time ) { return 0 }; + if( a.time < b.time ) { return -1}; + return 1; + }) + this.sortedResults = [...sorted]; + } } diff --git a/packages/bridge-ui/src/app/components/upload-replay-dialog/upload-replay-dialog.component.ts b/packages/bridge-ui/src/app/components/upload-replay-dialog/upload-replay-dialog.component.ts index 0b265d4..b12f447 100644 --- a/packages/bridge-ui/src/app/components/upload-replay-dialog/upload-replay-dialog.component.ts +++ b/packages/bridge-ui/src/app/components/upload-replay-dialog/upload-replay-dialog.component.ts @@ -65,8 +65,6 @@ export class UploadReplayDialogComponent { let local = this.seasonId; formData.append("seasonId", local); const upload$ = this.replaysService.uploadReplay(formData); - upload$.subscribe(data => { - window.location.reload(); - }); + upload$.subscribe(); } } diff --git a/packages/bridge-ui/src/app/services/races.service.ts b/packages/bridge-ui/src/app/services/races.service.ts index 9493a19..085c596 100644 --- a/packages/bridge-ui/src/app/services/races.service.ts +++ b/packages/bridge-ui/src/app/services/races.service.ts @@ -35,4 +35,12 @@ export class RacesService { create(mapUID: string, startDate: Date, endDate: Date, seasonId: number) { return this.httpClient.post(this.serverEndpointService.getCurrentEndpoint()+"races", { mapUID: mapUID, startDate: startDate, endDate: endDate, seasonId: seasonId}); } + + subscribeForUpdates(raceId: string) { + + } + + unsubscribeForUpdates(raceId: string) { + + } }