From 21b9e52f40e9166d7d0521dca2017ca56c2a11c5 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Fri, 21 Nov 2025 04:56:18 -0500 Subject: [PATCH] Add frontend speed test application and server setup * Introduced a new HTML frontend for network speed testing with a responsive UI * Implemented backend server functionality to serve the frontend and handle speed test APIs * Added speed test logic for downloading and uploading data, including progress tracking and result validation * Created README-SPEEDTEST.md for documentation on application architecture, setup, and usage. * Updated package.json to include necessary scripts and dependencies for development and testing --- README-SPEEDTEST.md | 120 ++++++ frontend.html | 365 ++++++++++++++++++ package.json | 7 +- serve-frontend.ts | 59 +++ src/api/SpeedTestApiRoutes.ts | 152 ++++++++ src/core/domain/SpeedTestTypes.ts | 29 ++ src/core/interfaces/IRequestRepository.ts | 8 + src/core/interfaces/ISpeedTestService.ts | 7 + src/data/RandomData.ts | 33 ++ src/health/HealthCheck.ts | 37 +- src/health/HealthCheckRoute.ts | 58 ++- src/index.ts | 43 +++ src/interfaces/ISpeedTestRepository.ts | 8 + src/interfaces/ISpeedTestService.ts | 21 + src/models/SpeedTestResult.ts | 12 + src/models/SpeedTestSession.ts | 10 + src/net/http/BunHttpServer.ts | 35 +- src/net/http/HttpMethod.ts | 52 +++ src/net/http/HttpServerFactory.ts | 2 +- src/repositories/InMemoryRequestRepository.ts | 19 + .../InMemorySpeedTestRepository.ts | 19 + src/services/SpeedTestService.ts | 116 ++++++ 22 files changed, 1152 insertions(+), 60 deletions(-) create mode 100644 README-SPEEDTEST.md create mode 100644 frontend.html create mode 100644 serve-frontend.ts create mode 100644 src/api/SpeedTestApiRoutes.ts create mode 100644 src/core/domain/SpeedTestTypes.ts create mode 100644 src/core/interfaces/IRequestRepository.ts create mode 100644 src/core/interfaces/ISpeedTestService.ts create mode 100644 src/data/RandomData.ts create mode 100644 src/index.ts create mode 100644 src/interfaces/ISpeedTestRepository.ts create mode 100644 src/interfaces/ISpeedTestService.ts create mode 100644 src/models/SpeedTestResult.ts create mode 100644 src/models/SpeedTestSession.ts create mode 100644 src/net/http/HttpMethod.ts create mode 100644 src/repositories/InMemoryRequestRepository.ts create mode 100644 src/repositories/InMemorySpeedTestRepository.ts create mode 100644 src/services/SpeedTestService.ts diff --git a/README-SPEEDTEST.md b/README-SPEEDTEST.md new file mode 100644 index 0000000..da575d8 --- /dev/null +++ b/README-SPEEDTEST.md @@ -0,0 +1,120 @@ +# Speedtest Application + +A full-stack network speed testing application built with Bun.js. + +## Architecture + +- **Server**: Single Bun.js HTTP server serving both frontend and APIs +- **Frontend**: HTML/JavaScript client served at `/` for running speed tests +- **Configuration**: Maximum request body size set to 3GB for large file uploads +- **APIs**: + - `GET /` - Speedtest frontend UI + - `GET /_health/readiness` - Health check + - `GET /data?size=` - Download random test data + - `POST /data?requestId=` - Upload data for validation + +## Quick Start + +### 1. Start the Server +```bash +bun run start +``` +Server will start on `http://localhost:8080` with both backend APIs and frontend served from the same server. + +### 2. Run Speed Test +1. Open `http://localhost:8080` in your browser +2. Select test size (1MB - 50MB) +3. Click "Start Speed Test" +4. View download/upload speeds and validation results + +## Development + +### Run Both Servers Simultaneously +```bash +# Requires concurrently (npm install -D concurrently) +bun run dev +``` + +### Manual Testing + +Test the API directly: +```bash +# Health check +curl http://localhost:8080/_health/readiness + +# Download test (1MB) +curl -o test.dat http://localhost:8080/data?size=1048576 + +# Upload same data back (get requestId from previous response headers) +curl -X POST "http://localhost:8080/data?requestId=" \ + --data-binary @test.dat \ + -H "Content-Type: application/octet-stream" +``` + +## Speed Test Workflow + +1. **Download Phase**: Frontend downloads random data from server +2. **Upload Phase**: Frontend uploads the same data back for validation +3. **Validation**: Server verifies data integrity and measures performance +4. **Results**: Frontend displays download/upload speeds, total time, and data integrity status + +## Features + +- ✅ Configurable test sizes (1MB - 2.5GB) +- ✅ Large file support (up to 3GB request body limit) +- ✅ Real-time progress indicators +- ✅ Data integrity validation with SHA256 +- ✅ Upload/download speed measurements +- ✅ Clean, responsive UI +- ✅ Comprehensive logging +- ✅ Error handling and recovery + +## API Reference + +### GET /data +Downloads random test data for speed testing. + +**Query Parameters:** +- `size` (number): Size in bytes (default: 10MB) + +**Response Headers:** +- `Content-Type`: `application/octet-stream` +- `Content-Length`: Data size +- `Content-Hash`: SHA256 hash +- `X-Request-ID`: Unique request identifier + +### POST /data +Uploads data for validation and performance measurement. + +**Query Parameters:** +- `requestId` (string): Request ID from download phase + +**Response:** +```json +{ + "requestId": "uuid", + "valid": true, + "expectedHash": "sha256...", + "receivedHash": "sha256...", + "size": 1048576, + "totalTimeMs": 1250, + "dataRateBps": 838860.8, + "dataRateMbps": 6.71, + "timestamp": "2024-01-01T12:00:00.000Z" +} +``` + +### GET /_health/readiness +Health check endpoint. + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2024-01-01T12:00:00.000Z", + "uptime": 123.45, + "memory": {...}, + "cpu": [...], + "os": "darwin" +} +``` diff --git a/frontend.html b/frontend.html new file mode 100644 index 0000000..a7ca3e6 --- /dev/null +++ b/frontend.html @@ -0,0 +1,365 @@ + + + + + + Speed Test + + + +
+

