Skip to content
ReviewablePublic

About

GitHub API library for JavaScript, promise-based, for both Node and the browser.

Resources

Stars

7 stars

Watchers

6 watching

Forks

Repository files navigation

hubkit

Project Status: Active - The project has reached a stable, usable state and is being actively developed.

A simple GitHub API library for JavaScript that works in both NodeJS 22+ and the browser. Features:

  • Takes a request-level approach that naturally covers the entire GitHub v3 API.
  • Supports the GraphQL v4 API.
  • All requests return promises. (You may need to add a polyfill in the browser, depending on your target platforms.)
  • Responses are (optionally) cached (segregated by user identity), and requests are conditional to save on bandwidth and request quota. Inspired by simple-github, octo, and octokit.

Integration and dependencies

You need to ensure that an ES2015-compatible Promise class is defined.

Caching is enabled by default but you can override with a custom instance of LRUCache passed as an option to the constructor. If the cache is enabled Hubkit respects Cache-Control headers on the response (that GitHub currently seems to set to 1 minute for all requests), and will return a potentially stale value from the cache unless you specify {fresh: true}.

Usage

A simple REST example:

var gh = new Hubkit({
  token: '123456890ABCDEF',
  owner: 'pkaminski',
  repo: 'hubkit'
});
gh.request('GET /repos/:owner/:repo/commits').then(console.log);
gh.request('GET /repos/:owner/:repo/git/commits/:sha', {sha: '09876abc'}).then(console.log);
gh.request('POST /repose/{owner}/{repo}/pulls', {body: {title: 'foo', head: 'bar', base: 'master'}});

And one for GraphQL:

// initialize gh as above
gh.graph(`
  query ($after: String) {
    search (type: ISSUE, first: 10, after: $after, query: `type: pr`) {
      pageInfo {hasNextPage, endCursor},
      nodes {
        ... on PullRequest {
          number, title
        }
      }
    }
  }
`);

You issue requests exactly as documented in GitHub's REST API or GraphQL API. For REST, path segments of the form :foo or {foo} are interpolated from the options object passed as the second argument and defaulting to the options object passed to the constructor. The method can be specified either together with the path, or as a {method: 'GET'} option (the inline one takes precedence, and GET is the default if nothing else is found).

GraphQL queries are first run through a preprocessor that supports the following directives:

  • #ghe(minVersion): The following block is excluded if the GHE version is too old.
  • #scope(scope): The following block is excluded if the user's authorization lacks the specified scope.
  • #exists(type[.field]): The following block is excluded if the given type or field does not exist in the schema.
  • #field(type, field1[, field2[, ...]]): Gets substituted with the first given field that exists in the schema for the given type.

This is useful since GraphQL forbids references to fields not in the schema, and GHE servers in the field are often months or years behind github.com in that respect. To use the #ghe or #scope directives you need to include the gheVersion or scopes properties respectively in the options (see below).

Schema information for the #exists and #field directives is queried from the server and is cached indefinitely in memory (regardless of any cache related options).

Here is an example demonstrating each directive:

gh.graph(`
  query ($owner: String!, $repo: String!, $number: Int!) {
    repository (owner: $owner, name: $repo) {
      pullRequest (number: $number) {
        id, number, title,
        #ghe(2.17) {
          isDraft
        #}
        #exists(PullRequest.mergeQueueEntry) {
          mergeQueueEntry {
            headCommit {oid}
          }
        #}
        reviewRequests {
          nodes {
            requestedReviewer {
              ...on User {
                login, name,
                id: #field(User, fullDatabaseId, databaseId)
              }
              #scope(read:org) {
                ...on Team {combinedSlug, name}
              #}
            }
          }
        }
      }
    }
  }
`);

There are two ways to authenticate: either pass a token to the options, or both a clientId and clientSecret. Unauthenticated requests are fine too, of course.

Every call returns a Promise. The returned values are exactly as documented in the GitHub API, except that requests with option {boolean: true} will return true or false instead (sorry, no way to automate it). Note that for paged responses, all pages will be concatenated together into the return value by default (see below).

After every request, quota information and oAuthScopes (the scopes your authorization entitles you to) are available on your metadata object (see below), or on Hubkit if you didn't set one. The default destination is the Hubkit constructor, shared by all instances. Supply a separate metadata object to keep observations separate. Quota metadata is also updated on HTTP errors, before onError runs or the request rejects.

