Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import fs from 'node:fs';
import type { IncomingHttpHeaders } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { getJSON } from './http.ts';
import { getJSON, HttpError } from './http.ts';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_OWNER = 'SocketDev';
Expand Down Expand Up @@ -64,15 +65,79 @@ export interface LatestRelease {
assets: Assets;
}

// Unauthenticated GitHub API calls are limited to 60/hour per IP, which shared
// CI runners and NAT'd networks exhaust quickly. Use a token when one is set.
function githubToken(): string | undefined {
return process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || undefined;
}

function apiHeaders(token: string | undefined): Record<string, string> {
return {
Accept: 'application/vnd.github+json',
...(token && { Authorization: `Bearer ${token}` }),
};
}

export async function fetchLatest(): Promise<LatestRelease> {
const json = await getJSON(LATEST_API, { userAgent: USER_AGENT });
const token = githubToken();
// biome-ignore lint/suspicious/noExplicitAny: reasonable type for the GitHub API response
let json: any;
try {
json = await getJSON(LATEST_API, { userAgent: USER_AGENT, headers: apiHeaders(token) });
} catch (e) {
// An expired or invalid token shouldn't break a public, anonymous fetch.
if (!(token && e instanceof HttpError && e.statusCode === 401)) {
throw e;
}
json = await getJSON(LATEST_API, { userAgent: USER_AGENT, headers: apiHeaders(undefined) });
}
return {
tag: json.tag_name,
name: json.name || json.tag_name,
assets: Array.isArray(json.assets) ? json.assets : [],
};
}

// When the limit lifts. Secondary limits send `retry-after`, which takes
// precedence: `x-ratelimit-reset` is on every response and is the primary
// hourly window, so it's only meaningful once that quota is used up.
function rateLimitWait(headers: IncomingHttpHeaders): string {
const retryAfter = Number(headers['retry-after']);
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
return ` (retry after ${retryAfter}s)`;
}
const reset = Number(headers['x-ratelimit-reset']);
if (headers['x-ratelimit-remaining'] === '0' && Number.isFinite(reset) && reset > 0) {
return ` (resets at ${new Date(reset * 1000).toISOString()})`;
}
return '';
}

// Turn a fetchLatest() failure into a message that says what went wrong and,
// where we can tell, what to do about it.
export function describeFetchError(e: unknown): string {
if (e instanceof HttpError) {
const remaining = e.headers['x-ratelimit-remaining'];
const retryAfter = e.headers['retry-after'];
const rateLimited =
e.statusCode === 429 ||
(e.statusCode === 403 && (remaining === '0' || retryAfter !== undefined));
if (rateLimited) {
// Hint at a token only if the failing request didn't already send one
// (it may have been rejected and retried anonymously).
const hint = e.authenticated
? 'The configured GITHUB_TOKEN/GH_TOKEN has also hit its limit.'
: 'Set GITHUB_TOKEN (or GH_TOKEN) to raise the limit.';
Comment thread
jerodsanto marked this conversation as resolved.
Resolved
Comment thread
agrathwohl marked this conversation as resolved.
Resolved
return `${e.message}: GitHub API rate limit exceeded${rateLimitWait(e.headers)}. ${hint}`;
}
return e.message;
}
if (e instanceof Error) {
return e.message;
Comment thread
agrathwohl marked this conversation as resolved.
Resolved
}
return String(e);
}

export function findAssetUrl(
assets: Array<{ name: string; browser_download_url: string; digest: string }>,
name: string,
Expand Down
47 changes: 44 additions & 3 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IncomingMessage } from 'node:http';
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
import https from 'node:https';
import { URL } from 'node:url';

Expand All @@ -8,6 +8,38 @@ export interface HttpGetOptions {
userAgent?: string;
}

export class HttpError extends Error {
readonly url: string;
Comment thread
agrathwohl marked this conversation as resolved.
Resolved
readonly statusCode: number;
readonly headers: IncomingHttpHeaders;
// Whether the request that failed carried an Authorization header.
readonly authenticated: boolean;

constructor(
url: string,
statusCode: number,
headers: IncomingHttpHeaders,
authenticated: boolean,
) {
super(`GET ${url} failed with ${statusCode}`);
this.name = 'HttpError';
this.url = url;
this.statusCode = statusCode;
this.headers = headers;
this.authenticated = authenticated;
}
}

function isAuthorizationHeader(name: string): boolean {
return name.toLowerCase() === 'authorization';
}

// Credentials must never follow a redirect to a different host (e.g. from
// api.github.com to the release-asset CDN).
function withoutAuthorization(headers: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(headers).filter(([k]) => !isAuthorizationHeader(k)));
}

export async function httpsGet(
url: string,
options: HttpGetOptions = {},
Expand All @@ -31,11 +63,20 @@ export async function httpsGet(
}
if (statusCode >= 300 && statusCode < 400 && h.location && maxRedirects > 0) {
res.resume();
return resolve(httpsGet(h.location, { ...options, maxRedirects: maxRedirects - 1 }));
const next = new URL(h.location, u);
const nextHeaders = next.host === u.host ? headers : withoutAuthorization(headers);
return resolve(
httpsGet(next.toString(), {
...options,
headers: nextHeaders,
maxRedirects: maxRedirects - 1,
}),
);
}
if (statusCode < 200 || statusCode >= 300) {
res.resume();
return reject(new Error(`GET ${url} failed with ${statusCode}`));
const authenticated = Object.keys(headers).some(isAuthorizationHeader);
return reject(new HttpError(url, statusCode, h, authenticated));
}
resolve(res);
})
Expand Down
15 changes: 12 additions & 3 deletions src/sfw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { fileURLToPath } from 'node:url';

import yoctoSpinner from 'yocto-spinner';

import { expectedAssetName, fetchLatest, findAssetUrl, type LatestRelease } from './github.ts';
import {
describeFetchError,
expectedAssetName,
fetchLatest,
findAssetUrl,
type LatestRelease,
} from './github.ts';
import { eagerAcquireLockAndDownload } from './resumable.ts';
import { swallowError } from './util.ts';

Expand Down Expand Up @@ -166,8 +172,11 @@ async function ensureLatestBinary(): Promise<LatestBinary> {
setNextCheckTimeSync();
try {
releaseInfo = await fetchLatest();
} catch (_e) {
throw new Error('Unable to fetch latest release and no valid cached release found.');
} catch (e) {
throw new Error(
`Unable to fetch latest release and no valid cached release found: ${describeFetchError(e)}`,
{ cause: e },
);
}
return await downloadAndVerifyReleaseSync(releaseInfo, assetName);
}
Expand Down
Loading