UNPKG

@versatiles/container

Version:
109 lines (108 loc) 4.7 kB
import https from 'https'; import http from 'http'; const DEFAULT_TIMEOUT = 10000; /** * A collection mapping protocol names to their respective `ClientInfo`. */ const clients = { https: { client: https, agent: new https.Agent({ keepAlive: true }) }, http: { client: http, agent: new http.Agent({ keepAlive: true }) }, }; /** * Creates a function capable of reading data from a specified URL, which can be used * to read data chunks in an HTTP GET request. This is particularly useful for * operations such as streaming or handling large data in segments. * * @param url - The URL from which data will be read. * @returns A `Reader` function that asynchronously reads a specified chunk of data from the URL. */ export default function getHTTPReader(url) { const protocol = new URL(url).protocol.slice(0, -1); if (!(protocol in clients)) { throw new Error(`Unsupported protocol: ${protocol}`); } /** * Asynchronously reads a data chunk from the provided URL based on the specified range. * * @param position - The starting byte position of the data chunk to read. * @param length - The number of bytes to read from the starting position. * @returns A promise that resolves with a `Buffer` containing the data chunk. * If the request fails or the server responds with a non-successful status code, * the promise is rejected with an error. */ return async function read(position, length) { if (position < 0) { throw new RangeError(`Invalid read position: ${position}. The read position must be a non-negative integer.`); } if (length < 0) { throw new RangeError(`Invalid read length: ${length}. The read length must be a non-negative integer.`); } const headers = { 'user-agent': 'Mozilla/5.0 (compatible; versatiles; +https://www.npmjs.com/package/versatiles)', range: `bytes=${position}-${position + length - 1}`, }; /** * Performs the HTTP request and retrieves the response. * @type {IncomingMessage} */ const message = await new Promise((resolve, reject) => { const watchdog = setTimeout(() => { req.destroy(); reject(new Error('Request timed out')); }, DEFAULT_TIMEOUT); const req = clients[protocol].client .request(url, { method: 'GET', agent: clients[protocol].agent, headers, timeout: DEFAULT_TIMEOUT, }) .on('response', (response) => { clearTimeout(watchdog); resolve(response); }) .on('error', (err) => { clearTimeout(watchdog); req.destroy(); reject(err); }) .end(); }); if (message.statusCode == null || Math.floor(message.statusCode / 100) !== 2) { message.destroy(); throw new Error(`Server responded with status code: ${message.statusCode} `); } const contentRange = message.headers['content-range']; if (contentRange == null) throw Error('The response header does not contain "content-range"'); const parts = /^bytes (\d+)-(\d+)\/(\d+)/i.exec(contentRange); if (parts == null) throw Error('"content-range" in response header is malformed'); if (position !== parseInt(parts[1], 10)) throw Error(`requested position (${position}) and returned offset (${parts[1]}) must be equal`); if (position + length > parseInt(parts[3], 10)) { throw new RangeError(`Read range out of bounds: The requested range ends at position ${position + length}, which exceeds the file's limit of ${parts[3]} bytes.`); } const returnedLength = parseInt(parts[2], 10) + 1 - position; if (length !== returnedLength) { throw new Error(`Returned length (${returnedLength}) is not requested length (${length}).`); } /** * Collects and concatenates response data chunks into a buffer. * @type {Buffer} */ const body = await new Promise((resolve, reject) => { const buffers = []; message .on('data', (chunk) => buffers.push(chunk)) .on('error', (err) => { message.destroy(); reject(err); }) .once('end', () => { resolve(Buffer.concat(buffers)); }); }); return body; }; }