feat(web_serviceworker): Add service-worker dashboard and request deduplication; improve caching, metrics and error handling

This commit is contained in:
2025-12-04 11:14:04 +00:00
parent 8010977d05
commit 84256fd8fc
4 changed files with 589 additions and 1 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## 2025-12-04 - 6.2.0 - feat(web_serviceworker)
Add service-worker dashboard and request deduplication; improve caching, metrics and error handling
- Add DashboardGenerator to serve an interactive terminal-style dashboard at /sw-dash and a metrics JSON endpoint at /sw-dash/metrics
- Introduce request deduplication in CacheManager to coalesce concurrent network fetches and avoid duplicate requests
- Add periodic cleanup for in-flight request tracking to prevent unbounded memory growth
- Improve caching flow: preserve response headers (excluding cache-control headers), ensure CORS headers and Cross-Origin-Resource-Policy, and store response bodies as blobs to avoid locked stream issues
- Provide clearer 500 error HTML responses for failed fetches to aid debugging
- Integrate metrics and event emissions for network and cache operations (record request success/failure, cache hits/misses, and emit corresponding events)
## 2025-12-04 - 6.1.0 - feat(web_serviceworker)
Enhance service worker subsystem: add metrics, event bus, error handling, config and caching/update improvements; make client connection & polling robust

View File

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

View File

