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'",
|
"lint": "eslint --max-warnings 0 './src'",
|
||||||
"prelint:fix": "bun run format",
|
"prelint:fix": "bun run format",
|
||||||
"lint:fix": "eslint --fix './src'",
|
"lint:fix": "eslint --fix './src'",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"dev": "bun run start"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/css": "0.14.1",
|
"@eslint/css": "0.14.1",
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
"typescript-eslint": "8.47.0"
|
"typescript-eslint": "8.47.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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,23 +1,22 @@
|
|||||||
import { LoggerFactory } from "@techniker-me/logger";
|
import {LoggerFactory} from '@techniker-me/logger';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
export default class HealthCheck {
|
export default class HealthCheck {
|
||||||
private readonly _logger = LoggerFactory.getLogger('HealthCheck');
|
private readonly _logger = LoggerFactory.getLogger('HealthCheck');
|
||||||
|
|
||||||
|
public async checkHealth() {
|
||||||
public async checkHealth() {
|
return {
|
||||||
return {
|
status: 'ok',
|
||||||
status: 'ok',
|
timestamp: new Date().toISOString(),
|
||||||
timestamp: new Date().toISOString(),
|
uptime: process.uptime(),
|
||||||
uptime: process.uptime(),
|
memory: process.memoryUsage(),
|
||||||
memory: process.memoryUsage(),
|
cpu: process.cpuUsage(),
|
||||||
cpu: process.cpuUsage(),
|
memoryTotal: process.platform === 'linux' ? os.totalmem() : undefined,
|
||||||
memoryTotal: process.platform === 'linux' ? os.totalmem() : undefined,
|
memoryFree: process.platform === 'linux' ? os.freemem() : undefined,
|
||||||
memoryFree: process.platform === 'linux' ? os.freemem() : undefined,
|
cpuUsage: process.cpuUsage(),
|
||||||
cpuUsage: process.cpuUsage(),
|
network: process.platform === 'linux' ? os.networkInterfaces() : undefined,
|
||||||
network: process.platform === 'linux' ? os.networkInterfaces() : undefined,
|
os: process.platform,
|
||||||
os: process.platform,
|
version: process.version
|
||||||
version: process.version
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +1,37 @@
|
|||||||
import type { BunRouteHandler } from '../net/http/BunHttpServer';
|
import type {BunRouteHandler} from '../net/http/BunHttpServer';
|
||||||
import { LoggerFactory } from "@techniker-me/logger";
|
import {LoggerFactory} from '@techniker-me/logger';
|
||||||
import type HealthCheck from './HealthCheck';
|
import type HealthCheck from './HealthCheck';
|
||||||
import type { BunRequest, Server as BunServer } from 'bun';
|
import type {BunRequest, Server as BunServer} from 'bun';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class HealthCheckApiRoutes {
|
export class HealthCheckApiRoutes {
|
||||||
private readonly _logger = LoggerFactory.getLogger('HealthCheckRoute');
|
private readonly _logger = LoggerFactory.getLogger('HealthCheckRoute');
|
||||||
private readonly _healthCheck: HealthCheck;
|
private readonly _healthCheck: HealthCheck;
|
||||||
|
|
||||||
constructor(healthCheck: HealthCheck) {
|
constructor(healthCheck: HealthCheck) {
|
||||||
this._healthCheck = healthCheck;
|
this._healthCheck = healthCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getGETRoute(): Record<string, BunRouteHandler> {
|
public getGETRoute(): Record<string, BunRouteHandler> {
|
||||||
return {
|
return {
|
||||||
'/_health/readiness': this.readiness.bind(this)
|
'/_health/readiness': this.readiness.bind(this)
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPOSTRoute(): Record<string, BunRouteHandler> {
|
public getPOSTRoute(): Record<string, BunRouteHandler> {
|
||||||
return { }
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPUTRoute(): Record<string, BunRouteHandler> {
|
public getPUTRoute(): Record<string, BunRouteHandler> {
|
||||||
return { }
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOPTIONSRoute(): Record<string, BunRouteHandler> {
|
public getOPTIONSRoute(): Record<string, BunRouteHandler> {
|
||||||
return { }
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readiness(_request: BunRequest, _server: BunServer<unknown>): Promise<Response> {
|
private async readiness(_request: BunRequest, _server: BunServer<unknown>): Promise<Response> {
|
||||||
const health = await this._healthCheck.checkHealth();
|
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
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> {
|
export class BunHttpServer<T> {
|
||||||
private readonly _logger = LoggerFactory.getLogger('BunHttpServer');
|
private readonly _logger = LoggerFactory.getLogger('BunHttpServer');
|
||||||
private readonly _webSocketHandler: WebSocketHandler<T> | undefined;
|
private readonly _webSocketHandler?: WebSocketHandler<T>;
|
||||||
private readonly _routes: BunRoutes;
|
private readonly _routes: BunRoutes;
|
||||||
private _port: number = 5000;
|
private _port: number = 5000;
|
||||||
private _bunServer: BunServer<unknown> | undefined;
|
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._routes = routes;
|
||||||
this._webSocketHandler = webSocketHandler;
|
this._webSocketHandler = webSocketHandler;
|
||||||
}
|
}
|
||||||
@@ -39,27 +39,46 @@ export class BunHttpServer<T> {
|
|||||||
public listen(port: number): Promise<void> {
|
public listen(port: number): Promise<void> {
|
||||||
this._port = port;
|
this._port = port;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
this._bunServer = Bun.serve({
|
this._bunServer = Bun.serve({
|
||||||
fetch: this.fetch.bind(this),
|
fetch: this.fetch.bind(this),
|
||||||
port: this._port,
|
port: this._port,
|
||||||
hostname: '0.0.0.0',
|
hostname: '0.0.0.0',
|
||||||
websocket: this._webSocketHandler,
|
websocket: this._webSocketHandler,
|
||||||
routes: this._routes
|
maxRequestBodySize: 1024 * 1024 * 1024 * 3 // 3GB for large speedtest files
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
this._bunServer?.stop();
|
this._bunServer?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(request: BunRequest, server: BunServer<unknown>): Promise<Response> {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
import type {WebSocketHandler} from 'bun';
|
||||||
|
|
||||||
export default class HttpServerFactory {
|
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);
|
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