521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import * as interfaces from '../dist_ts_interfaces/index.js';
|
|
import { logger } from './logging.js';
|
|
import { getMetricsCollector } from './classes.metrics.js';
|
|
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
|
|
import { getPersistentStore } from './classes.persistentstore.js';
|
|
import { getRequestLogStore } from './classes.requestlogstore.js';
|
|
|
|
// Add type definitions for ServiceWorker APIs
|
|
declare global {
|
|
interface ServiceWorkerGlobalScope extends EventTarget {
|
|
clients: Clients;
|
|
registration: ServiceWorkerRegistration;
|
|
}
|
|
|
|
// Define Clients interface
|
|
interface Clients {
|
|
matchAll(options?: ClientQueryOptions): Promise<Client[]>;
|
|
openWindow(url: string): Promise<WindowClient>;
|
|
claim(): Promise<void>;
|
|
get(id: string): Promise<Client | undefined>;
|
|
}
|
|
|
|
interface ClientQueryOptions {
|
|
includeUncontrolled?: boolean;
|
|
type?: 'window' | 'worker' | 'sharedworker' | 'all';
|
|
}
|
|
|
|
interface Client {
|
|
id: string;
|
|
type: 'window' | 'worker' | 'sharedworker';
|
|
url: string;
|
|
}
|
|
|
|
interface WindowClient extends Client {
|
|
focused: boolean;
|
|
visibilityState: 'hidden' | 'visible' | 'prerender' | 'unloaded';
|
|
focus(): Promise<WindowClient>;
|
|
navigate(url: string): Promise<WindowClient>;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This class is meant to be used only on the backend side
|
|
*/
|
|
export class ServiceworkerBackend {
|
|
public deesComms = new plugins.deesComms.DeesComms();
|
|
private swSelf: ServiceWorkerGlobalScope;
|
|
private clientUpdateInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Throttling for metrics updates (max 1 per 500ms)
|
|
private metricsUpdateThrottle: ReturnType<typeof setTimeout> | null = null;
|
|
private pendingMetricsUpdate = false;
|
|
private readonly METRICS_THROTTLE_MS = 500;
|
|
|
|
/**
|
|
* Helper to create properly formatted TypedRequest messages for DeesComms
|
|
*/
|
|
private createMessage<T>(method: string, request: T): any {
|
|
const id = `${method}_${Date.now()}`;
|
|
return {
|
|
method,
|
|
request,
|
|
messageId: id,
|
|
correlation: {
|
|
id,
|
|
phase: 'request' as const
|
|
}
|
|
};
|
|
}
|
|
|
|
constructor(optionsArg: {
|
|
self: any;
|
|
purgeCache: (reqArg: interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['request']) => Promise<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['response']>;
|
|
}) {
|
|
this.swSelf = optionsArg.self as unknown as ServiceWorkerGlobalScope;
|
|
const metrics = getMetricsCollector();
|
|
|
|
// lets handle wakestuff
|
|
optionsArg.self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'wakeUpCall') {
|
|
console.log('sw-backend: got wake up call');
|
|
}
|
|
});
|
|
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Client_Serviceworker_ConnectionPolling>('broadcastConnectionPolling', async reqArg => {
|
|
// Record connection attempt
|
|
metrics.recordConnectionAttempt();
|
|
metrics.recordConnectionSuccess();
|
|
// Update connected clients count
|
|
await this.updateConnectedClientsCount();
|
|
return {
|
|
serviceworkerId: '123'
|
|
};
|
|
});
|
|
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache>('purgeServiceWorkerCache', async reqArg => {
|
|
console.log(`Executing purge cache in serviceworker backend.`)
|
|
return await optionsArg.purgeCache?.(reqArg);
|
|
});
|
|
|
|
// Handler for getting current SW status
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetStatus>('serviceworker_getStatus', async () => {
|
|
const metrics = getMetricsCollector();
|
|
const metricsData = metrics.getMetrics();
|
|
return {
|
|
isActive: true,
|
|
isOnline: metricsData.speedtest.isOnline,
|
|
cacheHitRate: metrics.getCacheHitRate(),
|
|
resourceCount: metrics.getResourceCount(),
|
|
connectedClients: metricsData.connection.connectedClients,
|
|
lastUpdateCheck: metricsData.update.lastCheckTimestamp,
|
|
};
|
|
});
|
|
|
|
// Handler for getting event log
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetEventLog>('serviceworker_getEventLog', async (reqArg) => {
|
|
const persistentStore = getPersistentStore();
|
|
return await persistentStore.getEventLog({
|
|
limit: reqArg.limit,
|
|
type: reqArg.type,
|
|
since: reqArg.since,
|
|
before: reqArg.before,
|
|
});
|
|
});
|
|
|
|
// Handler for getting cumulative metrics
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetCumulativeMetrics>('serviceworker_getCumulativeMetrics', async () => {
|
|
const persistentStore = getPersistentStore();
|
|
return persistentStore.getCumulativeMetrics();
|
|
});
|
|
|
|
// Handler for clearing event log
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearEventLog>('serviceworker_clearEventLog', async () => {
|
|
const persistentStore = getPersistentStore();
|
|
const success = await persistentStore.clearEventLog();
|
|
return { success };
|
|
});
|
|
|
|
// Handler for getting event count since timestamp
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetEventCount>('serviceworker_getEventCount', async (reqArg) => {
|
|
const persistentStore = getPersistentStore();
|
|
const count = await persistentStore.getEventCount(reqArg.since);
|
|
return { count };
|
|
});
|
|
|
|
// ================================
|
|
// TypedRequest Traffic Monitoring
|
|
// ================================
|
|
|
|
// Handler for receiving TypedRequest logs from clients
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog', async (reqArg) => {
|
|
const requestLogStore = getRequestLogStore();
|
|
requestLogStore.addEntry(reqArg);
|
|
|
|
// Broadcast to sw-dash viewers
|
|
await this.broadcastTypedRequestLogged(reqArg);
|
|
return {};
|
|
});
|
|
|
|
// Handler for getting TypedRequest logs
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs', async (reqArg) => {
|
|
const requestLogStore = getRequestLogStore();
|
|
const logs = requestLogStore.getEntries({
|
|
limit: reqArg.limit,
|
|
method: reqArg.method,
|
|
since: reqArg.since,
|
|
before: reqArg.before,
|
|
});
|
|
const totalCount = requestLogStore.getTotalCount({
|
|
method: reqArg.method,
|
|
since: reqArg.since,
|
|
});
|
|
return { logs, totalCount };
|
|
});
|
|
|
|
// Handler for getting TypedRequest statistics
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats', async () => {
|
|
const requestLogStore = getRequestLogStore();
|
|
return requestLogStore.getStats();
|
|
});
|
|
|
|
// Handler for clearing TypedRequest logs
|
|
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs', async () => {
|
|
const requestLogStore = getRequestLogStore();
|
|
requestLogStore.clear();
|
|
return { success: true };
|
|
});
|
|
|
|
// Periodically update connected clients count
|
|
this.startClientCountUpdates();
|
|
|
|
// Subscribe to EventBus and broadcast status updates
|
|
this.setupEventBusSubscriptions();
|
|
}
|
|
|
|
/**
|
|
* Sets up subscriptions to EventBus events and broadcasts them to clients
|
|
*/
|
|
private setupEventBusSubscriptions(): void {
|
|
const eventBus = getEventBus();
|
|
const persistentStore = getPersistentStore();
|
|
|
|
// Network status changes
|
|
eventBus.on(ServiceWorkerEvent.NETWORK_ONLINE, async () => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'network',
|
|
type: 'online',
|
|
message: 'Connection restored',
|
|
persist: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Log to persistent store
|
|
await persistentStore.logEvent('network_online', 'Network connection restored');
|
|
});
|
|
|
|
eventBus.on(ServiceWorkerEvent.NETWORK_OFFLINE, async () => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'network',
|
|
type: 'offline',
|
|
message: 'Connection lost - offline mode',
|
|
persist: true,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Log to persistent store
|
|
await persistentStore.logEvent('network_offline', 'Network connection lost');
|
|
});
|
|
|
|
// Update events
|
|
eventBus.on(ServiceWorkerEvent.UPDATE_AVAILABLE, async (_event, payload: any) => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'serviceworker',
|
|
type: 'update',
|
|
message: 'Update available',
|
|
details: {
|
|
version: payload.newVersion,
|
|
},
|
|
persist: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Log to persistent store
|
|
await persistentStore.logEvent('update_check', `Update available: ${payload.newVersion}`, {
|
|
newVersion: payload.newVersion,
|
|
});
|
|
});
|
|
|
|
eventBus.on(ServiceWorkerEvent.UPDATE_APPLIED, async (_event, payload: any) => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'serviceworker',
|
|
type: 'update',
|
|
message: 'Update applied',
|
|
details: {
|
|
version: payload.newVersion,
|
|
},
|
|
persist: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Log to persistent store
|
|
await persistentStore.logEvent('sw_updated', `Service worker updated to ${payload.newVersion}`, {
|
|
newVersion: payload.newVersion,
|
|
});
|
|
});
|
|
|
|
eventBus.on(ServiceWorkerEvent.UPDATE_ERROR, async (_event, payload: any) => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'serviceworker',
|
|
type: 'error',
|
|
message: `Update error: ${payload.error || 'Unknown error'}`,
|
|
persist: true,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Log to persistent store
|
|
await persistentStore.logEvent('error', `Update error: ${payload.error || 'Unknown error'}`, {
|
|
error: payload.error,
|
|
});
|
|
});
|
|
|
|
// Cache invalidation
|
|
eventBus.on(ServiceWorkerEvent.CACHE_INVALIDATE_ALL, () => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'serviceworker',
|
|
type: 'cache',
|
|
message: 'Clearing cache...',
|
|
persist: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Note: cache_invalidated event is logged in the ServiceWorker class
|
|
});
|
|
|
|
// Lifecycle events
|
|
eventBus.on(ServiceWorkerEvent.ACTIVATE, () => {
|
|
this.broadcastStatusUpdate({
|
|
source: 'serviceworker',
|
|
type: 'connected',
|
|
message: 'Service worker activated',
|
|
persist: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
// Note: sw_activated event is logged in the ServiceWorker class
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Broadcasts a status update to all connected clients
|
|
*/
|
|
public async broadcastStatusUpdate(status: interfaces.serviceworker.IStatusUpdate): Promise<void> {
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_statusUpdate', status));
|
|
logger.log('info', `Status update broadcast: ${status.source}:${status.type} - ${status.message}`);
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to broadcast status update: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Broadcasts a TypedRequest log entry to all connected clients (for sw-dash)
|
|
*/
|
|
public async broadcastTypedRequestLogged(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_typedRequestLogged', entry));
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to broadcast TypedRequest log: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start periodic updates of connected client count
|
|
*/
|
|
private startClientCountUpdates(): void {
|
|
// Update immediately
|
|
this.updateConnectedClientsCount();
|
|
|
|
// Then update every 5 seconds
|
|
this.clientUpdateInterval = setInterval(() => {
|
|
this.updateConnectedClientsCount();
|
|
}, 5000);
|
|
}
|
|
|
|
/**
|
|
* Update the connected clients count using the Clients API
|
|
*/
|
|
private async updateConnectedClientsCount(): Promise<void> {
|
|
try {
|
|
const clients = await this.swSelf.clients.matchAll({ type: 'window' });
|
|
const metrics = getMetricsCollector();
|
|
metrics.setConnectedClients(clients.length);
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to update connected clients count: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* reloads all clients
|
|
*/
|
|
public async triggerReloadAll() {
|
|
try {
|
|
logger.log('info', 'Triggering reload for all clients due to new version');
|
|
|
|
// Send update message via DeesComms
|
|
// This will be picked up by clients that have registered a handler for 'serviceworker_newVersion'
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_newVersion', {}));
|
|
|
|
// As a fallback, also use the clients API to reload clients that might not catch the broadcast
|
|
const clients = await this.swSelf.clients.matchAll({ type: 'window' });
|
|
logger.log('info', `Found ${clients.length} clients to reload`);
|
|
|
|
// Update metrics with current client count
|
|
const metrics = getMetricsCollector();
|
|
metrics.setConnectedClients(clients.length);
|
|
|
|
for (const client of clients) {
|
|
if ('navigate' in client) {
|
|
// For modern browsers, navigate to the same URL to trigger reload
|
|
(client as any).navigate(client.url);
|
|
logger.log('info', `Navigated client to: ${client.url}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.log('error', `Failed to reload clients: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* display notification
|
|
*/
|
|
public async addNotification(notificationArg: {
|
|
title: string;
|
|
body: string;
|
|
}) {
|
|
try {
|
|
// Check if we have permission to show notifications
|
|
const permission = self.Notification?.permission;
|
|
if (permission !== 'granted') {
|
|
logger.log('warn', `Cannot show notification: permission is ${permission}`);
|
|
return;
|
|
}
|
|
|
|
// Type-cast self to ServiceWorkerGlobalScope
|
|
const swSelf = self as unknown as ServiceWorkerGlobalScope;
|
|
|
|
// Use type assertion for notification options to include vibrate
|
|
const options = {
|
|
body: notificationArg.body,
|
|
icon: '/favicon.ico', // Assuming there's a favicon
|
|
badge: '/favicon.ico',
|
|
vibrate: [200, 100, 200]
|
|
} as NotificationOptions;
|
|
|
|
await swSelf.registration.showNotification(notificationArg.title, options);
|
|
|
|
logger.log('info', `Notification shown: ${notificationArg.title}`);
|
|
} catch (error) {
|
|
logger.log('error', `Failed to show notification: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
public async alert(alertText: string) {
|
|
// Since we can't directly show alerts from service worker context,
|
|
// we'll use notifications as a fallback
|
|
await this.addNotification({
|
|
title: 'Alert',
|
|
body: alertText
|
|
});
|
|
|
|
// Send message to clients who might be able to show an actual alert
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_alert', { message: alertText }));
|
|
logger.log('info', `Alert message sent to clients: ${alertText}`);
|
|
} catch (error) {
|
|
logger.log('error', `Failed to send alert to clients: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// ===============
|
|
// Push methods for real-time updates
|
|
// ===============
|
|
|
|
/**
|
|
* Pushes a new event log entry to all connected clients
|
|
* Called immediately when an event is logged
|
|
*/
|
|
public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise<void> {
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_eventLogged', entry));
|
|
logger.log('note', `Pushed event to clients: ${entry.type}`);
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to push event: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pushes a metrics update to all connected clients
|
|
* Throttled to max 1 update per 500ms to prevent spam
|
|
*/
|
|
public pushMetricsUpdate(): void {
|
|
// Mark that we have a pending update
|
|
this.pendingMetricsUpdate = true;
|
|
|
|
// If we're already throttling, just wait for the next window
|
|
if (this.metricsUpdateThrottle) {
|
|
return;
|
|
}
|
|
|
|
// Send the update and start throttle window
|
|
this.sendMetricsUpdate();
|
|
|
|
this.metricsUpdateThrottle = setTimeout(() => {
|
|
this.metricsUpdateThrottle = null;
|
|
// If there was a pending update during the throttle window, send it now
|
|
if (this.pendingMetricsUpdate) {
|
|
this.sendMetricsUpdate();
|
|
}
|
|
}, this.METRICS_THROTTLE_MS);
|
|
}
|
|
|
|
/**
|
|
* Actually sends the metrics update via DeesComms
|
|
*/
|
|
private async sendMetricsUpdate(): Promise<void> {
|
|
this.pendingMetricsUpdate = false;
|
|
const metrics = getMetricsCollector();
|
|
const metricsData = metrics.getMetrics();
|
|
|
|
const snapshot: interfaces.serviceworker.IMetricsSnapshot = {
|
|
cache: {
|
|
hits: metricsData.cache.hits,
|
|
misses: metricsData.cache.misses,
|
|
errors: metricsData.cache.errors,
|
|
bytesServedFromCache: metricsData.cache.bytesServedFromCache,
|
|
bytesFetched: metricsData.cache.bytesFetched,
|
|
},
|
|
network: {
|
|
totalRequests: metricsData.network.totalRequests,
|
|
successfulRequests: metricsData.network.successfulRequests,
|
|
failedRequests: metricsData.network.failedRequests,
|
|
},
|
|
cacheHitRate: metrics.getCacheHitRate(),
|
|
networkSuccessRate: metrics.getNetworkSuccessRate(),
|
|
resourceCount: metrics.getResourceCount(),
|
|
uptime: metricsData.uptime,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_metricsUpdate', snapshot));
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to push metrics update: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pushes notification when a resource is cached
|
|
*/
|
|
public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise<void> {
|
|
try {
|
|
await this.deesComms.postMessage(this.createMessage('serviceworker_resourceCached', { url, contentType, size, cached }));
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to push resource cached: ${error}`);
|
|
}
|
|
}
|
|
} |