UNPKG

@remix-run/web-fetch

Version:

Web API compatible fetch implementation

599 lines (533 loc) 13.9 kB
// @ts-check /** * Body.js * * Body interface provides common methods for Request and Response */ import Stream from 'stream'; import {types} from 'util'; import {Blob, ReadableStream} from './package.js'; import {FetchError} from './errors/fetch-error.js'; import {FetchBaseError} from './errors/base.js'; import {formDataIterator, getBoundary, getFormDataLength, toFormData} from './utils/form-data.js'; import {isBlob, isURLSearchParameters, isFormData, isMultipartFormDataStream, isReadableStream} from './utils/is.js'; import * as utf8 from './utils/utf8.js'; const {readableHighWaterMark} = new Stream.Readable(); const INTERNALS = Symbol('Body internals'); /** * Body mixin * * Ref: https://fetch.spec.whatwg.org/#body * @implements {globalThis.Body} */ export default class Body { /** * @param {BodyInit|Stream|null} body * @param {{size?:number}} options */ constructor(body, { size = 0 } = {}) { const state = { /** @type {null|ReadableStream<Uint8Array>} */ body: null, /** @type {string|null} */ type: null, /** @type {number|null} */ size: null, /** @type {null|string} */ boundary: null, disturbed: false, /** @type {null|Error} */ error: null }; /** @private */ this[INTERNALS] = state; if (body === null) { // Body is undefined or null state.body = null; state.size = 0; } else if (isURLSearchParameters(body)) { // Body is a URLSearchParams const bytes = utf8.encode(body.toString()); state.body = fromBytes(bytes); state.size = bytes.byteLength; state.type = 'application/x-www-form-urlencoded;charset=UTF-8'; } else if (isBlob(body)) { // Body is blob state.size = body.size; state.type = body.type || null; state.body = body.stream(); } else if (body instanceof Uint8Array) { // Body is Buffer state.body = fromBytes(body); state.size = body.byteLength; } else if (types.isAnyArrayBuffer(body)) { // Body is ArrayBuffer const bytes = new Uint8Array(body); state.body = fromBytes(bytes); state.size = bytes.byteLength; } else if (ArrayBuffer.isView(body)) { // Body is ArrayBufferView const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); state.body = fromBytes(bytes); state.size = bytes.byteLength; } else if (isReadableStream(body)) { // Body is stream state.body = body; } else if (isFormData(body)) { // Body is an instance of formdata-node const boundary = `NodeFetchFormDataBoundary${getBoundary()}`; state.type = `multipart/form-data; boundary=${boundary}`; state.size = getFormDataLength(body, boundary); state.body = fromAsyncIterable(formDataIterator(body, boundary)); } else if (isMultipartFormDataStream(body)) { state.type = `multipart/form-data; boundary=${body.getBoundary()}`; state.size = body.hasKnownLength() ? body.getLengthSync() : null; state.body = fromStream(body); } else if (body instanceof Stream) { state.body = fromStream(body); } else { // None of the above // coerce to string then buffer const bytes = utf8.encode(String(body)); state.type = 'text/plain;charset=UTF-8'; state.size = bytes.byteLength; state.body = fromBytes(bytes); } 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; // }); // } } /** @type {Headers} */ /* c8 ignore next 3 */ get headers() { throw new TypeError(`'get headers' called on an object that does not implements interface.`) } get body() { return this[INTERNALS].body; } get bodyUsed() { return this[INTERNALS].disturbed; } /** * Decode response as ArrayBuffer * * @return {Promise<ArrayBuffer>} */ 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].type) || ''; const buf = await consumeBody(this); return new Blob([buf], { type: ct }); } /** * Decode response as json * * @return Promise */ async json() { return JSON.parse(await this.text()); } /** * Decode response as text * * @return Promise */ async text() { const buffer = await consumeBody(this); return utf8.decode(buffer); } /** * @returns {Promise<FormData>} */ async formData() { return toFormData(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}, formData: {enumerable: true} }); /** * Consume and convert an entire Body to a Buffer. * * Ref: https://fetch.spec.whatwg.org/#concept-body-consume-body * * @param {Body & {url?:string}} data * @return {Promise<Uint8Array>} */ async function consumeBody(data) { const state = data[INTERNALS]; if (state.disturbed) { throw new TypeError(`body used already for: ${data.url}`); } state.disturbed = true; if (state.error) { throw state.error; } const {body} = state; // Body is null if (body === null) { return new Uint8Array(0); } // Body is stream // get ready to actually consume the body /** @type {[Uint8Array|null, Uint8Array[], number]} */ const [buffer, chunks, limit] = data.size > 0 ? [new Uint8Array(data.size), [], data.size] : [null, [], Infinity]; let offset = 0; const source = streamIterator(body); try { for await (const chunk of source) { const bytes = chunk instanceof Uint8Array ? chunk : Buffer.from(chunk); if (offset + bytes.byteLength > limit) { const error = new FetchError(`content size at ${data.url} over limit: ${limit}`, 'max-size'); source.throw(error); throw error; } else if (buffer) { buffer.set(bytes, offset); } else { chunks.push(bytes); } offset += bytes.byteLength; } if (buffer) { if (offset < buffer.byteLength) { throw new FetchError(`Premature close of server response while trying to fetch ${data.url}`, 'premature-close'); } else { return buffer; } } else { return writeBytes(new Uint8Array(offset), chunks); } } catch (error) { if (error instanceof FetchBaseError) { throw error; // @ts-expect-error - we know it will have a name } else if (error && error.name === 'AbortError') { throw error; } else { const e = /** @type {import('./errors/fetch-error').SystemError} */(error) // Other errors, such as incorrect content-encoding throw new FetchError(`Invalid response body while trying to fetch ${data.url}: ${e.message}`, 'system', e); } } } /** * Clone body given Res/Req instance * * @param {Body} instance Response or Request instance * @return {ReadableStream<Uint8Array> | null} */ export const clone = instance => { const {body} = instance; // Don't allow cloning a used body if (instance.bodyUsed) { throw new Error('cannot clone body after it is used'); } if (!body) { return null; } const [left, right] = body.tee(); instance[INTERNALS].body = left; return right; }; /** * 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 {Body} source Any options.body input * @returns {string | null} */ export const extractContentType = source => source[INTERNALS].type; /** * 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 {Body} source - Body object from the Body instance. * @returns {number | null} */ export const getTotalBytes = source => source[INTERNALS].size; /** * Write a Body to a Node.js WritableStream (e.g. http.Request) object. * * @param {Stream.Writable} dest - The stream to write to. * @param {Body} source - Body object from the Body instance. * @returns {void} */ export const writeToStream = (dest, {body}) => { if (body === null) { // Body is null dest.end(); } else { Stream.Readable.from(streamIterator(body)).pipe(dest); } }; /** * @template T * @implements {AsyncGenerator<T, void, void>} */ class StreamIterableIterator { /** * @param {ReadableStream<T>} stream */ constructor(stream) { this.stream = stream; this.reader = null; } /** * @returns {AsyncGenerator<T, void, void>} */ [Symbol.asyncIterator]() { return this; } getReader() { if (this.reader) { return this.reader; } const reader = this.stream.getReader(); this.reader = reader; return reader; } /** * @returns {Promise<IteratorResult<T, void>>} */ next() { return /** @type {Promise<IteratorResult<T, void>>} */ (this.getReader().read()); } /** * @returns {Promise<IteratorResult<T, void>>} */ async return() { if (this.reader) { await this.reader.cancel(); } return {done: true, value: undefined}; } /** * * @param {any} error * @returns {Promise<IteratorResult<T, void>>} */ async throw(error) { await this.getReader().cancel(error); return {done: true, value: undefined}; } } /** * @template T * @param {ReadableStream<T>} stream */ export const streamIterator = stream => new StreamIterableIterator(stream); /** * @param {Uint8Array} buffer * @param {Uint8Array[]} chunks */ const writeBytes = (buffer, chunks) => { let offset = 0; for (const chunk of chunks) { buffer.set(chunk, offset); offset += chunk.byteLength; } return buffer; }; /** * @param {Uint8Array} bytes * @returns {ReadableStream<Uint8Array>} */ // @ts-ignore const fromBytes = bytes => new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }); /** * @param {AsyncIterable<Uint8Array>} content * @returns {ReadableStream<Uint8Array>} */ export const fromAsyncIterable = content => new ReadableStream(new AsyncIterablePump(content)); /** * @implements {UnderlyingSource<Uint8Array>} */ class AsyncIterablePump { /** * @param {AsyncIterable<Uint8Array>} source */ constructor(source) { this.source = source[Symbol.asyncIterator](); } /** * @param {ReadableStreamController<Uint8Array>} controller */ async pull(controller) { try { while (controller.desiredSize || 0 > 0) { // eslint-disable-next-line no-await-in-loop const next = await this.source.next(); if (next.done) { controller.close(); break; } else { controller.enqueue(next.value); } } } catch (error) { controller.error(error); } } /** * @param {any} [reason] */ cancel(reason) { if (reason) { if (typeof this.source.throw === 'function') { this.source.throw(reason); } else if (typeof this.source.return === 'function') { this.source.return(); } } else if (typeof this.source.return === 'function') { this.source.return(); } } } /** * @param {Stream & {readableHighWaterMark?:number}} source * @returns {ReadableStream<Uint8Array>} */ export const fromStream = source => { const pump = new StreamPump(source); const stream = new ReadableStream(pump, pump); return stream; }; /** * @implements {UnderlyingSource<Uint8Array>} * @implements {QueuingStrategy<Uint8Array>} */ class StreamPump { /** * @param {Stream & { * readableHighWaterMark?: number * readable?:boolean, * resume?: () => void, * pause?: () => void * destroy?: (error?:Error) => void * }} stream */ constructor(stream) { this.highWaterMark = stream.readableHighWaterMark || readableHighWaterMark; this.accumalatedSize = 0; this.stream = stream; this.enqueue = this.enqueue.bind(this); this.error = this.error.bind(this); this.close = this.close.bind(this); } /** * @param {Uint8Array} [chunk] */ size(chunk) { return chunk?.byteLength || 0; } /** * @param {ReadableStreamController<Uint8Array>} controller */ start(controller) { this.controller = controller; this.stream.on('data', this.enqueue); this.stream.once('error', this.error); this.stream.once('end', this.close); this.stream.once('close', this.close); } pull() { this.resume(); } /** * @param {any} [reason] */ cancel(reason) { if (this.stream.destroy) { this.stream.destroy(reason); } this.stream.off('data', this.enqueue); this.stream.off('error', this.error); this.stream.off('end', this.close); this.stream.off('close', this.close); } /** * @param {Uint8Array|string} chunk */ enqueue(chunk) { if (this.controller) { try { const bytes = chunk instanceof Uint8Array ? chunk : Buffer.from(chunk); const available = (this.controller.desiredSize || 0) - bytes.byteLength; this.controller.enqueue(bytes); if (available <= 0) { this.pause(); } } catch { this.controller.error(new Error('Could not create Buffer, chunk must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object')); this.cancel(); } } } pause() { if (this.stream.pause) { this.stream.pause(); } } resume() { if (this.stream.readable && this.stream.resume) { this.stream.resume(); } } close() { if (this.controller) { this.controller.close(); delete this.controller; } } /** * @param {Error} error */ error(error) { if (this.controller) { this.controller.error(error); delete this.controller; } } }