Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
7999e370f6 | |||
efade7a78e | |||
0fecf69420 | |||
804537c059 | |||
aebcbe4a61 | |||
c5cb8c1f01 | |||
8202ce6227 | |||
4598bd0e25 | |||
021c980a4f | |||
c7dca75827 | |||
4f7b2888ab | |||
e552a48c02 | |||
2ea4139974 | |||
e225c693a8 |
49
changelog.md
49
changelog.md
@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-02-03 - 3.0.59 - fix(serviceworker)
|
||||
Fixed CORS and Cache Control handling for Service Worker
|
||||
|
||||
- Improved handling of CORS settings for external requests.
|
||||
- Preserved important headers while excluding caching headers.
|
||||
- Ensured the presence of CORS headers in cached responses.
|
||||
- Adjusted Cache-Control headers to prevent browser caching but allow service worker caching.
|
||||
|
||||
## 2025-02-03 - 3.0.58 - fix(network-manager)
|
||||
Refined network management logic for better offline handling.
|
||||
|
||||
- Improved logic to handle missing connections more gracefully.
|
||||
- Added detailed online/offline connection status logging.
|
||||
- Implemented a check for stale cache with a grace period for offline scenarios.
|
||||
- Network requests now use optimized retries and timeouts.
|
||||
|
||||
## 2025-02-03 - 3.0.57 - fix(updateManager)
|
||||
Refine cache management for service worker updates.
|
||||
|
||||
- Ensured cache is forcibly updated if older than defined maximum age.
|
||||
- Implemented interval checks and forced updates for cache staleness.
|
||||
- Updated version information and cache timestamps upon forced updates or validations.
|
||||
|
||||
## 2025-02-03 - 3.0.56 - fix(cachemanager)
|
||||
Adjust cache control headers and fix redundant code
|
||||
|
||||
- Remove duplicate assetbroker URLs in the cache evaluation logic.
|
||||
- Update cache control headers to improve caching behavior.
|
||||
- Increase the timeout for fetch operations to improve compatibility.
|
||||
|
||||
## 2025-01-28 - 3.0.55 - fix(server)
|
||||
Fix response content manipulation for HTML files with injectReload
|
||||
|
||||
- Moved fileString declaration inside HTML file handling block to prevent unnecessary string conversion for non-HTML files.
|
||||
- Corrected responseContent assignment to ensure modified HTML strings are converted back to Buffer format.
|
||||
|
||||
## 2025-01-28 - 3.0.54 - fix(servertools)
|
||||
Fixed an issue with compression results handling in HandlerStatic where content was always being written even if not compressed.
|
||||
|
||||
- Corrected the double writing of response in HandlerStatic.
|
||||
- Ensured that file buffers are only conditionally written based on compression availability.
|
||||
|
||||
## 2024-12-26 - 3.0.53 - fix(infohtml)
|
||||
Remove Sentry script and logo from HTML template
|
||||
|
||||
- Removed Sentry script from the HTML template.
|
||||
- Removed Lossless GmbH logo and contact info.
|
||||
- Updated footer link to point to foss.global.
|
||||
|
||||
## 2024-12-25 - 3.0.52 - fix(dependencies)
|
||||
Bump package versions in dependencies and exports.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "3.0.52",
|
||||
"version": "3.0.59",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '3.0.52',
|
||||
version: '3.0.59',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
@ -135,8 +135,8 @@ export class TypedServer {
|
||||
'/*',
|
||||
new servertools.HandlerStatic(this.options.serveDir, {
|
||||
responseModifier: async (responseArg) => {
|
||||
let fileString = responseArg.responseContent.toString();
|
||||
if (plugins.path.parse(responseArg.path).ext === '.html') {
|
||||
let fileString = responseArg.responseContent.toString();
|
||||
const fileStringArray = fileString.split('<head>');
|
||||
if (this.options.injectReload && fileStringArray.length === 2) {
|
||||
fileStringArray[0] = `${fileStringArray[0]}<head>
|
||||
@ -152,6 +152,7 @@ export class TypedServer {
|
||||
`;
|
||||
fileString = fileStringArray.join('');
|
||||
console.log('injected typedserver script.');
|
||||
responseArg.responseContent = Buffer.from(fileString);
|
||||
} else if (this.options.injectReload) {
|
||||
console.log('Could not insert typedserver script');
|
||||
}
|
||||
@ -164,7 +165,7 @@ export class TypedServer {
|
||||
return {
|
||||
headers,
|
||||
path: responseArg.path,
|
||||
responseContent: Buffer.from(fileString),
|
||||
responseContent: responseArg.responseContent,
|
||||
};
|
||||
},
|
||||
serveIndexHtmlDefault: true,
|
||||
|
@ -96,26 +96,9 @@ export const simpleInfo = async (
|
||||
name="viewport"
|
||||
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height, target-densitydpi=device-dpi"
|
||||
/>
|
||||
<script
|
||||
src="https://browser.sentry-cdn.com/5.4.0/bundle.min.js"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
if (optionsArg.sentryDsn && optionsArg.sentryMessage) {
|
||||
Sentry.init({
|
||||
dsn: '${optionsArg.sentryDsn}',
|
||||
// ...
|
||||
});
|
||||
Sentry.setExtra('location', window.location.href);
|
||||
Sentry.captureMessage('${optionsArg.sentryMessage} @ ' + window.location.host);
|
||||
}
|
||||
</script>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">
|
||||
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||
</div>
|
||||
<div class="content">
|
||||
${(() => {
|
||||
const returnArray: plugins.smartntml.deesElement.TemplateResult[] = [];
|
||||
@ -129,15 +112,6 @@ export const simpleInfo = async (
|
||||
} else {
|
||||
returnArray.push(html` <div class="maintext">${optionsArg.text}</div> `);
|
||||
}
|
||||
if (optionsArg.sentryDsn && optionsArg.sentryMessage) {
|
||||
returnArray.push(
|
||||
html`<div class="addontext">
|
||||
We recorded this event. Should you continue to see this page against your
|
||||
expectations, feel free to mail us at
|
||||
<a href="mailto:hello@lossless.com">hello@lossless.com</a>
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
if (optionsArg.redirectTo) {
|
||||
returnArray.push(
|
||||
html`<div class="addontext">
|
||||
@ -149,9 +123,7 @@ export const simpleInfo = async (
|
||||
})()}
|
||||
</div>
|
||||
<div class="legal">
|
||||
<a href="https://lossless.com">Lossless GmbH</a> / © 2014-${new Date().getFullYear()}
|
||||
/ <a href="https://lossless.gmbh">Legal Info</a> /
|
||||
<a href="https://lossless.gmbh">Privacy Policy</a>
|
||||
<a href="https://foss.global">learn more about foss.global</a> / © 2014-${new Date().getFullYear()} Task Venture Capital GmbH
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -134,8 +134,10 @@ export class HandlerStatic extends Handler {
|
||||
res.status(200);
|
||||
if (compressionResult?.compressionMethod) {
|
||||
res.header('Content-Encoding', compressionResult.compressionMethod);
|
||||
res.write(compressionResult.result);
|
||||
} else {
|
||||
res.write(fileBuffer);
|
||||
}
|
||||
res.write(compressionResult.result);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
@ -23,9 +23,13 @@ export class CacheManager {
|
||||
// internal request
|
||||
matchRequest = requestArg;
|
||||
} else {
|
||||
// For external requests, create a new request with appropriate CORS settings
|
||||
matchRequest = new Request(requestArg.url, {
|
||||
...requestArg.clone(),
|
||||
mode: 'cors'
|
||||
method: requestArg.method,
|
||||
headers: requestArg.headers,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
});
|
||||
}
|
||||
return matchRequest;
|
||||
@ -98,8 +102,6 @@ export class CacheManager {
|
||||
!originalRequest.url.includes('/api/') &&
|
||||
!originalRequest.url.includes('smartserve/reloadcheck'))) ||
|
||||
originalRequest.url.includes('https://assetbroker.') ||
|
||||
originalRequest.url.includes('https://assetbroker.') ||
|
||||
originalRequest.url.includes('https://assetbroker.') ||
|
||||
originalRequest.url.includes('https://unpkg.com') ||
|
||||
originalRequest.url.includes('https://fonts.googleapis.com') ||
|
||||
originalRequest.url.includes('https://fonts.gstatic.com')
|
||||
@ -139,20 +141,34 @@ export class CacheManager {
|
||||
const responseToPutToCache = newResponse.clone();
|
||||
const headers = new Headers();
|
||||
responseToPutToCache.headers.forEach((value, key) => {
|
||||
if (
|
||||
value !== 'Cache-Control'
|
||||
&& value !== 'cache-control'
|
||||
&& value !== 'Expires'
|
||||
&& value !== 'expires'
|
||||
&& value !== 'Pragma'
|
||||
&& value !== 'pragma'
|
||||
) {
|
||||
// Preserve all headers except caching headers
|
||||
if (![
|
||||
'Cache-Control',
|
||||
'cache-control',
|
||||
'Expires',
|
||||
'expires',
|
||||
'Pragma',
|
||||
'pragma'
|
||||
].includes(key)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
||||
// Ensure CORS headers are present in cached response
|
||||
if (!headers.has('Access-Control-Allow-Origin')) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
if (!headers.has('Access-Control-Allow-Methods')) {
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
}
|
||||
if (!headers.has('Access-Control-Allow-Headers')) {
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
// Prevent browser caching while allowing service worker caching
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
headers.set('Expires', '0');
|
||||
headers.set('Surrogate-Control', 'no-store');
|
||||
await cache.put(matchRequest, new Response(responseToPutToCache.body, {
|
||||
...responseToPutToCache,
|
||||
headers
|
||||
@ -215,7 +231,7 @@ export class CacheManager {
|
||||
|
||||
// lets get a new response for comparison
|
||||
const clonedRequest = requestArg.clone();
|
||||
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 1000);
|
||||
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); // Increased timeout for better mobile compatibility
|
||||
if (response && response.status >= 200 && response.status < 300) {
|
||||
await runtimeCache.delete(requestArg);
|
||||
await runtimeCache.put(requestArg, response);
|
||||
|
@ -1,18 +1,37 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { ServiceWorker } from './classes.serviceworker.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
export class NetworkManager {
|
||||
public serviceWorkerRef: ServiceWorker;
|
||||
public webRequest: plugins.webrequest.WebRequest;
|
||||
private isOffline: boolean = false;
|
||||
private lastOnlineCheck: number = 0;
|
||||
private readonly ONLINE_CHECK_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
public previousState: string;
|
||||
|
||||
constructor(serviceWorkerRefArg: ServiceWorker) {
|
||||
this.serviceWorkerRef = serviceWorkerRefArg;
|
||||
this.webRequest = new plugins.webrequest.WebRequest();
|
||||
|
||||
// Listen for connection changes
|
||||
this.getConnection()?.addEventListener('change', () => {
|
||||
this.updateConnectionStatus();
|
||||
});
|
||||
|
||||
// Listen for online/offline events
|
||||
self.addEventListener('online', () => {
|
||||
this.isOffline = false;
|
||||
logger.log('info', 'Device is now online');
|
||||
this.updateConnectionStatus();
|
||||
});
|
||||
|
||||
self.addEventListener('offline', () => {
|
||||
this.isOffline = true;
|
||||
logger.log('warn', 'Device is now offline');
|
||||
this.updateConnectionStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,6 +47,81 @@ export class NetworkManager {
|
||||
}
|
||||
|
||||
public updateConnectionStatus() {
|
||||
console.log(`Connection type changed from ${this.previousState} to ${this.getEffectiveType()}`);
|
||||
const currentType = this.getEffectiveType();
|
||||
logger.log('info', `Connection type changed from ${this.previousState} to ${currentType}`);
|
||||
this.previousState = currentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the device is currently online by attempting to contact the server
|
||||
* @returns Promise<boolean> true if online, false if offline
|
||||
*/
|
||||
public async checkOnlineStatus(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
// Only check if enough time has passed since last check
|
||||
if (now - this.lastOnlineCheck < this.ONLINE_CHECK_INTERVAL) {
|
||||
return !this.isOffline;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/sw-typedrequest', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
this.isOffline = false;
|
||||
this.lastOnlineCheck = now;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.isOffline = true;
|
||||
this.lastOnlineCheck = now;
|
||||
logger.log('warn', 'Device appears to be offline');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a network request with offline handling
|
||||
* @param request The request to make
|
||||
* @param options Additional options
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
public async makeRequest<T>(request: Request | string, options: {
|
||||
timeoutMs?: number;
|
||||
retries?: number;
|
||||
backoffMs?: number;
|
||||
} = {}): Promise<Response> {
|
||||
const {
|
||||
timeoutMs = 5000,
|
||||
retries = 1,
|
||||
backoffMs = 1000
|
||||
} = options;
|
||||
|
||||
let lastError: Error;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
const isOnline = await this.checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
throw new Error('Device is offline');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await fetch(request, {
|
||||
...typeof request === 'string' ? {} : request,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoffMs * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
@ -17,24 +17,55 @@ export class UpdateManager {
|
||||
/**
|
||||
* 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 async checkUpdate(cacheManager: CacheManager): Promise<boolean> {
|
||||
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))
|
||||
) {
|
||||
} 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 millisSinceLastCheck = now - this.lastUpdateCheck;
|
||||
if (millisSinceLastCheck < 100000) {
|
||||
// TODO account for being offline
|
||||
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(cacheManager);
|
||||
return true;
|
||||
} else if (cacheAge > this.OFFLINE_GRACE_PERIOD) {
|
||||
// If we're offline and beyond grace period, warn but continue serving cached content
|
||||
logger.log('warn', `Cache is stale and device is offline. Cache age: ${cacheAge}ms. Using cached content with warning.`);
|
||||
// We could potentially show a warning to the user here
|
||||
return false;
|
||||
} else {
|
||||
logger.log('info', `Cache is stale but device is offline. Within grace period. Using cached content.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular update check interval
|
||||
if (millisSinceLastCheck < this.MIN_CHECK_INTERVAL && cacheAge < this.MAX_CACHE_AGE) {
|
||||
return false;
|
||||
}
|
||||
logger.log('info', 'checking for update of the app by comparing app hashes...');
|
||||
@ -49,9 +80,17 @@ export class UpdateManager {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,17 +98,49 @@ export class UpdateManager {
|
||||
* gets the apphash from the server
|
||||
*/
|
||||
public async getVersionInfoFromServer() {
|
||||
const getAppHashRequest = new plugins.typedrequest.TypedRequest<
|
||||
interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo
|
||||
>('/sw-typedrequest', 'serviceworker_versionInfo');
|
||||
const result = await getAppHashRequest.fire({});
|
||||
return result;
|
||||
try {
|
||||
const getAppHashRequest = new plugins.typedrequest.TypedRequest<
|
||||
interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo
|
||||
>('/sw-typedrequest', 'serviceworker_versionInfo');
|
||||
|
||||
// Use networkManager for the request with retries and timeout
|
||||
const response = await this.serviceworkerRef.networkManager.makeRequest('/sw-typedrequest', {
|
||||
timeoutMs: 5000,
|
||||
retries: 2,
|
||||
backoffMs: 1000
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} 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 () => {
|
||||
|
Reference in New Issue
Block a user