fetch.js


import { call, isFunction, isObject, isString, trace } from "./app"

/**
 * Global object to customize {@link fetch} and {@link afetch}
 * @example <caption>make POST default method</caption>
 * fetchOptions.method = "POST"
 * await afetch("url.com")
 */
export var fetchOptions = {
    method: "GET",
    cache: "default",
    headers: {},
};

function parseOptions(url, options)
{
    const headers = options?.headers || {};
    const opts = Object.assign({
        headers,
        method: options?.method || options?.post && "POST" || undefined,
    }, options?.request);

    for (const p in fetchOptions.headers) {
        headers[p] ??= fetchOptions.headers[p];
    }
    for (const p of ["method","cache","credentials","duplex","integrity","keepalive","mode","priority","redirect","referrer","referrerPolicy","signal"]) {
        if (fetchOptions[p] !== undefined) {
            opts[p] ??= fetchOptions[p];
        }
    }
    var body = options?.body;
    if (opts.method == "GET" || opts.method == "HEAD") {
        if (isObject(body)) {
            url += "?" + new URLSearchParams(body).toString();
        }
    } else
    if (isString(body)) {
        opts.body = body;
        headers["content-type"] ??= 'application/x-www-form-urlencoded; charset=UTF-8';
    } else
    if (body instanceof FormData) {
        opts.body = body;
        delete headers["content-type"];
    } else
    if (isObject(body)) {
        opts.body = JSON.stringify(body);
        headers["content-type"] = "application/json; charset=UTF-8";
    } else
    if (body) {
        opts.body = body;
        headers["content-type"] ??= "application/octet-stream";
    }
    return [url, opts];
}


function parseResponse(res)
{
    const info = { status: res.status, headers: {}, type: res.type, url: res.url, redirected: res.redirected };
    for (const h of res.headers) {
        info.headers[h[0].toLowerCase()] = h[1];
    }
    const h_csrf = fetchOptions.csrfHeader || "x-csrf-token";
    const v_csrf = info?.headers[h_csrf];
    if (v_csrf) {
        if (v_csrf <= 0) {
            delete fetchOptions.headers[h_csrf];
        } else {
            fetchOptions.headers[h_csrf] = v_csrf;
        }
    }
    return info;
}

/**
 * Fetch remote content, wrapper around Fetch API
 *
 * __NOTE: Saves X-CSRF-Token header and sends it back with subsequent requests__
 * @param {string} url - URL to fetch
 * @param {object} [options]
 * @param {string} [options.method] - GET, POST,...GET is default or from app.fetchOptions.method
 * @param {boolean} [options.post] - set method to POST
 * @param {string|object|FormData} [options.body] - a body accepted by window.fetch
 * @param {string} [options.dataType] - explicit return type: text, blob, default is auto detected between text or json
 * @param {object} [options.headers] - an object with additional headers to send, all global headers from app.fetchOptions.headers also are merged
 * @param {object} [options.request] - properties to pass to fetch options according to Web API `RequestInit`
 * @param {function} [callback] - callback as (err, data, info) where info is an object { status, headers, type }
 *
 * @example
 * fetch("http://api.host.com/user/123", (err, data, info) => {
 *    if (info.status == 200) console.log(data, info);
 * });
 */

export function fetch(url, options, callback)
{
    if (isFunction(options)) callback = options, options = null;

    try {
        const [uri, opts] = parseOptions(url, options);
        trace("fetch:", uri, opts, options);

        window.fetch(uri, opts).
        then(async (res) => {
            var err, data, info = parseResponse(res);
            if (!res.ok) {
                if (/\/json/.test(info.headers["content-type"])) {
                    const d = await res.json();
                    err = { status: res.status };
                    for (const p in d) err[p] = d[p];
                } else {
                    err = { message: await res.text(), status: res.status };
                }
                return call(callback, err, data, info);
            }
            switch (options?.dataType) {
            case "text":
                data = await res.text();
                break;
            case "blob":
                data = await res.blob();
                break;
            default:
                data = /\/json/.test(info.headers["content-type"]) ? await res.json() : await res.text();
            }
            call(callback, null, data, info);
        }).catch (err => {
            call(callback, err);
        });
    } catch (err) {
        call(callback, err);
    }
}

/**
 * Promisified {@link fetch} which returns a Promise, all exceptions are passed to the reject handler, no need to use try..catch
 * Return everything in an object `{ ok, status, err, data, info }`.
 * @example
 * const { err, data } = await afetch("https://localhost:8000")
 *
 * const { ok, err, status, data } = await afetch("https://localhost:8000")
 * if (!ok) console.log(status, err);
 * @param {string} url
 * @param {object} [options]
 * @async
 */

export function afetch(url, options)
{
    return new Promise((resolve, reject) => {
        fetch(url, options, (err, data, info) => {
            resolve({ ok: !err, status: info.status, err, data, info });
        });
    });
}