/** * Advanced Link Prefetching System * Supports multiple strategies: hover, visible, eager */ import { Logger } from './logger.js'; import { SimpleCache } from '../utils/cache.js'; export class LinkPrefetcher { constructor(options = {}) { this.options = { maxCacheSize: options.maxCacheSize || 20, cacheTTL: options.cacheTTL || 60000, // 60 seconds hoverDelay: options.hoverDelay || 150, // ms before prefetch on hover strategies: options.strategies || ['hover', 'visible'], // Available: hover, visible, eager observerMargin: options.observerMargin || '50px', priority: options.priority || 'low', // low, high ...options }; this.cache = new SimpleCache(this.options.maxCacheSize, this.options.cacheTTL); this.prefetching = new Set(); // Currently prefetching URLs this.prefetched = new Set(); // Already prefetched URLs this.observer = null; this.hoverTimeout = null; this.cleanupInterval = null; this.init(); } init() { // Setup strategies if (this.options.strategies.includes('visible')) { this.setupIntersectionObserver(); } // Setup cleanup interval this.cleanupInterval = setInterval(() => { this.cache.cleanup(); }, 120000); // Clean every 2 minutes Logger.info('[LinkPrefetcher] initialized with strategies:', this.options.strategies); } setupIntersectionObserver() { if (!('IntersectionObserver' in window)) { Logger.warn('[LinkPrefetcher] IntersectionObserver not supported'); return; } this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const link = entry.target; const href = link.getAttribute('href'); // Lower priority for visible links this.prefetch(href, { priority: 'low' }); // Stop observing once prefetched this.observer.unobserve(link); } }); }, { rootMargin: this.options.observerMargin }); } /** * Check if a link should be prefetched */ shouldPrefetch(link) { if (!link || !(link instanceof HTMLAnchorElement)) return false; const href = link.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) { return false; } // Skip external links try { const url = new URL(href, window.location.origin); if (url.origin !== window.location.origin) return false; } catch { return false; } // Skip if has data-no-prefetch attribute if (link.hasAttribute('data-no-prefetch')) return false; // Skip download links if (link.hasAttribute('download')) return false; // Skip target="_blank" links if (link.target === '_blank') return false; return true; } /** * Prefetch a URL */ async prefetch(href, options = {}) { if (!href || this.prefetching.has(href) || this.cache.has(href)) { return; } // Mark as prefetching this.prefetching.add(href); try { Logger.info(`[LinkPrefetcher] prefetching: ${href}`); // Use different methods based on priority if (options.priority === 'high' || this.options.priority === 'high') { await this.fetchWithPriority(href, 'high'); } else { // Use link prefetch hint for low priority this.createPrefetchLink(href); // Also fetch for cache await this.fetchWithPriority(href, 'low'); } } catch (error) { Logger.warn(`[LinkPrefetcher] failed to prefetch ${href}:`, error); } finally { this.prefetching.delete(href); this.prefetched.add(href); } } /** * Fetch with priority */ async fetchWithPriority(href, priority = 'low') { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout try { const response = await fetch(href, { signal: controller.signal, priority: priority, // Fetch priority hint (Chrome 121+) credentials: 'same-origin', headers: { 'X-Prefetch': 'true' } }); clearTimeout(timeoutId); if (response.ok) { const html = await response.text(); this.cache.set(href, { data: html, timestamp: Date.now(), headers: { contentType: response.headers.get('content-type'), lastModified: response.headers.get('last-modified') } }); } } catch (error) { clearTimeout(timeoutId); if (error.name !== 'AbortError') { throw error; } } } /** * Create a link element for browser prefetch hint */ createPrefetchLink(href) { // Check if already exists if (document.querySelector(`link[rel="prefetch"][href="${href}"]`)) { return; } const link = document.createElement('link'); link.rel = 'prefetch'; link.href = href; link.as = 'document'; document.head.appendChild(link); // Remove after some time to avoid cluttering setTimeout(() => link.remove(), 30000); } /** * Handle hover events */ handleHover(link) { if (!this.shouldPrefetch(link)) return; const href = link.getAttribute('href'); clearTimeout(this.hoverTimeout); this.hoverTimeout = setTimeout(() => { this.prefetch(href, { priority: 'high' }); }, this.options.hoverDelay); } /** * Handle mouse leave */ handleMouseLeave() { clearTimeout(this.hoverTimeout); } /** * Observe a link for intersection */ observeLink(link) { if (!this.observer || !this.shouldPrefetch(link)) return; this.observer.observe(link); } /** * Observe all links in a container */ observeLinks(container = document) { if (!this.options.strategies.includes('visible')) return; const links = container.querySelectorAll('a[href]'); links.forEach(link => this.observeLink(link)); } /** * Eagerly prefetch specific URLs */ prefetchEager(urls) { if (!Array.isArray(urls)) urls = [urls]; urls.forEach(url => { this.prefetch(url, { priority: 'high' }); }); } /** * Get cached content */ getCached(href) { const cached = this.cache.get(href); return cached ? cached.data : null; } /** * Check if URL is cached */ isCached(href) { return this.cache.has(href); } /** * Clear cache */ clearCache() { this.cache.clear(); this.prefetched.clear(); } /** * Get cache statistics */ getStats() { return { cacheSize: this.cache.size(), prefetching: this.prefetching.size, prefetched: this.prefetched.size, strategies: this.options.strategies }; } /** * Destroy the prefetcher */ destroy() { clearTimeout(this.hoverTimeout); clearInterval(this.cleanupInterval); if (this.observer) { this.observer.disconnect(); } this.cache.clear(); this.prefetching.clear(); this.prefetched.clear(); // Remove all prefetch links document.querySelectorAll('link[rel="prefetch"]').forEach(link => link.remove()); Logger.info('[LinkPrefetcher] destroyed'); } } // Export singleton instance export const linkPrefetcher = new LinkPrefetcher();