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:
120
README-SPEEDTEST.md
Normal file
120
README-SPEEDTEST.md
Normal 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
365
frontend.html
Normal 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>
|
||||
@@ -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
59
serve-frontend.ts
Normal 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);
|
||||
});
|
||||
152
src/api/SpeedTestApiRoutes.ts
Normal file
152
src/api/SpeedTestApiRoutes.ts
Normal 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}));
|
||||
}
|
||||
}
|
||||
29
src/core/domain/SpeedTestTypes.ts
Normal file
29
src/core/domain/SpeedTestTypes.ts
Normal 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;
|
||||
}
|
||||
|
||||
8
src/core/interfaces/IRequestRepository.ts
Normal file
8
src/core/interfaces/IRequestRepository.ts
Normal 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;
|
||||
}
|
||||
|
||||
7
src/core/interfaces/ISpeedTestService.ts
Normal file
7
src/core/interfaces/ISpeedTestService.ts
Normal 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
33
src/data/RandomData.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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');
|
||||
|
||||
|
||||
public async checkHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
@@ -18,6 +17,6 @@ export default class HealthCheck {
|
||||
network: process.platform === 'linux' ? os.networkInterfaces() : undefined,
|
||||
os: process.platform,
|
||||
version: process.version
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
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');
|
||||
@@ -16,19 +14,19 @@ export class HealthCheckApiRoutes {
|
||||
public getGETRoute(): Record<string, BunRouteHandler> {
|
||||
return {
|
||||
'/_health/readiness': this.readiness.bind(this)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getPOSTRoute(): Record<string, BunRouteHandler> {
|
||||
return { }
|
||||
return {};
|
||||
}
|
||||
|
||||
public getPUTRoute(): Record<string, BunRouteHandler> {
|
||||
return { }
|
||||
return {};
|
||||
}
|
||||
|
||||
public getOPTIONSRoute(): Record<string, BunRouteHandler> {
|
||||
return { }
|
||||
return {};
|
||||
}
|
||||
|
||||
private async readiness(_request: BunRequest, _server: BunServer<unknown>): Promise<Response> {
|
||||
|
||||
43
src/index.ts
Normal file
43
src/index.ts
Normal 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);
|
||||
});
|
||||
8
src/interfaces/ISpeedTestRepository.ts
Normal file
8
src/interfaces/ISpeedTestRepository.ts
Normal 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;
|
||||
}
|
||||
|
||||
21
src/interfaces/ISpeedTestService.ts
Normal file
21
src/interfaces/ISpeedTestService.ts
Normal 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>;
|
||||
}
|
||||
|
||||
12
src/models/SpeedTestResult.ts
Normal file
12
src/models/SpeedTestResult.ts
Normal 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;
|
||||
}
|
||||
|
||||
10
src/models/SpeedTestSession.ts
Normal file
10
src/models/SpeedTestSession.ts
Normal 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>;
|
||||
}
|
||||
@@ -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,13 +39,13 @@ 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();
|
||||
});
|
||||
@@ -56,10 +56,29 @@ export class BunHttpServer<T> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
52
src/net/http/HttpMethod.ts
Normal file
52
src/net/http/HttpMethod.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
19
src/repositories/InMemoryRequestRepository.ts
Normal file
19
src/repositories/InMemoryRequestRepository.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
||||
19
src/repositories/InMemorySpeedTestRepository.ts
Normal file
19
src/repositories/InMemorySpeedTestRepository.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
||||
116
src/services/SpeedTestService.ts
Normal file
116
src/services/SpeedTestService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user