import http from 'http' import https from 'https' import stream from 'stream/promises' import fs from 'fs' import Util from './util.mjs' function resolveRelative(from, to) { const resolvedUrl = new URL(to, new URL(from, 'resolve://')); if (resolvedUrl.protocol === 'resolve:') { // `from` is a relative URL. const { pathname, search, hash } = resolvedUrl; return pathname + search + hash; } return resolvedUrl.toString(); } export function request(config, path, filePath = null, redirects, fastRaw = false) { if (!config || typeof(config) !== 'object' || Array.isArray(config)) { return Promise.reject(new Error('Request must be called with config in first parameter')) } let newRedirects = (redirects || 0) + 1 if (!path || typeof(path) !== 'string' || !path.startsWith('http')) { return Promise.reject(new Error('URL was empty or invalid' + (typeof(path) === 'string' ? ': ' + path : ''))) } let parsed try { parsed = new URL(path) } catch { return Promise.reject(new Error('URL was empty or invalid: ' + path)) } let h = http if (parsed.protocol === 'https:') { h = https } let req = null let orgErr = new Error(); return new Promise(function(resolve, reject) { if (!path) { return reject(new Error('Request path was empty')) } let headers = { 'User-Agent': 'TheThing/service-core', Accept: 'application/vnd.github.v3+json' } if (config.token) { headers['Authorization'] = `token ${config.token}` } let timeout = config.timeout || 10000 let timedout = false let timer = setTimeout(function() { timedout = true if (req) { req.destroy() } reject(Util.combineStack(new Error(`Request ${path} timed out after ${timeout}ms`), orgErr)) }, timeout) req = h.request({ path: parsed.pathname + parsed.search, port: parsed.port, method: 'GET', headers: headers, timeout: timeout, hostname: parsed.hostname }, function(res) { if (timedout) { return } clearTimeout(timer) const ac = new AbortController() let output = '' if (filePath) { stream.pipeline(res, fs.createWriteStream(filePath), { signal: ac.signal }) .then(function() { resolve({ statusCode: res.statusCode, status: res.statusCode, statusMessage: res.statusMessage, headers: res.headers, body: null }) }, function(err) { if (err.code === 'ABORT_ERR') return reject(Util.combineStack(err, orgErr)) }) // let file = fs.createWriteStream(filePath) // res.pipe(file) } else { res.on('data', function(chunk) { output += chunk }) } res.on('end', function() { let err = null if (res.statusCode >= 300 && res.statusCode < 400) { if (newRedirects > 5) { err = new Error(`Too many redirects (last one was ${res.headers.location})`) } else if (!res.headers.location) { err = new Error('Redirect returned no path in location header') } else if (res.headers.location.startsWith('http')) { ac.abort() return resolve(request(config, res.headers.location, filePath, newRedirects, fastRaw)) } else { ac.abort() return resolve(request(config, resolveRelative(path, res.headers.location), filePath, newRedirects, fastRaw)) } } else if (res.statusCode >= 400) { err = new Error(`HTTP Error ${res.statusCode}: ${output}`) } if (err) { ac.abort() if (!filePath) return reject(Util.combineStack(err, orgErr)) // Do some cleanup in case we were in the middle of downloading file return fs.rm(filePath, function() { reject(Util.combineStack(err, orgErr)) }) } // Let the pipeline do the resolving so it can finish flusing before calling resolve if (!filePath) { resolve({ statusCode: res.statusCode, status: res.statusCode, statusMessage: res.statusMessage, headers: res.headers, body: output }) } }) }) req.on('error', function(err) { if (timedout) return let wrapped = new Error(`Error during request ${path}: ${err.message}`) wrapped.code = err.code reject(Util.combineStack(wrapped, orgErr)) }) req.on('timeout', function(err) { if (timedout) return reject(Util.combineStack(err, orgErr)) }) req.end() }).then(function(res) { if (req) { req.destroy() } if (!filePath && !fastRaw) { if (typeof(res.body) === 'string') { try { res.body = JSON.parse(res.body) } catch(e) { if (res.body.indexOf('