Files
MaashkinoozheandClaude Opus 5.5 896726a51b Performance trace, load test and chapter read-ahead
A switchable trace (DATA/perf/trace.on or PERF_TRACE=1) records every request's
phases (stat, open, first read, disk vs network wait), browser resource timings,
page-turn waits and flow stalls, a cold storage probe, event-loop lag, scans and
scraper calls as JSONL. tools/perf-report.js says which phase slow loads spend
their time in; tools/perf-bench.js simulates readers.

READ_AHEAD (default on) reads an opened chapter's pages, then the next chapter's,
into the OS cache so a NAS's cold reads stop reaching the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:28:50 +01:00

73 lines
4.0 KiB
JavaScript

'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 <img> 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;
},
};
},
};