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
This commit is contained in:
2025-11-21 04:56:18 -05:00
parent 3a3b85f138
commit 21b9e52f40
22 changed files with 1152 additions and 60 deletions

120
README-SPEEDTEST.md Normal file
View File

@@ -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=<bytes>` - Download random test data
- `POST /data?requestId=<id>` - 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=<request-id>" \
--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"
}
```

365
frontend.html Normal file
View File

@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speed Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 20px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
align-items: center;
}
.size-selector {
display: flex;
align-items: center;
gap: 10px;
}
select, button {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
button {
background: #007bff;
color: white;
border: none;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.progress {
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 20px;
background: #e9ecef;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
font-weight: bold;
color: #666;
}
.results {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.result-item {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #dee2e6;
}
.result-item:last-child {
border-bottom: none;
}
.result-label {
font-weight: 500;
}
.result-value {
font-weight: bold;
color: #007bff;
}
.status {
text-align: center;
padding: 10px;
margin: 10px 0;
border-radius: 6px;
font-weight: bold;
}
.status.idle {
background: #e9ecef;
color: #6c757d;
}
.status.downloading {
background: #d1ecf1;
color: #0c5460;
}
.status.uploading {
background: #d4edda;
color: #155724;
}
.status.completed {
background: #d1ecf1;
color: #0c5460;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>🌐 Network Speed Test</h1>
<div class="controls">
<div class="size-selector">
<label for="testSize">Test Size:</label>
<select id="testSize">
<option value="1048576">1 MB</option>
<option value="5242880">5 MB</option>
<option value="10485760" selected>10 MB</option>
<option value="26214400">25 MB</option>
<option value="52428800">50 MB</option>
<option value="104857600">100 MB</option>
<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>
</div>
<div class="status idle" id="status">Ready to start speed test</div>
<div class="progress hidden" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">0%</div>
</div>
<div class="results hidden" id="results">
<h3>Test Results</h3>
<div class="result-item">
<span class="result-label">Download Speed:</span>
<span class="result-value" id="downloadSpeed">-</span>
</div>
<div class="result-item">
<span class="result-label">Upload Speed:</span>
<span class="result-value" id="uploadSpeed">-</span>
</div>
<div class="result-item">
<span class="result-label">Total Time:</span>
<span class="result-value" id="totalTime">-</span>
</div>
<div class="result-item">
<span class="result-label">Data Size:</span>
<span class="result-value" id="dataSize">-</span>
</div>
<div class="result-item">
<span class="result-label">Data Integrity:</span>
<span class="result-value" id="dataIntegrity">-</span>
</div>
</div>
</div>
<script>
let downloadedData = null;
let requestId = null;
const baseUrl = window.location.origin; // Same server as frontend
async function startSpeedTest() {
const testSize = document.getElementById('testSize').value;
const startButton = document.getElementById('startTest');
// Reset UI
updateStatus('idle', 'Starting speed test...');
document.getElementById('progressContainer').classList.add('hidden');
document.getElementById('results').classList.add('hidden');
startButton.disabled = true;
startButton.textContent = 'Testing...';
try {
// Phase 1: Download
await performDownload(testSize);
// Phase 2: Upload
await performUpload();
// Show results
document.getElementById('results').classList.remove('hidden');
updateStatus('completed', 'Speed test completed successfully!');
} catch (error) {
console.error('Speed test failed:', error);
updateStatus('error', `Speed test failed: ${error.message}`);
} finally {
startButton.disabled = false;
startButton.textContent = 'Start Speed Test';
}
}
async function performDownload(size) {
updateStatus('downloading', 'Downloading test data...');
updateProgress(0);
document.getElementById('progressContainer').classList.remove('hidden');
const startTime = Date.now();
const response = await fetch(`${baseUrl}/data?size=${size}`);
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
// Get request ID from headers
requestId = response.headers.get('X-Request-ID');
if (!requestId) {
throw new Error('No request ID received from server');
}
// Read the response as array buffer
const contentLength = parseInt(response.headers.get('Content-Length') || '0');
const reader = response.body.getReader();
const chunks = [];
let receivedLength = 0;
while (true) {
const {done, value} = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
// Update progress
const progress = (receivedLength / contentLength) * 100;
updateProgress(progress);
}
// Combine chunks
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
downloadedData = combined;
const endTime = Date.now();
const downloadTime = (endTime - startTime) / 1000; // seconds
const downloadSpeedMBps = (totalLength / 1024 / 1024) / downloadTime;
const downloadSpeedMbps = downloadSpeedMBps * 8;
document.getElementById('downloadSpeed').textContent = `${downloadSpeedMbps.toFixed(2)} Mbps`;
document.getElementById('dataSize').textContent = `${(totalLength / 1024 / 1024).toFixed(2)} MB`;
console.log(`Download completed: ${downloadSpeedMbps.toFixed(2)} Mbps`);
}
async function performUpload() {
updateStatus('uploading', 'Uploading test data...');
updateProgress(0);
const startTime = Date.now();
const response = await fetch(`${baseUrl}/data?requestId=${requestId}`, {
method: 'POST',
body: downloadedData,
headers: {
'Content-Type': 'application/octet-stream'
}
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const endTime = Date.now();
const uploadTime = (endTime - startTime) / 1000; // seconds
const uploadSpeedMBps = (downloadedData.length / 1024 / 1024) / uploadTime;
const uploadSpeedMbps = uploadSpeedMBps * 8;
// Update results
document.getElementById('uploadSpeed').textContent = `${uploadSpeedMbps.toFixed(2)} Mbps`;
document.getElementById('totalTime').textContent = `${((endTime - startTime) / 1000).toFixed(2)} seconds`;
document.getElementById('dataIntegrity').textContent = result.valid ? '✅ Valid' : '❌ Invalid';
updateProgress(100);
console.log(`Upload completed: ${uploadSpeedMbps.toFixed(2)} Mbps`);
console.log('Validation result:', result);
}
function updateStatus(statusClass, message) {
const statusEl = document.getElementById('status');
statusEl.className = `status ${statusClass}`;
statusEl.textContent = message;
}
function updateProgress(percent) {
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
progressFill.style.width = `${percent}%`;
progressText.textContent = `${percent.toFixed(1)}%`;
}
// Server URL (same origin since frontend is served from same server)
// Initialize
document.addEventListener('DOMContentLoaded', function() {
console.log('Speed test frontend loaded. Server URL:', baseUrl);
});
</script>
</body>
</html>

View File

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

59
serve-frontend.ts Normal file
View File

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

View File

@@ -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<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,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<string, string>;
}
export interface TestValidationResult {
requestId: string;
valid: boolean;
expectedHash: string;
receivedHash: string;
size: number;
totalTimeMs: number;
dataRateBps: number;
dataRateMbps: number;
timestamp: string;
}

View File

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

View File

@@ -0,0 +1,7 @@
import type { TestInitializationResult, TestValidationResult } from '../domain/SpeedTestTypes';
export interface ISpeedTestService {
initiateTest(size: number, options?: Record<string, string>): Promise<TestInitializationResult>;
validateTest(requestId: string, data: Uint8Array): Promise<TestValidationResult>;
}

33
src/data/RandomData.ts Normal file
View File

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

View File

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

View File

@@ -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<string, BunRouteHandler> {
return {
'/_health/readiness': this.readiness.bind(this)
}
}
public getGETRoute(): Record<string, BunRouteHandler> {
return {
'/_health/readiness': this.readiness.bind(this)
};
}
public getPOSTRoute(): Record<string, BunRouteHandler> {
return { }
}
public getPOSTRoute(): Record<string, BunRouteHandler> {
return {};
}
public getPUTRoute(): Record<string, BunRouteHandler> {
return { }
}
public getPUTRoute(): Record<string, BunRouteHandler> {
return {};
}
public getOPTIONSRoute(): Record<string, BunRouteHandler> {
return { }
}
public getOPTIONSRoute(): Record<string, BunRouteHandler> {
return {};
}
private async readiness(_request: BunRequest, _server: BunServer<unknown>): Promise<Response> {
const health = await this._healthCheck.checkHealth();
private async readiness(_request: BunRequest, _server: BunServer<unknown>): Promise<Response> {
const health = await this._healthCheck.checkHealth();
return new Response(JSON.stringify(health), {status: 200, headers: {'Content-Type': 'application/json'}});
}
}
return new Response(JSON.stringify(health), {status: 200, headers: {'Content-Type': 'application/json'}});
}
}

43
src/index.ts Normal file
View File

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

View File

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

View File

@@ -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<string, string>;
}
export interface ISpeedTestService {
initiateDownload(params: SpeedTestServiceInitParams): Promise<SpeedTestServiceResponse>;
validateUpload(requestId: string, data: Uint8Array): Promise<SpeedTestResult>;
}

View File

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

View File

@@ -0,0 +1,10 @@
export interface SpeedTestSession {
requestId: string;
size: number;
expectedHash: string;
hashAlgorithm: string;
hashEncoding: string;
chunkSize: number;
startTime: number;
timeoutId?: ReturnType<typeof setTimeout>;
}

View File

@@ -14,12 +14,12 @@ export type BunRoutes = Record<string, BunMethodRoutes | BunRouteHandler>;
export class BunHttpServer<T> {
private readonly _logger = LoggerFactory.getLogger('BunHttpServer');
private readonly _webSocketHandler: WebSocketHandler<T> | undefined;
private readonly _webSocketHandler?: WebSocketHandler<T>;
private readonly _routes: BunRoutes;
private _port: number = 5000;
private _bunServer: BunServer<unknown> | undefined;
constructor(routes: BunRoutes, webSocketHandler: WebSocketHandler<T> | undefined, bunServer: BunServer<unknown>) {
constructor(routes: BunRoutes, webSocketHandler?: WebSocketHandler<T>) {
this._routes = routes;
this._webSocketHandler = webSocketHandler;
}
@@ -39,27 +39,46 @@ export class BunHttpServer<T> {
public listen(port: number): Promise<void> {
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<unknown>): Promise<Response> {
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);
}
}

View File

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

View File

@@ -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<unknown> | undefined): BunHttpServer<unknown> {
public static createBunHttpServer(routes: BunRoutes, webSocketHandler?: WebSocketHandler<unknown>): BunHttpServer<unknown> {
return new BunHttpServer(routes, webSocketHandler);
}

View File

@@ -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<string, SpeedTestContext> = {};
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];
}
}

View File

@@ -0,0 +1,19 @@
import { ISpeedTestRepository } from '../interfaces/ISpeedTestRepository';
import { SpeedTestSession } from '../models/SpeedTestSession';
export class InMemorySpeedTestRepository implements ISpeedTestRepository {
private readonly _sessions: Record<string, SpeedTestSession> = {};
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];
}
}

View File

@@ -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<string, string> = {}): Promise<TestInitializationResult> {
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<string, string> = {
'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<TestValidationResult> {
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);
}
}
}