243 lines
8.9 KiB
TypeScript
243 lines
8.9 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import * as interfaces from '../dist_ts_interfaces/index.js';
|
|
import { ServiceWorker } from './classes.serviceworker.js';
|
|
import { logger } from './logging.js';
|
|
import { CacheManager } from './classes.cachemanager.js';
|
|
import { getMetricsCollector } from './classes.metrics.js';
|
|
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
|
|
import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js';
|
|
|
|
export class UpdateManager {
|
|
public lastUpdateCheck: number = 0;
|
|
public lastVersionInfo: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'];
|
|
|
|
public serviceworkerRef: ServiceWorker;
|
|
|
|
// Rate limiting for update checks
|
|
private isCheckInProgress = false;
|
|
private pendingCheckPromise: Promise<boolean> | null = null;
|
|
|
|
constructor(serviceWorkerRefArg: ServiceWorker) {
|
|
this.serviceworkerRef = serviceWorkerRefArg;
|
|
}
|
|
|
|
/**
|
|
* checks wether an update is needed
|
|
*/
|
|
private readonly MAX_CACHE_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
|
private readonly MIN_CHECK_INTERVAL = 100000; // 100 seconds in milliseconds
|
|
private readonly OFFLINE_GRACE_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days grace period when offline
|
|
private lastCacheTimestamp: number = 0;
|
|
|
|
/**
|
|
* Public method to trigger an update check (rate-limited)
|
|
*/
|
|
public async checkUpdate(_cacheManager: CacheManager): Promise<boolean> {
|
|
const now = Date.now();
|
|
const millisSinceLastCheck = now - this.lastUpdateCheck;
|
|
|
|
// Rate limit: skip if we checked recently
|
|
if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL) {
|
|
return false;
|
|
}
|
|
|
|
// If a check is in progress, return the existing promise
|
|
if (this.pendingCheckPromise) {
|
|
return this.pendingCheckPromise;
|
|
}
|
|
|
|
// Perform the check
|
|
this.pendingCheckPromise = this.performUpdateCheck().finally(() => {
|
|
this.pendingCheckPromise = null;
|
|
});
|
|
|
|
return this.pendingCheckPromise;
|
|
}
|
|
|
|
/**
|
|
* Internal method that performs the actual update check
|
|
*/
|
|
private async performUpdateCheck(): Promise<boolean> {
|
|
// Prevent concurrent checks
|
|
if (this.isCheckInProgress) {
|
|
logger.log('note', 'Update check already in progress, skipping...');
|
|
return false;
|
|
}
|
|
|
|
this.isCheckInProgress = true;
|
|
const metrics = getMetricsCollector();
|
|
const eventBus = getEventBus();
|
|
const errorHandler = getErrorHandler();
|
|
|
|
try {
|
|
eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_START, { timestamp: Date.now() });
|
|
|
|
const lswVersionInfoKey = 'versionInfo';
|
|
const cacheTimestampKey = 'cacheTimestamp';
|
|
|
|
// Initialize or load version info
|
|
if (!this.lastVersionInfo && !(await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
|
|
this.lastVersionInfo = {
|
|
appHash: '',
|
|
appSemVer: 'v0.0.0',
|
|
};
|
|
} else if (!this.lastVersionInfo && (await this.serviceworkerRef.store.check(lswVersionInfoKey))) {
|
|
this.lastVersionInfo = await this.serviceworkerRef.store.get(lswVersionInfoKey);
|
|
}
|
|
|
|
// Load or initialize cache timestamp
|
|
if (await this.serviceworkerRef.store.check(cacheTimestampKey)) {
|
|
this.lastCacheTimestamp = await this.serviceworkerRef.store.get(cacheTimestampKey);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const cacheAge = now - this.lastCacheTimestamp;
|
|
|
|
// Check if we need to handle stale cache
|
|
if (cacheAge > this.MAX_CACHE_AGE) {
|
|
const isOnline = await this.serviceworkerRef.networkManager.checkOnlineStatus();
|
|
|
|
if (isOnline) {
|
|
logger.log('info', `Cache is older than ${this.MAX_CACHE_AGE}ms, forcing update...`);
|
|
await this.forceUpdate(this.serviceworkerRef.cacheManager);
|
|
metrics.recordUpdateCheck(true);
|
|
return true;
|
|
} else if (cacheAge > this.OFFLINE_GRACE_PERIOD) {
|
|
logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`);
|
|
metrics.recordUpdateCheck(false);
|
|
return false;
|
|
} else {
|
|
logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`);
|
|
metrics.recordUpdateCheck(false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
logger.log('info', 'checking for update of the app by comparing app hashes...');
|
|
this.lastUpdateCheck = Date.now();
|
|
|
|
const currentVersionInfo = await this.getVersionInfoFromServer();
|
|
logger.log('info', `old versionInfo: ${JSON.stringify(this.lastVersionInfo)}`);
|
|
logger.log('info', `current versionInfo: ${JSON.stringify(currentVersionInfo)}`);
|
|
|
|
const needsUpdate = this.lastVersionInfo.appHash !== currentVersionInfo.appHash;
|
|
|
|
if (needsUpdate) {
|
|
logger.log('info', 'Caches need to be updated');
|
|
logger.log('info', 'starting a debounced update task');
|
|
|
|
metrics.recordUpdateFound();
|
|
eventBus.emitUpdateAvailable(
|
|
this.lastVersionInfo.appSemVer,
|
|
currentVersionInfo.appSemVer,
|
|
this.lastVersionInfo.appHash,
|
|
currentVersionInfo.appHash
|
|
);
|
|
|
|
this.performAsyncUpdateDebouncedTask.trigger();
|
|
this.lastVersionInfo = currentVersionInfo;
|
|
await this.serviceworkerRef.store.set(lswVersionInfoKey, this.lastVersionInfo);
|
|
|
|
// Update cache timestamp
|
|
this.lastCacheTimestamp = now;
|
|
await this.serviceworkerRef.store.set('cacheTimestamp', now);
|
|
} else {
|
|
logger.log('ok', 'caches are still valid, performing revalidation in a bit...');
|
|
this.performAsyncCacheRevalidationDebouncedTask.trigger();
|
|
|
|
// Update cache timestamp after successful revalidation
|
|
this.lastCacheTimestamp = now;
|
|
await this.serviceworkerRef.store.set('cacheTimestamp', now);
|
|
}
|
|
|
|
metrics.recordUpdateCheck(true);
|
|
eventBus.emit(ServiceWorkerEvent.UPDATE_CHECK_COMPLETE, {
|
|
needsUpdate,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
return needsUpdate;
|
|
} catch (error) {
|
|
const err = errorHandler.handleUpdateError(
|
|
`Update check failed: ${error?.message || error}`,
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
metrics.recordUpdateCheck(false);
|
|
eventBus.emit(ServiceWorkerEvent.UPDATE_ERROR, { error: err.message });
|
|
return false;
|
|
} finally {
|
|
this.isCheckInProgress = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* gets the apphash from the server
|
|
*/
|
|
public async getVersionInfoFromServer() {
|
|
try {
|
|
const getAppHashRequest = new plugins.typedrequest.TypedRequest<
|
|
interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo
|
|
>('/typedrequest', 'serviceworker_versionInfo');
|
|
|
|
// Use networkManager for the request with retries and timeout
|
|
const response = await getAppHashRequest.fire({});
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.log('warn', `Failed to get version info from server: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// tasks
|
|
/**
|
|
* this task is executed once we know that there is a new version available
|
|
*/
|
|
private async forceUpdate(cacheManager: CacheManager) {
|
|
try {
|
|
logger.log('info', 'Forcing cache update due to staleness');
|
|
const currentVersionInfo = await this.getVersionInfoFromServer();
|
|
|
|
// Only proceed with cache cleaning if we successfully got new version info
|
|
await this.serviceworkerRef.cacheManager.cleanCaches('Cache is stale, forcing update.');
|
|
this.lastVersionInfo = currentVersionInfo;
|
|
await this.serviceworkerRef.store.set('versionInfo', this.lastVersionInfo);
|
|
this.lastCacheTimestamp = Date.now();
|
|
await this.serviceworkerRef.store.set('cacheTimestamp', this.lastCacheTimestamp);
|
|
await this.serviceworkerRef.leleServiceWorkerBackend.triggerReloadAll();
|
|
} catch (error) {
|
|
logger.log('error', `Failed to force update: ${error.message}. Keeping existing cache.`);
|
|
// If update fails, we'll keep using the existing cache
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public performAsyncUpdateDebouncedTask = new plugins.taskbuffer.TaskDebounced({
|
|
name: 'performAsyncUpdate',
|
|
taskFunction: async () => {
|
|
logger.log('info', 'trying to update PWA with serviceworker');
|
|
const metrics = getMetricsCollector();
|
|
const eventBus = getEventBus();
|
|
|
|
await this.serviceworkerRef.cacheManager.cleanCaches('a new app version has been communicated by the server.');
|
|
// lets notify all current clients about the update
|
|
await this.serviceworkerRef.leleServiceWorkerBackend.triggerReloadAll();
|
|
|
|
metrics.recordUpdateApplied();
|
|
eventBus.emitUpdateApplied(
|
|
this.lastVersionInfo?.appSemVer || 'unknown',
|
|
this.lastVersionInfo?.appHash || 'unknown'
|
|
);
|
|
},
|
|
debounceTimeInMillis: 2000,
|
|
});
|
|
|
|
public performAsyncCacheRevalidationDebouncedTask = new plugins.taskbuffer.TaskDebounced({
|
|
name: 'performAsyncCacheRevalidation',
|
|
taskFunction: async () => {
|
|
await this.serviceworkerRef.cacheManager.revalidateCache();
|
|
},
|
|
debounceTimeInMillis: 6000
|
|
});
|
|
}
|