From 5d9e914b23abc96194da6705c5580998b3b13be6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 4 Dec 2025 12:37:01 +0000 Subject: [PATCH] feat(web_serviceworker): Add per-resource metrics and request deduplication to service worker cache manager --- changelog.md | 10 + ts/00_commitinfo_data.ts | 2 +- ts_web_serviceworker/classes.cachemanager.ts | 13 + ts_web_serviceworker/classes.dashboard.ts | 900 +++++++++++-------- ts_web_serviceworker/classes.metrics.ts | 215 +++++ 5 files changed, 741 insertions(+), 399 deletions(-) diff --git a/changelog.md b/changelog.md index 2c9cc9b..7f0e00d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-12-04 - 6.7.0 - feat(web_serviceworker) +Add per-resource metrics and request deduplication to service worker cache manager + +- Introduce per-resource tracking in metrics: ICachedResource, IDomainStats, IContentTypeStats and a resourceStats map. +- Add MetricsCollector.recordResourceAccess(...) to record hits/misses, content-type and size; provide getters: getCachedResources, getDomainStats, getContentTypeStats and getResourceCount. +- Reset resourceStats when metrics are reset and limit resource entries via cleanupResourceStats to avoid memory bloat. +- Add request deduplication in CacheManager (fetchWithDeduplication) to coalesce identical concurrent fetches and a periodic safety cleanup for in-flight requests. +- Record resource accesses on cache hit and when storing new cache entries (captures content-type and body size). +- Expose a dashboard resources endpoint (/sw-dash/resources) served by the SW dashboard to return detailed resource data for SPA views. + ## 2025-12-04 - 6.6.0 - feat(web_serviceworker) Enable service worker dashboard speedtests via TypedSocket, expose ServiceWorker instance to dashboard, and add server-side speedtest handler diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 79fe259..3e974bf 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.6.0', + version: '6.7.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 3ab363e..6216824 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -220,6 +220,11 @@ export class CacheManager { fetchEventArg.respondWith(dashboard.runSpeedtest()); return; } + if (parsedUrl.pathname === '/sw-dash/resources') { + const dashboard = getDashboardGenerator(); + fetchEventArg.respondWith(Promise.resolve(dashboard.serveResources())); + return; + } // Block requests that we don't want the service worker to handle. if ( @@ -260,7 +265,9 @@ export class CacheManager { // Record cache hit const contentLength = cachedResponse.headers.get('content-length'); const bytes = contentLength ? parseInt(contentLength, 10) : 0; + const contentType = cachedResponse.headers.get('content-type') || 'unknown'; metrics.recordCacheHit(matchRequest.url, bytes); + metrics.recordResourceAccess(matchRequest.url, true, contentType, bytes); eventBus.emitCacheHit(matchRequest.url, bytes); logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`); @@ -335,6 +342,12 @@ export class CacheManager { }); await cache.put(matchRequest, newCachedResponse); + + // Record resource access for per-resource tracking + const cachedContentType = newResponse.headers.get('content-type') || 'unknown'; + const cachedSize = bodyBlob.size; + metrics.recordResourceAccess(matchRequest.url, false, cachedContentType, cachedSize); + logger.log('ok', `NOWCACHED: Cached response for ${matchRequest.url} for subsequent requests!`); done.resolve(newResponse); } catch (err) { diff --git a/ts_web_serviceworker/classes.dashboard.ts b/ts_web_serviceworker/classes.dashboard.ts index 987257e..2f75958 100644 --- a/ts_web_serviceworker/classes.dashboard.ts +++ b/ts_web_serviceworker/classes.dashboard.ts @@ -4,7 +4,7 @@ import * as interfaces from './env.js'; /** * Dashboard generator that creates a terminal-like metrics display - * served directly from the service worker + * served directly from the service worker as a single-page app */ export class DashboardGenerator { /** @@ -31,6 +31,18 @@ export class DashboardGenerator { }); } + /** + * Serves detailed resource data for the SPA views + */ + public serveResources(): Response { + return new Response(this.generateResourcesJson(), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + /** * Runs a speedtest and returns the results */ @@ -113,18 +125,33 @@ export class DashboardGenerator { ...metrics.getMetrics(), cacheHitRate: metrics.getCacheHitRate(), networkSuccessRate: metrics.getNetworkSuccessRate(), + resourceCount: metrics.getResourceCount(), summary: metrics.getSummary(), }); } /** - * Generates the complete HTML dashboard page with terminal-like styling + * Generates JSON response with detailed resource data + */ + public generateResourcesJson(): string { + const metrics = getMetricsCollector(); + return JSON.stringify({ + resources: metrics.getCachedResources(), + domains: metrics.getDomainStats(), + contentTypes: metrics.getContentTypeStats(), + resourceCount: metrics.getResourceCount(), + }); + } + + /** + * Generates the complete HTML dashboard page as a SPA with tab navigation */ public generateDashboardHtml(): string { const metrics = getMetricsCollector(); const data = metrics.getMetrics(); const hitRate = metrics.getCacheHitRate(); const successRate = metrics.getNetworkSuccessRate(); + const resourceCount = metrics.getResourceCount(); return ` @@ -133,12 +160,7 @@ export class DashboardGenerator { SW Dashboard
- [SW-DASH] Service Worker Metrics + [SW-DASH] Service Worker Dashboard Uptime: ${this.formatDuration(data.uptime)}
+ +
-
-
-
[ CACHE ]
-
-
-
- ${hitRate}% hit rate + +
+
+
+
[ 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)}
+
Resources:${resourceCount}
+
+ +
+
[ 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)}
+
+ +
+
[ 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)}
-
- 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 +
+
[ SPEEDTEST ]
+
+ + ${data.speedtest.isOnline ? 'Online' : 'Offline'}
-
-
- 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)} +
Download:${data.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps
+
+
Upload:${data.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps
+
+
Latency:${data.speedtest.lastLatencyMs.toFixed(0)} ms
+
+
-
-
[ 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)} -
+ +
+
+ + Loading...
- -
-
[ 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)} -
+
+ + + + + + + + + + + + + +
URL ^Type ^Size ^Hits ^Misses ^Hit Rate ^Last Access ^
+
-
-
[ 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} -
-
- -
+ +
+
+ + Loading... +
+
+ + + + + + + + + + + + +
Domain ^Resources ^Total Size ^Hits ^Misses ^Hit Rate ^
+
+
+ + +
+
+ + Loading... +
+
+ + + + + + + + + + + + +
Content Type ^Resources ^Total Size ^Hits ^Misses ^Hit Rate ^
@@ -576,64 +572,196 @@ export class DashboardGenerator {
`; diff --git a/ts_web_serviceworker/classes.metrics.ts b/ts_web_serviceworker/classes.metrics.ts index 311951c..483c444 100644 --- a/ts_web_serviceworker/classes.metrics.ts +++ b/ts_web_serviceworker/classes.metrics.ts @@ -12,6 +12,44 @@ export interface ICacheMetrics { averageResponseTime: number; } +/** + * Interface for per-resource tracking + */ +export interface ICachedResource { + url: string; + domain: string; + contentType: string; + size: number; + hitCount: number; + missCount: number; + lastAccessed: number; + cachedAt: number; +} + +/** + * Interface for domain statistics + */ +export interface IDomainStats { + domain: string; + totalResources: number; + totalSize: number; + totalHits: number; + totalMisses: number; + hitRate: number; +} + +/** + * Interface for content-type statistics + */ +export interface IContentTypeStats { + contentType: string; + totalResources: number; + totalSize: number; + totalHits: number; + totalMisses: number; + hitRate: number; +} + /** * Interface for network metrics */ @@ -129,6 +167,10 @@ export class MetricsCollector { private readonly maxResponseTimeEntries = 1000; private readonly responseTimeWindow = 5 * 60 * 1000; // 5 minutes + // Per-resource tracking + private resourceStats: Map = new Map(); + private readonly maxResourceEntries = 500; + // Start time private readonly startTime: number; @@ -441,6 +483,7 @@ export class MetricsCollector { // Note: isOnline is not reset as it reflects current state this.responseTimes = []; + this.resourceStats.clear(); logger.log('info', '[Metrics] All metrics reset'); } @@ -457,6 +500,178 @@ export class MetricsCollector { `Uptime: ${Math.round(metrics.uptime / 1000)}s`, ].join(' | '); } + + // =================== + // Per-Resource Tracking + // =================== + + /** + * Extracts domain from URL + */ + private extractDomain(url: string): string { + try { + const parsedUrl = new URL(url); + return parsedUrl.hostname; + } catch { + return 'unknown'; + } + } + + /** + * Records a resource access (cache hit or miss) with details + */ + public recordResourceAccess( + url: string, + isHit: boolean, + contentType: string = 'unknown', + size: number = 0 + ): void { + const now = Date.now(); + const domain = this.extractDomain(url); + + let resource = this.resourceStats.get(url); + + if (!resource) { + resource = { + url, + domain, + contentType, + size, + hitCount: 0, + missCount: 0, + lastAccessed: now, + cachedAt: now, + }; + this.resourceStats.set(url, resource); + } + + // Update resource stats + if (isHit) { + resource.hitCount++; + } else { + resource.missCount++; + } + resource.lastAccessed = now; + + // Update content-type and size if provided (may come from response headers) + if (contentType !== 'unknown') { + resource.contentType = contentType; + } + if (size > 0) { + resource.size = size; + } + + // Trim old entries if needed + this.cleanupResourceStats(); + } + + /** + * Cleans up old resource entries to prevent memory bloat + */ + private cleanupResourceStats(): void { + if (this.resourceStats.size <= this.maxResourceEntries) { + return; + } + + // Convert to array and sort by lastAccessed (oldest first) + const entries = Array.from(this.resourceStats.entries()) + .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + // Remove oldest entries until we're under the limit + const toRemove = entries.slice(0, entries.length - this.maxResourceEntries); + for (const [url] of toRemove) { + this.resourceStats.delete(url); + } + } + + /** + * Gets all cached resources + */ + public getCachedResources(): ICachedResource[] { + return Array.from(this.resourceStats.values()); + } + + /** + * Gets domain statistics + */ + public getDomainStats(): IDomainStats[] { + const domainMap = new Map(); + + for (const resource of this.resourceStats.values()) { + let stats = domainMap.get(resource.domain); + + if (!stats) { + stats = { + domain: resource.domain, + totalResources: 0, + totalSize: 0, + totalHits: 0, + totalMisses: 0, + hitRate: 0, + }; + domainMap.set(resource.domain, stats); + } + + stats.totalResources++; + stats.totalSize += resource.size; + stats.totalHits += resource.hitCount; + stats.totalMisses += resource.missCount; + } + + // Calculate hit rates + for (const stats of domainMap.values()) { + const total = stats.totalHits + stats.totalMisses; + stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0; + } + + return Array.from(domainMap.values()); + } + + /** + * Gets content-type statistics + */ + public getContentTypeStats(): IContentTypeStats[] { + const typeMap = new Map(); + + for (const resource of this.resourceStats.values()) { + // Normalize content-type (extract base type) + const baseType = resource.contentType.split(';')[0].trim() || 'unknown'; + + let stats = typeMap.get(baseType); + + if (!stats) { + stats = { + contentType: baseType, + totalResources: 0, + totalSize: 0, + totalHits: 0, + totalMisses: 0, + hitRate: 0, + }; + typeMap.set(baseType, stats); + } + + stats.totalResources++; + stats.totalSize += resource.size; + stats.totalHits += resource.hitCount; + stats.totalMisses += resource.missCount; + } + + // Calculate hit rates + for (const stats of typeMap.values()) { + const total = stats.totalHits + stats.totalMisses; + stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0; + } + + return Array.from(typeMap.values()); + } + + /** + * Gets resource count + */ + public getResourceCount(): number { + return this.resourceStats.size; + } } // Export singleton getter for convenience