From 2ac8e505f6206fba4112d65539087b3207bfae82 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Fri, 21 Nov 2025 05:04:00 -0500 Subject: [PATCH] Refactor speed test functionality and restructure codebase * Removed the old SpeedTestApiRoutes and RandomData classes to streamline the architecture. * Introduced SpeedTestController to handle API requests and integrate with the new service layer. * Added RandomDataGenerator for generating random data, improving separation of concerns. * Created interfaces for speed test operations, enhancing type safety and maintainability. * Updated InMemorySpeedTestRepository to manage sessions with improved timeout handling. --- frontend.html | 1 - src/api/SpeedTestApiRoutes.ts | 152 ------------------ src/controllers/SpeedTestController.ts | 86 ++++++++++ src/data/RandomData.ts | 33 ---- src/data/RandomDataGenerator.ts | 20 +++ src/interfaces/ISpeedTest.ts | 17 ++ src/models/SpeedTestModels.ts | 32 ++++ .../InMemorySpeedTestRepository.ts | 40 +++-- 8 files changed, 185 insertions(+), 196 deletions(-) delete mode 100644 src/api/SpeedTestApiRoutes.ts create mode 100644 src/controllers/SpeedTestController.ts delete mode 100644 src/data/RandomData.ts create mode 100644 src/data/RandomDataGenerator.ts create mode 100644 src/interfaces/ISpeedTest.ts create mode 100644 src/models/SpeedTestModels.ts diff --git a/frontend.html b/frontend.html index a7ca3e6..956e2c2 100644 --- a/frontend.html +++ b/frontend.html @@ -171,7 +171,6 @@ - diff --git a/src/api/SpeedTestApiRoutes.ts b/src/api/SpeedTestApiRoutes.ts deleted file mode 100644 index 1669d18..0000000 --- a/src/api/SpeedTestApiRoutes.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type {Server as BunServer} from 'bun'; -import type {BunMethodRoutes} from '../net/http/BunHttpServer'; -import type {BunRequest} from 'bun'; -import {LoggerFactory} from '@techniker-me/logger'; -import {RandomData} from '../data/RandomData'; -import {createHash} from 'node:crypto'; - -const defaultSize = 1024 * 1024 * 10; // 10MB -const defaultChunkSize = 1024 * 1024; // 1MB -const defaultHashAlgorithm = 'sha256'; -const defaultHashEncoding = 'base64'; -const defaultContentType = 'application/octet-stream'; -const defaultTimeoutDuration = 10000; - -type RequestId = string; -type RequestData = { - size: number; - hash: string; - data: DataView; - hashAlgorithm: string; - hashEncoding: string; - chunkSize: number; - timeoutId: NodeJS.Timeout; - sentDataAt: number; -}; - - -export class SpeedTestApiRoutes { - private readonly _logger = LoggerFactory.getLogger('SpeedTestApiRoutes'); - private readonly _openRequests: Record = {}; - - public getRoutes(): Record { - return { - '/data': { - GET: this.getData.bind(this), - POST: this.postData.bind(this), - PUT: this.methodNotAllowed.bind(this), - DELETE: this.methodNotAllowed.bind(this), - PATCH: this.methodNotAllowed.bind(this), - OPTIONS: this.methodNotAllowed.bind(this) - } - }; - } - - private methodNotAllowed(): Promise { - return Promise.resolve(new Response(JSON.stringify({error: 'Method not allowed'}), {status: 405, headers: {'Content-Type': 'application/json'}})); - } - - private async postData(request: BunRequest, _server: BunServer): Promise { - const url = new URL(request.url); - const requestId = url.searchParams.get('requestId'); - if (!requestId) { - return new Response(JSON.stringify({error: 'Request ID is required'}), {status: 400, headers: {'Content-Type': 'application/json'}}); - } - - const requestData = this._openRequests[requestId]; - if (!requestData) { - return new Response(JSON.stringify({error: 'Request not found or expired'}), {status: 404, headers: {'Content-Type': 'application/json'}}); - } - - try { - // Read the request body as ArrayBuffer - const receivedData = new Uint8Array(await request.arrayBuffer()); - const finishedReceivingDataAt = Date.now(); - - // Calculate timing metrics - const totalTime = finishedReceivingDataAt - requestData.sentDataAt; - const dataRate = (requestData.size / totalTime) * 1000; // bytes per second - - // Validate data size - if (receivedData.length !== requestData.size) { - clearTimeout(requestData.timeoutId); - delete this._openRequests[requestId]; - return new Response(JSON.stringify({ - error: 'Data size mismatch', - expected: requestData.size, - received: receivedData.length - }), {status: 400, headers: {'Content-Type': 'application/json'}}); - } - - // Validate data integrity - const receivedHash = createHash(requestData.hashAlgorithm).update(receivedData).digest(requestData.hashEncoding as any); - const isValid = receivedHash === requestData.hash; - - // Clean up - clearTimeout(requestData.timeoutId); - delete this._openRequests[requestId]; - - const result = { - requestId, - valid: isValid, - expectedHash: requestData.hash, - receivedHash, - size: requestData.size, - totalTimeMs: totalTime, - dataRateBps: dataRate, - dataRateMbps: dataRate * 8 / 1000000, // Convert to Mbps - timestamp: new Date().toISOString() - }; - - this._logger.info('Speedtest completed: valid=[%s], size=[%d] bytes, rate=[%s] Mbps, time=[%d]ms', - isValid, requestData.size, result.dataRateMbps.toFixed(2), totalTime); - - return new Response(JSON.stringify(result), { - status: isValid ? 200 : 400, - headers: {'Content-Type': 'application/json'} - }); - - } catch (error) { - this._logger.error('Error processing speedtest data: [%s]', error); - clearTimeout(requestData.timeoutId); - delete this._openRequests[requestId]; - return new Response(JSON.stringify({error: 'Failed to process request'}), {status: 500, headers: {'Content-Type': 'application/json'}}); - } - } - - private getData(request: BunRequest, _server: BunServer): Promise { - const url = new URL(request.url); - const size = parseInt(url.searchParams.get('size') ?? defaultSize.toString()); - const requestId = crypto.randomUUID(); - - this._logger.info('Generating random data: size=[%d] bytes, requestId=[%s]', size, requestId); - - const randomData = new RandomData(size); - - // Store request data for POST validation - const requestData: RequestData = this._openRequests[requestId] = { - size: size, - data: randomData.data, - hash: randomData.hash, - hashAlgorithm: url.searchParams.get('hashAlgorithm') ?? defaultHashAlgorithm, - hashEncoding: url.searchParams.get('hashEncoding') ?? defaultHashEncoding, - chunkSize: parseInt(url.searchParams.get('chunkSize') ?? defaultChunkSize.toString()), - sentDataAt: Date.now(), - timeoutId: setTimeout(() => { - delete this._openRequests[requestId]; - }, parseInt(url.searchParams.get('timeout') ?? defaultTimeoutDuration.toString())), - }; - - const headers = new Headers(); - headers.set('Content-Type', defaultContentType); - headers.set('Content-Length', randomData.data.byteLength.toString()); - headers.set('Content-Hash', randomData.hash); - headers.set('Content-Hash-Algorithm', defaultHashAlgorithm); - headers.set('Content-Hash-Encoding', defaultHashEncoding); - headers.set('Content-Chunk-Size', defaultChunkSize.toString()); - headers.set('X-Request-ID', requestId); - - // Return the actual random data as binary - return Promise.resolve(new Response(randomData.data, {status: 200, headers})); - } -} diff --git a/src/controllers/SpeedTestController.ts b/src/controllers/SpeedTestController.ts new file mode 100644 index 0000000..cbc0f63 --- /dev/null +++ b/src/controllers/SpeedTestController.ts @@ -0,0 +1,86 @@ +import type {Server as BunServer, BunRequest} from 'bun'; +import type {BunMethodRoutes} from '../net/http/BunHttpServer'; +import {LoggerFactory} from '@techniker-me/logger'; +import type { ISpeedTestService } from '../interfaces/ISpeedTest'; + +export class SpeedTestController { + private readonly _logger = LoggerFactory.getLogger('SpeedTestController'); + private readonly _service: ISpeedTestService; + + private readonly defaultSize = 1024 * 1024 * 10; // 10MB + + constructor(service: ISpeedTestService) { + this._service = service; + } + + public getRoutes(): Record { + return { + '/data': { + GET: this.getData.bind(this), + POST: this.postData.bind(this), + PUT: this.methodNotAllowed.bind(this), + DELETE: this.methodNotAllowed.bind(this), + PATCH: this.methodNotAllowed.bind(this), + OPTIONS: this.methodNotAllowed.bind(this) + } + }; + } + + private methodNotAllowed(): Promise { + return Promise.resolve(new Response(JSON.stringify({error: 'Method not allowed'}), {status: 405, headers: {'Content-Type': 'application/json'}})); + } + + private async postData(request: BunRequest, _server: BunServer): Promise { + const url = new URL(request.url); + const requestId = url.searchParams.get('requestId'); + if (!requestId) { + return new Response(JSON.stringify({error: 'Request ID is required'}), {status: 400, headers: {'Content-Type': 'application/json'}}); + } + + try { + const receivedData = new Uint8Array(await request.arrayBuffer()); + const result = await this._service.validateUpload(requestId, receivedData); + + return new Response(JSON.stringify(result), { + status: result.valid ? 200 : 400, + headers: {'Content-Type': 'application/json'} + }); + + } catch (error: any) { + this._logger.error('Error processing speedtest data: [%s]', error); + + // Distinguish between user error (404, 400) and server error (500) + // Simplification: if message is known (from service), return 400 or 404. + let status = 500; + if (error.message === 'Request not found or expired') status = 404; + if (error.message.startsWith('Data size mismatch')) status = 400; + + return new Response(JSON.stringify({error: error.message || 'Failed to process request'}), {status, headers: {'Content-Type': 'application/json'}}); + } + } + + private async getData(request: BunRequest, _server: BunServer): Promise { + const url = new URL(request.url); + const size = parseInt(url.searchParams.get('size') ?? this.defaultSize.toString()); + const timeout = url.searchParams.get('timeout') ? parseInt(url.searchParams.get('timeout')!) : undefined; + + try { + const { session, data } = await this._service.generateTestData(size, { timeout }); + + const headers = new Headers(); + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Content-Length', session.size.toString()); + headers.set('Content-Hash', session.hash); + headers.set('Content-Hash-Algorithm', session.hashAlgorithm); + headers.set('Content-Hash-Encoding', session.hashEncoding); + headers.set('Content-Chunk-Size', session.chunkSize.toString()); + headers.set('X-Request-ID', session.requestId); + + return new Response(data, {status: 200, headers}); + } catch (error: any) { + this._logger.error('Error generating data: [%s]', error); + return new Response(JSON.stringify({error: 'Failed to generate data'}), {status: 500, headers: {'Content-Type': 'application/json'}}); + } + } +} + diff --git a/src/data/RandomData.ts b/src/data/RandomData.ts deleted file mode 100644 index c4ce29c..0000000 --- a/src/data/RandomData.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {LoggerFactory} from '@techniker-me/logger'; -import {getRandomValues, createHash} from 'node:crypto'; - -type Bytes = number; - -export class RandomData { - private readonly _logger = LoggerFactory.getLogger('RandomData'); - private readonly _size: Bytes; - private readonly _chunkSize: Bytes; - private readonly _data: Uint8Array; - private readonly _dataHashAlgorithm: string; - private _dataHash: string; - - constructor(size: Bytes) { - this._size = size; - this._data = getRandomValues(new Uint8Array(size)); - this._dataHashAlgorithm = 'sha256'; - this._chunkSize = 1024 * 1024; - this._dataHash = this.generateDataHash(this._data); - } - - get hash(): string { - return this._dataHash; - } - - get data(): DataView { - return new DataView(this._data.buffer); - } - - private generateDataHash(data: Uint8Array): string { - return createHash(this._dataHashAlgorithm).update(data).digest('base64'); - } -} diff --git a/src/data/RandomDataGenerator.ts b/src/data/RandomDataGenerator.ts new file mode 100644 index 0000000..f321c49 --- /dev/null +++ b/src/data/RandomDataGenerator.ts @@ -0,0 +1,20 @@ +import {LoggerFactory} from '@techniker-me/logger'; +import {getRandomValues, createHash} from 'node:crypto'; +import type { IDataGenerator } from '../interfaces/ISpeedTest'; + +export class RandomDataGenerator implements IDataGenerator { + private readonly _logger = LoggerFactory.getLogger('RandomDataGenerator'); + + public generate(size: number): { data: Uint8Array; hash: string; hashAlgorithm: string } { + const data = getRandomValues(new Uint8Array(size)); + const hashAlgorithm = 'sha256'; + const hash = this.generateDataHash(data, hashAlgorithm); + + return { data, hash, hashAlgorithm }; + } + + private generateDataHash(data: Uint8Array, algorithm: string): string { + return createHash(algorithm).update(data).digest('base64'); + } +} + diff --git a/src/interfaces/ISpeedTest.ts b/src/interfaces/ISpeedTest.ts new file mode 100644 index 0000000..125160c --- /dev/null +++ b/src/interfaces/ISpeedTest.ts @@ -0,0 +1,17 @@ +import type { SpeedTestSession, SpeedTestResult, RequestId } from '../models/SpeedTestModels'; + +export interface ISpeedTestRepository { + saveSession(session: SpeedTestSession, ttlMs: number): Promise; + getSession(requestId: RequestId): Promise; + deleteSession(requestId: RequestId): Promise; +} + +export interface IDataGenerator { + generate(size: number): { data: Uint8Array; hash: string; hashAlgorithm: string }; +} + +export interface ISpeedTestService { + generateTestData(size: number, options?: { hashAlgorithm?: string; hashEncoding?: string; chunkSize?: number; timeout?: number }): Promise<{ session: SpeedTestSession; data: Uint8Array }>; + validateUpload(requestId: RequestId, receivedData: Uint8Array): Promise; +} + diff --git a/src/models/SpeedTestModels.ts b/src/models/SpeedTestModels.ts new file mode 100644 index 0000000..98767b0 --- /dev/null +++ b/src/models/SpeedTestModels.ts @@ -0,0 +1,32 @@ + +export type RequestId = string; + +export interface SpeedTestSession { + requestId: RequestId; + size: number; + hash: string; + hashAlgorithm: string; + hashEncoding: string; + chunkSize: number; + sentDataAt: number; + data?: DataView; // Optional because we might not want to store the full data in memory if we change strategy, but for now we do. + timeoutId?: NodeJS.Timeout; // Timeout is implementation specific to the repository/manager, but keeping it here for now as part of the session state. +} + +export interface SpeedTestResult { + requestId: RequestId; + valid: boolean; + expectedHash: string; + receivedHash: string; + size: number; + totalTimeMs: number; + dataRateBps: number; + dataRateMbps: number; + timestamp: string; +} + +export interface SpeedTestError { + error: string; + details?: any; +} + diff --git a/src/repositories/InMemorySpeedTestRepository.ts b/src/repositories/InMemorySpeedTestRepository.ts index ba5e8b4..c269179 100644 --- a/src/repositories/InMemorySpeedTestRepository.ts +++ b/src/repositories/InMemorySpeedTestRepository.ts @@ -1,19 +1,39 @@ -import { ISpeedTestRepository } from '../interfaces/ISpeedTestRepository'; -import { SpeedTestSession } from '../models/SpeedTestSession'; +import type { ISpeedTestRepository } from '../interfaces/ISpeedTest'; +import type { SpeedTestSession, RequestId } from '../models/SpeedTestModels'; export class InMemorySpeedTestRepository implements ISpeedTestRepository { - private readonly _sessions: Record = {}; + private readonly _sessions: Map = new Map(); - public save(session: SpeedTestSession): void { - this._sessions[session.requestId] = session; + public async saveSession(session: SpeedTestSession, ttlMs: number): Promise { + this._sessions.set(session.requestId, session); + + // In a real DB, we'd set an expiry. Here we simulate with setTimeout. + // We also clear any existing timeout if we're updating (though for this app we probably won't update sessions much) + if (session.timeoutId) { + clearTimeout(session.timeoutId); + } + + const timeoutId = setTimeout(() => { + this._sessions.delete(session.requestId); + }, ttlMs); + + // Update session with new timeoutId so we can clear it if deleted manually + // We need to mutate or re-set. Since we passed by reference, we can mutate, + // but it's cleaner to update the map value if we treat it as immutable elsewhere. + // Ideally we shouldn't mutate the object passed in, but for this in-memory impl it's pragmatic. + // However, let's be careful. The session object in the map now has the timeout. + session.timeoutId = timeoutId; } - public findById(requestId: string): SpeedTestSession | undefined { - return this._sessions[requestId]; + public async getSession(requestId: RequestId): Promise { + return this._sessions.get(requestId) || null; } - public delete(requestId: string): void { - delete this._sessions[requestId]; + public async deleteSession(requestId: RequestId): Promise { + const session = this._sessions.get(requestId); + if (session?.timeoutId) { + clearTimeout(session.timeoutId); + } + this._sessions.delete(requestId); } } -