@@ -5,6 +5,7 @@ import { ServiceWorker } from './classes.serviceworker.js';
import { getMetricsCollector } from './classes.metrics.js';
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js';
import { getDashboardGenerator } from './classes.dashboard.js';
export class CacheManager {
public losslessServiceWorkerRef: ServiceWorker;
@@ -203,6 +204,18 @@ export class CacheManager {
const originalRequest: Request = fetchEventArg.request;
const parsedUrl = new URL(originalRequest.url);
// Handle dashboard routes - serve directly from service worker
if (parsedUrl.pathname === '/sw-dash' || parsedUrl.pathname === '/sw-dash/') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveDashboard()));
return;
}
if (parsedUrl.pathname === '/sw-dash/metrics') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveMetrics()));
return;
}
// Block requests that we don't want the service worker to handle.
if (
parsedUrl.hostname.includes('paddle.com') ||

View File

@@ -0,0 +1,565 @@
import { getMetricsCollector, type IServiceWorkerMetrics } from './classes.metrics.js';
/**
* Dashboard generator that creates a terminal-like metrics display
* served directly from the service worker
*/
export class DashboardGenerator {
/**
* Serves the dashboard HTML page
*/
public serveDashboard(): Response {
return new Response(this.generateDashboardHtml(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
/**
* Serves the metrics JSON endpoint
*/
public serveMetrics(): Response {
return new Response(this.generateMetricsJson(), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Generates JSON metrics response
*/
public generateMetricsJson(): string {
const metrics = getMetricsCollector();
return JSON.stringify({
...metrics.getMetrics(),
cacheHitRate: metrics.getCacheHitRate(),
networkSuccessRate: metrics.getNetworkSuccessRate(),
summary: metrics.getSummary(),
});
}
/**
* Generates the complete HTML dashboard page with terminal-like styling
*/
public generateDashboardHtml(): string {
const metrics = getMetricsCollector();
const data = metrics.getMetrics();
const hitRate = metrics.getCacheHitRate();
const successRate = metrics.getNetworkSuccessRate();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SW Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #00ff00;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.4;
padding: 20px;
min-height: 100vh;
}
.terminal {
max-width: 900px;
margin: 0 auto;
border: 1px solid #00ff00;
background: #0d0d0d;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.1);
}
.header {
border-bottom: 1px solid #00ff00;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
background: #111;
}
.title {
color: #00ff00;
font-weight: bold;
font-size: 16px;
}
.uptime {
color: #888;
}
.content {
padding: 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 15px;
}
.panel {
border: 1px solid #333;
padding: 12px;
background: #0a0a0a;
}
.panel-title {
color: #00ffff;
font-weight: bold;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px dashed #333;
}
.row {
display: flex;
justify-content: space-between;
padding: 3px 0;
}
.label {
color: #888;
}
.value {
color: #00ff00;
}
.value.warning {
color: #ffff00;
}
.value.error {
color: #ff4444;
}
.value.success {
color: #00ff00;
}
.gauge {
margin: 8px 0;
}
.gauge-bar {
height: 16px;
background: #1a1a1a;
border: 1px solid #333;
position: relative;
font-size: 12px;
}
.gauge-fill {
height: 100%;
transition: width 0.3s ease;
}
.gauge-fill.good {
background: #00aa00;
}
.gauge-fill.warning {
background: #aaaa00;
}
.gauge-fill.bad {
background: #aa0000;
}
.gauge-text {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-weight: bold;
text-shadow: 1px 1px 2px #000;
}
.footer {
border-top: 1px solid #00ff00;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
background: #111;
font-size: 12px;
}
.refresh-info {
color: #888;
}
.status {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00ff00;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.ascii-bar {
font-family: monospace;
letter-spacing: 0;
}
.prompt {
color: #00ff00;
}
.cursor {
display: inline-block;
width: 8px;
height: 14px;
background: #00ff00;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 2px;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>
</head>
<body>
<div class="terminal">
<div class="header">
<span class="title">[SW-DASH] Service Worker Metrics</span>
<span class="uptime" id="uptime">Uptime: ${this.formatDuration(data.uptime)}</span>
</div>
<div class="content">
<div class="grid">
<div class="panel">
<div class="panel-title">[ CACHE ]</div>
<div class="gauge">
<div class="gauge-bar">
<div class="gauge-fill ${this.getGaugeClass(hitRate)}" style="width: ${hitRate}%"></div>
<span class="gauge-text">${hitRate}% hit rate</span>
</div>
</div>
<div class="row">
<span class="label">Hits:</span>
<span class="value success" id="cache-hits">${this.formatNumber(data.cache.hits)}</span>
</div>
<div class="row">
<span class="label">Misses:</span>
<span class="value warning" id="cache-misses">${this.formatNumber(data.cache.misses)}</span>
</div>
<div class="row">
<span class="label">Errors:</span>
<span class="value ${data.cache.errors > 0 ? 'error' : ''}" id="cache-errors">${this.formatNumber(data.cache.errors)}</span>
</div>
<div class="row">
<span class="label">From Cache:</span>
<span class="value" id="cache-bytes">${this.formatBytes(data.cache.bytesServedFromCache)}</span>
</div>
<div class="row">
<span class="label">Fetched:</span>
<span class="value" id="cache-fetched">${this.formatBytes(data.cache.bytesFetched)}</span>
</div>
<div class="row">
<span class="label">Avg Response:</span>
<span class="value" id="cache-response">${data.cache.averageResponseTime}ms</span>
</div>
</div>
<div class="panel">
<div class="panel-title">[ NETWORK ]</div>
<div class="gauge">
<div class="gauge-bar">
<div class="gauge-fill ${this.getGaugeClass(successRate)}" style="width: ${successRate}%"></div>
<span class="gauge-text">${successRate}% success</span>
</div>
</div>
<div class="row">
<span class="label">Total Requests:</span>
<span class="value" id="net-total">${this.formatNumber(data.network.totalRequests)}</span>
</div>
<div class="row">
<span class="label">Successful:</span>
<span class="value success" id="net-success">${this.formatNumber(data.network.successfulRequests)}</span>
</div>
<div class="row">
<span class="label">Failed:</span>
<span class="value ${data.network.failedRequests > 0 ? 'error' : ''}" id="net-failed">${this.formatNumber(data.network.failedRequests)}</span>
</div>
<div class="row">
<span class="label">Timeouts:</span>
<span class="value ${data.network.timeouts > 0 ? 'warning' : ''}" id="net-timeouts">${this.formatNumber(data.network.timeouts)}</span>
</div>
<div class="row">
<span class="label">Avg Latency:</span>
<span class="value" id="net-latency">${data.network.averageLatency}ms</span>
</div>
<div class="row">
<span class="label">Transferred:</span>
<span class="value" id="net-bytes">${this.formatBytes(data.network.totalBytesTransferred)}</span>
</div>
</div>
<div class="panel">
<div class="panel-title">[ UPDATES ]</div>
<div class="row">
<span class="label">Total Checks:</span>
<span class="value" id="upd-checks">${this.formatNumber(data.update.totalChecks)}</span>
</div>
<div class="row">
<span class="label">Successful:</span>
<span class="value success" id="upd-success">${this.formatNumber(data.update.successfulChecks)}</span>
</div>
<div class="row">
<span class="label">Failed:</span>
<span class="value ${data.update.failedChecks > 0 ? 'error' : ''}" id="upd-failed">${this.formatNumber(data.update.failedChecks)}</span>
</div>
<div class="row">
<span class="label">Updates Found:</span>
<span class="value" id="upd-found">${this.formatNumber(data.update.updatesFound)}</span>
</div>
<div class="row">
<span class="label">Updates Applied:</span>
<span class="value success" id="upd-applied">${this.formatNumber(data.update.updatesApplied)}</span>
</div>
<div class="row">
<span class="label">Last Check:</span>
<span class="value" id="upd-last-check">${this.formatTimestamp(data.update.lastCheckTimestamp)}</span>
</div>
<div class="row">
<span class="label">Last Update:</span>
<span class="value" id="upd-last-update">${this.formatTimestamp(data.update.lastUpdateTimestamp)}</span>
</div>
</div>
<div class="panel">
<div class="panel-title">[ CONNECTIONS ]</div>
<div class="row">
<span class="label">Active Clients:</span>
<span class="value success" id="conn-clients">${this.formatNumber(data.connection.connectedClients)}</span>
</div>
<div class="row">
<span class="label">Total Attempts:</span>
<span class="value" id="conn-attempts">${this.formatNumber(data.connection.totalConnectionAttempts)}</span>
</div>
<div class="row">
<span class="label">Successful:</span>
<span class="value success" id="conn-success">${this.formatNumber(data.connection.successfulConnections)}</span>
</div>
<div class="row">
<span class="label">Failed:</span>
<span class="value ${data.connection.failedConnections > 0 ? 'error' : ''}" id="conn-failed">${this.formatNumber(data.connection.failedConnections)}</span>
</div>
<div class="row" style="margin-top: 15px; padding-top: 10px; border-top: 1px dashed #333;">
<span class="label">Started:</span>
<span class="value" id="start-time">${this.formatTimestamp(data.startTime)}</span>
</div>
</div>
</div>
</div>
<div class="footer">
<span class="refresh-info">
<span class="prompt">$</span> Last refresh: <span id="last-refresh">${new Date().toLocaleTimeString()}</span><span class="cursor"></span>
</span>
<div class="status">
<span class="status-dot"></span>
<span>Auto-refresh: 2s</span>
</div>
</div>
</div>
<script>
function formatNumber(num) {
return num.toLocaleString();
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
return seconds + 's';
}
function formatTimestamp(ts) {
if (!ts || ts === 0) return 'never';
const ago = Date.now() - ts;
if (ago < 60000) return Math.floor(ago / 1000) + 's ago';
if (ago < 3600000) return Math.floor(ago / 60000) + 'm ago';
if (ago < 86400000) return Math.floor(ago / 3600000) + 'h ago';
return new Date(ts).toLocaleDateString();
}
function getGaugeClass(rate) {
if (rate >= 80) return 'good';
if (rate >= 50) return 'warning';
return 'bad';
}
function updateDashboard(data) {
// Uptime
document.getElementById('uptime').textContent = 'Uptime: ' + formatDuration(data.uptime);
// Cache
document.getElementById('cache-hits').textContent = formatNumber(data.cache.hits);
document.getElementById('cache-misses').textContent = formatNumber(data.cache.misses);
document.getElementById('cache-errors').textContent = formatNumber(data.cache.errors);
document.getElementById('cache-bytes').textContent = formatBytes(data.cache.bytesServedFromCache);
document.getElementById('cache-fetched').textContent = formatBytes(data.cache.bytesFetched);
document.getElementById('cache-response').textContent = data.cache.averageResponseTime + 'ms';
// Update cache gauge
const cacheGauge = document.querySelector('.panel:nth-child(1) .gauge-fill');
cacheGauge.style.width = data.cacheHitRate + '%';
cacheGauge.className = 'gauge-fill ' + getGaugeClass(data.cacheHitRate);
document.querySelector('.panel:nth-child(1) .gauge-text').textContent = data.cacheHitRate + '% hit rate';
// Network
document.getElementById('net-total').textContent = formatNumber(data.network.totalRequests);
document.getElementById('net-success').textContent = formatNumber(data.network.successfulRequests);
document.getElementById('net-failed').textContent = formatNumber(data.network.failedRequests);
document.getElementById('net-timeouts').textContent = formatNumber(data.network.timeouts);
document.getElementById('net-latency').textContent = data.network.averageLatency + 'ms';
document.getElementById('net-bytes').textContent = formatBytes(data.network.totalBytesTransferred);
// Update network gauge
const netGauge = document.querySelector('.panel:nth-child(2) .gauge-fill');
netGauge.style.width = data.networkSuccessRate + '%';
netGauge.className = 'gauge-fill ' + getGaugeClass(data.networkSuccessRate);
document.querySelector('.panel:nth-child(2) .gauge-text').textContent = data.networkSuccessRate + '% success';
// Updates
document.getElementById('upd-checks').textContent = formatNumber(data.update.totalChecks);
document.getElementById('upd-success').textContent = formatNumber(data.update.successfulChecks);
document.getElementById('upd-failed').textContent = formatNumber(data.update.failedChecks);
document.getElementById('upd-found').textContent = formatNumber(data.update.updatesFound);
document.getElementById('upd-applied').textContent = formatNumber(data.update.updatesApplied);
document.getElementById('upd-last-check').textContent = formatTimestamp(data.update.lastCheckTimestamp);
document.getElementById('upd-last-update').textContent = formatTimestamp(data.update.lastUpdateTimestamp);
// Connections
document.getElementById('conn-clients').textContent = formatNumber(data.connection.connectedClients);
document.getElementById('conn-attempts').textContent = formatNumber(data.connection.totalConnectionAttempts);
document.getElementById('conn-success').textContent = formatNumber(data.connection.successfulConnections);
document.getElementById('conn-failed').textContent = formatNumber(data.connection.failedConnections);
document.getElementById('start-time').textContent = formatTimestamp(data.startTime);
// Last refresh
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
// Auto-refresh every 2 seconds
setInterval(async () => {
try {
const response = await fetch('/sw-dash/metrics');
const data = await response.json();
updateDashboard(data);
} catch (err) {
console.error('Failed to fetch metrics:', err);
}
}, 2000);
</script>
</body>
</html>`;
}
/**
* Format bytes to human-readable string
*/
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Format duration to human-readable string
*/
private formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
/**
* Format timestamp to relative time string
*/
private formatTimestamp(ts: number): string {
if (!ts || ts === 0) return 'never';
const ago = Date.now() - ts;
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
if (ago < 86400000) return `${Math.floor(ago / 3600000)}h ago`;
return new Date(ts).toLocaleDateString();
}
/**
* Format number with thousands separator
*/
private formatNumber(num: number): string {
return num.toLocaleString();
}
/**
* Get gauge class based on percentage
*/
private getGaugeClass(rate: number): string {
if (rate >= 80) return 'good';
if (rate >= 50) return 'warning';
return 'bad';
}
}
// Export singleton getter
let dashboardInstance: DashboardGenerator | null = null;
export const getDashboardGenerator = (): DashboardGenerator => {
if (!dashboardInstance) {
dashboardInstance = new DashboardGenerator();
}
return dashboardInstance;
};