Propagate web-search cancellation
This commit is contained in:
@@ -12,61 +12,69 @@ function responseHeaders(headers) {
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function fetchBrowserResource(url, timeoutMs = HTTP_TIMEOUT) {
|
||||
export async function fetchBrowserResource(url, timeoutMs = HTTP_TIMEOUT, signal) {
|
||||
signal?.throwIfAborted();
|
||||
await assertSafeUrl(url);
|
||||
return browserPool.withBrowser(async browser => {
|
||||
const page = await browser.newPage();
|
||||
const devtools = await page.target().createCDPSession();
|
||||
await devtools.send("Network.enable");
|
||||
await devtools.send("Network.setBlockedURLs", {
|
||||
urls: ["ws://*", "wss://*", "file://*", "ftp://*"]
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const blockedTransport = name => class {
|
||||
constructor() {
|
||||
throw new DOMException(`${name} is disabled by the safe browser fetcher`, "SecurityError");
|
||||
}
|
||||
};
|
||||
for (const name of ["WebSocket", "WebTransport", "RTCPeerConnection", "webkitRTCPeerConnection"]) {
|
||||
if (name in globalThis) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
value: blockedTransport(name)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
await page.setBypassServiceWorker(true);
|
||||
let requests = 0;
|
||||
let totalBytes = 0;
|
||||
let blockedError;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", request => {
|
||||
void (async () => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url());
|
||||
if (!["http:", "https:"].includes(requestUrl.protocol)) throw new Error("unsupported browser request scheme");
|
||||
if (request.method() !== "GET") throw new Error("browser fetch blocks non-GET requests");
|
||||
requests += 1;
|
||||
if (requests > MAX_BROWSER_REQUESTS) throw new Error("browser request limit exceeded");
|
||||
await assertSafeUrl(requestUrl);
|
||||
const upstream = await fetchViaVettedAddress(requestUrl, timeoutMs);
|
||||
const body = Buffer.from(await upstream.arrayBuffer());
|
||||
totalBytes += body.byteLength;
|
||||
if (totalBytes > MAX_BROWSER_TOTAL_BYTES) throw new Error("browser byte limit exceeded");
|
||||
await request.respond({
|
||||
status: upstream.status,
|
||||
headers: responseHeaders(upstream.headers),
|
||||
body
|
||||
});
|
||||
} catch (error) {
|
||||
blockedError ||= error;
|
||||
await request.abort("blockedbyclient").catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
});
|
||||
const pendingRequests = new Set();
|
||||
const abort = () => void page.close().catch(() => undefined);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const devtools = await page.target().createCDPSession();
|
||||
await devtools.send("Network.enable");
|
||||
await devtools.send("Network.setBlockedURLs", {
|
||||
urls: ["ws://*", "wss://*", "file://*", "ftp://*"]
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const blockedTransport = name => class {
|
||||
constructor() {
|
||||
throw new DOMException(`${name} is disabled by the safe browser fetcher`, "SecurityError");
|
||||
}
|
||||
};
|
||||
for (const name of ["WebSocket", "WebTransport", "RTCPeerConnection", "webkitRTCPeerConnection"]) {
|
||||
if (name in globalThis) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
value: blockedTransport(name)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
await page.setBypassServiceWorker(true);
|
||||
let requests = 0;
|
||||
let totalBytes = 0;
|
||||
let blockedError;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", request => {
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url());
|
||||
if (!["http:", "https:"].includes(requestUrl.protocol)) throw new Error("unsupported browser request scheme");
|
||||
if (request.method() !== "GET") throw new Error("browser fetch blocks non-GET requests");
|
||||
requests += 1;
|
||||
if (requests > MAX_BROWSER_REQUESTS) throw new Error("browser request limit exceeded");
|
||||
await assertSafeUrl(requestUrl);
|
||||
const upstream = await fetchViaVettedAddress(requestUrl, timeoutMs, signal);
|
||||
const body = Buffer.from(await upstream.arrayBuffer());
|
||||
totalBytes += body.byteLength;
|
||||
if (totalBytes > MAX_BROWSER_TOTAL_BYTES) throw new Error("browser byte limit exceeded");
|
||||
await request.respond({
|
||||
status: upstream.status,
|
||||
headers: responseHeaders(upstream.headers),
|
||||
body
|
||||
});
|
||||
} catch (error) {
|
||||
blockedError ||= error;
|
||||
await request.abort("blockedbyclient").catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
pendingRequests.add(pending);
|
||||
void pending.finally(() => pendingRequests.delete(pending));
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const navigation = await page.goto(url.toString(), {
|
||||
waitUntil: "networkidle2",
|
||||
timeout: timeoutMs
|
||||
@@ -88,8 +96,13 @@ export async function fetchBrowserResource(url, timeoutMs = HTTP_TIMEOUT) {
|
||||
buffer,
|
||||
byteLength: buffer.byteLength
|
||||
};
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (!page.isClosed()) await page.close();
|
||||
await Promise.allSettled(pendingRequests);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,24 +17,22 @@ export async function attemptProvider(provider, query, limit, lang, options = {}
|
||||
const timeoutMs = Math.max(10, Math.min(options.timeoutMs || DEFAULT_TIMEOUT_MS, 60_000));
|
||||
const now = options.now || (() => performance.now());
|
||||
const started = now();
|
||||
options.signal?.throwIfAborted();
|
||||
if (provider.configured === false) {
|
||||
return {
|
||||
items: [],
|
||||
diagnostic: { provider: provider.name, status: "unavailable", duration_ms: 0, result_count: 0 }
|
||||
};
|
||||
}
|
||||
let timer;
|
||||
const controller = new AbortController();
|
||||
const signal = options.signal
|
||||
? AbortSignal.any([options.signal, controller.signal])
|
||||
: controller.signal;
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new Error(`provider timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const items = await Promise.race([
|
||||
provider.search(query, limit, lang, controller.signal),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`provider timed out after ${timeoutMs}ms`));
|
||||
reject(new Error(`provider timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
const items = await provider.search(query, limit, lang, signal);
|
||||
const bounded = Array.isArray(items) ? items.slice(0, limit) : [];
|
||||
return {
|
||||
items: bounded,
|
||||
@@ -46,6 +44,7 @@ export async function attemptProvider(provider, query, limit, lang, options = {}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted();
|
||||
return {
|
||||
items: [],
|
||||
diagnostic: {
|
||||
@@ -57,7 +56,6 @@ export async function attemptProvider(provider, query, limit, lang, options = {}
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,19 @@ export class ProviderRegistry {
|
||||
return this.providers.get(name);
|
||||
}
|
||||
|
||||
async searchWithFallback(q, limit, lang, preferredProvider) {
|
||||
async searchWithFallback(q, limit, lang, preferredProvider, signal) {
|
||||
const defaultProvider = preferredProvider || DEFAULT_SEARCH_PROVIDER;
|
||||
const order = [defaultProvider, ...PROVIDERS.filter(name => name !== defaultProvider)].slice(0, MAX_PROVIDER_ATTEMPTS);
|
||||
const attempts = [];
|
||||
const started = performance.now();
|
||||
for (const providerName of order) {
|
||||
signal?.throwIfAborted();
|
||||
const provider = this.providers.get(providerName);
|
||||
if (!provider) continue;
|
||||
const attempt = await attemptProvider(provider, q, limit, lang, { timeoutMs: PROVIDER_TIMEOUT_MS });
|
||||
const attempt = await attemptProvider(provider, q, limit, lang, {
|
||||
timeoutMs: PROVIDER_TIMEOUT_MS,
|
||||
signal
|
||||
});
|
||||
attempts.push(attempt.diagnostic);
|
||||
if (attempt.items.length) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user