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.
This commit is contained in:
2025-11-21 05:04:00 -05:00
parent 21b9e52f40
commit 2ac8e505f6
8 changed files with 185 additions and 196 deletions

View File

@@ -171,7 +171,6 @@
<option value="262144000">250 MB</option> <option value="262144000">250 MB</option>
<option value="524288000">500 MB</option> <option value="524288000">500 MB</option>
<option value="1048576000">1 GB</option> <option value="1048576000">1 GB</option>
<option value="2621440000">2.5 GB</option>
</select> </select>
</div> </div>
<button id="startTest" onclick="startSpeedTest()">Start Speed Test</button> <button id="startTest" onclick="startSpeedTest()">Start Speed Test</button>

View File

@@ -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<RequestId, RequestData> = {};
public getRoutes(): Record<string, BunMethodRoutes> {
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<Response> {
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<unknown>): Promise<Response> {
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<unknown>): Promise<Response> {
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}));
}
}

View File

@@ -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<string, BunMethodRoutes> {
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<Response> {
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<unknown>): Promise<Response> {
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<unknown>): Promise<Response> {
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'}});
}
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -0,0 +1,17 @@
import type { SpeedTestSession, SpeedTestResult, RequestId } from '../models/SpeedTestModels';
export interface ISpeedTestRepository {
saveSession(session: SpeedTestSession, ttlMs: number): Promise<void>;
getSession(requestId: RequestId): Promise<SpeedTestSession | null>;
deleteSession(requestId: RequestId): Promise<void>;
}
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<SpeedTestResult>;
}

View File

@@ -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;
}

View File

@@ -1,19 +1,39 @@
import { ISpeedTestRepository } from '../interfaces/ISpeedTestRepository'; import type { ISpeedTestRepository } from '../interfaces/ISpeedTest';
import { SpeedTestSession } from '../models/SpeedTestSession'; import type { SpeedTestSession, RequestId } from '../models/SpeedTestModels';
export class InMemorySpeedTestRepository implements ISpeedTestRepository { export class InMemorySpeedTestRepository implements ISpeedTestRepository {
private readonly _sessions: Record<string, SpeedTestSession> = {}; private readonly _sessions: Map<RequestId, SpeedTestSession> = new Map();
public save(session: SpeedTestSession): void { public async saveSession(session: SpeedTestSession, ttlMs: number): Promise<void> {
this._sessions[session.requestId] = session; 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 { public async getSession(requestId: RequestId): Promise<SpeedTestSession | null> {
return this._sessions[requestId]; return this._sessions.get(requestId) || null;
} }
public delete(requestId: string): void { public async deleteSession(requestId: RequestId): Promise<void> {
delete this._sessions[requestId]; const session = this._sessions.get(requestId);
if (session?.timeoutId) {
clearTimeout(session.timeoutId);
}
this._sessions.delete(requestId);
} }
} }