Files
ytplayer/frontend/async-guard.js

107 lines
4.7 KiB
JavaScript
Executable File

/* ============================================================================
* async-guard — tiny concurrency helper shared by the frontend.
*
* Keeps long-running native operations (offline-cache download/delete, playlist
* mutations) from being triggered twice at once for the same target, so the UI
* stays responsive and on-device state can't be corrupted by rapid clicks.
*
* Framework-free and dependency-free on purpose:
* • Loads as a plain <script> under CSP `script-src 'self'` (browser global
* `window.AsyncGuard`).
* • `require`-able by `node --test` (CommonJS `module.exports`).
* ========================================================================== */
(function (root) {
'use strict';
/**
* Run `fn` exclusively for `key`: if an operation for `key` is already
* in-flight (tracked in `set`), this call is a no-op and resolves to
* `undefined`. Otherwise `key` is added to `set`, `fn` runs, and `key` is
* removed once it settles — on success AND on failure — so a failed
* operation can be retried.
*
* @param {Set<string>} set in-flight key registry (caller owns it)
* @param {string} key identifies the target being operated on
* @param {() => (any|Promise<any>)} fn the work to run exclusively
* @returns {Promise<any>} fn's resolved value, or undefined if skipped
*/
async function runExclusive(set, key, fn) {
if (set.has(key)) return undefined;
set.add(key);
try {
return await fn();
} finally {
set.delete(key);
}
}
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000,
shouldRetry = () => true, sleep = wait } = {}) {
let last;
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
try { return await task(attempt); }
catch (error) {
last = error;
if (attempt >= attempts || !shouldRetry(error, attempt)) throw error;
await sleep(delay(attempt));
}
}
throw last;
}
// Retry only idempotent GETs. Keep the timeout active through JSON parsing so
// a stalled response body is treated like a stalled connection.
async function getJson(url, options = {}, config = {}) {
const fetchFn = config.fetch || root.fetch?.bind(root);
if (!fetchFn) throw new Error('fetch is unavailable');
if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET');
const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000;
return retry(async () => {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
let timedOut = false, timer, externalAbort;
const external = options.signal;
if (controller && external) {
if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError');
externalAbort = () => controller.abort(external.reason);
external.addEventListener('abort', externalAbort, { once: true });
}
if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
try {
const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error;
}
if (!response.ok) {
let message = `HTTP ${response.status}`;
try { message = (await response.json()).error || message; } catch {}
const error = new Error(message); error.retryable = false; throw error;
}
const value = await response.json();
if (config.retryResult?.(value)) {
const error = new Error(value.error || value.message || 'Temporary API failure');
error.retryable = true;
throw error;
}
return value;
} catch (error) {
if (external?.aborted) throw error;
if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; }
if (error.retryable === undefined) error.retryable = true;
throw error;
} finally {
if (timer) clearTimeout(timer);
if (external && externalAbort) external.removeEventListener('abort', externalAbort);
}
}, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait });
}
const AsyncGuard = { runExclusive, retry, getJson };
if (typeof module !== 'undefined' && module.exports) {
module.exports = AsyncGuard;
} else {
root.AsyncGuard = AsyncGuard;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);