Files
typedserver/ts_web_serviceworker/classes.backend.ts

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}`);
}
}
}