ky
Version:
Tiny and elegant HTTP client based on the Fetch API
202 lines (201 loc) • 12 kB
JavaScript
import { responseTypes, usualFormBoundarySize } from '../core/constants.js';
import { ResponseSizeError } from '../errors/ResponseSizeError.js';
const encoder = new TextEncoder();
const responseSizeErrors = new WeakMap();
// The `Response` constructor rejects a body for these statuses, but some browsers (for example, Chromium and WebKit) still expose a body stream on such responses, so they must not be wrapped. A runtime that exposes a non-empty body for one of them, such as WebKit for 205, is therefore left unlimited and without progress events.
const nullBodyStatuses = new Set([101, 103, 204, 205, 304]);
/**
Cancels the body of a request or response that is no longer used, unless `keep` still reads the same stream.
Fire-and-forget: cancellation failures from already-locked or already-consumed streams are ignored, and the cancellation is not awaited since a clone leaves extra tee branches that keep the cancel promise pending per the Streams spec.
*/
export const cancelBody = (value, keep) => {
const body = value?.body;
if (body && body !== keep?.body) {
void body.cancel().catch(() => undefined);
}
};
const getContentLength = (headers) => Math.max(0, Number(headers.get('content-length')) || 0);
// A multipart body sends every line break in a field name or string value as CRLF, and escapes line breaks and quotes in a field name or filename.
const normalizeLineBreaks = (value) => value.replaceAll(/\r\n|\r|\n/g, '\r\n');
const escapeFormDataName = (name) => name.replaceAll('\n', '%0A').replaceAll('\r', '%0D').replaceAll('"', '%22');
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export const getBodySize = (body) => {
if (!body) {
return 0;
}
if (body instanceof FormData) {
// This is an approximation, as FormData size calculation is not straightforward. The boundary length is a stand-in for the runtime's own, and every other byte is counted so the estimate errs high (except in Bun, see `usualFormBoundarySize`): an estimate below the real size makes `percent` reach its ceiling part way through the upload.
let size = 0;
for (const [key, value] of body) {
size += usualFormBoundarySize + 4; // `--<boundary>\r\n`
size += encoder.encode(`Content-Disposition: form-data; name="${escapeFormDataName(normalizeLineBreaks(key))}"`).byteLength;
if (value instanceof Blob) {
// Most runtimes turn an appended `Blob` into a `File` named `blob`, but Bun keeps a `Blob` with an `undefined` name and sends an empty filename.
size += encoder.encode(`; filename="${escapeFormDataName(value.name ?? '')}"`).byteLength;
size += encoder.encode(`\r\nContent-Type: ${value.type || 'application/octet-stream'}`).byteLength;
}
size += 4; // The CRLF that ends the headers, plus the blank line.
size += typeof value === 'string'
? encoder.encode(normalizeLineBreaks(value)).byteLength
: value.size;
size += 2; // The CRLF that ends the part.
}
// The closing `--<boundary>--\r\n`. An empty `FormData` still sends it, but reports `0`, the documented total for an empty transfer.
return size === 0 ? 0 : size + usualFormBoundarySize + 6;
}
if (body instanceof Blob) {
return body.size;
}
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
return body.byteLength;
}
if (typeof body === 'string') {
return encoder.encode(body).byteLength;
}
if (body instanceof URLSearchParams) {
return encoder.encode(body.toString()).byteLength;
}
return 0;
};
const withProgress = (stream, totalBytes, onProgress) => {
let previousChunk;
let transferredBytes = 0;
return stream.pipeThrough(new TransformStream({
transform(currentChunk, controller) {
controller.enqueue(currentChunk);
if (previousChunk) {
transferredBytes += previousChunk.byteLength;
let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
// Avoid reporting 100% progress before the stream is actually finished (in case totalBytes is inaccurate)
if (percent >= 1) {
// Epsilon is used here to get as close as possible to 100% without reaching it.
// If we were to use 0.99 here, percent could potentially go backwards.
percent = 1 - Number.EPSILON;
}
// An unknown total stays `0`, matching the `percent` computed from the same estimate and the documented progress shape.
const reportedTotalBytes = totalBytes === 0 ? 0 : Math.max(totalBytes, transferredBytes);
onProgress?.({ percent, totalBytes: reportedTotalBytes, transferredBytes }, previousChunk);
}
previousChunk = currentChunk;
},
flush() {
const finalChunk = previousChunk ?? new Uint8Array();
transferredBytes += finalChunk.byteLength;
onProgress?.({ percent: 1, totalBytes: transferredBytes, transferredBytes }, finalChunk);
},
}));
};
const copyResponseMetadata = (response, originalResponse, getError = originalResponse.body ? responseSizeErrors.get(originalResponse.body) : undefined) => {
const nativeClone = response.clone.bind(response);
if (response.body && getError) {
responseSizeErrors.set(response.body, getError);
// Chromium replaces stream errors with a generic TypeError in native body methods.
// Restore the size error, including on clones and download-progress wrappers.
for (const type of Object.keys(responseTypes)) {
if (typeof response[type] !== 'function') {
continue;
}
const nativeMethod = response[type].bind(response);
Object.defineProperty(response, type, {
async value() {
try {
return await nativeMethod();
}
catch (error) {
throw getError() ?? error;
}
},
writable: true,
configurable: true,
});
}
}
Object.defineProperties(response, {
// The `Response` constructor cannot set these, so copy them over from the original response.
url: { value: originalResponse.url },
redirected: { value: originalResponse.redirected },
type: { value: originalResponse.type },
// The constructor also rebuilds the header list under the mutable "response" guard, so a network response that arrived immutable would silently become mutable. Reusing the original's headers keeps the guard; a clone shares that same object, which the spec would have copied, but copying it would drop the guard again.
headers: { value: originalResponse.headers },
// Native `clone()` creates a new `Response`, which would drop them again.
// Keep the shim replaceable like `Response.prototype.clone` for instrumentation and mocks.
clone: {
value() {
const clone = nativeClone();
// Cloning replaces the original body too, so retain the error accessor on both branches.
if (response.body && getError) {
responseSizeErrors.set(response.body, getError);
}
return copyResponseMetadata(clone, response);
},
writable: true,
configurable: true,
},
});
return response;
};
// The body of a response the two wrappers below can pipe through, or `undefined` when they must leave it alone. A body an `afterResponse` hook already read, or locked with a reader, would otherwise fail with "The ReadableStream is locked" instead of the native "Body is unusable" error that names the actual mistake. A null body status cannot be given a replacement body at all, and there is nothing left to report once the body is gone.
const wrappableBody = (response) => {
const { body } = response;
if (!body || response.bodyUsed || body.locked || nullBodyStatuses.has(response.status)) {
return undefined;
}
return body;
};
export const limitResponseSize = (response, request, maxResponseSize) => {
const originalBody = wrappableBody(response);
if (!originalBody || maxResponseSize === Number.POSITIVE_INFINITY) {
return response;
}
let transferredBytes = 0;
let sizeError;
const getOriginalSizeError = responseSizeErrors.get(originalBody);
const body = originalBody.pipeThrough(new TransformStream({
transform(chunk, controller) {
transferredBytes += chunk.byteLength;
if (transferredBytes > maxResponseSize) {
sizeError = new ResponseSizeError(request, maxResponseSize);
throw sizeError;
}
controller.enqueue(chunk);
},
}));
return copyResponseMetadata(new Response(body, response), response, () => sizeError ?? getOriginalSizeError?.());
};
export const streamResponse = (response, onDownloadProgress) => {
// A response with no wrappable body is not streamed, so it reports no progress. That covers a null body status, and a `HEAD` response in runtimes that give it no body.
const originalBody = wrappableBody(response);
if (!originalBody) {
return response;
}
// `content-length` counts encoded bytes on the wire, while the progress stream counts the bytes after decompression. Using it for a content-coded response would report a total below what actually arrives, so every event would sit at ~100%. The total is then unknown, which `Progress` already models with `0`. `identity` is a registered no-op coding (RFC 9110 §8.4.2), so it leaves the length valid.
const contentEncoding = response.headers.get('content-encoding')?.toLowerCase();
const isContentCoded = Boolean(contentEncoding) && contentEncoding !== 'identity';
const totalBytes = isContentCoded ? 0 : getContentLength(response.headers);
const body = withProgress(originalBody, totalBytes, onDownloadProgress);
return copyResponseMetadata(new Response(body, response), response);
};
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export const streamRequest = (request, onUploadProgress, originalBody) => {
// A Request-like object from a hook is used as-is, because the `Request` constructor cannot copy it. It would stringify the object as a URL here, so progress reporting is skipped for it. A body a hook already read, or locked with a reader, is left alone for the same reason as a response body: the runtime's own error names the actual mistake, where piping it would fail with "The ReadableStream is locked".
if (!request.body
|| request.bodyUsed
|| request.body.locked
|| request.keepalive
|| request.mode === 'no-cors'
|| !(request instanceof globalThis.Request)) {
return request;
}
// Use original body for size calculation since request.body is already a stream. A `ReadableStream` measures 0, so fall back to a `content-length` the caller declared. Browsers drop that header from a request, so this only helps outside browsers.
const totalBytes = getBodySize(originalBody ?? request.body) || getContentLength(request.headers);
return new Request(request, {
// @ts-expect-error - Types are outdated.
duplex: 'half',
body: withProgress(request.body, totalBytes, onUploadProgress),
// Bun drops the content type derived from a `FormData` body when the body is replaced, unless the headers are passed explicitly.
headers: request.headers,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
});
};
//# sourceMappingURL=body.js.map