routerconsole/jsp/js/fetchWorker.js

/**
 * @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);
}