From 299e3ac33f39c3f1e62e354d053b5c157779b343 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 4 Dec 2025 16:25:51 +0000 Subject: [PATCH] feat(serviceworker): Add real-time service worker push updates and DeesComms integration (metrics, events, resource caching) --- changelog.md | 12 ++ ts/00_commitinfo_data.ts | 2 +- ts_interfaces/serviceworker.ts | 73 +++++++ ts_swdash/plugins.ts | 4 + ts_swdash/sw-dash-app.ts | 194 ++++++++++++++++-- ts_swdash/sw-dash-events.ts | 33 +++ ts_web_serviceworker/classes.backend.ts | 107 +++++++++- ts_web_serviceworker/classes.metrics.ts | 19 ++ .../classes.persistentstore.ts | 31 ++- ts_web_serviceworker/init.ts | 3 + 10 files changed, 438 insertions(+), 40 deletions(-) diff --git a/changelog.md b/changelog.md index b6dc0b1..ba4d0c7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-12-04 - 7.5.0 - feat(serviceworker) +Add real-time service worker push updates and DeesComms integration (metrics, events, resource caching) + +- Integrate DeesComms push channel for real-time SW → client communication and export/consume deesComms in relevant plugin modules. +- Add typed push message interfaces for events, metrics snapshots and resource-cached notifications in serviceworker interfaces. +- Implement backend push methods: pushEvent, pushMetricsUpdate (with 500ms throttle) and pushResourceCached in ServiceworkerBackend. +- Trigger push updates from MetricsCollector and PersistentStore so metrics and logged events are broadcast to connected clients. +- Add client-side DeesComms handlers in sw-dash app: receive metrics, event logs and resource notifications; add heartbeat and initial HTTP seed to maintain SW health state. +- Add event push listener and cleanup in sw-dash-events component to prepend incoming events and avoid leaks. +- Expose getServiceWorkerBackend() from SW init for internal modules to call push methods. +- Misc: implement request deduplication and various robustness improvements (throttling, heartbeat, safer polling, removed noisy debug logs). + ## 2025-12-04 - 7.4.1 - fix(web_serviceworker) Improve service worker persistence, metrics and caching robustness diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6cb9b82..64b29e3 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: '7.4.1', + version: '7.5.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_interfaces/serviceworker.ts b/ts_interfaces/serviceworker.ts index 15b2139..19f6a8a 100644 --- a/ts_interfaces/serviceworker.ts +++ b/ts_interfaces/serviceworker.ts @@ -436,4 +436,77 @@ export interface IRequest_Serviceworker_GetEventCount response: { count: number; }; +} + +// =============== +// Push message interfaces (SW → Clients via DeesComms) +// =============== + +/** + * Push notification when a new event is logged + * Sent via DeesComms BroadcastChannel + */ +export interface IMessage_Serviceworker_EventLogged + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IMessage_Serviceworker_EventLogged + > { + method: 'serviceworker_eventLogged'; + request: IEventLogEntry; + response: {}; +} + +/** + * Metrics snapshot for push updates + */ +export interface IMetricsSnapshot { + cache: { + hits: number; + misses: number; + errors: number; + bytesServedFromCache: number; + bytesFetched: number; + }; + network: { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + }; + cacheHitRate: number; + networkSuccessRate: number; + resourceCount: number; + uptime: number; + timestamp: number; +} + +/** + * Push notification for metrics updates + * Sent via DeesComms BroadcastChannel (throttled) + */ +export interface IMessage_Serviceworker_MetricsUpdate + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IMessage_Serviceworker_MetricsUpdate + > { + method: 'serviceworker_metricsUpdate'; + request: IMetricsSnapshot; + response: {}; +} + +/** + * Push notification when a new resource is cached + */ +export interface IMessage_Serviceworker_ResourceCached + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IMessage_Serviceworker_ResourceCached + > { + method: 'serviceworker_resourceCached'; + request: { + url: string; + contentType: string; + size: number; + cached: boolean; + }; + response: {}; } \ No newline at end of file diff --git a/ts_swdash/plugins.ts b/ts_swdash/plugins.ts index d239102..416389d 100644 --- a/ts_swdash/plugins.ts +++ b/ts_swdash/plugins.ts @@ -3,6 +3,9 @@ import { LitElement, html, css } from 'lit'; import type { CSSResult, TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +// DeesComms for push communication +import * as deesComms from '@design.estate/dees-comms'; + export { LitElement, html, @@ -10,6 +13,7 @@ export { customElement, property, state, + deesComms, }; export type { CSSResult, TemplateResult }; diff --git a/ts_swdash/sw-dash-app.ts b/ts_swdash/sw-dash-app.ts index 2c68e57..509825a 100644 --- a/ts_swdash/sw-dash-app.ts +++ b/ts_swdash/sw-dash-app.ts @@ -1,10 +1,11 @@ -import { LitElement, html, css, state, customElement } from './plugins.js'; +import { LitElement, html, css, state, customElement, deesComms } from './plugins.js'; import type { CSSResult, TemplateResult } from './plugins.js'; import { sharedStyles, terminalStyles, navStyles } from './sw-dash-styles.js'; import type { IMetricsData } from './sw-dash-overview.js'; import type { ICachedResource } from './sw-dash-urls.js'; import type { IDomainStats } from './sw-dash-domains.js'; import type { IContentTypeStats } from './sw-dash-types.js'; +import type { serviceworker } from '../dist_ts_interfaces/index.js'; // Import components to register them import './sw-dash-overview.js'; @@ -134,39 +135,194 @@ export class SwDashApp extends LitElement { resourceCount: 0 }; @state() accessor lastRefresh = new Date().toLocaleTimeString(); + @state() accessor isConnected = false; - private refreshInterval: ReturnType | null = null; + // DeesComms for receiving push updates from service worker + private comms: deesComms.DeesComms | null = null; + + // Heartbeat interval (30 seconds) for SW health check + private heartbeatInterval: ReturnType | null = null; + private readonly HEARTBEAT_INTERVAL_MS = 30000; connectedCallback(): void { super.connectedCallback(); - this.loadMetrics(); - this.loadResourceData(); - // Auto-refresh every 2 seconds - this.refreshInterval = setInterval(() => { - this.loadMetrics(); - if (this.currentView !== 'overview') { - this.loadResourceData(); - } - }, 2000); + // Initial HTTP seed request to wake up SW and get initial data + this.loadInitialData(); + // Setup push listeners via DeesComms + this.setupPushListeners(); + // Start heartbeat for SW health check + this.startHeartbeat(); } disconnectedCallback(): void { super.disconnectedCallback(); - if (this.refreshInterval) { - clearInterval(this.refreshInterval); + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); } } - private async loadMetrics(): Promise { + /** + * Initial HTTP request to seed data and wake up service worker + */ + private async loadInitialData(): Promise { try { - const response = await fetch('/sw-dash/metrics'); - this.metrics = await response.json(); + // Fetch metrics (wakes up SW) + const metricsResponse = await fetch('/sw-dash/metrics'); + this.metrics = await metricsResponse.json(); this.lastRefresh = new Date().toLocaleTimeString(); + this.isConnected = true; + + // Also load resources + const resourcesResponse = await fetch('/sw-dash/resources'); + this.resourceData = await resourcesResponse.json(); } catch (err) { - console.error('Failed to load metrics:', err); + console.error('Failed to load initial data:', err); + this.isConnected = false; } } + /** + * Setup DeesComms handlers for receiving push updates + */ + private setupPushListeners(): void { + this.comms = new deesComms.DeesComms(); + + // Handle metrics push updates + this.comms.createTypedHandler( + 'serviceworker_metricsUpdate', + async (snapshot) => { + // Update metrics from push + if (this.metrics) { + this.metrics = { + ...this.metrics, + cache: { + ...this.metrics.cache, + hits: snapshot.cache.hits, + misses: snapshot.cache.misses, + errors: snapshot.cache.errors, + bytesServedFromCache: snapshot.cache.bytesServedFromCache, + bytesFetched: snapshot.cache.bytesFetched, + }, + network: { + ...this.metrics.network, + totalRequests: snapshot.network.totalRequests, + successfulRequests: snapshot.network.successfulRequests, + failedRequests: snapshot.network.failedRequests, + }, + cacheHitRate: snapshot.cacheHitRate, + networkSuccessRate: snapshot.networkSuccessRate, + resourceCount: snapshot.resourceCount, + uptime: snapshot.uptime, + }; + } else { + // If no metrics yet, create minimal structure + this.metrics = { + cache: { + hits: snapshot.cache.hits, + misses: snapshot.cache.misses, + errors: snapshot.cache.errors, + bytesServedFromCache: snapshot.cache.bytesServedFromCache, + bytesFetched: snapshot.cache.bytesFetched, + averageResponseTime: 0, + }, + network: { + totalRequests: snapshot.network.totalRequests, + successfulRequests: snapshot.network.successfulRequests, + failedRequests: snapshot.network.failedRequests, + timeouts: 0, + averageLatency: 0, + totalBytesTransferred: 0, + }, + update: { + totalChecks: 0, + successfulChecks: 0, + failedChecks: 0, + updatesFound: 0, + updatesApplied: 0, + lastCheckTimestamp: 0, + lastUpdateTimestamp: 0, + }, + connection: { + connectedClients: 0, + totalConnectionAttempts: 0, + successfulConnections: 0, + failedConnections: 0, + }, + speedtest: { + lastDownloadSpeedMbps: 0, + lastUploadSpeedMbps: 0, + lastLatencyMs: 0, + lastTestTimestamp: 0, + testCount: 0, + isOnline: true, + }, + startTime: Date.now() - snapshot.uptime, + uptime: snapshot.uptime, + cacheHitRate: snapshot.cacheHitRate, + networkSuccessRate: snapshot.networkSuccessRate, + resourceCount: snapshot.resourceCount, + }; + } + this.lastRefresh = new Date().toLocaleTimeString(); + this.isConnected = true; + return {}; + } + ); + + // Handle event log push updates - dispatch to events component + this.comms.createTypedHandler( + 'serviceworker_eventLogged', + async (entry) => { + // Dispatch custom event for sw-dash-events component + this.dispatchEvent(new CustomEvent('event-logged', { + detail: entry, + bubbles: true, + composed: true, + })); + return {}; + } + ); + + // Handle resource cached push updates + this.comms.createTypedHandler( + 'serviceworker_resourceCached', + async (resource) => { + // Update resource count optimistically + if (resource.cached && this.metrics) { + this.metrics = { + ...this.metrics, + resourceCount: this.metrics.resourceCount + 1, + }; + } + return {}; + } + ); + } + + /** + * Heartbeat to check SW health periodically + */ + private startHeartbeat(): void { + this.heartbeatInterval = setInterval(async () => { + try { + const response = await fetch('/sw-dash/metrics'); + if (response.ok) { + this.isConnected = true; + // Optionally refresh full metrics periodically + this.metrics = await response.json(); + this.lastRefresh = new Date().toLocaleTimeString(); + } else { + this.isConnected = false; + } + } catch { + this.isConnected = false; + } + }, this.HEARTBEAT_INTERVAL_MS); + } + + /** + * Load resource data on demand (when switching to urls/domains/types view) + */ private async loadResourceData(): Promise { try { const response = await fetch('/sw-dash/resources'); @@ -184,8 +340,8 @@ export class SwDashApp extends LitElement { } private handleSpeedtestComplete(_e: CustomEvent): void { - // Refresh metrics after speedtest - this.loadMetrics(); + // Refresh metrics after speedtest via HTTP + this.loadInitialData(); } private formatUptime(ms: number): string { diff --git a/ts_swdash/sw-dash-events.ts b/ts_swdash/sw-dash-events.ts index 23153a5..0706ff4 100644 --- a/ts_swdash/sw-dash-events.ts +++ b/ts_swdash/sw-dash-events.ts @@ -197,9 +197,42 @@ export class SwDashEvents extends LitElement { @state() accessor page = 1; private readonly pageSize = 50; + // Bound event handler reference for cleanup + private boundEventHandler: ((e: Event) => void) | null = null; + connectedCallback(): void { super.connectedCallback(); this.loadEvents(); + // Listen for pushed events from parent + this.setupPushEventListener(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + // Clean up event listener + if (this.boundEventHandler) { + window.removeEventListener('event-logged', this.boundEventHandler); + } + } + + /** + * Sets up listener for pushed events from service worker (via sw-dash-app) + */ + private setupPushEventListener(): void { + this.boundEventHandler = (e: Event) => { + const customEvent = e as CustomEvent; + const newEvent = customEvent.detail; + + // Only add if it matches current filter (or filter is 'all') + if (this.filter === 'all' || newEvent.type === this.filter) { + // Prepend new event to the list + this.events = [newEvent, ...this.events]; + this.totalCount++; + } + }; + + // Listen at window level since events bubble up with composed: true + window.addEventListener('event-logged', this.boundEventHandler); } private async loadEvents(): Promise { diff --git a/ts_web_serviceworker/classes.backend.ts b/ts_web_serviceworker/classes.backend.ts index 5377776..ab0081c 100644 --- a/ts_web_serviceworker/classes.backend.ts +++ b/ts_web_serviceworker/classes.backend.ts @@ -47,6 +47,11 @@ export class ServiceworkerBackend { private swSelf: ServiceWorkerGlobalScope; private clientUpdateInterval: ReturnType | null = null; + // Throttling for metrics updates (max 1 per 500ms) + private metricsUpdateThrottle: ReturnType | null = null; + private pendingMetricsUpdate = false; + private readonly METRICS_THROTTLE_MS = 500; + constructor(optionsArg: { self: any; purgeCache: (reqArg: interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['request']) => Promise; @@ -352,7 +357,7 @@ export class ServiceworkerBackend { title: 'Alert', body: alertText }); - + // Send message to clients who might be able to show an actual alert try { await this.deesComms.postMessage({ @@ -365,4 +370,104 @@ export class ServiceworkerBackend { logger.log('error', `Failed to send alert to clients: ${error instanceof Error ? error.message : String(error)}`); } } + + // =============== + // Push methods for real-time updates + // =============== + + /** + * Pushes a new event log entry to all connected clients + * Called immediately when an event is logged + */ + public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise { + try { + await this.deesComms.postMessage({ + method: 'serviceworker_eventLogged', + request: entry, + messageId: `sw_event_${entry.id}` + }); + logger.log('note', `Pushed event to clients: ${entry.type}`); + } catch (error) { + logger.log('warn', `Failed to push event: ${error}`); + } + } + + /** + * Pushes a metrics update to all connected clients + * Throttled to max 1 update per 500ms to prevent spam + */ + public pushMetricsUpdate(): void { + // Mark that we have a pending update + this.pendingMetricsUpdate = true; + + // If we're already throttling, just wait for the next window + if (this.metricsUpdateThrottle) { + return; + } + + // Send the update and start throttle window + this.sendMetricsUpdate(); + + this.metricsUpdateThrottle = setTimeout(() => { + this.metricsUpdateThrottle = null; + // If there was a pending update during the throttle window, send it now + if (this.pendingMetricsUpdate) { + this.sendMetricsUpdate(); + } + }, this.METRICS_THROTTLE_MS); + } + + /** + * Actually sends the metrics update via DeesComms + */ + private async sendMetricsUpdate(): Promise { + this.pendingMetricsUpdate = false; + const metrics = getMetricsCollector(); + const metricsData = metrics.getMetrics(); + + const snapshot: interfaces.serviceworker.IMetricsSnapshot = { + cache: { + hits: metricsData.cache.hits, + misses: metricsData.cache.misses, + errors: metricsData.cache.errors, + bytesServedFromCache: metricsData.cache.bytesServedFromCache, + bytesFetched: metricsData.cache.bytesFetched, + }, + network: { + totalRequests: metricsData.network.totalRequests, + successfulRequests: metricsData.network.successfulRequests, + failedRequests: metricsData.network.failedRequests, + }, + cacheHitRate: metrics.getCacheHitRate(), + networkSuccessRate: metrics.getNetworkSuccessRate(), + resourceCount: metrics.getResourceCount(), + uptime: metricsData.uptime, + timestamp: Date.now(), + }; + + try { + await this.deesComms.postMessage({ + method: 'serviceworker_metricsUpdate', + request: snapshot, + messageId: `sw_metrics_${Date.now()}` + }); + } catch (error) { + logger.log('warn', `Failed to push metrics update: ${error}`); + } + } + + /** + * Pushes notification when a resource is cached + */ + public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise { + try { + await this.deesComms.postMessage({ + method: 'serviceworker_resourceCached', + request: { url, contentType, size, cached }, + messageId: `sw_resource_${Date.now()}` + }); + } catch (error) { + logger.log('warn', `Failed to push resource cached: ${error}`); + } + } } \ No newline at end of file diff --git a/ts_web_serviceworker/classes.metrics.ts b/ts_web_serviceworker/classes.metrics.ts index 483c444..9be6c77 100644 --- a/ts_web_serviceworker/classes.metrics.ts +++ b/ts_web_serviceworker/classes.metrics.ts @@ -1,4 +1,5 @@ import { logger } from './logging.js'; +import { getServiceWorkerBackend } from './init.js'; /** * Interface for cache metrics @@ -178,6 +179,20 @@ export class MetricsCollector { this.startTime = Date.now(); } + /** + * Triggers a push metrics update to all connected clients (throttled in backend) + */ + private triggerPushUpdate(): void { + try { + const backend = getServiceWorkerBackend(); + if (backend) { + backend.pushMetricsUpdate(); + } + } catch (error) { + // Silently ignore - push is best-effort + } + } + /** * Gets the singleton instance */ @@ -196,11 +211,13 @@ export class MetricsCollector { this.cacheHits++; this.bytesServedFromCache += bytes; logger.log('note', `[Metrics] Cache hit: ${url} (${bytes} bytes)`); + this.triggerPushUpdate(); } public recordCacheMiss(url: string): void { this.cacheMisses++; logger.log('note', `[Metrics] Cache miss: ${url}`); + this.triggerPushUpdate(); } public recordCacheError(url: string, error?: string): void { @@ -224,11 +241,13 @@ export class MetricsCollector { this.successfulRequests++; this.totalBytesTransferred += bytes; this.recordResponseTime(url, duration); + this.triggerPushUpdate(); } public recordRequestFailure(url: string, error?: string): void { this.failedRequests++; logger.log('warn', `[Metrics] Request failed: ${url} - ${error || 'unknown'}`); + this.triggerPushUpdate(); } public recordTimeout(url: string, duration: number): void { diff --git a/ts_web_serviceworker/classes.persistentstore.ts b/ts_web_serviceworker/classes.persistentstore.ts index c5ff40c..14efe97 100644 --- a/ts_web_serviceworker/classes.persistentstore.ts +++ b/ts_web_serviceworker/classes.persistentstore.ts @@ -1,6 +1,7 @@ import * as plugins from './plugins.js'; import { logger } from './logging.js'; import type { serviceworker } from '../dist_ts_interfaces/index.js'; +import { getServiceWorkerBackend } from './init.js'; type ICumulativeMetrics = serviceworker.ICumulativeMetrics; type IEventLogEntry = serviceworker.IEventLogEntry; @@ -85,27 +86,20 @@ export class PersistentStore { * Initializes the store and starts periodic saving */ public async init(): Promise { - console.log('[PersistentStore] init() called, initialized:', this.initialized); if (this.initialized) { - console.log('[PersistentStore] Already initialized, returning early'); return; } try { - console.log('[PersistentStore] Calling store.init()...'); // Initialize the WebStore (required before using any methods) await this.store.init(); - console.log('[PersistentStore] store.init() completed successfully'); - await this.loadCumulativeMetrics(); - console.log('[PersistentStore] loadCumulativeMetrics() completed'); // Increment restart count if (this.cumulativeMetrics) { this.cumulativeMetrics.swRestartCount++; this.isDirty = true; await this.saveCumulativeMetrics(); - console.log('[PersistentStore] Saved cumulative metrics after restart count increment'); } // Start periodic save @@ -113,9 +107,7 @@ export class PersistentStore { this.initialized = true; logger.log('ok', '[PersistentStore] Initialized successfully'); - console.log('[PersistentStore] Initialization complete'); } catch (error) { - console.error('[PersistentStore] Failed to initialize:', error); logger.log('error', `[PersistentStore] Failed to initialize: ${error}`); // Don't throw - allow SW to continue even if persistent store fails this.initialized = true; // Mark as initialized to prevent retry loops @@ -273,7 +265,6 @@ export class PersistentStore { message: string, details?: Record ): Promise { - console.log('[PersistentStore] logEvent called:', type, message); const entry: IEventLogEntry = { id: generateId(), timestamp: Date.now(), @@ -285,19 +276,13 @@ export class PersistentStore { try { // Ensure initialized if (!this.initialized) { - console.log('[PersistentStore] Not initialized, calling init() first'); await this.init(); } let events: IEventLogEntry[] = []; - console.log('[PersistentStore] Checking if event log exists...'); if (await this.store.check(this.EVENT_LOG_KEY)) { - console.log('[PersistentStore] Event log exists, loading...'); events = await this.store.get(this.EVENT_LOG_KEY); - console.log('[PersistentStore] Loaded', events.length, 'events'); - } else { - console.log('[PersistentStore] Event log does not exist, creating new one'); } // Add new entry @@ -306,12 +291,20 @@ export class PersistentStore { // Apply retention policy events = this.applyRetentionPolicy(events); - console.log('[PersistentStore] Saving', events.length, 'events...'); await this.store.set(this.EVENT_LOG_KEY, events); - console.log('[PersistentStore] Events saved successfully'); logger.log('note', `[PersistentStore] Logged event: ${type} - ${message}`); + + // Push event to connected clients via DeesComms + try { + const backend = getServiceWorkerBackend(); + if (backend) { + await backend.pushEvent(entry); + } + } catch (pushError) { + // Don't fail the log operation if push fails + logger.log('warn', `[PersistentStore] Failed to push event: ${pushError}`); + } } catch (error) { - console.error('[PersistentStore] Failed to log event:', error); logger.log('error', `[PersistentStore] Failed to log event: ${error}`); } } diff --git a/ts_web_serviceworker/init.ts b/ts_web_serviceworker/init.ts index dd96ada..ffd43e6 100644 --- a/ts_web_serviceworker/init.ts +++ b/ts_web_serviceworker/init.ts @@ -4,7 +4,10 @@ import * as env from './env.js'; declare var self: env.ServiceWindow; import { ServiceWorker } from './classes.serviceworker.js'; +import type { ServiceworkerBackend } from './classes.backend.js'; const sw = new ServiceWorker(self); export const getServiceWorkerInstance = (): ServiceWorker => sw; + +export const getServiceWorkerBackend = (): ServiceworkerBackend => sw.leleServiceWorkerBackend;