Core quota Search quota GraphQL quota Meaning
rateLimit searchRateLimit graphRateLimit Maximum quota
rateLimitRemaining searchRateLimitRemaining graphRateLimitRemaining Remaining quota
rateLimitResetTimestamp searchRateLimitResetTimestamp graphRateLimitResetTimestamp Reset time, in milliseconds since the Unix epoch
rateLimitTimestamp searchRateLimitTimestamp graphRateLimitTimestamp Header receipt time, in milliseconds since the Unix epoch

Each bucket is updated independently from response headers. x-ratelimit-resource selects the bucket when present; otherwise Hubkit infers it from the request URL. Other resource families are not recorded in these fields. An observation contains the valid nonnegative integer quota headers from that response; missing or invalid fields are undefined, so values from different observations are not combined. If no valid quota headers are present, the previous observation is left unchanged. Quota is recorded when response headers arrive, before reading the body, so a slow body cannot overwrite a later observation or make older quota appear fresh. Received quota headers remain available even if reading the body subsequently fails. Cache hits and transport failures before receiving headers do not refresh observations. A 304 response can update quota from its own headers, but never from cached headers. Retries and automatic pagination leave the latest received quota observation in metadata when the request finishes. For shared fetches, every waiting caller receives the quota observation when headers arrive. A caller joining while the body is still pending receives the original observation timestamp; joining never replaces an equal-time or newer quota observation already in that caller's metadata.

oAuthScopesTimestamp records when the x-oauth-scopes header was received, in milliseconds since the Unix epoch. Scopes and their timestamp update together, before reading the body, including on HTTP errors and for callers sharing a fetch. A missing scope header leaves both fields unchanged, even when quota headers are present. An empty scope header records an empty array. Newly received scope headers update metadata in arrival order, even if timestamps tie. Scope observations from late joiners or cached headers never replace an equal-time or newer scope observation. A 304 may restore cached scopes with their original timestamp; only an explicit scope header on the 304 gives them a new timestamp, which is retained with the updated scopes for later revalidations.

Since 9.1.0, missing quota headers no longer assign null or an empty string to quota fields. Before the first valid observation, quota properties may be absent entirely. A partial observation creates the bucket's fields, with missing or invalid values set to undefined. Check whether a value is available with typeof metadata.rateLimit === 'number'; do not rely on === null or 'rateLimit' in metadata.

You can augment a Hubkit instance by calling gh.scope({...moreOptions}) to return a new instance that combines both sets of options.

Identifying 403 errors

Hubkit.identify403Error(error) identifies known GitHub 403 causes from a message string or an object with a message property, including an Error. It accepts raw GitHub messages and messages prefixed by Hubkit or a server response wrapper. Unknown messages return undefined. It only examines the message; the caller should check the HTTP status as appropriate. The quota patterns can also be used for 429 errors.

The result contains a stable, detailed code. Authentication and access failures also include a broad category and a concise error description. Quota failures instead have quota: true and no category or error, so callers can supply their own retry guidance.

code category error quota
account-suspended badauth GitHub account suspended
email-unverified badauth Email address not verified
saml-enforcement badauth Incomplete SAML authorization
admin-required badauth No admin rights
two-factor-required badauth Two-factor authentication not set up
oauth-app-restrictions thirdparty Third-party app restrictions in effect
ip-allow-list iprestricted GitHub IP allow list blocks access
access-blocked notfound Repository access blocked
secondary-rate-limit true
rate-limit true
const reason = Hubkit.identify403Error(error);
if (reason?.category) {
  // Consumers using broad error codes can retain their existing representation.
  return {code: reason.category, error: reason.error};
}

The detailed code distinguishes causes for diagnostics or error grouping without including request URLs or organization names. The rate-limit code covers other rate-limit, request-quota, and abuse-detection messages.

HTTP error bodies (breaking change in 9.0.0)

