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="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>
|
||||||
|
|||||||
@@ -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 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user