File size: 3,539 Bytes
9c380c2
39371ea
 
 
 
9c380c2
39371ea
 
 
9c380c2
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c380c2
39371ea
 
 
 
36a016c
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// just-bash's curl uses this browser transport. There is no proxy/server.
export const HTTP_METHODS = Object.freeze(['GET', 'HEAD', 'POST']);
export const HTTP_LIMIT = 1024 * 1024;
export const HTTP_TIMEOUT = 8_000;

export function createBrowserFetch({ transport = globalThis.fetch,
  timeoutMs = HTTP_TIMEOUT, maxBytes = HTTP_LIMIT } = {}) {
  return async (input, options = {}) => {
    const url = new URL(input);
    if (url.protocol !== 'https:') throw Error('Only HTTPS URLs are supported. Use https:// instead.');
    if (url.username || url.password) throw Error('Credentials in URLs are unsupported. Use an explicit Authorization header if needed.');
    const method = (options.method ?? 'GET').toUpperCase();
    if (!HTTP_METHODS.includes(method)) throw Error(`HTTP method ${method} is not enabled. Use GET, HEAD, or POST.`);
    const requestedTimeout = options.timeoutMs > 0 ? options.timeoutMs : timeoutMs;
    const controller = new AbortController();
    const deadline = Math.min(requestedTimeout, timeoutMs);
    const timer = setTimeout(() => controller.abort(new DOMException(`HTTP request timed out after ${deadline} ms.`, 'TimeoutError')), deadline);
    const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
    let reader;
    try {
      signal.throwIfAborted();
      const response = await transport(url.href, { method, headers: options.headers,
        body: ['GET', 'HEAD'].includes(method) ? undefined : options.body,
        mode: 'cors', credentials: 'omit', redirect: 'manual', referrerPolicy: 'no-referrer', signal });
      // Browsers hide both the Location header and status on manual redirects.
      // Following automatically would skip the HTTPS check at intermediate hops.
      if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400 && response.status !== 304)) {
        throw Error('HTTP redirect cannot be followed safely in this browser shell. Use the final HTTPS URL directly (including exact capitalization).');
      }
      if (response.status === 0 || response.type === 'opaque') throw Error('HTTP response is unreadable. The destination must allow browser access with CORS.');
      if (method !== 'HEAD' && Number(response.headers.get('content-length')) > maxBytes) {
        await response.body?.cancel();
        throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`);
      }
      const chunks = []; let size = 0;
      if (response.body) {
        reader = response.body.getReader();
        while (true) {
          const { done, value } = await reader.read();
          signal.throwIfAborted();
          if (done) break;
          size += value.byteLength;
          if (size > maxBytes) { await reader.cancel(); throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`); }
          chunks.push(value);
        }
      }
      const body = new Uint8Array(size); let offset = 0;
      for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
      return { status: response.status, statusText: response.statusText,
        headers: Object.fromEntries(response.headers.entries()), body, url: response.url || url.href };
    } catch (error) {
      if (signal.aborted) throw signal.reason;
      if (error instanceof TypeError) throw Error('HTTP request failed: the server may block browser requests (CORS), or the connection failed. No proxy is used.');
      throw error;
    } finally { clearTimeout(timer); reader?.releaseLock(); }
  };
}