diff --git a/packages/create-nuxt/src/main.ts b/packages/create-nuxt/src/main.ts index b7c99f4ac..3c92833c4 100644 --- a/packages/create-nuxt/src/main.ts +++ b/packages/create-nuxt/src/main.ts @@ -6,11 +6,14 @@ import { provider } from 'std-env' import { checkEngines } from '../../nuxt-cli/src/utils/engines' import { getCreateCommand, isPinnedCreateInvocation } from '../../nuxt-cli/src/utils/headless' import { debug, logger } from '../../nuxt-cli/src/utils/logger' +import { setupProxySupport } from '../../nuxt-cli/src/utils/network' import { scheduleSelfUpdateNudge } from '../../nuxt-cli/src/utils/update-check' import { description, name, version } from '../package.json' import { setupInitCompletions } from './completions' import init from './init' +setupProxySupport() + const _main = defineCommand({ meta: { name, diff --git a/packages/create-nuxt/test/unit/proxy.spec.ts b/packages/create-nuxt/test/unit/proxy.spec.ts new file mode 100644 index 000000000..85029b3e7 --- /dev/null +++ b/packages/create-nuxt/test/unit/proxy.spec.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { setGlobalProxyFromEnv, startTunnelProxy } from '../../../nuxt-cli/test/utils/proxy' + +describe.skipIf(!setGlobalProxyFromEnv)('proxy support', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it.each([ + ['create-nuxt', () => import('../../src/main')], + ['nuxi', () => import('../../../nuxi/src/main')], + ])('should route %s requests through HTTP_PROXY', async (_name, load) => { + const proxy = await startTunnelProxy() + try { + for (const key of ['HTTPS_PROXY', 'https_proxy', 'http_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_ENV_PROXY', 'NODE_OPTIONS']) { + vi.stubEnv(key, undefined) + } + vi.stubEnv('HTTP_PROXY', proxy.proxyUrl) + await load() + expect(await fetch('http://nuxt.invalid/').then(r => r.text())).toBe('ok') + expect(proxy.tunnelled).toEqual(['nuxt.invalid:80']) + } + finally { + proxy.close() + } + }) +}) diff --git a/packages/nuxi/src/main.ts b/packages/nuxi/src/main.ts index c4d29b0aa..8a9ceac93 100644 --- a/packages/nuxi/src/main.ts +++ b/packages/nuxi/src/main.ts @@ -12,6 +12,7 @@ import { isNuxiCommand, nuxiCommands } from '../../nuxt-cli/src/commands/_utils' import { setupGlobalConsole } from '../../nuxt-cli/src/utils/console' import { checkEngines } from '../../nuxt-cli/src/utils/engines' import { debug, logger } from '../../nuxt-cli/src/utils/logger' +import { setupProxySupport } from '../../nuxt-cli/src/utils/network' import { findInPath, withLocalBinPath } from '../../nuxt-cli/src/utils/path-env' import { description, name, version } from '../package.json' @@ -19,6 +20,8 @@ const commands = { init: () => import('../../create-nuxt/src/init').then(m => m.default || m), } as const +setupProxySupport() + const _main = defineCommand({ meta: { name: name.endsWith('nightly') ? name : 'nuxi', diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 22d0387c1..82e9b4a47 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -20,9 +20,6 @@ import { templateNames } from './utils/templates/names' import { findUnknownFlags, replaceFlag, suggestFlags } from './utils/unknown-args' import { scheduleUpdateNudge } from './utils/update-lazy' -// Node.js only reads `NODE_USE_ENV_PROXY` during bootstrap, so this cannot make -// the current process proxy-aware; it propagates the setting to child processes -// (package manager installs, dev server) and records the state for error hints. setupProxySupport() const _main = defineCommand({ diff --git a/packages/nuxt-cli/src/utils/network.ts b/packages/nuxt-cli/src/utils/network.ts index a24378025..8e1f1f98b 100644 --- a/packages/nuxt-cli/src/utils/network.ts +++ b/packages/nuxt-cli/src/utils/network.ts @@ -1,3 +1,4 @@ +import http from 'node:http' import process from 'node:process' import { styleText } from 'node:util' @@ -52,15 +53,18 @@ export function isEnvProxyActive(env: NodeJS.ProcessEnv = process.env, execArgv: export type ProxySetupResult = 'unused' | 'active' | 'children-only' | 'unsupported' +type EnableGlobalProxy = (env: NodeJS.ProcessEnv) => unknown + +const setGlobalProxyFromEnv = (http as { setGlobalProxyFromEnv?: EnableGlobalProxy }).setGlobalProxyFromEnv + let envProxyActive: boolean | undefined let proxyHintShown = false /** - * Propagate Node.js' built-in proxy support to child processes (package manager - * installs, the dev server) when proxy environment variables are set, and record - * whether the current process is itself proxy-aware so failures can say so. + * Route requests from this process and its children through the proxy + * environment variables, and record whether this process is proxy-aware. */ -export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, flags?: NodeFlags): ProxySetupResult { +export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, flags?: NodeFlags, enableGlobalProxy: EnableGlobalProxy | null = setGlobalProxyFromEnv ?? null): ProxySetupResult { proxyHintShown = false if (!hasProxyEnv(env)) { @@ -73,6 +77,10 @@ export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, flags?: } envProxyActive = isEnvProxyActive(env, process.execArgv, flags) + if (!envProxyActive && enableGlobalProxy && env.NODE_USE_ENV_PROXY !== '0') { + enableGlobalProxy(env) + envProxyActive = true + } env.NODE_USE_ENV_PROXY ||= '1' return envProxyActive ? 'active' : 'children-only' diff --git a/packages/nuxt-cli/test/unit/utils/network.spec.ts b/packages/nuxt-cli/test/unit/utils/network.spec.ts index 87585eb5e..1598c7d8e 100644 --- a/packages/nuxt-cli/test/unit/utils/network.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/network.spec.ts @@ -11,6 +11,7 @@ import { stripVTControlCharacters } from 'node:util' import { downloadTemplate } from 'giget' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { setGlobalProxyFromEnv, startTunnelProxy } from '../../utils/proxy' const logs: Array<[string, string]> = [] @@ -80,10 +81,31 @@ describe('setupProxySupport', () => { it('propagates proxy support to child processes', () => { const env = { HTTP_PROXY: 'http://localhost:3128' } as NodeJS.ProcessEnv - expect(setupProxySupport(env, MODERN_NODE)).toBe('children-only') + expect(setupProxySupport(env, MODERN_NODE, null)).toBe('children-only') expect(env.NODE_USE_ENV_PROXY).toBe('1') }) + it.skipIf(!setGlobalProxyFromEnv)('routes fetch through the proxy while honouring NO_PROXY', async () => { + const proxy = await startTunnelProxy() + try { + const env = { HTTP_PROXY: proxy.proxyUrl, NO_PROXY: '127.0.0.1' } as NodeJS.ProcessEnv + expect(setupProxySupport(env, MODERN_NODE)).toBe('active') + expect(await fetch('http://nuxt.invalid/').then(r => r.text())).toBe('ok') + expect(await fetch(proxy.targetUrl).then(r => r.text())).toBe('ok') + expect(proxy.tunnelled).toEqual(['nuxt.invalid:80']) + expect(getProxyHint('refused', { env, flags: MODERN_NODE })).toBeUndefined() + } + finally { + proxy.close() + } + }) + + it('does not enable the proxy when NODE_USE_ENV_PROXY is explicitly disabled', () => { + const enable = vi.fn() + expect(setupProxySupport({ HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '0' }, MODERN_NODE, enable)).toBe('children-only') + expect(enable).not.toHaveBeenCalled() + }) + it('reports Node.js versions that cannot use the proxy', () => { const env = { HTTP_PROXY: 'http://localhost:3128' } as NodeJS.ProcessEnv expect(setupProxySupport(env, OLD_NODE)).toBe('unsupported') diff --git a/packages/nuxt-cli/test/utils/proxy.ts b/packages/nuxt-cli/test/utils/proxy.ts new file mode 100644 index 000000000..93e96841d --- /dev/null +++ b/packages/nuxt-cli/test/utils/proxy.ts @@ -0,0 +1,43 @@ +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' + +import http, { createServer } from 'node:http' +import { connect } from 'node:net' + +export const setGlobalProxyFromEnv = (http as { setGlobalProxyFromEnv?: (env: NodeJS.ProcessEnv) => () => void }).setGlobalProxyFromEnv + +function listen(server: Server) { + return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))) +} + +/** + * Start a CONNECT proxy that tunnels every request to a local server responding + * with `ok`, recording the requested `host:port` of each tunnel. + */ +export async function startTunnelProxy() { + const tunnelled: string[] = [] + const target = createServer((_req, res) => res.end('ok')) + const proxy = createServer() + const [proxyPort, targetPort] = await Promise.all([listen(proxy), listen(target)]) + proxy.on('connect', (req, socket) => { + tunnelled.push(req.url!) + const upstream = connect(targetPort, '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + upstream.pipe(socket).pipe(upstream) + }) + }) + const restoreDispatcher = setGlobalProxyFromEnv?.({}) + + return { + proxyUrl: `http://127.0.0.1:${proxyPort}`, + targetUrl: `http://127.0.0.1:${targetPort}/`, + tunnelled, + close() { + restoreDispatcher?.() + for (const server of [proxy, target]) { + server.closeAllConnections() + server.close() + } + }, + } +}