Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.1 kB · 172 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173// The one place outbound requests are made. Everything the extension fetches// belongs to somebody else — the page's origin, a stranger's PDS, plc.directory// — so no request may hang forever, retry something that will never succeed, or// read an unbounded body into a service worker that cannot be restarted cleanly.//// Retries are deliberately narrow. A 404 from a well-known probe is the answer,// not a failure, and that is the response almost every origin gives: retrying it// would multiply the extension's traffic across the whole web for nothing. Only// timeouts, transport errors and the statuses that explicitly mean "later"// (408, 425, 429, 5xx) are worth a second attempt.
/** A response that arrived and said no. Never retried past the policy below. */export class HttpError extends Error { constructor( readonly status: number, readonly statusText: string, readonly url: string, ) { super(`${status} ${statusText} for ${url}`) this.name = 'HttpError' }}
export interface RequestOptions { /** Per attempt, not for the whole call; each retry gets a fresh budget. */ timeoutMs?: number /** Extra attempts after the first. 0 disables retrying. */ retries?: number baseDelayMs?: number maxDelayMs?: number /** Hard cap on the body we will read. */ maxBytes?: number /** Caller cancellation; distinct from our own timeout and never retried. */ signal?: AbortSignal}
const DEFAULTS = { timeoutMs: 8_000, retries: 2, baseDelayMs: 300, maxDelayMs: 4_000, maxBytes: 1_000_000,} satisfies Required<Omit<RequestOptions, 'signal'>>
/** * Cheap, stable-by-default profile for the well-known probe, which runs against * every origin the user visits: one short attempt and a single retry, because * the overwhelming majority of these are 404s that we want to spend as little * of the user's bandwidth and the site's attention on as possible. */export const PROBE_OPTIONS: RequestOptions = { timeoutMs: 5_000, retries: 1, maxBytes: 8_192 }
function transientStatus(status: number): boolean { return status === 408 || status === 425 || status === 429 || status >= 500}
/** Full-jitter exponential backoff: spreads retries instead of synchronising them. */export function backoffDelay(attempt: number, base: number, max: number): number { return Math.random() * Math.min(max, base * 2 ** attempt)}
/** `Retry-After` in seconds or as an HTTP date; undefined when absent or absurd. */export function retryAfterDelay(header: string | null, now = Date.now()): number | undefined { if (!header) return undefined const seconds = Number(header.trim()) const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - now if (!Number.isFinite(ms) || ms < 0) return undefined // A server asking us to wait a minute has effectively said no; treat // anything beyond that as non-retryable rather than stalling a page check. return ms <= 60_000 ? ms : undefined}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** True once the browser is sure it is offline; retries cannot help then. */function definitelyOffline(): boolean { return typeof navigator !== 'undefined' && navigator.onLine === false}
/** * Read at most `maxBytes` of a response body. A hostile or broken origin can * otherwise stream indefinitely into the worker; content-length is checked * first as a courtesy and the stream is counted regardless, since the header * is a claim rather than a guarantee. */export async function readCapped(res: Response, maxBytes: number): Promise<string> { const declared = Number(res.headers.get('content-length')) if (Number.isFinite(declared) && declared > maxBytes) { throw new Error(`response from ${res.url} declares ${declared} bytes, over the ${maxBytes} cap`) } if (!res.body) return (await res.text()).slice(0, maxBytes)
const reader = res.body.getReader() const chunks: Uint8Array[] = [] let total = 0 try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total > maxBytes) { throw new Error(`response from ${res.url} exceeded the ${maxBytes} byte cap`) } chunks.push(value) } } finally { await reader.cancel().catch(() => {}) } return new TextDecoder().decode( chunks.reduce<Uint8Array>((acc, c) => { const out = new Uint8Array(acc.length + c.length) out.set(acc) out.set(c, acc.length) return out }, new Uint8Array()), )}
/** * Fetch with a per-attempt timeout and narrow retries. `credentials: 'omit'` is * explicit rather than inherited: these requests go to third parties, and the * privacy policy's claim that we send nothing of the user's should be visible * in the code that sends them. */export async function request(url: string, options: RequestOptions = {}): Promise<Response> { const { timeoutMs, retries, baseDelayMs, maxDelayMs } = { ...DEFAULTS, ...options }
let lastError: unknown for (let attempt = 0; ; attempt++) { // AbortSignal.timeout is Chrome 103; AbortSignal.any is Chrome 116, above // the manifest's minimum_chrome_version. Nothing passes `signal` today, so // that branch is unreachable and the floor stays where it is — but the // first caller to pass one makes 116 the real minimum. Raise // minimum_chrome_version then, or combine the signals by hand here. const timeout = AbortSignal.timeout(timeoutMs) const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout // Decided rather than thrown: a `throw` here would be caught by this same // try's own handler, which cannot tell giving up from failing. let delay: number | undefined let retryable: boolean
try { const res = await fetch(url, { credentials: 'omit', redirect: 'follow', signal }) if (res.ok) return res lastError = new HttpError(res.status, res.statusText, url) const retryAfter = res.headers.get('retry-after') delay = retryAfterDelay(retryAfter) // A Retry-After we are unwilling to honour ends the call: the server // named its terms and they are worse for the user than failing now. retryable = transientStatus(res.status) && !(retryAfter !== null && delay === undefined) } catch (err) { // The caller gave up, or asked us to; never retry either. if (options.signal?.aborted) throw err lastError = err retryable = !definitelyOffline() }
if (!retryable || attempt >= retries) break await sleep(delay ?? backoffDelay(attempt, baseDelayMs, maxDelayMs)) console.debug('[substandard] retrying', url, `attempt ${attempt + 2}/${retries + 1}`) } throw lastError}
export async function requestText(url: string, options: RequestOptions = {}): Promise<string> { const res = await request(url, options) return readCapped(res, options.maxBytes ?? DEFAULTS.maxBytes)}
export async function requestJson<T>(url: string, options: RequestOptions = {}): Promise<T> { return JSON.parse(await requestText(url, options)) as T}