feat(serviceworker): Add service worker status updates, EventBus and UI status pill for realtime observability

This commit is contained in:
2025-12-04 14:09:10 +00:00
parent 8bb4814350
commit c4e0e9b915
7 changed files with 924 additions and 17 deletions

View File

@@ -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

View File

@@ -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.'
}

View File

@@ -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;
};
}

View File

@@ -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<void> {
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<interfaces.IReq_GetLatestServerChangeTime>(
@@ -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;
}
}
}

View File

@@ -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`
<div class="status-indicator">
<span class="status-dot ${this.networkStatus.type}"></span>
<span class="status-label">Net</span>
</div>
`);
}
if (this.backendStatus) {
indicators.push(html`
<div class="status-indicator">
<span class="status-dot ${this.backendStatus.type}"></span>
<span class="status-label">API</span>
</div>
`);
}
if (this.swStatus) {
indicators.push(html`
<div class="status-indicator">
<span class="status-dot ${this.swStatus.type}"></span>
<span class="status-label">SW</span>
</div>
`);
}
return indicators;
}
/**
* Render expanded details
*/
private renderDetails() {
const details = [];
if (this.networkStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Network</span>
<span class="detail-value ${this.networkStatus.type === 'online' ? 'success' : 'error'}">
${this.networkStatus.message}
${this.networkStatus.details?.connectionType ? ` (${this.networkStatus.details.connectionType})` : ''}
</span>
</div>
`);
}
if (this.backendStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Backend</span>
<span class="detail-value ${this.backendStatus.type === 'connected' ? 'success' : this.backendStatus.type === 'reconnecting' ? 'warning' : 'error'}">
${this.backendStatus.message}
</span>
</div>
`);
}
if (this.swStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Service Worker</span>
<span class="detail-value ${this.swStatus.type === 'connected' ? 'success' : this.swStatus.type === 'update' ? 'warning' : ''}">
${this.swStatus.message}
${this.swStatus.details?.version ? ` v${this.swStatus.details.version}` : ''}
</span>
</div>
`);
if (this.swStatus.details?.cacheHitRate !== undefined) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Cache Hit Rate</span>
<span class="detail-value">${this.swStatus.details.cacheHitRate.toFixed(1)}%</span>
</div>
`);
}
if (this.swStatus.details?.resourceCount !== undefined) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Cached Resources</span>
<span class="detail-value">${this.swStatus.details.resourceCount}</span>
</div>
`);
}
}
return details;
}
public render() {
const latestStatus = this.getLatestStatus();
const message = latestStatus?.message || '';
const indicators = this.renderStatusIndicators();
return html`
<div
class="pill ${this.visible ? 'visible' : ''} ${this.getStatusClass()} ${this.expanded ? 'expanded' : ''}"
@click="${this.toggleExpanded}"
>
<div class="pill-main">
${indicators.length > 0 ? html`
${indicators}
${message ? html`<span class="separator"></span>` : ''}
` : ''}
${message ? html`<span class="status-message">${message}</span>` : ''}
</div>
<div class="pill-expanded">
${this.renderDetails()}
</div>
</div>
`;
}
}

View File

@@ -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<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,
};
});
// 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<void> {
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}`);
}
}
/**

View File

@@ -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<TStatusUpdateCallback> = 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<interfaces.serviceworker.IMessage_Serviceworker_StatusUpdate>('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<interfaces.serviceworker.IRequest_Serviceworker_GetStatus['response'] | null> {
try {
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_GetStatus>('serviceworker_getStatus');
const response = await Promise.race([
tr.fire({}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
]);
return response;
} catch (error) {
logger.log('warn', `Failed to get service worker status: ${error}`);
return null;
}
}
/**