1476 lines
36 KiB
JavaScript
1476 lines
36 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = fetch;
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const zlib = require('zlib');
|
|
const Stream = require('stream');
|
|
const dataUriToBuffer = require('data-uri-to-buffer');
|
|
const util = require('util');
|
|
const Blob = require('fetch-blob');
|
|
const crypto = require('crypto');
|
|
const url = require('url');
|
|
|
|
class FetchBaseError extends Error {
|
|
constructor(message, type) {
|
|
super(message);
|
|
// Hide custom error implementation details from end-users
|
|
Error.captureStackTrace(this, this.constructor);
|
|
|
|
this.type = type;
|
|
}
|
|
|
|
get name() {
|
|
return this.constructor.name;
|
|
}
|
|
|
|
get [Symbol.toStringTag]() {
|
|
return this.constructor.name;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ address?: string, code: string, dest?: string, errno: number, info?: object, message: string, path?: string, port?: number, syscall: string}} SystemError
|
|
*/
|
|
|
|
/**
|
|
* FetchError interface for operational errors
|
|
*/
|
|
class FetchError extends FetchBaseError {
|
|
/**
|
|
* @param {string} message - Error message for human
|
|
* @param {string} [type] - Error type for machine
|
|
* @param {SystemError} [systemError] - For Node.js system error
|
|
*/
|
|
constructor(message, type, systemError) {
|
|
super(message, type);
|
|
// When err.type is `system`, err.erroredSysCall contains system error and err.code contains system error code
|
|
if (systemError) {
|
|
// eslint-disable-next-line no-multi-assign
|
|
this.code = this.errno = systemError.code;
|
|
this.erroredSysCall = systemError.syscall;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is.js
|
|
*
|
|
* Object type checks.
|
|
*/
|
|
|
|
const NAME = Symbol.toStringTag;
|
|
|
|
/**
|
|
* Check if `obj` is a URLSearchParams object
|
|
* ref: https://github.com/node-fetch/node-fetch/issues/296#issuecomment-307598143
|
|
*
|
|
* @param {*} obj
|
|
* @return {boolean}
|
|
*/
|
|
const isURLSearchParameters = object => {
|
|
return (
|
|
typeof object === 'object' &&
|
|
typeof object.append === 'function' &&
|
|
typeof object.delete === 'function' &&
|
|
typeof object.get === 'function' &&
|
|
typeof object.getAll === 'function' &&
|
|
typeof object.has === 'function' &&
|
|
typeof object.set === 'function' &&
|
|
typeof object.sort === 'function' &&
|
|
object[NAME] === 'URLSearchParams'
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Check if `object` is a W3C `Blob` object (which `File` inherits from)
|
|
*
|
|
* @param {*} obj
|
|
* @return {boolean}
|
|
*/
|
|
const isBlob = object => {
|
|
return (
|
|
typeof object === 'object' &&
|
|
typeof object.arrayBuffer === 'function' &&
|
|
typeof object.type === 'string' &&
|
|
typeof object.stream === 'function' &&
|
|
typeof object.constructor === 'function' &&
|
|
/^(Blob|File)$/.test(object[NAME])
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Check if `obj` is a spec-compliant `FormData` object
|
|
*
|
|
* @param {*} object
|
|
* @return {boolean}
|
|
*/
|
|
function isFormData(object) {
|
|
return (
|
|
typeof object === 'object' &&
|
|
typeof object.append === 'function' &&
|
|
typeof object.set === 'function' &&
|
|
typeof object.get === 'function' &&
|
|
typeof object.getAll === 'function' &&
|
|
typeof object.delete === 'function' &&
|
|
typeof object.keys === 'function' &&
|
|
typeof object.values === 'function' &&
|
|
typeof object.entries === 'function' &&
|
|
typeof object.constructor === 'function' &&
|
|
object[NAME] === 'FormData'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if `obj` is an instance of AbortSignal.
|
|
*
|
|
* @param {*} obj
|
|
* @return {boolean}
|
|
*/
|
|
const isAbortSignal = object => {
|
|
return (
|
|
typeof object === 'object' &&
|
|
object[NAME] === 'AbortSignal'
|
|
);
|
|
};
|
|
|
|
const carriage = '\r\n';
|
|
const dashes = '-'.repeat(2);
|
|
const carriageLength = Buffer.byteLength(carriage);
|
|
|
|
/**
|
|
* @param {string} boundary
|
|
*/
|
|
const getFooter = boundary => `${dashes}${boundary}${dashes}${carriage.repeat(2)}`;
|
|
|
|
/**
|
|
* @param {string} boundary
|
|
* @param {string} name
|
|
* @param {*} field
|
|
*
|
|
* @return {string}
|
|
*/
|
|
function getHeader(boundary, name, field) {
|
|
let header = '';
|
|
|
|
header += `${dashes}${boundary}${carriage}`;
|
|
header += `Content-Disposition: form-data; name="${name}"`;
|
|
|
|
if (isBlob(field)) {
|
|
header += `; filename="${field.name}"${carriage}`;
|
|
header += `Content-Type: ${field.type || 'application/octet-stream'}`;
|
|
}
|
|
|
|
return `${header}${carriage.repeat(2)}`;
|
|
}
|
|
|
|
/**
|
|
* @return {string}
|
|
*/
|
|
const getBoundary = () => crypto.randomBytes(8).toString('hex');
|
|
|
|
/**
|
|
* @param {FormData} form
|
|
* @param {string} boundary
|
|
*/
|
|
async function * formDataIterator(form, boundary) {
|
|
for (const [name, value] of form) {
|
|
yield getHeader(boundary, name, value);
|
|
|
|
if (isBlob(value)) {
|
|
yield * value.stream();
|
|
} else {
|
|
yield value;
|
|
}
|
|
|
|
yield carriage;
|
|
}
|
|
|
|
yield getFooter(boundary);
|
|
}
|
|
|
|
/**
|
|
* @param {FormData} form
|
|
* @param {string} boundary
|
|
*/
|
|
function getFormDataLength(form, boundary) {
|
|
let length = 0;
|
|
|
|
for (const [name, value] of form) {
|
|
length += Buffer.byteLength(getHeader(boundary, name, value));
|
|
|
|
if (isBlob(value)) {
|
|
length += value.size;
|
|
} else {
|
|
length += Buffer.byteLength(String(value));
|
|
}
|
|
|
|
length += carriageLength;
|
|
}
|
|
|
|
length += Buffer.byteLength(getFooter(boundary));
|
|
|
|
return length;
|
|
}
|
|
|
|
const INTERNALS = Symbol('Body internals');
|
|
|
|
/**
|
|
* Body mixin
|
|
*
|
|
* Ref: https://fetch.spec.whatwg.org/#body
|
|
*
|
|
* @param Stream body Readable stream
|
|
* @param Object opts Response options
|
|
* @return Void
|
|
*/
|
|
class Body {
|
|
constructor(body, {
|
|
size = 0
|
|
} = {}) {
|
|
let boundary = null;
|
|
|
|
if (body === null) {
|
|
// Body is undefined or null
|
|
body = null;
|
|
} else if (isURLSearchParameters(body)) {
|
|
// Body is a URLSearchParams
|
|
body = Buffer.from(body.toString());
|
|
} else if (isBlob(body)) ; else if (Buffer.isBuffer(body)) ; else if (util.types.isAnyArrayBuffer(body)) {
|
|
// Body is ArrayBuffer
|
|
body = Buffer.from(body);
|
|
} else if (ArrayBuffer.isView(body)) {
|
|
// Body is ArrayBufferView
|
|
body = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
} else if (body instanceof Stream) ; else if (isFormData(body)) {
|
|
// Body is an instance of formdata-node
|
|
boundary = `NodeFetchFormDataBoundary${getBoundary()}`;
|
|
body = Stream.Readable.from(formDataIterator(body, boundary));
|
|
} else {
|
|
// None of the above
|
|
// coerce to string then buffer
|
|
body = Buffer.from(String(body));
|
|
}
|
|
|
|
this[INTERNALS] = {
|
|
body,
|
|
boundary,
|
|
disturbed: false,
|
|
error: null
|
|
};
|
|
this.size = size;
|
|
|
|
if (body instanceof Stream) {
|
|
body.on('error', err => {
|
|
const error = err instanceof FetchBaseError ?
|
|
err :
|
|
new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err);
|
|
this[INTERNALS].error = error;
|
|
});
|
|
}
|
|
}
|
|
|
|
get body() {
|
|
return this[INTERNALS].body;
|
|
}
|
|
|
|
get bodyUsed() {
|
|
return this[INTERNALS].disturbed;
|
|
}
|
|
|
|
/**
|
|
* Decode response as ArrayBuffer
|
|
*
|
|
* @return Promise
|
|
*/
|
|
async arrayBuffer() {
|
|
const {buffer, byteOffset, byteLength} = await consumeBody(this);
|
|
return buffer.slice(byteOffset, byteOffset + byteLength);
|
|
}
|
|
|
|
/**
|
|
* Return raw response as Blob
|
|
*
|
|
* @return Promise
|
|
*/
|
|
async blob() {
|
|
const ct = (this.headers && this.headers.get('content-type')) || (this[INTERNALS].body && this[INTERNALS].body.type) || '';
|
|
const buf = await this.buffer();
|
|
|
|
return new Blob([buf], {
|
|
type: ct
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decode response as json
|
|
*
|
|
* @return Promise
|
|
*/
|
|
async json() {
|
|
const buffer = await consumeBody(this);
|
|
return JSON.parse(buffer.toString());
|
|
}
|
|
|
|
/**
|
|
* Decode response as text
|
|
*
|
|
* @return Promise
|
|
*/
|
|
async text() {
|
|
const buffer = await consumeBody(this);
|
|
return buffer.toString();
|
|
}
|
|
|
|
/**
|
|
* Decode response as buffer (non-spec api)
|
|
*
|
|
* @return Promise
|
|
*/
|
|
buffer() {
|
|
return consumeBody(this);
|
|
}
|
|
}
|
|
|
|
// In browsers, all properties are enumerable.
|
|
Object.defineProperties(Body.prototype, {
|
|
body: {enumerable: true},
|
|
bodyUsed: {enumerable: true},
|
|
arrayBuffer: {enumerable: true},
|
|
blob: {enumerable: true},
|
|
json: {enumerable: true},
|
|
text: {enumerable: true}
|
|
});
|
|
|
|
/**
|
|
* Consume and convert an entire Body to a Buffer.
|
|
*
|
|
* Ref: https://fetch.spec.whatwg.org/#concept-body-consume-body
|
|
*
|
|
* @return Promise
|
|
*/
|
|
async function consumeBody(data) {
|
|
if (data[INTERNALS].disturbed) {
|
|
throw new TypeError(`body used already for: ${data.url}`);
|
|
}
|
|
|
|
data[INTERNALS].disturbed = true;
|
|
|
|
if (data[INTERNALS].error) {
|
|
throw data[INTERNALS].error;
|
|
}
|
|
|
|
let {body} = data;
|
|
|
|
// Body is null
|
|
if (body === null) {
|
|
return Buffer.alloc(0);
|
|
}
|
|
|
|
// Body is blob
|
|
if (isBlob(body)) {
|
|
body = body.stream();
|
|
}
|
|
|
|
// Body is buffer
|
|
if (Buffer.isBuffer(body)) {
|
|
return body;
|
|
}
|
|
|
|
/* c8 ignore next 3 */
|
|
if (!(body instanceof Stream)) {
|
|
return Buffer.alloc(0);
|
|
}
|
|
|
|
// Body is stream
|
|
// get ready to actually consume the body
|
|
const accum = [];
|
|
let accumBytes = 0;
|
|
|
|
try {
|
|
for await (const chunk of body) {
|
|
if (data.size > 0 && accumBytes + chunk.length > data.size) {
|
|
const err = new FetchError(`content size at ${data.url} over limit: ${data.size}`, 'max-size');
|
|
body.destroy(err);
|
|
throw err;
|
|
}
|
|
|
|
accumBytes += chunk.length;
|
|
accum.push(chunk);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof FetchBaseError) {
|
|
throw error;
|
|
} else {
|
|
// Other errors, such as incorrect content-encoding
|
|
throw new FetchError(`Invalid response body while trying to fetch ${data.url}: ${error.message}`, 'system', error);
|
|
}
|
|
}
|
|
|
|
if (body.readableEnded === true || body._readableState.ended === true) {
|
|
try {
|
|
if (accum.every(c => typeof c === 'string')) {
|
|
return Buffer.from(accum.join(''));
|
|
}
|
|
|
|
return Buffer.concat(accum, accumBytes);
|
|
} catch (error) {
|
|
throw new FetchError(`Could not create Buffer from response body for ${data.url}: ${error.message}`, 'system', error);
|
|
}
|
|
} else {
|
|
throw new FetchError(`Premature close of server response while trying to fetch ${data.url}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clone body given Res/Req instance
|
|
*
|
|
* @param Mixed instance Response or Request instance
|
|
* @param String highWaterMark highWaterMark for both PassThrough body streams
|
|
* @return Mixed
|
|
*/
|
|
const clone = (instance, highWaterMark) => {
|
|
let p1;
|
|
let p2;
|
|
let {body} = instance;
|
|
|
|
// Don't allow cloning a used body
|
|
if (instance.bodyUsed) {
|
|
throw new Error('cannot clone body after it is used');
|
|
}
|
|
|
|
// Check that body is a stream and not form-data object
|
|
// note: we can't clone the form-data object without having it as a dependency
|
|
if ((body instanceof Stream) && (typeof body.getBoundary !== 'function')) {
|
|
// Tee instance body
|
|
p1 = new Stream.PassThrough({highWaterMark});
|
|
p2 = new Stream.PassThrough({highWaterMark});
|
|
body.pipe(p1);
|
|
body.pipe(p2);
|
|
// Set instance body to teed body and return the other teed body
|
|
instance[INTERNALS].body = p1;
|
|
body = p2;
|
|
}
|
|
|
|
return body;
|
|
};
|
|
|
|
/**
|
|
* Performs the operation "extract a `Content-Type` value from |object|" as
|
|
* specified in the specification:
|
|
* https://fetch.spec.whatwg.org/#concept-bodyinit-extract
|
|
*
|
|
* This function assumes that instance.body is present.
|
|
*
|
|
* @param {any} body Any options.body input
|
|
* @returns {string | null}
|
|
*/
|
|
const extractContentType = (body, request) => {
|
|
// Body is null or undefined
|
|
if (body === null) {
|
|
return null;
|
|
}
|
|
|
|
// Body is string
|
|
if (typeof body === 'string') {
|
|
return 'text/plain;charset=UTF-8';
|
|
}
|
|
|
|
// Body is a URLSearchParams
|
|
if (isURLSearchParameters(body)) {
|
|
return 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
}
|
|
|
|
// Body is blob
|
|
if (isBlob(body)) {
|
|
return body.type || null;
|
|
}
|
|
|
|
// Body is a Buffer (Buffer, ArrayBuffer or ArrayBufferView)
|
|
if (Buffer.isBuffer(body) || util.types.isAnyArrayBuffer(body) || ArrayBuffer.isView(body)) {
|
|
return null;
|
|
}
|
|
|
|
// Detect form data input from form-data module
|
|
if (body && typeof body.getBoundary === 'function') {
|
|
return `multipart/form-data;boundary=${body.getBoundary()}`;
|
|
}
|
|
|
|
if (isFormData(body)) {
|
|
return `multipart/form-data; boundary=${request[INTERNALS].boundary}`;
|
|
}
|
|
|
|
// Body is stream - can't really do much about this
|
|
if (body instanceof Stream) {
|
|
return null;
|
|
}
|
|
|
|
// Body constructor defaults other things to string
|
|
return 'text/plain;charset=UTF-8';
|
|
};
|
|
|
|
/**
|
|
* The Fetch Standard treats this as if "total bytes" is a property on the body.
|
|
* For us, we have to explicitly get it with a function.
|
|
*
|
|
* ref: https://fetch.spec.whatwg.org/#concept-body-total-bytes
|
|
*
|
|
* @param {any} obj.body Body object from the Body instance.
|
|
* @returns {number | null}
|
|
*/
|
|
const getTotalBytes = request => {
|
|
const {body} = request;
|
|
|
|
// Body is null or undefined
|
|
if (body === null) {
|
|
return 0;
|
|
}
|
|
|
|
// Body is Blob
|
|
if (isBlob(body)) {
|
|
return body.size;
|
|
}
|
|
|
|
// Body is Buffer
|
|
if (Buffer.isBuffer(body)) {
|
|
return body.length;
|
|
}
|
|
|
|
// Detect form data input from form-data module
|
|
if (body && typeof body.getLengthSync === 'function') {
|
|
return body.hasKnownLength && body.hasKnownLength() ? body.getLengthSync() : null;
|
|
}
|
|
|
|
// Body is a spec-compliant form-data
|
|
if (isFormData(body)) {
|
|
return getFormDataLength(request[INTERNALS].boundary);
|
|
}
|
|
|
|
// Body is stream
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Write a Body to a Node.js WritableStream (e.g. http.Request) object.
|
|
*
|
|
* @param {Stream.Writable} dest The stream to write to.
|
|
* @param obj.body Body object from the Body instance.
|
|
* @returns {void}
|
|
*/
|
|
const writeToStream = (dest, {body}) => {
|
|
if (body === null) {
|
|
// Body is null
|
|
dest.end();
|
|
} else if (isBlob(body)) {
|
|
// Body is Blob
|
|
body.stream().pipe(dest);
|
|
} else if (Buffer.isBuffer(body)) {
|
|
// Body is buffer
|
|
dest.write(body);
|
|
dest.end();
|
|
} else {
|
|
// Body is stream
|
|
body.pipe(dest);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Headers.js
|
|
*
|
|
* Headers class offers convenient helpers
|
|
*/
|
|
|
|
const validateHeaderName = typeof http.validateHeaderName === 'function' ?
|
|
http.validateHeaderName :
|
|
name => {
|
|
if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(name)) {
|
|
const err = new TypeError(`Header name must be a valid HTTP token [${name}]`);
|
|
Object.defineProperty(err, 'code', {value: 'ERR_INVALID_HTTP_TOKEN'});
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const validateHeaderValue = typeof http.validateHeaderValue === 'function' ?
|
|
http.validateHeaderValue :
|
|
(name, value) => {
|
|
if (/[^\t\u0020-\u007E\u0080-\u00FF]/.test(value)) {
|
|
const err = new TypeError(`Invalid character in header content ["${name}"]`);
|
|
Object.defineProperty(err, 'code', {value: 'ERR_INVALID_CHAR'});
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @typedef {Headers | Record<string, string> | Iterable<readonly [string, string]> | Iterable<Iterable<string>>} HeadersInit
|
|
*/
|
|
|
|
/**
|
|
* This Fetch API interface allows you to perform various actions on HTTP request and response headers.
|
|
* These actions include retrieving, setting, adding to, and removing.
|
|
* A Headers object has an associated header list, which is initially empty and consists of zero or more name and value pairs.
|
|
* You can add to this using methods like append() (see Examples.)
|
|
* In all methods of this interface, header names are matched by case-insensitive byte sequence.
|
|
*
|
|
*/
|
|
class Headers extends URLSearchParams {
|
|
/**
|
|
* Headers class
|
|
*
|
|
* @constructor
|
|
* @param {HeadersInit} [init] - Response headers
|
|
*/
|
|
constructor(init) {
|
|
// Validate and normalize init object in [name, value(s)][]
|
|
/** @type {string[][]} */
|
|
let result = [];
|
|
if (init instanceof Headers) {
|
|
const raw = init.raw();
|
|
for (const [name, values] of Object.entries(raw)) {
|
|
result.push(...values.map(value => [name, value]));
|
|
}
|
|
} else if (init == null) ; else if (typeof init === 'object' && !util.types.isBoxedPrimitive(init)) {
|
|
const method = init[Symbol.iterator];
|
|
// eslint-disable-next-line no-eq-null, eqeqeq
|
|
if (method == null) {
|
|
// Record<ByteString, ByteString>
|
|
result.push(...Object.entries(init));
|
|
} else {
|
|
if (typeof method !== 'function') {
|
|
throw new TypeError('Header pairs must be iterable');
|
|
}
|
|
|
|
// Sequence<sequence<ByteString>>
|
|
// Note: per spec we have to first exhaust the lists then process them
|
|
result = [...init]
|
|
.map(pair => {
|
|
if (
|
|
typeof pair !== 'object' || util.types.isBoxedPrimitive(pair)
|
|
) {
|
|
throw new TypeError('Each header pair must be an iterable object');
|
|
}
|
|
|
|
return [...pair];
|
|
}).map(pair => {
|
|
if (pair.length !== 2) {
|
|
throw new TypeError('Each header pair must be a name/value tuple');
|
|
}
|
|
|
|
return [...pair];
|
|
});
|
|
}
|
|
} else {
|
|
throw new TypeError('Failed to construct \'Headers\': The provided value is not of type \'(sequence<sequence<ByteString>> or record<ByteString, ByteString>)');
|
|
}
|
|
|
|
// Validate and lowercase
|
|
result =
|
|
result.length > 0 ?
|
|
result.map(([name, value]) => {
|
|
validateHeaderName(name);
|
|
validateHeaderValue(name, String(value));
|
|
return [String(name).toLowerCase(), String(value)];
|
|
}) :
|
|
undefined;
|
|
|
|
super(result);
|
|
|
|
// Returning a Proxy that will lowercase key names, validate parameters and sort keys
|
|
// eslint-disable-next-line no-constructor-return
|
|
return new Proxy(this, {
|
|
get(target, p, receiver) {
|
|
switch (p) {
|
|
case 'append':
|
|
case 'set':
|
|
return (name, value) => {
|
|
validateHeaderName(name);
|
|
validateHeaderValue(name, String(value));
|
|
return URLSearchParams.prototype[p].call(
|
|
receiver,
|
|
String(name).toLowerCase(),
|
|
String(value)
|
|
);
|
|
};
|
|
|
|
case 'delete':
|
|
case 'has':
|
|
case 'getAll':
|
|
return name => {
|
|
validateHeaderName(name);
|
|
return URLSearchParams.prototype[p].call(
|
|
receiver,
|
|
String(name).toLowerCase()
|
|
);
|
|
};
|
|
|
|
case 'keys':
|
|
return () => {
|
|
target.sort();
|
|
return new Set(URLSearchParams.prototype.keys.call(target)).keys();
|
|
};
|
|
|
|
default:
|
|
return Reflect.get(target, p, receiver);
|
|
}
|
|
}
|
|
/* c8 ignore next */
|
|
});
|
|
}
|
|
|
|
get [Symbol.toStringTag]() {
|
|
return this.constructor.name;
|
|
}
|
|
|
|
toString() {
|
|
return Object.prototype.toString.call(this);
|
|
}
|
|
|
|
get(name) {
|
|
const values = this.getAll(name);
|
|
if (values.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let value = values.join(', ');
|
|
if (/^content-encoding$/i.test(name)) {
|
|
value = value.toLowerCase();
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
forEach(callback) {
|
|
for (const name of this.keys()) {
|
|
callback(this.get(name), name);
|
|
}
|
|
}
|
|
|
|
* values() {
|
|
for (const name of this.keys()) {
|
|
yield this.get(name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @type {() => IterableIterator<[string, string]>}
|
|
*/
|
|
* entries() {
|
|
for (const name of this.keys()) {
|
|
yield [name, this.get(name)];
|
|
}
|
|
}
|
|
|
|
[Symbol.iterator]() {
|
|
return this.entries();
|
|
}
|
|
|
|
/**
|
|
* Node-fetch non-spec method
|
|
* returning all headers and their values as array
|
|
* @returns {Record<string, string[]>}
|
|
*/
|
|
raw() {
|
|
return [...this.keys()].reduce((result, key) => {
|
|
result[key] = this.getAll(key);
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
/**
|
|
* For better console.log(headers) and also to convert Headers into Node.js Request compatible format
|
|
*/
|
|
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
return [...this.keys()].reduce((result, key) => {
|
|
const values = this.getAll(key);
|
|
// Http.request() only supports string as Host header.
|
|
// This hack makes specifying custom Host header possible.
|
|
if (key === 'host') {
|
|
result[key] = values[0];
|
|
} else {
|
|
result[key] = values.length > 1 ? values : values[0];
|
|
}
|
|
|
|
return result;
|
|
}, {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-shaping object for Web IDL tests
|
|
* Only need to do it for overridden methods
|
|
*/
|
|
Object.defineProperties(
|
|
Headers.prototype,
|
|
['get', 'entries', 'forEach', 'values'].reduce((result, property) => {
|
|
result[property] = {enumerable: true};
|
|
return result;
|
|
}, {})
|
|
);
|
|
|
|
/**
|
|
* Create a Headers object from an http.IncomingMessage.rawHeaders, ignoring those that do
|
|
* not conform to HTTP grammar productions.
|
|
* @param {import('http').IncomingMessage['rawHeaders']} headers
|
|
*/
|
|
function fromRawHeaders(headers = []) {
|
|
return new Headers(
|
|
headers
|
|
// Split into pairs
|
|
.reduce((result, value, index, array) => {
|
|
if (index % 2 === 0) {
|
|
result.push(array.slice(index, index + 2));
|
|
}
|
|
|
|
return result;
|
|
}, [])
|
|
.filter(([name, value]) => {
|
|
try {
|
|
validateHeaderName(name);
|
|
validateHeaderValue(name, String(value));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
|
|
);
|
|
}
|
|
|
|
const redirectStatus = new Set([301, 302, 303, 307, 308]);
|
|
|
|
/**
|
|
* Redirect code matching
|
|
*
|
|
* @param {number} code - Status code
|
|
* @return {boolean}
|
|
*/
|
|
const isRedirect = code => {
|
|
return redirectStatus.has(code);
|
|
};
|
|
|
|
/**
|
|
* Response.js
|
|
*
|
|
* Response class provides content decoding
|
|
*/
|
|
|
|
const INTERNALS$1 = Symbol('Response internals');
|
|
|
|
/**
|
|
* Response class
|
|
*
|
|
* @param Stream body Readable stream
|
|
* @param Object opts Response options
|
|
* @return Void
|
|
*/
|
|
class Response extends Body {
|
|
constructor(body = null, options = {}) {
|
|
super(body, options);
|
|
|
|
const status = options.status || 200;
|
|
const headers = new Headers(options.headers);
|
|
|
|
if (body !== null && !headers.has('Content-Type')) {
|
|
const contentType = extractContentType(body);
|
|
if (contentType) {
|
|
headers.append('Content-Type', contentType);
|
|
}
|
|
}
|
|
|
|
this[INTERNALS$1] = {
|
|
url: options.url,
|
|
status,
|
|
statusText: options.statusText || '',
|
|
headers,
|
|
counter: options.counter,
|
|
highWaterMark: options.highWaterMark
|
|
};
|
|
}
|
|
|
|
get url() {
|
|
return this[INTERNALS$1].url || '';
|
|
}
|
|
|
|
get status() {
|
|
return this[INTERNALS$1].status;
|
|
}
|
|
|
|
/**
|
|
* Convenience property representing if the request ended normally
|
|
*/
|
|
get ok() {
|
|
return this[INTERNALS$1].status >= 200 && this[INTERNALS$1].status < 300;
|
|
}
|
|
|
|
get redirected() {
|
|
return this[INTERNALS$1].counter > 0;
|
|
}
|
|
|
|
get statusText() {
|
|
return this[INTERNALS$1].statusText;
|
|
}
|
|
|
|
get headers() {
|
|
return this[INTERNALS$1].headers;
|
|
}
|
|
|
|
get highWaterMark() {
|
|
return this[INTERNALS$1].highWaterMark;
|
|
}
|
|
|
|
/**
|
|
* Clone this response
|
|
*
|
|
* @return Response
|
|
*/
|
|
clone() {
|
|
return new Response(clone(this, this.highWaterMark), {
|
|
url: this.url,
|
|
status: this.status,
|
|
statusText: this.statusText,
|
|
headers: this.headers,
|
|
ok: this.ok,
|
|
redirected: this.redirected,
|
|
size: this.size
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} url The URL that the new response is to originate from.
|
|
* @param {number} status An optional status code for the response (e.g., 302.)
|
|
* @returns {Response} A Response object.
|
|
*/
|
|
static redirect(url, status = 302) {
|
|
if (!isRedirect(status)) {
|
|
throw new RangeError('Failed to execute "redirect" on "response": Invalid status code');
|
|
}
|
|
|
|
return new Response(null, {
|
|
headers: {
|
|
location: new URL(url).toString()
|
|
},
|
|
status
|
|
});
|
|
}
|
|
|
|
get [Symbol.toStringTag]() {
|
|
return 'Response';
|
|
}
|
|
}
|
|
|
|
Object.defineProperties(Response.prototype, {
|
|
url: {enumerable: true},
|
|
status: {enumerable: true},
|
|
ok: {enumerable: true},
|
|
redirected: {enumerable: true},
|
|
statusText: {enumerable: true},
|
|
headers: {enumerable: true},
|
|
clone: {enumerable: true}
|
|
});
|
|
|
|
const getSearch = parsedURL => {
|
|
if (parsedURL.search) {
|
|
return parsedURL.search;
|
|
}
|
|
|
|
const lastOffset = parsedURL.href.length - 1;
|
|
const hash = parsedURL.hash || (parsedURL.href[lastOffset] === '#' ? '#' : '');
|
|
return parsedURL.href[lastOffset - hash.length] === '?' ? '?' : '';
|
|
};
|
|
|
|
const INTERNALS$2 = Symbol('Request internals');
|
|
|
|
/**
|
|
* Check if `obj` is an instance of Request.
|
|
*
|
|
* @param {*} obj
|
|
* @return {boolean}
|
|
*/
|
|
const isRequest = object => {
|
|
return (
|
|
typeof object === 'object' &&
|
|
typeof object[INTERNALS$2] === 'object'
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Request class
|
|
*
|
|
* @param Mixed input Url or Request instance
|
|
* @param Object init Custom options
|
|
* @return Void
|
|
*/
|
|
class Request extends Body {
|
|
constructor(input, init = {}) {
|
|
let parsedURL;
|
|
|
|
// Normalize input and force URL to be encoded as UTF-8 (https://github.com/node-fetch/node-fetch/issues/245)
|
|
if (isRequest(input)) {
|
|
parsedURL = new URL(input.url);
|
|
} else {
|
|
parsedURL = new URL(input);
|
|
input = {};
|
|
}
|
|
|
|
let method = init.method || input.method || 'GET';
|
|
method = method.toUpperCase();
|
|
|
|
// eslint-disable-next-line no-eq-null, eqeqeq
|
|
if (((init.body != null || isRequest(input)) && input.body !== null) &&
|
|
(method === 'GET' || method === 'HEAD')) {
|
|
throw new TypeError('Request with GET/HEAD method cannot have body');
|
|
}
|
|
|
|
const inputBody = init.body ?
|
|
init.body :
|
|
(isRequest(input) && input.body !== null ?
|
|
clone(input) :
|
|
null);
|
|
|
|
super(inputBody, {
|
|
size: init.size || input.size || 0
|
|
});
|
|
|
|
const headers = new Headers(init.headers || input.headers || {});
|
|
|
|
if (inputBody !== null && !headers.has('Content-Type')) {
|
|
const contentType = extractContentType(inputBody, this);
|
|
if (contentType) {
|
|
headers.append('Content-Type', contentType);
|
|
}
|
|
}
|
|
|
|
let signal = isRequest(input) ?
|
|
input.signal :
|
|
null;
|
|
if ('signal' in init) {
|
|
signal = init.signal;
|
|
}
|
|
|
|
if (signal !== null && !isAbortSignal(signal)) {
|
|
throw new TypeError('Expected signal to be an instanceof AbortSignal');
|
|
}
|
|
|
|
this[INTERNALS$2] = {
|
|
method,
|
|
redirect: init.redirect || input.redirect || 'follow',
|
|
headers,
|
|
parsedURL,
|
|
signal
|
|
};
|
|
|
|
// Node-fetch-only options
|
|
this.follow = init.follow === undefined ? (input.follow === undefined ? 20 : input.follow) : init.follow;
|
|
this.compress = init.compress === undefined ? (input.compress === undefined ? true : input.compress) : init.compress;
|
|
this.counter = init.counter || input.counter || 0;
|
|
this.agent = init.agent || input.agent;
|
|
this.highWaterMark = init.highWaterMark || input.highWaterMark || 16384;
|
|
this.insecureHTTPParser = init.insecureHTTPParser || input.insecureHTTPParser || false;
|
|
}
|
|
|
|
get method() {
|
|
return this[INTERNALS$2].method;
|
|
}
|
|
|
|
get url() {
|
|
return url.format(this[INTERNALS$2].parsedURL);
|
|
}
|
|
|
|
get headers() {
|
|
return this[INTERNALS$2].headers;
|
|
}
|
|
|
|
get redirect() {
|
|
return this[INTERNALS$2].redirect;
|
|
}
|
|
|
|
get signal() {
|
|
return this[INTERNALS$2].signal;
|
|
}
|
|
|
|
/**
|
|
* Clone this request
|
|
*
|
|
* @return Request
|
|
*/
|
|
clone() {
|
|
return new Request(this);
|
|
}
|
|
|
|
get [Symbol.toStringTag]() {
|
|
return 'Request';
|
|
}
|
|
}
|
|
|
|
Object.defineProperties(Request.prototype, {
|
|
method: {enumerable: true},
|
|
url: {enumerable: true},
|
|
headers: {enumerable: true},
|
|
redirect: {enumerable: true},
|
|
clone: {enumerable: true},
|
|
signal: {enumerable: true}
|
|
});
|
|
|
|
/**
|
|
* Convert a Request to Node.js http request options.
|
|
*
|
|
* @param Request A Request instance
|
|
* @return Object The options object to be passed to http.request
|
|
*/
|
|
const getNodeRequestOptions = request => {
|
|
const {parsedURL} = request[INTERNALS$2];
|
|
const headers = new Headers(request[INTERNALS$2].headers);
|
|
|
|
// Fetch step 1.3
|
|
if (!headers.has('Accept')) {
|
|
headers.set('Accept', '*/*');
|
|
}
|
|
|
|
// HTTP-network-or-cache fetch steps 2.4-2.7
|
|
let contentLengthValue = null;
|
|
if (request.body === null && /^(post|put)$/i.test(request.method)) {
|
|
contentLengthValue = '0';
|
|
}
|
|
|
|
if (request.body !== null) {
|
|
const totalBytes = getTotalBytes(request);
|
|
// Set Content-Length if totalBytes is a number (that is not NaN)
|
|
if (typeof totalBytes === 'number' && !Number.isNaN(totalBytes)) {
|
|
contentLengthValue = String(totalBytes);
|
|
}
|
|
}
|
|
|
|
if (contentLengthValue) {
|
|
headers.set('Content-Length', contentLengthValue);
|
|
}
|
|
|
|
// HTTP-network-or-cache fetch step 2.11
|
|
if (!headers.has('User-Agent')) {
|
|
headers.set('User-Agent', 'node-fetch');
|
|
}
|
|
|
|
// HTTP-network-or-cache fetch step 2.15
|
|
if (request.compress && !headers.has('Accept-Encoding')) {
|
|
headers.set('Accept-Encoding', 'gzip,deflate,br');
|
|
}
|
|
|
|
let {agent} = request;
|
|
if (typeof agent === 'function') {
|
|
agent = agent(parsedURL);
|
|
}
|
|
|
|
if (!headers.has('Connection') && !agent) {
|
|
headers.set('Connection', 'close');
|
|
}
|
|
|
|
// HTTP-network fetch step 4.2
|
|
// chunked encoding is handled by Node.js
|
|
|
|
const search = getSearch(parsedURL);
|
|
|
|
// Manually spread the URL object instead of spread syntax
|
|
const requestOptions = {
|
|
path: parsedURL.pathname + search,
|
|
pathname: parsedURL.pathname,
|
|
hostname: parsedURL.hostname,
|
|
protocol: parsedURL.protocol,
|
|
port: parsedURL.port,
|
|
hash: parsedURL.hash,
|
|
search: parsedURL.search,
|
|
query: parsedURL.query,
|
|
href: parsedURL.href,
|
|
method: request.method,
|
|
headers: headers[Symbol.for('nodejs.util.inspect.custom')](),
|
|
insecureHTTPParser: request.insecureHTTPParser,
|
|
agent
|
|
};
|
|
|
|
return requestOptions;
|
|
};
|
|
|
|
/**
|
|
* AbortError interface for cancelled requests
|
|
*/
|
|
class AbortError extends FetchBaseError {
|
|
constructor(message, type = 'aborted') {
|
|
super(message, type);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Index.js
|
|
*
|
|
* a request API compatible with window.fetch
|
|
*
|
|
* All spec algorithm step numbers are based on https://fetch.spec.whatwg.org/commit-snapshots/ae716822cb3a61843226cd090eefc6589446c1d2/.
|
|
*/
|
|
|
|
const supportedSchemas = new Set(['data:', 'http:', 'https:']);
|
|
|
|
/**
|
|
* Fetch function
|
|
*
|
|
* @param {string | URL | import('./request').default} url - Absolute url or Request instance
|
|
* @param {*} [options_] - Fetch options
|
|
* @return {Promise<import('./response').default>}
|
|
*/
|
|
async function fetch(url, options_) {
|
|
return new Promise((resolve, reject) => {
|
|
// Build request object
|
|
const request = new Request(url, options_);
|
|
const options = getNodeRequestOptions(request);
|
|
if (!supportedSchemas.has(options.protocol)) {
|
|
throw new TypeError(`node-fetch cannot load ${url}. URL scheme "${options.protocol.replace(/:$/, '')}" is not supported.`);
|
|
}
|
|
|
|
if (options.protocol === 'data:') {
|
|
const data = dataUriToBuffer(request.url);
|
|
const response = new Response(data, {headers: {'Content-Type': data.typeFull}});
|
|
resolve(response);
|
|
return;
|
|
}
|
|
|
|
// Wrap http.request into fetch
|
|
const send = (options.protocol === 'https:' ? https : http).request;
|
|
const {signal} = request;
|
|
let response = null;
|
|
|
|
const abort = () => {
|
|
const error = new AbortError('The operation was aborted.');
|
|
reject(error);
|
|
if (request.body && request.body instanceof Stream.Readable) {
|
|
request.body.destroy(error);
|
|
}
|
|
|
|
if (!response || !response.body) {
|
|
return;
|
|
}
|
|
|
|
response.body.emit('error', error);
|
|
};
|
|
|
|
if (signal && signal.aborted) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
const abortAndFinalize = () => {
|
|
abort();
|
|
finalize();
|
|
};
|
|
|
|
// Send request
|
|
const request_ = send(options);
|
|
|
|
if (signal) {
|
|
signal.addEventListener('abort', abortAndFinalize);
|
|
}
|
|
|
|
const finalize = () => {
|
|
request_.abort();
|
|
if (signal) {
|
|
signal.removeEventListener('abort', abortAndFinalize);
|
|
}
|
|
};
|
|
|
|
request_.on('error', err => {
|
|
reject(new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err));
|
|
finalize();
|
|
});
|
|
|
|
request_.on('response', response_ => {
|
|
request_.setTimeout(0);
|
|
const headers = fromRawHeaders(response_.rawHeaders);
|
|
|
|
// HTTP fetch step 5
|
|
if (isRedirect(response_.statusCode)) {
|
|
// HTTP fetch step 5.2
|
|
const location = headers.get('Location');
|
|
|
|
// HTTP fetch step 5.3
|
|
const locationURL = location === null ? null : new URL(location, request.url);
|
|
|
|
// HTTP fetch step 5.5
|
|
switch (request.redirect) {
|
|
case 'error':
|
|
reject(new FetchError(`uri requested responds with a redirect, redirect mode is set to error: ${request.url}`, 'no-redirect'));
|
|
finalize();
|
|
return;
|
|
case 'manual':
|
|
// Node-fetch-specific step: make manual redirect a bit easier to use by setting the Location header value to the resolved URL.
|
|
if (locationURL !== null) {
|
|
// Handle corrupted header
|
|
try {
|
|
headers.set('Location', locationURL);
|
|
/* c8 ignore next 3 */
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
}
|
|
|
|
break;
|
|
case 'follow': {
|
|
// HTTP-redirect fetch step 2
|
|
if (locationURL === null) {
|
|
break;
|
|
}
|
|
|
|
// HTTP-redirect fetch step 5
|
|
if (request.counter >= request.follow) {
|
|
reject(new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect'));
|
|
finalize();
|
|
return;
|
|
}
|
|
|
|
// HTTP-redirect fetch step 6 (counter increment)
|
|
// Create a new Request object.
|
|
const requestOptions = {
|
|
headers: new Headers(request.headers),
|
|
follow: request.follow,
|
|
counter: request.counter + 1,
|
|
agent: request.agent,
|
|
compress: request.compress,
|
|
method: request.method,
|
|
body: request.body,
|
|
signal: request.signal,
|
|
size: request.size
|
|
};
|
|
|
|
// HTTP-redirect fetch step 9
|
|
if (response_.statusCode !== 303 && request.body && options_.body instanceof Stream.Readable) {
|
|
reject(new FetchError('Cannot follow redirect with body being a readable stream', 'unsupported-redirect'));
|
|
finalize();
|
|
return;
|
|
}
|
|
|
|
// HTTP-redirect fetch step 11
|
|
if (response_.statusCode === 303 || ((response_.statusCode === 301 || response_.statusCode === 302) && request.method === 'POST')) {
|
|
requestOptions.method = 'GET';
|
|
requestOptions.body = undefined;
|
|
requestOptions.headers.delete('content-length');
|
|
}
|
|
|
|
// HTTP-redirect fetch step 15
|
|
resolve(fetch(new Request(locationURL, requestOptions)));
|
|
finalize();
|
|
return;
|
|
}
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
// Prepare response
|
|
response_.once('end', () => {
|
|
if (signal) {
|
|
signal.removeEventListener('abort', abortAndFinalize);
|
|
}
|
|
});
|
|
|
|
let body = Stream.pipeline(response_, new Stream.PassThrough(), error => {
|
|
reject(error);
|
|
});
|
|
// see https://github.com/nodejs/node/pull/29376
|
|
if (process.version < 'v12.10') {
|
|
response_.on('aborted', abortAndFinalize);
|
|
}
|
|
|
|
const responseOptions = {
|
|
url: request.url,
|
|
status: response_.statusCode,
|
|
statusText: response_.statusMessage,
|
|
headers,
|
|
size: request.size,
|
|
counter: request.counter,
|
|
highWaterMark: request.highWaterMark
|
|
};
|
|
|
|
// HTTP-network fetch step 12.1.1.3
|
|
const codings = headers.get('Content-Encoding');
|
|
|
|
// HTTP-network fetch step 12.1.1.4: handle content codings
|
|
|
|
// in following scenarios we ignore compression support
|
|
// 1. compression support is disabled
|
|
// 2. HEAD request
|
|
// 3. no Content-Encoding header
|
|
// 4. no content response (204)
|
|
// 5. content not modified response (304)
|
|
if (!request.compress || request.method === 'HEAD' || codings === null || response_.statusCode === 204 || response_.statusCode === 304) {
|
|
response = new Response(body, responseOptions);
|
|
resolve(response);
|
|
return;
|
|
}
|
|
|
|
// For Node v6+
|
|
// Be less strict when decoding compressed responses, since sometimes
|
|
// servers send slightly invalid responses that are still accepted
|
|
// by common browsers.
|
|
// Always using Z_SYNC_FLUSH is what cURL does.
|
|
const zlibOptions = {
|
|
flush: zlib.Z_SYNC_FLUSH,
|
|
finishFlush: zlib.Z_SYNC_FLUSH
|
|
};
|
|
|
|
// For gzip
|
|
if (codings === 'gzip' || codings === 'x-gzip') {
|
|
body = Stream.pipeline(body, zlib.createGunzip(zlibOptions), error => {
|
|
reject(error);
|
|
});
|
|
response = new Response(body, responseOptions);
|
|
resolve(response);
|
|
return;
|
|
}
|
|
|
|
// For deflate
|
|
if (codings === 'deflate' || codings === 'x-deflate') {
|
|
// Handle the infamous raw deflate response from old servers
|
|
// a hack for old IIS and Apache servers
|
|
const raw = Stream.pipeline(response_, new Stream.PassThrough(), error => {
|
|
reject(error);
|
|
});
|
|
raw.once('data', chunk => {
|
|
// See http://stackoverflow.com/questions/37519828
|
|
if ((chunk[0] & 0x0F) === 0x08) {
|
|
body = Stream.pipeline(body, zlib.createInflate(), error => {
|
|
reject(error);
|
|
});
|
|
} else {
|
|
body = Stream.pipeline(body, zlib.createInflateRaw(), error => {
|
|
reject(error);
|
|
});
|
|
}
|
|
|
|
response = new Response(body, responseOptions);
|
|
resolve(response);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// For br
|
|
if (codings === 'br') {
|
|
body = Stream.pipeline(body, zlib.createBrotliDecompress(), error => {
|
|
reject(error);
|
|
});
|
|
response = new Response(body, responseOptions);
|
|
resolve(response);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, use response as-is
|
|
response = new Response(body, responseOptions);
|
|
resolve(response);
|
|
});
|
|
|
|
writeToStream(request_, request);
|
|
});
|
|
}
|
|
|
|
exports.AbortError = AbortError;
|
|
exports.FetchError = FetchError;
|
|
exports.Headers = Headers;
|
|
exports.Request = Request;
|
|
exports.Response = Response;
|
|
exports.default = fetch;
|
|
exports.isRedirect = isRedirect;
|
|
//# sourceMappingURL=index.cjs.map
|