diff --git a/changelog.md b/changelog.md index 3a5f5eb..9119a94 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 64616f6..cca325c 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/servertools/tools.serviceworker.ts b/ts/servertools/tools.serviceworker.ts index 1c83a50..71fb739 100644 --- a/ts/servertools/tools.serviceworker.ts +++ b/ts/servertools/tools.serviceworker.ts @@ -84,6 +84,48 @@ export const addServiceWorkerRoute = ( ) ); + // Speedtest handler for measuring connection speed + typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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, diff --git a/ts_interfaces/serviceworker.ts b/ts_interfaces/serviceworker.ts index 79fb7d2..43cf41a 100644 --- a/ts_interfaces/serviceworker.ts +++ b/ts_interfaces/serviceworker.ts @@ -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 + }; } \ No newline at end of file diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index 6e9f6f6..3ab363e 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -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 ( diff --git a/ts_web_serviceworker/classes.dashboard.ts b/ts_web_serviceworker/classes.dashboard.ts index 2e56673..fac04f6 100644 --- a/ts_web_serviceworker/classes.dashboard.ts +++ b/ts_web_serviceworker/classes.dashboard.ts @@ -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 { + 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; + } @@ -379,6 +537,43 @@ export class DashboardGenerator { ${this.formatTimestamp(data.startTime)} + +
+
[ SPEEDTEST ]
+
+ + ${data.speedtest.isOnline ? 'Online' : 'Offline'} +
+
+ Download: + ${data.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps +
+
+
+
+
+ Upload: + ${data.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps +
+
+
+
+
+ Latency: + ${data.speedtest.lastLatencyMs.toFixed(0)} ms +
+
+ Last Test: + ${this.formatTimestamp(data.speedtest.lastTestTimestamp)} +
+
+ Test Count: + ${data.speedtest.testCount} +
+
+ +
+
@@ -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 { diff --git a/ts_web_serviceworker/classes.metrics.ts b/ts_web_serviceworker/classes.metrics.ts index 0bd8b32..311951c 100644 --- a/ts_web_serviceworker/classes.metrics.ts +++ b/ts_web_serviceworker/classes.metrics.ts @@ -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');