- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
297 lines
8.3 KiB
JavaScript
297 lines
8.3 KiB
JavaScript
/**
|
|
* 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(); |