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