From c4e0e9b91568c4fbd06d4234a778435a832ea58d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 4 Dec 2025 14:09:10 +0000 Subject: [PATCH] feat(serviceworker): Add service worker status updates, EventBus and UI status pill for realtime observability --- changelog.md | 12 + ts/00_commitinfo_data.ts | 2 +- ts_interfaces/serviceworker.ts | 73 +++ ts_web_inject/index.ts | 150 ++++- ts_web_inject/typedserver_web.statuspill.ts | 534 ++++++++++++++++++ ts_web_serviceworker/classes.backend.ts | 121 ++++ .../classes.actionmanager.ts | 49 ++ 7 files changed, 924 insertions(+), 17 deletions(-) create mode 100644 ts_web_inject/typedserver_web.statuspill.ts diff --git a/changelog.md b/changelog.md index 6784c3e..b387eb9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-12-04 - 7.2.0 - feat(serviceworker) +Add service worker status updates, EventBus and UI status pill for realtime observability + +- Introduce a status update protocol for service worker <-> clients (IStatusUpdate, IMessage_Serviceworker_StatusUpdate, IRequest_Serviceworker_GetStatus). +- Add typedserver-statuspill Lit component to display backend/serviceworker/network status in the UI, with expand/collapse details and persistent/error states. +- Wire ReloadChecker to use the new status pill: show network/backend/serviceworker status, handle online/offline events, and subscribe to service worker status broadcasts. +- Extend ActionManager (client) with subscribeToStatusUpdates and getServiceWorkerStatus helpers; forward serviceworker_statusUpdate broadcasts to registered callbacks. +- Serviceworker backend: add serviceworker_getStatus handler and broadcastStatusUpdate API; subscribe to EventBus lifecycle/network/update events to broadcast status changes to clients. +- Add EventBus for decoupled service worker internal events (ServiceWorkerEvent enum, pub/sub API, history and convenience emitters). +- Ensure proper subscribe/unsubscribe lifecycle (ReloadChecker stops SW subscription on stop). +- Improve cache/connection status reporting integration so status updates include details like cacheHitRate, resourceCount and connected clients. + ## 2025-12-04 - 7.1.0 - feat(swdash) Add live speedtest progress UI to service worker dashboard diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index b19eaa3..5c398b3 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '7.1.0', + version: '7.2.0', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts_interfaces/serviceworker.ts b/ts_interfaces/serviceworker.ts index aa0598e..65c1a0f 100644 --- a/ts_interfaces/serviceworker.ts +++ b/ts_interfaces/serviceworker.ts @@ -240,4 +240,77 @@ export interface IRequest_Serviceworker_Speedtest timestamp: number; payload?: string; // For download_chunk, the data received }; +} + +// =============== +// Status update interfaces +// =============== + +/** + * Status update source types + */ +export type TStatusSource = 'backend' | 'serviceworker' | 'network'; + +/** + * Status update event types + */ +export type TStatusType = 'connected' | 'disconnected' | 'reconnecting' | 'update' | 'cache' | 'error' | 'offline' | 'online'; + +/** + * Status update details + */ +export interface IStatusDetails { + version?: string; + cacheHitRate?: number; + resourceCount?: number; + connectionType?: string; + latencyMs?: number; + message?: string; +} + +/** + * Status update payload sent from SW to clients + */ +export interface IStatusUpdate { + source: TStatusSource; + type: TStatusType; + message: string; + details?: IStatusDetails; + persist?: boolean; // Stay visible until resolved + timestamp: number; +} + +/** + * Message for status updates from service worker to clients + */ +export interface IMessage_Serviceworker_StatusUpdate + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IMessage_Serviceworker_StatusUpdate + > { + method: 'serviceworker_statusUpdate'; + request: IStatusUpdate; + response: {}; +} + +/** + * Request to get current service worker status + */ +export interface IRequest_Serviceworker_GetStatus + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_GetStatus + > { + method: 'serviceworker_getStatus'; + request: {}; + response: { + isActive: boolean; + isOnline: boolean; + version?: string; + cacheHitRate: number; + resourceCount: number; + connectionType?: string; + connectedClients: number; + lastUpdateCheck: number; + }; } \ No newline at end of file diff --git a/ts_web_inject/index.ts b/ts_web_inject/index.ts index 8245185..5cd3f13 100644 --- a/ts_web_inject/index.ts +++ b/ts_web_inject/index.ts @@ -3,12 +3,12 @@ import * as interfaces from '../dist_ts_interfaces/index.js'; import { logger } from './typedserver_web.logger.js'; logger.log('info', `TypedServer-Devtools initialized!`); -import { TypedserverInfoscreen } from './typedserver_web.infoscreen.js'; +import { TypedserverStatusPill } from './typedserver_web.statuspill.js'; export class ReloadChecker { public reloadJustified = false; public backendConnectionLost = false; - public infoscreen = new TypedserverInfoscreen(); + public statusPill = new TypedserverStatusPill(); public store = new plugins.webstore.WebStore({ dbName: 'apiglobal__typedserver', storeName: 'apiglobal__typedserver', @@ -17,14 +17,90 @@ export class ReloadChecker { public typedsocket: plugins.typedsocket.TypedSocket; public typedrouter = new plugins.typedrequest.TypedRouter(); + private swStatusUnsubscribe: (() => void) | null = null; - constructor() {} + constructor() { + // Listen to browser online/offline events + window.addEventListener('online', () => { + this.statusPill.updateStatus({ + source: 'network', + type: 'online', + message: 'Back online', + persist: false, + timestamp: Date.now(), + }); + }); + + window.addEventListener('offline', () => { + this.statusPill.updateStatus({ + source: 'network', + type: 'offline', + message: 'No internet connection', + persist: true, + timestamp: Date.now(), + }); + }); + } public async reload() { // this looks a bit hacky, but apparently is the safest way to really reload stuff window.location.reload(); } + /** + * Subscribe to service worker status updates + */ + public subscribeToServiceWorker(): void { + // Check if service worker client is available + if (globalThis.globalSw?.actionManager) { + this.swStatusUnsubscribe = globalThis.globalSw.actionManager.subscribeToStatusUpdates((status) => { + this.statusPill.updateStatus({ + source: status.source, + type: status.type, + message: status.message, + details: status.details, + persist: status.persist || false, + timestamp: status.timestamp, + }); + }); + logger.log('info', 'Subscribed to service worker status updates'); + + // Get initial SW status + this.fetchServiceWorkerStatus(); + } else { + logger.log('note', 'Service worker client not available yet, will retry...'); + // Retry after a delay + setTimeout(() => this.subscribeToServiceWorker(), 2000); + } + } + + /** + * Fetch and display initial service worker status + */ + private async fetchServiceWorkerStatus(): Promise { + if (!globalThis.globalSw?.actionManager) return; + + try { + const status = await globalThis.globalSw.actionManager.getServiceWorkerStatus(); + if (status) { + this.statusPill.updateStatus({ + source: 'serviceworker', + type: status.isActive ? 'connected' : 'disconnected', + message: status.isActive ? 'Service worker active' : 'Service worker inactive', + details: { + cacheHitRate: status.cacheHitRate, + resourceCount: status.resourceCount, + connectionType: status.connectionType, + }, + persist: false, + timestamp: Date.now(), + }); + } + } catch (error) { + logger.log('warn', `Failed to get SW status: ${error}`); + } + } + /** * starts the reload checker */ @@ -50,11 +126,23 @@ export class ReloadChecker { if (response?.status !== 200) { this.backendConnectionLost = true; logger.log('warn', `got a status ${response?.status}.`); - this.infoscreen.setText(`backend connection lost... Status ${response?.status}`); + this.statusPill.updateStatus({ + source: 'backend', + type: 'disconnected', + message: `Backend connection lost (${response?.status || 'timeout'})`, + persist: true, + timestamp: Date.now(), + }); } if (response?.status === 200 && this.backendConnectionLost) { this.backendConnectionLost = false; - this.infoscreen.setSuccess('regained connection to backend...'); + this.statusPill.updateStatus({ + source: 'backend', + type: 'connected', + message: 'Backend connection restored', + persist: false, + timestamp: Date.now(), + }); } return response; } @@ -69,10 +157,15 @@ export class ReloadChecker { if (reloadJustified) { this.store.set(this.storeKey, lastServerChange); - const reloadText = `upgrading... ${ - globalThis.globalSw ? '(purging the sw cache first...)' : '' - }`; - this.infoscreen.setText(reloadText); + const hasSw = !!globalThis.globalSw; + this.statusPill.updateStatus({ + source: 'serviceworker', + type: 'update', + message: hasSw ? 'Updating app...' : 'Upgrading...', + persist: true, + timestamp: Date.now(), + }); + if (globalThis.globalSw?.purgeCache) { await globalThis.globalSw.purgeCache(); } else if ('caches' in window) { @@ -87,14 +180,19 @@ export class ReloadChecker { } else { console.log('globalThis.globalSw not found and Cache API not available...'); } - this.infoscreen.setText(`cleaned caches`); + + this.statusPill.updateStatus({ + source: 'serviceworker', + type: 'cache', + message: 'Cache cleared, reloading...', + persist: true, + timestamp: Date.now(), + }); await plugins.smartdelay.delayFor(200); this.reload(); return; } else { - if (this.infoscreen) { - this.infoscreen.hide(); - } + // All good, hide after brief show return; } } @@ -116,10 +214,22 @@ export class ReloadChecker { console.log(`typedsocket status: ${statusArg}`); if (statusArg === 'disconnected' || statusArg === 'reconnecting') { this.backendConnectionLost = true; - this.infoscreen.setText(`typedsocket ${statusArg}!`); + this.statusPill.updateStatus({ + source: 'backend', + type: statusArg === 'disconnected' ? 'disconnected' : 'reconnecting', + message: `TypedSocket ${statusArg}`, + persist: true, + timestamp: Date.now(), + }); } else if (statusArg === 'connected' && this.backendConnectionLost) { this.backendConnectionLost = false; - this.infoscreen.setSuccess('typedsocket connected!'); + this.statusPill.updateStatus({ + source: 'backend', + type: 'connected', + message: 'TypedSocket connected', + persist: false, + timestamp: Date.now(), + }); // lets check if a reload is necessary const getLatestServerChangeTime = this.typedsocket.createTypedRequest( @@ -137,9 +247,13 @@ export class ReloadChecker { public async start() { this.started = true; logger.log('info', `starting ReloadChecker...`); + + // Subscribe to service worker status updates + this.subscribeToServiceWorker(); + while (this.started) { const response = await this.performHttpRequest(); - if (response.status === 200) { + if (response?.status === 200) { logger.log('info', `ReloadChecker reached backend!`); await this.checkReload(parseInt(await response.text())); await this.connectTypedsocket(); @@ -150,6 +264,10 @@ export class ReloadChecker { public async stop() { this.started = false; + if (this.swStatusUnsubscribe) { + this.swStatusUnsubscribe(); + this.swStatusUnsubscribe = null; + } } } diff --git a/ts_web_inject/typedserver_web.statuspill.ts b/ts_web_inject/typedserver_web.statuspill.ts new file mode 100644 index 0000000..e7d9894 --- /dev/null +++ b/ts_web_inject/typedserver_web.statuspill.ts @@ -0,0 +1,534 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import * as plugins from './typedserver_web.plugins.js'; + +declare global { + interface HTMLElementTagNameMap { + 'typedserver-statuspill': TypedserverStatusPill; + } +} + +/** + * Status source types + */ +export type TStatusSource = 'backend' | 'serviceworker' | 'network'; + +/** + * Status type + */ +export type TStatusType = 'connected' | 'disconnected' | 'reconnecting' | 'update' | 'cache' | 'error' | 'offline' | 'online'; + +/** + * Status item with details + */ +export interface IStatusItem { + source: TStatusSource; + type: TStatusType; + message: string; + details?: { + version?: string; + cacheHitRate?: number; + resourceCount?: number; + connectionType?: string; + latencyMs?: number; + }; + persist: boolean; + timestamp: number; +} + +/** + * Modern status pill component that displays connection and service worker status + * - Shows at center-bottom on connectivity changes + * - Stays visible during error states + * - Expands on hover to show detailed status + */ +@customElement('typedserver-statuspill') +export class TypedserverStatusPill extends LitElement { + // Current status items by source + @state() accessor backendStatus: IStatusItem | null = null; + @state() accessor swStatus: IStatusItem | null = null; + @state() accessor networkStatus: IStatusItem | null = null; + + // UI state + @state() accessor visible = false; + @state() accessor expanded = false; + @state() accessor hasError = false; + + // Hide timeout + private hideTimeout: number | null = null; + private appended = false; + + public static styles = css` + * { + box-sizing: border-box; + } + + :host { + --pill-bg: rgba(20, 20, 20, 0.9); + --pill-bg-error: rgba(180, 40, 40, 0.95); + --pill-bg-success: rgba(40, 140, 60, 0.95); + --pill-text: #fff; + --pill-text-muted: rgba(255, 255, 255, 0.7); + --pill-border: rgba(255, 255, 255, 0.1); + --pill-accent: #4af; + --pill-success: #4f8; + --pill-warning: #fa4; + --pill-error: #f44; + } + + .pill { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--pill-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 24px; + padding: 10px 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + color: var(--pill-text); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; + pointer-events: none; + z-index: 10000; + max-width: 90vw; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); + border: 1px solid var(--pill-border); + } + + .pill.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; + pointer-events: auto; + } + + .pill.error { + background: var(--pill-bg-error); + } + + .pill.success { + background: var(--pill-bg-success); + } + + .pill-main { + display: flex; + align-items: center; + gap: 12px; + white-space: nowrap; + } + + .status-indicator { + display: flex; + align-items: center; + gap: 6px; + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pill-text-muted); + transition: background 0.3s; + } + + .status-dot.connected { + background: var(--pill-success); + box-shadow: 0 0 6px var(--pill-success); + } + + .status-dot.disconnected, + .status-dot.offline, + .status-dot.error { + background: var(--pill-error); + box-shadow: 0 0 6px var(--pill-error); + } + + .status-dot.reconnecting, + .status-dot.update { + background: var(--pill-warning); + animation: pulse 1s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .status-label { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .status-message { + color: var(--pill-text); + font-weight: 400; + } + + .separator { + width: 1px; + height: 16px; + background: var(--pill-border); + } + + .pill-expanded { + display: none; + width: 100%; + padding-top: 8px; + border-top: 1px solid var(--pill-border); + flex-direction: column; + gap: 6px; + } + + .pill.expanded .pill-expanded { + display: flex; + } + + .detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + gap: 20px; + } + + .detail-label { + color: var(--pill-text-muted); + } + + .detail-value { + color: var(--pill-text); + font-weight: 500; + } + + .detail-value.success { + color: var(--pill-success); + } + + .detail-value.error { + color: var(--pill-error); + } + + .detail-value.warning { + color: var(--pill-warning); + } + + /* Click hint */ + .pill::after { + content: ''; + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + width: 32px; + height: 3px; + background: var(--pill-border); + border-radius: 2px; + transition: background 0.2s; + } + + .pill:hover::after { + background: var(--pill-text-muted); + } + `; + + /** + * Update status from a specific source + */ + public updateStatus(status: IStatusItem): void { + // Store by source + switch (status.source) { + case 'backend': + this.backendStatus = status; + break; + case 'serviceworker': + this.swStatus = status; + break; + case 'network': + this.networkStatus = status; + break; + } + + // Determine if we have any errors (should persist) + this.hasError = this.hasAnyError(); + + // Show the pill + this.show(); + + // Auto-hide after delay if not persistent + if (!status.persist && !this.hasError) { + this.scheduleHide(2500); + } else { + this.cancelHide(); + } + } + + /** + * Check if any status is an error state + */ + private hasAnyError(): boolean { + const errorTypes: TStatusType[] = ['disconnected', 'error', 'offline']; + return ( + (this.backendStatus && errorTypes.includes(this.backendStatus.type)) || + (this.networkStatus && errorTypes.includes(this.networkStatus.type)) || + false + ); + } + + /** + * Get overall status class + */ + private getStatusClass(): string { + if (this.hasError) return 'error'; + + const latestStatus = this.getLatestStatus(); + if (latestStatus?.type === 'connected' || latestStatus?.type === 'online') { + return 'success'; + } + return ''; + } + + /** + * Get the most recent status + */ + private getLatestStatus(): IStatusItem | null { + const statuses = [this.backendStatus, this.swStatus, this.networkStatus].filter(Boolean) as IStatusItem[]; + if (statuses.length === 0) return null; + return statuses.reduce((latest, current) => + current.timestamp > latest.timestamp ? current : latest + ); + } + + /** + * Show the pill + */ + public show(): void { + if (!this.appended) { + document.body.appendChild(this); + this.appended = true; + } + // Small delay to ensure DOM update + requestAnimationFrame(() => { + this.visible = true; + }); + } + + /** + * Hide the pill + */ + public hide(): void { + this.visible = false; + this.expanded = false; + } + + /** + * Schedule auto-hide + */ + private scheduleHide(delayMs: number): void { + this.cancelHide(); + this.hideTimeout = window.setTimeout(() => { + if (!this.hasError) { + this.hide(); + } + }, delayMs); + } + + /** + * Cancel scheduled hide + */ + private cancelHide(): void { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + } + + /** + * Toggle expanded state + */ + private toggleExpanded(): void { + this.expanded = !this.expanded; + if (this.expanded) { + this.cancelHide(); + } + } + + /** + * Clear all status and hide + */ + public clearStatus(): void { + this.backendStatus = null; + this.swStatus = null; + this.networkStatus = null; + this.hasError = false; + this.hide(); + } + + /** + * Set success message (auto-hides) + */ + public setSuccess(message: string, source: TStatusSource = 'backend'): void { + this.updateStatus({ + source, + type: 'connected', + message, + persist: false, + timestamp: Date.now(), + }); + } + + /** + * Set error message (persists) + */ + public setError(message: string, source: TStatusSource = 'backend'): void { + this.updateStatus({ + source, + type: 'error', + message, + persist: true, + timestamp: Date.now(), + }); + } + + /** + * Set transitional message (auto-hides) + */ + public setText(message: string, source: TStatusSource = 'backend'): void { + this.updateStatus({ + source, + type: 'reconnecting', + message, + persist: false, + timestamp: Date.now(), + }); + } + + /** + * Render status indicators + */ + private renderStatusIndicators() { + const indicators = []; + + if (this.networkStatus) { + indicators.push(html` +
+ + Net +
+ `); + } + + if (this.backendStatus) { + indicators.push(html` +
+ + API +
+ `); + } + + if (this.swStatus) { + indicators.push(html` +
+ + SW +
+ `); + } + + return indicators; + } + + /** + * Render expanded details + */ + private renderDetails() { + const details = []; + + if (this.networkStatus) { + details.push(html` +
+ Network + + ${this.networkStatus.message} + ${this.networkStatus.details?.connectionType ? ` (${this.networkStatus.details.connectionType})` : ''} + +
+ `); + } + + if (this.backendStatus) { + details.push(html` +
+ Backend + + ${this.backendStatus.message} + +
+ `); + } + + if (this.swStatus) { + details.push(html` +
+ Service Worker + + ${this.swStatus.message} + ${this.swStatus.details?.version ? ` v${this.swStatus.details.version}` : ''} + +
+ `); + + if (this.swStatus.details?.cacheHitRate !== undefined) { + details.push(html` +
+ Cache Hit Rate + ${this.swStatus.details.cacheHitRate.toFixed(1)}% +
+ `); + } + + if (this.swStatus.details?.resourceCount !== undefined) { + details.push(html` +
+ Cached Resources + ${this.swStatus.details.resourceCount} +
+ `); + } + } + + return details; + } + + public render() { + const latestStatus = this.getLatestStatus(); + const message = latestStatus?.message || ''; + const indicators = this.renderStatusIndicators(); + + return html` +
+
+ ${indicators.length > 0 ? html` + ${indicators} + ${message ? html`` : ''} + ` : ''} + ${message ? html`${message}` : ''} +
+
+ ${this.renderDetails()} +
+
+ `; + } +} diff --git a/ts_web_serviceworker/classes.backend.ts b/ts_web_serviceworker/classes.backend.ts index 5e57ba8..0a9d631 100644 --- a/ts_web_serviceworker/classes.backend.ts +++ b/ts_web_serviceworker/classes.backend.ts @@ -2,6 +2,7 @@ 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'; // Add type definitions for ServiceWorker APIs declare global { @@ -75,8 +76,128 @@ export class ServiceworkerBackend { return await optionsArg.purgeCache?.(reqArg); }); + // Handler for getting current SW status + this.deesComms.createTypedHandler('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, + }; + }); + // 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(); + + // Network status changes + eventBus.on(ServiceWorkerEvent.NETWORK_ONLINE, () => { + this.broadcastStatusUpdate({ + source: 'network', + type: 'online', + message: 'Connection restored', + persist: false, + timestamp: Date.now(), + }); + }); + + eventBus.on(ServiceWorkerEvent.NETWORK_OFFLINE, () => { + this.broadcastStatusUpdate({ + source: 'network', + type: 'offline', + message: 'Connection lost - offline mode', + persist: true, + timestamp: Date.now(), + }); + }); + + // Update events + eventBus.on(ServiceWorkerEvent.UPDATE_AVAILABLE, (_event, payload: any) => { + this.broadcastStatusUpdate({ + source: 'serviceworker', + type: 'update', + message: 'Update available', + details: { + version: payload.newVersion, + }, + persist: false, + timestamp: Date.now(), + }); + }); + + eventBus.on(ServiceWorkerEvent.UPDATE_APPLIED, (_event, payload: any) => { + this.broadcastStatusUpdate({ + source: 'serviceworker', + type: 'update', + message: 'Update applied', + details: { + version: payload.newVersion, + }, + persist: false, + timestamp: Date.now(), + }); + }); + + eventBus.on(ServiceWorkerEvent.UPDATE_ERROR, (_event, payload: any) => { + this.broadcastStatusUpdate({ + source: 'serviceworker', + type: 'error', + message: `Update error: ${payload.error || 'Unknown error'}`, + persist: true, + timestamp: Date.now(), + }); + }); + + // Cache invalidation + eventBus.on(ServiceWorkerEvent.CACHE_INVALIDATE_ALL, () => { + this.broadcastStatusUpdate({ + source: 'serviceworker', + type: 'cache', + message: 'Clearing cache...', + persist: false, + timestamp: Date.now(), + }); + }); + + // Lifecycle events + eventBus.on(ServiceWorkerEvent.ACTIVATE, () => { + this.broadcastStatusUpdate({ + source: 'serviceworker', + type: 'connected', + message: 'Service worker activated', + persist: false, + timestamp: Date.now(), + }); + }); + } + + /** + * Broadcasts a status update to all connected clients + */ + public async broadcastStatusUpdate(status: interfaces.serviceworker.IStatusUpdate): Promise { + try { + await this.deesComms.postMessage({ + method: 'serviceworker_statusUpdate', + request: status, + messageId: `sw_status_${Date.now()}` + }); + logger.log('info', `Status update broadcast: ${status.source}:${status.type} - ${status.message}`); + } catch (error) { + logger.log('warn', `Failed to broadcast status update: ${error}`); + } } /** diff --git a/ts_web_serviceworker_client/classes.actionmanager.ts b/ts_web_serviceworker_client/classes.actionmanager.ts index 73842f1..8041c99 100644 --- a/ts_web_serviceworker_client/classes.actionmanager.ts +++ b/ts_web_serviceworker_client/classes.actionmanager.ts @@ -26,8 +26,14 @@ const DEFAULT_CONNECTION_OPTIONS: IConnectionOptions = { * * the serviceWorker method * * the deesComms method using BroadcastChannel */ +/** + * Callback type for status update subscriptions + */ +export type TStatusUpdateCallback = (status: interfaces.serviceworker.IStatusUpdate) => void; + export class ActionManager { public deesComms = new plugins.deesComms.DeesComms(); + private statusCallbacks: Set = new Set(); constructor() { // lets define handlers on the client/tab side @@ -37,6 +43,49 @@ export class ActionManager { }, 200); return {}; }); + + // Handler for status updates from service worker + this.deesComms.createTypedHandler('serviceworker_statusUpdate', async (status) => { + // Forward to all registered callbacks + for (const callback of this.statusCallbacks) { + try { + callback(status); + } catch (error) { + logger.log('warn', `Status callback error: ${error}`); + } + } + return {}; + }); + } + + /** + * Subscribe to status updates from the service worker + * @returns Unsubscribe function + */ + public subscribeToStatusUpdates(callback: TStatusUpdateCallback): () => void { + this.statusCallbacks.add(callback); + logger.log('info', 'Subscribed to service worker status updates'); + return () => { + this.statusCallbacks.delete(callback); + logger.log('info', 'Unsubscribed from service worker status updates'); + }; + } + + /** + * Get current service worker status + */ + public async getServiceWorkerStatus(): Promise { + try { + const tr = this.deesComms.createTypedRequest('serviceworker_getStatus'); + const response = await Promise.race([ + tr.fire({}), + new Promise((resolve) => setTimeout(() => resolve(null), 5000)), + ]); + return response; + } catch (error) { + logger.log('warn', `Failed to get service worker status: ${error}`); + return null; + } } /**