diff --git a/changelog.md b/changelog.md index 47c0610..dd01df1 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index cb18c85..afffeb9 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.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.' } diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index df8c40e..6e9f6f6 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -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') || diff --git a/ts_web_serviceworker/classes.dashboard.ts b/ts_web_serviceworker/classes.dashboard.ts new file mode 100644 index 0000000..2e56673 --- /dev/null +++ b/ts_web_serviceworker/classes.dashboard.ts @@ -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 ` + + + + + SW Dashboard + + + +
+
+ [SW-DASH] Service Worker Metrics + Uptime: ${this.formatDuration(data.uptime)} +
+ +
+
+
+
[ CACHE ]
+
+
+
+ ${hitRate}% hit rate +
+
+
+ Hits: + ${this.formatNumber(data.cache.hits)} +
+
+ Misses: + ${this.formatNumber(data.cache.misses)} +
+
+ Errors: + ${this.formatNumber(data.cache.errors)} +
+
+ From Cache: + ${this.formatBytes(data.cache.bytesServedFromCache)} +
+
+ Fetched: + ${this.formatBytes(data.cache.bytesFetched)} +
+
+ Avg Response: + ${data.cache.averageResponseTime}ms +
+
+ +
+
[ NETWORK ]
+
+
+
+ ${successRate}% success +
+
+
+ Total Requests: + ${this.formatNumber(data.network.totalRequests)} +
+
+ Successful: + ${this.formatNumber(data.network.successfulRequests)} +
+
+ Failed: + ${this.formatNumber(data.network.failedRequests)} +
+
+ Timeouts: + ${this.formatNumber(data.network.timeouts)} +
+
+ Avg Latency: + ${data.network.averageLatency}ms +
+
+ Transferred: + ${this.formatBytes(data.network.totalBytesTransferred)} +
+
+ +
+
[ UPDATES ]
+
+ Total Checks: + ${this.formatNumber(data.update.totalChecks)} +
+
+ Successful: + ${this.formatNumber(data.update.successfulChecks)} +
+
+ Failed: + ${this.formatNumber(data.update.failedChecks)} +
+
+ Updates Found: + ${this.formatNumber(data.update.updatesFound)} +
+
+ Updates Applied: + ${this.formatNumber(data.update.updatesApplied)} +
+
+ Last Check: + ${this.formatTimestamp(data.update.lastCheckTimestamp)} +
+
+ Last Update: + ${this.formatTimestamp(data.update.lastUpdateTimestamp)} +
+
+ +
+
[ CONNECTIONS ]
+
+ Active Clients: + ${this.formatNumber(data.connection.connectedClients)} +
+
+ Total Attempts: + ${this.formatNumber(data.connection.totalConnectionAttempts)} +
+
+ Successful: + ${this.formatNumber(data.connection.successfulConnections)} +
+
+ Failed: + ${this.formatNumber(data.connection.failedConnections)} +
+
+ Started: + ${this.formatTimestamp(data.startTime)} +
+
+
+
+ + +
+ + + +`; + } + + /** + * 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; +};