Skip to content

Instantly share code, notes, and snippets.

@cxmeel
Last active April 19, 2024 18:52
Show Gist options
  • Save cxmeel/d4f96eaac2de81f4b2821a495a0635cd to your computer and use it in GitHub Desktop.
Save cxmeel/d4f96eaac2de81f4b2821a495a0635cd to your computer and use it in GitHub Desktop.

A custom fetch/GM_xmlhttpRequest wrapper for UserScripts.

Fetch Options

Extends the default fetch options. If using GM_xmlhttpRequest (useFetch: false), the timeout is 60 seconds.

type CustomFetchOptions = {
  cache: (boolean | number)?, -- Defaults to `true` on "GET" and "HEAD" requests if "GM_getValue", "GM_setValue" and "GM_deleteValue" are `@grant`ed
  cachePrefix: string?, -- Cached values are stored as `@refetch/${prefix ? `${prefix}/` : ""}/${btoa(`${method}:${href}`)}`
  useFetch: boolean?, -- If `false`, will use `GM_xmlhttpRequest` internally. Will throw if `GM_xmlhttpRequest` is not `@grant`ed
  resolve2xxOnly: boolean?, -- Throws an error if the response status code is not in the [200,299] range
}

Response Object

Returns a custom response object. The response is the same regardless of whether fetch or GM_xmlhttpRequest was used.

type CustomFetchResponse = {
  status: number,
  statusText: string,
  redirected: boolean,
  headers: Headers,
  url: string,
  ok: boolean,
  
  json: async () => object, -- will perform `JSON.parse` on body
  text: async () => string, -- assumes body is already plaintext; i.e. returns body as-is
  html: async () => Document, -- will use a DOMParser on body
  xml: async () => Document, -- will use a DOMParser on body
  blob: async () => Blob, -- assumes body is already a blob; i.e. returns body as-is
}

Instances

You can use refetch.build to populate with default request options.

const someApi = refetch.build({
  baseUrl: "https://api.example.com/v1",
  headers: {
    "x-csrf-token": "aabbccddeeff0123456789",
  },
})

const users = await someApi("/users", {
  method: "GET",
})
(function(namespace) {
const CACHE_METHODS = ["GET", "HEAD"]
function createResponseObject({ headers, redirected, status, statusText, url, body }) {
return {
status,
statusText,
redirected,
url,
headers: headers instanceof Headers ? headers : new Headers(headers),
ok: status >= 200 && status < 300,
json: async () => JSON.parse(body),
text: async () => body,
blob: async () => body,
html: async () => new DOMParser().parseFromString(body, "text/html"),
xml: async () => new DOMParser().parseFromString(body, "text/xml"),
}
}
function userscriptPolyfill(url, options = {}) {
if (typeof(globalThis["GM_xmlhttpRequest"]) !== "function") {
throw new Error("Refetch requires the `GM_xmlhttpRequest` permission.")
}
const requestUrl = new URL(url)
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url,
method: options.method ?? "GET",
headers: options.headers,
responseType: "text",
data: options.body,
anonymous: options.credentials === "omit",
timeout: 1000 * 60,
onabort: () => reject("Request aborted"),
onerror: (err) => reject(err),
onload: (response) => {
const responseObject = createResponseObject({
url: response.finalUrl,
status: response.status,
statusText: response.statusText,
redirected: response.finalUrl != requestUrl.href,
headers: response.responseHeaders.split("\n").reduce((acc, pair) => {
const [key, ...values] = pair.split(":").map((value) => value.trim())
if (key.length) {
acc[key.toLowerCase()] = values.join(":")
}
return acc
}, {}),
body: response.responseText,
})
if (!responseObject.ok) {
return reject(responseObject)
}
resolve(responseObject)
},
})
})
}
async function refetch(path = "/", options = {}) {
const cacheAbilityValidation = { enabled: true, reason: "" }
for (const requiredMethod of ["GM_getValue", "GM_setValue", "GM_deleteValue"]) {
if (typeof(globalThis[requiredMethod]) !== "function") {
cacheAbilityValidation.enabled = false
cacheAbilityValidation.reason = requiredMethod
break
}
}
const targetUrl = path instanceof URL ? path : new URL(path, path.match(/^https?:\/\//i) ? undefined : location.origin)
const now = Date.now()
const method = options.method?.toUpperCase() ?? "GET"
const cacheHash = `@refetch/${options.cachePrefix ? `${options.cachePrefix}/` : ""}${btoa(`${method}:${targetUrl.href}`)}`
const cachedValue = cacheAbilityValidation.enabled === true ? GM_getValue(cacheHash) : undefined
options.cache = options.cache ?? CACHE_METHODS.includes(method)
options.credentials = options.credentials ?? "include"
options.resolve2xxOnly = options.resolve2xxOnly ?? true
options.useFetch = options.useFetch ?? namespace?.refetch?.useFetch ?? true
if (cacheAbilityValidation.enabled === false && options.cache !== false) {
console.warn(`Caching disabled for \`refetch\`: Requires the \`${cacheAbilityValidation.reason}\` permission`)
options.cache = false
}
if (cacheAbilityValidation.enabled === true && options.cache !== false) {
if (cachedValue?.expiry > now) {
return createResponseObject({
...cachedValue.response,
body: cachedValue.value,
})
} else {
GM_deleteValue(cacheHash)
}
}
const requestOptions = {
...options,
cache: undefined,
cachePrefix: undefined,
useFetch: undefined,
resolve2xxOnly: undefined,
}
let response
try {
response = options.useFetch
? await fetch(targetUrl, requestOptions)
: await userscriptPolyfill(targetUrl, requestOptions)
} catch (err) {
if (options.resolve2xxOnly === true) {
return err
}
}
if (response?.ok === false && options.resolve2xxOnly === true) {
throw response
}
const clonedResponse = response?.clone ? response.clone() : { ...response }
const responseBody = await clonedResponse.text()
if (!(clonedResponse?.headers instanceof Map)) {
clonedResponse.headers = new Map(Object.entries(clonedResponse.headers))
}
const cacheHeader = clonedResponse.headers.get("cache-control")
if (cacheAbilityValidation.enabled && cacheHeader && options.cache && clonedResponse.ok) {
const cacheValues = cacheHeader.split(",").reduce((accumulator, rawValue) => {
const [key, value] = rawValue.trim().split("=")
accumulator[key.replace(/-(\w)/g, (_, c) => c.toUpperCase())] = value ?? true
return accumulator
})
if (cacheValues.noStore || cacheValues.noCache || cacheValues.mustRevalidate || cacheValues.maxAge == "0") {
options.cache = false
}
}
if (cacheAbilityValidation.enabled === true && options.cache !== false && clonedResponse.ok === true) {
GM_setValue(cacheHash, {
expiry: now + (typeof(options.cache) === "number" ? options.cache : 10000),
value: responseBody,
response: {
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
url: clonedResponse.url,
},
})
}
return createResponseObject({
...response,
body: responseBody,
})
}
refetch.build = (defaultOptions = {}) => {
return (path = "/", options = {}) => {
const targetUrl = defaultOptions.baseUrl ? new URL(path, defaultOptions.baseUrl) : path
return refetch(targetUrl, {
...defaultOptions,
...options,
headers: {
...defaultOptions?.headers,
...options?.headers,
},
baseUrl: undefined,
})
}
}
namespace.refetch = refetch
})(globalThis ?? window)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment