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
JavaScript
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;