Files
OmniRoute/open-sse/utils/proxyFetch.ts
Diego Rodrigues de Sa e Souza 1442c47bbb chore(release): v3.5.6 — email masking, model toggle, OpenRouter registries & bug fixes (#1080)
* fix(minimax): switch auth from x-api-key to Authorization Bearer (#1076)

Integrated into release/v3.5.6 — MiniMax auth fix with authHeader consistency normalization

* feat(CI,i18n): autogenerate language files + Add missing strings (#1071)

Integrated into release/v3.5.6 — i18n translations for memory, skills, and missing keys across 31 languages

* fix(ci): restore i18n continue-on-error, remove auto-commit race condition

* fix(husky): load nvm in hooks for VS Code compatibility

* fix(husky): gracefully skip hooks when npm is not in PATH

* fix: convert OpenAI function tool_choice to Claude tool format (#1072)

* fix: prevent EPIPE feedback loop filling logs at GB/s (#1006)

* fix: fallback to native fetch when undici dispatcher fails (#1054)

* fix: improve Qoder PAT validation with actionable error messages (#966)

- Add QODER_PERSONAL_ACCESS_TOKEN env var fallback for both validation and execution
- Pre-flight ping check to diagnose connectivity issues (Docker/proxy)
- Detect encrypted auth blobs from ~/.qoder/.auth/user and guide to website PAT
- Clear error messages for auth failures with link to integrations page
- Treat non-auth 4xx as auth-pass (request format issue, not token issue)
- Update tests to cover new validation paths (23 tests, all passing)

* feat: Improve the Chinese translation (#1079)

Integrated into release/v3.5.6

* chore(release): v3.5.6 — i18n updates and credential security fixes

* fix(ci): resolve e2e and docs-sync pipeline failures

* fix(security): bump next to 16.2.3 to resolve SNYK-JS-NEXT-15954202

* fix: guard Memory/Cache UI against null toLocaleString crash (#1083)

* fix: translate OpenAI tool_choice type 'function' to Claude 'tool' format (#1072)

* fix: pass custom baseUrl in provider API key validation (#1078)

* docs: update CHANGELOG with v3.5.6 bug fixes and security patches

* docs: rewrite implement-features workflow with 5-phase harvest-research-report-plan-execute pipeline

* docs: organize _ideia/ into viable/defer/notfit + add Phase 2.5 auto-response workflow

* docs: implementation plans for #1025, #750, #960, #1046 + close already-implemented #833, #973, #982

* feat: mask email addresses in dashboard for privacy (#1025)

* feat: add OpenRouter and GitHub to embedding/image provider registries (#960)

* feat: add model visibility toggle and search filter to provider page (#750)

* docs: move implemented features to notfit, update task plans status

* chore: untrack _ideia/ and _tasks/ from git — private/internal only

* chore(release): bump to v3.5.6 — changelog, docs, version sync & any-budget fix

* fix: remove explicit .ts extension in qoderCli import that caused 500 error in production build

---------

Co-authored-by: Jean Brito <jeanfbrito@gmail.com>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Ethan Hunt <136065060+only4copilot@users.noreply.github.com>
2026-04-09 15:55:59 -03:00

253 lines
7.8 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { fetch as undiciFetch } from "undici";
import {
createProxyDispatcher,
getDefaultDispatcher,
normalizeProxyUrl,
proxyConfigToUrl,
proxyUrlForLogs,
} from "./proxyDispatcher.ts";
import tlsClient from "./tlsClient.ts";
import { isProxyReachable } from "@/lib/proxyHealth";
function isTlsFingerprintEnabled() {
return process.env.ENABLE_TLS_FINGERPRINT === "true";
}
/** Per-request tracking of whether TLS fingerprint was used */
type TlsFingerprintStore = { used: boolean };
const tlsFingerprintContext = new AsyncLocalStorage<TlsFingerprintStore>();
type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown };
type FetchWithDispatcher = (
input: RequestInfo | URL,
init?: FetchWithDispatcherOptions
) => Promise<Response>;
type PatchState = {
originalFetch: typeof globalThis.fetch;
proxyContext: AsyncLocalStorage<unknown>;
isPatched: boolean;
};
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state");
function getPatchState(): PatchState {
const scopedGlobal = globalThis as typeof globalThis & {
[PATCH_STATE_KEY]?: PatchState;
};
if (!scopedGlobal[PATCH_STATE_KEY]) {
scopedGlobal[PATCH_STATE_KEY] = {
originalFetch: globalThis.fetch,
proxyContext: new AsyncLocalStorage(),
isPatched: false,
};
}
return scopedGlobal[PATCH_STATE_KEY];
}
const patchState = getPatchState();
const originalFetch = patchState.originalFetch;
const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher;
const proxyContext = patchState.proxyContext;
function noProxyMatch(targetUrl) {
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
if (!noProxy) return false;
let target;
try {
target = new URL(targetUrl);
} catch {
return false;
}
const hostname = target.hostname.toLowerCase();
const port = target.port || (target.protocol === "https:" ? "443" : "80");
const patterns = noProxy
.split(",")
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern === "*") return true;
const [patternHost, patternPort] = pattern.split(":");
if (patternPort && patternPort !== port) return false;
if (!patternHost) return false;
if (patternHost.startsWith(".")) {
return hostname.endsWith(patternHost) || hostname === patternHost.slice(1);
}
return hostname === patternHost || hostname.endsWith(`.${patternHost}`);
});
}
function resolveEnvProxyUrl(targetUrl) {
if (noProxyMatch(targetUrl)) return null;
let protocol;
try {
protocol = new URL(targetUrl).protocol;
} catch {
return null;
}
const proxyUrl =
protocol === "https:"
? process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy
: process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy;
if (!proxyUrl) return null;
return normalizeProxyUrl(proxyUrl, "environment proxy");
}
function resolveProxyForRequest(targetUrl) {
const contextProxy = proxyContext.getStore();
if (contextProxy) {
return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) };
}
const envProxyUrl = resolveEnvProxyUrl(targetUrl);
if (envProxyUrl) {
return { source: "env", proxyUrl: envProxyUrl };
}
return { source: "direct", proxyUrl: null };
}
function getTargetUrl(input) {
if (typeof input === "string") return input;
if (input && typeof input.url === "string") return input.url;
return String(input);
}
export async function runWithProxyContext(proxyConfig, fn) {
if (typeof fn !== "function") {
throw new TypeError("runWithProxyContext requires a callback function");
}
const resolvedProxyUrl = proxyConfig ? proxyConfigToUrl(proxyConfig) : null;
// T14: Proxy Fast-Fail
// Perform a short TCP reachability check before issuing upstream requests.
if (resolvedProxyUrl) {
const reachable = await isProxyReachable(resolvedProxyUrl);
if (!reachable) {
const proxyLabel = proxyUrlForLogs(resolvedProxyUrl);
const err = new Error(`[Proxy Fast-Fail] Proxy unreachable: ${proxyLabel}`) as Error & {
code?: string;
statusCode?: number;
};
err.code = "PROXY_UNREACHABLE";
err.statusCode = 503;
throw err;
}
}
return proxyContext.run(proxyConfig || null, async () => {
if (resolvedProxyUrl) {
console.log(
`[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}`
);
}
return fn();
});
}
async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}) {
if (options?.dispatcher) {
return (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(input, options);
}
const targetUrl = getTargetUrl(input);
let resolved;
try {
resolved = resolveProxyForRequest(targetUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ProxyFetch] Proxy configuration error: ${message}`);
throw error;
}
const { source, proxyUrl } = resolved;
if (!proxyUrl) {
// TLS fingerprint spoofing for direct connections (no proxy configured)
if (isTlsFingerprintEnabled() && tlsClient.available) {
try {
const store = tlsFingerprintContext.getStore();
if (store) store.used = true;
return await tlsClient.fetch(targetUrl, {
...options,
headers: options.headers,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}`
);
const store = tlsFingerprintContext.getStore();
if (store) store.used = false;
}
}
// Direct connection (no proxy) — use undici with custom dispatcher for timeout control.
// Falls back to original native fetch if dispatcher initialization fails (#1054).
try {
return await (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(input, {
...options,
dispatcher: getDefaultDispatcher(),
});
} catch (dispatcherError) {
const msg = dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
// Only fallback for connection/dispatcher errors, not HTTP errors
if (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("UND_ERR")) {
console.warn(`[ProxyFetch] Undici dispatcher failed, falling back to native fetch: ${msg}`);
return originalFetchWithDispatcher(input, options);
}
throw dispatcherError;
}
}
try {
const dispatcher = createProxyDispatcher(proxyUrl);
return await (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(input, {
...options,
dispatcher,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`);
throw error;
}
}
if (!isCloud && !patchState.isPatched) {
globalThis.fetch = patchedFetch;
patchState.isPatched = true;
}
/**
* Run a function with TLS fingerprint tracking context.
* After fn completes, returns { result, tlsFingerprintUsed }.
*/
export async function runWithTlsTracking(fn) {
const store = { used: false };
const result = await tlsFingerprintContext.run(store, fn);
return { result, tlsFingerprintUsed: store.used };
}
/** Check if TLS fingerprint is enabled and available */
export function isTlsFingerprintActive() {
return isTlsFingerprintEnabled() && tlsClient.available;
}
export default isCloud ? originalFetch : patchedFetch;