Compare commits

...

4 Commits

Author SHA1 Message Date
722bf5d946 v7.5.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 16:25:51 +00:00
299e3ac33f feat(serviceworker): Add real-time service worker push updates and DeesComms integration (metrics, events, resource caching) 2025-12-04 16:25:51 +00:00
951a48cf88 v7.4.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-04 15:33:47 +00:00
8b7fe245f0 fix(web_serviceworker): Improve service worker persistence, metrics and caching robustness 2025-12-04 15:33:47 +00:00
14 changed files with 467 additions and 26 deletions

View File

@@ -1,5 +1,27 @@
# Changelog
## 2025-12-04 - 7.5.0 - feat(serviceworker)
Add real-time service worker push updates and DeesComms integration (metrics, events, resource caching)
- Integrate DeesComms push channel for real-time SW → client communication and export/consume deesComms in relevant plugin modules.
- Add typed push message interfaces for events, metrics snapshots and resource-cached notifications in serviceworker interfaces.
- Implement backend push methods: pushEvent, pushMetricsUpdate (with 500ms throttle) and pushResourceCached in ServiceworkerBackend.
- Trigger push updates from MetricsCollector and PersistentStore so metrics and logged events are broadcast to connected clients.
- Add client-side DeesComms handlers in sw-dash app: receive metrics, event logs and resource notifications; add heartbeat and initial HTTP seed to maintain SW health state.
- Add event push listener and cleanup in sw-dash-events component to prepend incoming events and avoid leaks.
- Expose getServiceWorkerBackend() from SW init for internal modules to call push methods.
- Misc: implement request deduplication and various robustness improvements (throttling, heartbeat, safer polling, removed noisy debug logs).
## 2025-12-04 - 7.4.1 - fix(web_serviceworker)
Improve service worker persistence, metrics and caching robustness
- Ensure persistent store is initialized before use in dashboard handlers and service worker activation/handlers (calls to persistentStore.init())
- Make serveCumulativeMetrics async and align fetchEvent.respondWith usage (remove unnecessary Promise.resolve)
- Change persistent WebStore database name to 'losslessServiceworkerPersistent' to separate durable store from runtime store
- Make PersistentStore.init() more resilient: add detailed logging, avoid throwing on init failure, mark initialized to prevent retry loops, and start periodic save only after load
- Ensure logEvent awaits initialization and adds defensive logging around reading/writing the event log
- Add request deduplication logic and improved cache handling in CacheManager (fetchWithDeduplication usage and safer respondWith)
## 2025-12-04 - 7.4.0 - feat(serviceworker)
Add persistent event store, cumulative metrics and dashboard events UI for service worker observability

View File

@@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "7.4.0",
"version": "7.5.0",
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
"type": "module",
"exports": {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '7.4.0',
version: '7.5.0',
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
}

View File

@@ -436,4 +436,77 @@ export interface IRequest_Serviceworker_GetEventCount
response: {
count: number;
};
}
// ===============
// Push message interfaces (SW → Clients via DeesComms)
// ===============
/**
* Push notification when a new event is logged
* Sent via DeesComms BroadcastChannel
*/
export interface IMessage_Serviceworker_EventLogged
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IMessage_Serviceworker_EventLogged
> {
method: 'serviceworker_eventLogged';
request: IEventLogEntry;
response: {};
}
/**
* Metrics snapshot for push updates
*/
export interface IMetricsSnapshot {
cache: {
hits: number;
misses: number;
errors: number;
bytesServedFromCache: number;
bytesFetched: number;
};
network: {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
};
cacheHitRate: number;
networkSuccessRate: number;
resourceCount: number;
uptime: number;
timestamp: number;
}
/**
* Push notification for metrics updates
* Sent via DeesComms BroadcastChannel (throttled)
*/
export interface IMessage_Serviceworker_MetricsUpdate
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IMessage_Serviceworker_MetricsUpdate
> {
method: 'serviceworker_metricsUpdate';
request: IMetricsSnapshot;
response: {};
}
/**
* Push notification when a new resource is cached
*/
export interface IMessage_Serviceworker_ResourceCached
extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IMessage_Serviceworker_ResourceCached
> {
method: 'serviceworker_resourceCached';
request: {
url: string;
contentType: string;
size: number;
cached: boolean;
};
response: {};
}

View File

