|
1 | 1 | import { fetch as secretStreamFetchOrig } from 'secret-stream-http' |
2 | 2 |
|
3 | | -import { errors } from './errors.js' |
4 | | -import { isArrayReadonly } from './utils.js' |
| 3 | +import { noop } from './utils.js' |
5 | 4 |
|
6 | 5 | const CONNECTION_TIMEOUT_MS = 5000 // 5s |
7 | 6 |
|
| 7 | +type FetchFn = typeof secretStreamFetchOrig |
| 8 | +type FetchInput = Parameters<FetchFn>[0] |
| 9 | +type FetchInit = NonNullable<Parameters<FetchFn>[1]> |
| 10 | + |
| 11 | +type AnyFetchInit = FetchInit & { |
| 12 | + /** |
| 13 | + * Underlying fetch implementation. Defaults to secret-stream-http's fetch. |
| 14 | + * Useful for overriding in tests. |
| 15 | + */ |
| 16 | + fetch?: FetchFn |
| 17 | + /** |
| 18 | + * Connection timeout in milliseconds. This is a timeout for establishing the |
| 19 | + * connection only, not for the entire request. If the connection is not |
| 20 | + * established within this time, the request will be aborted and treated as a |
| 21 | + * connection failure. Default is 5000ms (5s), which should be plenty of time |
| 22 | + * for a local network request |
| 23 | + */ |
| 24 | + timeoutMs?: number |
| 25 | +} |
| 26 | + |
8 | 27 | /** |
9 | | - * A wrapper around secret-stream-http's fetch that tries multiple URLs until one works. |
10 | | - * This is useful when the server has multiple IPs for different network interfaces. |
| 28 | + * A fetch-compatible wrapper that accepts an array of URLs that are first |
| 29 | + * probed in parallel with OPTIONS requests. The real request is then sent to |
| 30 | + * each URL in series — starting with whichever probe responded first — until |
| 31 | + * one succeeds. The probe step is what makes this safe against stateful |
| 32 | + * endpoints: only one real request ever hits the server per call. |
| 33 | + * |
| 34 | + * Adds a per-request connection timeout, set by `init.timeoutMs` and defaults |
| 35 | + * to 5000ms (5s). |
| 36 | + * |
| 37 | + * The `init.fetch` option overrides the underlying fetch implementation |
| 38 | + * and defaults to secret-stream-http's fetch. |
11 | 39 | */ |
12 | | -export async function secretStreamFetch( |
13 | | - urls: string | URL | readonly [string | URL, ...Array<string | URL>], |
14 | | - options: Parameters<typeof secretStreamFetchOrig>[1], |
15 | | -) { |
16 | | - if (!isArrayReadonly(urls)) { |
17 | | - urls = [urls] |
| 40 | +export async function anyFetch( |
| 41 | + inputs: readonly [FetchInput, ...FetchInput[]], |
| 42 | + init?: AnyFetchInit, |
| 43 | +): Promise<Response> { |
| 44 | + if (inputs.length === 1) { |
| 45 | + return await timeoutFetch(inputs[0], init) |
18 | 46 | } |
19 | | - let response: Response | undefined |
20 | | - let error: unknown |
21 | 47 |
|
22 | | - // The server could have multiple IPs for different network interfaces, and |
23 | | - // not all of them may be on the same network as us, so try each URL until |
24 | | - // one works |
25 | | - for (const url of urls) { |
26 | | - const controller = new AbortController() |
27 | | - const timeout = setTimeout(() => { |
28 | | - controller.abort() |
29 | | - }, CONNECTION_TIMEOUT_MS) |
30 | | - const signal = options?.signal |
31 | | - ? AbortSignal.any([options.signal, controller.signal]) |
32 | | - : controller.signal |
| 48 | + let winningInput: FetchInput |
| 49 | + try { |
| 50 | + winningInput = await raceProbes(inputs, init) |
| 51 | + } catch (err) { |
| 52 | + // If the caller aborted mid-probe, propagate that as an AbortError |
| 53 | + // (via signal.reason) rather than the probe's AggregateError — the |
| 54 | + // caller needs to distinguish "I cancelled this" from "nothing was |
| 55 | + // reachable". |
| 56 | + init?.signal?.throwIfAborted() |
| 57 | + throw err |
| 58 | + } |
| 59 | + const orderedInputs = [ |
| 60 | + winningInput, |
| 61 | + ...inputs.filter((i) => i !== winningInput), |
| 62 | + ] |
| 63 | + |
| 64 | + const errors: unknown[] = [] |
| 65 | + for (const input of orderedInputs) { |
33 | 66 | try { |
34 | | - response = (await secretStreamFetchOrig(url, { |
35 | | - ...options, |
36 | | - signal, |
37 | | - })) as unknown as Response // Subtle difference bewteen Undici fetch Response and whatwg Response |
38 | | - break // Exit loop on successful fetch |
| 67 | + return await timeoutFetch(input, init) |
39 | 68 | } catch (err) { |
40 | | - error = err |
41 | | - // Ignore errors and try the next URL |
42 | | - } finally { |
43 | | - clearTimeout(timeout) |
| 69 | + // If the caller aborted mid-fetch, propagate that rather than our |
| 70 | + // internal connection-timeout abort (or whatever lower-level error). |
| 71 | + init?.signal?.throwIfAborted() |
| 72 | + errors.push(err) |
44 | 73 | } |
45 | 74 | } |
46 | | - if (!response) { |
47 | | - throw new errors.DOWNLOAD_ERROR({ |
48 | | - message: 'Could not connect to map share sender', |
49 | | - urls, |
50 | | - cause: error, |
51 | | - }) |
| 75 | + throw new AggregateError(errors, 'All fetch attempts failed') |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Fetch a single URL with a connection timeout (different than calling WHAT-WG |
| 80 | + * fetch with a timeout signal, which applies to the entire request, not just |
| 81 | + * connection establishment). |
| 82 | + */ |
| 83 | +async function timeoutFetch( |
| 84 | + url: FetchInput, |
| 85 | + { |
| 86 | + fetch: innerFetch = secretStreamFetchOrig, |
| 87 | + timeoutMs = CONNECTION_TIMEOUT_MS, |
| 88 | + ...fetchInit |
| 89 | + }: AnyFetchInit = {}, |
| 90 | +): Promise<Response> { |
| 91 | + const controller = new AbortController() |
| 92 | + const timeout = setTimeout(() => { |
| 93 | + controller.abort(new DOMException('Connection timed out', 'TimeoutError')) |
| 94 | + }, timeoutMs) |
| 95 | + const signal = fetchInit.signal |
| 96 | + ? AbortSignal.any([fetchInit.signal, controller.signal]) |
| 97 | + : controller.signal |
| 98 | + try { |
| 99 | + return (await innerFetch(url, { |
| 100 | + ...fetchInit, |
| 101 | + signal, |
| 102 | + })) as unknown as Response // Subtle difference between Undici fetch Response and whatwg Response |
| 103 | + } finally { |
| 104 | + clearTimeout(timeout) |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * Probe URLs in parallel with OPTIONS requests. Returns the "winning" URL. |
| 110 | + * Throws AggregateError if no probe responds before the connection timeout or |
| 111 | + * if all probes fail with a network error. |
| 112 | + */ |
| 113 | +async function raceProbes( |
| 114 | + inputs: readonly FetchInput[], |
| 115 | + fetchInit: FetchInit = {}, |
| 116 | +): Promise<FetchInput> { |
| 117 | + const controllers: AbortController[] = [] |
| 118 | + const probePromises: Promise<FetchInput>[] = [] |
| 119 | + let hasWinner = false |
| 120 | + |
| 121 | + for (const url of inputs) { |
| 122 | + const controller = new AbortController() |
| 123 | + const signal = fetchInit.signal |
| 124 | + ? AbortSignal.any([fetchInit.signal, controller.signal]) |
| 125 | + : controller.signal |
| 126 | + controllers.push(controller) |
| 127 | + const probePromise = (async () => { |
| 128 | + const response = await timeoutFetch(url, { |
| 129 | + ...fetchInit, |
| 130 | + method: 'OPTIONS', |
| 131 | + body: undefined, |
| 132 | + signal, |
| 133 | + }) |
| 134 | + response.body?.cancel().catch(noop) // We don't care about the response body, and we want to free resources as soon as possible |
| 135 | + if (hasWinner) { |
| 136 | + throw new DOMException('Aborted by winner', 'AbortError') |
| 137 | + } |
| 138 | + hasWinner = true |
| 139 | + for (const c of controllers) { |
| 140 | + if (c !== controller) { |
| 141 | + c.abort(new DOMException('Aborted by winner', 'AbortError')) |
| 142 | + } |
| 143 | + } |
| 144 | + return url |
| 145 | + })() |
| 146 | + probePromises.push(probePromise) |
52 | 147 | } |
53 | | - return response |
| 148 | + |
| 149 | + return Promise.any(probePromises) |
54 | 150 | } |
0 commit comments