repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/web/fetch.ts
Raw// HTTP fetch helper — returns body as Buffer + content-type for routing
import type { FetchResult } from "./constants.js";
import {
BROWSER_HEADERS,
DEFAULT_MAX_BYTES,
DEFAULT_TIMEOUT_MS,
} from "./constants.js";
import {
assertSafeHttpUrl,
type DnsResolver,
resolveRedirectUrl,
} from "./safety.js";
export interface FetchUrlOptions {
maxBytes?: number;
maxRedirects?: number;
timeoutMs?: number;
resolver?: DnsResolver;
}
export async function fetchUrlStream(
url: string,
onProgress?: (loaded: number, total: number) => void,
signal?: AbortSignal,
options: FetchUrlOptions = {},
): Promise<FetchResult> {
let safe = await assertSafeHttpUrl(url, { resolver: options.resolver });
const maxRedirects = options.maxRedirects ?? 5;
const timeoutSignal =
signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchOnce(safe.normalizedUrl, timeoutSignal);
if (!isRedirect(response.status)) {
return readResponse(
response,
safe.normalizedUrl,
onProgress,
options.maxBytes ?? DEFAULT_MAX_BYTES,
);
}
const location = response.headers.get("location");
await response.body?.cancel().catch(() => {});
if (!location)
throw new Error(`HTTP ${response.status}: redirect missing location`);
if (redirects === maxRedirects)
throw new Error(`Too many redirects (${maxRedirects})`);
safe = await resolveRedirectUrl(safe.normalizedUrl, location, {
resolver: options.resolver,
});
}
throw new Error(`Too many redirects (${maxRedirects})`);
}
async function fetchOnce(url: string, signal: AbortSignal): Promise<Response> {
const parsed = new URL(url);
return await fetch(url, {
redirect: "manual",
headers: {
...BROWSER_HEADERS,
Referer: `${parsed.protocol}//${parsed.host}/`,
},
signal,
});
}
async function readResponse(
response: Response,
finalUrl: string,
onProgress: ((loaded: number, total: number) => void) | undefined,
maxBytes: number,
): Promise<FetchResult> {
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
if (!response.body) throw new Error("No response body");
const contentType = response.headers.get("content-type") ?? "";
const contentLength =
Number.parseInt(response.headers.get("content-length") ?? "0", 10) || 0;
if (contentLength > maxBytes) {
throw new Error(
`Response exceeds maxBytes (${contentLength} > ${maxBytes})`,
);
}
const reader = response.body.getReader();
let loaded = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
if (loaded > maxBytes) {
await reader.cancel().catch(() => {});
throw new Error(`Response exceeds maxBytes (${loaded} > ${maxBytes})`);
}
chunks.push(value);
onProgress?.(loaded, contentLength);
}
return {
body: Buffer.concat(chunks),
contentType,
contentLength,
status: response.status,
finalUrl,
};
}
function isRedirect(status: number): boolean {
return status >= 300 && status < 400;
}
// Search pages use manual redirects so challenges cannot redirect into private networks.
export async function fetchSearchHtml(
url: string,
signal?: AbortSignal,
timeout?: number,
request: Pick<RequestInit, "method" | "headers" | "body"> = {},
): Promise<string> {
signal?.throwIfAborted();
await assertSafeHttpUrl(url);
const deadline = AbortSignal.timeout(timeout ?? DEFAULT_TIMEOUT_MS);
const response = await fetch(url, {
...request,
headers: request.headers ?? BROWSER_HEADERS,
redirect: "manual",
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
});
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.text();
}