UNPKG

idle-resource-loader

Version:

Lightweight resource preloading SDK that intelligently utilizes browser idle time for optimal performance. Features smart concurrency control, dynamic time slicing, and user experience first design.

307 lines (306 loc) 9.21 kB
function getFileExtension(url) { const cleanUrl = url.split("?")[0] || ""; const parts = cleanUrl.split("."); if (parts.length <= 1) return ""; const extension = parts[parts.length - 1]; return extension ? extension.toLowerCase() : ""; } function setCrossOrigin(element) { try { element.crossOrigin = "anonymous"; } catch {} } function createResourceLoader(config) { return (url) => { return new Promise((resolve, reject) => { const element = config.createElement(); if ("crossOrigin" in element) setCrossOrigin(element); let isCleanedUp = false; const cleanup = () => { if (isCleanedUp) return; isCleanedUp = true; element.removeEventListener(config.successEvent, onSuccess); element.removeEventListener(config.errorEvent, onError); config.cleanup?.(element); }; const onSuccess = () => { cleanup(); resolve(); }; const onError = () => { cleanup(); reject(new Error(`Failed to load ${element.tagName.toLowerCase()}: ${url}`)); }; element.addEventListener(config.successEvent, onSuccess, { once: true }); element.addEventListener(config.errorEvent, onError, { once: true }); config.setup(element, url); }); }; } function loadByType(url) { const extension = getFileExtension(url); const loader = LOADER_MAP.get(extension); return loader ? loader(url) : loadGeneric(url); } const loadImage = createResourceLoader({ createElement: () => { if (typeof Image === "undefined") throw new Error("Image constructor not available in this environment"); return new Image(); }, successEvent: "load", errorEvent: "error", setup: (img, url) => { img.src = url; } }); const loadAudio = createResourceLoader({ createElement: () => { if (typeof Audio === "undefined") throw new Error("Audio constructor not available in this environment"); return new Audio(); }, successEvent: "canplaythrough", errorEvent: "error", setup: (audio, url) => { audio.src = url; } }); const loadVideo = createResourceLoader({ createElement: () => { if (typeof document === "undefined") throw new Error("document not available in this environment"); return document.createElement("video"); }, successEvent: "loadedmetadata", errorEvent: "error", setup: (video, url) => { video.src = url; } }); function getFontType(url) { const extension = getFileExtension(url); switch (extension) { case "woff2": return "font/woff2"; case "woff": return "font/woff"; case "ttf": return "font/ttf"; default: return "font/woff2"; } } const loadFont = createResourceLoader({ createElement: () => { if (typeof document === "undefined") throw new Error("document not available in this environment"); return document.createElement("link"); }, successEvent: "load", errorEvent: "error", setup: (link, url) => { link.rel = "preload"; link.as = "font"; link.type = getFontType(url); link.href = url; if (document.head) document.head.appendChild(link); }, cleanup: (link) => { if (link.parentNode) link.parentNode.removeChild(link); } }); const loadGeneric = async (url) => { if (typeof fetch === "undefined") throw new Error("fetch not available in this environment"); try { await fetch(url, { mode: "no-cors", cache: "default" }); } catch { throw new Error(`Failed to load resource: ${url}`); } }; const LOADER_MAP = new Map([ ["jpg", loadImage], ["jpeg", loadImage], ["png", loadImage], ["gif", loadImage], ["webp", loadImage], ["svg", loadImage], ["mp3", loadAudio], ["wav", loadAudio], ["ogg", loadAudio], ["mp4", loadVideo], ["webm", loadVideo], ["mov", loadVideo], ["avi", loadVideo], ["mkv", loadVideo], ["flv", loadVideo], ["woff", loadFont], ["woff2", loadFont], ["ttf", loadFont] ]); function createTimeoutController(timeout) { if (typeof AbortController === "undefined") return null; const controller = new AbortController(); setTimeout(() => { if (!controller.signal.aborted) controller.abort(); }, timeout); return controller; } function getRequestIdleCallback() { if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") return window.requestIdleCallback.bind(window); return null; } function handleError(error, url, userCallback) { console.warn(`Resource load failed: ${url}`, error); if (userCallback) try { userCallback(url, error); } catch (callbackError) { console.warn("Error in user callback:", callbackError); } } const idleQueue = []; let isIdleProcessing = false; function getOptimalBatchSize(requestedSize) { const MAX_CONCURRENT = 2; const DEFAULT_SIZE = 1; if (requestedSize > 0) return Math.min(requestedSize, MAX_CONCURRENT); return DEFAULT_SIZE; } function addIdleTask(urls, options) { idleQueue.push({ urls, options }); } async function loadSingleAsset(url, timeout, options) { const controller = createTimeoutController(timeout); try { const loadPromise = loadByType(url); if (controller) { const timeoutPromise = new Promise((_, reject) => { controller.signal.addEventListener("abort", () => { reject(new Error(`Timeout: ${url}`)); }); }); await Promise.race([loadPromise, timeoutPromise]); } else { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Timeout: ${url}`)), timeout); }); await Promise.race([loadPromise, timeoutPromise]); } } catch (error) { const loadError = error; handleError(loadError, url, options.onError); throw loadError; } } async function loadAssetsBatch(urls, options) { const { batchSize = 1, timeout = 15e3 } = options; const optimalBatchSize = getOptimalBatchSize(batchSize); for (let i = 0; i < urls.length; i += optimalBatchSize) { const batch = urls.slice(i, i + optimalBatchSize); const promises = batch.map((url) => loadSingleAsset(url, timeout, options)); await Promise.allSettled(promises); } } function scheduleIdleWork() { if (isIdleProcessing || idleQueue.length === 0) return; isIdleProcessing = true; const requestIdleCallback = getRequestIdleCallback(); if (requestIdleCallback) requestIdleCallback((deadline) => { try { processIdleQueue(deadline); } catch (error) { console.warn("Idle processing failed:", error); isIdleProcessing = false; } }); else setTimeout(() => { try { const deadline = { timeRemaining: () => 5 }; processIdleQueue(deadline); } catch (error) { console.warn("Idle processing failed:", error); isIdleProcessing = false; } }, 50); } function shouldPauseIdleLoading() { if (typeof document !== "undefined" && document.hidden) return true; return false; } const TIME_SLICE_CONFIG = { MIN_TIME_REMAINING: 12, ESTIMATED_PROCESS_TIME: 3, MAX_BATCH_SIZE: 2 }; function calculateOptimalBatchSize(timeRemaining) { const { MIN_TIME_REMAINING, ESTIMATED_PROCESS_TIME, MAX_BATCH_SIZE } = TIME_SLICE_CONFIG; const availableTime = timeRemaining - MIN_TIME_REMAINING; if (availableTime <= 0) return 0; const calculatedSize = Math.floor(availableTime / ESTIMATED_PROCESS_TIME); return Math.min(calculatedSize, MAX_BATCH_SIZE); } function processIdleQueue(deadline) { if (shouldPauseIdleLoading()) { isIdleProcessing = false; setTimeout(() => scheduleIdleWork(), 1e3); return; } const timeRemaining = deadline.timeRemaining(); const optimalBatchSize = calculateOptimalBatchSize(timeRemaining); if (optimalBatchSize === 0 || idleQueue.length === 0) { isIdleProcessing = false; if (idleQueue.length > 0) scheduleIdleWork(); return; } let processedCount = 0; while (processedCount < optimalBatchSize && idleQueue.length > 0) { const task = idleQueue.shift(); if (task) { const { urls, options } = task; const url = urls.shift(); if (url) { loadSingleAsset(url, options.timeout || 15e3, options).catch((error) => handleError(error, url, options.onError)); processedCount++; } if (urls.length > 0) idleQueue.push({ urls, options }); } } isIdleProcessing = false; if (idleQueue.length > 0) scheduleIdleWork(); } function resolveResourceUrl(resource) { if (!resource || typeof resource !== "string") return null; const trimmed = resource.trim(); if (!trimmed) return null; if (/^(javascript|data|file|ftp|blob):/i.test(trimmed)) return null; if (trimmed.includes("://") && !/^https?:\/\//.test(trimmed)) return null; if (!/^[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$/.test(trimmed)) return null; return trimmed; } function prepareResourcesForLoading(resources) { const resourceArray = Array.isArray(resources) ? resources : [resources]; return resourceArray.map(resolveResourceUrl).filter((url) => url !== null); } function loadResources(resources, options = {}) { const { strategy = "immediate", batchSize = 1,...restOptions } = options; const validUrls = prepareResourcesForLoading(resources); if (validUrls.length === 0) return; if (strategy === "idle") { const idleOptions = { ...restOptions, batchSize }; addIdleTask(validUrls, idleOptions); scheduleIdleWork(); } else { const immediateOptions = { ...restOptions, batchSize }; loadAssetsBatch(validUrls, immediateOptions).catch((error) => { console.warn("[system] Immediate load failed:", error); }); } } exports.loadResources = loadResources;