feat(web_serviceworker): Enhance service worker subsystem: add metrics, event bus, error handling, config and caching/update improvements; make client connection & polling robust
This commit is contained in:
@@ -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<string, Promise<Response>> = new Map();
|
||||
private readonly INFLIGHT_CLEANUP_INTERVAL = 30000; // 30 seconds
|
||||
private cleanupIntervalId: ReturnType<typeof setInterval> | 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<Response> {
|
||||
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));
|
||||
|
||||
232
ts_web_serviceworker/classes.config.ts
Normal file
232
ts_web_serviceworker/classes.config.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<IServiceWorkerConfig>): Promise<void> {
|
||||
this.config = this.mergeConfig(this.config, partialConfig);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets configuration to defaults
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
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>
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
333
ts_web_serviceworker/classes.errorhandler.ts
Normal file
333
ts_web_serviceworker/classes.errorhandler.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> {
|
||||
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<IErrorContext> = {}
|
||||
): 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<IErrorContext> = {}
|
||||
): 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<IErrorContext> = {}
|
||||
): 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<IErrorContext> = {}
|
||||
): 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<IErrorContext> = {}
|
||||
): 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<ServiceWorkerErrorType, number> {
|
||||
const stats: Record<ServiceWorkerErrorType, number> = {
|
||||
[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();
|
||||
409
ts_web_serviceworker/classes.eventbus.ts
Normal file
409
ts_web_serviceworker/classes.eventbus.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* Event listener callback type
|
||||
*/
|
||||
export type TEventListener<T extends TEventPayload = TEventPayload> = (
|
||||
event: ServiceWorkerEvent,
|
||||
payload: T
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<ServiceWorkerEvent, Set<TEventListener>>;
|
||||
private globalListeners: Set<TEventListener>;
|
||||
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<T extends TEventPayload>(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<T extends TEventPayload>(
|
||||
event: ServiceWorkerEvent,
|
||||
listener: TEventListener<T>
|
||||
): 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<T extends TEventPayload>(
|
||||
events: ServiceWorkerEvent[],
|
||||
listener: TEventListener<T>
|
||||
): 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<T extends TEventPayload>(listener: TEventListener<T>): 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<T extends TEventPayload>(
|
||||
event: ServiceWorkerEvent,
|
||||
listener: TEventListener<T>
|
||||
): 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<T extends TEventPayload>(
|
||||
event: ServiceWorkerEvent,
|
||||
timeout?: number
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const subscription = this.once<T>(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();
|
||||
386
ts_web_serviceworker/classes.metrics.ts
Normal file
386
ts_web_serviceworker/classes.metrics.ts
Normal file
@@ -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();
|
||||
@@ -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<boolean> | 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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
// 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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user