UNPKG

ipfs-utils

Version:

Package to aggregate shared logic and dependencies for the IPFS ecosystem

376 lines (326 loc) 9.28 kB
/* eslint-disable no-undef */ 'use strict' const { fetch, Request, Headers } = require('./http/fetch') const { TimeoutError, HTTPError } = require('./http/error') const merge = require('merge-options').bind({ ignoreUndefined: true }) const { URL, URLSearchParams } = require('iso-url') const anySignal = require('any-signal') const browserReableStreamToIt = require('browser-readablestream-to-it') const { isBrowser, isWebWorker } = require('./env') const all = require('it-all') /** * @typedef {import('stream').Readable} NodeReadableStream * @typedef {import('./types').HTTPOptions} HTTPOptions * @typedef {import('./types').ExtendedResponse} ExtendedResponse */ /** * @template TResponse * @param {Promise<TResponse>} promise * @param {number | undefined} ms * @param {AbortController} abortController * @returns {Promise<TResponse>} */ const timeout = (promise, ms, abortController) => { if (ms === undefined) { return promise } const start = Date.now() const timedOut = () => { const time = Date.now() - start return time >= ms } return new Promise((resolve, reject) => { const timeoutID = setTimeout(() => { if (timedOut()) { reject(new TimeoutError()) abortController.abort() } }, ms) /** * @param {(value: any) => void } next */ const after = (next) => { /** * @param {any} res */ const fn = (res) => { clearTimeout(timeoutID) if (timedOut()) { reject(new TimeoutError()) return } next(res) } return fn } promise .then(after(resolve), after(reject)) }) } const defaults = { throwHttpErrors: true, credentials: 'same-origin' } class HTTP { /** * * @param {HTTPOptions} options */ constructor (options = {}) { /** @type {HTTPOptions} */ this.opts = merge(defaults, options) } /** * Fetch * * @param {string | Request} resource * @param {HTTPOptions} options * @returns {Promise<ExtendedResponse>} */ async fetch (resource, options = {}) { /** @type {HTTPOptions} */ const opts = merge(this.opts, options) // @ts-expect-error const headers = new Headers(opts.headers) // validate resource type // @ts-expect-error if (typeof resource !== 'string' && !(resource instanceof URL || resource instanceof Request)) { throw new TypeError('`resource` must be a string, URL, or Request') } const url = new URL(resource.toString(), opts.base) const { searchParams, transformSearchParams, json } = opts if (searchParams) { if (typeof transformSearchParams === 'function') { // @ts-ignore url.search = transformSearchParams(new URLSearchParams(opts.searchParams)) } else { // @ts-ignore url.search = new URLSearchParams(opts.searchParams) } } if (json) { opts.body = JSON.stringify(opts.json) headers.set('content-type', 'application/json') } const abortController = new AbortController() // @ts-ignore const signal = anySignal([abortController.signal, opts.signal]) if (globalThis.ReadableStream != null && opts.body instanceof globalThis.ReadableStream && (isBrowser || isWebWorker)) { // https://bugzilla.mozilla.org/show_bug.cgi?id=1387483 opts.body = new Blob(await all(browserReableStreamToIt(opts.body))) } /** @type {ExtendedResponse} */ // @ts-expect-error additional fields are assigned below const response = await timeout( fetch( url.toString(), { ...opts, signal, // @ts-expect-error non-browser fetch implementations may take extra options timeout: undefined, headers, // https://fetch.spec.whatwg.org/#dom-requestinit-duplex // https://github.com/whatwg/fetch/issues/1254 duplex: 'half' } ), opts.timeout, abortController ) if (!response.ok && opts.throwHttpErrors) { if (opts.handleError) { await opts.handleError(response) } throw new HTTPError(response) } response.iterator = async function * () { yield * fromStream(response.body) } response.ndjson = async function * () { for await (const chunk of ndjson(response.iterator())) { if (options.transform) { yield options.transform(chunk) } else { yield chunk } } } return response } /** * @param {string | Request} resource * @param {HTTPOptions} options */ post (resource, options = {}) { return this.fetch(resource, { ...options, method: 'POST' }) } /** * @param {string | Request} resource * @param {HTTPOptions} options */ get (resource, options = {}) { return this.fetch(resource, { ...options, method: 'GET' }) } /** * @param {string | Request} resource * @param {HTTPOptions} options */ put (resource, options = {}) { return this.fetch(resource, { ...options, method: 'PUT' }) } /** * @param {string | Request} resource * @param {HTTPOptions} options */ delete (resource, options = {}) { return this.fetch(resource, { ...options, method: 'DELETE' }) } /** * @param {string | Request} resource * @param {HTTPOptions} options */ options (resource, options = {}) { return this.fetch(resource, { ...options, method: 'OPTIONS' }) } } /** * Parses NDJSON chunks from an iterator * * @param {AsyncIterable<Uint8Array>} source * @returns {AsyncIterable<any>} */ const ndjson = async function * (source) { const decoder = new TextDecoder() let buf = '' for await (const chunk of source) { buf += decoder.decode(chunk, { stream: true }) const lines = buf.split(/\r?\n/) for (let i = 0; i < lines.length - 1; i++) { const l = lines[i].trim() if (l.length > 0) { yield JSON.parse(l) } } buf = lines[lines.length - 1] } buf += decoder.decode() buf = buf.trim() if (buf.length !== 0) { yield JSON.parse(buf) } } /** * Stream to AsyncIterable * * @template TChunk * @param {ReadableStream<TChunk> | NodeReadableStream | null} source * @returns {AsyncIterable<TChunk>} */ const fromStream = (source) => { if (isAsyncIterable(source)) { return source } // Workaround for https://github.com/node-fetch/node-fetch/issues/766 if (isNodeReadableStream(source)) { const iter = source[Symbol.asyncIterator]() return { [Symbol.asyncIterator] () { return { next: iter.next.bind(iter), return (value) { source.destroy() if (typeof iter.return === 'function') { return iter.return() } return Promise.resolve({ done: true, value }) } } } } } if (isWebReadableStream(source)) { const reader = source.getReader() return (async function * () { try { while (true) { // Read from the stream const { done, value } = await reader.read() // Exit if we're done if (done) return // Else yield the chunk if (value) { yield value } } } finally { reader.releaseLock() } })() } throw new TypeError('Body can\'t be converted to AsyncIterable') } /** * Check if it's an AsyncIterable * * @template {unknown} TChunk * @template {any} Other * @param {Other|AsyncIterable<TChunk>} value * @returns {value is AsyncIterable<TChunk>} */ const isAsyncIterable = (value) => { return typeof value === 'object' && value !== null && typeof /** @type {any} */(value)[Symbol.asyncIterator] === 'function' } /** * Check for web readable stream * * @template {unknown} TChunk * @template {any} Other * @param {Other|ReadableStream<TChunk>} value * @returns {value is ReadableStream<TChunk>} */ const isWebReadableStream = (value) => { return value && typeof /** @type {any} */(value).getReader === 'function' } /** * @param {any} value * @returns {value is NodeReadableStream} */ const isNodeReadableStream = (value) => Object.prototype.hasOwnProperty.call(value, 'readable') && Object.prototype.hasOwnProperty.call(value, 'writable') HTTP.HTTPError = HTTPError HTTP.TimeoutError = TimeoutError HTTP.streamToAsyncIterator = fromStream /** * @param {string | Request} resource * @param {HTTPOptions} [options] */ HTTP.post = (resource, options) => new HTTP(options).post(resource, options) /** * @param {string | Request} resource * @param {HTTPOptions} [options] */ HTTP.get = (resource, options) => new HTTP(options).get(resource, options) /** * @param {string | Request} resource * @param {HTTPOptions} [options] */ HTTP.put = (resource, options) => new HTTP(options).put(resource, options) /** * @param {string | Request} resource * @param {HTTPOptions} [options] */ HTTP.delete = (resource, options) => new HTTP(options).delete(resource, options) /** * @param {string | Request} resource * @param {HTTPOptions} [options] */ HTTP.options = (resource, options) => new HTTP(options).options(resource, options) module.exports = HTTP