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:
@@ -171,7 +171,6 @@
|
||||
<option value="262144000">250 MB</option>
|
||||
<option value="524288000">500 MB</option>
|
||||
<option value="1048576000">1 GB</option>
|
||||
<option value="2621440000">2.5 GB</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="startTest" onclick="startSpeedTest()">Start Speed Test</button>
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
86
src/controllers/SpeedTestController.ts
Normal file
86
src/controllers/SpeedTestController.ts
Normal 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'}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
20
src/data/RandomDataGenerator.ts
Normal file
20
src/data/RandomDataGenerator.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
17
src/interfaces/ISpeedTest.ts
Normal file
17
src/interfaces/ISpeedTest.ts
Normal 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>;
|
||||
}
|
||||
|
||||
32
src/models/SpeedTestModels.ts
Normal file
32
src/models/SpeedTestModels.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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<string, SpeedTestSession> = {};
|
||||
private readonly _sessions: Map<RequestId, SpeedTestSession> = new Map();
|
||||
|
||||
public save(session: SpeedTestSession): void {
|
||||
this._sessions[session.requestId] = session;
|
||||
public async saveSession(session: SpeedTestSession, ttlMs: number): Promise<void> {
|
||||
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<SpeedTestSession | null> {
|
||||
return this._sessions.get(requestId) || null;
|
||||
}
|
||||
|
||||
public delete(requestId: string): void {
|
||||
delete this._sessions[requestId];
|
||||
public async deleteSession(requestId: RequestId): Promise<void> {
|
||||
const session = this._sessions.get(requestId);
|
||||
if (session?.timeoutId) {
|
||||
clearTimeout(session.timeoutId);
|
||||
}
|
||||
this._sessions.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user