fix(ai): proxy dispatcher uses ESM import, actually installs in dev path

Previous version did `require('undici')` inside a try/catch on the
theory that would let it run on both CJS and ESM. In Vite/Nitro's dev
path the helper loads as an ESM module, where `require` is undefined —
the call threw `ReferenceError: require is not defined`, the catch
block silenced it, `configured=true` still got flipped, and every
subsequent call short-circuited. Net effect: the proxy was never
installed in the very dev environment the fix was meant to repair, so
image-search kept ECONNREFUSED-ing on Openverse + Wikimedia and
landing zero filled placeholders.

Switch to a static `import { setGlobalDispatcher, EnvHttpProxyAgent }
from 'undici'`. undici is a transitive dep of h3 in this workspace
(verified resolved in node_modules), and it's also the package Node 18+
uses internally for fetch — pinning it as a direct dep on apps/web
makes the resolution intentional rather than reliant on the h3 chain.

Also swapped the hand-rolled ProxyAgent for undici's built-in
`EnvHttpProxyAgent`: it reads HTTPS_PROXY / HTTP_PROXY / NO_PROXY
itself (case-insensitive) and applies the no-proxy bypass list, which
saves us from re-implementing those rules.

Verified with both `bun -e` (workspace deps) AND a direct ESM Node
context: with HTTPS_PROXY set, `fetch(api.openverse.org/...)` now
returns 240 results for "salmon sushi" instead of the earlier
ECONNREFUSED. The "configured" guard still makes calls idempotent so
multiple endpoints can opt in without coordinating.
This commit is contained in:
Fini 2026-05-05 12:22:40 +08:00
parent 0dbd93eb8c
commit 6a87721870
3 changed files with 1130 additions and 1128 deletions

View file

@ -14,6 +14,7 @@
"@zseven-w/pen-react": "workspace:*",
"@zseven-w/pen-renderer": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
"undici": "^7.22.0",
"zod": "^3.24"
}
}

View file

@ -1,22 +1,37 @@
import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici';
/**
* Configure undici's global fetch dispatcher to honor `HTTPS_PROXY` /
* `HTTP_PROXY` env vars. Node's native `fetch` (built on undici) does
* NOT auto-route through the system proxy the way `curl` does it
* goes direct, which fails with `ECONNREFUSED` on machines that route
* outbound HTTPS through a local proxy (clash / mihomo / corporate
* gateways). The image-search endpoint already swallows that failure
* and silently falls back to Wikimedia, but Wikimedia is also blocked
* on the same machines, so designs land with empty image placeholders.
* Configure undici's global fetch dispatcher to honor HTTPS_PROXY /
* HTTP_PROXY / NO_PROXY env vars. Node's native `fetch` (built on
* undici) does NOT auto-route through the system proxy the way `curl`
* does it goes direct, which fails with `ECONNREFUSED` on machines
* that route outbound HTTPS through a local proxy (clash / mihomo /
* corporate gateway). The image-search endpoint already swallows that
* failure and silently falls back to Wikimedia, but Wikimedia is
* blocked on the same machines, so designs land with empty image
* placeholders.
*
* Calling `setGlobalDispatcher(new ProxyAgent(...))` once at module
* load makes every `fetch()` in the server route through the proxy.
* When no proxy env var is set (production deploys, CI) the function
* is a no-op the default global dispatcher continues to handle
* direct connections.
* `EnvHttpProxyAgent` is undici's built-in env-aware dispatcher: it
* reads HTTPS_PROXY / HTTP_PROXY / NO_PROXY (case-insensitive)
* directly from `process.env`, applies the bypass list to no-proxy
* hosts, and routes the rest through the configured proxy. When no
* proxy env var is set, requests pass through unchanged (production
* deploys, CI), so this is a safe no-op there.
*
* Idempotent: subsequent calls return without re-installing the
* dispatcher, so callers can safely import and invoke from any
* endpoint that makes external fetches.
* Why static ESM import (not `require('undici')`):
* The previous version did `require('undici')` inside a try/catch,
* thinking that would let it run on both CJS and ESM. In an ESM
* module (which is what Vite/Nitro produces in dev) `require` is
* undefined and the call threw a ReferenceError that got caught and
* silenced meaning the proxy was never installed, and the
* image-search endpoint kept ECONNREFUSED-ing on proxied dev
* machines. undici ships inside Node 18+ itself (Node's fetch is
* built on it) and is always resolvable as an ESM module, so a
* static import is the right shape.
*
* Idempotent: first call installs the dispatcher, subsequent calls
* are no-ops, so endpoints can call this from their own module init
* without coordinating.
*/
let configured = false;
@ -31,20 +46,5 @@ export function configureProxyDispatcher(): void {
process.env.http_proxy;
if (!proxy) return;
try {
// Dynamic require so production builds that strip undici (or run
// on non-node runtimes) don't crash at import time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const undici = require('undici') as {
setGlobalDispatcher: (d: unknown) => void;
ProxyAgent: new (uri: string) => unknown;
};
undici.setGlobalDispatcher(new undici.ProxyAgent(proxy));
} catch {
// undici not resolvable — the default fetch dispatcher will be
// used, which means external fetches that need the proxy will
// continue to fail. Logging here is suppressed because this runs
// at module load and would noise up startup; the symptom shows up
// as fetch failures downstream where it can be diagnosed.
}
setGlobalDispatcher(new EnvHttpProxyAgent());
}

2193
bun.lock

File diff suppressed because it is too large Load diff