prefetch.js (10131B)
1 /*! 2 * cdnV2 link prefetcher. 3 * 4 * Once a page has settled, quietly fetches the pages it links to (folders, file pages, the navbar 5 * destinations) so the server's listing cache is already warm when you click. The expensive part of a 6 * click is the cold Hugging Face lookup behind a folder, and that cache only lives for 60 seconds, so 7 * this re-warms it just ahead of use. 8 * 9 * Deliberately conservative: 10 * - same-origin links only; never /api/*, /admin, /mask/*, /pack/*, /static/*, /raw/*, downloads, 11 * links with target/download, or anything inside data-turbo="false" / data-no-prefetch 12 * - at most 16 pages per page view, 3 at a time, page content before navbar links, visible links 13 * first, and only while the tab is visible 14 * - idle time only; cancelled the moment you navigate so it never competes with a real click 15 * - skipped entirely on Save-Data, 2g connections and prefers-reduced-data 16 * 17 * Opt out: <meta name="cdn-prefetch" content="off"> on a page, or data-no-prefetch on a link/container. 18 * Works with Turbo (re-runs on turbo:load) and without it. Loaded once from <head>; safe to include twice. 19 */ 20 (function (global) { 21 'use strict'; 22 23 // ttlMs stays below the pages' Cache-Control max-age (app/fast_html.py, 30 s): a page this old is 24 // re-fetched, one younger is still in the browser's cache and needs no work. 25 const DEFAULTS = { maxLinks: 16, concurrency: 3, ttlMs: 25000 }; 26 27 // First path segments that belong to the app itself, not to the CDN's content. Anything under them is 28 // an API, a redirect, an admin page or a download, so it must never be fetched speculatively. 29 const RESERVED_SEGMENTS = new Set(['api', 'admin', 'mask', 'pack', 'static', 'raw']); 30 31 function pathAllowed(pathname) { 32 if (pathname === '/favicon.ico') return false; 33 const first = pathname.split('/')[1] || ''; 34 return !RESERVED_SEGMENTS.has(first); 35 } 36 37 // Returns the normalised URL to prefetch for a link, or null if it should be left alone. 38 // `link` is plain data ({href, target, download, noPrefetch, turboOff}) and `base` a URL for the 39 // current page, so this stays testable without a DOM. 40 function candidateUrl(link, base) { 41 if (link.download || link.noPrefetch || link.turboOff) return null; 42 if (link.target && link.target !== '_self') return null; 43 let url; 44 try { 45 url = new URL(link.href, base); 46 } catch (e) { 47 return null; 48 } 49 if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; 50 if (url.origin !== base.origin) return null; 51 if (!pathAllowed(url.pathname)) return null; 52 if (url.pathname === base.pathname && url.search === base.search) return null; // this page 53 url.hash = ''; 54 return url.href; 55 } 56 57 // A small bounded work queue. `fetchFn(url, signal)` must return a promise that settles when the 58 // response has been fully read (the server only finishes warming its cache once it has answered). 59 function createPrefetcher(opts) { 60 const cfg = Object.assign({}, DEFAULTS, opts.config); 61 const now = opts.now || Date.now; 62 const seen = new Map(); // url -> when it last finished; stops repeat fetches inside the cache TTL 63 const inflight = new Map(); // url -> AbortController, so one request can be cancelled on its own 64 let queue = []; 65 let queued = new Set(); 66 let active = 0; 67 let used = 0; // pages accepted for the current page view (the budget) 68 let paused = false; 69 70 function fresh(url) { 71 const t = seen.get(url); 72 return t !== undefined && now() - t < cfg.ttlMs; 73 } 74 75 function run(url) { 76 active++; 77 const ctl = new AbortController(); 78 inflight.set(url, ctl); 79 Promise.resolve() 80 .then(() => opts.fetchFn(url, ctl.signal)) 81 .then( 82 () => seen.set(url, now()), 83 // A failed fetch is remembered too, so a flaky or 404 link is not retried in a loop. 84 () => { if (!ctl.signal.aborted) seen.set(url, now()); } 85 ) 86 .then(() => { 87 active--; 88 inflight.delete(url); 89 pump(); 90 }); 91 } 92 93 function pump() { 94 while (!paused && active < cfg.concurrency && queue.length) run(queue.shift()); 95 } 96 97 return { 98 // Start a new page view: fresh budget, drop anything still waiting. Finished work is remembered. 99 newPage() { 100 queue = []; 101 queued = new Set(); 102 used = 0; 103 }, 104 remaining() { 105 return cfg.maxLinks - used; 106 }, 107 enqueue(urls) { 108 let added = 0; 109 for (const url of urls) { 110 if (used >= cfg.maxLinks) break; 111 if (queued.has(url) || inflight.has(url) || fresh(url)) continue; 112 queue.push(url); 113 queued.add(url); 114 used++; 115 added++; 116 } 117 pump(); 118 return added; 119 }, 120 // Cancel in-flight requests and forget the queue (called when navigating away). A request for 121 // `keepUrl` (the page being opened) is left running: it is exactly what the click needs, and the 122 // browser can hand its response to the navigation instead of starting over. 123 abort(keepUrl) { 124 for (const [url, ctl] of inflight) if (url !== keepUrl) ctl.abort(); 125 queue = []; 126 queued = new Set(); 127 }, 128 setPaused(value) { 129 paused = !!value; 130 if (!paused) pump(); 131 }, 132 stats() { 133 return { queued: queue.length, active, remembered: seen.size, used }; 134 }, 135 }; 136 } 137 138 function shouldSkipDevice(win) { 139 const conn = win.navigator && (win.navigator.connection || win.navigator.mozConnection); 140 if (conn && (conn.saveData || /2g$/.test(conn.effectiveType || ''))) return true; 141 if (win.matchMedia && win.matchMedia('(prefers-reduced-data: reduce)').matches) return true; 142 return false; 143 } 144 145 function boot(win) { 146 const doc = win.document; 147 if (!doc || win.__cdnPrefetch || typeof win.fetch !== 'function') return; 148 if (shouldSkipDevice(win)) return; 149 150 const conn = win.navigator && win.navigator.connection; 151 const config = conn && conn.effectiveType === '3g' ? { maxLinks: 6, concurrency: 1 } : {}; 152 153 const pf = createPrefetcher({ 154 config, 155 fetchFn: (url, signal) => 156 win 157 .fetch(url, { 158 credentials: 'same-origin', 159 signal, 160 priority: 'low', // ignored by browsers that don't support it 161 headers: { Accept: 'text/html', 'X-Purpose': 'prefetch' }, 162 }) 163 .then((res) => res.arrayBuffer()), 164 }); 165 win.__cdnPrefetch = pf; // handy for debugging: __cdnPrefetch.stats() 166 167 function scan(freshPage) { 168 if (doc.querySelector('meta[name="cdn-prefetch"][content="off"]')) return; 169 if (freshPage) pf.newPage(); 170 if (pf.remaining() <= 0) return; 171 172 const base = new URL(win.location.href); 173 const viewport = win.innerHeight || 800; 174 const found = []; 175 const dedupe = new Set(); 176 177 doc.querySelectorAll('a[href]').forEach((a) => { 178 if (a.getClientRects().length === 0) return; // hidden: filtered-out rows, collapsed menus 179 const url = candidateUrl( 180 { 181 href: a.href, 182 target: a.getAttribute('target'), 183 download: a.hasAttribute('download'), 184 noPrefetch: !!a.closest('[data-no-prefetch]'), 185 turboOff: !!a.closest('[data-turbo="false"]'), 186 }, 187 base 188 ); 189 if (!url || dedupe.has(url)) return; 190 // A folder the instant-navigation tree can build locally needs no server round trip at all. 191 if (win.__cdnFast && win.__cdnFast.handles(url)) return; 192 dedupe.add(url); 193 const r = a.getBoundingClientRect(); 194 // Page content first (on-screen links, plus a screenful below, ahead of the rest), then site 195 // chrome (navbar, footer). Those pages are cheap for the server, so they only get what's left. 196 const chrome = !!a.closest('header, nav, footer'); 197 found.push({ url, rank: chrome ? 2 : r.bottom > 0 && r.top < viewport * 1.5 ? 0 : 1 }); 198 }); 199 200 found.sort((x, y) => x.rank - y.rank); 201 pf.enqueue(found.map((f) => f.url)); 202 } 203 204 // Wait for the document to be parsed and the browser to go idle before touching the network. 205 let pending = false; 206 let pendingFresh = false; 207 function request(freshPage) { 208 pendingFresh = pendingFresh || freshPage; 209 if (pending) return; 210 pending = true; 211 const go = () => { 212 const run = () => { 213 pending = false; 214 const f = pendingFresh; 215 pendingFresh = false; 216 scan(f); 217 }; 218 if (win.requestIdleCallback) win.requestIdleCallback(run, { timeout: 500 }); 219 else win.setTimeout(run, 100); 220 }; 221 // The HTML is enough to know the links; don't wait for every image and script to finish. 222 if (doc.readyState !== 'loading') go(); 223 else doc.addEventListener('DOMContentLoaded', go, { once: true }); 224 } 225 226 request(true); 227 doc.addEventListener('turbo:load', () => request(true)); 228 229 // Pages that build their list with JS (search results, history) add links after load. 230 let timer = 0; 231 new win.MutationObserver(() => { 232 win.clearTimeout(timer); 233 timer = win.setTimeout(() => request(false), 800); 234 }).observe(doc.documentElement, { childList: true, subtree: true }); 235 236 pf.setPaused(doc.hidden); 237 doc.addEventListener('visibilitychange', () => pf.setPaused(doc.hidden)); 238 239 // Navigating: stop prefetching so the real request gets the connection to itself. 240 doc.addEventListener('turbo:before-visit', (event) => { 241 let keep = null; 242 try { 243 const u = new URL(event.detail.url); 244 u.hash = ''; 245 keep = u.href; 246 } catch (e) { /* no usable URL: cancel everything */ } 247 pf.abort(keep); 248 }); 249 win.addEventListener('pagehide', () => pf.abort()); 250 } 251 252 if (typeof module === 'object' && module.exports) { 253 module.exports = { DEFAULTS, pathAllowed, candidateUrl, createPrefetcher, boot }; 254 } else { 255 boot(global); 256 } 257 })(typeof window !== 'undefined' ? window : globalThis);