UNPKG

@helia/verified-fetch

Version:

A fetch-like API for obtaining verified & trustless IPFS content on the web

420 lines (333 loc) 11.8 kB
import itToBrowserReadableStream from 'it-to-browser-readablestream' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { rangeToOffsetAndLength } from './get-offset-and-length.ts' import { getContentRangeHeader } from './response-headers.ts' import type { SupportedBodyTypes, ContentType } from '../index.js' import type { Range, RangeHeader } from './get-range-header.ts' function setField (response: Response, name: string, value: string | boolean): void { Object.defineProperty(response, name, { enumerable: true, configurable: false, set: () => {}, get: () => value }) } function setType (response: Response, value: 'basic' | 'cors' | 'error' | 'opaque' | 'opaqueredirect'): void { if (response.type !== value) { setField(response, 'type', value) } } function setUrl (response: Response, value: string | URL): void { value = value.toString() const fragmentStart = value.indexOf('#') if (fragmentStart > -1) { value = value.substring(0, fragmentStart) } if (response.url !== value) { setField(response, 'url', value) } } function setRedirected (response: Response): void { setField(response, 'redirected', true) } export interface ResponseOptions extends ResponseInit { redirected?: boolean } export function okResponse (url: string, body?: SupportedBodyTypes, init?: ResponseOptions): Response { const response = new Response(body, { ...(init ?? {}), status: 200, statusText: 'OK' }) if (init?.redirected === true) { setRedirected(response) } setType(response, 'basic') setUrl(response, url) return response } export function internalServerErrorResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { const response = new Response(body, { ...(init ?? {}), status: 500, statusText: 'Internal Server Error' }) response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header setType(response, 'basic') setUrl(response, url) return response } /** * A 504 Gateway Timeout for when a request made to an upstream server timed out */ export function gatewayTimeoutResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { const response = new Response(body, { ...(init ?? {}), status: 504, statusText: 'Gateway Timeout' }) setType(response, 'basic') setUrl(response, url) return response } /** * A 502 Bad Gateway is for when an invalid response was received from an * upstream server. */ export function badGatewayResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { const response = new Response(body, { ...(init ?? {}), status: 502, statusText: 'Bad Gateway' }) setType(response, 'basic') setUrl(response, url) return response } export function notImplementedResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { const response = new Response(body, { ...(init ?? {}), status: 501, statusText: 'Not Implemented' }) response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header setType(response, 'basic') setUrl(response, url) return response } export function notAcceptableResponse (url: string | URL, requested: Array<Pick<ContentType, 'mediaType'>>, acceptable: Array<Pick<ContentType, 'mediaType'>>, init?: ResponseInit): Response { const headers = new Headers(init?.headers) headers.set('content-type', 'application/json') const response = new Response(JSON.stringify({ requested: requested.map(contentType => contentType.mediaType), acceptable: acceptable.map(contentType => contentType.mediaType) }), { ...(init ?? {}), status: 406, statusText: 'Not Acceptable', headers }) setType(response, 'basic') setUrl(response, url) return response } export function notFoundResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { const response = new Response(body, { ...(init ?? {}), status: 404, statusText: 'Not Found' }) setType(response, 'basic') setUrl(response, url) return response } function isArrayOfErrors (body: unknown | Error | Error[]): body is Error[] { return Array.isArray(body) && body.every(e => e instanceof Error) } export function badRequestResponse (url: string, errors: Error | Error[], init?: ResponseInit): Response { // stacktrace of the single error, or the stacktrace of the last error in the array let stack: string | undefined let convertedErrors: Array<{ message: string, stack: string }> | undefined if (isArrayOfErrors(errors)) { stack = errors[errors.length - 1].stack convertedErrors = errors.map(e => ({ message: e.message, stack: e.stack ?? '' })) } else if (errors instanceof Error) { stack = errors.stack convertedErrors = [{ message: errors.message, stack: errors.stack ?? '' }] } const bodyJson = JSON.stringify({ stack, errors: convertedErrors }) const response = new Response(bodyJson, { status: 400, statusText: 'Bad Request', ...(init ?? {}), headers: { ...(init?.headers ?? {}), 'Content-Type': 'application/json' } }) setType(response, 'basic') setUrl(response, url) return response } export function movedPermanentlyResponse (url: string, location: string, init?: ResponseInit): Response { const response = new Response(null, { ...(init ?? {}), status: 301, statusText: 'Moved Permanently', headers: { ...(init?.headers ?? {}), location } }) setType(response, 'basic') setUrl(response, url) return response } export function notModifiedResponse (url: string, headers: Headers, init?: ResponseInit): Response { const response = new Response(null, { ...(init ?? {}), status: 304, statusText: 'Not Modified' }) // These fields would be present on a 200 and must be sent as part of a 304 // for the same resource // https://www.rfc-editor.org/rfc/rfc9110.html#name-304-not-modified const copyHeaders = [ 'cache-control', 'content-location', 'date', 'etag', 'expires', 'vary' ] copyHeaders.forEach(key => { const value = headers.get(key) if (value != null) { response.headers.set(key, value) } }) setType(response, 'basic') setUrl(response, url) return response } export interface PartialContent { /** * Yield data from the content starting at `start` (or 0) inclusive and ending * at `end` exclusive */ (offset: number, length: number): AsyncGenerator<Uint8Array> } export function partialContentResponse (url: string, getSlice: PartialContent, range: RangeHeader, documentSize: number | bigint, init?: ResponseOptions): Response { let response: Response if (range.ranges.length === 1) { response = singleRangeResponse(url, getSlice, range.ranges[0], documentSize, init) } else if (range.ranges.length > 1) { response = multiRangeResponse(url, getSlice, range, documentSize, init) } else { return notSatisfiableResponse(url, documentSize) } if (init?.redirected === true) { setRedirected(response) } setType(response, 'basic') setUrl(response, url) return response } function singleRangeResponse (url: string, getSlice: PartialContent, range: Range, documentSize: number | bigint, init?: ResponseOptions): Response { try { // create headers object with any initial headers from init const headers = new Headers(init?.headers) const { offset, length } = rangeToOffsetAndLength(documentSize, range.start, range.end) headers.set('content-length', `${length}`) // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Range headers.set('content-range', getContentRangeHeader(documentSize, range.start, range.end)) const stream = itToBrowserReadableStream(getSlice(offset, length)) return new Response(stream, { ...(init ?? {}), status: 206, statusText: 'Partial Content', headers }) } catch (err: any) { if (err.name === 'InvalidRangeError') { return notSatisfiableResponse(url, documentSize, init) } return internalServerErrorResponse(url, '', init) } } /** * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests */ function multiRangeResponse (url: string, getSlice: PartialContent, range: RangeHeader, documentSize: number | bigint, init?: ResponseOptions): Response { // create headers object with any initial headers from init const headers = new Headers(init?.headers) const contentType = headers.get('content-type') if (contentType == null) { throw new Error('Content-Type header must be set') } headers.delete('content-type') let contentLength = 0n // calculate content range based on range headers const rangeHeaders = range.ranges.map(({ start, end }) => { const header = uint8ArrayFromString([ `--${range.multipartBoundary}`, // content-type of multipart part `Content-Type: ${contentType}`, // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Range `Content-Range: ${getContentRangeHeader(documentSize, start, end)}`, '', '' ].join('\r\n')) contentLength += BigInt(header.byteLength) + (BigInt(end ?? documentSize) - BigInt(start ?? 0)) return header }) const trailer = uint8ArrayFromString([ `--${range.multipartBoundary}--`, '' ].join('\r\n')) contentLength += BigInt(trailer.byteLength) // content length is the expected length of all multipart parts headers.set('content-length', `${contentLength}`) // content type of response is multipart headers.set('content-type', `multipart/byteranges; boundary=${range.multipartBoundary}`) const stream = itToBrowserReadableStream(async function * () { for (let i = 0; i < rangeHeaders.length; i++) { yield rangeHeaders[i] const { offset, length } = rangeToOffsetAndLength(documentSize, range.ranges[i].start, range.ranges[i].end) yield * getSlice(offset, length) yield uint8ArrayFromString('\r\n') } yield trailer }()) return new Response(stream, { ...(init ?? {}), status: 206, statusText: 'Partial Content', headers }) } /** * We likely need to catch errors handled by upstream helia libraries if * range-request throws an error. Some examples: * * - The range is out of bounds * - The range is invalid * - The range is not supported for the given type */ export function notSatisfiableResponse (url: string, documentSize?: number | bigint | string, init?: ResponseInit): Response { const headers = new Headers(init?.headers) if (documentSize != null) { headers.set('content-range', `bytes */${documentSize}`) } const response = new Response('Range Not Satisfiable', { ...init, headers, status: 416, statusText: 'Range Not Satisfiable' }) setType(response, 'basic') setUrl(response, url) return response } /** * Error to indicate that request was formally correct, but Gateway is unable to * return requested data under the additional (usually cache-related) conditions * sent by the client. * * @see https://specs.ipfs.tech/http-gateways/path-gateway/#412-precondition-failed */ export function preconditionFailedResponse (url: string, init?: ResponseInit): Response { const headers = new Headers(init?.headers) const response = new Response('Precondition Failed', { ...init, headers, status: 412, statusText: 'Precondition Failed' }) setType(response, 'basic') setUrl(response, url) return response }