/** * tiddl-web — HTTP transport layer * * Provides: * - timedFetch : plain fetch wrapped with an AbortController timeout; * network / CORS errors are normalized to TiddlError(NETWORK). * - robustFetch : timedFetch with automatic retry and exponential back-off. * - TiddlError : unified error class with a stable `kind` code. * - ErrKind : enum of all error categories used across the codebase. */ // ─── Timeout constants ──────────────────────────────────────────────────────── /** Default timeout for Tidal API JSON requests (ms). */ export const API_TIMEOUT_MS = 30_000; /** Timeout for individual audio/video segment downloads (ms). */ export const SEGMENT_TIMEOUT_MS = 90_000; /** Timeout for OAuth2 / auth endpoint requests (ms). */ export const AUTH_TIMEOUT_MS = 20_000; // ─── Error taxonomy ─────────────────────────────────────────────────────────── /** * Stable error kind codes. Every thrown error from this module — and from * higher-level modules that use it — carries one of these codes so the UI * can decide how to display / retry without string-matching on messages. */ export const ErrKind = Object.freeze({ NETWORK: "NETWORK", // fetch / CORS / timeout AUTH: "AUTH", // 401 / 403 authentication failures API: "API", // upstream Tidal API error (non-auth) PROXY: "PROXY", // CORS proxy failure MANIFEST: "MANIFEST", // manifest parse failure SEGMENT: "SEGMENT", // audio/video segment fetch failure ENCRYPTED: "ENCRYPTED", // DRM-encrypted stream PACKAGING: "PACKAGING", // ZIP / file-save failure UNKNOWN: "UNKNOWN", }); /** * Unified application error. All modules convert low-level fetch / parse * errors into TiddlError instances so callers have a single type to handle. * * @property {string} kind One of ErrKind * @property {boolean} isTiddlError Always true — lets callers do a fast * `if (err.isTiddlError)` check without * instanceof across module boundaries. */ export class TiddlError extends Error { /** * @param {string} message Human-readable description. * @param {string} [kind] One of ErrKind (default: UNKNOWN). * @param {object} [extra] Extra properties mixed into `this` * (e.g. { url, status, isTimeout: true }). */ constructor(message, kind = ErrKind.UNKNOWN, extra = {}) { super(message); this.name = "TiddlError"; this.kind = kind; this.isTiddlError = true; Object.assign(this, extra); } } // ─── Internal helpers ───────────────────────────────────────────────────────── const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** Returns true when `err` looks like a network / CORS / timeout error. */ export function isNetworkError(err) { if (err instanceof TypeError) return true; // native "Failed to fetch" // TiddlError with kind NETWORK already represents the normalized form of // any network failure (including timeouts) produced by timedFetch. if (err?.isTiddlError && err.kind === ErrKind.NETWORK) return true; const m = String(err?.message ?? "").toLowerCase(); return /failed to fetch|network[\s_]?error|load failed|cors|cross[\s-]?origin/.test(m); } // Keep internal alias for the few places this module uses it before exporting. const _isNetworkLike = isNetworkError; // ─── timedFetch ─────────────────────────────────────────────────────────────── /** * Fetch with an AbortController-backed timeout. * * - Network / CORS errors are re-thrown as TiddlError(NETWORK). * - Timeout expiry re-throws as TiddlError(NETWORK) with `isTimeout: true`. * - Non-OK responses are returned as-is — the caller decides how to handle them. * * @param {string} url * @param {RequestInit} [init] * @param {number} [timeoutMs=API_TIMEOUT_MS] * @returns {Promise} */ export async function timedFetch(url, init = {}, timeoutMs = API_TIMEOUT_MS) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeoutMs); try { // lgtm[js/request-forgery] — URL originates from Tidal API responses and // user-configured settings; caller is responsible for validating inputs. return await fetch(url, { ...init, signal: ac.signal }); } catch (err) { if (err.name === "AbortError" || ac.signal.aborted) { throw new TiddlError( `Request timed out after ${timeoutMs / 1000}s`, ErrKind.NETWORK, { url, isTimeout: true }, ); } if (_isNetworkLike(err)) { throw new TiddlError( err.message || "Network / CORS error", ErrKind.NETWORK, { url, cause: err }, ); } throw err; } finally { clearTimeout(timer); } } // ─── robustFetch ────────────────────────────────────────────────────────────── /** * Fetch with automatic retry and exponential back-off. * * Behaviour: * - Retries on network errors (including timeout) and 429/5xx responses. * - Does NOT retry 4xx responses (caller handles auth errors etc.). * - Hard errors (TiddlError with kind AUTH or ENCRYPTED) skip retries. * * Non-OK responses are returned as-is — the caller inspects the status. * * @param {string} url * @param {RequestInit} [init] * @param {object} [opts] * @param {number} [opts.timeoutMs=API_TIMEOUT_MS] * @param {number} [opts.retries=2] Extra attempts after first failure. * @param {number} [opts.baseDelay=700] ms for first back-off pause. * @returns {Promise} */ export async function robustFetch(url, init = {}, { timeoutMs = API_TIMEOUT_MS, retries = 2, baseDelay = 700, } = {}) { for (let attempt = 0; attempt <= retries; attempt++) { // Back-off before every retry (not before the first attempt). if (attempt > 0) { await _sleep(baseDelay * attempt); } try { const res = await timedFetch(url, init, timeoutMs); if (!res.ok && (res.status === 429 || res.status >= 500) && attempt < retries) { continue; // sleep + retry } return res; } catch (err) { const isHardStop = err instanceof TiddlError && (err.kind === ErrKind.AUTH || err.kind === ErrKind.ENCRYPTED); if (isHardStop || attempt >= retries) throw err; // Network / timeout errors are retried; sleep is handled at the top of // the next iteration. } } // Safety fallback — not reachable given the logic above (every path returns // or throws inside the loop), but included so TypeScript / strict linters // do not complain about a missing return and to guard future edits. throw new TiddlError("All retry attempts exhausted", ErrKind.NETWORK, { url }); }