🌐 Network Speed Test

+ +
+
+ + +
+ +
+ +
Ready to start speed test
+ + + + +
+ + + + diff --git a/package.json b/package.json index e9969ba..deb0dab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "eslint --max-warnings 0 './src'", "prelint:fix": "bun run format", "lint:fix": "eslint --fix './src'", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "start": "bun run src/index.ts", + "dev": "bun run start" }, "devDependencies": { "@eslint/css": "0.14.1", @@ -24,6 +26,7 @@ "typescript-eslint": "8.47.0" }, "dependencies": { - "@techniker-me/logger": "0.0.15" + "@techniker-me/logger": "0.0.15", + "concurrently": "9.2.1" } } diff --git a/serve-frontend.ts b/serve-frontend.ts new file mode 100644 index 0000000..5324c9b --- /dev/null +++ b/serve-frontend.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +import { readFile } from 'fs/promises'; +import { LoggerFactory } from '@techniker-me/logger'; + +const logger = LoggerFactory.getLogger('FrontendServer'); + +const PORT = 3001; + +const server = Bun.serve({ + port: PORT, + async fetch(request) { + const url = new URL(request.url); + + // Serve the HTML frontend + if (url.pathname === '/' || url.pathname === '/index.html') { + try { + const html = await readFile('./frontend.html', 'utf-8'); + return new Response(html, { + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + } + }); + } catch (error) { + logger.error('Failed to read frontend.html:', error); + return new Response('Frontend file not found', { status: 404 }); + } + } + + // Health check endpoint + if (url.pathname === '/health') { + return new Response(JSON.stringify({ + status: 'ok', + frontend: 'serving', + speedtest_server: 'http://localhost:8080' + }), { + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response('Not found', { status: 404 }); + } +}); + +logger.info(`Frontend server started on http://localhost:${PORT}`); +logger.info(`Speedtest backend server expected on http://localhost:8080`); +logger.info(`Open http://localhost:${PORT} in your browser to run the speed test`); + +process.on('SIGINT', () => { + logger.info('Shutting down frontend server...'); + server.stop(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + logger.info('Shutting down frontend server...'); + server.stop(); + process.exit(0); +}); diff --git a/src/api/SpeedTestApiRoutes.ts b/src/api/SpeedTestApiRoutes.ts new file mode 100644 index 0000000..1669d18 --- /dev/null +++ b/src/api/SpeedTestApiRoutes.ts @@ -0,0 +1,152 @@ +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/core/domain/SpeedTestTypes.ts b/src/core/domain/SpeedTestTypes.ts new file mode 100644 index 0000000..3e50ddd --- /dev/null +++ b/src/core/domain/SpeedTestTypes.ts @@ -0,0 +1,29 @@ +export interface SpeedTestContext { + requestId: string; + size: number; + data: DataView; + hash: string; + hashAlgorithm: string; + hashEncoding: string; + chunkSize: number; + timeoutId: NodeJS.Timeout; + sentDataAt: number; +} + +export interface TestInitializationResult { + data: DataView; + headers: Record; +} + +export interface TestValidationResult { + requestId: string; + valid: boolean; + expectedHash: string; + receivedHash: string; + size: number; + totalTimeMs: number; + dataRateBps: number; + dataRateMbps: number; + timestamp: string; +} + diff --git a/src/core/interfaces/IRequestRepository.ts b/src/core/interfaces/IRequestRepository.ts new file mode 100644 index 0000000..d49d1be --- /dev/null +++ b/src/core/interfaces/IRequestRepository.ts @@ -0,0 +1,8 @@ +import type { SpeedTestContext } from '../domain/SpeedTestTypes'; + +export interface IRequestRepository { + save(id: string, context: SpeedTestContext): void; + get(id: string): SpeedTestContext | undefined; + delete(id: string): void; +} + diff --git a/src/core/interfaces/ISpeedTestService.ts b/src/core/interfaces/ISpeedTestService.ts new file mode 100644 index 0000000..aa25aee --- /dev/null +++ b/src/core/interfaces/ISpeedTestService.ts @@ -0,0 +1,7 @@ +import type { TestInitializationResult, TestValidationResult } from '../domain/SpeedTestTypes'; + +export interface ISpeedTestService { + initiateTest(size: number, options?: Record): Promise; + validateTest(requestId: string, data: Uint8Array): Promise; +} + diff --git a/src/data/RandomData.ts b/src/data/RandomData.ts new file mode 100644 index 0000000..c4ce29c --- /dev/null +++ b/src/data/RandomData.ts @@ -0,0 +1,33 @@ +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/health/HealthCheck.ts b/src/health/HealthCheck.ts index b729ef4..ec07f7d 100644 --- a/src/health/HealthCheck.ts +++ b/src/health/HealthCheck.ts @@ -1,23 +1,22 @@ -import { LoggerFactory } from "@techniker-me/logger"; +import {LoggerFactory} from '@techniker-me/logger'; import os from 'os'; export default class HealthCheck { - private readonly _logger = LoggerFactory.getLogger('HealthCheck'); + private readonly _logger = LoggerFactory.getLogger('HealthCheck'); - - public async checkHealth() { - return { - status: 'ok', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - memory: process.memoryUsage(), - cpu: process.cpuUsage(), - memoryTotal: process.platform === 'linux' ? os.totalmem() : undefined, - memoryFree: process.platform === 'linux' ? os.freemem() : undefined, - cpuUsage: process.cpuUsage(), - network: process.platform === 'linux' ? os.networkInterfaces() : undefined, - os: process.platform, - version: process.version - } - } -} \ No newline at end of file + public async checkHealth() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + cpu: process.cpuUsage(), + memoryTotal: process.platform === 'linux' ? os.totalmem() : undefined, + memoryFree: process.platform === 'linux' ? os.freemem() : undefined, + cpuUsage: process.cpuUsage(), + network: process.platform === 'linux' ? os.networkInterfaces() : undefined, + os: process.platform, + version: process.version + }; + } +} diff --git a/src/health/HealthCheckRoute.ts b/src/health/HealthCheckRoute.ts index 6618413..b43db57 100644 --- a/src/health/HealthCheckRoute.ts +++ b/src/health/HealthCheckRoute.ts @@ -1,39 +1,37 @@ -import type { BunRouteHandler } from '../net/http/BunHttpServer'; -import { LoggerFactory } from "@techniker-me/logger"; +import type {BunRouteHandler} from '../net/http/BunHttpServer'; +import {LoggerFactory} from '@techniker-me/logger'; import type HealthCheck from './HealthCheck'; -import type { BunRequest, Server as BunServer } from 'bun'; - - +import type {BunRequest, Server as BunServer} from 'bun'; -export class HealthCheckApiRoutes { - private readonly _logger = LoggerFactory.getLogger('HealthCheckRoute'); - private readonly _healthCheck: HealthCheck; +export class HealthCheckApiRoutes { + private readonly _logger = LoggerFactory.getLogger('HealthCheckRoute'); + private readonly _healthCheck: HealthCheck; - constructor(healthCheck: HealthCheck) { - this._healthCheck = healthCheck; - } + constructor(healthCheck: HealthCheck) { + this._healthCheck = healthCheck; + } - public getGETRoute(): Record { - return { - '/_health/readiness': this.readiness.bind(this) - } - } + public getGETRoute(): Record { + return { + '/_health/readiness': this.readiness.bind(this) + }; + } - public getPOSTRoute(): Record { - return { } - } + public getPOSTRoute(): Record { + return {}; + } - public getPUTRoute(): Record { - return { } - } + public getPUTRoute(): Record { + return {}; + } - public getOPTIONSRoute(): Record { - return { } - } + public getOPTIONSRoute(): Record { + return {}; + } - private async readiness(_request: BunRequest, _server: BunServer): Promise { - const health = await this._healthCheck.checkHealth(); + private async readiness(_request: BunRequest, _server: BunServer): Promise { + const health = await this._healthCheck.checkHealth(); - return new Response(JSON.stringify(health), {status: 200, headers: {'Content-Type': 'application/json'}}); - } -} \ No newline at end of file + return new Response(JSON.stringify(health), {status: 200, headers: {'Content-Type': 'application/json'}}); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3e51c43 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,43 @@ +import HttpServerFactory from './net/http/HttpServerFactory'; +import {HealthCheckApiRoutes} from './health/HealthCheckRoute'; +import HealthCheck from './health/HealthCheck'; +import {SpeedTestApiRoutes} from './api/SpeedTestApiRoutes'; + +const healthCheck = new HealthCheck(); +const healthCheckRoutes = new HealthCheckApiRoutes(healthCheck); +const speedTestApiRoutes = new SpeedTestApiRoutes(); + +// Frontend serving function - using Bun's super simple static file serving +const serveFrontend = async (_request: any, _server: any) => new Response(Bun.file('./frontend.html'), { + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + } +}); + +const server = HttpServerFactory.createBunHttpServer({ + '/': serveFrontend, + ...healthCheckRoutes.getGETRoute(), + ...speedTestApiRoutes.getRoutes(), +}); + +const listenPromise = server.listen(8080); + +process.on('SIGINT', () => { + server.stop(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + server.stop(); + process.exit(0); +}); + +listenPromise + .then(() => { + console.log(`Server is running on port [${server.port}]`); + }) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/src/interfaces/ISpeedTestRepository.ts b/src/interfaces/ISpeedTestRepository.ts new file mode 100644 index 0000000..ce8169e --- /dev/null +++ b/src/interfaces/ISpeedTestRepository.ts @@ -0,0 +1,8 @@ +import { SpeedTestSession } from '../models/SpeedTestSession'; + +export interface ISpeedTestRepository { + save(session: SpeedTestSession): void; + findById(requestId: string): SpeedTestSession | undefined; + delete(requestId: string): void; +} + diff --git a/src/interfaces/ISpeedTestService.ts b/src/interfaces/ISpeedTestService.ts new file mode 100644 index 0000000..29b1cb0 --- /dev/null +++ b/src/interfaces/ISpeedTestService.ts @@ -0,0 +1,21 @@ +import { SpeedTestResult } from '../models/SpeedTestResult'; + +export interface SpeedTestServiceInitParams { + size: number; + hashAlgorithm?: string; + hashEncoding?: string; + chunkSize?: number; + timeout?: number; +} + +export interface SpeedTestServiceResponse { + requestId: string; + data: DataView; + headers: Record; +} + +export interface ISpeedTestService { + initiateDownload(params: SpeedTestServiceInitParams): Promise; + validateUpload(requestId: string, data: Uint8Array): Promise; +} + diff --git a/src/models/SpeedTestResult.ts b/src/models/SpeedTestResult.ts new file mode 100644 index 0000000..17f6678 --- /dev/null +++ b/src/models/SpeedTestResult.ts @@ -0,0 +1,12 @@ +export interface SpeedTestResult { + requestId: string; + valid: boolean; + expectedHash: string; + receivedHash: string; + size: number; + totalTimeMs: number; + dataRateBps: number; + dataRateMbps: number; + timestamp: string; +} + diff --git a/src/models/SpeedTestSession.ts b/src/models/SpeedTestSession.ts new file mode 100644 index 0000000..1757246 --- /dev/null +++ b/src/models/SpeedTestSession.ts @@ -0,0 +1,10 @@ +export interface SpeedTestSession { + requestId: string; + size: number; + expectedHash: string; + hashAlgorithm: string; + hashEncoding: string; + chunkSize: number; + startTime: number; + timeoutId?: ReturnType; +} diff --git a/src/net/http/BunHttpServer.ts b/src/net/http/BunHttpServer.ts index a57b558..ea39d00 100644 --- a/src/net/http/BunHttpServer.ts +++ b/src/net/http/BunHttpServer.ts @@ -14,12 +14,12 @@ export type BunRoutes = Record; export class BunHttpServer { private readonly _logger = LoggerFactory.getLogger('BunHttpServer'); - private readonly _webSocketHandler: WebSocketHandler | undefined; + private readonly _webSocketHandler?: WebSocketHandler; private readonly _routes: BunRoutes; private _port: number = 5000; private _bunServer: BunServer | undefined; - constructor(routes: BunRoutes, webSocketHandler: WebSocketHandler | undefined, bunServer: BunServer) { + constructor(routes: BunRoutes, webSocketHandler?: WebSocketHandler) { this._routes = routes; this._webSocketHandler = webSocketHandler; } @@ -39,27 +39,46 @@ export class BunHttpServer { public listen(port: number): Promise { this._port = port; - return new Promise((resolve) => { + return new Promise(resolve => { this._bunServer = Bun.serve({ fetch: this.fetch.bind(this), port: this._port, hostname: '0.0.0.0', websocket: this._webSocketHandler, - routes: this._routes + maxRequestBodySize: 1024 * 1024 * 1024 * 3 // 3GB for large speedtest files }); resolve(); }); } - + public stop(): void { this._bunServer?.stop(); } private async fetch(request: BunRequest, server: BunServer): Promise { - const url = new globalThis.URL(request.url); + const url = new URL(request.url); - this._logger.info('Received [%s] request from [%s] for unknown route [%s]', request.method, server.requestIP(request)?.address ?? 'unknown', url.pathname); + this._logger.info('Received [%s] request from [%s] for route [%s]', request.method, server.requestIP(request)?.address ?? 'unknown', url.pathname); - return new Response(JSON.stringify({status: 'not-found'}), {status: 404, headers: {'Content-Type': 'application/json'}}); + // Find route by pathname + const route = this._routes[url.pathname]; + if (!route) { + this._logger.warn('Route not found: [%s]', url.pathname); + return new Response(JSON.stringify({status: 'not-found'}), {status: 404, headers: {'Content-Type': 'application/json'}}); + } + + // Handle single route handler (function) + if (typeof route === 'function') { + return route(request, server); + } + + // Handle method-specific routes + const methodHandler = route[request.method as keyof BunMethodRoutes]; + if (!methodHandler) { + this._logger.warn('Method [%s] not allowed for route [%s]', request.method, url.pathname); + return new Response(JSON.stringify({error: 'Method not allowed'}), {status: 405, headers: {'Content-Type': 'application/json'}}); + } + + return methodHandler(request, server); } } diff --git a/src/net/http/HttpMethod.ts b/src/net/http/HttpMethod.ts new file mode 100644 index 0000000..6221cd5 --- /dev/null +++ b/src/net/http/HttpMethod.ts @@ -0,0 +1,52 @@ +import {assertUnreachable} from '@techniker-me/tools'; + +export enum HttpMethod { + GET = 0, + POST = 1, + PUT = 2, + DELETE = 3, + PATCH = 4, + OPTIONS = 5 +} + +export type HttpMethodType = keyof typeof HttpMethod; + +export class HttpMethodMapping { + public static convertHttpMethodTypeToHttpMethod(method: HttpMethodType): HttpMethod { + switch (method) { + case 'GET': + return HttpMethod.GET; + case 'POST': + return HttpMethod.POST; + case 'PUT': + return HttpMethod.PUT; + case 'DELETE': + return HttpMethod.DELETE; + case 'PATCH': + return HttpMethod.PATCH; + case 'OPTIONS': + return HttpMethod.OPTIONS; + default: + assertUnreachable(method); + } + } + + public static convertHttpMethodToHttpMethodType(method: HttpMethod): HttpMethodType { + switch (method) { + case HttpMethod.GET: + return 'GET'; + case HttpMethod.POST: + return 'POST'; + case HttpMethod.PUT: + return 'PUT'; + case HttpMethod.DELETE: + return 'DELETE'; + case HttpMethod.PATCH: + return 'PATCH'; + case HttpMethod.OPTIONS: + return 'OPTIONS'; + default: + assertUnreachable(method); + } + } +} diff --git a/src/net/http/HttpServerFactory.ts b/src/net/http/HttpServerFactory.ts index 975202f..763e0cb 100644 --- a/src/net/http/HttpServerFactory.ts +++ b/src/net/http/HttpServerFactory.ts @@ -2,7 +2,7 @@ import {BunHttpServer, type BunRoutes} from './BunHttpServer'; import type {WebSocketHandler} from 'bun'; export default class HttpServerFactory { - public static createBunHttpServer(routes: BunRoutes, webSocketHandler: WebSocketHandler | undefined): BunHttpServer { + public static createBunHttpServer(routes: BunRoutes, webSocketHandler?: WebSocketHandler): BunHttpServer { return new BunHttpServer(routes, webSocketHandler); } diff --git a/src/repositories/InMemoryRequestRepository.ts b/src/repositories/InMemoryRequestRepository.ts new file mode 100644 index 0000000..b9c2c6c --- /dev/null +++ b/src/repositories/InMemoryRequestRepository.ts @@ -0,0 +1,19 @@ +import type { IRequestRepository } from '../core/interfaces/IRequestRepository'; +import type { SpeedTestContext } from '../core/domain/SpeedTestTypes'; + +export class InMemoryRequestRepository implements IRequestRepository { + private readonly _storage: Record = {}; + + save(id: string, context: SpeedTestContext): void { + this._storage[id] = context; + } + + get(id: string): SpeedTestContext | undefined { + return this._storage[id]; + } + + delete(id: string): void { + delete this._storage[id]; + } +} + diff --git a/src/repositories/InMemorySpeedTestRepository.ts b/src/repositories/InMemorySpeedTestRepository.ts new file mode 100644 index 0000000..ba5e8b4 --- /dev/null +++ b/src/repositories/InMemorySpeedTestRepository.ts @@ -0,0 +1,19 @@ +import { ISpeedTestRepository } from '../interfaces/ISpeedTestRepository'; +import { SpeedTestSession } from '../models/SpeedTestSession'; + +export class InMemorySpeedTestRepository implements ISpeedTestRepository { + private readonly _sessions: Record = {}; + + public save(session: SpeedTestSession): void { + this._sessions[session.requestId] = session; + } + + public findById(requestId: string): SpeedTestSession | undefined { + return this._sessions[requestId]; + } + + public delete(requestId: string): void { + delete this._sessions[requestId]; + } +} + diff --git a/src/services/SpeedTestService.ts b/src/services/SpeedTestService.ts new file mode 100644 index 0000000..4529eb2 --- /dev/null +++ b/src/services/SpeedTestService.ts @@ -0,0 +1,116 @@ +import { LoggerFactory } from '@techniker-me/logger'; +import { createHash } from 'node:crypto'; +import type { ISpeedTestService } from '../core/interfaces/ISpeedTestService'; +import type { IRequestRepository } from '../core/interfaces/IRequestRepository'; +import type { TestInitializationResult, TestValidationResult, SpeedTestContext } from '../core/domain/SpeedTestTypes'; +import { RandomData } from '../data/RandomData'; + +const DEFAULT_SIZE = 1024 * 1024 * 10; // 10MB +const DEFAULT_CHUNK_SIZE = 1024 * 1024; // 1MB +const DEFAULT_HASH_ALGORITHM = 'sha256'; +const DEFAULT_HASH_ENCODING = 'base64'; +const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; +const DEFAULT_TIMEOUT_DURATION = 10000; + +export class SpeedTestService implements ISpeedTestService { + private readonly _logger = LoggerFactory.getLogger('SpeedTestService'); + + constructor(private readonly _repository: IRequestRepository) {} + + async initiateTest(sizeParam: number, options: Record = {}): Promise { + const size = isNaN(sizeParam) ? DEFAULT_SIZE : sizeParam; + const requestId = crypto.randomUUID(); + + this._logger.info('Generating random data: size=[%d] bytes, requestId=[%s]', size, requestId); + + const randomData = new RandomData(size); + + const hashAlgorithm = options['hashAlgorithm'] ?? DEFAULT_HASH_ALGORITHM; + const hashEncoding = options['hashEncoding'] ?? DEFAULT_HASH_ENCODING; + const chunkSize = parseInt(options['chunkSize'] ?? DEFAULT_CHUNK_SIZE.toString()); + const timeoutDuration = parseInt(options['timeout'] ?? DEFAULT_TIMEOUT_DURATION.toString()); + + const context: SpeedTestContext = { + requestId, + size, + data: randomData.data, + hash: randomData.hash, + hashAlgorithm, + hashEncoding, + chunkSize, + sentDataAt: Date.now(), + timeoutId: setTimeout(() => { + this._repository.delete(requestId); + }, timeoutDuration), + }; + + this._repository.save(requestId, context); + + const headers: Record = { + 'Content-Type': DEFAULT_CONTENT_TYPE, + 'Content-Length': randomData.data.byteLength.toString(), + 'Content-Hash': randomData.hash, + 'Content-Hash-Algorithm': DEFAULT_HASH_ALGORITHM, + 'Content-Hash-Encoding': DEFAULT_HASH_ENCODING, + 'Content-Chunk-Size': DEFAULT_CHUNK_SIZE.toString(), + 'X-Request-ID': requestId, + }; + + return { + data: randomData.data, + headers + }; + } + + async validateTest(requestId: string, receivedData: Uint8Array): Promise { + const context = this._repository.get(requestId); + + if (!context) { + throw new Error('Request not found or expired'); + } + + try { + const finishedReceivingDataAt = Date.now(); + + // Calculate timing metrics + const totalTime = finishedReceivingDataAt - context.sentDataAt; + const dataRate = (context.size / totalTime) * 1000; // bytes per second + + // Validate data size + if (receivedData.length !== context.size) { + throw new Error(`Data size mismatch. Expected: ${context.size}, Received: ${receivedData.length}`); + } + + // Validate data integrity + // Note: createHash is from node:crypto, context.hashEncoding needs casting or validation if strictly typed + const receivedHash = createHash(context.hashAlgorithm) + .update(receivedData) + .digest(context.hashEncoding as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const isValid = receivedHash === context.hash; + + const result: TestValidationResult = { + requestId, + valid: isValid, + expectedHash: context.hash, + receivedHash, + size: context.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=[%f] Mbps, time=[%d]ms', + isValid, context.size, result.dataRateMbps, totalTime); + + return result; + + } finally { + // Cleanup + clearTimeout(context.timeoutId); + this._repository.delete(requestId); + } + } +} +