mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
* chore(release): open v3.8.42 cycle
Bump version to 3.8.42, add CHANGELOG placeholder, sync openapi/electron/open-sse + 42 i18n CHANGELOG mirrors.
* chore: remove unused qdrant schema aliases (#5404)
Integrated into release/v3.8.42
* chore: remove unused memory schema aliases (#5403)
Integrated into release/v3.8.42
* chore: remove unused quota schema types (#5402)
Integrated into release/v3.8.42
* chore: remove unused playground row type (#5401)
Integrated into release/v3.8.42
* chore: remove unused codegraph exports (#5400)
Integrated into release/v3.8.42
* chore: remove unused notion client type (#5399)
Integrated into release/v3.8.42
* chore: remove unused settings types (#5398)
Integrated into release/v3.8.42
* chore: remove unused combo types (#5396)
Integrated into release/v3.8.42
* chore: remove unused provider types (#5393)
Integrated into release/v3.8.42
* chore: remove unused skillssh skill type (#5392)
Integrated into release/v3.8.42
* chore: remove unused status hex key type (#5391)
Integrated into release/v3.8.42
* chore: remove unused batch provider type (#5390)
Integrated into release/v3.8.42
* chore: remove unused skills schema types (#5389)
Integrated into release/v3.8.42
* chore: remove unused codex auth input type (#5388)
Integrated into release/v3.8.42
* chore: remove unused memory schema types (#5387)
Integrated into release/v3.8.42
* chore: remove unused playground row type (#5386)
Integrated into release/v3.8.42
* chore: remove unused qdrant schema types (#5385)
Integrated into release/v3.8.42
* chore: remove unused kiro social schema (#5384)
Integrated into release/v3.8.42
* chore: remove unused memory schema types (#5383)
Integrated into release/v3.8.42
* chore: remove unused audit action type (#5382)
Integrated into release/v3.8.42
* chore: remove unused agent skills schema types (#5381)
Integrated into release/v3.8.42
* chore: remove unused shared logger default export (#5380)
Integrated into release/v3.8.42
* chore: remove unused sse logger helpers (#5378)
Integrated into release/v3.8.42
* chore: remove unused sse model legacy helpers (#5377)
Integrated into release/v3.8.42
* chore: remove unused v1 search response schema (#5376)
Integrated into release/v3.8.42
* chore: remove unused cloud agent result schemas (#5375)
Integrated into release/v3.8.42
* chore: remove unused a2a routing logger readers (#5374)
Integrated into release/v3.8.42
* chore: remove unused webhook delivery detail export (#5372)
Integrated into release/v3.8.42
* chore: remove unused api key type (#5395)
Integrated into release/v3.8.42
* chore: remove unused usage types (#5397)
Integrated into release/v3.8.42
* chore: remove unused cloud agent input types (#5373)
Integrated into release/v3.8.42
* deps: bump electron from 42.4.1 to 42.5.1 in /electron (#5413)
Integrated into release/v3.8.42
* deps: bump the production group with 11 updates (#5414)
Integrated into release/v3.8.42
* fix: frame non-streaming JSON responses (#5416)
Integrated into release/v3.8.42
* fix(services): runNpm shell on win32 + prefix via env for Node 24 EINVAL (#5379) (#5474)
Node 24 refuses execFile of npm.cmd without a shell (nodejs/node#52554),
so embedded-service install (9Router/CLIProxy) failed with spawn EINVAL on
Windows. runNpm now enables shell on win32 only; to stay Hard-Rule-#13 safe
under a shell, the install --prefix is passed via npm_config_prefix (env)
instead of an argv path (survives spaces), and the user-supplied version is
constrained by SERVICE_VERSION_PATTERN at the route boundary.
* fix(cli): restore dist/tls-options.mjs to npm tarball (#5452) (#5503)
Closes #5452
* fix(dashboard): render onboarding wizard on /providers/new (#5427) (#5505)
Closes #5427
* fix(db): EBUSY-safe database import on Windows (#5406) (#5507)
Closes #5406
* chore: remove unused gamification streak exports (#5463)
* chore: remove unused headroom log tail export (#5464)
* chore(dead-code): remove unused prompt cache control helper (#5466)
* chore(duplication): share vscode metadata helpers (#5471)
* chore(duplication): share auth zip extractors (#5475)
* chore(duplication): share vscode tokenized request helper (#5479)
* chore(duplication): share quota strategy ranking helpers (#5482)
* chore(duplication): share recharts donut card (#5484)
* chore(duplication): share provider specific validation (#5485)
* chore(duplication): share batch response formatter (#5488)
* chore(duplication): share redis runtime helpers (#5490)
* chore(duplication): share version manager request parsing (#5492)
* chore(duplication): share media generation route helpers (#5493)
* chore(duplication): share settings transform schemas (#5496)
* chore(duplication): share relay stream finalizer (#5497)
* chore(duplication): share machine id fallback (#5498)
* chore(duplication): share node sqlite adapter (#5500)
* fix: treat terminal stream cancels as complete (#5491)
* fix post-merge ci regressions (#5467)
* fix: gate claude adaptive thinking defaults (#5480)
Co-authored-by: KooshaPari <koosha@example.com>
* fix(fallback): normalize provider error rule headers (#5473)
Co-authored-by: KooshaPari <koosha@example.com>
* fix(rate-limit): normalize queue refresh settings (#5499)
Co-authored-by: KooshaPari <koosha@example.com>
* chore(ci): add npm fetch-retry + release-freeze protocol (Hard Rule #21) (#5506)
- .npmrc: bump fetch-retries 2->5 with backoff so transient registry ECONNRESET during npm ci (electron-release, v3.8.41) retries instead of failing the job; applies repo-wide.
- CLAUDE.md Hard Rule #21: release-freeze coordination marker (label release-freeze) that campaign workflows honor before merging into the active release branch, preventing the mid-release commit races that forced CHANGELOG re-reconciliation in v3.8.40/v3.8.41.
* chore(duplication): share service install helpers (#5495)
Share service install helpers; re-add SERVICE_VERSION_PATTERN regex to the shared schema (dropped in extraction, #5474) + tests rejecting malformed versions.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* chore(duplication): share proxy route handlers (#5472)
Share proxy route handlers; add resolveProxyLookupResponse regression test (3 branches + custom whereUsed param name).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* chore(duplication): share combo builder model options (#5477)
Share combo builder model options; add regression test locking custom-model source classification (manual->custom, api-sync->imported).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* chore(dead-code): ratchet dead code baseline (#5468)
Ratchet dead-code baseline to the true measured value (310 -> 225) after the v3.8.42 dead-code + duplication wave. Measured by check-dead-code.mjs on the tip.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(dashboard): provider-add UX — i18n labels, surface import warning, default key name (#5511)
* fix(dashboard): provider-add UX — real i18n labels, surface import warning, default key name (#5421 #5428 #5429 #5431 #5435)
Three rough edges in the Add-API-Key / model-import flow, all from the
provider-catalog audit:
1. Validation Model + Account ID form fields shipped untranslated i18n
stub copy ('Validation Model Id Label', etc.) that rendered verbatim.
Replaced with real copy in en.json.
2. Model import silently fell back to the cached/local catalog — the route
returns a 'warning' field the import hook never read. New pure helper
extractImportWarning surfaces it as a log line.
3. Required connection-name field defaulted to '' (let browser autofill
inject garbage like 'wiw'); now defaults to 'main'.
Regression guard: tests/unit/provider-add-ux-i18n-import-warning.test.ts.
* fix(dashboard): compress AddApiKeyModal comment to keep file under frozen size cap
* fix(providers): align Muse Spark (Meta AI) cookie copy to ecto_1_sess (#5449) (#5513)
* fix(providers): align Muse Spark (Meta AI) cookie copy to ecto_1_sess (#5449)
The default Meta AI session cookie migrated from the retired abra_sess to
ecto_1_sess (META_AI_DEFAULT_COOKIE), but the provider form hint and one
401 auth-failure message still named abra_sess, telling users to paste a
cookie that no longer exists. Both strings now name ecto_1_sess.
Regression guard: tests/unit/muse-spark-cookie-copy-5449.test.ts.
* chore: reconcile CHANGELOG with release (keep #5449 + #5511 bullets)
* fix(providers): correct FriendliAI (serverless) + Novita (/openai/v1) endpoints (#5430 #5455) (#5515)
* fix(providers): correct FriendliAI (serverless) and Novita (/openai/v1) endpoints (#5430 #5455)
Both rejected valid keys, verified live with real provider keys:
- FriendliAI baseUrl was /dedicated/v1/... which 403s a serverless flp_* token;
switched to /serverless/v1/... + serverless modelsUrl.
- Novita baseUrl was the legacy /v3/... with a typo'd model id ai-ai/...
(both 404); switched to OpenAI-compat /openai/v1/... + meta-llama/llama-3.1-8b-instruct.
Regression guard: tests/unit/provider-endpoints-friendliai-novita.test.ts.
* chore: reconcile CHANGELOG with release (keep #5430/#5455 + prior bullets)
* fix(providers): gate import for tool-only providers + sanitize Coze validation error (#5420 #5426) (#5522)
#5420: the 'Import Models' button now hides for tool-only providers
(web search / web fetch) via a capability check over resolved serviceKinds,
not just the -search suffix — firecrawl/jina-reader (webFetch) no longer
show an Import button that 400s. No LLM/media provider is affected.
#5426: Coze key validation no longer leaks the raw upstream envelope
({code,msg,logId,from}) into the UI; the Coze error becomes a friendly
message, scoped to provider === 'coze' so no other provider is affected.
Regression guards: tests/unit/model-listing-capability-5420.test.ts,
tests/unit/coze-validation-error-5426.test.ts.
* fix(providers): correct LongCat free tier — GA LongCat-2.0, one-time 10M (KYC) (#5508)
LongCat's preview ended and the Flash-* line was retired (2026-05-29);
the API now exposes only the GA LongCat-2.0 (1M context, 128K output).
The free tier is a ONE-TIME 10M-token grant unlocked after account
signup + KYC verification — NOT a recurring daily/monthly allowance.
The catalog still described the retired preview/Flash models and a
recurring 150M / 5M-per-day budget; this corrects every reference.
Config / code:
- registry/longcat: model LongCat-2.0-Preview -> LongCat-2.0, name +
comment reflect one-time 10M (KYC) and pay-as-you-go beyond it.
- freeModelCatalog: longcat-2.0-preview (150M, recurring-daily) ->
LongCat-2.0 (10M, freeType one-time-initial via creditTokens).
- freeTierCatalog: drop longcat from the recurring-monthly budget map
(one-time credits are excluded by that catalog's own rule).
- regional.ts freeNote: one-time 10M after signup + KYC, not recurring.
- providerCostData: longcat-flash-lite -> longcat-2.0 (pay-as-you-go
0.75/2.95 per 1M, 10M free quota).
- validation probe model longcat -> LongCat-2.0.
Tests:
- free-tier-catalog: longcat now absent from FREE_TIER_BUDGETS;
providerCount 22->21 (clean 21->20); documented total ~1.39B.
- tierResolver: sample model flash-lite -> LongCat-2.0.
Docs:
- README, PROVIDERS-GUIDE, FREE-TIERS-GUIDE, FREE_TIERS: 50M/day
Flash-Lite -> one-time 10M LongCat-2.0 (KYC); 'No auth' -> API key + KYC.
- Regenerated PROVIDER_REFERENCE.md (picks up the new freeNote).
typecheck:core clean; changed-file lint 0 errors; docs-sync PASS.
* fix(providers): Bytez OpenAI-compat base URL + auth-only key validation (#5422) (#5528)
Bytez IS OpenAI-compatible at .../models/v2/openai/v1, but the registry
stored the bare .../models/v2 base, so validation's chat-probe hit
.../models/v2/chat/completions -> 404 -> 'endpoint not supported'.
Part A: registry baseUrl -> full OpenAI-compat chat path.
Part B: a Bytez account only serves catalog-provisioned models, so chat-probe
validation 404s even for valid keys. validateBytezProvider instead probes the
auth-only GET .../models/v2/list/tasks (200=valid, 401/403=invalid).
Verified live with a real key: list/tasks -> 200 (valid) / 401 (invalid).
Regression guard: tests/unit/bytez-validation-5422.test.ts.
* fix(providers): remove dead Phind provider + dedupe HuggingChat catalog listing (#5530)
Integrated into release/v3.8.42 (round 3). Dead Phind removal + HuggingChat dedupe, verified complete.
* fix: protect dynamic dashboard tests with CSRF (#5405)
Integrated into release/v3.8.42 (round 3). Reworked CSRF (HMAC-signed synchronized token).
* docs: clarify bifrost relay backend envs (#5520)
Integrated into release/v3.8.42 (round 3). Doc-only: bifrost relay envs.
* test(quota): guard Claude-Code identity version lockstep (Phase 2) (#5514)
Integrated into release/v3.8.42 (round 3). Claude-Code identity version lockstep guard.
* feat(compression): T02 — honest default-on pipeline inflation guard (H1) (#5527)
Integrated into release/v3.8.42 (round 3). T02 pipeline inflation guard
* feat(compression): T05/C2 — caveman dedup + ultra packs for de, fr, ja (#5529)
Integrated into release/v3.8.42 (round 3). T05/C2 caveman packs de/fr/ja
* feat(compression): T05/C6 — Chinese (zh / wenyan) caveman pack + detection (#5532)
Integrated into release/v3.8.42 (round 3). T05/C6 zh/wenyan pack + detection
* feat(compression): T07/R9 — gradle + dotnet RTK catalog filters (#5537)
Integrated into release/v3.8.42 (round 3). T07/R9 RTK gradle+dotnet filters
* refactor(dashboard): T11 — drop duplicate caveman on/off toggle from the compression settings tab (#5524)
Integrated into release/v3.8.42 (round 3). T11 consolidate duplicate caveman controls; i18n'd the panel hint string (source key).
* test relay routing fallback headers (#5526)
Integrated into release/v3.8.42 (round 3). Relay fallback header extraction + tests (drift-shed: dependabot #5415 commit dropped).
* fix(opencode-plugin): bump to 0.2.0 + auto-publish on release (#5363)
- Bump @omniroute/opencode-plugin from 0.1.0 to 0.2.0 so CI publishes
the accumulated fixes (auto combos, schema fields, debug logging) that
were merged after the initial 0.1.0 publish on May 24.
- Add auto-bump step in npm-publish.yml: detects if the plugin dir
changed since the last release tag and auto-increments patch version,
so the plugin never falls behind again on future releases.
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
* [codex] add bifrost auto fallback cooldown (#5519)
Integrated into release/v3.8.42 (round 3). Bifrost auto fallback cooldown; header reconciled with #5526 helper + env-doc.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix onboarding schema client import (#5525)
Integrated into release/v3.8.42 (round 3). Browser-safe onboarding schema import (drift-shed: dependabot #5415 dropped).
* docs: add relay backend strategy guide (#5547)
Port #5533 relay strategy guide to release/v3.8.42 (doc-only).
* fix(chatgpt-web): support GPT-5.5 Pro handoff (#5536)
Integrated into release/v3.8.42 (round 3). GPT-5.5 Pro async stream_handoff support (drift-shed: dependabot #5415 dropped).
* fix(providers): persist Configured filter across page reloads (#5510)
Integrated into release/v3.8.42 (round 3). Persist Configured filter across reloads; extracted shouldSyncProviderDisplayMode race guard + TDD test (Closes #4059).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(mimocode): route per-account traffic through SOCKS5 proxy dispatchers (#5521)
Integrated into release/v3.8.42 (round 3). Per-account SOCKS5 dispatcher routing — completes #3837's stored proxy config with the actual undici dispatcher layer. Rebased onto .42 (dropped the CI-workflow-deletion commits; merged proxyUrlMap dispatch with #3837's acct.proxy storage).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(chatgpt-web): portable SHA3-512 for sentinel PoW under Electron/BoringSSL (#5531) (#5540)
* fix(build): keep ioredis out of the client/CLI bundle via SPAWN_CAPABLE_PREFIXES leaf (#5546)
Fix the dast-smoke ioredis client-bundle regression (proven: dast-smoke green). Remaining reds are pre-existing base-reds/flakes (base.ts file-size, GOLDEN provider drift, shard-1 compression flakes) inherited by all PRs — not from this change.
* chore(release): finalize v3.8.42 CHANGELOG + cycle-close reconciliation
- Reconcile CHANGELOG.md for v3.8.42: 40 bullets covering all 89 commits
since v3.8.41 (4 features, 26 fixes, 10 maintenance incl. 2 rollups for
the 35-PR dead-code sweep + 17-PR DRY consolidation), dedup the merge-
artifact duplicate New Features headers, set release date 2026-06-30.
- Sync 42 docs/i18n/*/CHANGELOG.md mirrors.
- Document 3 new chatgpt-web/TLS env vars in .env.example + ENVIRONMENT.md
(OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS, _PRO_POLL_INTERVAL_MS,
OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS).
- Cycle-close ratchet rebaselines: eslintWarnings 4116->4121, file-size
base.ts/chatgpt-web.ts/strategySelector.ts/chatgpt-web.test.ts (all
inherited drift, justified inline).
- Regenerate provider translate-path golden snapshot for the merged
bytez/friendliai/novita endpoint fixes.
* chore(changelog): cover #5415 dev-deps bump merged from main
The release/v3.8.42 ↔ main merge (c4c1b56ba) brought #5415 (development
dependency group, 9 updates) and #5533 (relay backend guide) from main.
#5533's content is already covered by the #5547 port bullet; add a
Maintenance bullet for #5415 and re-sync the 42 i18n CHANGELOG mirrors.
* test: relocate 2 orphaned test files to collected runner paths
check:test-discovery flagged two cycle-merged tests that no runner
collects (they never ran → false coverage confidence):
- compression-settings-tab-consolidation.test.tsx (#5524) → tests/unit/ui/
(vitest UI runner collects tests/unit/ui/**/*.test.tsx); 3/3 pass.
- providers/providerPageStorage.test.ts (#5510) → tests/unit/dashboard/
('providers' is not a collected subdir; 'dashboard' is, same ../../../
import depth); 30/30 pass under the node runner.
Both confirmed green when actually executed; no assertions weakened.
* fix(release): repair inherited base-red tests from #5480/#5527/#5427/#5521
The fast-path (PR->release/**) does not run the full unit+integration suites,
so four merged feature PRs shipped with stale/incorrect tests that only surface
on the release PR (PR->main). Repairs (features are correct; align tests to the
new behavior — no assertions weakened):
- #5480 (gate claude adaptive thinking): adaptive thinking is now injected only
for a real Claude Code client (x-app:cli / claude-code UA), not for any bare
Claude OAuth token. claude-thinking-tool-choice-guard + base-thinking-budget-5312
now identify as a Claude Code client to exercise the adaptive path (3 tests).
- #5527 (T02 inflation guard): the guard reverts a stacked body that did not
shrink in tokens. The bail-out/advancement fixtures used growth-appending mock
engines; they now carry a droppable padding message the engines empty, so the
body realistically shrinks and the marker assertions survive. bailout (5),
stacked-async (3), engine-enabled-toggle (2).
- #5427 (render onboarding wizard at /providers/new): integration-wiring asserted
the old redirect stub; now asserts the route renders ProviderOnboardingWizard.
- #5521 (mimocode SOCKS5 per-account proxy): the constructor's default account
omitted the proxy field (undefined), breaking the 'all proxies null' backward
compat guard. Default it to null, mirroring syncAccountsFromCredentials().
* fix(proxyfetch): skip fallback for non-replayable bodies
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: KooshaPari <koosha@example.com>
Co-authored-by: backryun <bakryun0718@proton.me>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Arthur Bodera <abodera@gmail.com>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: OpenClaw Auto <openclaw-auto@example.invalid>
667 lines
26 KiB
TypeScript
667 lines
26 KiB
TypeScript
// @ts-nocheck
|
|
import "./setupPolyfill.ts";
|
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import { fetch as undiciFetch } from "undici";
|
|
import {
|
|
buildVercelRelayHeaders,
|
|
createProxyDispatcher,
|
|
getDefaultDispatcher,
|
|
getRetryDispatcher,
|
|
isRelayType,
|
|
normalizeProxyUrl,
|
|
proxyConfigToUrl,
|
|
proxyUrlForLogs,
|
|
} from "./proxyDispatcher.ts";
|
|
import tlsClient from "./tlsClient.ts";
|
|
import { isProxyReachable } from "@/lib/proxyHealth";
|
|
import {
|
|
isControlPlaneProxyDirectFallbackEnabled,
|
|
isFeatureFlagEnabled,
|
|
} from "@/shared/utils/featureFlags";
|
|
import { findWorkingProxy } from "./proxyFallback.ts";
|
|
|
|
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>();
|
|
|
|
/**
|
|
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
|
|
* by `runWithProxyContext` for the in-flight request. Executors that pin their
|
|
* own per-account proxy *internally* (e.g. OpencodeExecutor wraps its dispatch
|
|
* in `runWithProxyContext(account.proxy, …)`) never propagate that choice back
|
|
* to the caller's `proxyInfo`, so the post-execution `[ProxyEgress]` line logged
|
|
* `proxy=direct` even though `[ProxyFetch] Applied request proxy context: …`
|
|
* fired. Wrapping the execution in `runWithAppliedProxyCapture(sink, fn)` lets
|
|
* the egress logger read the innermost applied proxy (the last writer wins, which
|
|
* is the executor's per-account proxy).
|
|
*/
|
|
export type AppliedProxySink = { proxy: unknown };
|
|
const appliedProxyContext = new AsyncLocalStorage<AppliedProxySink>();
|
|
|
|
/**
|
|
* Run `fn` with an applied-proxy capture sink in context. Any
|
|
* `runWithProxyContext` call inside `fn` that ends up applying a proxy records
|
|
* that proxy config into `sink.proxy` (innermost wins). The sink is a plain
|
|
* mutable object the caller retains, so it can read `sink.proxy` after `fn`
|
|
* resolves. Pure plumbing — no behavioral change to the request itself.
|
|
*/
|
|
export function runWithAppliedProxyCapture<T>(sink: AppliedProxySink, fn: () => T): T {
|
|
return appliedProxyContext.run(sink, fn);
|
|
}
|
|
|
|
type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown };
|
|
type FetchWithDispatcher = (
|
|
input: RequestInfo | URL,
|
|
init?: FetchWithDispatcherOptions
|
|
) => Promise<Response>;
|
|
|
|
/**
|
|
* Flatten a fetch error's `cause` chain (and any Happy-Eyeballs `AggregateError`
|
|
* sub-errors) into a single diagnostic line: code/syscall/errno/address:port + a
|
|
* truncated message. undici/native both reject with a bare `TypeError: fetch failed`
|
|
* whose real reason hides in `.cause`; surfacing it is what makes dispatcher-failure
|
|
* bursts (#4252) diagnosable. Never includes a stack trace (Rule #12). Pure + testable.
|
|
*/
|
|
export function describeFetchCause(err: unknown): string {
|
|
const parts: string[] = [];
|
|
const seen = new Set<unknown>();
|
|
let cur: unknown = err;
|
|
for (let depth = 0; cur && depth < 5 && !seen.has(cur); depth++) {
|
|
seen.add(cur);
|
|
const e = cur as Record<string, unknown>;
|
|
const seg = [
|
|
typeof e.name === "string" && e.name !== "Error" ? e.name : null,
|
|
typeof e.message === "string" ? e.message.slice(0, 160) : null,
|
|
e.code != null ? `code=${String(e.code)}` : null,
|
|
e.syscall != null ? `syscall=${String(e.syscall)}` : null,
|
|
e.errno != null ? `errno=${String(e.errno)}` : null,
|
|
e.address != null
|
|
? `address=${String(e.address)}${e.port != null ? `:${String(e.port)}` : ""}`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
if (seg) parts.push(seg);
|
|
if (Array.isArray(e.errors)) {
|
|
for (const sub of (e.errors as unknown[]).slice(0, 4)) {
|
|
const s = (sub ?? {}) as Record<string, unknown>;
|
|
const subSeg = [
|
|
s.code != null ? `code=${String(s.code)}` : null,
|
|
s.syscall != null ? `syscall=${String(s.syscall)}` : null,
|
|
s.address != null
|
|
? `address=${String(s.address)}${s.port != null ? `:${String(s.port)}` : ""}`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
if (subSeg) parts.push(`↳ ${subSeg}`);
|
|
else if (typeof s.message === "string") parts.push(`↳ ${s.message.slice(0, 80)}`);
|
|
}
|
|
}
|
|
cur = e.cause;
|
|
}
|
|
return parts.join(" | ") || String(err);
|
|
}
|
|
|
|
|
|
function isStreamLikeBody(body: unknown): boolean {
|
|
return (
|
|
body !== null &&
|
|
body !== undefined &&
|
|
typeof body === "object" &&
|
|
(typeof (body as Record<string, unknown>).getReader === "function" ||
|
|
typeof (body as Record<string, unknown>).stream === "function")
|
|
);
|
|
}
|
|
|
|
function requestHasNonReplayableBody(
|
|
input: RequestInfo | URL,
|
|
options: FetchWithDispatcherOptions
|
|
): boolean {
|
|
if (isStreamLikeBody(options.body as unknown)) return true;
|
|
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
if (input.bodyUsed) return true;
|
|
if (input.body !== null) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Injectable dependencies for testability (Approach B DI). */
|
|
export type ProxyFetchDeps = {
|
|
undiciFetch?: FetchWithDispatcher;
|
|
nativeFetch?: (input: RequestInfo | URL, init?: RequestInit) => 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;
|
|
|
|
// Support wildcard matching (e.g. 192.168.* or *.local).
|
|
// Uses a linear glob scan instead of dynamic RegExp to avoid ReDoS.
|
|
if (patternHost.includes("*")) {
|
|
const parts = patternHost.split("*");
|
|
let pos = 0;
|
|
let ok = hostname.startsWith(parts[0]);
|
|
if (ok) {
|
|
pos = parts[0].length;
|
|
for (let i = 1; i < parts.length && ok; i++) {
|
|
const seg = parts[i];
|
|
if (i === parts.length - 1) {
|
|
ok = seg === "" || (hostname.endsWith(seg) && hostname.length - seg.length >= pos);
|
|
} else {
|
|
const idx = seg ? hostname.indexOf(seg, pos) : pos;
|
|
if (idx === -1) {
|
|
ok = false;
|
|
} else {
|
|
pos = idx + seg.length;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (ok) return true;
|
|
}
|
|
|
|
if (patternHost.startsWith(".")) {
|
|
return hostname.endsWith(patternHost) || hostname === patternHost.slice(1);
|
|
}
|
|
return hostname === patternHost || hostname.endsWith(`.${patternHost}`);
|
|
});
|
|
}
|
|
|
|
function isLocalAddress(hostname: string): boolean {
|
|
const host = hostname
|
|
.replace(/^\[/, "")
|
|
.replace(/\]$/, "")
|
|
.replace(/^::ffff:/i, "");
|
|
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host === "::1") {
|
|
return true;
|
|
}
|
|
if (host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".internal")) return true;
|
|
// RFC1918 + loopback + link-local (169.254, incl. cloud metadata 169.254.169.254)
|
|
// + CGNAT (100.64/10). 127/8 covers all loopback, not just 127.0.0.1.
|
|
if (host.startsWith("192.168.")) return true;
|
|
if (host.startsWith("10.")) return true;
|
|
if (host.startsWith("127.")) return true;
|
|
if (host.startsWith("169.254.")) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
|
|
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host)) return true;
|
|
// IPv6 ULA (fc00::/7 → fc/fd prefix) and link-local (fe80::/10)
|
|
if (/^f[cd][0-9a-f]*:/i.test(host) || host.startsWith("fe80:")) return true;
|
|
return false;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
export function resolveProxyForRequest(targetUrl) {
|
|
let target;
|
|
try {
|
|
target = new URL(targetUrl);
|
|
} catch {
|
|
target = null;
|
|
}
|
|
|
|
// Always bypass proxy for local/LAN addresses
|
|
if (target && isLocalAddress(target.hostname.toLowerCase())) {
|
|
return { source: "direct", proxyUrl: null };
|
|
}
|
|
|
|
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,
|
|
opts?: { directFallbackOnUnreachable?: boolean }
|
|
) {
|
|
if (typeof fn !== "function") {
|
|
throw new TypeError("runWithProxyContext requires a callback function");
|
|
}
|
|
|
|
// Inherit existing context if no specific proxyConfig is provided
|
|
const currentContext = proxyContext.getStore();
|
|
const effectiveProxyConfig = proxyConfig || currentContext || null;
|
|
|
|
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
|
|
|
|
// The caller must opt in, and the runtime feature flag must also be enabled.
|
|
// This fallback changes egress IP, so upgrades must not silently turn it on.
|
|
const directFallbackOnUnreachable =
|
|
opts?.directFallbackOnUnreachable === true && isControlPlaneProxyDirectFallbackEnabled();
|
|
// Run fn with the proxy context cleared so the request egresses directly.
|
|
const runDirect = () => proxyContext.run(null, fn);
|
|
|
|
// T14: Proxy Fast-Fail
|
|
// Perform a short TCP reachability check before issuing upstream requests.
|
|
// Skip for edge-relay types (vercel / deno): proxyConfigToUrl returns
|
|
// "https://<host>" which is the relay endpoint itself, not an HTTP proxy —
|
|
// the actual routing is handled via x-relay-* headers below.
|
|
const isVercelRelay = isRelayType((effectiveProxyConfig as { type?: string })?.type);
|
|
if (resolvedProxyUrl && !isVercelRelay) {
|
|
const reachable = await isProxyReachable(resolvedProxyUrl);
|
|
if (!reachable) {
|
|
const proxyLabel = proxyUrlForLogs(resolvedProxyUrl);
|
|
if (directFallbackOnUnreachable) {
|
|
console.warn(
|
|
`[ProxyFetch] Proxy unreachable (${proxyLabel}); using a direct connection for this request.`
|
|
);
|
|
return runDirect();
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Fail-closed family check: when the proxy URL carries a ?family=ipv6|ipv4 marker
|
|
// (set for HOSTNAME proxies by proxyConfigToUrl), verify the hostname actually has a
|
|
// record in that family before egressing. Refuse early rather than silently fall back
|
|
// to the other family. No-op for IP literals (their family is intrinsic).
|
|
if (resolvedProxyUrl && !isVercelRelay) {
|
|
try {
|
|
const u = new URL(resolvedProxyUrl);
|
|
const fam = u.searchParams.get("family");
|
|
if (fam === "ipv6" || fam === "ipv4") {
|
|
const { assertHostnameSupportsFamily } = await import("./proxyFamilyResolve.ts");
|
|
await assertHostnameSupportsFamily(u.hostname, fam === "ipv6" ? 6 : 4);
|
|
}
|
|
} catch (familyErr) {
|
|
if (directFallbackOnUnreachable) {
|
|
console.warn(
|
|
`[ProxyFetch] Proxy family pre-check failed (${proxyUrlForLogs(resolvedProxyUrl)}); using a direct connection for this request.`
|
|
);
|
|
return runDirect();
|
|
}
|
|
const e = familyErr as Error & { code?: string; statusCode?: number };
|
|
e.code = e.code || "PROXY_FAMILY_UNAVAILABLE";
|
|
e.statusCode = e.statusCode || 503;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
return proxyContext.run(effectiveProxyConfig, async () => {
|
|
if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) {
|
|
console.log(
|
|
`[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}`
|
|
);
|
|
}
|
|
// #5217: record the proxy actually applied so a post-execution egress logger
|
|
// reflects the real egress (executors that pin a per-account proxy internally
|
|
// otherwise leave proxyInfo reading "direct"). Innermost runWithProxyContext
|
|
// wins, which is exactly the per-account proxy the executor selected.
|
|
if (effectiveProxyConfig) {
|
|
const sink = appliedProxyContext.getStore();
|
|
if (sink) sink.proxy = effectiveProxyConfig;
|
|
}
|
|
return fn();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Like {@link runWithProxyContext}, but if the assigned proxy is unreachable or fails
|
|
* its pre-checks the request can degrade to a DIRECT connection instead of throwing.
|
|
*
|
|
* For control-plane flows — OAuth code/token exchange, connection tests, token refresh —
|
|
* where a dead pinned proxy must not block reaching the upstream (it otherwise surfaces
|
|
* as a generic "Internal server error"). Data-plane chat keeps strict pinning via
|
|
* runWithProxyContext so per-account egress-IP isolation is preserved.
|
|
*
|
|
* This remains disabled unless OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK is enabled
|
|
* from Feature Flags or the environment.
|
|
*/
|
|
export async function runWithProxyContextOrDirect(proxyConfig, fn) {
|
|
return runWithProxyContext(proxyConfig, fn, { directFallbackOnUnreachable: true });
|
|
}
|
|
|
|
async function patchedFetch(
|
|
input: RequestInfo | URL,
|
|
options: FetchWithDispatcherOptions = {},
|
|
deps: ProxyFetchDeps = {}
|
|
) {
|
|
if (options?.dispatcher) {
|
|
// When a dispatcher is present, we MUST use the undici library fetch
|
|
// to ensure version compatibility. Node 22 built-in fetch (undici v6)
|
|
// is incompatible with undici v8 dispatchers (missing onRequestStart, etc.)
|
|
const _undiciDispatcher =
|
|
deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>);
|
|
return _undiciDispatcher(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,
|
|
signal: options.signal ?? undefined,
|
|
});
|
|
} 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).
|
|
// Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry).
|
|
//
|
|
// Non-replayable body guard: if the body is stream-like (ReadableStream/Blob)
|
|
// or the input is a Request that carries a body, the first dispatcher attempt
|
|
// owns that body. Retrying or falling back to native fetch would replay a
|
|
// consumed/locked body and can mask the original transport error with
|
|
// "Response body object should not be disturbed or locked".
|
|
const hasNonReplayableBody = requestHasNonReplayableBody(input, options);
|
|
const maxAttempts = hasNonReplayableBody ? 1 : 2;
|
|
const _undiciDirect =
|
|
deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>);
|
|
const _nativeFallback =
|
|
(deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher;
|
|
let lastDispatcherError: unknown = null;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
return await _undiciDirect(input, {
|
|
...options,
|
|
// #4252: first attempt uses the pooled keep-alive dispatcher; a retry
|
|
// (after a transient socket error) uses the no-keep-alive dispatcher so
|
|
// it opens a FRESH socket instead of grabbing another stale pooled one
|
|
// — the burst pattern was the retry re-hitting a dead pooled socket and
|
|
// then falling through to native fetch (which also pools) → 502.
|
|
dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(),
|
|
});
|
|
} catch (dispatcherError) {
|
|
const msg =
|
|
dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
|
|
// CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart)
|
|
// because the native fetch will definitely fail with the undici v8 dispatcher.
|
|
if (msg.includes("onRequestStart")) {
|
|
console.error(
|
|
`[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}`
|
|
);
|
|
throw dispatcherError;
|
|
}
|
|
// Only retry/fallback for connection/dispatcher errors, not HTTP errors.
|
|
// Prefer the .code property when available (more stable across undici
|
|
// versions than message-string matching); fall back to substring match
|
|
// for errors that lack a structured code.
|
|
const errCode = (dispatcherError as { code?: unknown })?.code;
|
|
if (
|
|
msg.includes("fetch failed") ||
|
|
errCode === "ECONNREFUSED" ||
|
|
msg.includes("ECONNREFUSED") ||
|
|
(typeof errCode === "string" && errCode.startsWith("UND_ERR")) ||
|
|
msg.includes("UND_ERR")
|
|
) {
|
|
if (attempt === 0 && maxAttempts > 1) {
|
|
// First failure — retry once with a short jittered delay before giving up.
|
|
lastDispatcherError = dispatcherError;
|
|
await new Promise((r) => setTimeout(r, 25 + Math.random() * 50));
|
|
continue;
|
|
}
|
|
if (hasNonReplayableBody) {
|
|
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[skipped: non-replayable request body]`;
|
|
console.warn(
|
|
`[ProxyFetch] skipping native fetch fallback for non-replayable body: ${detail}`
|
|
);
|
|
if (dispatcherError instanceof Error) {
|
|
(dispatcherError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail;
|
|
}
|
|
throw dispatcherError;
|
|
}
|
|
|
|
// All attempts exhausted — try proxy fallback before native fetch
|
|
if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) {
|
|
let targetHostname = "";
|
|
try {
|
|
targetHostname = new URL(targetUrl).hostname;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (targetHostname) {
|
|
const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl);
|
|
if (fallbackProxyUrl) {
|
|
try {
|
|
const dispatcher = createProxyDispatcher(fallbackProxyUrl);
|
|
return await _undiciDirect(input, { ...options, dispatcher });
|
|
} catch {
|
|
// Proxy also failed — fall through to native fetch
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch"
|
|
// #4252: append the flattened err.cause (code/syscall/errno/address) — the bare
|
|
// "fetch failed" message hides what actually broke, making bursts undiagnosable.
|
|
console.warn(
|
|
`[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${describeFetchCause(dispatcherError)}`
|
|
);
|
|
try {
|
|
return await _nativeFallback(input, options);
|
|
} catch (nativeError) {
|
|
// #4252: both the undici dispatcher AND native fetch failed. Surface BOTH
|
|
// causes (server log) and tag the propagated error so the combo executor sees
|
|
// a diagnosable failure IMMEDIATELY instead of a bare "fetch failed" — the
|
|
// latter left jobs sitting until the 30s semaphore queue timeout, which then
|
|
// tripped the circuit breaker.
|
|
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
|
|
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
|
|
if (nativeError instanceof Error) {
|
|
(nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail;
|
|
}
|
|
throw nativeError;
|
|
}
|
|
}
|
|
throw dispatcherError;
|
|
}
|
|
}
|
|
// Should not be reached, but satisfy TypeScript control-flow.
|
|
throw lastDispatcherError;
|
|
}
|
|
|
|
// Edge relay (vercel / deno): instead of routing through an HTTP proxy
|
|
// dispatcher, we send x-relay-* headers to the edge function which forwards
|
|
// the request upstream. Both backends share the same envelope shape.
|
|
const contextProxy = proxyContext.getStore();
|
|
if (
|
|
contextProxy &&
|
|
typeof contextProxy === "object" &&
|
|
isRelayType((contextProxy as { type?: string }).type)
|
|
) {
|
|
const vc = contextProxy as { type?: string; host?: string; relayAuth?: string };
|
|
if (!vc.relayAuth) {
|
|
// Generic message without internal labels — this throw can bubble up to
|
|
// catch blocks that put error.message in response bodies (combo per-model
|
|
// timeout, executor catch-all). Don't leak "[ProxyFetch]" diagnostics.
|
|
const label = vc.type === "vercel" ? "Vercel relay" : `${vc.type || "Edge"} relay`;
|
|
throw new Error(`${label} configuration error: missing relayAuth`);
|
|
}
|
|
const targetUrl = getTargetUrl(input);
|
|
const relayHeaders = buildVercelRelayHeaders(targetUrl, vc.relayAuth);
|
|
const mergedHeaders = new Headers(options?.headers);
|
|
for (const [k, v] of Object.entries(relayHeaders)) mergedHeaders.set(k, v);
|
|
// Pass host through proxyUrlForLogs so the same redaction policy applies
|
|
// to relay routing logs (the rest of this module already follows that rule).
|
|
const hostForLogs = proxyUrlForLogs(vc.host ? `https://${vc.host}` : "");
|
|
if (process.env.OMNIROUTE_PROXY_FETCH_DEBUG === "true") {
|
|
console.debug(`[ProxyFetch] Routing via ${vc.type || "edge"} relay: ${hostForLogs}`);
|
|
}
|
|
return await originalFetch(`https://${vc.host}`, {
|
|
...options,
|
|
headers: mergedHeaders,
|
|
duplex: "half",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const dispatcher = createProxyDispatcher(proxyUrl);
|
|
const _undiciProxy =
|
|
deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>);
|
|
return await _undiciProxy(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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Named export for proxyFetch — identical to the patched globalThis.fetch but
|
|
* accepts an optional ProxyFetchDeps for unit test dependency injection.
|
|
* Production code should use globalThis.fetch (or the default export) instead.
|
|
*/
|
|
export async function proxyFetch(
|
|
input: RequestInfo | URL,
|
|
options: RequestInit = {},
|
|
deps: ProxyFetchDeps = {}
|
|
): Promise<Response> {
|
|
return patchedFetch(input, options as FetchWithDispatcherOptions, deps);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Get the original unpatched global fetch function (Node.js native fetch
|
|
* before the proxy/TLS fingerprint patch was applied).
|
|
* Use this to bypass the patched fetch for specific requests when the
|
|
* proxy dispatcher has compatibility issues with a particular endpoint.
|
|
*/
|
|
export function getOriginalFetch(): typeof globalThis.fetch {
|
|
return originalFetch;
|
|
}
|
|
|
|
export default isCloud ? originalFetch : patchedFetch;
|