2021-01-11 12:13:41 -05:00
|
|
|
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
// @ts-check
|
|
|
|
/// <reference path="../../core/lib.deno_core.d.ts" />
|
|
|
|
/// <reference path="../web/internal.d.ts" />
|
2021-04-14 16:49:16 -04:00
|
|
|
/// <reference path="../url/internal.d.ts" />
|
2021-01-28 15:37:21 -05:00
|
|
|
/// <reference path="../web/lib.deno_web.d.ts" />
|
2021-06-14 07:51:02 -04:00
|
|
|
/// <reference path="../web/06_streams_types.d.ts" />
|
2021-01-28 15:37:21 -05:00
|
|
|
/// <reference path="./internal.d.ts" />
|
|
|
|
/// <reference path="./lib.deno_fetch.d.ts" />
|
|
|
|
/// <reference lib="esnext" />
|
2021-02-04 17:18:32 -05:00
|
|
|
"use strict";
|
2021-01-28 15:37:21 -05:00
|
|
|
|
2020-09-18 09:20:55 -04:00
|
|
|
((window) => {
|
|
|
|
const core = window.Deno.core;
|
2021-04-20 08:47:22 -04:00
|
|
|
const webidl = window.__bootstrap.webidl;
|
2021-06-06 09:37:17 -04:00
|
|
|
const { errorReadableStream } = window.__bootstrap.streams;
|
2021-04-20 08:47:22 -04:00
|
|
|
const { InnerBody, extractBody } = window.__bootstrap.fetchBody;
|
|
|
|
const {
|
|
|
|
toInnerRequest,
|
2021-06-06 09:37:17 -04:00
|
|
|
toInnerResponse,
|
2021-04-20 08:47:22 -04:00
|
|
|
fromInnerResponse,
|
|
|
|
redirectStatus,
|
|
|
|
nullBodyStatus,
|
|
|
|
networkError,
|
2021-06-06 09:37:17 -04:00
|
|
|
abortedNetworkError,
|
2021-04-20 08:47:22 -04:00
|
|
|
} = window.__bootstrap.fetch;
|
2021-06-06 09:37:17 -04:00
|
|
|
const abortSignal = window.__bootstrap.abortSignal;
|
2021-04-20 08:47:22 -04:00
|
|
|
|
|
|
|
const REQUEST_BODY_HEADER_NAMES = [
|
|
|
|
"content-encoding",
|
|
|
|
"content-language",
|
|
|
|
"content-location",
|
|
|
|
"content-type",
|
|
|
|
];
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {{ method: string, url: string, headers: [string, string][], clientRid: number | null, hasBody: boolean }} args
|
|
|
|
* @param {Uint8Array | null} body
|
2021-04-20 08:47:22 -04:00
|
|
|
* @returns {{ requestRid: number, requestBodyRid: number | null }}
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2020-09-18 09:20:55 -04:00
|
|
|
function opFetch(args, body) {
|
2021-04-20 08:47:22 -04:00
|
|
|
return core.opSync("op_fetch", args, body);
|
2021-01-10 14:54:29 -05:00
|
|
|
}
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {number} rid
|
2021-04-20 08:47:22 -04:00
|
|
|
* @returns {Promise<{ status: number, statusText: string, headers: [string, string][], url: string, responseRid: number }>}
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2021-04-05 12:40:24 -04:00
|
|
|
function opFetchSend(rid) {
|
2021-04-12 15:55:05 -04:00
|
|
|
return core.opAsync("op_fetch_send", rid);
|
2021-01-10 14:54:29 -05:00
|
|
|
}
|
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {number} rid
|
|
|
|
* @param {Uint8Array} body
|
2021-01-28 15:37:21 -05:00
|
|
|
* @returns {Promise<void>}
|
|
|
|
*/
|
2021-04-05 12:40:24 -04:00
|
|
|
function opFetchRequestWrite(rid, body) {
|
2021-04-20 08:47:22 -04:00
|
|
|
return core.opAsync("op_fetch_request_write", rid, body);
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {number} rid
|
|
|
|
* @param {Uint8Array} body
|
2021-04-20 08:47:22 -04:00
|
|
|
* @returns {Promise<number>}
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2021-04-20 08:47:22 -04:00
|
|
|
function opFetchResponseRead(rid, body) {
|
|
|
|
return core.opAsync("op_fetch_response_read", rid, body);
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-04-12 20:45:57 -04:00
|
|
|
/**
|
2021-04-20 08:47:22 -04:00
|
|
|
* @param {number} responseBodyRid
|
2021-06-06 09:37:17 -04:00
|
|
|
* @param {AbortSignal} [terminator]
|
2021-04-20 08:47:22 -04:00
|
|
|
* @returns {ReadableStream<Uint8Array>}
|
2021-04-12 20:45:57 -04:00
|
|
|
*/
|
2021-06-06 09:37:17 -04:00
|
|
|
function createResponseBodyStream(responseBodyRid, terminator) {
|
|
|
|
function onAbort() {
|
|
|
|
if (readable) {
|
|
|
|
errorReadableStream(
|
|
|
|
readable,
|
|
|
|
new DOMException("Ongoing fetch was aborted.", "AbortError"),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
core.close(responseBodyRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// TODO(lucacasonato): clean up registration
|
|
|
|
terminator[abortSignal.add](onAbort);
|
|
|
|
const readable = new ReadableStream({
|
2021-04-20 08:47:22 -04:00
|
|
|
type: "bytes",
|
|
|
|
async pull(controller) {
|
|
|
|
try {
|
|
|
|
// This is the largest possible size for a single packet on a TLS
|
|
|
|
// stream.
|
|
|
|
const chunk = new Uint8Array(16 * 1024 + 256);
|
|
|
|
const read = await opFetchResponseRead(
|
|
|
|
responseBodyRid,
|
|
|
|
chunk,
|
|
|
|
);
|
|
|
|
if (read > 0) {
|
|
|
|
// We read some data. Enqueue it onto the stream.
|
|
|
|
controller.enqueue(chunk.subarray(0, read));
|
|
|
|
} else {
|
|
|
|
// We have reached the end of the body, so we close the stream.
|
|
|
|
controller.close();
|
2021-06-06 09:37:17 -04:00
|
|
|
try {
|
|
|
|
core.close(responseBodyRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
}
|
|
|
|
} catch (err) {
|
2021-06-06 09:37:17 -04:00
|
|
|
if (terminator.aborted) {
|
|
|
|
controller.error(
|
|
|
|
new DOMException("Ongoing fetch was aborted.", "AbortError"),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// There was an error while reading a chunk of the body, so we
|
|
|
|
// error.
|
|
|
|
controller.error(err);
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
core.close(responseBodyRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
}
|
|
|
|
},
|
|
|
|
cancel() {
|
2021-06-06 09:37:17 -04:00
|
|
|
if (!terminator.aborted) {
|
|
|
|
terminator[abortSignal.signalAbort]();
|
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
},
|
|
|
|
});
|
2021-06-06 09:37:17 -04:00
|
|
|
return readable;
|
2021-04-12 20:45:57 -04:00
|
|
|
}
|
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {InnerRequest} req
|
2021-04-20 08:47:22 -04:00
|
|
|
* @param {boolean} recursive
|
2021-06-06 09:37:17 -04:00
|
|
|
* @param {AbortSignal} terminator
|
2021-04-20 08:47:22 -04:00
|
|
|
* @returns {Promise<InnerResponse>}
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2021-06-06 09:37:17 -04:00
|
|
|
async function mainFetch(req, recursive, terminator) {
|
2021-04-20 08:47:22 -04:00
|
|
|
/** @type {ReadableStream<Uint8Array> | Uint8Array | null} */
|
|
|
|
let reqBody = null;
|
2021-06-18 05:14:14 -04:00
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
if (req.body !== null) {
|
|
|
|
if (req.body.streamOrStatic instanceof ReadableStream) {
|
|
|
|
if (req.body.length === null) {
|
|
|
|
reqBody = req.body.stream;
|
2021-04-12 20:46:33 -04:00
|
|
|
} else {
|
2021-04-20 08:47:22 -04:00
|
|
|
const reader = req.body.stream.getReader();
|
|
|
|
const r1 = await reader.read();
|
|
|
|
if (r1.done) throw new TypeError("Unreachable");
|
|
|
|
reqBody = r1.value;
|
|
|
|
const r2 = await reader.read();
|
|
|
|
if (!r2.done) throw new TypeError("Unreachable");
|
2021-04-12 20:46:33 -04:00
|
|
|
}
|
2020-09-18 09:20:55 -04:00
|
|
|
} else {
|
2021-04-20 08:47:22 -04:00
|
|
|
req.body.streamOrStatic.consumed = true;
|
|
|
|
reqBody = req.body.streamOrStatic.body;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-06 09:37:17 -04:00
|
|
|
const { requestRid, requestBodyRid, cancelHandleRid } = opFetch({
|
2021-04-20 08:47:22 -04:00
|
|
|
method: req.method,
|
|
|
|
url: req.currentUrl(),
|
|
|
|
headers: req.headerList,
|
|
|
|
clientRid: req.clientRid,
|
|
|
|
hasBody: reqBody !== null,
|
|
|
|
}, reqBody instanceof Uint8Array ? reqBody : null);
|
|
|
|
|
2021-06-06 09:37:17 -04:00
|
|
|
function onAbort() {
|
|
|
|
try {
|
|
|
|
core.close(cancelHandleRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
core.close(requestBodyRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
terminator[abortSignal.add](onAbort);
|
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
if (requestBodyRid !== null) {
|
|
|
|
if (reqBody === null || !(reqBody instanceof ReadableStream)) {
|
|
|
|
throw new TypeError("Unreachable");
|
|
|
|
}
|
|
|
|
const reader = reqBody.getReader();
|
|
|
|
(async () => {
|
|
|
|
while (true) {
|
2021-06-06 09:37:17 -04:00
|
|
|
const { value, done } = await reader.read().catch((err) => {
|
|
|
|
if (terminator.aborted) return { done: true, value: undefined };
|
|
|
|
throw err;
|
|
|
|
});
|
2021-04-20 08:47:22 -04:00
|
|
|
if (done) break;
|
|
|
|
if (!(value instanceof Uint8Array)) {
|
|
|
|
await reader.cancel("value not a Uint8Array");
|
|
|
|
break;
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
try {
|
2021-06-06 09:37:17 -04:00
|
|
|
await opFetchRequestWrite(requestBodyRid, value).catch((err) => {
|
|
|
|
if (terminator.aborted) return;
|
|
|
|
throw err;
|
|
|
|
});
|
|
|
|
if (terminator.aborted) break;
|
2021-04-20 08:47:22 -04:00
|
|
|
} catch (err) {
|
|
|
|
await reader.cancel(err);
|
|
|
|
break;
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
}
|
2021-06-06 09:37:17 -04:00
|
|
|
try {
|
|
|
|
core.close(requestBodyRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
})();
|
|
|
|
}
|
|
|
|
|
2021-06-06 09:37:17 -04:00
|
|
|
let resp;
|
|
|
|
try {
|
|
|
|
resp = await opFetchSend(requestRid).catch((err) => {
|
|
|
|
if (terminator.aborted) return;
|
|
|
|
throw err;
|
|
|
|
});
|
|
|
|
} finally {
|
|
|
|
try {
|
|
|
|
core.close(cancelHandleRid);
|
|
|
|
} catch (_) {
|
|
|
|
// might have already been closed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (terminator.aborted) return abortedNetworkError();
|
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
/** @type {InnerResponse} */
|
|
|
|
const response = {
|
|
|
|
headerList: resp.headers,
|
|
|
|
status: resp.status,
|
|
|
|
body: null,
|
|
|
|
statusMessage: resp.statusText,
|
|
|
|
type: "basic",
|
|
|
|
url() {
|
|
|
|
if (this.urlList.length == 0) return null;
|
|
|
|
return this.urlList[this.urlList.length - 1];
|
|
|
|
},
|
|
|
|
urlList: req.urlList,
|
|
|
|
};
|
|
|
|
if (redirectStatus(resp.status)) {
|
|
|
|
switch (req.redirectMode) {
|
|
|
|
case "error":
|
|
|
|
core.close(resp.responseRid);
|
|
|
|
return networkError(
|
|
|
|
"Encountered redirect while redirect mode is set to 'error'",
|
|
|
|
);
|
|
|
|
case "follow":
|
|
|
|
core.close(resp.responseRid);
|
2021-06-06 09:37:17 -04:00
|
|
|
return httpRedirectFetch(req, response, terminator);
|
2021-04-20 08:47:22 -04:00
|
|
|
case "manual":
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (nullBodyStatus(response.status)) {
|
|
|
|
core.close(resp.responseRid);
|
|
|
|
} else {
|
2021-06-18 05:14:14 -04:00
|
|
|
if (req.method === "HEAD" || req.method === "OPTIONS") {
|
|
|
|
response.body = null;
|
|
|
|
core.close(resp.responseRid);
|
|
|
|
} else {
|
|
|
|
response.body = new InnerBody(
|
|
|
|
createResponseBodyStream(resp.responseRid, terminator),
|
|
|
|
);
|
|
|
|
}
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
if (recursive) return response;
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
if (response.urlList.length === 0) {
|
|
|
|
response.urlList = [...req.urlList];
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
return response;
|
2021-01-07 13:06:08 -05:00
|
|
|
}
|
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-20 08:47:22 -04:00
|
|
|
* @param {InnerRequest} request
|
|
|
|
* @param {InnerResponse} response
|
|
|
|
* @returns {Promise<InnerResponse>}
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2021-06-06 09:37:17 -04:00
|
|
|
function httpRedirectFetch(request, response, terminator) {
|
2021-06-18 05:14:14 -04:00
|
|
|
const locationHeaders = response.headerList.filter(
|
|
|
|
(entry) => entry[0] === "location",
|
|
|
|
);
|
2021-04-20 08:47:22 -04:00
|
|
|
if (locationHeaders.length === 0) {
|
|
|
|
return response;
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
const locationURL = new URL(
|
|
|
|
locationHeaders[0][1],
|
|
|
|
response.url() ?? undefined,
|
2021-01-10 14:54:29 -05:00
|
|
|
);
|
2021-04-20 08:47:22 -04:00
|
|
|
if (locationURL.hash === "") {
|
|
|
|
locationURL.hash = request.currentUrl().hash;
|
|
|
|
}
|
|
|
|
if (locationURL.protocol !== "https:" && locationURL.protocol !== "http:") {
|
|
|
|
return networkError("Can not redirect to a non HTTP(s) url");
|
|
|
|
}
|
|
|
|
if (request.redirectCount === 20) {
|
|
|
|
return networkError("Maximum number of redirects (20) reached");
|
|
|
|
}
|
|
|
|
request.redirectCount++;
|
|
|
|
if (
|
2021-06-18 05:14:14 -04:00
|
|
|
response.status !== 303 &&
|
|
|
|
request.body !== null &&
|
2021-04-20 08:47:22 -04:00
|
|
|
request.body.source === null
|
|
|
|
) {
|
|
|
|
return networkError(
|
|
|
|
"Can not redeliver a streaming request body after a redirect",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
((response.status === 301 || response.status === 302) &&
|
|
|
|
request.method === "POST") ||
|
|
|
|
(response.status === 303 &&
|
2021-06-18 05:14:14 -04:00
|
|
|
request.method !== "GET" &&
|
|
|
|
request.method !== "HEAD")
|
2021-04-20 08:47:22 -04:00
|
|
|
) {
|
|
|
|
request.method = "GET";
|
|
|
|
request.body = null;
|
|
|
|
for (let i = 0; i < request.headerList.length; i++) {
|
2021-06-15 10:37:05 -04:00
|
|
|
if (REQUEST_BODY_HEADER_NAMES.includes(request.headerList[i][0])) {
|
2021-04-20 08:47:22 -04:00
|
|
|
request.headerList.splice(i, 1);
|
|
|
|
i--;
|
|
|
|
}
|
2021-01-28 15:37:21 -05:00
|
|
|
}
|
2021-01-10 14:54:29 -05:00
|
|
|
}
|
2021-04-20 08:47:22 -04:00
|
|
|
if (request.body !== null) {
|
|
|
|
const res = extractBody(request.body.source);
|
|
|
|
request.body = res.body;
|
|
|
|
}
|
|
|
|
request.urlList.push(locationURL.href);
|
2021-06-06 09:37:17 -04:00
|
|
|
return mainFetch(request, true, terminator);
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-01-28 15:37:21 -05:00
|
|
|
/**
|
2021-04-28 10:08:51 -04:00
|
|
|
* @param {RequestInfo} input
|
|
|
|
* @param {RequestInit} init
|
2021-01-28 15:37:21 -05:00
|
|
|
*/
|
2021-06-06 09:37:17 -04:00
|
|
|
function fetch(input, init = {}) {
|
2021-04-20 08:47:22 -04:00
|
|
|
// 1.
|
2021-06-06 09:37:17 -04:00
|
|
|
const p = new Promise((resolve, reject) => {
|
|
|
|
const prefix = "Failed to call 'fetch'";
|
|
|
|
webidl.requiredArguments(arguments.length, 1, { prefix });
|
|
|
|
input = webidl.converters["RequestInfo"](input, {
|
|
|
|
prefix,
|
|
|
|
context: "Argument 1",
|
|
|
|
});
|
|
|
|
init = webidl.converters["RequestInit"](init, {
|
|
|
|
prefix,
|
|
|
|
context: "Argument 2",
|
|
|
|
});
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-06-06 09:37:17 -04:00
|
|
|
// 2.
|
|
|
|
const requestObject = new Request(input, init);
|
|
|
|
// 3.
|
|
|
|
const request = toInnerRequest(requestObject);
|
|
|
|
// 4.
|
|
|
|
if (requestObject.signal.aborted) {
|
|
|
|
reject(abortFetch(request, null));
|
|
|
|
return;
|
|
|
|
}
|
2020-09-18 09:20:55 -04:00
|
|
|
|
2021-06-06 09:37:17 -04:00
|
|
|
// 7.
|
|
|
|
let responseObject = null;
|
|
|
|
// 9.
|
|
|
|
let locallyAborted = false;
|
|
|
|
// 10.
|
|
|
|
function onabort() {
|
|
|
|
locallyAborted = true;
|
|
|
|
reject(abortFetch(request, responseObject));
|
|
|
|
}
|
|
|
|
requestObject.signal[abortSignal.add](onabort);
|
|
|
|
|
2021-06-15 10:37:05 -04:00
|
|
|
if (!requestObject.headers.has("accept")) {
|
|
|
|
request.headerList.push(["accept", "*/*"]);
|
2021-06-06 09:37:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// 12.
|
|
|
|
mainFetch(request, false, requestObject.signal).then((response) => {
|
|
|
|
// 12.1.
|
|
|
|
if (locallyAborted) return;
|
|
|
|
// 12.2.
|
|
|
|
if (response.aborted) {
|
|
|
|
reject(request, responseObject);
|
|
|
|
requestObject.signal[abortSignal.remove](onabort);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// 12.3.
|
|
|
|
if (response.type === "error") {
|
|
|
|
const err = new TypeError(
|
|
|
|
"Fetch failed: " + (response.error ?? "unknown error"),
|
|
|
|
);
|
|
|
|
reject(err);
|
|
|
|
requestObject.signal[abortSignal.remove](onabort);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
responseObject = fromInnerResponse(response, "immutable");
|
|
|
|
resolve(responseObject);
|
|
|
|
requestObject.signal[abortSignal.remove](onabort);
|
|
|
|
}).catch((err) => {
|
|
|
|
reject(err);
|
|
|
|
requestObject.signal[abortSignal.remove](onabort);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
return p;
|
|
|
|
}
|
|
|
|
|
|
|
|
function abortFetch(request, responseObject) {
|
|
|
|
const error = new DOMException("Ongoing fetch was aborted.", "AbortError");
|
|
|
|
if (request.body !== null) request.body.cancel(error);
|
|
|
|
if (responseObject !== null) {
|
|
|
|
const response = toInnerResponse(responseObject);
|
|
|
|
if (response.body !== null) response.body.error(error);
|
|
|
|
}
|
|
|
|
return error;
|
2020-09-18 09:20:55 -04:00
|
|
|
}
|
|
|
|
|
2021-04-20 08:47:22 -04:00
|
|
|
window.__bootstrap.fetch ??= {};
|
|
|
|
window.__bootstrap.fetch.fetch = fetch;
|
2020-09-18 09:20:55 -04:00
|
|
|
})(this);
|