feat(serviceworker): Add speedtest support to service worker and dashboard

This commit is contained in:
2025-12-04 11:36:27 +00:00
parent 5a81858df5
commit aa677a2b7c
7 changed files with 429 additions and 2 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## 2025-12-04 - 6.4.0 - feat(serviceworker)
Add speedtest support to service worker and dashboard
- Add serviceworker_speedtest typed request handler to measure download, upload and latency
- Expose dashboard speedtest endpoint (/sw-dash/speedtest) and integrate runSpeedtest flow
- Dashboard UI: add speedtest panel, run button, visual speed bars and online indicator
- Metrics: introduce ISpeedtestMetrics and methods (recordSpeedtest, setOnlineStatus, getSpeedtestMetrics) and include speedtest data in metrics output
- Server/tools: add typedrequest handling for speedtest in sw-typedrequest and route service worker dashboard path in CacheManager
## 2025-12-04 - 6.3.0 - feat(web_serviceworker)
Add advanced service worker subsystems: cache deduplication, metrics, update & network managers, event bus and dashboard

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '6.3.0',
version: '6.4.0',
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
}

View File

@@ -84,6 +84,48 @@ export const addServiceWorkerRoute = (
)
);
// Speedtest handler for measuring connection speed
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Speedtest>(
'serviceworker_speedtest',
async (reqArg) => {
const startTime = Date.now();
const payloadSizeKB = reqArg.payloadSizeKB || 100;
const sizeBytes = payloadSizeKB * 1024;
let payload: string | undefined;
let bytesTransferred = 0;
switch (reqArg.type) {
case 'download':
// Generate random payload for download test
payload = 'x'.repeat(sizeBytes);
bytesTransferred = sizeBytes;
break;
case 'upload':
// For upload, measure bytes received from client
bytesTransferred = reqArg.payload?.length || 0;
break;
case 'latency':
// Minimal payload for latency test
bytesTransferred = 1;
break;
}
const durationMs = Date.now() - startTime;
// Speed in Mbps: (bytes * 8 bits/byte) / (ms * 1000 to get seconds) / 1,000,000 for Mbps
const speedMbps = durationMs > 0 ? (bytesTransferred * 8) / (durationMs * 1000) : 0;
return {
durationMs,
bytesTransferred,
speedMbps,
timestamp: Date.now(),
payload, // Only for download tests
};
}
)
);
const response = await typedrouter.routeAndAddResponse(body);
return new Response(plugins.smartjson.stringify(response), {
status: 200,

View File

@@ -189,4 +189,31 @@ export interface IConnectionResult {
error?: string;
attempts?: number;
duration?: number;
}
// ===============
// Speedtest interfaces
// ===============
/**
* Speedtest request between service worker and backend
*/
export interface IRequest_Serviceworker_Speedtest
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IRequest_Serviceworker_Speedtest
> {
method: 'serviceworker_speedtest';
request: {
type: 'download' | 'upload' | 'latency';
payloadSizeKB?: number; // Size of test payload in KB (default: 100)
payload?: string; // For upload tests, the payload to send
};
response: {
durationMs: number;
bytesTransferred: number;
speedMbps: number;
timestamp: number;
payload?: string; // For download tests, the payload received
};
}

View File

@@ -215,6 +215,11 @@ export class CacheManager {
fetchEventArg.respondWith(Promise.resolve(dashboard.serveMetrics()));
return;
}
if (parsedUrl.pathname === '/sw-dash/speedtest') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(dashboard.runSpeedtest());
return;
}
// Block requests that we don't want the service worker to handle.
if (

View File

@@ -1,4 +1,4 @@
import { getMetricsCollector, type IServiceWorkerMetrics } from './classes.metrics.js';
import { getMetricsCollector } from './classes.metrics.js';
/**
* Dashboard generator that creates a terminal-like metrics display
@@ -29,6 +29,94 @@ export class DashboardGenerator {
});
}
/**
* Runs a speedtest and returns the results
*/
public async runSpeedtest(): Promise<Response> {
const metrics = getMetricsCollector();
const results: {
latency?: { durationMs: number; speedMbps: number };
download?: { durationMs: number; speedMbps: number; bytesTransferred: number };
upload?: { durationMs: number; speedMbps: number; bytesTransferred: number };
error?: string;
isOnline: boolean;
} = { isOnline: false };
try {
// Latency test
const latencyStart = Date.now();
const latencyResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'latency' },
}),
});
if (latencyResponse.ok) {
await latencyResponse.json(); // Consume response
const latencyDuration = Date.now() - latencyStart;
results.latency = { durationMs: latencyDuration, speedMbps: 0 };
metrics.recordSpeedtest('latency', latencyDuration);
results.isOnline = true;
metrics.setOnlineStatus(true);
}
// Download test (100KB)
const downloadStart = Date.now();
const downloadResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'download', payloadSizeKB: 100 },
}),
});
if (downloadResponse.ok) {
const downloadData = await downloadResponse.json();
const downloadDuration = Date.now() - downloadStart;
const bytesTransferred = downloadData.response?.payload?.length || 0;
// Speed in Mbps: (bytes * 8) / (ms / 1000) / 1000000 = bytes * 8 / ms / 1000
const downloadSpeedMbps = downloadDuration > 0 ? (bytesTransferred * 8) / (downloadDuration * 1000) : 0;
results.download = { durationMs: downloadDuration, speedMbps: downloadSpeedMbps, bytesTransferred };
metrics.recordSpeedtest('download', downloadSpeedMbps);
}
// Upload test (100KB)
const uploadPayload = 'x'.repeat(100 * 1024);
const uploadStart = Date.now();
const uploadResponse = await fetch('/sw-typedrequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'serviceworker_speedtest',
request: { type: 'upload', payload: uploadPayload },
}),
});
if (uploadResponse.ok) {
const uploadDuration = Date.now() - uploadStart;
const uploadSpeedMbps = uploadDuration > 0 ? (uploadPayload.length * 8) / (uploadDuration * 1000) : 0;
results.upload = { durationMs: uploadDuration, speedMbps: uploadSpeedMbps, bytesTransferred: uploadPayload.length };
metrics.recordSpeedtest('upload', uploadSpeedMbps);
}
} catch (error) {
results.error = error instanceof Error ? error.message : String(error);
results.isOnline = false;
metrics.setOnlineStatus(false);
}
return new Response(JSON.stringify(results), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Generates JSON metrics response
*/
@@ -245,6 +333,76 @@ export class DashboardGenerator {
@keyframes blink {
50% { opacity: 0; }
}
.btn {
background: #1a1a1a;
border: 1px solid #00ff00;
color: #00ff00;
padding: 8px 16px;
cursor: pointer;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
transition: all 0.2s ease;
}
.btn:hover {
background: #00ff00;
color: #000;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.online-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
margin-bottom: 10px;
border-bottom: 1px dashed #333;
}
.online-dot {
width: 12px;
height: 12px;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.online-dot.online {
background: #00ff00;
box-shadow: 0 0 8px rgba(0, 255, 0, 0.5);
}
.online-dot.offline {
background: #ff4444;
box-shadow: 0 0 8px rgba(255, 68, 68, 0.5);
}
.speedtest-results {
margin-top: 10px;
}
.speed-bar {
height: 8px;
background: #1a1a1a;
border: 1px solid #333;
margin: 4px 0;
}
.speed-fill {
height: 100%;
background: #00aa00;
transition: width 0.5s ease;
}
.btn-row {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
</style>
</head>
<body>
@@ -379,6 +537,43 @@ export class DashboardGenerator {
<span class="value" id="start-time">${this.formatTimestamp(data.startTime)}</span>
</div>
</div>
<div class="panel">
<div class="panel-title">[ SPEEDTEST ]</div>
<div class="online-indicator">
<span class="online-dot ${data.speedtest.isOnline ? 'online' : 'offline'}" id="online-dot"></span>
<span class="value ${data.speedtest.isOnline ? 'success' : 'error'}" id="online-status">${data.speedtest.isOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="row">
<span class="label">Download:</span>
<span class="value" id="speed-download">${data.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps</span>
</div>
<div class="speed-bar">
<div class="speed-fill" id="speed-download-bar" style="width: ${Math.min(data.speedtest.lastDownloadSpeedMbps, 100)}%"></div>
</div>
<div class="row">
<span class="label">Upload:</span>
<span class="value" id="speed-upload">${data.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps</span>
</div>
<div class="speed-bar">
<div class="speed-fill" id="speed-upload-bar" style="width: ${Math.min(data.speedtest.lastUploadSpeedMbps, 100)}%"></div>
</div>
<div class="row">
<span class="label">Latency:</span>
<span class="value" id="speed-latency">${data.speedtest.lastLatencyMs.toFixed(0)} ms</span>
</div>
<div class="row">
<span class="label">Last Test:</span>
<span class="value" id="speed-last-test">${this.formatTimestamp(data.speedtest.lastTestTimestamp)}</span>
</div>
<div class="row">
<span class="label">Test Count:</span>
<span class="value" id="speed-test-count">${data.speedtest.testCount}</span>
</div>
<div class="btn-row">
<button class="btn" id="run-speedtest" onclick="runSpeedtest()">Run Test</button>
</div>
</div>
</div>
</div>
@@ -481,10 +676,82 @@ export class DashboardGenerator {
document.getElementById('conn-failed').textContent = formatNumber(data.connection.failedConnections);
document.getElementById('start-time').textContent = formatTimestamp(data.startTime);
// Speedtest
if (data.speedtest) {
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot ' + (data.speedtest.isOnline ? 'online' : 'offline');
onlineStatus.textContent = data.speedtest.isOnline ? 'Online' : 'Offline';
onlineStatus.className = 'value ' + (data.speedtest.isOnline ? 'success' : 'error');
document.getElementById('speed-download').textContent = data.speedtest.lastDownloadSpeedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-upload').textContent = data.speedtest.lastUploadSpeedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-latency').textContent = data.speedtest.lastLatencyMs.toFixed(0) + ' ms';
document.getElementById('speed-last-test').textContent = formatTimestamp(data.speedtest.lastTestTimestamp);
document.getElementById('speed-test-count').textContent = formatNumber(data.speedtest.testCount);
// Update speed bars (max 100 Mbps for visualization)
document.getElementById('speed-download-bar').style.width = Math.min(data.speedtest.lastDownloadSpeedMbps, 100) + '%';
document.getElementById('speed-upload-bar').style.width = Math.min(data.speedtest.lastUploadSpeedMbps, 100) + '%';
}
// Last refresh
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
// Speedtest function
let speedtestRunning = false;
async function runSpeedtest() {
if (speedtestRunning) return;
speedtestRunning = true;
const btn = document.getElementById('run-speedtest');
const originalText = btn.textContent;
btn.textContent = 'Testing...';
btn.disabled = true;
try {
const response = await fetch('/sw-dash/speedtest');
const result = await response.json();
// Update online status immediately
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot ' + (result.isOnline ? 'online' : 'offline');
onlineStatus.textContent = result.isOnline ? 'Online' : 'Offline';
onlineStatus.className = 'value ' + (result.isOnline ? 'success' : 'error');
if (result.download) {
document.getElementById('speed-download').textContent = result.download.speedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-download-bar').style.width = Math.min(result.download.speedMbps, 100) + '%';
}
if (result.upload) {
document.getElementById('speed-upload').textContent = result.upload.speedMbps.toFixed(2) + ' Mbps';
document.getElementById('speed-upload-bar').style.width = Math.min(result.upload.speedMbps, 100) + '%';
}
if (result.latency) {
document.getElementById('speed-latency').textContent = result.latency.durationMs.toFixed(0) + ' ms';
}
document.getElementById('speed-last-test').textContent = 'just now';
if (result.error) {
console.error('Speedtest error:', result.error);
}
} catch (err) {
console.error('Failed to run speedtest:', err);
// Mark as offline on error
const onlineDot = document.getElementById('online-dot');
const onlineStatus = document.getElementById('online-status');
onlineDot.className = 'online-dot offline';
onlineStatus.textContent = 'Offline';
onlineStatus.className = 'value error';
} finally {
speedtestRunning = false;
btn.textContent = originalText;
btn.disabled = false;
}
}
// Auto-refresh every 2 seconds
setInterval(async () => {
try {

View File

@@ -47,6 +47,18 @@ export interface IConnectionMetrics {
failedConnections: number;
}
/**
* Interface for speedtest metrics
*/
export interface ISpeedtestMetrics {
lastDownloadSpeedMbps: number;
lastUploadSpeedMbps: number;
lastLatencyMs: number;
lastTestTimestamp: number;
testCount: number;
isOnline: boolean;
}
/**
* Combined metrics interface
*/
@@ -55,6 +67,7 @@ export interface IServiceWorkerMetrics {
network: INetworkMetrics;
update: IUpdateMetrics;
connection: IConnectionMetrics;
speedtest: ISpeedtestMetrics;
startTime: number;
uptime: number;
}
@@ -103,6 +116,14 @@ export class MetricsCollector {
private successfulConnections = 0;
private failedConnections = 0;
// Speedtest metrics
private lastDownloadSpeedMbps = 0;
private lastUploadSpeedMbps = 0;
private lastLatencyMs = 0;
private lastSpeedtestTimestamp = 0;
private speedtestCount = 0;
private isOnline = true;
// Response time tracking
private responseTimes: IResponseTimeEntry[] = [];
private readonly maxResponseTimeEntries = 1000;
@@ -221,6 +242,47 @@ export class MetricsCollector {
this.connectedClients = count;
}
// ===================
// Speedtest Metrics
// ===================
public recordSpeedtest(type: 'download' | 'upload' | 'latency', value: number): void {
this.speedtestCount++;
this.lastSpeedtestTimestamp = Date.now();
this.isOnline = true;
switch (type) {
case 'download':
this.lastDownloadSpeedMbps = value;
logger.log('info', `[Metrics] Speedtest download: ${value.toFixed(2)} Mbps`);
break;
case 'upload':
this.lastUploadSpeedMbps = value;
logger.log('info', `[Metrics] Speedtest upload: ${value.toFixed(2)} Mbps`);
break;
case 'latency':
this.lastLatencyMs = value;
logger.log('info', `[Metrics] Speedtest latency: ${value.toFixed(0)} ms`);
break;
}
}
public setOnlineStatus(online: boolean): void {
this.isOnline = online;
logger.log('info', `[Metrics] Online status: ${online ? 'online' : 'offline'}`);
}
public getSpeedtestMetrics(): ISpeedtestMetrics {
return {
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
lastLatencyMs: this.lastLatencyMs,
lastTestTimestamp: this.lastSpeedtestTimestamp,
testCount: this.speedtestCount,
isOnline: this.isOnline,
};
}
// ===================
// Response Time Tracking
// ===================
@@ -309,6 +371,14 @@ export class MetricsCollector {
successfulConnections: this.successfulConnections,
failedConnections: this.failedConnections,
},
speedtest: {
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
lastLatencyMs: this.lastLatencyMs,
lastTestTimestamp: this.lastSpeedtestTimestamp,
testCount: this.speedtestCount,
isOnline: this.isOnline,
},
startTime: this.startTime,
uptime: now - this.startTime,
};
@@ -363,6 +433,13 @@ export class MetricsCollector {
this.successfulConnections = 0;
this.failedConnections = 0;
this.lastDownloadSpeedMbps = 0;
this.lastUploadSpeedMbps = 0;
this.lastLatencyMs = 0;
this.lastSpeedtestTimestamp = 0;
this.speedtestCount = 0;
// Note: isOnline is not reset as it reflects current state
this.responseTimes = [];
logger.log('info', '[Metrics] All metrics reset');