import type { Json } from './types' import { arrayFlatten } from './data/array' import { toBase64 } from './data/bin' import { deepMerge } from './data/deep' import { isArray } from './data/is' import { jsonParse, jsonStringifySafe } from './data/json' import { encodeQuery } from './data/url' import { DefaultLogger } from './log/log' // TODO: Abort signal https://codedrivendevelopment.com/posts/everything-about-abort-signal-timeout /** * Options for fetch requests. * @category Network */ export interface fetchOptionType { /** Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching. */ cache?: RequestCache /** Returns the credentials mode associated with request, which is a string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. */ credentials?: RequestCredentials /** Returns the kind of resource requested by request, e.g., "document" or "script". */ destination?: RequestDestination /** Returns a Headers object consisting of the headers associated with request. Note that headers added in the network layer by the user agent will not be accounted for in this object, e.g., the "Host" header. */ headers?: Record /** Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI] */ integrity?: string /** Returns a boolean indicating whether or not request can outlive the global in which it was created. */ keepalive?: boolean /** Returns request's HTTP method, which is "GET" by default. */ method?: string /** Returns the mode associated with request, which is a string indicating whether the request will use CORS, or will be restricted to same-origin URLs. */ mode?: RequestMode /** Returns the redirect mode associated with request, which is a string indicating how redirects for the request will be handled during fetching. A request will follow redirects by default. */ redirect?: RequestRedirect /** Returns the referrer of request. Its value can be a same-origin URL if explicitly set in init, the empty string to indicate no referrer, and "about:client" when defaulting to the global's default. This is used during fetching to determine the value of the `Referer` header of the request being made. */ referrer?: string /** Returns the referrer policy associated with request. This is used during fetching to compute the value of the request's referrer. */ referrerPolicy?: ReferrerPolicy /** Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler. */ signal?: AbortSignal /** Returns the URL of request as a string. */ url?: string body?: any } /** * Type for fetch options, can be a single option or an array of options. * @category Network */ export type fetchOptionsType = fetchOptionType | fetchOptionsType[] const defaultOptions: fetchOptionType = { cache: 'no-cache', redirect: 'follow', headers: {}, } // Source https://developer.mozilla.org/de/docs/Web/HTTP/Methods export type httpMethod = | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH' export function parseBasicAuth(url: string) { const m = /:\/\/([^@]*)@/.exec(url) if (m && m[1]) { const [username, password] = m[1].split(':', 2) return { url: url.replace(`${m[1]}@`, ''), username, password, } } } /** * Simplified `fetch` wrapper. * - Accepts a single fetch options object or an array of options which will be deep-merged. * - Supports basic auth embedded in the URL (e.g. https://user:pass@host/path). * - Normalizes headers to a `Headers` instance. * - Returns the `Response` when status < 400, otherwise returns `undefined`. * @category Network */ export async function fetchBasic( url: string | URL, fetchOptions: fetchOptionsType = {}, fetchFn: (input: RequestInfo, init?: RequestInit) => Promise = fetch, ): Promise { try { if (isArray(fetchOptions)) fetchOptions = deepMerge({}, ...arrayFlatten(fetchOptions)) const auth = parseBasicAuth(String(url)) if (auth) { url = auth.url fetchOptions = deepMerge( {}, fetchOptions, fetchOptionsBasicAuth(auth.username, auth.password), ) } if ( // @ts-expect-error headers fetchOptions.headers != null // @ts-expect-error headers && !(fetchOptions.headers instanceof Headers) ) { // @ts-expect-error headers fetchOptions.headers = new Headers(fetchOptions.headers) } // log("fetch", url, fetchOptions) const response = await fetchFn(String(url), fetchOptions as RequestInit) // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status if (response.status < 400) return response const log = DefaultLogger('zeed:network') try { log.warn(`Fetch of ${String(url)} returned status=${response.status}. Options:`, fetchOptions) log.warn(`Response: ${await response.text()}`) } catch (err) { log.error('Exception:', err) } } catch (err) { const log = DefaultLogger('zeed:network') log.error('fetchBasic', err) } } /** * Fetch and parse JSON. * Returns the parsed JSON object or `undefined` on error. * @category Network */ export async function fetchJson( url: string | URL, fetchOptions: fetchOptionsType = {}, fetchFn: (input: RequestInfo, init?: RequestInit) => Promise = fetch, ): Promise { try { const res = await fetchBasic( url, [ { method: 'GET', headers: { Accept: 'application/json', }, }, fetchOptions, ], fetchFn, ) if (res) return jsonParse(await res.text()) } catch (err) { const log = DefaultLogger('zeed:network') log.error('fetchJSON error:', err) } } /** * Fetch text content. * Returns the response text or `undefined` on error. * @category Network */ export async function fetchText( url: string | URL, fetchOptions: fetchOptionsType = {}, fetchFn: (input: RequestInfo, init?: RequestInit) => Promise = fetch, ): Promise { try { const res = await fetchBasic( url, [defaultOptions, { method: 'GET' }, fetchOptions], fetchFn, ) if (res) return await res.text() } catch (err) { const log = DefaultLogger('zeed:network') log.error('fetchHTML error:', err) } } /// /** * Options for `fetchBasic` to send data as application/x-www-form-urlencoded. * @category Network */ export function fetchOptionsFormURLEncoded( data: object, method: httpMethod = 'POST', ): fetchOptionType { return { method, ...defaultOptions, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', }, body: encodeQuery(data), } } /** * Options for `fetchBasic` to send JSON data. * @category Network */ export function fetchOptionsJson( data: object, method: httpMethod = 'POST', ): fetchOptionType { return { method, ...defaultOptions, headers: { 'Content-Type': 'application/json; charset=utf-8', // Accept: "application/json", }, body: jsonStringifySafe(data), } } /** * Create options to add Basic Authorization header using given username/password. * @category Network */ export function fetchOptionsBasicAuth( username: string, password: string, ): fetchOptionType { return { headers: { Authorization: `Basic ${toBase64(`${username}:${password}`)}`, }, } }