From 0437b6e743da03b8ee874e6aeaf493d0b7815304 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:46:11 +0530 Subject: [PATCH 1/2] Fixed proxy issue --- .../src/contentstack-management-sdk.ts | 13 +- .../src/http-client/client.ts | 120 ++++++----- .../src/proxy-helper.ts | 200 +++++++----------- 3 files changed, 160 insertions(+), 173 deletions(-) diff --git a/packages/contentstack-utilities/src/contentstack-management-sdk.ts b/packages/contentstack-utilities/src/contentstack-management-sdk.ts index 77600cc31f..bdedcc5b8e 100644 --- a/packages/contentstack-utilities/src/contentstack-management-sdk.ts +++ b/packages/contentstack-utilities/src/contentstack-management-sdk.ts @@ -2,7 +2,12 @@ import { client, ContentstackClient, ContentstackConfig } from '@contentstack/ma import authHandler from './auth-handler'; import { Agent } from 'node:https'; import configHandler, { default as configStore } from './config-handler'; -import { getProxyConfigForHost, resolveRequestHost, clearProxyEnv } from './proxy-helper'; +import { + getProxyConfigForHost, + resolveRequestHost, + clearProxyEnv, + shouldBypassProxy, +} from './proxy-helper'; import dotenv from 'dotenv'; dotenv.config(); @@ -22,8 +27,8 @@ class ManagementSDKInitiator { // NO_PROXY has priority over HTTP_PROXY/HTTPS_PROXY and config-set proxy const proxyConfig = getProxyConfigForHost(host); - // When bypassing, clear proxy env immediately so SDK never see it (they may read at init or first request). - if (!proxyConfig) { + // When NO_PROXY matches, strip proxy env so the SDK/axios cannot pick up HTTP_PROXY for this process. + if (host && shouldBypassProxy(host)) { clearProxyEnv(); } @@ -118,6 +123,8 @@ class ManagementSDKInitiator { if (proxyConfig) { option.proxy = proxyConfig; + } else if (host && shouldBypassProxy(host)) { + option.proxy = false; } if (config.endpoint) { option.endpoint = config.endpoint; diff --git a/packages/contentstack-utilities/src/http-client/client.ts b/packages/contentstack-utilities/src/http-client/client.ts index 2fd213d2ed..d621026c60 100644 --- a/packages/contentstack-utilities/src/http-client/client.ts +++ b/packages/contentstack-utilities/src/http-client/client.ts @@ -3,7 +3,15 @@ import { IHttpClient } from './client-interface'; import { HttpResponse } from './http-response'; import configStore from '../config-handler'; import authHandler from '../auth-handler'; -import { hasProxy, getProxyUrl, getProxyConfig, getProxyConfigForHost } from '../proxy-helper'; +import { + hasProxy, + getProxyUrl, + getProxyConfigForHost, + resolveRequestHost, + shouldBypassProxy, +} from '../proxy-helper'; + +type AxiosRequestConfigWithRetry = AxiosRequestConfig & { __httpClientRetryCount?: number }; /** * Derive request host from baseURL or url for NO_PROXY checks. @@ -52,6 +60,62 @@ export class HttpClient implements IHttpClient { // Sets payload format as json by default this.asJson(); + this.attachResponseInterceptor(); + } + + /** Single interceptor per instance — avoids stacking handlers on every request (major perf win). */ + private attachResponseInterceptor(): void { + this.axiosInstance.interceptors.response.use(null, async (error) => { + const cfg = error.config as AxiosRequestConfigWithRetry | undefined; + if (!cfg) { + return Promise.reject(error); + } + + const { message, response, code } = error; + const proxyFromCfg = cfg.proxy; + const isProxyConfigured = !!proxyFromCfg || hasProxy(); + + const proxyErrorCodes = ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'ERR_BAD_RESPONSE']; + if (isProxyConfigured && (proxyErrorCodes.includes(code) || message?.includes('ERR_BAD_RESPONSE'))) { + const p = proxyFromCfg as { protocol?: string; host?: string; port?: number } | undefined; + const proxyUrl = + p && typeof p === 'object' && p.host + ? `${p.protocol ?? 'http'}://${p.host}:${p.port ?? 80}` + : getProxyUrl(); + + return Promise.reject( + new Error( + `Proxy error: Unable to connect to proxy server at ${proxyUrl}. Please verify your proxy configuration.`, + ), + ); + } + + if (response?.data?.error_message?.includes('access token is invalid or expired')) { + const token = await this.refreshToken(); + this.headers({ ...this.request.headers, authorization: token.authorization }); + return this.axiosInstance({ + ...cfg, + headers: { ...cfg.headers, authorization: token.authorization }, + }); + } + + if ( + !( + message?.includes('timeout') || + message?.includes('Network Error') || + message?.includes('getaddrinfo ENOTFOUND') + ) + ) { + return Promise.reject(error); + } + + const retries = cfg.__httpClientRetryCount ?? 0; + if (retries < 1) { + cfg.__httpClientRetryCount = retries + 1; + return this.axiosInstance(cfg); + } + return Promise.reject(error); + }); } /** @@ -373,52 +437,6 @@ export class HttpClient implements IHttpClient { * @returns {Request} */ async createAndSendRequest(method: HttpMethod, url: string): Promise { - let counter = 0; - this.axiosInstance.interceptors.response.use(null, async (error) => { - const { message, response, code } = error; - - // Don't retry proxy connection errors - fail fast - const proxyErrorCodes = ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'ERR_BAD_RESPONSE']; - const isProxyConfigured = this.request.proxy || hasProxy(); - - if (isProxyConfigured && (proxyErrorCodes.includes(code) || message?.includes('ERR_BAD_RESPONSE'))) { - const proxyUrl = this.request.proxy && typeof this.request.proxy === 'object' - ? `${this.request.proxy.protocol}://${this.request.proxy.host}:${this.request.proxy.port}` - : getProxyUrl(); - - return Promise.reject(new Error(`Proxy error: Unable to connect to proxy server at ${proxyUrl}. Please verify your proxy configuration.`)); - } - - if (response?.data?.error_message?.includes('access token is invalid or expired')) { - const token = await this.refreshToken(); - this.headers({ ...this.request.headers, authorization: token.authorization }); - return await this.axiosInstance({ - url, - method, - withCredentials: true, - ...this.request, - data: this.prepareRequestPayload(), - }); - } - - if ( - !(message.includes('timeout') || message.includes('Network Error') || message.includes('getaddrinfo ENOTFOUND')) - ) { - return Promise.reject(error); - } - if (counter < 1) { - counter++; - return await this.axiosInstance({ - url, - method, - withCredentials: true, - ...this.request, - data: this.prepareRequestPayload(), - }); - } - return Promise.reject(error); - }); - if (!this.disableEarlyAccessHeaders) { // Add early access header by default const earlyAccessHeaders = configStore.get(`earlyAccessHeaders`); @@ -427,12 +445,14 @@ export class HttpClient implements IHttpClient { } } - // Configure proxy if available. NO_PROXY has priority: hosts in NO_PROXY never use proxy. + // Configure proxy if available. NO_PROXY has priority; fall back to region CMA for host resolution. if (!this.request.proxy) { - const host = getRequestHost(this.request.baseURL, url); - const proxyConfig = host ? getProxyConfigForHost(host) : getProxyConfig(); + const host = getRequestHost(this.request.baseURL, url) || resolveRequestHost({}); + const proxyConfig = getProxyConfigForHost(host); if (proxyConfig) { this.request.proxy = proxyConfig; + } else if (host && shouldBypassProxy(host)) { + this.request.proxy = false; } } diff --git a/packages/contentstack-utilities/src/proxy-helper.ts b/packages/contentstack-utilities/src/proxy-helper.ts index f370b97c56..9b9f96f92e 100644 --- a/packages/contentstack-utilities/src/proxy-helper.ts +++ b/packages/contentstack-utilities/src/proxy-helper.ts @@ -10,35 +10,35 @@ export interface ProxyConfig { }; } +let noProxyEnvSnapshot = ''; +let noProxyListCache: string[] = []; + /** * Parse NO_PROXY / no_proxy env (both uppercase and lowercase). - * NO_PROXY has priority over HTTP_PROXY/HTTPS_PROXY: hosts in this list never use the proxy. - * Values are hostnames only, comma-separated; leading dot matches subdomains (e.g. .contentstack.io). - * The bypass list is fully dynamic: only env values are used (no hardcoded default). - * @returns List of trimmed entries, or empty array when NO_PROXY/no_proxy is unset + * List is cached until the env value changes (avoids split/trim on every request). */ export function getNoProxyList(): string[] { const raw = process.env.NO_PROXY || process.env.no_proxy || ''; - return raw + if (raw === noProxyEnvSnapshot) { + return noProxyListCache; + } + noProxyEnvSnapshot = raw; + noProxyListCache = raw .split(',') - .map((s) => s.trim()) + .map((s) => s.trim().toLowerCase()) .filter(Boolean); + return noProxyListCache; } -/** - * Normalize host for NO_PROXY matching: strip protocol/URL, port, lowercase, handle IPv6 brackets. - * Accepts hostname, host:port, or full URL (e.g. https://api.contentstack.io). - */ function normalizeHost(host: string): string { if (!host || typeof host !== 'string') return ''; let h = host.trim().toLowerCase(); - // If it looks like a URL, extract hostname so NO_PROXY matching works (e.g. region.cma is full URL) if (h.includes('://')) { try { const u = new URL(h); h = u.hostname; } catch { - // fall through to port stripping below + // fall through } } const portIdx = h.lastIndexOf(':'); @@ -58,126 +58,95 @@ function normalizeHost(host: string): string { /** * Check if the given host should bypass the proxy based on NO_PROXY / no_proxy. - * Supports: exact host, leading-dot subdomain match (e.g. .contentstack.io), and wildcard *. - * @param host - Request hostname (with or without port; will be normalized) - * @returns true if proxy should not be used for this host */ export function shouldBypassProxy(host: string): boolean { const normalized = normalizeHost(host); if (!normalized) return false; const list = getNoProxyList(); - for (const entry of list) { - const e = entry.trim().toLowerCase(); - if (!e) continue; + for (const e of list) { if (e === '*') return true; if (e.startsWith('.')) { const domain = e.slice(1); if (normalized === domain || normalized.endsWith(e)) return true; - } else { - if (normalized === e) return true; + } else if (normalized === e) { + return true; } } return false; } -/** - * Get proxy configuration. Sources (in order): env (HTTP_PROXY/HTTPS_PROXY), then global config - * from `csdx config:set:proxy --host --port --protocol `. - * For per-request use, prefer getProxyConfigForHost(host) so NO_PROXY overrides both sources. - * @returns ProxyConfig object or undefined if no proxy is configured - */ -export function getProxyConfig(): ProxyConfig | undefined { - // Priority 1: Environment variables (HTTPS_PROXY or HTTP_PROXY) - const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; - - if (proxyUrl) { - try { - const url = new URL(proxyUrl); - const defaultPort = url.protocol === 'https:' ? 443 : 80; - const port = url.port ? Number.parseInt(url.port, 10) : defaultPort; - - if (!Number.isNaN(port) && port >= 1 && port <= 65535) { - const protocol = url.protocol.replace(':', '') as 'http' | 'https'; - const proxyConfig: ProxyConfig = { - protocol: protocol, - host: url.hostname, - port: port, - }; - - if (url.username || url.password) { - proxyConfig.auth = { - username: url.username, - password: url.password, - }; - } - - return proxyConfig; - } - } catch { - // Invalid URL, continue to check global config +function proxyConfigFromUrlString(proxyUrl: string): ProxyConfig | undefined { + try { + const url = new URL(proxyUrl); + const defaultPort = url.protocol === 'https:' ? 443 : 80; + const port = url.port ? Number.parseInt(url.port, 10) : defaultPort; + + if (Number.isNaN(port) || port < 1 || port > 65535) { + return undefined; + } + + const protocol = url.protocol.replace(':', '') as 'http' | 'https'; + const proxyConfig: ProxyConfig = { + protocol, + host: url.hostname, + port, + }; + + if (url.username || url.password) { + proxyConfig.auth = { + username: url.username, + password: url.password, + }; } + + return proxyConfig; + } catch { + return undefined; } - - // Priority 2: Global config (csdx config:set:proxy) +} + +function proxyFromGlobalStore(): ProxyConfig | undefined { const globalProxyConfig = configStore.get('proxy'); - if (globalProxyConfig) { - if (typeof globalProxyConfig === 'object') { - const port = globalProxyConfig.port; - if (port !== undefined && !Number.isNaN(port) && port >= 1 && port <= 65535) { - return globalProxyConfig as ProxyConfig; - } - } else if (typeof globalProxyConfig === 'string') { - try { - const url = new URL(globalProxyConfig); - const defaultPort = url.protocol === 'https:' ? 443 : 80; - const port = url.port ? Number.parseInt(url.port, 10) : defaultPort; - - if (!Number.isNaN(port) && port >= 1 && port <= 65535) { - const protocol = url.protocol.replace(':', '') as 'http' | 'https'; - const proxyConfig: ProxyConfig = { - protocol: protocol, - host: url.hostname, - port: port, - }; - - if (url.username || url.password) { - proxyConfig.auth = { - username: url.username, - password: url.password, - }; - } - - return proxyConfig; - } - } catch { - // Invalid URL, return undefined - } + if (!globalProxyConfig) { + return undefined; + } + if (typeof globalProxyConfig === 'object') { + const port = globalProxyConfig.port; + if (port !== undefined && !Number.isNaN(port) && port >= 1 && port <= 65535) { + return globalProxyConfig as ProxyConfig; } + return undefined; + } + if (typeof globalProxyConfig === 'string') { + return proxyConfigFromUrlString(globalProxyConfig); } - return undefined; } /** - * Get proxy config only when the request host is not in NO_PROXY. - * NO_PROXY has priority over both HTTP_PROXY/HTTPS_PROXY and over proxy set via - * `csdx config:set:proxy` — if the host matches NO_PROXY, no proxy is used. - * Use this for all outbound requests so Contentstack and localhost bypass the proxy when set. - * @param host - Request hostname (e.g. api.contentstack.io or full URL like https://api.contentstack.io) - * @returns ProxyConfig or undefined if proxy is disabled or host should bypass (NO_PROXY) + * Global CLI proxy first, then HTTP_PROXY / HTTPS_PROXY. + * Use getProxyConfigForHost(host) so NO_PROXY is applied per request. */ +export function getProxyConfig(): ProxyConfig | undefined { + const fromGlobal = proxyFromGlobalStore(); + if (fromGlobal) { + return fromGlobal; + } + + const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + if (proxyUrl) { + return proxyConfigFromUrlString(proxyUrl); + } + + return undefined; +} + export function getProxyConfigForHost(host: string): ProxyConfig | undefined { if (shouldBypassProxy(host)) return undefined; return getProxyConfig(); } -/** - * Resolve request host for proxy/NO_PROXY checks: config.host or default CMA from region. - * Use when the caller may omit host so NO_PROXY still applies (e.g. from region.cma). - * @param config - Object with optional host (e.g. API client config) - * @returns Host string (hostname or empty) - */ export function resolveRequestHost(config: { host?: string }): string { if (config.host) return config.host; const cma = configStore.get('region')?.cma; @@ -195,11 +164,6 @@ export function resolveRequestHost(config: { host?: string }): string { return ''; } -/** - * Temporarily clear proxy-related env vars so SDK/axios cannot use them. - * Call the returned function to restore. Use when creating a client for a host in NO_PROXY. - * @returns Restore function (call to put env back) - */ export function clearProxyEnv(): () => void { const saved: Record = {}; const keys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']; @@ -216,34 +180,30 @@ export function clearProxyEnv(): () => void { }; } -/** - * Check if proxy is configured (from any source) - * @returns true if proxy is configured, false otherwise - */ export function hasProxy(): boolean { - return !!getProxyConfig() || !!process.env.HTTPS_PROXY || !!process.env.HTTP_PROXY || !!configStore.get('proxy'); + return ( + !!getProxyConfig() || + !!process.env.HTTPS_PROXY || + !!process.env.HTTP_PROXY || + !!configStore.get('proxy') + ); } -/** - * Get proxy URL string for display purposes - * @returns Proxy URL string or 'proxy server' if not available - */ export function getProxyUrl(): string { const proxyConfig = getProxyConfig(); if (proxyConfig) { return `${proxyConfig.protocol}://${proxyConfig.host}:${proxyConfig.port}`; } - + const envProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; if (envProxy) { return envProxy; } - + const globalProxy = configStore.get('proxy'); if (globalProxy && typeof globalProxy === 'object') { return `${globalProxy.protocol}://${globalProxy.host}:${globalProxy.port}`; } - + return 'proxy server'; } - From 31c2058bf5b6ff9ba4b65811652879b661ab6b97 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:51:30 +0530 Subject: [PATCH 2/2] Fixed host issue in NO_PROXY --- .../src/proxy-helper.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/contentstack-utilities/src/proxy-helper.ts b/packages/contentstack-utilities/src/proxy-helper.ts index 9b9f96f92e..4781f2f0c3 100644 --- a/packages/contentstack-utilities/src/proxy-helper.ts +++ b/packages/contentstack-utilities/src/proxy-helper.ts @@ -147,21 +147,34 @@ export function getProxyConfigForHost(host: string): ProxyConfig | undefined { return getProxyConfig(); } -export function resolveRequestHost(config: { host?: string }): string { - if (config.host) return config.host; +function regionCmaHostname(): string { const cma = configStore.get('region')?.cma; - if (cma && typeof cma === 'string') { - if (cma.startsWith('http')) { - try { - const u = new URL(cma); - return u.hostname || cma; - } catch { - return cma; - } + if (!cma || typeof cma !== 'string') { + return ''; + } + if (cma.startsWith('http')) { + try { + const u = new URL(cma); + return u.hostname || cma; + } catch { + return cma; } - return cma; } - return ''; + return cma; +} + + +export function resolveRequestHost(config: { host?: string }): string { + const fromRegion = regionCmaHostname(); + if (fromRegion) { + return normalizeHost(fromRegion) || fromRegion; + } + + const raw = config.host?.trim() || ''; + if (!raw) { + return ''; + } + return normalizeHost(raw) || raw; } export function clearProxyEnv(): () => void {