diff --git a/README.md b/README.md index 7ccd8d5..e9b1192 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,7 @@ include: in NodeJS. * `host`: The URL to prepend to all request paths; defaults to `https://api.github.com`. * `graphHost`: The URL to use for all GraphQL requests; defaults to using the value of `host` which works fine for `github.com`, but you'll need to set a separate value when working with GitHub Enterprise. -* `timeout`: The timeout in milliseconds to apply to the request; none by default. If the timeout is reached, the request will abort with an error that will have a `timeout` attribute set to the value you provided. -* `agent`: On NodeJS only, the agent to use for the HTTP connection, e.g. to do connection pooling. You may want to consider using [agentkeepalive](https://www.npmjs.com/package/agentkeepalive) if you're making a lot of requests. +* `timeout`: The timeout in milliseconds to apply to the request; none by default. If the timeout is reached, the request will abort with an `AbortError`. * `cache`: An instance of [LRUCache](https://github.com/isaacs/node-lru-cache). The objects inserted into the cache will be of the form `{value: {...}, eTag: 'abc123', status: 200, headers: {...}, size: 1763, expiry: 1770853094}`. @@ -149,7 +148,7 @@ content. Valid values are: * `body`: The contents of the request to send, typically a JSON-friendly object. * `variables`: For GraphQL queries, variables to pass to the server along with the query. * `autoQueryRateLimit`: For GraphQL queries, whether to inject a `rateLimit {cost, remaining}` property into every query. This is used to figure out the cost information passed to `onReceive` (see below). -* `responseType`: The XHR2 response type if you want to receive raw binary data; one of `text`, `arraybuffer`, `blob`, or `document`. Only useful when fetching file blobs. +* `responseType`: The response type if you want to receive raw data; one of `text`, `arraybuffer`, or `blob`. Only useful when fetching file blobs. * `perPage`: The number of items to return per page of response. Defaults to 100. * `allPages`: Whether to automatically fetch all pages by following the `next` links and concatenate the results before returning them. Defaults to true. If set to false and a result has more pages, @@ -162,7 +161,7 @@ of items. This also works for GraphQL queries, as long as your query has a `$af * `onError`: A function to be called when an error occurs, either in the request itself or an unexpected 4xx or 5xx response. If it's an error response, the error object will have `status`, `method`, `path`, and `response` attributes. If the function returns `undefined`, the promise will -be rejected as usual (or the request retried in some special cases, like socket hang ups and abuse quota 403s), if it returns `Hubkit.RETRY` the request will be retried, if it returns `Hubkit.DONT_RETRY` the promise will always be rejected, and if returns any other value the promise will be resolved with the returned value. If multiple onError handlers are assigned (e.g., in default options and in per-request options), they will all be executed, and the first non-undefined value from the most specific handler will be used. +be rejected as usual (or the request retried in some special cases, like network failures and abuse quota 403s), if it returns `Hubkit.RETRY` the request will be retried, if it returns `Hubkit.DONT_RETRY` the promise will always be rejected, and if returns any other value the promise will be resolved with the returned value. If multiple onError handlers are assigned (e.g., in default options and in per-request options), they will all be executed, and the first non-undefined value from the most specific handler will be used. * `maxTries`: The maximum number of times that a request will be tried (including the original call) if `onError` keeps returning `Hubkit.RETRY`. * `onSend`: A function to be called before every individual request gets sent to GitHub. The sole argument will be a string indicating the reason for the request: `initial` for the initial request, `page` for an automatic next page request (if the `allPages` option is on), and `retry` for an explicit or automatic retry. The function can return a duration in milliseconds that will override the timeout provided in the options (if any). The function can also return a promise for the above, in which case the request will be held until the promise is resolved. * `onReceive`. A function to be called after a reponse (or error) is received from GitHub. If a response was received then the function will be passed an object with properties `api` (indicating the API used, and hence the quota pool) and `cost` (how much quota was used by this request). The function's return value, if any, is discarded. diff --git a/eslint.config.mjs b/eslint.config.mjs index 67c22f0..ffc01ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,9 +7,13 @@ export default [ languageOptions: { globals: { lrucache: true, - axios: true, + AbortController: false, + btoa: false, + clearTimeout: false, + fetch: false, Promise: false, - setTimeout: false + setTimeout: false, + URL: false }, sourceType: 'script' } diff --git a/hubkit.js b/hubkit.js index 3cee713..5cc5ab6 100644 --- a/hubkit.js +++ b/hubkit.js @@ -1,6 +1,5 @@ if (typeof require !== 'undefined') { /* global require */ - if (typeof axios === 'undefined') axios = require('axios'); if (typeof lrucache === 'undefined') lrucache = require('lru-cache'); } @@ -10,11 +9,6 @@ if (typeof require !== 'undefined') { /* global process */ const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; - const NETWORK_ERROR_CODES = [ - 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EADDRINFO', 'ESOCKETTIMEDOUT', 'ECONNABORTED', - 'ERR_NETWORK' - ]; - class Directive { constructor(arg, body, options, hubkit) { this.arg = arg; @@ -128,8 +122,8 @@ if (typeof require !== 'undefined') { static defaults = { method: 'GET', host: 'https://api.github.com', perPage: 100, allPages: true, maxTries: 3, - maxItemSizeRatio: 0.1, metadata: Hubkit, stats: new Hubkit.Stats(), agent: false, - corsSuccessFlags: {}, gheVersion: undefined, scopes: undefined, apiVersion: undefined + maxItemSizeRatio: 0.1, metadata: Hubkit, stats: new Hubkit.Stats(), + gheVersion: undefined, scopes: undefined, apiVersion: undefined }; static RETRY = {}; // marker object @@ -147,7 +141,8 @@ if (typeof require !== 'undefined') { if (options.onRequest) await options.onRequest(options); path = interpolatePath(path, options); - let cachedItem = null, cacheKey, cacheable = options.cache && options.method === 'GET'; + let cachedItem = null, cacheKey; + const cacheable = options.cache && options.method === 'GET'; if (cacheable) { // Pin cached value, in case it gets evicted during the request cacheKey = computeCacheKey(path, options); @@ -177,44 +172,32 @@ if (typeof require !== 'undefined') { send(options.body, options._cause || 'initial'); function handleError(error, res) { - error.request = {method: options.method, url: path, headers: res && res.headers}; - if (error.request.headers) delete error.request.headers.authorization; - if (cacheable && res && res.status) { + const headers = res && Object.fromEntries( + [...res.headers].filter(([k]) => k !== 'authorization')); + error.request = {method: options.method, url: path, headers}; + if (cacheable && res) { options.cache.delete(cacheKey); if (options.stats) options.stats.record(false); } - // If the request failed due to CORS, it may be because it was both preflighted and - // redirected. Attempt to recover by reissuing it as a simple request without - // preflight, which requires getting rid of all extraneous headers. - if (cacheable && /Network Error/.test(error.originalMessage)) { - cacheable = false; - retry(); - return; - } let value; if (options.onError) value = options.onError(error); if (value === undefined) { - if (NETWORK_ERROR_CODES.indexOf(error.code) >= 0 || - [500, 502, 503, 504].indexOf(res && res.status) >= 0 || - error.originalMessage === 'socket hang up' || - error.originalMessage === 'Unexpected end of input' - ) { + if (error.networkFailure || [500, 502, 503, 504].includes(res?.status)) { value = Hubkit.RETRY; - options.agent = false; - } else if (res && res.status === 403 && res.headers['retry-after']) { + } else if (res?.status === 403 && res.headers.get('retry-after')) { try { error.retryDelay = - parseInt(res.headers['retry-after'].replace(/[^\d]*$/, ''), 10) * 1000; + parseInt(res.headers.get('retry-after').replace(/[^\d]*$/, ''), 10) * 1000; if (!options.timeout || error.retryDelay < options.timeout) value = Hubkit.RETRY; } catch { // ignore, don't retry request } - } else if (res && res.status === 403 && - res.headers['x-ratelimit-remaining'] === '0' && - res.headers['x-ratelimit-reset']) { + } else if (res?.status === 403 && + res.headers.get('x-ratelimit-remaining') === '0' && + res.headers.get('x-ratelimit-reset')) { try { error.retryDelay = - Math.max(0, parseInt(res.headers['x-ratelimit-reset'], 10) * 1000 - Date.now()); + Math.max(0, parseInt(res.headers.get('x-ratelimit-reset'), 10) * 1000 - Date.now()); if (!options.timeout || error.retryDelay < options.timeout) value = Hubkit.RETRY; } catch { // ignore, don't retry request @@ -247,9 +230,6 @@ if (typeof require !== 'undefined') { const onComplete = (res, rawData) => { extractMetadata(path, res.headers, options.metadata); - if (res.headers['access-control-allow-origin']) { - options.corsSuccessFlags[options.host] = true; - } try { if (res.status === 304) { @@ -317,14 +297,14 @@ if (typeof require !== 'undefined') { } statusError.path = path; // This is the fully expanded URL at this point. statusError.pathPattern = options.pathPattern; - statusError.response = res; + statusError.response = {...res, headers: Object.fromEntries(res.headers)}; if (options.logTag) statusError.logTag = options.logTag; statusError.fingerprint = ['Hubkit', options.method, options.logTag || options.pathPattern, `${status}`]; handleError(statusError, res); } } else if (options.media === 'raw' && !( - /^(?:text\/plain|application\/octet-stream) *;?/.test(res.headers['content-type']) + /^(?:text\/plain|application\/octet-stream) *;?/.test(res.headers.get('content-type')) )) { // retry if github disregards 'raw' handleError(new Error( @@ -332,17 +312,13 @@ if (typeof require !== 'undefined') { ), res); } else { let nextUrl; - if (res.headers.link) { - const match = /<([^>]+?)>;\s*rel="next"/.exec(res.headers.link); + if (res.headers.get('link')) { + const match = /<([^>]+?)>;\s*rel="next"/.exec(res.headers.get('link')); nextUrl = match && match[1]; if (nextUrl && !(options.method === 'GET' || options.method === 'HEAD')) { throw new Error(formatError('Hubkit', 'paginated response for non-GET method')); } } - if (!res.data && rawData && - /\bformat=json\b/.test(res.headers['x-github-media-type'])) { - res.data = JSON.parse(rawData); - } if (detectApi(path) === 'graph') { let root = res.data.data; const rootKeys = []; @@ -472,11 +448,12 @@ if (typeof require !== 'undefined') { res.data ? res.data.size || res.data.byteLength : 1; if (options.stats) options.stats.record(false, size); - if (res.status === 200 && (res.headers.etag || res.headers['cache-control']) && + if (res.status === 200 && + (res.headers.get('etag') || res.headers.get('cache-control')) && size <= options.cache.maxSize * options.maxItemSizeRatio) { options.cache.set(cacheKey, { - value: result, eTag: res.headers.etag, status: res.status, headers: res.headers, - size, expiry: parseExpiry(res.headers) + value: result, eTag: res.headers.get('etag'), status: res.status, + headers: res.headers, size, expiry: parseExpiry(res.headers) }); } else { options.cache.delete(cacheKey); @@ -490,18 +467,6 @@ if (typeof require !== 'undefined') { }; function onError(error) { - // If we get an error response without a status, then it's not a real error coming back - // from the server but some kind of synthetic response Axios concocted for us. Treat it - // as a generic network error. - if (error.response && error.response.status) return onComplete(error.response); - - if ((/Network Error/.test(error.message) || error.message === '0') && - (options.corsSuccessFlags[options.host] || - !cacheable && (options.method === 'GET' || options.method === 'HEAD')) - ) { - error.message = 'Request terminated abnormally, network may be offline'; - } - if (error.message === 'maxContentLength size of -1 exceeded') error.message = 'aborted'; error.originalMessage = error.message; error.message = formatError('Hubkit', error.message); error.fingerprint = @@ -517,18 +482,9 @@ if (typeof require !== 'undefined') { const config = { url: path, method: options.method, - timeout: timeout || 0, + timeout, params: {}, - headers: {}, - transformResponse: [data => { - rawData = data; - // avoid axios default transform for 'raw' - // https://github.com/axios/axios/issues/907 - if (options.media !== 'raw') { - return axios.defaults.transformResponse[0](data); - } - return data; - }] + headers: {} }; addHeaders(config, options, cachedItem); @@ -540,11 +496,12 @@ if (typeof require !== 'undefined') { if (body) { if (options.method === 'GET') config.params = Object.assign(config.params, body); - else config.data = body; + else config.body = body; } let received = false; try { - const res = await axios(config); + const res = await fetchResponse(config, options); + rawData = res.rawData; received = true; const api = detectApi(path); const cost = api === 'graph' ? res.data?.data?.rateLimit?.cost : 1; @@ -675,25 +632,19 @@ if (typeof require !== 'undefined') { function addHeaders(config, options, cachedItem) { /* eslint-disable dot-notation */ if (cachedItem && cachedItem.eTag) config.headers['If-None-Match'] = cachedItem.eTag; - if (isNode && options.agent) { - config[/^https:/.test(options.host) ? 'httpsAgent' : 'httpAgent'] = options.agent; - } if (options.token) { config.headers['Authorization'] = `token ${options.token}`; } else if (options.username && options.password) { throw new Error('Username / password authentication is no longer supported'); } else if (options.clientId && options.clientSecret) { - config.auth = { - username: options.clientId, - password: options.clientSecret - }; + config.headers['Authorization'] = + `Basic ${btoa(`${options.clientId}:${options.clientSecret}`)}`; } if (options.userAgent) config.headers['User-Agent'] = options.userAgent; if (options.media) config.headers['Accept'] = `application/vnd.github.${options.media}`; if (options.method === 'GET' || options.method === 'HEAD') { config.params['per_page'] = options.perPage; } - if (!isNode && options.responseType) config.responseType = options.responseType; // We can't use Cache-Control because it's not // allowed by Github's cross-domain request headers if (!isNode && (options.method === 'GET' || options.method === 'HEAD')) { @@ -705,19 +656,70 @@ if (typeof require !== 'undefined') { /* eslint-enable dot-notation */ } + async function fetchResponse(config, options) { + let timeoutId; + try { + const init = {method: config.method, headers: config.headers}; + if (config.body) { + init.body = JSON.stringify(config.body); + init.headers['Content-Type'] = 'application/json'; + } + if (config.timeout) { + const controller = new AbortController(); + timeoutId = setTimeout(() => controller.abort(), config.timeout); + init.signal = controller.signal; + } + + const url = new URL(config.url); + for (const key in config.params) url.searchParams.set(key, config.params[key]); + let response, rawData; + try { + response = await fetch(url, init); + rawData = await readResponseBody(response, options); + } catch (error) { + error.networkFailure = true; + throw error; + } + return { + status: response.status, + headers: response.headers, + data: parseResponseData(rawData, response.headers, options), + rawData + }; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + + function readResponseBody(response, options) { + switch (options.responseType) { + case 'arraybuffer': return response.arrayBuffer(); + case 'blob': return response.blob(); + default: return response.text(); + } + } + + function parseResponseData(rawData, headers, options) { + if (options.media === 'raw' || options.responseType) return rawData; + if (!rawData) return ''; + const contentType = headers.get('content-type') || ''; + if (/^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(contentType)) return JSON.parse(rawData); + return rawData; + } + function extractMetadata(path, headers, metadata) { if (!(headers && metadata)) return; const api = detectApi(path); const rateName = api === 'core' ? 'rateLimit' : `${api}RateLimit`; - metadata[rateName] = headers['x-ratelimit-limit'] && - parseInt(headers['x-ratelimit-limit'], 10); - metadata[`${rateName}Remaining`] = headers['x-ratelimit-remaining'] && - parseInt(headers['x-ratelimit-remaining'], 10); + metadata[rateName] = headers.get('x-ratelimit-limit') && + parseInt(headers.get('x-ratelimit-limit'), 10); + metadata[`${rateName}Remaining`] = headers.get('x-ratelimit-remaining') && + parseInt(headers.get('x-ratelimit-remaining'), 10); // Not every response includes an X-OAuth-Scopes header, so keep the last known set if // missing. - if ('x-oauth-scopes' in headers) { + if (headers.has('x-oauth-scopes')) { metadata.oAuthScopes = []; - const scopes = (headers['x-oauth-scopes'] || '').split(/\s*,\s*/); + const scopes = (headers.get('x-oauth-scopes') || '').split(/\s*,\s*/); if (!(scopes.length === 1 && scopes[0] === '')) { // GitHub will sometimes return duplicate scopes in the list, so uniquefy them. scopes.sort(); @@ -727,11 +729,11 @@ if (typeof require !== 'undefined') { } } } - if ('content-type' in headers) metadata.contentType = headers['content-type']; + if (headers.has('content-type')) metadata.contentType = headers.get('content-type'); } function parseExpiry(headers) { - const match = (headers['cache-control'] || '').match(/(^|[,\s])max-age=(\d+)/); + const match = (headers.get('cache-control') || '').match(/(^|[,\s])max-age=(\d+)/); if (match) return Date.now() + 1000 * parseInt(match[2], 10); } diff --git a/index.d.ts b/index.d.ts index af19fd3..4a3d632 100644 --- a/index.d.ts +++ b/index.d.ts @@ -29,14 +29,12 @@ interface Options { immutable?: boolean; fresh?: boolean; stale?: boolean; - responseType?: string; + responseType?: 'text' | 'arraybuffer' | 'blob'; maxTries?: number; timeout?: number; maxItemSizeRatio?: number; metadata?: Metadata; stats?: Stats; - agent?: any; - corsSuccessFlags?: Record; cache?: LRUCache< string, {promise: Promise, size: number} | @@ -65,7 +63,7 @@ interface Options { response?: any, logTag?: string, fingerprint?: string[], - timeout?: boolean, + networkFailure?: boolean, }): undefined | typeof Hubkit.RETRY | typeof Hubkit.DONT_RETRY | any; } diff --git a/package.json b/package.json index a935297..5ccafbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hubkit", - "version": "6.1.0", + "version": "7.0.0", "description": "GitHub API library for JavaScript, promise-based, for both NodeJS and the browser", "main": "index.js", "types": "index.d.ts", @@ -23,11 +23,10 @@ }, "homepage": "https://github.com/pkaminski/hubkit", "engines": { - "node": ">=14.0" + "node": ">=18.0" }, "packageManager": "yarn@4.13.0", "dependencies": { - "axios": "^1.13.2", "lru-cache": "11.x" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 787a00e..291a646 100644 --- a/yarn.lock +++ b/yarn.lock @@ -610,13 +610,6 @@ __metadata: languageName: node linkType: hard -"asynckit@npm:^0.4.0": - version: 0.4.0 - resolution: "asynckit@npm:0.4.0" - checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d - languageName: node - linkType: hard - "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -626,17 +619,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.13.2": - version: 1.14.0 - resolution: "axios@npm:1.14.0" - dependencies: - follow-redirects: "npm:^1.15.11" - form-data: "npm:^4.0.5" - proxy-from-env: "npm:^2.1.0" - checksum: 10c0/2541f4aa215a7d1842429dad006fc682d82bc0e74bd14500823f7d8cce3bbae0e0a8c328c8538946718f366ab8ce5a4c12e9ad40e5a0f3482ff8bff0cd115d45 - languageName: node - linkType: hard - "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -735,15 +717,6 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": - version: 1.0.8 - resolution: "combined-stream@npm:1.0.8" - dependencies: - delayed-stream: "npm:~1.0.0" - checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 - languageName: node - linkType: hard - "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -845,13 +818,6 @@ __metadata: languageName: node linkType: hard -"delayed-stream@npm:~1.0.0": - version: 1.0.0 - resolution: "delayed-stream@npm:1.0.0" - checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 - languageName: node - linkType: hard - "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -1290,16 +1256,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 - languageName: node - linkType: hard - "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -1309,19 +1265,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b - languageName: node - linkType: hard - "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" @@ -1502,7 +1445,6 @@ __metadata: version: 0.0.0-use.local resolution: "hubkit@workspace:." dependencies: - axios: "npm:^1.13.2" eslint: "npm:^9.39.1" lru-cache: "npm:11.x" npm-check-updates: "npm:^20.0.0" @@ -1900,22 +1842,6 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.52.0": - version: 1.52.0 - resolution: "mime-db@npm:1.52.0" - checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa - languageName: node - linkType: hard - -"mime-types@npm:^2.1.12": - version: 2.1.35 - resolution: "mime-types@npm:2.1.35" - dependencies: - mime-db: "npm:1.52.0" - checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 - languageName: node - linkType: hard - "minimatch@npm:^10.2.2": version: 10.2.5 resolution: "minimatch@npm:10.2.5" @@ -2131,13 +2057,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^2.1.0": - version: 2.1.0 - resolution: "proxy-from-env@npm:2.1.0" - checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 - languageName: node - linkType: hard - "punycode@npm:^2.1.0": version: 2.3.1 resolution: "punycode@npm:2.3.1"