'use strict'; /* * Reader-side half of the performance trace (docs/performance.md). Only started when the server says its * trace is on (`perf: true` in /api/chapter). It reports, in batches to POST api/perf: * - `res` the browser's resource timing of every library image: queueing, connection, time to first byte, * download, size, the server's own `stat` time and whether the connection was reused; * - `view` a page turn (page/book mode): how long the new page(s) took to appear, and whether they were * preloaded already; * - `stall` flow mode: a page scrolled into view before its image had loaded, and how long it stayed empty. */ window.PerfTrace = { start(pageName) { let queue = [], on = true; const push = e => { if (on) queue.push(e); }; const rel = url => { try { return decodeURIComponent(new URL(url).pathname); } catch { return String(url); } }; const flush = beacon => { if (!queue.length || !on) return; const body = JSON.stringify({ page: pageName, events: queue.splice(0, 300) }); if (beacon && navigator.sendBeacon) navigator.sendBeacon('api/perf', new Blob([body], { type: 'application/json' })); else fetch('api/perf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true }) .then(r => r.json()).then(r => { if (r.on === false) on = false; }).catch(() => {}); }; setInterval(() => flush(false), 5000); addEventListener('pagehide', () => flush(true)); push({ e: 'hello', ua: navigator.userAgent, conn: navigator.connection ? navigator.connection.effectiveType : '', dpr: devicePixelRatio, vw: innerWidth, vh: innerHeight }); if (window.PerformanceObserver) { new PerformanceObserver(list => { for (const r of list.getEntries()) { if (!/\/lib\//.test(r.name)) continue; const st = (r.serverTiming || []).find(x => x.name === 'stat'); push({ e: 'res', url: rel(r.name), at: r.startTime, queue: r.requestStart ? r.requestStart - r.startTime : null, // waiting for a free connection connect: r.connectEnd - r.connectStart, dns: r.domainLookupEnd - r.domainLookupStart, ttfb: r.responseStart && r.requestStart ? r.responseStart - r.requestStart : null, download: r.responseStart ? r.responseEnd - r.responseStart : null, total: r.duration, size: r.transferSize, body: r.encodedBodySize, server: st ? st.duration : null, proto: r.nextHopProtocol || '', cached: r.transferSize === 0 && r.encodedBodySize > 0 }); } }).observe({ type: 'resource', buffered: true }); } return { // Called right after a page turn put new elements on screen. view(imgs, info) { const t0 = performance.now(), waiting = imgs.filter(img => !img.complete); const event = { e: 'view', ...info, n: imgs.length, preloaded: !waiting.length }; if (!waiting.length) { push({ ...event, wait: 0 }); return; } Promise.all(waiting.map(img => (img.decode ? img.decode() : Promise.resolve()).catch(() => {}))) .then(() => requestAnimationFrame(() => push({ ...event, wait: performance.now() - t0, url: rel(waiting[0].currentSrc || waiting[0].src) }))); }, // Flow mode: watch pages enter the real viewport (not the preload margin) before their image is there. watchFlow(root, els) { if (!window.IntersectionObserver) return; const io = new IntersectionObserver(entries => { for (const en of entries) { const img = en.target.firstChild; if (!en.isIntersecting || (img.complete && img.naturalWidth)) continue; const t0 = performance.now(), hadSrc = !!img.getAttribute('src'); const done = () => push({ e: 'stall', wait: performance.now() - t0, hadSrc, url: rel(img.currentSrc || img.src) }); img.addEventListener('load', done, { once: true }); io.unobserve(en.target); } }, { root }); for (const el of els) io.observe(el); return io; }, }; }, };