@@ -3,6 +3,9 @@ import { LitElement, html, css } from 'lit';
import type { CSSResult, TemplateResult } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
// DeesComms for push communication
import * as deesComms from '@design.estate/dees-comms';
export {
LitElement,
html,
@@ -10,6 +13,7 @@ export {
customElement,
property,
state,
deesComms,
};
export type { CSSResult, TemplateResult };

View File

@@ -1,10 +1,11 @@
import { LitElement, html, css, state, customElement } from './plugins.js';
import { LitElement, html, css, state, customElement, deesComms } from './plugins.js';
import type { CSSResult, TemplateResult } from './plugins.js';
import { sharedStyles, terminalStyles, navStyles } from './sw-dash-styles.js';
import type { IMetricsData } from './sw-dash-overview.js';
import type { ICachedResource } from './sw-dash-urls.js';
import type { IDomainStats } from './sw-dash-domains.js';
import type { IContentTypeStats } from './sw-dash-types.js';
import type { serviceworker } from '../dist_ts_interfaces/index.js';
// Import components to register them
import './sw-dash-overview.js';
@@ -134,39 +135,194 @@ export class SwDashApp extends LitElement {
resourceCount: 0
};
@state() accessor lastRefresh = new Date().toLocaleTimeString();
@state() accessor isConnected = false;
private refreshInterval: ReturnType<typeof setInterval> | null = null;
// DeesComms for receiving push updates from service worker
private comms: deesComms.DeesComms | null = null;
// Heartbeat interval (30 seconds) for SW health check
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private readonly HEARTBEAT_INTERVAL_MS = 30000;
connectedCallback(): void {
super.connectedCallback();
this.loadMetrics();
this.loadResourceData();
// Auto-refresh every 2 seconds
this.refreshInterval = setInterval(() => {
this.loadMetrics();
if (this.currentView !== 'overview') {
this.loadResourceData();
}
}, 2000);
// Initial HTTP seed request to wake up SW and get initial data
this.loadInitialData();
// Setup push listeners via DeesComms
this.setupPushListeners();
// Start heartbeat for SW health check
this.startHeartbeat();
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
}
private async loadMetrics(): Promise<void> {
/**
* Initial HTTP request to seed data and wake up service worker
*/
private async loadInitialData(): Promise<void> {
try {
const response = await fetch('/sw-dash/metrics');
this.metrics = await response.json();
// Fetch metrics (wakes up SW)
const metricsResponse = await fetch('/sw-dash/metrics');
this.metrics = await metricsResponse.json();
this.lastRefresh = new Date().toLocaleTimeString();
this.isConnected = true;
// Also load resources
const resourcesResponse = await fetch('/sw-dash/resources');
this.resourceData = await resourcesResponse.json();
} catch (err) {
console.error('Failed to load metrics:', err);
console.error('Failed to load initial data:', err);
this.isConnected = false;
}
}
/**
* Setup DeesComms handlers for receiving push updates
*/
private setupPushListeners(): void {
this.comms = new deesComms.DeesComms();
// Handle metrics push updates
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_MetricsUpdate>(
'serviceworker_metricsUpdate',
async (snapshot) => {
// Update metrics from push
if (this.metrics) {
this.metrics = {
...this.metrics,
cache: {
...this.metrics.cache,
hits: snapshot.cache.hits,
misses: snapshot.cache.misses,
errors: snapshot.cache.errors,
bytesServedFromCache: snapshot.cache.bytesServedFromCache,
bytesFetched: snapshot.cache.bytesFetched,
},
network: {
...this.metrics.network,
totalRequests: snapshot.network.totalRequests,
successfulRequests: snapshot.network.successfulRequests,
failedRequests: snapshot.network.failedRequests,
},
cacheHitRate: snapshot.cacheHitRate,
networkSuccessRate: snapshot.networkSuccessRate,
resourceCount: snapshot.resourceCount,
uptime: snapshot.uptime,
};
} else {
// If no metrics yet, create minimal structure
this.metrics = {
cache: {
hits: snapshot.cache.hits,
misses: snapshot.cache.misses,
errors: snapshot.cache.errors,
bytesServedFromCache: snapshot.cache.bytesServedFromCache,
bytesFetched: snapshot.cache.bytesFetched,
averageResponseTime: 0,
},
network: {
totalRequests: snapshot.network.totalRequests,
successfulRequests: snapshot.network.successfulRequests,
failedRequests: snapshot.network.failedRequests,
timeouts: 0,
averageLatency: 0,
totalBytesTransferred: 0,
},
update: {
totalChecks: 0,
successfulChecks: 0,
failedChecks: 0,
updatesFound: 0,
updatesApplied: 0,
lastCheckTimestamp: 0,
lastUpdateTimestamp: 0,
},
connection: {
connectedClients: 0,
totalConnectionAttempts: 0,
successfulConnections: 0,
failedConnections: 0,
},
speedtest: {
lastDownloadSpeedMbps: 0,
lastUploadSpeedMbps: 0,
lastLatencyMs: 0,
lastTestTimestamp: 0,
testCount: 0,
isOnline: true,
},
startTime: Date.now() - snapshot.uptime,
uptime: snapshot.uptime,
cacheHitRate: snapshot.cacheHitRate,
networkSuccessRate: snapshot.networkSuccessRate,
resourceCount: snapshot.resourceCount,
};
}
this.lastRefresh = new Date().toLocaleTimeString();
this.isConnected = true;
return {};
}
);
// Handle event log push updates - dispatch to events component
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_EventLogged>(
'serviceworker_eventLogged',
async (entry) => {
// Dispatch custom event for sw-dash-events component
this.dispatchEvent(new CustomEvent('event-logged', {
detail: entry,
bubbles: true,
composed: true,
}));
return {};
}
);
// Handle resource cached push updates
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_ResourceCached>(
'serviceworker_resourceCached',
async (resource) => {
// Update resource count optimistically
if (resource.cached && this.metrics) {
this.metrics = {
...this.metrics,
resourceCount: this.metrics.resourceCount + 1,
};
}
return {};
}
);
}
/**
* Heartbeat to check SW health periodically
*/
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(async () => {
try {
const response = await fetch('/sw-dash/metrics');
if (response.ok) {
this.isConnected = true;
// Optionally refresh full metrics periodically
this.metrics = await response.json();
this.lastRefresh = new Date().toLocaleTimeString();
} else {
this.isConnected = false;
}
} catch {
this.isConnected = false;
}
}, this.HEARTBEAT_INTERVAL_MS);
}
/**
* Load resource data on demand (when switching to urls/domains/types view)
*/
private async loadResourceData(): Promise<void> {
try {
const response = await fetch('/sw-dash/resources');
@@ -184,8 +340,8 @@ export class SwDashApp extends LitElement {
}
private handleSpeedtestComplete(_e: CustomEvent): void {
// Refresh metrics after speedtest
this.loadMetrics();
// Refresh metrics after speedtest via HTTP
this.loadInitialData();
}
private formatUptime(ms: number): string {

View File

@@ -197,9 +197,42 @@ export class SwDashEvents extends LitElement {
@state() accessor page = 1;
private readonly pageSize = 50;
// Bound event handler reference for cleanup
private boundEventHandler: ((e: Event) => void) | null = null;
connectedCallback(): void {
super.connectedCallback();
this.loadEvents();
// Listen for pushed events from parent
this.setupPushEventListener();
}
disconnectedCallback(): void {
super.disconnectedCallback();
// Clean up event listener
if (this.boundEventHandler) {
window.removeEventListener('event-logged', this.boundEventHandler);
}
}
/**
* Sets up listener for pushed events from service worker (via sw-dash-app)
*/
private setupPushEventListener(): void {
this.boundEventHandler = (e: Event) => {
const customEvent = e as CustomEvent<IEventLogEntry>;
const newEvent = customEvent.detail;
// Only add if it matches current filter (or filter is 'all')
if (this.filter === 'all' || newEvent.type === this.filter) {
// Prepend new event to the list
this.events = [newEvent, ...this.events];
this.totalCount++;
}
};
// Listen at window level since events bubble up with composed: true
window.addEventListener('event-logged', this.boundEventHandler);
}
private async loadEvents(): Promise<void> {

View File

@@ -47,6 +47,11 @@ export class ServiceworkerBackend {
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;
constructor(optionsArg: {
self: any;
purgeCache: (reqArg: interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['request']) => Promise<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['response']>;
@@ -352,7 +357,7 @@ export class ServiceworkerBackend {
title: 'Alert',
body: alertText
});
// Send message to clients who might be able to show an actual alert
try {
await this.deesComms.postMessage({
@@ -365,4 +370,104 @@ export class ServiceworkerBackend {
logger.log('error', `Failed to send alert to clients: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ===============
// Push methods for real-time updates
// ===============
/**
* Pushes a new event log entry to all connected clients
* Called immediately when an event is logged
*/
public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise<void> {
try {
await this.deesComms.postMessage({
method: 'serviceworker_eventLogged',
request: entry,
messageId: `sw_event_${entry.id}`
});
logger.log('note', `Pushed event to clients: ${entry.type}`);
} catch (error) {
logger.log('warn', `Failed to push event: ${error}`);
}
}
/**
* Pushes a metrics update to all connected clients
* Throttled to max 1 update per 500ms to prevent spam
*/
public pushMetricsUpdate(): void {
// Mark that we have a pending update
this.pendingMetricsUpdate = true;
// If we're already throttling, just wait for the next window
if (this.metricsUpdateThrottle) {
return;
}
// Send the update and start throttle window
this.sendMetricsUpdate();
this.metricsUpdateThrottle = setTimeout(() => {
this.metricsUpdateThrottle = null;
// If there was a pending update during the throttle window, send it now
if (this.pendingMetricsUpdate) {
this.sendMetricsUpdate();
}
}, this.METRICS_THROTTLE_MS);
}
/**
* Actually sends the metrics update via DeesComms
*/
private async sendMetricsUpdate(): Promise<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({
method: 'serviceworker_metricsUpdate',
request: snapshot,
messageId: `sw_metrics_${Date.now()}`
});
} catch (error) {
logger.log('warn', `Failed to push metrics update: ${error}`);
}
}
/**
* Pushes notification when a resource is cached
*/
public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise<void> {
try {
await this.deesComms.postMessage({
method: 'serviceworker_resourceCached',
request: { url, contentType, size, cached },
messageId: `sw_resource_${Date.now()}`
});
} catch (error) {
logger.log('warn', `Failed to push resource cached: ${error}`);
}
}
}

View File

@@ -237,7 +237,7 @@ export class CacheManager {
}
if (parsedUrl.pathname === '/sw-dash/cumulative-metrics') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveCumulativeMetrics()));
fetchEventArg.respondWith(dashboard.serveCumulativeMetrics());
return;
}
// DELETE method for clearing events

View File

@@ -52,6 +52,7 @@ export class DashboardGenerator {
*/
public async serveEventLog(searchParams: URLSearchParams): Promise<Response> {
const persistentStore = getPersistentStore();
await persistentStore.init();
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : undefined;
const type = searchParams.get('type') as TEventType | undefined;
@@ -72,6 +73,7 @@ export class DashboardGenerator {
*/
public async serveEventCount(searchParams: URLSearchParams): Promise<Response> {
const persistentStore = getPersistentStore();
await persistentStore.init();
const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : Date.now() - 3600000; // Default: last hour
@@ -88,8 +90,9 @@ export class DashboardGenerator {
/**
* Serves cumulative metrics
*/
public serveCumulativeMetrics(): Response {
public async serveCumulativeMetrics(): Promise<Response> {
const persistentStore = getPersistentStore();
await persistentStore.init();
const metrics = persistentStore.getCumulativeMetrics();
return new Response(JSON.stringify(metrics), {
@@ -105,6 +108,7 @@ export class DashboardGenerator {
*/
public async clearEventLog(): Promise<Response> {
const persistentStore = getPersistentStore();
await persistentStore.init();
const success = await persistentStore.clearEventLog();
return new Response(JSON.stringify({ success }), {
@@ -126,6 +130,7 @@ export class DashboardGenerator {
public async runSpeedtest(): Promise<Response> {
const metrics = getMetricsCollector();
const persistentStore = getPersistentStore();
await persistentStore.init();
const results: {
latency?: { durationMs: number };
download?: { durationMs: number; speedMbps: number; bytesTransferred: number };

View File

@@ -1,4 +1,5 @@
import { logger } from './logging.js';
import { getServiceWorkerBackend } from './init.js';
/**
* Interface for cache metrics
@@ -178,6 +179,20 @@ export class MetricsCollector {
this.startTime = Date.now();
}
/**
* Triggers a push metrics update to all connected clients (throttled in backend)
*/
private triggerPushUpdate(): void {
try {
const backend = getServiceWorkerBackend();
if (backend) {
backend.pushMetricsUpdate();
}
} catch (error) {
// Silently ignore - push is best-effort
}
}
/**
* Gets the singleton instance
*/
@@ -196,11 +211,13 @@ export class MetricsCollector {
this.cacheHits++;
this.bytesServedFromCache += bytes;
logger.log('note', `[Metrics] Cache hit: ${url} (${bytes} bytes)`);
this.triggerPushUpdate();
}
public recordCacheMiss(url: string): void {
this.cacheMisses++;
logger.log('note', `[Metrics] Cache miss: ${url}`);
this.triggerPushUpdate();
}
public recordCacheError(url: string, error?: string): void {
@@ -224,11 +241,13 @@ export class MetricsCollector {
this.successfulRequests++;
this.totalBytesTransferred += bytes;
this.recordResponseTime(url, duration);
this.triggerPushUpdate();
}
public recordRequestFailure(url: string, error?: string): void {
this.failedRequests++;
logger.log('warn', `[Metrics] Request failed: ${url} - ${error || 'unknown'}`);
this.triggerPushUpdate();
}
public recordTimeout(url: string, duration: number): void {

View File

@@ -1,6 +1,7 @@
import * as plugins from './plugins.js';
import { logger } from './logging.js';
import type { serviceworker } from '../dist_ts_interfaces/index.js';
import { getServiceWorkerBackend } from './init.js';
type ICumulativeMetrics = serviceworker.ICumulativeMetrics;
type IEventLogEntry = serviceworker.IEventLogEntry;
@@ -66,7 +67,7 @@ export class PersistentStore {
private constructor() {
this.store = new plugins.webstore.WebStore({
dbName: 'losslessServiceworker',
dbName: 'losslessServiceworkerPersistent',
storeName: 'persistentStore',
});
}
@@ -90,6 +91,7 @@ export class PersistentStore {
}
try {
// Initialize the WebStore (required before using any methods)
await this.store.init();
await this.loadCumulativeMetrics();
@@ -107,7 +109,8 @@ export class PersistentStore {
logger.log('ok', '[PersistentStore] Initialized successfully');
} catch (error) {
logger.log('error', `[PersistentStore] Failed to initialize: ${error}`);
throw error;
// Don't throw - allow SW to continue even if persistent store fails
this.initialized = true; // Mark as initialized to prevent retry loops
}
}
@@ -271,6 +274,11 @@ export class PersistentStore {
};
try {
// Ensure initialized
if (!this.initialized) {
await this.init();
}
let events: IEventLogEntry[] = [];
if (await this.store.check(this.EVENT_LOG_KEY)) {
@@ -285,6 +293,17 @@ export class PersistentStore {
await this.store.set(this.EVENT_LOG_KEY, events);
logger.log('note', `[PersistentStore] Logged event: ${type} - ${message}`);
// Push event to connected clients via DeesComms
try {
const backend = getServiceWorkerBackend();
if (backend) {
await backend.pushEvent(entry);
}
} catch (pushError) {
// Don't fail the log operation if push fails
logger.log('warn', `[PersistentStore] Failed to push event: ${pushError}`);
}
} catch (error) {
logger.log('error', `[PersistentStore] Failed to log event: ${error}`);
}

View File

@@ -95,6 +95,7 @@ export class ServiceWorker {
// Log activation event
const persistentStore = getPersistentStore();
await persistentStore.init(); // Ensure store is initialized (safe to call multiple times)
await persistentStore.logEvent('sw_activated', 'Service worker activated', {
timestamp: new Date().toISOString(),
});
@@ -123,6 +124,7 @@ export class ServiceWorker {
// Log cache invalidation event (survives)
const persistentStore = getPersistentStore();
await persistentStore.init(); // Ensure store is initialized
await persistentStore.logEvent('cache_invalidated', `Cache invalidated: ${reqArg.reason}`, {
reason: reqArg.reason,
timestamp: reqArg.timestamp,

View File

@@ -4,7 +4,10 @@ import * as env from './env.js';
declare var self: env.ServiceWindow;
import { ServiceWorker } from './classes.serviceworker.js';
import type { ServiceworkerBackend } from './classes.backend.js';
const sw = new ServiceWorker(self);
export const getServiceWorkerInstance = (): ServiceWorker => sw;
export const getServiceWorkerBackend = (): ServiceworkerBackend => sw.leleServiceWorkerBackend;