diff --git a/changelog.md b/changelog.md index b890f79..47c0610 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,20 @@ # Changelog +## 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 + +- Introduce MetricsCollector (cache, network, update, connection) for runtime observability and APIs to retrieve metrics +- Add EventBus singleton to emit/subscribe to internal SW events (cache hits/misses, network events, update lifecycle, connection events) +- Add ErrorHandler and ServiceWorkerError types for consistent error classification and tracking +- Add ServiceWorkerConfig with defaults and WebStore persistence to centralize SW settings (cache, update, network, blocked/cacheable domains) +- CacheManager: implement request deduplication (in-flight request coalescing), periodic in-flight cleanup, record cache hit/miss metrics and safer cache storing (headers/body handling) +- UpdateManager: rate-limited and concurrency-safe update checks, improved stale-cache handling, event emissions, debounced update and revalidation tasks, and metrics recording +- NetworkManager: enhanced online/offline detection and robust request retries/timeouts/backoff handling +- ServiceworkerBackend: improved client reload logic and notification handling via DeesComms and clients API +- Serviceworker client-side: ActionManager.waitForServiceWorkerConnection now returns a structured result with timeout/retries/backoff; ServiceworkerClient gains controllable polling (AbortController), visibility-based pause/resume, manual trigger and lifecycle cleanup +- Expose serviceworker bundle routes at both nested and root paths (/serviceworker/*splat and /serviceworker.bundle.js(.map)) in servertools +- Add/extend typed interfaces for serviceworker metrics and connection results + ## 2025-12-04 - 6.0.1 - fix(web_inject) Use TypedSocket status API in web_inject and bump dependencies diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6137611..cb18c85 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.0.1', + version: '6.1.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 6788484..1c83a50 100644 --- a/ts/servertools/tools.serviceworker.ts +++ b/ts/servertools/tools.serviceworker.ts @@ -33,8 +33,8 @@ export const addServiceWorkerRoute = ( // Set the version info swVersionInfo = swDataFunc(); - // Service worker bundle handler - typedserverInstance.addRoute('/serviceworker/*splat', 'GET', async (request: Request) => { + // Handler function for serviceworker bundle requests + const handleServiceWorkerRequest = async (request: Request): Promise => { await loadServiceWorkerBundle(); const url = new URL(request.url); const path = url.pathname; @@ -58,7 +58,14 @@ export const addServiceWorkerRoute = ( } return null; - }); + }; + + // Service worker bundle handler - nested path + typedserverInstance.addRoute('/serviceworker/*splat', 'GET', handleServiceWorkerRequest); + + // Service worker bundle handler - root level (for /serviceworker.bundle.js) + typedserverInstance.addRoute('/serviceworker.bundle.js', 'GET', handleServiceWorkerRequest); + typedserverInstance.addRoute('/serviceworker.bundle.js.map', 'GET', handleServiceWorkerRequest); // Typed request handler for service worker typedserverInstance.addRoute('/sw-typedrequest', 'POST', async (request: Request) => { diff --git a/ts_interfaces/serviceworker.ts b/ts_interfaces/serviceworker.ts index 2fe305f..79fb7d2 100644 --- a/ts_interfaces/serviceworker.ts +++ b/ts_interfaces/serviceworker.ts @@ -124,4 +124,69 @@ export interface IRequest_Client_Serviceworker_ConnectionPolling response: { serviceworkerId: string; } +} + +// =============== +// Metrics interfaces +// =============== + +/** + * Request to get service worker metrics + */ +export interface IRequest_Serviceworker_Metrics + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_Metrics + > { + method: 'serviceworker_metrics'; + request: {}; + response: { + cache: { + hits: number; + misses: number; + errors: number; + bytesServedFromCache: number; + bytesFetched: number; + averageResponseTime: number; + }; + network: { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + timeouts: number; + averageLatency: number; + totalBytesTransferred: number; + }; + update: { + totalChecks: number; + successfulChecks: number; + failedChecks: number; + updatesFound: number; + updatesApplied: number; + lastCheckTimestamp: number; + lastUpdateTimestamp: number; + }; + connection: { + connectedClients: number; + totalConnectionAttempts: number; + successfulConnections: number; + failedConnections: number; + }; + startTime: number; + uptime: number; + }; +} + +// =============== +// Connection result interface +// =============== + +/** + * Result of a service worker connection attempt + */ +export interface IConnectionResult { + connected: boolean; + error?: string; + attempts?: number; + duration?: number; } \ No newline at end of file diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index 8419028..df8c40e 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -2,6 +2,9 @@ import * as plugins from './plugins.js'; import * as interfaces from './env.js'; import { logger } from './logging.js'; 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'; export class CacheManager { public losslessServiceWorkerRef: ServiceWorker; @@ -10,9 +13,113 @@ export class CacheManager { runtimeCacheName: 'runtime' }; + // Request deduplication: tracks in-flight requests to prevent duplicate fetches + private inFlightRequests: Map> = new Map(); + private readonly INFLIGHT_CLEANUP_INTERVAL = 30000; // 30 seconds + private cleanupIntervalId: ReturnType | null = null; + constructor(losslessServiceWorkerRefArg: ServiceWorker) { this.losslessServiceWorkerRef = losslessServiceWorkerRefArg; this._setupCache(); + this._setupInFlightCleanup(); + } + + /** + * Sets up periodic cleanup of stale in-flight request entries + */ + private _setupInFlightCleanup(): void { + // Clean up stale entries periodically + this.cleanupIntervalId = setInterval(() => { + // The Map should naturally clean up via .finally(), but this is a safety net + if (this.inFlightRequests.size > 100) { + logger.log('warn', `In-flight requests map has ${this.inFlightRequests.size} entries, clearing...`); + this.inFlightRequests.clear(); + } + }, this.INFLIGHT_CLEANUP_INTERVAL); + } + + /** + * Fetches a request with deduplication - coalesces identical concurrent requests + */ + private async fetchWithDeduplication(request: Request): Promise { + const key = `${request.method}:${request.url}`; + const metrics = getMetricsCollector(); + const eventBus = getEventBus(); + + // Check if we already have an in-flight request for this URL + const existingRequest = this.inFlightRequests.get(key); + if (existingRequest) { + logger.log('note', `Deduplicating request for ${request.url}`); + try { + const response = await existingRequest; + // Clone the response since it may have been consumed + return response.clone(); + } catch (error) { + // If the original request failed, we should try again + this.inFlightRequests.delete(key); + throw error; + } + } + + // Record the new request + metrics.recordRequest(request.url); + eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_START, { + url: request.url, + method: request.method, + }); + + const startTime = Date.now(); + + // Create a new fetch promise and track it + const fetchPromise = fetch(request) + .then(async (response) => { + const duration = Date.now() - startTime; + + // Try to get response size + const contentLength = response.headers.get('content-length'); + const bytes = contentLength ? parseInt(contentLength, 10) : 0; + + metrics.recordRequestSuccess(request.url, duration, bytes); + eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_COMPLETE, { + url: request.url, + method: request.method, + status: response.status, + duration, + bytes, + }); + + return response; + }) + .catch((error) => { + const duration = Date.now() - startTime; + const errorHandler = getErrorHandler(); + + errorHandler.handleNetworkError( + `Fetch failed for ${request.url}: ${error?.message || error}`, + request.url, + error instanceof Error ? error : undefined, + { method: request.method, duration } + ); + + metrics.recordRequestFailure(request.url, error?.message); + eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_ERROR, { + url: request.url, + method: request.method, + duration, + error: error?.message || 'Unknown error', + }); + + throw error; + }) + .finally(() => { + // Remove from in-flight requests when done + this.inFlightRequests.delete(key); + }); + + // Track the in-flight request + this.inFlightRequests.set(key, fetchPromise); + + return fetchPromise; } /** @@ -128,16 +235,30 @@ export class CacheManager { const matchRequest = createMatchRequest(originalRequest); const cachedResponse = await caches.match(matchRequest); + const metrics = getMetricsCollector(); + const eventBus = getEventBus(); + if (cachedResponse) { + // Record cache hit + const contentLength = cachedResponse.headers.get('content-length'); + const bytes = contentLength ? parseInt(contentLength, 10) : 0; + metrics.recordCacheHit(matchRequest.url, bytes); + eventBus.emitCacheHit(matchRequest.url, bytes); + logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`); done.resolve(cachedResponse); return; } + // Record cache miss + metrics.recordCacheMiss(matchRequest.url); + eventBus.emitCacheMiss(matchRequest.url); + logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`); let newResponse: Response; try { - newResponse = await fetch(matchRequest); + // Use deduplicated fetch to prevent concurrent requests for the same resource + newResponse = await this.fetchWithDeduplication(matchRequest); } catch (err: any) { logger.log('error', `Fetch error for ${matchRequest.url}: ${err}`); newResponse = await create500Response(matchRequest, new Response(err.message)); diff --git a/ts_web_serviceworker/classes.config.ts b/ts_web_serviceworker/classes.config.ts new file mode 100644 index 0000000..89837f7 --- /dev/null +++ b/ts_web_serviceworker/classes.config.ts @@ -0,0 +1,232 @@ +import * as plugins from './plugins.js'; + +/** + * Configuration interface for service worker settings + */ +export interface IServiceWorkerConfig { + // Cache settings + cache: { + maxAge: number; // Maximum cache age in milliseconds (default: 24 hours) + offlineGracePeriod: number; // Grace period when offline (default: 7 days) + runtimeCacheName: string; // Name of the runtime cache + }; + + // Update check settings + update: { + minCheckInterval: number; // Minimum interval between update checks (default: 100 seconds) + debounceTime: number; // Debounce time for update tasks (default: 2000ms) + revalidationDebounce: number; // Debounce time for revalidation (default: 6000ms) + }; + + // Network settings + network: { + requestTimeout: number; // Default request timeout (default: 5000ms) + maxRetries: number; // Maximum retry attempts (default: 3) + retryDelay: number; // Delay between retries (default: 1000ms) + }; + + // Blocked domains - requests to these domains bypass the service worker + blockedDomains: string[]; + + // Blocked paths - requests with these path prefixes bypass the service worker + blockedPaths: string[]; + + // External cacheable domains - external domains that should be cached + cacheableDomains: string[]; +} + +/** + * Default configuration values + */ +const DEFAULT_CONFIG: IServiceWorkerConfig = { + cache: { + maxAge: 24 * 60 * 60 * 1000, // 24 hours + offlineGracePeriod: 7 * 24 * 60 * 60 * 1000, // 7 days + runtimeCacheName: 'runtime', + }, + update: { + minCheckInterval: 100000, // 100 seconds + debounceTime: 2000, + revalidationDebounce: 6000, + }, + network: { + requestTimeout: 5000, + maxRetries: 3, + retryDelay: 1000, + }, + blockedDomains: [ + 'paddle.com', + 'paypal.com', + 'reception.lossless.one', + 'umami.', + ], + blockedPaths: [ + '/socket.io', + '/api/', + 'smartserve/reloadcheck', + ], + cacheableDomains: [ + 'assetbroker.', + 'unpkg.com', + 'fonts.googleapis.com', + 'fonts.gstatic.com', + ], +}; + +/** + * ServiceWorkerConfig manages the configuration for the service worker. + * Configuration is persisted to WebStore and can be updated at runtime. + */ +export class ServiceWorkerConfig { + private static readonly STORE_KEY = 'sw_config'; + private config: IServiceWorkerConfig; + private store: plugins.webstore.WebStore; + + constructor(store: plugins.webstore.WebStore) { + this.store = store; + this.config = { ...DEFAULT_CONFIG }; + } + + /** + * Loads configuration from WebStore, falling back to defaults + */ + public async load(): Promise { + try { + if (await this.store.check(ServiceWorkerConfig.STORE_KEY)) { + const storedConfig = await this.store.get(ServiceWorkerConfig.STORE_KEY); + this.config = this.mergeConfig(DEFAULT_CONFIG, storedConfig); + } + } catch (error) { + console.warn('Failed to load service worker config, using defaults:', error); + this.config = { ...DEFAULT_CONFIG }; + } + } + + /** + * Saves current configuration to WebStore + */ + public async save(): Promise { + await this.store.set(ServiceWorkerConfig.STORE_KEY, this.config); + } + + /** + * Gets the current configuration + */ + public get(): IServiceWorkerConfig { + return this.config; + } + + /** + * Updates configuration with partial values + */ + public async update(partialConfig: Partial): Promise { + this.config = this.mergeConfig(this.config, partialConfig); + await this.save(); + } + + /** + * Resets configuration to defaults + */ + public async reset(): Promise { + this.config = { ...DEFAULT_CONFIG }; + await this.save(); + } + + // Getters for common configuration values + public get cacheMaxAge(): number { + return this.config.cache.maxAge; + } + + public get offlineGracePeriod(): number { + return this.config.cache.offlineGracePeriod; + } + + public get runtimeCacheName(): string { + return this.config.cache.runtimeCacheName; + } + + public get minCheckInterval(): number { + return this.config.update.minCheckInterval; + } + + public get updateDebounceTime(): number { + return this.config.update.debounceTime; + } + + public get revalidationDebounce(): number { + return this.config.update.revalidationDebounce; + } + + public get requestTimeout(): number { + return this.config.network.requestTimeout; + } + + public get maxRetries(): number { + return this.config.network.maxRetries; + } + + public get retryDelay(): number { + return this.config.network.retryDelay; + } + + /** + * Checks if a URL should be blocked from service worker handling + */ + public shouldBlockUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + + // Check blocked domains + for (const domain of this.config.blockedDomains) { + if (parsedUrl.hostname.includes(domain)) { + return true; + } + } + + // Check blocked paths + for (const path of this.config.blockedPaths) { + if (parsedUrl.pathname.includes(path)) { + return true; + } + } + + // Check if URL starts with blocked domain pattern + if (url.startsWith('https://umami.')) { + return true; + } + + return false; + } catch { + return false; + } + } + + /** + * Checks if an external URL should be cached + */ + public shouldCacheExternalUrl(url: string): boolean { + for (const domain of this.config.cacheableDomains) { + if (url.includes(domain)) { + return true; + } + } + return false; + } + + /** + * Deep merges two configuration objects + */ + private mergeConfig( + base: IServiceWorkerConfig, + override: Partial + ): IServiceWorkerConfig { + return { + cache: { ...base.cache, ...override.cache }, + update: { ...base.update, ...override.update }, + network: { ...base.network, ...override.network }, + blockedDomains: override.blockedDomains ?? base.blockedDomains, + blockedPaths: override.blockedPaths ?? base.blockedPaths, + cacheableDomains: override.cacheableDomains ?? base.cacheableDomains, + }; + } +} diff --git a/ts_web_serviceworker/classes.errorhandler.ts b/ts_web_serviceworker/classes.errorhandler.ts new file mode 100644 index 0000000..4779915 --- /dev/null +++ b/ts_web_serviceworker/classes.errorhandler.ts @@ -0,0 +1,333 @@ +import { logger } from './logging.js'; + +/** + * Error types for categorizing service worker errors + */ +export enum ServiceWorkerErrorType { + NETWORK = 'NETWORK', + CACHE = 'CACHE', + UPDATE = 'UPDATE', + CONNECTION = 'CONNECTION', + TIMEOUT = 'TIMEOUT', + UNKNOWN = 'UNKNOWN', +} + +/** + * Error severity levels + */ +export enum ErrorSeverity { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +/** + * Interface for error context + */ +export interface IErrorContext { + url?: string; + method?: string; + statusCode?: number; + attempt?: number; + maxAttempts?: number; + duration?: number; + componentName?: string; + additionalInfo?: Record; +} + +/** + * Service Worker Error class with type categorization and context + */ +export class ServiceWorkerError extends Error { + public readonly type: ServiceWorkerErrorType; + public readonly severity: ErrorSeverity; + public readonly context: IErrorContext; + public readonly timestamp: number; + public readonly originalError?: Error; + + constructor( + message: string, + type: ServiceWorkerErrorType = ServiceWorkerErrorType.UNKNOWN, + severity: ErrorSeverity = ErrorSeverity.ERROR, + context: IErrorContext = {}, + originalError?: Error + ) { + super(message); + this.name = 'ServiceWorkerError'; + this.type = type; + this.severity = severity; + this.context = context; + this.timestamp = Date.now(); + this.originalError = originalError; + + // Maintain proper stack trace in V8 environments + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ServiceWorkerError); + } + } + + /** + * Creates a formatted log message + */ + public toLogMessage(): string { + const parts = [ + `[${this.type}]`, + this.message, + ]; + + if (this.context.url) { + parts.push(`URL: ${this.context.url}`); + } + if (this.context.method) { + parts.push(`Method: ${this.context.method}`); + } + if (this.context.statusCode !== undefined) { + parts.push(`Status: ${this.context.statusCode}`); + } + if (this.context.attempt !== undefined && this.context.maxAttempts !== undefined) { + parts.push(`Attempt: ${this.context.attempt}/${this.context.maxAttempts}`); + } + if (this.context.duration !== undefined) { + parts.push(`Duration: ${this.context.duration}ms`); + } + + return parts.join(' | '); + } + + /** + * Converts to a plain object for serialization + */ + public toJSON(): Record { + return { + name: this.name, + message: this.message, + type: this.type, + severity: this.severity, + context: this.context, + timestamp: this.timestamp, + stack: this.stack, + originalError: this.originalError?.message, + }; + } +} + +/** + * Error handler for consistent error handling across service worker components + */ +export class ErrorHandler { + private static instance: ErrorHandler; + private errorHistory: ServiceWorkerError[] = []; + private readonly maxHistorySize = 100; + + private constructor() {} + + /** + * Gets the singleton instance + */ + public static getInstance(): ErrorHandler { + if (!ErrorHandler.instance) { + ErrorHandler.instance = new ErrorHandler(); + } + return ErrorHandler.instance; + } + + /** + * Handles an error with consistent logging and tracking + */ + public handle( + error: Error | ServiceWorkerError | string, + type: ServiceWorkerErrorType = ServiceWorkerErrorType.UNKNOWN, + severity: ErrorSeverity = ErrorSeverity.ERROR, + context: IErrorContext = {} + ): ServiceWorkerError { + let swError: ServiceWorkerError; + + if (error instanceof ServiceWorkerError) { + swError = error; + } else if (error instanceof Error) { + swError = new ServiceWorkerError(error.message, type, severity, context, error); + } else { + swError = new ServiceWorkerError(String(error), type, severity, context); + } + + // Log the error + this.logError(swError); + + // Track the error + this.trackError(swError); + + return swError; + } + + /** + * Creates and handles a network error + */ + public handleNetworkError( + message: string, + url: string, + originalError?: Error, + context: Partial = {} + ): ServiceWorkerError { + return this.handle( + originalError || message, + ServiceWorkerErrorType.NETWORK, + ErrorSeverity.WARN, + { url, ...context } + ); + } + + /** + * Creates and handles a cache error + */ + public handleCacheError( + message: string, + url?: string, + originalError?: Error, + context: Partial = {} + ): ServiceWorkerError { + return this.handle( + originalError || message, + ServiceWorkerErrorType.CACHE, + ErrorSeverity.ERROR, + { url, ...context } + ); + } + + /** + * Creates and handles an update error + */ + public handleUpdateError( + message: string, + originalError?: Error, + context: Partial = {} + ): ServiceWorkerError { + return this.handle( + originalError || message, + ServiceWorkerErrorType.UPDATE, + ErrorSeverity.ERROR, + context + ); + } + + /** + * Creates and handles a connection error + */ + public handleConnectionError( + message: string, + originalError?: Error, + context: Partial = {} + ): ServiceWorkerError { + return this.handle( + originalError || message, + ServiceWorkerErrorType.CONNECTION, + ErrorSeverity.WARN, + context + ); + } + + /** + * Creates and handles a timeout error + */ + public handleTimeoutError( + message: string, + url?: string, + duration?: number, + context: Partial = {} + ): ServiceWorkerError { + return this.handle( + message, + ServiceWorkerErrorType.TIMEOUT, + ErrorSeverity.WARN, + { url, duration, ...context } + ); + } + + /** + * Gets the error history + */ + public getErrorHistory(): ServiceWorkerError[] { + return [...this.errorHistory]; + } + + /** + * Gets errors by type + */ + public getErrorsByType(type: ServiceWorkerErrorType): ServiceWorkerError[] { + return this.errorHistory.filter((e) => e.type === type); + } + + /** + * Gets errors within a time range + */ + public getRecentErrors(withinMs: number): ServiceWorkerError[] { + const cutoff = Date.now() - withinMs; + return this.errorHistory.filter((e) => e.timestamp >= cutoff); + } + + /** + * Clears the error history + */ + public clearHistory(): void { + this.errorHistory = []; + } + + /** + * Gets error statistics + */ + public getStats(): Record { + const stats: Record = { + [ServiceWorkerErrorType.NETWORK]: 0, + [ServiceWorkerErrorType.CACHE]: 0, + [ServiceWorkerErrorType.UPDATE]: 0, + [ServiceWorkerErrorType.CONNECTION]: 0, + [ServiceWorkerErrorType.TIMEOUT]: 0, + [ServiceWorkerErrorType.UNKNOWN]: 0, + }; + + for (const error of this.errorHistory) { + stats[error.type]++; + } + + return stats; + } + + /** + * Logs an error with the appropriate severity + */ + private logError(error: ServiceWorkerError): void { + const logMessage = error.toLogMessage(); + + switch (error.severity) { + case ErrorSeverity.DEBUG: + logger.log('note', logMessage); + break; + case ErrorSeverity.INFO: + logger.log('info', logMessage); + break; + case ErrorSeverity.WARN: + logger.log('warn', logMessage); + break; + case ErrorSeverity.ERROR: + case ErrorSeverity.FATAL: + logger.log('error', logMessage); + break; + } + } + + /** + * Tracks an error in the history + */ + private trackError(error: ServiceWorkerError): void { + this.errorHistory.push(error); + + // Trim history if needed + if (this.errorHistory.length > this.maxHistorySize) { + this.errorHistory = this.errorHistory.slice(-this.maxHistorySize); + } + } +} + +// Export singleton getter for convenience +export const getErrorHandler = (): ErrorHandler => ErrorHandler.getInstance(); diff --git a/ts_web_serviceworker/classes.eventbus.ts b/ts_web_serviceworker/classes.eventbus.ts new file mode 100644 index 0000000..914f1a6 --- /dev/null +++ b/ts_web_serviceworker/classes.eventbus.ts @@ -0,0 +1,409 @@ +import { logger } from './logging.js'; + +/** + * Event types for service worker internal communication + */ +export enum ServiceWorkerEvent { + // Cache events + CACHE_HIT = 'cache:hit', + CACHE_MISS = 'cache:miss', + CACHE_ERROR = 'cache:error', + CACHE_INVALIDATE = 'cache:invalidate', + CACHE_INVALIDATE_ALL = 'cache:invalidate_all', + CACHE_REVALIDATE = 'cache:revalidate', + + // Update events + UPDATE_CHECK_START = 'update:check_start', + UPDATE_CHECK_COMPLETE = 'update:check_complete', + UPDATE_AVAILABLE = 'update:available', + UPDATE_APPLIED = 'update:applied', + UPDATE_ERROR = 'update:error', + + // Network events + NETWORK_REQUEST_START = 'network:request_start', + NETWORK_REQUEST_COMPLETE = 'network:request_complete', + NETWORK_REQUEST_ERROR = 'network:request_error', + NETWORK_ONLINE = 'network:online', + NETWORK_OFFLINE = 'network:offline', + + // Connection events + CLIENT_CONNECTED = 'connection:client_connected', + CLIENT_DISCONNECTED = 'connection:client_disconnected', + + // Lifecycle events + INSTALL = 'lifecycle:install', + ACTIVATE = 'lifecycle:activate', + READY = 'lifecycle:ready', +} + +/** + * Event payload interfaces + */ +export interface ICacheEventPayload { + url: string; + method?: string; + bytes?: number; + error?: string; +} + +export interface IUpdateEventPayload { + oldVersion?: string; + newVersion?: string; + oldHash?: string; + newHash?: string; + error?: string; +} + +export interface INetworkEventPayload { + url: string; + method?: string; + status?: number; + duration?: number; + bytes?: number; + error?: string; +} + +export interface IConnectionEventPayload { + clientId?: string; + tabId?: string; +} + +export interface ILifecycleEventPayload { + timestamp: number; +} + +/** + * Union type for all event payloads + */ +export type TEventPayload = + | ICacheEventPayload + | IUpdateEventPayload + | INetworkEventPayload + | IConnectionEventPayload + | ILifecycleEventPayload + | Record; + +/** + * Event listener callback type + */ +export type TEventListener = ( + event: ServiceWorkerEvent, + payload: T +) => void | Promise; + +/** + * Subscription interface + */ +export interface ISubscription { + unsubscribe: () => void; +} + +/** + * Event bus for decoupled communication between service worker components. + * Implements a simple pub/sub pattern. + */ +export class EventBus { + private static instance: EventBus; + private listeners: Map>; + private globalListeners: Set; + private eventHistory: Array<{ event: ServiceWorkerEvent; payload: TEventPayload; timestamp: number }>; + private readonly maxHistorySize = 100; + private debugMode = false; + + private constructor() { + this.listeners = new Map(); + this.globalListeners = new Set(); + this.eventHistory = []; + } + + /** + * Gets the singleton instance + */ + public static getInstance(): EventBus { + if (!EventBus.instance) { + EventBus.instance = new EventBus(); + } + return EventBus.instance; + } + + /** + * Enables or disables debug mode (logs all events) + */ + public setDebugMode(enabled: boolean): void { + this.debugMode = enabled; + } + + /** + * Emits an event to all subscribed listeners + */ + public emit(event: ServiceWorkerEvent, payload: T): void { + if (this.debugMode) { + logger.log('note', `[EventBus] Emit: ${event} ${JSON.stringify(payload)}`); + } + + // Record in history + this.recordEvent(event, payload); + + // Notify specific listeners + const specificListeners = this.listeners.get(event); + if (specificListeners) { + for (const listener of specificListeners) { + try { + const result = listener(event, payload); + if (result instanceof Promise) { + result.catch((err) => { + logger.log('error', `[EventBus] Async listener error for ${event}: ${err}`); + }); + } + } catch (err) { + logger.log('error', `[EventBus] Listener error for ${event}: ${err}`); + } + } + } + + // Notify global listeners + for (const listener of this.globalListeners) { + try { + const result = listener(event, payload); + if (result instanceof Promise) { + result.catch((err) => { + logger.log('error', `[EventBus] Global async listener error for ${event}: ${err}`); + }); + } + } catch (err) { + logger.log('error', `[EventBus] Global listener error for ${event}: ${err}`); + } + } + } + + /** + * Subscribes to a specific event + */ + public on( + event: ServiceWorkerEvent, + listener: TEventListener + ): ISubscription { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + this.listeners.get(event)!.add(listener as TEventListener); + + return { + unsubscribe: () => { + this.off(event, listener as TEventListener); + }, + }; + } + + /** + * Subscribes to multiple events at once + */ + public onMany( + events: ServiceWorkerEvent[], + listener: TEventListener + ): ISubscription { + const subscriptions = events.map((event) => + this.on(event, listener as TEventListener) + ); + + return { + unsubscribe: () => { + subscriptions.forEach((sub) => sub.unsubscribe()); + }, + }; + } + + /** + * Subscribes to all events + */ + public onAll(listener: TEventListener): ISubscription { + this.globalListeners.add(listener as TEventListener); + + return { + unsubscribe: () => { + this.globalListeners.delete(listener as TEventListener); + }, + }; + } + + /** + * Subscribes to an event for only one emission + */ + public once( + event: ServiceWorkerEvent, + listener: TEventListener + ): ISubscription { + const onceListener: TEventListener = (evt, payload) => { + this.off(event, onceListener); + return listener(evt, payload as T); + }; + + return this.on(event, onceListener); + } + + /** + * Unsubscribes a listener from an event + */ + public off(event: ServiceWorkerEvent, listener: TEventListener): void { + const listeners = this.listeners.get(event); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(event); + } + } + } + + /** + * Removes all listeners for an event + */ + public removeAllListeners(event?: ServiceWorkerEvent): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + this.globalListeners.clear(); + } + } + + /** + * Gets the count of listeners for an event + */ + public listenerCount(event: ServiceWorkerEvent): number { + const listeners = this.listeners.get(event); + return (listeners?.size ?? 0) + this.globalListeners.size; + } + + /** + * Gets the event history + */ + public getHistory(): Array<{ event: ServiceWorkerEvent; payload: TEventPayload; timestamp: number }> { + return [...this.eventHistory]; + } + + /** + * Gets events of a specific type from history + */ + public getHistoryByType(event: ServiceWorkerEvent): Array<{ payload: TEventPayload; timestamp: number }> { + return this.eventHistory + .filter((entry) => entry.event === event) + .map(({ payload, timestamp }) => ({ payload, timestamp })); + } + + /** + * Clears the event history + */ + public clearHistory(): void { + this.eventHistory = []; + } + + /** + * Waits for an event to be emitted (returns a promise) + */ + public waitFor( + event: ServiceWorkerEvent, + timeout?: number + ): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const subscription = this.once(event, (_, payload) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve(payload); + }); + + if (timeout) { + timeoutId = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for event: ${event}`)); + }, timeout); + } + }); + } + + /** + * Records an event in history + */ + private recordEvent(event: ServiceWorkerEvent, payload: TEventPayload): void { + this.eventHistory.push({ + event, + payload, + timestamp: Date.now(), + }); + + // Trim history if needed + if (this.eventHistory.length > this.maxHistorySize) { + this.eventHistory = this.eventHistory.slice(-this.maxHistorySize); + } + } + + // =================== + // Convenience Methods + // =================== + + /** + * Emits a cache hit event + */ + public emitCacheHit(url: string, bytes?: number): void { + this.emit(ServiceWorkerEvent.CACHE_HIT, { url, bytes }); + } + + /** + * Emits a cache miss event + */ + public emitCacheMiss(url: string): void { + this.emit(ServiceWorkerEvent.CACHE_MISS, { url }); + } + + /** + * Emits a cache error event + */ + public emitCacheError(url: string, error?: string): void { + this.emit(ServiceWorkerEvent.CACHE_ERROR, { url, error }); + } + + /** + * Emits a cache invalidation event + */ + public emitCacheInvalidate(url?: string): void { + if (url) { + this.emit(ServiceWorkerEvent.CACHE_INVALIDATE, { url }); + } else { + this.emit(ServiceWorkerEvent.CACHE_INVALIDATE_ALL, {}); + } + } + + /** + * Emits an update available event + */ + public emitUpdateAvailable(oldVersion: string, newVersion: string, oldHash: string, newHash: string): void { + this.emit(ServiceWorkerEvent.UPDATE_AVAILABLE, { oldVersion, newVersion, oldHash, newHash }); + } + + /** + * Emits an update applied event + */ + public emitUpdateApplied(newVersion: string, newHash: string): void { + this.emit(ServiceWorkerEvent.UPDATE_APPLIED, { newVersion, newHash }); + } + + /** + * Emits a network online event + */ + public emitNetworkOnline(): void { + this.emit(ServiceWorkerEvent.NETWORK_ONLINE, {}); + } + + /** + * Emits a network offline event + */ + public emitNetworkOffline(): void { + this.emit(ServiceWorkerEvent.NETWORK_OFFLINE, {}); + } +} + +// Export singleton getter for convenience +export const getEventBus = (): EventBus => EventBus.getInstance(); diff --git a/ts_web_serviceworker/classes.metrics.ts b/ts_web_serviceworker/classes.metrics.ts new file mode 100644 index 0000000..0bd8b32 --- /dev/null +++ b/ts_web_serviceworker/classes.metrics.ts @@ -0,0 +1,386 @@ +import { logger } from './logging.js'; + +/** + * Interface for cache metrics + */ +export interface ICacheMetrics { + hits: number; + misses: number; + errors: number; + bytesServedFromCache: number; + bytesFetched: number; + averageResponseTime: number; +} + +/** + * Interface for network metrics + */ +export interface INetworkMetrics { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + timeouts: number; + averageLatency: number; + totalBytesTransferred: number; +} + +/** + * Interface for update metrics + */ +export interface IUpdateMetrics { + totalChecks: number; + successfulChecks: number; + failedChecks: number; + updatesFound: number; + updatesApplied: number; + lastCheckTimestamp: number; + lastUpdateTimestamp: number; +} + +/** + * Interface for connection metrics + */ +export interface IConnectionMetrics { + connectedClients: number; + totalConnectionAttempts: number; + successfulConnections: number; + failedConnections: number; +} + +/** + * Combined metrics interface + */ +export interface IServiceWorkerMetrics { + cache: ICacheMetrics; + network: INetworkMetrics; + update: IUpdateMetrics; + connection: IConnectionMetrics; + startTime: number; + uptime: number; +} + +/** + * Response time entry for calculating averages + */ +interface IResponseTimeEntry { + url: string; + duration: number; + timestamp: number; +} + +/** + * Metrics collector for service worker observability + */ +export class MetricsCollector { + private static instance: MetricsCollector; + + // Cache metrics + private cacheHits = 0; + private cacheMisses = 0; + private cacheErrors = 0; + private bytesServedFromCache = 0; + private bytesFetched = 0; + + // Network metrics + private totalRequests = 0; + private successfulRequests = 0; + private failedRequests = 0; + private timeouts = 0; + private totalBytesTransferred = 0; + + // Update metrics + private totalUpdateChecks = 0; + private successfulUpdateChecks = 0; + private failedUpdateChecks = 0; + private updatesFound = 0; + private updatesApplied = 0; + private lastCheckTimestamp = 0; + private lastUpdateTimestamp = 0; + + // Connection metrics + private connectedClients = 0; + private totalConnectionAttempts = 0; + private successfulConnections = 0; + private failedConnections = 0; + + // Response time tracking + private responseTimes: IResponseTimeEntry[] = []; + private readonly maxResponseTimeEntries = 1000; + private readonly responseTimeWindow = 5 * 60 * 1000; // 5 minutes + + // Start time + private readonly startTime: number; + + private constructor() { + this.startTime = Date.now(); + } + + /** + * Gets the singleton instance + */ + public static getInstance(): MetricsCollector { + if (!MetricsCollector.instance) { + MetricsCollector.instance = new MetricsCollector(); + } + return MetricsCollector.instance; + } + + // =================== + // Cache Metrics + // =================== + + public recordCacheHit(url: string, bytes: number = 0): void { + this.cacheHits++; + this.bytesServedFromCache += bytes; + logger.log('note', `[Metrics] Cache hit: ${url} (${bytes} bytes)`); + } + + public recordCacheMiss(url: string): void { + this.cacheMisses++; + logger.log('note', `[Metrics] Cache miss: ${url}`); + } + + public recordCacheError(url: string, error?: string): void { + this.cacheErrors++; + logger.log('warn', `[Metrics] Cache error: ${url} - ${error || 'unknown'}`); + } + + public recordBytesFetched(bytes: number): void { + this.bytesFetched += bytes; + } + + // =================== + // Network Metrics + // =================== + + public recordRequest(_url: string): void { + this.totalRequests++; + } + + public recordRequestSuccess(url: string, duration: number, bytes: number = 0): void { + this.successfulRequests++; + this.totalBytesTransferred += bytes; + this.recordResponseTime(url, duration); + } + + public recordRequestFailure(url: string, error?: string): void { + this.failedRequests++; + logger.log('warn', `[Metrics] Request failed: ${url} - ${error || 'unknown'}`); + } + + public recordTimeout(url: string, duration: number): void { + this.timeouts++; + logger.log('warn', `[Metrics] Request timeout: ${url} after ${duration}ms`); + } + + // =================== + // Update Metrics + // =================== + + public recordUpdateCheck(success: boolean): void { + this.totalUpdateChecks++; + this.lastCheckTimestamp = Date.now(); + if (success) { + this.successfulUpdateChecks++; + } else { + this.failedUpdateChecks++; + } + } + + public recordUpdateFound(): void { + this.updatesFound++; + } + + public recordUpdateApplied(): void { + this.updatesApplied++; + this.lastUpdateTimestamp = Date.now(); + } + + // =================== + // Connection Metrics + // =================== + + public recordConnectionAttempt(): void { + this.totalConnectionAttempts++; + } + + public recordConnectionSuccess(): void { + this.successfulConnections++; + this.connectedClients++; + } + + public recordConnectionFailure(): void { + this.failedConnections++; + } + + public recordClientDisconnect(): void { + this.connectedClients = Math.max(0, this.connectedClients - 1); + } + + public setConnectedClients(count: number): void { + this.connectedClients = count; + } + + // =================== + // Response Time Tracking + // =================== + + private recordResponseTime(url: string, duration: number): void { + const entry: IResponseTimeEntry = { + url, + duration, + timestamp: Date.now(), + }; + + this.responseTimes.push(entry); + + // Trim old entries + this.cleanupResponseTimes(); + } + + private cleanupResponseTimes(): void { + const cutoff = Date.now() - this.responseTimeWindow; + + // Remove old entries + this.responseTimes = this.responseTimes.filter( + (entry) => entry.timestamp >= cutoff + ); + + // Keep within max size + if (this.responseTimes.length > this.maxResponseTimeEntries) { + this.responseTimes = this.responseTimes.slice(-this.maxResponseTimeEntries); + } + } + + private calculateAverageResponseTime(): number { + if (this.responseTimes.length === 0) { + return 0; + } + + const sum = this.responseTimes.reduce((acc, entry) => acc + entry.duration, 0); + return Math.round(sum / this.responseTimes.length); + } + + private calculateAverageLatency(): number { + // Same as response time for now + return this.calculateAverageResponseTime(); + } + + // =================== + // Metrics Retrieval + // =================== + + /** + * Gets all metrics + */ + public getMetrics(): IServiceWorkerMetrics { + const now = Date.now(); + this.cleanupResponseTimes(); + + return { + cache: { + hits: this.cacheHits, + misses: this.cacheMisses, + errors: this.cacheErrors, + bytesServedFromCache: this.bytesServedFromCache, + bytesFetched: this.bytesFetched, + averageResponseTime: this.calculateAverageResponseTime(), + }, + network: { + totalRequests: this.totalRequests, + successfulRequests: this.successfulRequests, + failedRequests: this.failedRequests, + timeouts: this.timeouts, + averageLatency: this.calculateAverageLatency(), + totalBytesTransferred: this.totalBytesTransferred, + }, + update: { + totalChecks: this.totalUpdateChecks, + successfulChecks: this.successfulUpdateChecks, + failedChecks: this.failedUpdateChecks, + updatesFound: this.updatesFound, + updatesApplied: this.updatesApplied, + lastCheckTimestamp: this.lastCheckTimestamp, + lastUpdateTimestamp: this.lastUpdateTimestamp, + }, + connection: { + connectedClients: this.connectedClients, + totalConnectionAttempts: this.totalConnectionAttempts, + successfulConnections: this.successfulConnections, + failedConnections: this.failedConnections, + }, + startTime: this.startTime, + uptime: now - this.startTime, + }; + } + + /** + * Gets cache hit rate as a percentage + */ + public getCacheHitRate(): number { + const total = this.cacheHits + this.cacheMisses; + if (total === 0) { + return 0; + } + return Math.round((this.cacheHits / total) * 100); + } + + /** + * Gets network success rate as a percentage + */ + public getNetworkSuccessRate(): number { + if (this.totalRequests === 0) { + return 100; + } + return Math.round((this.successfulRequests / this.totalRequests) * 100); + } + + /** + * Resets all metrics + */ + public reset(): void { + this.cacheHits = 0; + this.cacheMisses = 0; + this.cacheErrors = 0; + this.bytesServedFromCache = 0; + this.bytesFetched = 0; + + this.totalRequests = 0; + this.successfulRequests = 0; + this.failedRequests = 0; + this.timeouts = 0; + this.totalBytesTransferred = 0; + + this.totalUpdateChecks = 0; + this.successfulUpdateChecks = 0; + this.failedUpdateChecks = 0; + this.updatesFound = 0; + this.updatesApplied = 0; + this.lastCheckTimestamp = 0; + this.lastUpdateTimestamp = 0; + + this.totalConnectionAttempts = 0; + this.successfulConnections = 0; + this.failedConnections = 0; + + this.responseTimes = []; + + logger.log('info', '[Metrics] All metrics reset'); + } + + /** + * Gets a summary string for logging + */ + public getSummary(): string { + const metrics = this.getMetrics(); + return [ + `Cache: ${this.getCacheHitRate()}% hit rate (${metrics.cache.hits}/${metrics.cache.hits + metrics.cache.misses})`, + `Network: ${this.getNetworkSuccessRate()}% success (${metrics.network.successfulRequests}/${metrics.network.totalRequests})`, + `Updates: ${metrics.update.updatesFound} found, ${metrics.update.updatesApplied} applied`, + `Uptime: ${Math.round(metrics.uptime / 1000)}s`, + ].join(' | '); + } +} + +// Export singleton getter for convenience +export const getMetricsCollector = (): MetricsCollector => MetricsCollector.getInstance(); diff --git a/ts_web_serviceworker/classes.updatemanager.ts b/ts_web_serviceworker/classes.updatemanager.ts index b96aa56..429c827 100644 --- a/ts_web_serviceworker/classes.updatemanager.ts +++ b/ts_web_serviceworker/classes.updatemanager.ts @@ -3,6 +3,9 @@ import * as interfaces from '../dist_ts_interfaces/index.js'; import { ServiceWorker } from './classes.serviceworker.js'; import { logger } from './logging.js'; import { CacheManager } from './classes.cachemanager.js'; +import { getMetricsCollector } from './classes.metrics.js'; +import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js'; +import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js'; export class UpdateManager { public lastUpdateCheck: number = 0; @@ -10,6 +13,10 @@ export class UpdateManager { public serviceworkerRef: ServiceWorker; + // Rate limiting for update checks + private isCheckInProgress = false; + private pendingCheckPromise: Promise | null = null; + constructor(serviceWorkerRefArg: ServiceWorker) { this.serviceworkerRef = serviceWorkerRefArg; } @@ -22,75 +29,144 @@ export class UpdateManager { private readonly OFFLINE_GRACE_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days grace period when offline private lastCacheTimestamp: number = 0; - public async checkUpdate(cacheManager: CacheManager): Promise { - const lswVersionInfoKey = 'versionInfo'; - const cacheTimestampKey = 'cacheTimestamp'; - - // Initialize or load version info - if (!this.lastVersionInfo && !(await this.serviceworkerRef.store.check(lswVersionInfoKey))) { - this.lastVersionInfo = { - appHash: '', - appSemVer: 'v0.0.0', - }; - } else if (!this.lastVersionInfo && (await this.serviceworkerRef.store.check(lswVersionInfoKey))) { - this.lastVersionInfo = await this.serviceworkerRef.store.get(lswVersionInfoKey); - } - - // Load or initialize cache timestamp - if (await this.serviceworkerRef.store.check(cacheTimestampKey)) { - this.lastCacheTimestamp = await this.serviceworkerRef.store.get(cacheTimestampKey); - } - + /** + * Public method to trigger an update check (rate-limited) + */ + public async checkUpdate(_cacheManager: CacheManager): Promise { const now = Date.now(); const millisSinceLastCheck = now - this.lastUpdateCheck; - const cacheAge = now - this.lastCacheTimestamp; - // Check if we need to handle stale cache - if (cacheAge > this.MAX_CACHE_AGE) { - const isOnline = await this.serviceworkerRef.networkManager.checkOnlineStatus(); - - if (isOnline) { - logger.log('info', `Cache is older than ${this.MAX_CACHE_AGE}ms, forcing update...`); - await this.forceUpdate(cacheManager); - return true; - } else if (cacheAge > this.OFFLINE_GRACE_PERIOD) { - // If we're offline and beyond grace period, warn but continue serving cached content - logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`); - // We could potentially show a warning to the user here - return false; - } else { - logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`); - return false; - } - } - - // Regular update check interval - if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL && cacheAge < this.MAX_CACHE_AGE) { + // Rate limit: skip if we checked recently + if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL) { return false; } - logger.log('info', 'checking for update of the app by comparing app hashes...'); - this.lastUpdateCheck = now; - const currentVersionInfo = await this.getVersionInfoFromServer(); - logger.log('info', `old versionInfo: ${JSON.stringify(this.lastVersionInfo)}`); - logger.log('info', `current versionInfo: ${JSON.stringify(currentVersionInfo)}`); - const needsUpdate = this.lastVersionInfo.appHash !== currentVersionInfo.appHash ? true : false; - if (needsUpdate) { - logger.log('info', 'Caches need to be updated'); - logger.log('info', 'starting a debounced update task'); - this.performAsyncUpdateDebouncedTask.trigger(); - this.lastVersionInfo = currentVersionInfo; - await this.serviceworkerRef.store.set(lswVersionInfoKey, this.lastVersionInfo); - - // Update cache timestamp - this.lastCacheTimestamp = now; - await this.serviceworkerRef.store.set('cacheTimestamp', now); - } else { - logger.log('ok', 'caches are still valid, performing revalidation in a bit...'); - this.performAsyncCacheRevalidationDebouncedTask.trigger(); - - // Update cache timestamp after successful revalidation - this.lastCacheTimestamp = now; - await this.serviceworkerRef.store.set('cacheTimestamp', now); + + // If a check is in progress, return the existing promise + if (this.pendingCheckPromise) { + return this.pendingCheckPromise; + } + + // Perform the check + this.pendingCheckPromise = this.performUpdateCheck().finally(() => { + this.pendingCheckPromise = null; + }); + + return this.pendingCheckPromise; + } + + /** + * Internal method that performs the actual update check + */ + private async performUpdateCheck(): Promise { + // Prevent concurrent checks + if (this.isCheckInProgress) { + logger.log('note', 'Update check already in progress, skipping...'); + return false; + } + + this.isCheckInProgress = true; + const metrics = getMetricsCollector(); + const eventBus = getEventBus(); + const errorHandler = getErrorHandler(); + + try { + eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_START, { timestamp: Date.now() }); + + const lswVersionInfoKey = 'versionInfo'; + const cacheTimestampKey = 'cacheTimestamp'; + + // Initialize or load version info + if (!this.lastVersionInfo && !(await this.serviceworkerRef.store.check(lswVersionInfoKey))) { + this.lastVersionInfo = { + appHash: '', + appSemVer: 'v0.0.0', + }; + } else if (!this.lastVersionInfo && (await this.serviceworkerRef.store.check(lswVersionInfoKey))) { + this.lastVersionInfo = await this.serviceworkerRef.store.get(lswVersionInfoKey); + } + + // Load or initialize cache timestamp + if (await this.serviceworkerRef.store.check(cacheTimestampKey)) { + this.lastCacheTimestamp = await this.serviceworkerRef.store.get(cacheTimestampKey); + } + + const now = Date.now(); + const cacheAge = now - this.lastCacheTimestamp; + + // Check if we need to handle stale cache + if (cacheAge > this.MAX_CACHE_AGE) { + const isOnline = await this.serviceworkerRef.networkManager.checkOnlineStatus(); + + if (isOnline) { + logger.log('info', `Cache is older than ${this.MAX_CACHE_AGE}ms, forcing update...`); + await this.forceUpdate(this.serviceworkerRef.cacheManager); + metrics.recordUpdateCheck(true); + return true; + } else if (cacheAge > this.OFFLINE_GRACE_PERIOD) { + logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`); + metrics.recordUpdateCheck(false); + return false; + } else { + logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`); + metrics.recordUpdateCheck(false); + return false; + } + } + + logger.log('info', 'checking for update of the app by comparing app hashes...'); + this.lastUpdateCheck = Date.now(); + + const currentVersionInfo = await this.getVersionInfoFromServer(); + logger.log('info', `old versionInfo: ${JSON.stringify(this.lastVersionInfo)}`); + logger.log('info', `current versionInfo: ${JSON.stringify(currentVersionInfo)}`); + + const needsUpdate = this.lastVersionInfo.appHash !== currentVersionInfo.appHash; + + if (needsUpdate) { + logger.log('info', 'Caches need to be updated'); + logger.log('info', 'starting a debounced update task'); + + metrics.recordUpdateFound(); + eventBus.emitUpdateAvailable( + this.lastVersionInfo.appSemVer, + currentVersionInfo.appSemVer, + this.lastVersionInfo.appHash, + currentVersionInfo.appHash + ); + + this.performAsyncUpdateDebouncedTask.trigger(); + this.lastVersionInfo = currentVersionInfo; + await this.serviceworkerRef.store.set(lswVersionInfoKey, this.lastVersionInfo); + + // Update cache timestamp + this.lastCacheTimestamp = now; + await this.serviceworkerRef.store.set('cacheTimestamp', now); + } else { + logger.log('ok', 'caches are still valid, performing revalidation in a bit...'); + this.performAsyncCacheRevalidationDebouncedTask.trigger(); + + // Update cache timestamp after successful revalidation + this.lastCacheTimestamp = now; + await this.serviceworkerRef.store.set('cacheTimestamp', now); + } + + metrics.recordUpdateCheck(true); + eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_COMPLETE, { + needsUpdate, + timestamp: Date.now() + }); + + return needsUpdate; + } catch (error) { + const err = errorHandler.handleUpdateError( + `Update check failed: ${error?.message || error}`, + error instanceof Error ? error : undefined + ); + metrics.recordUpdateCheck(false); + eventBus.emit(ServiceWorkerEvent.UPDATE_ERROR, { error: err.message }); + return false; + } finally { + this.isCheckInProgress = false; } } @@ -140,9 +216,18 @@ export class UpdateManager { name: 'performAsyncUpdate', taskFunction: async () => { logger.log('info', 'trying to update PWA with serviceworker'); + const metrics = getMetricsCollector(); + const eventBus = getEventBus(); + await this.serviceworkerRef.cacheManager.cleanCaches('a new app version has been communicated by the server.'); // lets notify all current clients about the update await this.serviceworkerRef.leleServiceWorkerBackend.triggerReloadAll(); + + metrics.recordUpdateApplied(); + eventBus.emitUpdateApplied( + this.lastVersionInfo?.appSemVer || 'unknown', + this.lastVersionInfo?.appHash || 'unknown' + ); }, debounceTimeInMillis: 2000, }); diff --git a/ts_web_serviceworker_client/classes.actionmanager.ts b/ts_web_serviceworker_client/classes.actionmanager.ts index 8af58cc..73842f1 100644 --- a/ts_web_serviceworker_client/classes.actionmanager.ts +++ b/ts_web_serviceworker_client/classes.actionmanager.ts @@ -2,6 +2,25 @@ import * as plugins from './plugins.js'; import * as interfaces from '../dist_ts_interfaces/index.js'; import { logger } from './logging.js'; +/** + * Connection options for service worker connection attempts + */ +export interface IConnectionOptions { + timeoutMs: number; // Total timeout for all attempts (default: 30000) + maxRetries: number; // Maximum number of retry attempts (default: 10) + initialDelayMs: number; // Initial delay between retries (default: 500) + maxDelayMs: number; // Maximum delay between retries (default: 5000) + backoffMultiplier: number; // Multiplier for exponential backoff (default: 1.5) +} + +const DEFAULT_CONNECTION_OPTIONS: IConnectionOptions = { + timeoutMs: 30000, + maxRetries: 10, + initialDelayMs: 500, + maxDelayMs: 5000, + backoffMultiplier: 1.5, +}; + /** * MessageManager implements two ways of serviceworker communication * * the serviceWorker method @@ -20,28 +39,96 @@ export class ActionManager { }); } - public async waitForServiceWorkerConnection () { - console.log('waiting for service worker connection...') + /** + * Waits for service worker connection with timeout and retry logic. + * Returns a result object instead of blocking forever. + */ + public async waitForServiceWorkerConnection( + options: Partial = {} + ): Promise { + const opts = { ...DEFAULT_CONNECTION_OPTIONS, ...options }; + const startTime = Date.now(); + let attempt = 0; + let currentDelay = opts.initialDelayMs; + + logger.log('info', 'Waiting for service worker connection...'); + const tr = this.deesComms.createTypedRequest('broadcastConnectionPolling'); - let connected = false; - while (!connected) { - tr.fire({ - tabId: '123' - }).then(response => { - if (response.serviceworkerId) { - console.log('connected to serviceworker!'); - connected = true; - } - }).catch(); - await plugins.smartdelay.delayFor(777); - if (!connected) { - // lets wake it up. - navigator.serviceWorker.controller.postMessage({ - type: 'wakeUpCall', - }); + + while (attempt < opts.maxRetries) { + // Check total timeout + const elapsed = Date.now() - startTime; + if (elapsed >= opts.timeoutMs) { + logger.log('warn', `Service worker connection timeout after ${elapsed}ms (${attempt} attempts)`); + return { + connected: false, + error: 'timeout', + attempts: attempt, + duration: elapsed, + }; } + + attempt++; + + try { + // Create a per-request timeout + const requestTimeout = Math.min(currentDelay * 2, opts.maxDelayMs); + const response = await Promise.race([ + tr.fire({ tabId: crypto.randomUUID?.() || String(Date.now()) }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Request timeout')), requestTimeout) + ), + ]); + + if (response && response.serviceworkerId) { + const duration = Date.now() - startTime; + logger.log('ok', `Connected to service worker after ${attempt} attempt(s) (${duration}ms)`); + return { + connected: true, + attempts: attempt, + duration, + }; + } + } catch (error) { + // Request failed or timed out, continue with retry + logger.log('note', `Connection attempt ${attempt} failed: ${error?.message || 'unknown error'}`); + } + + // Try to wake up the service worker + if (navigator.serviceWorker.controller) { + try { + navigator.serviceWorker.controller.postMessage({ + type: 'wakeUpCall', + }); + } catch { + // Ignore errors when posting message + } + } + + // Wait before next attempt with exponential backoff + await plugins.smartdelay.delayFor(currentDelay); + currentDelay = Math.min(currentDelay * opts.backoffMultiplier, opts.maxDelayMs); + } + + const duration = Date.now() - startTime; + logger.log('warn', `Service worker connection failed after ${opts.maxRetries} attempts (${duration}ms)`); + return { + connected: false, + error: 'max_retries_exceeded', + attempts: attempt, + duration, + }; + } + + /** + * Legacy method for backward compatibility - blocks until connected or gives up + * @deprecated Use waitForServiceWorkerConnection() instead which returns a result + */ + public async waitForServiceWorkerConnectionBlocking(): Promise { + const result = await this.waitForServiceWorkerConnection(); + if (!result.connected) { + logger.log('warn', `Failed to connect to service worker: ${result.error}`); } - console.log('ok, got serviceworker connection.') } public async purgeServiceWorkerCache () { diff --git a/ts_web_serviceworker_client/classes.serviceworkerclient.ts b/ts_web_serviceworker_client/classes.serviceworkerclient.ts index c729741..3740b14 100644 --- a/ts_web_serviceworker_client/classes.serviceworkerclient.ts +++ b/ts_web_serviceworker_client/classes.serviceworkerclient.ts @@ -5,28 +5,54 @@ import { NotificationManager } from './classes.notificationmanager.js'; import { ActionManager } from './classes.actionmanager.js'; import { GlobalSW } from './classes.globalsw.js' +/** + * Polling options for service worker update checks + */ +export interface IPollingOptions { + intervalMs: number; // Interval between update checks (default: 60000 - 1 minute) + pauseWhenHidden: boolean; // Pause polling when page is hidden (default: true) + initialDelayMs: number; // Initial delay before first poll (default: 2000) +} + +const DEFAULT_POLLING_OPTIONS: IPollingOptions = { + intervalMs: 60000, // 1 minute + pauseWhenHidden: true, + initialDelayMs: 2000, +}; + export class ServiceworkerClient { // STATIC - public static async createServiceWorker(): Promise { + private static pollingController: AbortController | null = null; + private static swRegistration: ServiceWorkerRegistration | null = null; + private static isPollingActive = false; + + public static async createServiceWorker( + pollingOptions: Partial = {} + ): Promise { if ('serviceWorker' in navigator) { try { logger.log('info', 'trying to register serviceworker'); // this is some magic for Parcel to not pick up the serviceworker const serviceworkerInNavigator: ServiceWorkerContainer = navigator.serviceWorker; - const swRegistration: ServiceWorkerRegistration = await serviceworkerInNavigator.register('/serviceworker.bundle.js', { + this.swRegistration = await serviceworkerInNavigator.register('/serviceworker.bundle.js', { scope: '/', updateViaCache: 'none' }); - plugins.smartdelay.delayFor(2000).then(async () => { - swRegistration.onupdatefound = () => { - logger.log('info', 'update found for service worker!'); - logger.log('warn', 'trying to find convenient time to update'); - }; - while(true) { - await plugins.smartdelay.delayFor(60000); - swRegistration.update(); - } - }); + + this.swRegistration.onupdatefound = () => { + logger.log('info', 'update found for service worker!'); + logger.log('warn', 'trying to find convenient time to update'); + }; + + // Start polling with controllable abort mechanism + const opts = { ...DEFAULT_POLLING_OPTIONS, ...pollingOptions }; + this.startPolling(opts); + + // Set up visibility change handler to pause/resume polling + if (opts.pauseWhenHidden) { + this.setupVisibilityHandler(opts); + } + logger.log('ok', 'serviceworker registered'); await navigator.serviceWorker.ready; logger.log('ok', 'serviceworker is ready!'); @@ -41,6 +67,120 @@ export class ServiceworkerClient { } } + /** + * Starts the update polling loop with an AbortController + */ + private static startPolling(opts: IPollingOptions): void { + // Cancel any existing polling + this.stopPolling(); + + this.pollingController = new AbortController(); + this.isPollingActive = true; + const signal = this.pollingController.signal; + + const poll = async () => { + // Initial delay + await plugins.smartdelay.delayFor(opts.initialDelayMs); + + while (!signal.aborted && this.swRegistration) { + try { + // Check for updates + await this.swRegistration.update(); + logger.log('note', 'Service worker update check completed'); + } catch (err) { + if (!signal.aborted) { + logger.log('warn', `Service worker update check failed: ${err?.message || err}`); + } + } + + // Wait for next interval, but check abort signal + if (!signal.aborted) { + await this.delayWithAbort(opts.intervalMs, signal); + } + } + + this.isPollingActive = false; + logger.log('info', 'Service worker polling stopped'); + }; + + // Start polling (fire and forget) + poll().catch((err) => { + if (!signal.aborted) { + logger.log('error', `Service worker polling error: ${err?.message || err}`); + } + }); + } + + /** + * Stops the polling loop + */ + public static stopPolling(): void { + if (this.pollingController) { + this.pollingController.abort(); + this.pollingController = null; + } + this.isPollingActive = false; + } + + /** + * Sets up visibility change handler to pause/resume polling + */ + private static setupVisibilityHandler(opts: IPollingOptions): void { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + // Page is hidden, stop polling to save resources + logger.log('note', 'Page hidden, pausing service worker polling'); + this.stopPolling(); + } else if (document.visibilityState === 'visible') { + // Page is visible again, resume polling + if (!this.isPollingActive && this.swRegistration) { + logger.log('note', 'Page visible, resuming service worker polling'); + this.startPolling(opts); + } + } + }); + } + + /** + * Delay that can be aborted + */ + private static delayWithAbort(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(); + }, ms); + + signal.addEventListener('abort', () => { + clearTimeout(timeout); + resolve(); + }, { once: true }); + }); + } + + /** + * Manually triggers an update check + */ + public static async triggerUpdateCheck(): Promise { + if (this.swRegistration) { + try { + await this.swRegistration.update(); + logger.log('ok', 'Manual service worker update check completed'); + } catch (err) { + logger.log('error', `Manual update check failed: ${err?.message || err}`); + throw err; + } + } else { + logger.log('warn', 'Cannot trigger update check: no service worker registration'); + } + } + + /** + * Gets whether polling is currently active + */ + public static get isPolling(): boolean { + return this.isPollingActive; + } + private static async waitForController() { const done = new plugins.smartpromise.Deferred(); const checkReady = () => { @@ -66,4 +206,11 @@ export class ServiceworkerClient { this.actionManager = new ActionManager(); this.globalSw = new GlobalSW(this); } + + /** + * Cleanup method to stop polling when the client is no longer needed + */ + public destroy(): void { + ServiceworkerClient.stopPolling(); + } } \ No newline at end of file diff --git a/ts_web_serviceworker_client/index.ts b/ts_web_serviceworker_client/index.ts index aea1d97..1e7295b 100644 --- a/ts_web_serviceworker_client/index.ts +++ b/ts_web_serviceworker_client/index.ts @@ -11,14 +11,17 @@ export type { import { logger } from './logging.js'; logger.log('note', 'mainthread console initialized!'); -import { ServiceworkerClient } from './classes.serviceworkerclient.js'; +import { ServiceworkerClient, type IPollingOptions } from './classes.serviceworkerclient.js'; +import { type IConnectionOptions } from './classes.actionmanager.js'; export type { - ServiceworkerClient + ServiceworkerClient, + IPollingOptions, + IConnectionOptions, } -export const getServiceworkerClient = async () => { - const swClient = await ServiceworkerClient.createServiceWorker(); // lets setup the service worker +export const getServiceworkerClient = async (pollingOptions?: Partial) => { + const swClient = await ServiceworkerClient.createServiceWorker(pollingOptions); // lets setup the service worker logger.log('ok', 'service worker ready!'); // and wait for it to be ready return swClient; };