1
mirror of https://github.com/jakejarvis/hugo-extended.git synced 2026-06-12 08:45:27 -04:00

feat: add HTTP proxy support for binary downloads

Use undici's EnvHttpProxyAgent to automatically respect standard proxy
environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) when downloading
Hugo binaries during installation.

Closes #178
This commit is contained in:
2026-01-16 10:51:28 -05:00
parent 6cd02e5744
commit e2ae415adb
6 changed files with 256 additions and 9 deletions
+12
View File
@@ -137,6 +137,18 @@ npm run test:coverage # coverage via v8
Some variables have aliases (e.g., `HUGO_FORCE_STANDARD``HUGO_NO_EXTENDED`, `HUGO_SILENT``HUGO_QUIET`). Check `ENV_VARS` in `src/lib/env.ts` for the full list.
### Proxy support
The installer automatically respects standard proxy environment variables via `undici`'s `EnvHttpProxyAgent`:
| Variable | Description |
|----------|-------------|
| `HTTP_PROXY` / `http_proxy` | Proxy server for HTTP requests |
| `HTTPS_PROXY` / `https_proxy` | Proxy server for HTTPS requests |
| `NO_PROXY` / `no_proxy` | Comma-separated list of hosts to bypass proxy |
Lowercase variants take precedence over uppercase (matching standard convention). The proxy URL is logged once during installation (respects `HUGO_QUIET`).
### Version-dependent behavior
- **macOS v0.153.0+**: Hugo ships as `.pkg` installer, extracted locally using `pkgutil --expand-full` (no sudo required).
+11 -5
View File
@@ -11,7 +11,8 @@
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",
"tar": "^7.5.2"
"tar": "^7.5.2",
"undici": "^6.23.0"
},
"bin": {
"hugo": "dist/cli.mjs",
@@ -2217,7 +2218,6 @@
"integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.107.0",
"@rolldown/pluginutils": "1.0.0-beta.59"
@@ -2545,7 +2545,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2568,6 +2567,15 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -2608,7 +2616,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -2684,7 +2691,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
+2 -1
View File
@@ -59,7 +59,8 @@
},
"dependencies": {
"adm-zip": "^0.5.16",
"tar": "^7.5.2"
"tar": "^7.5.2",
"undici": "^6.23.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
+82
View File
@@ -0,0 +1,82 @@
/**
* Proxy-aware fetch utility for Hugo binary downloads.
*
* Automatically uses HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment
* variables when making network requests during installation.
*
* @module
*/
import type { RequestInit, Response } from "undici";
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
import { logger } from "./utils";
/**
* Shared proxy agent that reads HTTP_PROXY, HTTPS_PROXY, NO_PROXY from env.
* The agent handles both lowercase and uppercase variants, with lowercase
* taking precedence.
*/
const proxyAgent = new EnvHttpProxyAgent();
/**
* Track whether we've already logged the proxy message to avoid spam.
*/
let hasLoggedProxy = false;
/**
* Gets the proxy URL from environment variables, if configured.
*
* Checks both uppercase and lowercase variants, with lowercase taking
* precedence (matching undici's behavior).
*
* @returns The proxy URL if configured, undefined otherwise
*/
export function getProxyUrl(): string | undefined {
return (
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY
);
}
/**
* Proxy-aware fetch that automatically uses HTTP_PROXY/HTTPS_PROXY env vars.
*
* This is a drop-in replacement for native fetch() that routes requests
* through a proxy server when the standard proxy environment variables
* are set:
*
* - `HTTP_PROXY` / `http_proxy` - Proxy for HTTP requests
* - `HTTPS_PROXY` / `https_proxy` - Proxy for HTTPS requests
* - `NO_PROXY` / `no_proxy` - Comma-separated list of hosts to bypass
*
* @param url - The URL to fetch
* @param init - Optional fetch init options (same as native fetch)
* @returns A Promise that resolves to the Response
*
* @example
* ```typescript
* // With HTTPS_PROXY=http://proxy.example.com:8080
* const response = await proxyFetch('https://github.com/...');
* // Request is routed through the proxy
* ```
*/
export async function proxyFetch(
url: string | URL,
init?: RequestInit,
): Promise<Response> {
// Log proxy usage once per installation (respects HUGO_QUIET)
if (!hasLoggedProxy) {
const proxyUrl = getProxyUrl();
if (proxyUrl) {
logger.info(`Using proxy: ${proxyUrl}`);
}
hasLoggedProxy = true;
}
return undiciFetch(url, {
...init,
dispatcher: proxyAgent,
});
}
+7 -3
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import AdmZip from "adm-zip";
import * as tar from "tar";
import { getEnvConfig } from "./env";
import { proxyFetch } from "./fetch";
import {
getBinFilename,
getBinVersion,
@@ -76,14 +77,17 @@ export function parseChecksumFile(content: string): Map<string, string> {
* @returns A promise that resolves when the download is complete
*/
async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetch(url);
const response = await proxyFetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.statusText}`);
}
if (!response.body) {
throw new Error(`No response body from ${url}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
await pipeline(
Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]),
fs.createWriteStream(dest),
);
}
/**
@@ -149,7 +153,7 @@ async function verifyChecksum(
checksumUrl: string,
filename: string,
): Promise<void> {
const response = await fetch(checksumUrl);
const response = await proxyFetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksums: ${response.statusText}`);
}
+142
View File
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock undici before importing the module under test
vi.mock("undici", () => {
// Create a mock class for EnvHttpProxyAgent
class MockEnvHttpProxyAgent {}
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
});
return {
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
fetch: mockFetch,
};
});
// Mock the logger to avoid console output during tests
vi.mock("../../src/lib/utils", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Import after mocks are set up
import { getProxyUrl, proxyFetch } from "../../src/lib/fetch";
describe("fetch", () => {
// Store original env vars to restore after each test
const originalEnv: Record<string, string | undefined> = {};
// List of all proxy env vars we might set during tests
const proxyEnvVars = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"NO_PROXY",
"no_proxy",
];
beforeEach(() => {
// Save original values and clear
for (const key of proxyEnvVars) {
originalEnv[key] = process.env[key];
delete process.env[key];
}
// Reset mocks
vi.clearAllMocks();
});
afterEach(() => {
// Restore original values
for (const key of proxyEnvVars) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
});
describe("getProxyUrl", () => {
it("should return undefined when no proxy env vars are set", () => {
expect(getProxyUrl()).toBeUndefined();
});
it("should return HTTPS_PROXY value", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:8080";
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
});
it("should return https_proxy value (lowercase)", () => {
process.env.https_proxy = "http://proxy.example.com:8080";
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
});
it("should return HTTP_PROXY value when HTTPS_PROXY is not set", () => {
process.env.HTTP_PROXY = "http://proxy.example.com:8080";
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
});
it("should return http_proxy value (lowercase)", () => {
process.env.http_proxy = "http://proxy.example.com:8080";
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
});
it("should prefer lowercase https_proxy over uppercase", () => {
process.env.https_proxy = "http://lowercase.example.com:8080";
process.env.HTTPS_PROXY = "http://uppercase.example.com:8080";
expect(getProxyUrl()).toBe("http://lowercase.example.com:8080");
});
it("should prefer HTTPS_PROXY over HTTP_PROXY", () => {
process.env.HTTPS_PROXY = "http://https-proxy.example.com:8080";
process.env.HTTP_PROXY = "http://http-proxy.example.com:8080";
expect(getProxyUrl()).toBe("http://https-proxy.example.com:8080");
});
});
describe("proxyFetch", () => {
it("should call undici fetch with the provided URL", async () => {
const { fetch: mockFetch } = await import("undici");
await proxyFetch("https://example.com/file.tar.gz");
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/file.tar.gz",
expect.objectContaining({
dispatcher: expect.anything(),
}),
);
});
it("should pass through init options", async () => {
const { fetch: mockFetch } = await import("undici");
await proxyFetch("https://example.com/file.tar.gz", {
method: "GET",
headers: { "X-Custom": "header" },
});
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/file.tar.gz",
expect.objectContaining({
method: "GET",
headers: { "X-Custom": "header" },
dispatcher: expect.anything(),
}),
);
});
// Note: Testing that logging happens exactly once requires module reset
// which is complex with Vitest's module caching. The logging behavior
// is covered by the implementation and manual testing. The key behaviors
// (proxy detection and fetch dispatcher usage) are tested above.
});
});