@hyperbrowser/agent
Version:
Hyperbrowsers Web Agent
158 lines (157 loc) • 6.54 kB
JavaScript
;
/**
* Wait for DOM to settle by monitoring network activity
*
* Definition of "settled":
* - No in-flight network requests (except WebSocket / Server-Sent-Events)
* - That idle state lasts for at least 500ms (the "quiet-window")
*
* How it works:
* 1. Subscribe to CDP Network and Page events
* 2. Track in-flight requests with metadata (URL, start time)
* 3. Stalled request sweep: Force-complete requests stuck for >2 seconds
* 4. Handle Document requests and frameStoppedLoading events
* 5. When no requests for 500ms, consider DOM settled
* 6. Global timeout ensures we don't wait forever
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.waitForSettledDOM = waitForSettledDOM;
const cdp_1 = require("../cdp");
const perf_hooks_1 = require("perf_hooks");
const options_1 = require("../debug/options");
const NETWORK_IDLE_THRESHOLD_MS = 500;
const STALLED_REQUEST_MS = 2000;
const STALLED_SWEEP_INTERVAL_MS = 500;
const ENV_TRACE_WAIT = process.env.HYPERAGENT_TRACE_WAIT === "1" ||
process.env.HYPERAGENT_TRACE_WAIT === "true";
async function waitForSettledDOM(page, timeoutMs = 10000) {
const ctx = page.context();
const debugOptions = (0, options_1.getDebugOptions)();
const traceWaitFlag = (debugOptions.enabled && debugOptions.traceWait) || ENV_TRACE_WAIT;
const traceWait = traceWaitFlag || !!ctx._options?.recordVideo;
const totalStart = perf_hooks_1.performance.now();
// Currently we only wait for network idle (historical behavior). Hook exists if we add DOM states later.
const lifecycleDuration = 0;
if (traceWait) {
console.log(`[Perf][waitForSettledDOM] lifecycle took ${lifecycleDuration.toFixed(0)}ms`);
}
const cdpClient = await (0, cdp_1.getCDPClient)(page);
const manager = (0, cdp_1.getOrCreateFrameContextManager)(cdpClient);
manager.setDebug(traceWait);
const lifecycleSession = await cdpClient.acquireSession("lifecycle");
const networkStart = perf_hooks_1.performance.now();
const stats = await waitForNetworkIdle(lifecycleSession, {
timeoutMs,
trace: traceWaitFlag,
});
const networkDuration = perf_hooks_1.performance.now() - networkStart;
if (traceWait) {
console.log(`[Perf][waitForSettledDOM] networkidle took ${networkDuration.toFixed(0)}ms (requests=${stats.requestsSeen}, peakInflight=${stats.peakInflight}, reason=${stats.resolvedByTimeout ? "timeout" : "quiet"})`);
const totalDuration = perf_hooks_1.performance.now() - totalStart;
console.log(`[Perf][waitForSettledDOM] total took ${totalDuration.toFixed(0)}ms`);
}
const totalDuration = perf_hooks_1.performance.now() - totalStart;
return {
durationMs: totalDuration,
lifecycleMs: lifecycleDuration,
networkMs: networkDuration,
requestsSeen: stats.requestsSeen,
peakInflight: stats.peakInflight,
resolvedByTimeout: stats.resolvedByTimeout,
forcedDrops: stats.forcedDrops,
};
}
async function waitForNetworkIdle(session, options) {
const { timeoutMs, trace = false } = options;
const inflight = new Set();
let quietTimer = null;
let globalTimeout = null;
const stats = {
requestsSeen: 0,
peakInflight: 0,
resolvedByTimeout: false,
forcedDrops: 0,
};
await new Promise((resolve) => {
const requestMeta = new Map();
let stalledSweepTimer = null;
const maybeResolve = () => {
if (inflight.size === 0 && !quietTimer) {
quietTimer = setTimeout(() => resolveDone(false), NETWORK_IDLE_THRESHOLD_MS);
}
};
const resolveDone = (byTimeout) => {
stats.resolvedByTimeout = byTimeout;
if (quietTimer)
clearTimeout(quietTimer);
if (globalTimeout)
clearTimeout(globalTimeout);
cleanup();
resolve();
};
const cleanup = () => {
if (session.off) {
session.off("Network.requestWillBeSent", onRequestWillBeSent);
session.off("Network.loadingFinished", onLoadingFinished);
session.off("Network.loadingFailed", onLoadingFailed);
}
if (stalledSweepTimer) {
clearInterval(stalledSweepTimer);
stalledSweepTimer = null;
}
};
const onRequestWillBeSent = (event) => {
if (event.type === "WebSocket" || event.type === "EventSource") {
return;
}
inflight.add(event.requestId);
stats.requestsSeen += 1;
if (inflight.size > stats.peakInflight) {
stats.peakInflight = inflight.size;
}
requestMeta.set(event.requestId, {
url: event.request.url,
start: Date.now(),
});
if (quietTimer) {
clearTimeout(quietTimer);
quietTimer = null;
}
};
const onLoadingFinished = (event) => {
finishRequest(event.requestId);
};
const onLoadingFailed = (event) => {
finishRequest(event.requestId);
};
session.on("Network.requestWillBeSent", onRequestWillBeSent);
session.on("Network.loadingFinished", onLoadingFinished);
session.on("Network.loadingFailed", onLoadingFailed);
stalledSweepTimer = setInterval(() => {
if (!requestMeta.size)
return;
const now = Date.now();
for (const [id, meta] of requestMeta.entries()) {
if (now - meta.start > STALLED_REQUEST_MS) {
if (inflight.delete(id)) {
stats.forcedDrops += 1;
if (trace) {
console.warn(`[waitForSettledDOM] Forcing completion of stalled request ${id} (age=${now - meta.start}ms url=${meta.url ?? "unknown"})`);
}
requestMeta.delete(id);
maybeResolve();
}
}
}
}, STALLED_SWEEP_INTERVAL_MS);
globalTimeout = setTimeout(() => resolveDone(true), timeoutMs);
maybeResolve();
function finishRequest(requestId) {
if (inflight.delete(requestId)) {
requestMeta.delete(requestId);
maybeResolve();
}
}
});
return stats;
}