/**
* @module fetchWorker
* @description A general-purpose SharedWorker for background fetch requests with
* concurrency control, debouncing, and queue management. Handles both HTML text
* and binary blob responses.
* @author dr|z3d
* @license AGPLv3 or later
*/
/** @type {number} */
const MAX_CONCURRENT_REQUESTS = 16;
/** @type {number} */
const MAX_QUEUE_SIZE = 16;
/** @type {number} */
const MIN_INTERVAL = 500;
/** @type {number} */
const DEBOUNCE_DELAY = 200;
/** @type {number} */
const FETCH_TIMEOUT = 10000;
/** @type {Map<string, {lastRequestTime: number}>} */
const responseCountMap = new Map();
/** @type {number} */
let activeRequests = 0;
/** @type {Array<{url: string, now: number, clientData: Object}>} */
let fetchQueue = [];
/** @type {number} */
let noResponse = 0;
/** @type {Map<string, {timeoutId: number, clientId: string}>} */
let debounceTimeouts = new Map();
/**
* Handles new SharedWorker connections by setting up message and close handlers.
* Each connection gets its own unique id so closing one tab cannot purge the
* shared queue or rate limit for other tabs.
* @function self.onconnect
* @param {MessageEvent} e - The connection event containing ports
* @returns {void}
*/
self.onconnect = function(e) {
const port = e.ports[0];
const clientData = { port, clientId: Math.random().toString(36).substr(2, 9), visible: true };
port.onmessage = function(event) {handleClientMessage(event, clientData);};
port.onclose = () => {cleanupClient(clientData);};
};
/**
* Processes incoming messages from a client port, debouncing or force-enqueueing fetch requests.
* Clients report their visibility with `{visibility: bool}` so hidden tabs are
* suspended (their pending work is purged and new requests ignored) until they
* report visible again.
* @function handleClientMessage
* @param {MessageEvent} event - The message event containing fetch data
* @param {Object} clientData - Client data with port and clientId
* @param {MessagePort} clientData.port - The communication port
* @param {string} clientData.clientId - The client's unique identifier
* @returns {void}
*/
function handleClientMessage(event, clientData) {
const { url, force = false, visibility } = event.data;
if (visibility !== undefined) {
setClientVisibility(clientData, visibility);
return;
}
if (!url || !clientData.visible) { return; }
const now = Date.now();
const lastRequestTime = responseCountMap.get(clientData.clientId)?.lastRequestTime || 0;
if (!force && (now - lastRequestTime < MIN_INTERVAL)) { return; }
if (fetchQueue.length >= MAX_QUEUE_SIZE) { return; }
if (force) {enqueueFetchRequest(url, now, clientData); return;}
if (debounceTimeouts.has(url)) {clearTimeout(debounceTimeouts.get(url).timeoutId);}
const timeoutId = setTimeout(() => {
enqueueFetchRequest(url, now, clientData);
debounceTimeouts.delete(url);
}, DEBOUNCE_DELAY);
debounceTimeouts.set(url, { timeoutId, clientId: clientData.clientId });
}
/**
* Updates a client's visibility state, purging all its pending work when it
* becomes hidden so nothing fetches for an invisible tab.
* @function setClientVisibility
* @param {Object} clientData - Client data with port and clientId
* @param {boolean} visible - Whether the client's page is visible
* @returns {void}
*/
function setClientVisibility(clientData, visible) {
if (clientData.visible === visible) { return; }
clientData.visible = visible;
if (!visible) { purgeClientWork(clientData.clientId); }
}
/**
* Removes all pending debounce timers and queued fetches for a client.
* @function purgeClientWork
* @param {string} clientId - The client's unique identifier
* @returns {void}
*/
function purgeClientWork(clientId) {
for (const [url, entry] of debounceTimeouts) {
if (entry.clientId === clientId) {
clearTimeout(entry.timeoutId);
debounceTimeouts.delete(url);
}
}
fetchQueue = fetchQueue.filter(item => item.clientData.clientId !== clientId);
}
/**
* Enqueues a fetch request, executing immediately if concurrency limit not reached.
* @function enqueueFetchRequest
* @param {string} url - The URL to fetch
* @param {number} now - Timestamp of the request
* @param {Object} clientData - Client data with port and clientId
* @returns {void}
*/
function enqueueFetchRequest(url, now, clientData) {
if (activeRequests < MAX_CONCURRENT_REQUESTS) {
activeRequests++;
processFetchRequest(url, now, clientData);
} else {
fetchQueue.push({ url, now, clientData });
}
}
/**
* Executes a fetch request and posts the response back to the client port.
* Requests are aborted after FETCH_TIMEOUT so a stalled connection cannot
* pin a concurrency slot (or the page's in-flight guard) forever.
* @function processFetchRequest
* @param {string} url - The URL to fetch
* @param {number} now - Timestamp of the request
* @param {Object} clientData - Client data with port and clientId
* @returns {Promise<void>}
*/
async function processFetchRequest(url, now, clientData) {
const {port, clientId} = clientData;
const controller = new AbortController();
const timeoutId = setTimeout(() => {controller.abort();}, FETCH_TIMEOUT);
try {
const response = await fetch(url, { signal: controller.signal });
let messagePayload;
if (response.ok) {
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.includes("text/html")) {
const responseText = await response.text();
messagePayload = { url, responseText, isDown: false, noResponse: 0, status: response.status };
} else {
const responseBlob = await response.blob();
messagePayload = { url, responseBlob, isDown: false, noResponse: 0, status: response.status };
}
updateLastRequestTime(clientId, now);
noResponse = 0;
} else {
messagePayload = { url, isDown: true, noResponse: incrementNoResponse(), status: response.status };
}
port.postMessage(messagePayload);
} catch (error) {
port.postMessage({ url, isDown: true, noResponse: incrementNoResponse(), status: 0 });
} finally {
clearTimeout(timeoutId);
decrementActiveRequests();
processNextFetchRequest();
}
}
/**
* Updates the last request timestamp for a given client.
* @function updateLastRequestTime
* @param {string} clientId - The client's unique identifier
* @param {number} now - The current timestamp
* @returns {void}
*/
function updateLastRequestTime(clientId, now) {
responseCountMap.set(clientId, { lastRequestTime: now });
}
/**
* Increments and returns the no-response counter.
* @function incrementNoResponse
* @returns {number} The incremented counter value
*/
function incrementNoResponse() {return ++noResponse;}
/**
* Decrements the active request counter.
* @function decrementActiveRequests
* @returns {void}
*/
function decrementActiveRequests() {activeRequests--;}
/**
* Processes the next fetch request in the queue if concurrency limit allows.
* Skips queued items belonging to clients that went hidden since enqueue.
* @function processNextFetchRequest
* @returns {void}
*/
function processNextFetchRequest() {
while (activeRequests < MAX_CONCURRENT_REQUESTS && fetchQueue.length > 0) {
const { url, now, clientData } = fetchQueue.shift();
if (!clientData.visible) { continue; }
activeRequests++;
processFetchRequest(url, now, clientData);
}
}
/**
* Cleans up resources associated with a disconnected client.
* @function cleanupClient
* @param {Object} clientData - Client data with port and clientId
* @returns {void}
*/
function cleanupClient(clientData) {
const { clientId } = clientData;
responseCountMap.delete(clientId);
purgeClientWork(clientId);
}