@remix-run/web-fetch
Version:
Web API compatible fetch implementation
599 lines (533 loc) • 13.9 kB
JavaScript
// @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;
}
}
}