|
|
@@ -8,7 +8,7 @@
|
|
|
* @module @deepseek-ai/dsh-http-proxy/install
|
|
|
*/
|
|
|
|
|
|
-import type { Agent, Dispatcher } from 'undici'
|
|
|
+import type { Agent, Dispatcher, Pool } from 'undici'
|
|
|
import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts'
|
|
|
|
|
|
|
|
|
@@ -16,10 +16,14 @@ import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from '
|
|
|
let active: ProxyPolicy | undefined
|
|
|
|
|
|
/**
|
|
|
- * The proxy environment as it stood before the active policy was published, or `undefined` when none
|
|
|
- * is installed. A spawned child receives these, not the published ones: normalizing what this process
|
|
|
- * resolved into a child's environment would replace a value the user set for another tool — a SOCKS
|
|
|
- * proxy this package refuses but `curl` uses, or an `HTTPS_PROXY` the user never wrote at all.
|
|
|
+ * The proxy environment as the user exported it, or `undefined` when no policy is installed.
|
|
|
+ *
|
|
|
+ * Owned by the OUTERMOST install: a nested one — the plugin mounted over the launcher's policy —
|
|
|
+ * would otherwise record the outer policy's published values as if the user had written them, and
|
|
|
+ * hand every child a normalization the user never asked for.
|
|
|
+ *
|
|
|
+ * {@link childProxyEnv} keeps a value the user set rather than the one this process resolved from
|
|
|
+ * it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme.
|
|
|
*/
|
|
|
let inheritedProxyEnv: Readonly<Record<string, string | undefined>> | undefined
|
|
|
|
|
|
@@ -34,9 +38,10 @@ export function currentProxyPolicy(): ProxyPolicy | undefined {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Publish a policy through the proxy environment variables so both undici and every spawned child
|
|
|
- * observe the one resolved answer — including the `ALL_PROXY` fallback and the merged loopback
|
|
|
- * bypass, neither of which they would derive on their own.
|
|
|
+ * Publish a policy through the proxy environment variables, which is how the consumers that read an
|
|
|
+ * environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see
|
|
|
+ * the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that
|
|
|
+ * neither derives on its own. The global dispatcher does not read these; it routes by the policy.
|
|
|
*
|
|
|
* @param policy - the policy to publish.
|
|
|
* @returns a function restoring every name this call changed.
|
|
|
@@ -49,7 +54,8 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
|
|
|
for (const names of Object.values(POLICY_ENV_NAMES)) {
|
|
|
for (const name of names) previous.set(name, process.env[name])
|
|
|
}
|
|
|
- inheritedProxyEnv = Object.fromEntries(previous)
|
|
|
+ const previousInherited = inheritedProxyEnv
|
|
|
+ inheritedProxyEnv = previousInherited ?? Object.fromEntries(previous)
|
|
|
for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
|
|
|
const value = policy[field as keyof typeof POLICY_ENV_NAMES]
|
|
|
for (const name of names) {
|
|
|
@@ -62,16 +68,44 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
|
|
|
if (value === undefined) Reflect.deleteProperty(process.env, name)
|
|
|
else process.env[name] = value
|
|
|
}
|
|
|
- inheritedProxyEnv = undefined
|
|
|
+ inheritedProxyEnv = previousInherited
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Build the global dispatcher for one policy.
|
|
|
+ *
|
|
|
+ * Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a
|
|
|
+ * URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express
|
|
|
+ * this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would
|
|
|
+ * tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL
|
|
|
+ * the user named for it — the route and the diagnostic would then disagree.
|
|
|
+ *
|
|
|
+ * @param policy - the policy to route by; it must proxy at least one scheme.
|
|
|
+ * @returns the dispatcher to install, owning every per-origin agent its factory created.
|
|
|
+ */
|
|
|
+async function createPolicyDispatcher(policy: ProxyPolicy): Promise<Dispatcher> {
|
|
|
+ const { Agent, Pool, ProxyAgent } = await import('undici')
|
|
|
+ return new Agent({
|
|
|
+ factory(origin, options) {
|
|
|
+ // undici declares this parameter as `Object`, discarding the pool options it actually passes.
|
|
|
+ const passed = options as Pool.Options
|
|
|
+ const proxy = proxyForUrl(policy, new URL(origin.toString()))
|
|
|
+ if (proxy !== undefined) return new ProxyAgent({ ...passed, uri: proxy })
|
|
|
+ // What undici's own default factory builds for these options, which `factory` replaces
|
|
|
+ // wholesale. It reaches for a bare `Client` only at `connections: 1`, an option this
|
|
|
+ // dispatcher never carries: it is constructed with undici's defaults.
|
|
|
+ return new Pool(origin, passed)
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Route this process's outbound HTTP through `policy`.
|
|
|
*
|
|
|
* Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so
|
|
|
* every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy
|
|
|
- * that proxies nothing installs no dispatcher and leaves the environment untouched.
|
|
|
+ * that proxies nothing installs a direct dispatcher and leaves the environment untouched.
|
|
|
*
|
|
|
* Worker threads do not inherit the global dispatcher; each one calls this with the policy its host
|
|
|
* passed through `workerData`.
|
|
|
@@ -105,11 +139,9 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
|
|
|
}
|
|
|
}
|
|
|
const restoreEnv = applyPolicyEnv(policy)
|
|
|
- const { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
|
|
|
+ const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
|
|
|
const previousDispatcher = getGlobalDispatcher()
|
|
|
- // Constructed with no options on purpose: it reads the names applyPolicyEnv just wrote, so the
|
|
|
- // agent and `proxyForUrl` answer from the same values instead of each parsing the raw environment.
|
|
|
- const agent = new EnvHttpProxyAgent()
|
|
|
+ const agent = await createPolicyDispatcher(policy)
|
|
|
setGlobalDispatcher(agent)
|
|
|
active = policy
|
|
|
return async () => {
|
|
|
@@ -132,11 +164,19 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
|
|
|
* @param options - agent options; applied to whichever agent the policy selects. On the proxied path
|
|
|
* `connect` governs the connection to the PROXY, not to the origin, so a lookup meant to pin an
|
|
|
* origin address belongs only on a URL the policy bypasses.
|
|
|
+ * @param policy - the policy to route by, defaulting to the active one. A caller that already
|
|
|
+ * branched on {@link proxyForUrl} MUST pass the same policy object it branched on: reading the
|
|
|
+ * active policy again would let a mount or disposal between the two reads return a direct agent
|
|
|
+ * for a URL the caller cleared as proxied, dropping the address checks that branch skipped.
|
|
|
* @returns a dispatcher the caller owns and must close once the response body is consumed.
|
|
|
*/
|
|
|
-export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise<Dispatcher> {
|
|
|
+export async function createDispatcher(
|
|
|
+ url: URL,
|
|
|
+ options: Agent.Options = {},
|
|
|
+ policy: ProxyPolicy = active ?? DIRECT_POLICY,
|
|
|
+): Promise<Dispatcher> {
|
|
|
const undici = await import('undici')
|
|
|
- const proxy = proxyForUrl(active ?? DIRECT_POLICY, url)
|
|
|
+ const proxy = proxyForUrl(policy, url)
|
|
|
if (proxy === undefined) return new undici.Agent(options)
|
|
|
return new undici.ProxyAgent({ ...options, uri: proxy })
|
|
|
}
|
|
|
@@ -182,12 +222,19 @@ export function proxyUrlFor(url: URL): string | undefined {
|
|
|
/**
|
|
|
* The proxy environment a spawned child needs.
|
|
|
*
|
|
|
- * A child inherits the parent environment, which this process rewrote to its own resolved policy so
|
|
|
- * undici reads back exactly what was resolved. That normalization must not reach the child: it would
|
|
|
- * hand `curl` an `HTTPS_PROXY` this package invented from the HTTP one, or replace a SOCKS proxy the
|
|
|
- * user set for `curl` with an HTTP proxy they never named for that scheme. The result therefore
|
|
|
- * restores each name to the value the user exported — `undefined` for a name they never set — and
|
|
|
- * adds only the flag that makes a child Node honor them.
|
|
|
+ * A child inherits the parent environment, which this process rewrote to its own resolved policy.
|
|
|
+ * Handing that normalization straight through would replace values the user set for other tools, so
|
|
|
+ * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
|
|
|
+ * not swapped for the HTTP one this package fell back to for that scheme.
|
|
|
+ *
|
|
|
+ * A scheme the user named in neither casing carries the resolved value instead of being removed.
|
|
|
+ * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` reads
|
|
|
+ * neither `ALL_PROXY` nor a proxy that came from `cordis.yml`, so the child would connect directly
|
|
|
+ * while the parent proxies.
|
|
|
+ *
|
|
|
+ * The bypass list is always the resolved one. It only ever adds the loopback entries to what
|
|
|
+ * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
|
|
|
+ * proxy that cannot route it.
|
|
|
*
|
|
|
* The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
|
|
|
* also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
|
|
|
@@ -201,6 +248,16 @@ export function proxyUrlFor(url: URL): string | undefined {
|
|
|
* object when no proxy is active.
|
|
|
*/
|
|
|
export function childProxyEnv(): Readonly<Record<string, string | undefined>> {
|
|
|
- if (active === undefined || active.source === 'none' || inheritedProxyEnv === undefined) return {}
|
|
|
- return { ...inheritedProxyEnv, NODE_USE_ENV_PROXY: '1' }
|
|
|
+ const policy = active
|
|
|
+ const inherited = inheritedProxyEnv
|
|
|
+ if (policy === undefined || policy.source === 'none' || inherited === undefined) return {}
|
|
|
+ const overlay: Record<string, string | undefined> = { NODE_USE_ENV_PROXY: '1' }
|
|
|
+ for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
|
|
|
+ const resolved = policy[field as keyof typeof POLICY_ENV_NAMES]
|
|
|
+ // Naming a scheme in either casing claims that scheme: the child then gets exactly what the
|
|
|
+ // user wrote, in the casing they wrote it, rather than a value derived for this process.
|
|
|
+ const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined)
|
|
|
+ for (const name of names) overlay[name] = named ? inherited[name] : resolved
|
|
|
+ }
|
|
|
+ return overlay
|
|
|
}
|