|
(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) |