File size: 14,928 Bytes
488d96b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 | /**
* Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
* process-wide record of which policy is active.
*
* `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
* transport exists, matching how `dsh-web-fetch-http` defers its own transport import.
* @module @deepseek-ai/dsh-http-proxy/install
*/
import type { Dispatcher, Pool } from 'undici'
import {
isSupportedProxyUrl,
POLICY_ENV_NAMES,
PROXY_ENV_NAMES,
proxyForUrl,
resolveProxyPolicy,
type EnvLookup,
type ProxyPolicy,
} from './policy.ts'
/** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
let active: ProxyPolicy | undefined
/**
* The proxy environment as the user exported it, or `undefined` when no policy is installed.
*
* Owned by the OUTERMOST install: one layered over the launcher's 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 proxyEnvironmentForChild} 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
/** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */
let installed: Dispatcher | undefined
/**
* How this process must send one request.
*
* A caller that branches on the answer needs the transport that answer assumed, or an install or
* disposal landing between the two would send the request somewhere the branch did not clear. The
* proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide
* and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather
* than destroying it, so a request already dispatched when a policy is unmounted still finishes.
*/
export type ProxyRoute =
| { readonly proxied: true; readonly proxy: string; readonly dispatcher: Dispatcher }
| { readonly proxied: false }
/** A route that sends nothing through a proxy, shared because it carries no per-request state. */
const DIRECT_ROUTE: ProxyRoute = { proxied: false }
/**
* Decide how to send one request, and hand back the transport that decision assumed.
*
* @param url - the request URL.
* @returns the proxied route with its proxy URL and dispatcher, or the direct route.
*/
export function proxyRouteFor(url: URL): ProxyRoute {
const policy = active
const dispatcher = installed
if (policy === undefined || dispatcher === undefined) return DIRECT_ROUTE
const proxy = proxyForUrl(policy, url)
return proxy === undefined ? DIRECT_ROUTE : { proxied: true, proxy, dispatcher }
}
/**
* 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.
*/
function applyPolicyEnv(policy: ProxyPolicy): () => void {
const previousInherited = inheritedProxyEnv
inheritedProxyEnv = previousInherited ?? snapshotProxyEnv()
const published: Record<string, string | undefined> = {}
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) published[name] = value
}
const restore = writeProxyEnv(published)
return () => {
restore()
inheritedProxyEnv = previousInherited
}
}
/**
* Read every proxy name this package publishes, as `process.env` holds it now.
*
* @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name.
*/
function snapshotProxyEnv(): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {}
for (const names of Object.values(POLICY_ENV_NAMES)) {
for (const name of names) snapshot[name] = process.env[name]
}
return snapshot
}
/**
* Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`.
*
* @param values - the value each name in {@link POLICY_ENV_NAMES} should hold.
* @returns a function restoring every name to what it held before this call.
*/
function writeProxyEnv(values: Readonly<Record<string, string | undefined>>): () => void {
// Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively,
// so reading the uppercase spelling after writing the lowercase one would read back the value just
// written and restore the policy instead of the user's environment.
const previous = snapshotProxyEnv()
for (const name of Object.keys(previous)) {
const value = values[name]
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
return () => {
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
}
}
/**
* 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 a direct dispatcher and leaves the environment untouched.
*
* A worker thread has its own `globalThis` and so its own dispatcher; installing here does not
* reach it. No worker installs one today: both this repository ships — the workflow engine and the
* PTC runtime — evaluate model-authored scripts, which must not receive a proxy URL that may carry
* credentials. A worker that needs the policy has to be handed one explicitly and install it itself.
*
* @param policy - the resolved policy to install.
* @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent.
*/
async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<void>> {
const previousPolicy = active
if (policy.source === 'none') {
// A direct policy mounted over an installed one must actually stop proxying. Recording the policy
// alone would leave the previous agent as the global dispatcher, so a plain `fetch()` would keep
// tunnelling while `proxyForUrl()` reported a direct connection — and `mode: 'off'` would be a
// silent no-op. With nothing installed there is nothing to displace.
if (previousPolicy === undefined) {
active = policy
return () => {
active = previousPolicy
return Promise.resolve()
}
}
const previousInstalled = installed
// The install underneath published its normalized policy into `process.env`, which is what a
// spawned child copies. With no policy active there is no normalization to stand behind, so the
// user's own values return for the window and the outer install's come back when it ends. An
// install underneath that proxied nothing published nothing, and there is nothing to put back.
const restoreEnv = inheritedProxyEnv === undefined ? undefined : writeProxyEnv(inheritedProxyEnv)
const undici = await import('undici')
const previous = undici.getGlobalDispatcher()
const direct = new undici.Agent()
undici.setGlobalDispatcher(direct)
active = policy
installed = undefined
return async () => {
undici.setGlobalDispatcher(previous)
active = previousPolicy
installed = previousInstalled
restoreEnv?.()
await direct.close()
}
}
const restoreEnv = applyPolicyEnv(policy)
const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
const previousDispatcher = getGlobalDispatcher()
const previousInstalled = installed
const agent = await createPolicyDispatcher(policy)
setGlobalDispatcher(agent)
active = policy
installed = agent
return async () => {
setGlobalDispatcher(previousDispatcher)
active = previousPolicy
installed = previousInstalled
restoreEnv()
await agent.close()
}
}
/**
* The proxy environment a spawned child needs.
*
* 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` does
* not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name 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
* their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
* read the variables themselves.
*
* The flag is withheld when a proxy value the child receives is one this package refused. Node
* parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a
* scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node
* child from starting. Without the flag such a child connects directly, as this process already
* reported for that scheme, and `curl` still reads the value it was kept for.
*
* A worker thread is deliberately NOT served here — see the workflow engine, which runs
* model-authored scripts and must not receive a proxy URL that may carry credentials.
*
* @returns names to apply to the child environment, where `undefined` means remove, or an empty
* object when no proxy is active.
*/
export function proxyEnvironmentForChild(): Readonly<Record<string, string | undefined>> {
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
}
const parsedByNode = [...POLICY_ENV_NAMES.httpProxy, ...POLICY_ENV_NAMES.httpsProxy]
if (parsedByNode.some(name => overlay[name] !== undefined && !isSupportedProxyUrl(overlay[name]))) {
delete overlay.NODE_USE_ENV_PROXY
}
return overlay
}
/**
* Resolve this process's proxy policy from `env` and install it.
*
* Resolution, reporting, and installation are one operation because no caller needs them apart: the
* launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
* installed routes nothing.
*
* A value the environment supplies but this package cannot use is reported and skipped rather than
* thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
* must not stop the agent from starting.
*
* @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
* @param report - receives one message per rejected value, in the order the values were considered.
* @returns a disposer restoring the previous dispatcher, policy, and environment.
*/
export async function installProxyFromEnvironment(
env: EnvLookup,
report: (message: string) => void,
): Promise<() => Promise<void>> {
const { policy, diagnostics } = resolveProxyPolicy(env)
for (const diagnostic of diagnostics) report(diagnostic.message)
return await installGlobalProxy(policy)
}
/**
* The environment overlay that removes every proxy name from a spawned child.
*
* A harness that replays a recorded session must reach its own fixture server, not the proxy a
* developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
*
* @returns one entry per proxy name, each `undefined`.
*/
export function clearedProxyEnv(): Record<string, undefined> {
return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
}
|