| 1 |
// Cache-warming prefetch for MainWP (no DOM swap) |
| 2 |
// Targets only safe links. |
| 3 |
// Opt-in: .mainwp-js-prefetch or [mainwp-data-warm]; Opt-out: [mainwp-data-no-warm] |
| 4 |
(function ($) { |
| 5 |
// -------------------- Config -------------------- |
| 6 |
const HOVER_DELAY_MS = 200; // delay before hover prefetch |
| 7 |
const REWARM_TTL_MS = 5 * 60 * 1000;// re-warm same URL after 5 min |
| 8 |
const MAX_INFLIGHT = 3; // throttle concurrent warms |
| 9 |
const MAX_BYTES = 1.5 * 1024 * 1024; // 1.5 MB content size limit |
| 10 |
|
| 11 |
// -------------------- State --------------------- |
| 12 |
const SEEN = new Map(); // url -> timestamp last warmed |
| 13 |
let inflight = 0; |
| 14 |
|
| 15 |
// -------------------- Helpers ------------------- |
| 16 |
function canPrefetch() { |
| 17 |
const c = navigator.connection; |
| 18 |
if (c) { |
| 19 |
if (c.saveData) return false; |
| 20 |
if (['slow-2g', '2g'].includes(c.effectiveType)) return false; |
| 21 |
} |
| 22 |
return location.protocol.startsWith('http'); |
| 23 |
} |
| 24 |
|
| 25 |
function isWarmableLink(a) { |
| 26 |
if (!a) return false; |
| 27 |
if (a.hasAttribute('download') || a.target === '_blank') return false; |
| 28 |
if (a.hasAttribute('mainwp-data-no-warm')) return false; |
| 29 |
|
| 30 |
const href = a.getAttribute('href'); |
| 31 |
if (!href) return false; |
| 32 |
|
| 33 |
// Only warm explicitly opt-in links OR MainWP nav links |
| 34 |
const explicitlyOpted = a.classList.contains('mainwp-js-prefetch') || a.hasAttribute('mainwp-data-warm'); |
| 35 |
const url = new URL(href, location.href); |
| 36 |
|
| 37 |
if (url.origin !== location.origin) return false; // same-origin only |
| 38 |
if (url.hash && url.pathname === location.pathname) return false; // same-page anchor |
| 39 |
if (!explicitlyOpted) return false; // limit scope to MainWP |
| 40 |
|
| 41 |
// De-dupe within TTL |
| 42 |
const seenAt = SEEN.get(url.toString()); |
| 43 |
if (seenAt && (Date.now() - seenAt) < REWARM_TTL_MS) return false; |
| 44 |
|
| 45 |
return true; |
| 46 |
} |
| 47 |
|
| 48 |
function markSeen(urlStr) { |
| 49 |
SEEN.set(urlStr, Date.now()); |
| 50 |
// Clean out old entries occasionally |
| 51 |
if (SEEN.size > 200) { |
| 52 |
const now = Date.now(); |
| 53 |
for (const [u, ts] of SEEN) if (now - ts > REWARM_TTL_MS) SEEN.delete(u); |
| 54 |
} |
| 55 |
} |
| 56 |
|
| 57 |
function prefetchViaLink(urlStr) { |
| 58 |
const l = document.createElement('link'); |
| 59 |
l.rel = 'prefetch'; |
| 60 |
l.href = urlStr; |
| 61 |
l.as = 'document'; |
| 62 |
document.head.appendChild(l); |
| 63 |
} |
| 64 |
|
| 65 |
async function headOKAndSmall(urlStr) { |
| 66 |
// Try HEAD first to check Content-Length; if missing, allow. |
| 67 |
try { |
| 68 |
const res = await fetch(urlStr, { |
| 69 |
method: 'HEAD', |
| 70 |
credentials: 'same-origin', |
| 71 |
mode: 'same-origin', |
| 72 |
cache: 'default' |
| 73 |
}); |
| 74 |
if (!res.ok) return false; |
| 75 |
const len = res.headers.get('content-length'); |
| 76 |
if (!len) return true; // no info -> allow |
| 77 |
return Number.parseInt(len, 10) <= MAX_BYTES; |
| 78 |
} catch { |
| 79 |
// HEAD may be blocked or not supported; allow rather than fail closed. |
| 80 |
return true; |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
async function warm(urlStr) { |
| 85 |
if (inflight >= MAX_INFLIGHT) return; |
| 86 |
inflight++; |
| 87 |
try { |
| 88 |
// 1) Let the browser decide (best-effort) |
| 89 |
prefetchViaLink(urlStr); |
| 90 |
|
| 91 |
// 2) Skip very large pages |
| 92 |
const ok = await headOKAndSmall(urlStr); |
| 93 |
if (!ok) { markSeen(urlStr); return; } |
| 94 |
|
| 95 |
// 3) Fetch to warm the HTTP cache & connection with auth cookies |
| 96 |
await fetch(urlStr, { |
| 97 |
method: 'GET', |
| 98 |
credentials: 'same-origin', |
| 99 |
mode: 'same-origin', |
| 100 |
cache: 'default' |
| 101 |
}).catch(() => { /* ignore */ }); |
| 102 |
console.log('Warm cached url: ' + urlStr); |
| 103 |
markSeen(urlStr); |
| 104 |
} finally { |
| 105 |
inflight--; |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
function scheduleHoverWarm(a) { |
| 110 |
const href = a.getAttribute('href'); |
| 111 |
if (!href) return; |
| 112 |
const urlStr = new URL(href, location.href).toString(); |
| 113 |
const t = setTimeout(() => warm(urlStr), HOVER_DELAY_MS); |
| 114 |
const cancel = () => clearTimeout(t); |
| 115 |
a.addEventListener('pointerleave', cancel, { once: true }); |
| 116 |
a.addEventListener('focusout', cancel, { once: true }); |
| 117 |
} |
| 118 |
|
| 119 |
function setupViewportWarm(root) { |
| 120 |
if (!('IntersectionObserver' in globalThis)) return; |
| 121 |
const io = new IntersectionObserver((entries) => { |
| 122 |
entries.forEach((e) => { |
| 123 |
if (!e.isIntersecting) return; |
| 124 |
const a = e.target; |
| 125 |
if (!isWarmableLink(a)) return; |
| 126 |
const urlStr = new URL(a.getAttribute('href'), location.href).toString(); |
| 127 |
io.unobserve(a); |
| 128 |
warm(urlStr); |
| 129 |
}); |
| 130 |
}, { root: null, rootMargin: '200px', threshold: 0 }); |
| 131 |
|
| 132 |
root.querySelectorAll('a.mainwp-js-prefetch, a[mainwp-data-warm]') |
| 133 |
.forEach((a) => { if (isWarmableLink(a)) io.observe(a); }); |
| 134 |
} |
| 135 |
|
| 136 |
// -------------------- Boot ---------------------- |
| 137 |
$(function () { |
| 138 |
if (!canPrefetch()) return; |
| 139 |
|
| 140 |
// Intent-based warm (hover/focus) |
| 141 |
document.addEventListener('pointerenter', (e) => { |
| 142 |
const target = e.target; |
| 143 |
if (!(target instanceof Element)) return; // skip if not an element |
| 144 |
const a = target.closest('a'); |
| 145 |
if (!a || !isWarmableLink(a)) return; |
| 146 |
scheduleHoverWarm(a); |
| 147 |
}, true); |
| 148 |
document.addEventListener('focusin', (e) => { |
| 149 |
const a = e.target.closest('a'); |
| 150 |
if (!isWarmableLink(a)) return; |
| 151 |
scheduleHoverWarm(a); |
| 152 |
}, true); |
| 153 |
|
| 154 |
// Touch warm |
| 155 |
document.addEventListener('touchstart', (e) => { |
| 156 |
const a = e.target.closest('a'); |
| 157 |
if (!isWarmableLink(a)) return; |
| 158 |
const urlStr = new URL(a.getAttribute('href'), location.href).toString(); |
| 159 |
warm(urlStr); |
| 160 |
}, { passive: true }); |
| 161 |
|
| 162 |
// Viewport warm for persistent nav (e.g., sidebar) |
| 163 |
setupViewportWarm(document); |
| 164 |
}); |
| 165 |
})(jQuery); |
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
|