For HTTP status codes 400 and above, Hubkit reads the response body as text, regardless of media or responseType. In the error passed to onError or rejected by the request promise:

  • error.response.rawData is the body as text.
  • error.response.data is the parsed JSON value when the response Content-Type is application/json or an application/*+json type. Otherwise it is the body as text.
  • Malformed JSON and empty bodies remain text, preserving the HTTP status and original text instead of replacing the HTTP error with a JSON parsing error.

Successful responses keep their requested representation. When upgrading from 8.x, update any error handlers that expect a Blob or ArrayBuffer in error.response.data or error.response.rawData; those fields now contain parsed JSON or text as described above. This also applies to handlers that recover from an HTTP error by returning a value from onError.

Automatic retries

Automatic retries for network failures, server errors, and rate limits are restricted to idempotent operations: REST GET, HEAD, OPTIONS, TRACE, PUT, and DELETE, plus recognized GraphQL queries. By default, REST POST/PATCH requests and GraphQL mutations are not retried automatically, including mutations that return partial data alongside errors. Return Hubkit.RETRY from onError to explicitly retry an operation when the caller knows it is safe; maxTries still applies.

GraphQL detection conservatively recognizes a leading query keyword or shorthand {, skipping comments, whitespace, BOMs, and commas. If body.operationName is supplied, it must match the leading query's name. Documents starting with fragments or descriptions, or selecting a later operation, require an explicit retry decision. This same classification controls the error's method attribute.

The top-level idempotent: true option enables the usual automatic retries when the caller knows the operation is idempotent, including GraphQL queries starting with fragments. Set it to false to disable automatic retries for an otherwise recognized idempotent operation. Only boolean values override the inference, and onError still takes precedence. The option is local to Hubkit and is not sent to GitHub. For example:

await gh.graph(fragmentFirstQuery, {idempotent: true, variables});

The flag also works with request('POST /graphql', {idempotent: true, body: {query}}), REST requests, and scoped defaults. It controls retries without changing error.method.

Options reference

Valid options to pass (to the constructor or to each request), or to set on Hubkit.defaults, include:

  • token: String token to use for authentication; takes precedence over other auth methods.
  • clientId and clientSecret: For app-based anonymous authentication (increased API quotas without impersonating a user).
  • userAgent: The user-agent to present in requests. Uses the browser's user agent, or Hubkit 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 for each attempt, including waiting for a shared fetch; none by default. Each caller runs its own onSend to override this timeout, then enforces the resulting budget independently. Zero produces a TimeoutError immediately before sending or joining, invoking onError but not onReceive. It rejects by default; onError can recover or explicitly request a retry within maxTries. A positive timeout follows the caller's normal network-error/retry policy. Timing out does not abort a fetch while another caller is waiting; a pending fetch is aborted when its last caller leaves. An already completed cached response is returned regardless of the timeout.
  • cache: An instance of LRUCache. The objects inserted into the cache will be of the form {value: {...}, eTag: 'abc123', status: 200, headers: {...}, timestamp: 1770853000000, size: 1763, expiry: 1770853094}. You can use the (approximate) size field to help your cache determine when to evict items, but note that it tends to underestimate the actual size size of the object by 3-4x. The default cache is set to hold ~10MB of the measured bytes amount (so ~30-40MB of actual memory usage). While requests are being processed, the cache can also contain internal {pending: number, cachedItem?: object, size: 100} entries. cachedItem retains the previous completed cache entry, when present, for conditional requests and 304 restoration. Concurrent callers share individual fetches and raw response bodies, but run their own preparation callbacks, JSON parsing, pagination, and error/retry policies. The initiating caller owns the transport-level onReceive callback. The initiating caller reuses the transport's parsed response; joiners parse separate copies so pagination and error-handler mutations cannot affect another caller. Pending entries are removed when all their callers finish unless replaced by a completed response; rejected promises are never cached.
  • fresh: If true, force a request to be issued to the server even if a cache is in use and an unexpired value available. This is different from turning off the cache for the request since it can still make use of ETags and get a cheap 304 response in return.
  • maxItemSizeRatio: The maximum ratio of the size of any single item to the size of the cache, to avoid blowing away the entire cache with one huge item. The default is set to 0.1, limiting each item to at most 1/10th the max size of the cache.
  • stats: Reports the cache hit rate via hitRate (number of items hit / total attempted) and hitSizeRate (total size of items hit / total attempted) attributes. These metrics measure response reuse. Joining a fetch counts as a hit when its response is processed, including HTTP errors whether the caller recovers, retries, or rejects. Two callers sharing one 500 response record one miss and one hit; each retry records its own response separately. Completed cache hits and 304 revalidations also count as hits. You can reset() the stats to start counting from scratch again. A default instance is set on Hubkit.defaults but you can also assign a new Hubkit.Stats() to a Hubkit instance if you prefer.
  • immutable: If true, indicates that the return value for this call is immutable, so if it's available in the cache it can be reused without sending a request to GitHub to check freshness.
  • stale: If true, any cached value is considered acceptable, even if it has expired.
  • method: The HTTP method to use for the request.
  • media: A GitHub-specific media type for the response content. Valid values are:
    • for comment bodies: raw+json (default), text+json, html+json, full+json
    • for blobs: json (default), raw
    • for commits, etc.: diff, patch
  • body: The contents of the request to send, typically a JSON-friendly object.
  • idempotent: A boolean overriding whether the operation is eligible for automatic retries. Applies to REST and GraphQL requests; inferred from the HTTP method or GraphQL document when omitted. onError takes precedence.
  • 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 response type if you want to receive raw data; one of text, arraybuffer, or blob. Only useful when fetching file blobs. Raw response bodies are shared without copying. Treat returned ArrayBuffer instances as read-only: copy a buffer before mutating it, resizing it, or transferring/detaching it. Applies to successful responses only; HTTP error responses ignore this option (see HTTP error bodies).
  • 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, you'll find a next() function on the result that you can call to get a promise with the next page of items. This also works for GraphQL queries, as long as your query has a $after: String parameter defined, and the results have a single top-level key with pageInfo {hasNextPage, endCursor} and either nodes or edges children. Use edges when you need per-edge fields such as permission.
  • boolean: If true, interprets a 404 as false and a 20x as true.
  • metadata: The object on which to set metadata found in the response headers. Defaults to Hubkit.
  • ifNotFound: A value to return instead of throwing an exception when the request results in a 404.
  • ifGone: A value to return instead of throwing an exception when the request results in a 410.
  • 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. GraphQL errors returned with HTTP 200 have a synthesized error.status, which also controls automatic retries; error.response.status retains the original HTTP status. If the function returns undefined, the promise will be rejected as usual (or the request retried in some special cases, like network failures and rate-limited 403s or 429s), 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.

Rate-limited 403 and 429 responses follow the same retry rules: Retry-After takes precedence; otherwise, exhausted quota (x-ratelimit-remaining: 0) with x-ratelimit-reset determines the delay. Both headers must contain nonnegative integer seconds (x-ratelimit-reset since the Unix epoch); HTTP-date Retry-After values are unsupported. Invalid values and delays exceeding the timer limit of 2,147,483,647 milliseconds do not trigger automatic retries or set error.retryDelay. Automatic retries respect maxTries and require the delay to fit within timeout when one is set. error.retryDelay contains the computed delay in milliseconds before onError runs, including when the handler suppresses retries, and remains available if the request rejects.

  • 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 called before each caller's attempt, including attempts that join an existing fetch. The sole argument indicates the reason: initial, page for an automatic next page, or retry for an explicit or automatic retry. Sharing is determined after this callback completes. The function can return a duration in milliseconds that overrides the options timeout. Returning 0 invokes onError immediately with a TimeoutError, without sending or joining a fetch or invoking onReceive; see timeout above for recovery/retry behavior. Returning undefined or null keeps the options timeout. The function can also return a promise for the above; that caller waits for it before proceeding.
  • onReceive: A function called once per physical fetch, after its response body finishes or its transport fails or aborts. Only the initiating caller's callback runs, even if that caller has already timed out while other callers keep the fetch alive. An individual caller timing out does not invoke it while the transport continues. The first argument is an object with api (the quota pool) and cost (quota used), or undefined when no complete response was received. GraphQL cost may be zero or unknown. The second argument is the physical fetch latency in milliseconds, measured after onSend completes and through the body read or transport failure/abort. A thrown exception rejects the shared attempt for every caller still waiting; each caller applies its own onError and retry policy. The callback is not invoked again for its own exception. Response metadata is copied into each caller's metadata object independently. The function's return value is discarded. Completed cache hits and zero timeouts that prevent sending invoke no onReceive.
  • gheVersion: A string representing the version of the GitHub Enterprise server you're making calls to. You can retrieve it via a request to /meta. Ignored if your host is https://api.github.com (and all #ghe preprocessing directives pass automatically). Otherwise, if a #ghe directive is encountered and gheVersion is not set then an error is thrown.
  • scopes: An array of strings representing all the scopes granted to the user's token. (Note that some scopes imply others, but Hubkit doesn't expand these internally — you might want to do so yourself.) If a #scope directive is encountered and scopes is not set then an error is thrown.

About

GitHub API library for JavaScript, promise-based, for both Node and the browser.

Resources

Stars

7 stars

Watchers

6 watching

Forks

Releases

Packages

Used by

Contributors

Languages