mirror of
https://github.com/ONLYOFFICE/sdkjs.git
synced 2026-02-10 18:15:19 +08:00
281 lines
9.4 KiB
JavaScript
281 lines
9.4 KiB
JavaScript
/*
|
|
* (c) Copyright Ascensio System SIA 2010-2024
|
|
*
|
|
* This program is a free software product. You can redistribute it and/or
|
|
* modify it under the terms of the GNU Affero General Public License (AGPL)
|
|
* version 3 as published by the Free Software Foundation. In accordance with
|
|
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
|
|
* that Ascensio System SIA expressly excludes the warranty of non-infringement
|
|
* of any third-party rights.
|
|
*
|
|
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
|
|
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
|
|
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
|
|
*
|
|
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
|
|
* street, Riga, Latvia, EU, LV-1050.
|
|
*
|
|
* The interactive user interfaces in modified source and object code versions
|
|
* of the Program must display Appropriate Legal Notices, as required under
|
|
* Section 5 of the GNU AGPL version 3.
|
|
*
|
|
* Pursuant to Section 7(b) of the License you must retain the original Product
|
|
* logo when distributing the program. Pursuant to Section 7(e) we decline to
|
|
* grant you any rights under trademark law for use of our trademarks.
|
|
*
|
|
* All the Product's GUI elements, including illustrations and icon sets, as
|
|
* well as technical writing content are licensed under the terms of the
|
|
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
|
|
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
|
|
*
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
let g_version = "0.0.0-0";//"localhost:8000" for develop version
|
|
const pathnameParts = self.location.pathname.split('/');
|
|
if (pathnameParts.length > 1 && pathnameParts[pathnameParts.length - 2]) {
|
|
g_version = pathnameParts[pathnameParts.length - 2];
|
|
}
|
|
const g_cacheNamePrefix = 'document_editor_static_';
|
|
const g_cacheName = g_cacheNamePrefix + g_version;
|
|
const g_cacheablePrefixes = [
|
|
"web-apps/",
|
|
"sdkjs/",
|
|
"fonts/",
|
|
"sdkjs-plugins/",
|
|
"dictionaries/"
|
|
];
|
|
const isDesktopEditor = navigator.userAgent.indexOf("AscDesktopEditor") !== -1;
|
|
let g_storageInfoCache = null;
|
|
let g_storageInfoCacheTime = 0;
|
|
const STORAGE_INFO_CACHE_DURATION = 30000; // 30 seconds
|
|
|
|
/**
|
|
* Check if a response is safe to cache
|
|
* @param {Request} request
|
|
* @param {Response} response
|
|
* @returns {boolean} true if response can be safely cached
|
|
*/
|
|
function safeToCache(request, response) {
|
|
return request.method === 'GET' // only GET requests
|
|
&& response
|
|
&& response.ok // status 200-299. todo 0 or 1223?
|
|
&& (response.type === 'basic' || response.type === 'cors') // same-origin or CORS
|
|
&& !response.redirected; // no 30x redirect chain
|
|
}
|
|
|
|
/**
|
|
* Get storage information (size limits and health) with single API call
|
|
* @returns {Promise<{maxEntrySize: number, isHealthy: boolean}>} Storage info
|
|
*/
|
|
function getStorageInfo() {
|
|
const now = Date.now();
|
|
if (g_storageInfoCache !== null && (now - g_storageInfoCacheTime) < STORAGE_INFO_CACHE_DURATION) {
|
|
return Promise.resolve(g_storageInfoCache);
|
|
}
|
|
|
|
if (!navigator.storage || !navigator.storage.estimate) {
|
|
// Fallback values if API not available
|
|
g_storageInfoCache = {
|
|
maxEntrySize: 50 * 1024 * 1024,
|
|
isHealthy: true
|
|
};
|
|
g_storageInfoCacheTime = now;
|
|
return Promise.resolve(g_storageInfoCache);
|
|
}
|
|
|
|
return navigator.storage.estimate()
|
|
.then(function(estimate) {
|
|
// Validate estimate fields; fall back if missing or invalid
|
|
if (!estimate || typeof estimate.quota !== 'number' || !isFinite(estimate.quota) || estimate.quota <= 0 ||
|
|
typeof estimate.usage !== 'number' || !isFinite(estimate.usage)) {
|
|
g_storageInfoCache = {
|
|
maxEntrySize: 50 * 1024 * 1024,
|
|
isHealthy: true
|
|
};
|
|
g_storageInfoCacheTime = Date.now();
|
|
return g_storageInfoCache;
|
|
}
|
|
// Calculate max entry size: cache ≈ 10% of quota, cap entry at 1/8th
|
|
const cacheSize = Math.min(estimate.quota * 0.10, 1024 * 1024 * 1024); // 1 GiB max
|
|
const maxEntrySize = cacheSize / 8; // Per-entry cap is 1/8th of cache size
|
|
|
|
// Calculate storage health: back off when disk is 80% full
|
|
const usageRatio = estimate.usage / estimate.quota;
|
|
const isHealthy = usageRatio < 0.8;
|
|
|
|
g_storageInfoCache = { maxEntrySize: maxEntrySize, isHealthy: isHealthy };
|
|
g_storageInfoCacheTime = Date.now();
|
|
return g_storageInfoCache;
|
|
})
|
|
.catch(function(error) {
|
|
// Fallback values on error
|
|
g_storageInfoCache = {
|
|
maxEntrySize: 50 * 1024 * 1024,
|
|
isHealthy: true
|
|
};
|
|
g_storageInfoCacheTime = Date.now();
|
|
return g_storageInfoCache;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if response size is within cacheable limits
|
|
* @param {Response} response
|
|
* @returns {Promise<boolean>} true if response is not too large
|
|
*/
|
|
function isReasonableSize(response) {
|
|
const size = Number(response.headers.get('content-length')) || 0;
|
|
if (size === 0) {
|
|
return Promise.resolve(true); // No size header, assume reasonable
|
|
}
|
|
|
|
return getStorageInfo()
|
|
.then(function(info) {
|
|
return size < info.maxEntrySize;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if storage quota is healthy for caching
|
|
* @returns {Promise<boolean>} true if storage is healthy
|
|
*/
|
|
function storageHealthy() {
|
|
return getStorageInfo()
|
|
.then(function(info) {
|
|
return info.isHealthy;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Put response in cache with retry logic for transient errors
|
|
* @param {Request} request
|
|
* @param {Response} response - Response to cache; this function clones per attempt to preserve the original for retries
|
|
* @param {number} attempt - Current attempt number (for retry logic)
|
|
* @returns {Promise} Promise that resolves when caching completes or fails
|
|
*/
|
|
function putInCache(request, response, attempt) {
|
|
if (typeof attempt === 'undefined') attempt = 0;
|
|
return caches.open(g_cacheName)
|
|
.then(function(cache) {
|
|
// Clone at the moment of caching so the provided response remains pristine for retries
|
|
return cache.put(request, response.clone());
|
|
})
|
|
.catch(function(err) {
|
|
// Transient quota/disk hiccup? Retry up to 2x with exponential back-off
|
|
if (attempt < 2) {
|
|
return new Promise(function(resolve) {
|
|
setTimeout(resolve, 250 * Math.pow(2, attempt)); // 250ms, 500ms
|
|
})
|
|
.then(function() {
|
|
// Reuse the original unconsumed response; a fresh clone will be created inside cache.put
|
|
return putInCache(request, response, attempt + 1);
|
|
});
|
|
} else {
|
|
const size = response.headers ? response.headers.get('content-length') : 'unknown';
|
|
console.error('putInCache failed after max retries:', {
|
|
url: request.url,
|
|
method: request.method,
|
|
responseSize: size,
|
|
responseType: response.type,
|
|
cacheName: g_cacheName,
|
|
error: err && (err.message || err)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function cacheFirst(event) {
|
|
const request = event.request;
|
|
|
|
return caches.match(request, { cacheName: g_cacheName })
|
|
.then(function(cached) {
|
|
return cached || fetch(request).then(function(networkResp) {
|
|
// Clone immediately to avoid "body already used" errors
|
|
const responseForCache = networkResp.clone();
|
|
|
|
// Fire-and-forget caching **after** responding to the page
|
|
if (safeToCache(request, networkResp)) {
|
|
event.waitUntil(
|
|
getStorageInfo()
|
|
.then(function(info) {
|
|
const size = Number(networkResp.headers.get('content-length')) || 0;
|
|
const sizeOk = size === 0 || size < info.maxEntrySize;
|
|
|
|
if (info.isHealthy && sizeOk) {
|
|
return putInCache(request, responseForCache);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
return networkResp;
|
|
});
|
|
});
|
|
}
|
|
function activateWorker(event) {
|
|
return self.clients.claim()
|
|
.then(function(){
|
|
//remove stale caches
|
|
return caches.keys();
|
|
})
|
|
.then(function (keys) {
|
|
const deletePromises = keys
|
|
.filter(function(cache) {
|
|
return cache.includes(g_cacheNamePrefix) && !cache.includes(g_cacheName);
|
|
})
|
|
.map(function(cache) {
|
|
return caches.delete(cache);
|
|
});
|
|
return Promise.all(deletePromises);
|
|
}).catch(function (err) {
|
|
console.error('activateWorker failed with ' + err);
|
|
});
|
|
}
|
|
/**
|
|
* Filter function for cacheable paths
|
|
* @param {string} url
|
|
* @returns {boolean}
|
|
*/
|
|
function matchesCacheablePath(url) {
|
|
const g_versionNeedle = "/" + g_version + "/";
|
|
const versionIndex = url.indexOf(g_versionNeedle);
|
|
if (versionIndex === -1) return false;
|
|
|
|
// Position just after "/<version>/"
|
|
const i = versionIndex + g_versionNeedle.length;
|
|
|
|
for (let k = 0; k < g_cacheablePrefixes.length; k++) {
|
|
//startsWith not supported in ie11 but at first service worker not supported
|
|
if (url.startsWith(g_cacheablePrefixes[k], i)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(self.skipWaiting());
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(activateWorker());
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const request = event.request;
|
|
const url = request.url;
|
|
|
|
// Fast path: check method and URL pattern in one go
|
|
if (request.method !== "GET" || !matchesCacheablePath(url)) {
|
|
return;
|
|
}
|
|
|
|
// Desktop editor exclusion
|
|
if (isDesktopEditor && url.indexOf("/sdkjs/common/AllFonts.js") !== -1) {
|
|
return;
|
|
}
|
|
|
|
event.respondWith(cacheFirst(event));
|
|
});
|