UNPKG

@waiting/fetch

Version:

HTTP fetch API for browser and Node.js. Handle 302/303 redirect correctly on Node.js

401 lines (327 loc) 10 kB
import { ReadStream } from 'node:fs' import QueryString from 'qs' import type { RequestInfo as UndiciRequestInfo, RequestInit } from 'undici' import { FormData, Headers } from 'undici' import { initialOptions } from './config.js' import type { Args, ArgsRequestInitCombined, Options } from './types.js' import { ContentTypeList } from './types.js' /** Update initialFetchOptions */ export function setGlobalRequestOptions(options: Partial<Options>): void { for (const [key, value] of Object.entries(options)) { Object.defineProperty(initialOptions, key, { configurable: true, enumerable: true, writable: true, value, }) } } /** Get copy of initialFetchOptions */ export function getGlobalRequestOptions(): Readonly<Options> { return { ...initialOptions } } export function buildQueryString(url: string, data: Options['data']): string { /* istanbul ignore else */ if (data && typeof data === 'object' && Object.keys(data).length) { const ps = QueryString.stringify(data) return url.includes('?') ? `${url}&${ps}` : `${url}?${ps}` } return url } /** Split FetchOptions object to RequestInit and Args */ export function splitInitArgs(options: Options): ArgsRequestInitCombined { const opts: Options = { ...options } const args: Args = {} /* istanbul ignore else */ if (typeof opts.cookies !== 'undefined') { args.cookies = opts.cookies } delete opts.cookies if (opts.abortController && typeof opts.abortController.abort === 'function') { args.abortController = opts.abortController } delete opts.abortController /* istanbul ignore else */ if (typeof opts.contentType !== 'undefined') { args.contentType = opts.contentType } delete opts.contentType if (typeof opts.data !== 'undefined') { args.data = opts.data } delete opts.data if (opts.dataType) { args.dataType = opts.dataType } delete opts.dataType /* c8 ignore next */ if (typeof opts.keepRedirectCookies !== 'undefined') { args.keepRedirectCookies = !! opts.keepRedirectCookies } delete opts.keepRedirectCookies /* c8 ignore next */ if (typeof opts.processData !== 'undefined') { args.processData = opts.processData } delete opts.processData /* c8 ignore next */ if (typeof opts.timeout !== 'undefined') { args.timeout = opts.timeout } delete opts.timeout const requestInit = { ...opts } as RequestInit return { args, requestInit, } } export function processParams(options: Options): ArgsRequestInitCombined { const initOpts: Options = { ...initialOptions, ...options } const opts = splitInitArgs(initOpts) return processInitOpts(opts) } export function processInitOpts(options: ArgsRequestInitCombined): ArgsRequestInitCombined { let opts = { ...options } opts = processHeaders(opts) // at first! opts = processAbortController(opts) opts = processCookies(opts) opts = processMethod(opts) opts.args.dataType = processDataType(opts.args.dataType) opts.args.timeout = parseTimeout(opts.args.timeout) const redirect = processRedirect( !! opts.args.keepRedirectCookies, opts.requestInit.redirect, ) if (redirect) { opts.requestInit.redirect = redirect } return opts } function processHeaders(options: ArgsRequestInitCombined): ArgsRequestInitCombined { const { args, requestInit } = options requestInit.headers = requestInit.headers ? new Headers(requestInit.headers) : new Headers() const { headers } = requestInit if (! headers.has('Accept')) { headers.set('Accept', 'application/json, text/html, text/javascript, text/plain, */*') } return { args, requestInit } } function processAbortController(options: ArgsRequestInitCombined): ArgsRequestInitCombined { const { args, requestInit } = options if (! args.abortController || typeof args.abortController.abort !== 'function') { if (typeof AbortController === 'function') { args.abortController = new AbortController() } else { throw new TypeError('AbortController not available') } } requestInit.signal = args.abortController.signal return { args, requestInit } } function processCookies(options: ArgsRequestInitCombined): ArgsRequestInitCombined { const { args, requestInit } = options const data = args.cookies const arr: string[] = [] if (data && typeof data === 'object') { for (let [key, value] of Object.entries(data)) { /* istanbul ignore else */ if (key && typeof key === 'string') { key = key.trim() /* istanbul ignore else */ if (! key) { continue } value = typeof value === 'string' || typeof value === 'number' ? value.toString().trim() : '' arr.push(`${key}=${value}`) } } } if (arr.length) { const headers = requestInit.headers as Headers let cookies = headers.get('Cookie') if (cookies) { cookies = cookies.trim() let ret = arr.join('; ') /* istanbul ignore if */ if (cookies.endsWith(';')) { cookies = cookies.slice(0, -1) ret = `${cookies}; ` + ret } else { ret = `${cookies}; ` + ret } headers.set('Cookie', ret) } else { headers.set('Cookie', arr.join('; ')) } } return { args, requestInit } } function processMethod(options: ArgsRequestInitCombined): ArgsRequestInitCombined { const { args, requestInit } = options if (requestInit.method && ['DELETE', 'POST', 'PUT'].includes(requestInit.method)) { const headers = requestInit.headers as Headers if (args.contentType === false) { void 0 } else if (args.contentType) { headers.set('Content-Type', args.contentType) } /* istanbul ignore else */ else if (! headers.has('Content-Type')) { headers.set('Content-Type', ContentTypeList.json) } } return { args, requestInit } } /** * Parse type of return data * @returns default: json **/ function processDataType(value: unknown): NonNullable<Args['dataType']> { /* istanbul ignore else */ if (typeof value === 'string' && ['arrayBuffer', 'bare', 'blob', 'formData', 'json', 'text', 'raw'].includes(value)) { return value as NonNullable<Args['dataType']> } return 'json' } function parseTimeout(ps: unknown): number { const value = typeof ps === 'number' && ps >= 0 ? Math.ceil(ps) : Infinity return value === Infinity || ! Number.isSafeInteger(value) ? Infinity : value } /** * set redirect to 'manual' for retrieve cookies during 301/302 when keepRedirectCookies:TRUE * and current value only when "follow" */ function processRedirect( keepRedirectCookies: boolean, curValue: RequestInit['redirect'] | undefined, ): RequestInit['redirect'] { // not change value if on Browser /* istanbul ignore else */ // eslint-disable-next-line unicorn/prefer-global-this if (keepRedirectCookies && typeof window === 'undefined') { /* istanbul ignore else */ if (curValue === 'follow') { return 'manual' } } return curValue ? curValue : 'follow' } /** * Return input url string */ export function processRequestGetLikeData(input: string, args: Args): string { let url = '' if (typeof args.data === 'undefined') { url = input } else if (args.processData) { // override the value of body by args.data url = buildQueryString(input, args.data) } else { throw new TypeError('Typeof args.data invalid for GET/DELETE when args.processData not true, type is :' + typeof args.data) } return url } export function processRequestPostLikeData(args: Args): RequestInit['body'] | null { let body: RequestInit['body'] | null const { data } = args if (typeof data === 'string') { body = data } else if (typeof data === 'undefined') { body = null } else if (data === null) { body = null } else if (data instanceof FormData) { body = data } // else if (data instanceof NodeFormData) { // throw new TypeError('NodeFormData from pkg "form-data" not supported, use FormData from "undici" instead') // } else if (typeof Blob !== 'undefined' && data instanceof Blob) { // @ts-ignore body = data } else if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer) { body = data } else if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { body = data } else if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) { body = data } else if (typeof ReadStream !== 'undefined' && data instanceof ReadStream) { body = data } else if (args.processData) { const { contentType } = args if (typeof contentType === 'string' && contentType.includes('json')) { body = JSON.stringify(data) } else { body = QueryString.stringify(data) } } else { body = data as NonNullable<RequestInit['body']> } return body } /** "foo=cookie_foo; Secure; Path=/" */ export function parseRespCookie(cookie: string | null): Args['cookies'] { /* istanbul ignore else */ if (! cookie) { return } const arr = cookie.split(/;/) const ret: Args['cookies'] = {} for (let row of arr) { row = row.trim() /* istanbul ignore else */ if (! row) { continue } if (! row.includes('=')) { continue } if (row.startsWith('Path=')) { continue } const [key, value] = row.split('=') /* istanbul ignore else */ if (key && value) { ret[key] = value } } /* istanbul ignore else */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ret && Object.keys(ret).length) { return ret } } export function pickUrlStrFromRequestInfo(input: RequestInfo | UndiciRequestInfo): string { let url = '' if (typeof input === 'string') { url = input } else if (input instanceof URL) { url = input.toString() } else if (input instanceof Request) { url = input.url } else { url = '' } return url }