GM_fetch

CORS Bypass script

// ==UserScript==
// @name        GM_fetch
// @author      xihale
// @description CORS Bypass script
// @namespace   xihale.top
// @license     GPL version 3
// @grant       GM_xmlhttpRequest
// @grant       unsafeWindow
// @connect     *
// @match       localhost
// @match       q.xihale.top
// @run-at      document-start
// @version     0.0.2
// ==/UserScript==

// reference: https://github.com/Tampermonkey/tampermonkey/issues/1278#issuecomment-1004568936

;(function () {
  'use strict'

  const PREFIX = '[GMFetch]'
  const nativeFetch =
    typeof unsafeWindow.fetch === 'function' ? unsafeWindow.fetch.bind(unsafeWindow) : null
  const RESPONSE_TYPE = GM_xmlhttpRequest?.RESPONSE_TYPE_STREAM ? 'stream' : 'arraybuffer'

  function toPlainHeaders(headersInit = {}) {
    try {
      return Object.fromEntries(new Headers(headersInit).entries())
    } catch (error) {
      console.warn(`${PREFIX} 无法解析请求头`, headersInit, error)
      return {}
    }
  }

  function parseHeaders(raw) {
    const headers = new Headers()
    if (!raw) {
      return headers
    }
    raw
      .trim()
      .split(/\r?\n/)
      .forEach((line) => {
        if (!line) return
        const index = line.indexOf(':')
        if (index === -1) return
        const key = line.slice(0, index).trim()
        const value = line.slice(index + 1).trim()
        try {
          headers.append(key, value)
        } catch (err) {
          console.warn(`${PREFIX} 无法解析响应头`, line, err)
        }
      })
    return headers
  }

  const toBody = (body, binary = false) => ({ body, binary })
  const emptyBody = () => toBody()
  const binaryBody = (body) => toBody(body, true)

  const matchAsync = async (value, cases, fallback) => {
    for (const [predicate, resolver] of cases) {
      if (await predicate(value)) {
        return resolver(value)
      }
    }
    return fallback(value)
  }

  const isInstance = (Ctor) => (value) => typeof Ctor !== 'undefined' && value instanceof Ctor

  async function normalizeBody(body) {
    return matchAsync(
      body,
      [
        [(value) => value == null, () => emptyBody()],
        [(value) => typeof value === 'string', (value) => toBody(value)],
        [isInstance(URLSearchParams), (value) => toBody(value.toString())],
        [isInstance(FormData), (value) => toBody(value)],
        [isInstance(Blob), async (value) => binaryBody(await value.arrayBuffer())],
        [isInstance(ArrayBuffer), (value) => binaryBody(value)],
        [ArrayBuffer.isView, (value) => binaryBody(value.buffer)],
      ],
      (value) => toBody(value),
    )
  }

  async function resolveBody(request, init = {}) {
    if (init.body !== undefined) {
      return normalizeBody(init.body)
    }

    if (!(request instanceof Request)) {
      return emptyBody()
    }

    const method = request.method?.toUpperCase?.() ?? 'GET'
    if (method === 'GET' || method === 'HEAD') {
      return emptyBody()
    }

    const readers = [
      async () => {
        const text = await request.clone().text()
        return text ? toBody(text) : null
      },
      async () => {
        const buffer = await request.clone().arrayBuffer()
        return buffer && buffer.byteLength > 0 ? binaryBody(buffer) : null
      },
    ]

    for (const read of readers) {
      try {
        const result = await read()
        if (result) {
          return result
        }
      } catch {}
    }

    return emptyBody()
  }

  function buildResponse(gmResponse) {
    const headers = parseHeaders(gmResponse.responseHeaders)
    const status = gmResponse.status || 0
    const init = {
      headers,
      status: status === 0 ? 200 : status,
      statusText: gmResponse.statusText || 'OK',
    }
    if (status === 0) {
      console.warn(`${PREFIX} 接收到状态码 0,自动回退为 200`, gmResponse)
    }
    const body = gmResponse.response ?? null
    return new Response(body, init)
  }

  function toRequest(input, init) {
    if (input instanceof Request) {
      return new Request(input, init)
    }
    return new Request(String(input), init)
  }

  async function gmFetch(input, fetchInit = {}) {
    const request = toRequest(input, fetchInit)
    const requestUrl = request.url

    if (!requestUrl) {
      throw new TypeError('Failed to execute fetch: URL is required')
    }

    if (typeof GM_xmlhttpRequest !== 'function') {
      if (nativeFetch) {
        return nativeFetch(input, fetchInit)
      }
      throw new ReferenceError('GM_xmlhttpRequest is not available')
    }

    const headers = toPlainHeaders(fetchInit.headers ?? request.headers)
    const { body, binary } = await resolveBody(request, fetchInit)

    return new Promise((resolve, reject) => {
      let settled = false

      const settleWith = (factory) => (response) => {
        if (settled) return
        settled = true
        try {
          resolve(factory(response))
        } catch (error) {
          reject(error)
        }
      }

      const finalize = settleWith(buildResponse)

      const config = {
        url: requestUrl,
        method: (fetchInit.method ?? request.method ?? 'GET').toUpperCase(),
        headers,
        data: body,
        binary: Boolean(binary),
        responseType: RESPONSE_TYPE,
        onload: finalize,
        onerror: (error) => {
          if (settled) return
          settled = true
          reject(error?.error || error || new Error('GM_xmlhttpRequest failed'))
        },
      }

      if (RESPONSE_TYPE === 'stream') {
        config.onreadystatechange = (response) => {
          if (response.readyState === 2 && !settled) {
            finalize(response)
          }
        }
      }

      GM_xmlhttpRequest(config)
    })
  }

  Object.defineProperty(gmFetch, 'native', {
    value: nativeFetch,
    writable: false,
    enumerable: false,
    configurable: false,
  })

  unsafeWindow.gm_fetch = gmFetch

  console.info(`${PREFIX} Tampermonkey 通用 CORS 代理已启用`)
})()