@openclaw/proxyline 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +59 -90
- package/dist/connect.d.ts +4 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +28 -8
- package/dist/node-http.d.ts.map +1 -1
- package/dist/node-http.js +19 -18
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +19 -5
- package/dist/shared.d.ts +2 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +9 -0
- package/docs/api-reference.md +1 -1
- package/docs/surfaces.md +1 -1
- package/package.json +6 -6
- package/src/connect.ts +33 -8
- package/src/node-http.ts +19 -18
- package/src/runtime.ts +22 -5
- package/src/shared.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.6 - 2026-08-02
|
|
4
|
+
|
|
5
|
+
- Fixed HTTP forwarding through HTTPS proxies to wait for the proxy TLS handshake before assigning the socket to Node's agent. Thanks @SebTardif.
|
|
6
|
+
- Added a 30-second default timeout to `openProxyConnectTunnel` when `timeoutMs` is omitted; pass `0` for unbounded waits. Thanks @SebTardif.
|
|
7
|
+
|
|
8
|
+
## 0.3.5 - 2026-08-01
|
|
9
|
+
|
|
10
|
+
- Fixed malformed percent-encoding in proxy credentials to fail through CONNECT promises and Node request errors with `INVALID_PROXY_USERINFO`. Thanks @SebTardif.
|
|
11
|
+
- Updated Undici, WebSocket and TypeScript tooling, pnpm, and pinned GitHub Actions to their latest stable releases.
|
|
12
|
+
|
|
3
13
|
## 0.3.4 - 2026-07-20
|
|
4
14
|
|
|
5
15
|
- Added `AbortSignal` cancellation to explicit CONNECT tunnels, including active socket cleanup.
|
package/README.md
CHANGED
|
@@ -1,133 +1,97 @@
|
|
|
1
|
-
# 🌐
|
|
1
|
+
# Proxyline 🌐 — Keep every Node request in line.
|
|
2
2
|
|
|
3
|
-
](./LICENSE)
|
|
8
|
-
|
|
9
|
-
Process-global proxy routing for Node.js. One install replaces `node:http`, `node:https`, the undici/fetch global dispatcher, and provides WebSocket and explicit HTTP CONNECT helpers for the same policy.
|
|
3
|
+
[](https://github.com/openclaw/proxyline/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@openclaw/proxyline)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](./LICENSE)
|
|
10
7
|
|
|
11
|
-
Proxyline
|
|
8
|
+

|
|
12
9
|
|
|
13
|
-
Proxyline's
|
|
10
|
+
Proxyline installs one process-wide proxy policy across Node's built-in HTTP(S) clients, global fetch and Undici, compatible WebSocket clients, and explicit CONNECT tunnels. It is for Node applications that need one runtime to enforce proxy routing, explain its decisions, and restore the original networking globals.
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
Documentation is available at [proxyline.dev](https://proxyline.dev).
|
|
16
13
|
|
|
17
|
-
##
|
|
14
|
+
## Install
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **Intentional bypasses only.** Managed mode can accept a `bypassPolicy` callback, process-wide `registerBypass()`, or async-scoped `withBypass()` calls for trusted loopback or control-plane traffic that must stay direct; every bypass is visible through `explain()`.
|
|
23
|
-
- **Embeddable runtime controls.** `ifActive` handles process singleton reuse/replacement, `undici` options tune dispatcher defaults, and `isProxylineDispatcher()` identifies Proxyline-owned dispatchers without constructor-name checks.
|
|
24
|
-
- **Scoped proxy CA trust.** `proxyTls.ca` / `proxyTls.caFile` trust a private CA for the proxy endpoint only — no `NODE_EXTRA_CA_CERTS` and no `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
|
25
|
-
- **Observable.** `proxy.explain(url)` returns a structured decision (`proxied` / `direct` with a `reason`), and an `onEvent` callback receives `runtime.installed`, `runtime.stopped`, and per-decision events. Proxy URLs are credential-redacted.
|
|
26
|
-
- **Restoreable.** `proxy.stop()` restores the captured Node HTTP(S) methods, global agents, undici dispatcher, and fetch globals. The runtime is a process-wide singleton; by default a second active install throws `RUNTIME_ALREADY_ACTIVE`, while `ifActive` can reuse or replace intentionally.
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add @openclaw/proxyline undici@^8.5.0
|
|
18
|
+
```
|
|
27
19
|
|
|
28
|
-
|
|
20
|
+
Or with npm:
|
|
29
21
|
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
# or
|
|
33
|
-
npm install @openclaw/proxyline
|
|
22
|
+
```sh
|
|
23
|
+
npm install @openclaw/proxyline undici@^8.5.0
|
|
34
24
|
```
|
|
35
25
|
|
|
36
|
-
|
|
26
|
+
Proxyline requires Node.js 22.19.0 or newer and a host `undici` version in the `>=8.5.0 <9` range. The package is ESM-only and includes TypeScript declarations.
|
|
37
27
|
|
|
38
28
|
## Quick start
|
|
39
29
|
|
|
40
|
-
|
|
30
|
+
Save this as `proxy.mjs`:
|
|
41
31
|
|
|
42
|
-
```
|
|
32
|
+
```js
|
|
43
33
|
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
44
34
|
|
|
45
35
|
const proxy = installGlobalProxy({
|
|
46
36
|
mode: "managed",
|
|
47
|
-
proxyUrl: "
|
|
48
|
-
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
49
|
-
onEvent: (event) => console.debug("[proxyline]", event),
|
|
37
|
+
proxyUrl: "http://127.0.0.1:3128",
|
|
50
38
|
});
|
|
39
|
+
console.log(proxy.explain("https://api.example.com/").reason);
|
|
40
|
+
proxy.stop();
|
|
41
|
+
```
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
```sh
|
|
44
|
+
node proxy.mjs
|
|
45
|
+
# managed-proxy-active
|
|
53
46
|
```
|
|
54
47
|
|
|
55
|
-
|
|
48
|
+
This asks Proxyline for a routing decision without connecting to the placeholder proxy. Install Proxyline before loading application or plugin code that may capture networking functions.
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
50
|
+
## Choose a mode
|
|
59
51
|
|
|
60
|
-
|
|
61
|
-
if (!proxy.active) {
|
|
62
|
-
console.warn("no HTTP_PROXY/HTTPS_PROXY/ALL_PROXY set — egress will be direct");
|
|
63
|
-
}
|
|
64
|
-
```
|
|
52
|
+
Proxyline has two explicit routing modes:
|
|
65
53
|
|
|
66
|
-
|
|
54
|
+
| Mode | Configuration | Direct traffic |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `managed` | A required `proxyUrl` in code | Only through `bypassPolicy`, `registerBypass()`, or `withBypass()` |
|
|
57
|
+
| `ambient` | `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` | Whenever the environment has no matching proxy |
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
import WebSocket from "ws";
|
|
59
|
+
Managed mode fails during setup when its proxy configuration is missing or unsupported. Ambient mode reads the environment once at installation and stays inactive when no supported HTTP or HTTPS proxy is configured. See [Modes](./docs/modes.md) and [Environment Variables](./docs/environment-variables.md) for the complete rules.
|
|
70
60
|
|
|
71
|
-
|
|
72
|
-
agent: proxy.createWebSocketAgent(),
|
|
73
|
-
});
|
|
74
|
-
```
|
|
61
|
+
## Covered traffic
|
|
75
62
|
|
|
76
|
-
|
|
63
|
+
| Surface | How Proxyline applies the policy |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `node:http` and `node:https` | Patches request methods and replaces global and caller-supplied agents |
|
|
66
|
+
| Global fetch and Undici | Installs a global dispatcher and a compatible fetch stack |
|
|
67
|
+
| WebSocket clients | Supplies `proxy.createWebSocketAgent()` for clients that accept a Node agent |
|
|
68
|
+
| Explicit tunnels | Supplies `openProxyConnectTunnel()` for callers that need the connected socket |
|
|
77
69
|
|
|
78
|
-
|
|
79
|
-
import { openProxyConnectTunnel } from "@openclaw/proxyline";
|
|
70
|
+
`proxy.createNodeAgent()` and `proxy.createUndiciDispatcher()` expose the same policy to libraries that accept an agent or dispatcher directly. The [surface guide](./docs/surfaces.md) describes ownership, TLS preservation, and cleanup for each API.
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
const socket = await openProxyConnectTunnel({
|
|
83
|
-
proxyUrl: "https://proxy.corp.example:8443",
|
|
84
|
-
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
85
|
-
targetHost: "api.example.com",
|
|
86
|
-
targetPort: 443,
|
|
87
|
-
timeoutMs: 2_000,
|
|
88
|
-
signal: controller.signal,
|
|
89
|
-
});
|
|
90
|
-
```
|
|
72
|
+
## Bypasses and proxy trust
|
|
91
73
|
|
|
92
|
-
|
|
74
|
+
Managed mode supports deliberate direct-routing exceptions. A `bypassPolicy` handles installation-time policy, `registerBypass()` registers an exact process-wide exception, and `withBypass()` limits an exception to one async context. Each decision remains visible through `explain()`.
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
import { createAmbientNodeProxyAgent } from "@openclaw/proxyline";
|
|
76
|
+
For an HTTPS proxy with a private CA, use `proxyTls.ca` or `proxyTls.caFile`. That trust applies only to the proxy connection; destination TLS validation remains separate. See [Proxy TLS](./docs/proxy-tls.md).
|
|
96
77
|
|
|
97
|
-
|
|
98
|
-
protocol: "https",
|
|
99
|
-
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
100
|
-
});
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
The helper returns `undefined` when ambient proxy env is not configured, so callers can pass an agent only when needed.
|
|
104
|
-
It uses Proxyline's built-in HTTP/HTTPS Node agent, and `proxyTls` applies only to HTTPS proxy endpoints. SOCKS and PAC proxy schemes remain unsupported.
|
|
78
|
+
## Observability and lifecycle
|
|
105
79
|
|
|
106
|
-
|
|
80
|
+
`proxy.explain(url)` reports `proxied` or `direct`, the reason, the surface, and a credential-redacted proxy URL when one applies. The optional `onEvent` callback receives installation, shutdown, and decision events.
|
|
107
81
|
|
|
108
|
-
|
|
109
|
-
- `https.request` / `https.get`: covered by global method patching and global agent replacement.
|
|
110
|
-
- `globalThis.fetch`: covered by the fetch patch, including explicit dispatcher options and later Undici global dispatcher replacement in managed mode.
|
|
111
|
-
- Undici global dispatcher: installed for Undici APIs that read the current process dispatcher.
|
|
112
|
-
- WebSocket clients accepting a Node `agent`: covered with `proxy.createWebSocketAgent()`.
|
|
113
|
-
- Caller-built `http.Agent` / `https.Agent`: overridden in managed and active ambient mode, with TLS options preserved.
|
|
114
|
-
- Explicit HTTP CONNECT sockets: covered with `openProxyConnectTunnel()`.
|
|
115
|
-
- Raw `net.connect` / `tls.connect`: out of scope; see [Security](./docs/security.md).
|
|
116
|
-
- Native or private transport stacks: out of scope; see [Security](./docs/security.md).
|
|
82
|
+
Only one Proxyline runtime is active in a process. A second installation fails by default; `ifActive` can reuse a compatible runtime or replace it intentionally. `proxy.stop()` restores the captured Node HTTP(S) methods, global agents, Undici dispatcher, and fetch globals. See [Observability](./docs/observability.md) and the [API Reference](./docs/api-reference.md).
|
|
117
83
|
|
|
118
|
-
##
|
|
84
|
+
## Security boundary
|
|
119
85
|
|
|
120
|
-
|
|
86
|
+
Proxyline is a Node-process runtime, not an operating-system sandbox. Raw `net` or `tls` sockets, native or private transport stacks, networking functions captured before installation, and DNS traffic are outside its boundary. Combine it with operating-system egress controls when code in the process is not trusted.
|
|
121
87
|
|
|
122
|
-
|
|
88
|
+
Read the [security model](./docs/security.md) before treating managed mode as an enforcement boundary.
|
|
123
89
|
|
|
124
90
|
## Documentation
|
|
125
91
|
|
|
126
|
-
Full docs live in [`docs/`](./docs/README.md):
|
|
127
|
-
|
|
128
92
|
- [Getting Started](./docs/getting-started.md)
|
|
129
|
-
- [Modes](./docs/modes.md)
|
|
130
|
-
- [Surfaces](./docs/surfaces.md)
|
|
93
|
+
- [Modes](./docs/modes.md)
|
|
94
|
+
- [Surfaces](./docs/surfaces.md)
|
|
131
95
|
- [API Reference](./docs/api-reference.md)
|
|
132
96
|
- [Environment Variables](./docs/environment-variables.md)
|
|
133
97
|
- [Proxy TLS](./docs/proxy-tls.md)
|
|
@@ -136,9 +100,14 @@ Full docs live in [`docs/`](./docs/README.md):
|
|
|
136
100
|
- [Troubleshooting](./docs/troubleshooting.md)
|
|
137
101
|
- [Testing](./docs/testing.md)
|
|
138
102
|
|
|
139
|
-
##
|
|
103
|
+
## Development
|
|
140
104
|
|
|
141
|
-
|
|
105
|
+
```sh
|
|
106
|
+
pnpm install --frozen-lockfile
|
|
107
|
+
pnpm check
|
|
108
|
+
pnpm test
|
|
109
|
+
pnpm docs:build
|
|
110
|
+
```
|
|
142
111
|
|
|
143
112
|
## License
|
|
144
113
|
|
package/dist/connect.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type OpenProxyConnectTunnelOptions = Readonly<{
|
|
|
6
6
|
proxyTls?: ProxylineTlsOptions;
|
|
7
7
|
targetHost: string;
|
|
8
8
|
targetPort: number;
|
|
9
|
+
/**
|
|
10
|
+
* Overall budget for the CONNECT handshake.
|
|
11
|
+
* Omit for the default (30s). Pass `0` (or a negative value) for no timeout.
|
|
12
|
+
*/
|
|
9
13
|
timeoutMs?: number;
|
|
10
14
|
signal?: AbortSignal;
|
|
11
15
|
}>;
|
package/dist/connect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,EAAgD,KAAK,mBAAmB,EAAqC,MAAM,aAAa,CAAC;AAExI,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACnD,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC;IACvB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,CAAC,CAAC;AAsBH,KAAK,WAAW,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC;AAsB9C,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAqBrF;AAkDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,WAAW,CAAC,CA6HtB"}
|
package/dist/connect.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import tls from "node:tls";
|
|
3
|
-
import { ProxylineError, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
3
|
+
import { ProxylineError, decodeProxyUserinfoComponent, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
4
|
+
/** Default CONNECT handshake budget when `timeoutMs` is omitted. */
|
|
5
|
+
const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the CONNECT timeout to apply.
|
|
8
|
+
* - `undefined` → default 30s
|
|
9
|
+
* - `<= 0` → no timeout (unbounded)
|
|
10
|
+
* - positive → truncated milliseconds
|
|
11
|
+
*/
|
|
12
|
+
function resolveProxyConnectTimeoutMs(timeoutMs) {
|
|
13
|
+
if (timeoutMs === undefined) {
|
|
14
|
+
return DEFAULT_PROXY_CONNECT_TIMEOUT_MS;
|
|
15
|
+
}
|
|
16
|
+
return timeoutMs > 0 ? Math.trunc(timeoutMs) : undefined;
|
|
17
|
+
}
|
|
4
18
|
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
5
19
|
const INVALID_CONNECT_AUTHORITY_PATTERN = /[\u0000-\u0020\u007f]/;
|
|
6
20
|
const INVALID_CONNECT_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
|
|
@@ -17,8 +31,8 @@ function resolveProxyAuthorization(proxy) {
|
|
|
17
31
|
if (!proxy.username && !proxy.password) {
|
|
18
32
|
return undefined;
|
|
19
33
|
}
|
|
20
|
-
const username =
|
|
21
|
-
const password =
|
|
34
|
+
const username = decodeProxyUserinfoComponent(proxy.username);
|
|
35
|
+
const password = decodeProxyUserinfoComponent(proxy.password);
|
|
22
36
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
23
37
|
}
|
|
24
38
|
export function formatConnectAuthority(targetHost, targetPort) {
|
|
@@ -109,7 +123,7 @@ export async function openProxyConnectTunnel(options) {
|
|
|
109
123
|
settled = true;
|
|
110
124
|
cleanup();
|
|
111
125
|
socket?.destroy();
|
|
112
|
-
reject(failConnect(proxy, error));
|
|
126
|
+
reject(error instanceof ProxylineError ? error : failConnect(proxy, error));
|
|
113
127
|
};
|
|
114
128
|
const succeed = (connectedSocket, tunneledBytes) => {
|
|
115
129
|
if (settled) {
|
|
@@ -128,7 +142,12 @@ export async function openProxyConnectTunnel(options) {
|
|
|
128
142
|
fail(new Error("proxy socket missing after connect"));
|
|
129
143
|
return;
|
|
130
144
|
}
|
|
131
|
-
|
|
145
|
+
try {
|
|
146
|
+
writeConnectRequest(socket, proxy, target);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
fail(error);
|
|
150
|
+
}
|
|
132
151
|
};
|
|
133
152
|
const onData = (chunk) => {
|
|
134
153
|
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
|
@@ -172,10 +191,11 @@ export async function openProxyConnectTunnel(options) {
|
|
|
172
191
|
onAbort();
|
|
173
192
|
return;
|
|
174
193
|
}
|
|
175
|
-
|
|
194
|
+
const connectTimeoutMs = resolveProxyConnectTimeoutMs(options.timeoutMs);
|
|
195
|
+
if (connectTimeoutMs !== undefined) {
|
|
176
196
|
timeout = setTimeout(() => {
|
|
177
|
-
fail(new Error(`proxy CONNECT timed out after ${
|
|
178
|
-
},
|
|
197
|
+
fail(new Error(`proxy CONNECT timed out after ${connectTimeoutMs}ms`));
|
|
198
|
+
}, connectTimeoutMs);
|
|
179
199
|
}
|
|
180
200
|
socket = connectToProxy(proxy, options.proxyTls);
|
|
181
201
|
socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
package/dist/node-http.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node-http.d.ts","sourceRoot":"","sources":["../src/node-http.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,MAAM,YAAY,CAAC;AAI/B,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,UAAU,CAAC;AAElB,OAAO,
|
|
1
|
+
{"version":3,"file":"node-http.d.ts","sourceRoot":"","sources":["../src/node-http.ts"],"names":[],"mappings":"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"}
|
package/dist/node-http.js
CHANGED
|
@@ -5,7 +5,7 @@ import tls from "node:tls";
|
|
|
5
5
|
import { domainToASCII } from "node:url";
|
|
6
6
|
import { readProxyEnv, resolveAmbientProxyForUrl, } from "./env.js";
|
|
7
7
|
import { formatConnectAuthority } from "./connect.js";
|
|
8
|
-
import { ProxylineError, resolveProxyTlsCa } from "./shared.js";
|
|
8
|
+
import { ProxylineError, decodeProxyUserinfoComponent, resolveProxyTlsCa } from "./shared.js";
|
|
9
9
|
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
10
10
|
const INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
|
|
11
11
|
const INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN = /[\u0000-\u0020\u007f]/;
|
|
@@ -133,8 +133,8 @@ function proxyAuthorization(proxy) {
|
|
|
133
133
|
if (!proxy.username && !proxy.password) {
|
|
134
134
|
return undefined;
|
|
135
135
|
}
|
|
136
|
-
const username =
|
|
137
|
-
const password =
|
|
136
|
+
const username = decodeProxyUserinfoComponent(proxy.username);
|
|
137
|
+
const password = decodeProxyUserinfoComponent(proxy.password);
|
|
138
138
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
139
139
|
}
|
|
140
140
|
function proxyConnectOptions(proxy, proxyTls) {
|
|
@@ -314,6 +314,9 @@ class ProxylineHttpForwardAgent extends http.Agent {
|
|
|
314
314
|
}
|
|
315
315
|
createConnection(_options, callback) {
|
|
316
316
|
const socket = connectToProxy(this.#proxy, this.#proxyTls);
|
|
317
|
+
// When Node supplies an async callback, deliver the socket only through that
|
|
318
|
+
// path. Returning the same socket as well double-invokes Agent setup and can
|
|
319
|
+
// hand an unready TLS proxy socket to plain HTTP forward traffic.
|
|
317
320
|
if (callback !== undefined) {
|
|
318
321
|
const onError = (error) => {
|
|
319
322
|
callback(error, socket);
|
|
@@ -324,6 +327,7 @@ class ProxylineHttpForwardAgent extends http.Agent {
|
|
|
324
327
|
};
|
|
325
328
|
socket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
326
329
|
socket.once("error", onError);
|
|
330
|
+
return undefined;
|
|
327
331
|
}
|
|
328
332
|
return socket;
|
|
329
333
|
}
|
|
@@ -445,26 +449,23 @@ class ProxylineConnectAgent extends http.Agent {
|
|
|
445
449
|
finish(error, proxySocket);
|
|
446
450
|
};
|
|
447
451
|
const onConnected = () => {
|
|
448
|
-
let target;
|
|
449
452
|
try {
|
|
450
|
-
|
|
453
|
+
const { host, port } = connectTarget(options);
|
|
454
|
+
const authority = formatConnectAuthority(host, port);
|
|
455
|
+
const headers = [
|
|
456
|
+
`CONNECT ${authority} HTTP/1.1`,
|
|
457
|
+
`Host: ${authority}`,
|
|
458
|
+
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
|
|
459
|
+
];
|
|
460
|
+
const authorization = proxyAuthorization(this.#proxy);
|
|
461
|
+
if (authorization !== undefined) {
|
|
462
|
+
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
463
|
+
}
|
|
464
|
+
proxySocket.write([...headers, "", ""].join("\r\n"));
|
|
451
465
|
}
|
|
452
466
|
catch (error) {
|
|
453
467
|
fail(error instanceof Error ? error : new Error(String(error)));
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
const { host, port } = target;
|
|
457
|
-
const authority = formatConnectAuthority(host, port);
|
|
458
|
-
const headers = [
|
|
459
|
-
`CONNECT ${authority} HTTP/1.1`,
|
|
460
|
-
`Host: ${authority}`,
|
|
461
|
-
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
|
|
462
|
-
];
|
|
463
|
-
const authorization = proxyAuthorization(this.#proxy);
|
|
464
|
-
if (authorization !== undefined) {
|
|
465
|
-
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
466
468
|
}
|
|
467
|
-
proxySocket.write([...headers, "", ""].join("\r\n"));
|
|
468
469
|
};
|
|
469
470
|
const onData = (chunk) => {
|
|
470
471
|
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
package/dist/runtime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAIV,eAAe,EACf,gBAAgB,EAIjB,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAIV,eAAe,EACf,gBAAgB,EAIjB,MAAM,YAAY,CAAC;AAs0BpB,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,eAAe,CAgI3E;AAED,eAAO,MAAM,kBAAkB,yBAAmB,CAAC"}
|
package/dist/runtime.js
CHANGED
|
@@ -334,13 +334,25 @@ function createUndiciProxyAgent(proxyUrl, options) {
|
|
|
334
334
|
...(net.isIP(proxyHostname) === 6 ? { checkServerIdentity: checkProxyServerIdentity } : {}),
|
|
335
335
|
}
|
|
336
336
|
: undefined;
|
|
337
|
+
const dispatcherFactory = createProxyClientFactory();
|
|
337
338
|
return new UndiciProxyAgent({
|
|
338
339
|
...resolveUndiciBaseOptions(options.undici),
|
|
339
340
|
uri: proxyUrl,
|
|
340
|
-
clientFactory:
|
|
341
|
+
clientFactory: dispatcherFactory,
|
|
342
|
+
factory: dispatcherFactory,
|
|
341
343
|
...(proxyTls !== undefined ? { proxyTls } : {}),
|
|
342
344
|
});
|
|
343
345
|
}
|
|
346
|
+
function normalizeUndiciDispatchOptions(options) {
|
|
347
|
+
if (options.origin === undefined || !/^https?:\/\//i.test(options.path)) {
|
|
348
|
+
return options;
|
|
349
|
+
}
|
|
350
|
+
const pathUrl = new URL(options.path);
|
|
351
|
+
return {
|
|
352
|
+
...options,
|
|
353
|
+
path: `${pathUrl.pathname}${pathUrl.search}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
344
356
|
function createUndiciProxyDispatcher(options, dispatcherOptions) {
|
|
345
357
|
if (options.mode === "ambient") {
|
|
346
358
|
if (!options.active) {
|
|
@@ -379,10 +391,11 @@ class ManagedUndiciDispatcher extends Dispatcher {
|
|
|
379
391
|
if (this.#closedError !== undefined) {
|
|
380
392
|
return reportClosedDispatchError(handler, this.#closedError);
|
|
381
393
|
}
|
|
382
|
-
const
|
|
394
|
+
const normalizedOptions = normalizeUndiciDispatchOptions(options);
|
|
395
|
+
const url = resolveUndiciDispatchUrl(normalizedOptions);
|
|
383
396
|
const proxyUrl = url === undefined ? "" : this.#resolver.getProxyForUrl(url, "undici");
|
|
384
397
|
const dispatcher = proxyUrl === "" ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl);
|
|
385
|
-
return dispatcher.dispatch(
|
|
398
|
+
return dispatcher.dispatch(normalizedOptions, handler);
|
|
386
399
|
}
|
|
387
400
|
close(callback) {
|
|
388
401
|
const closing = this.#closeAll();
|
|
@@ -445,10 +458,11 @@ class AmbientUndiciDispatcher extends Dispatcher {
|
|
|
445
458
|
if (this.#closedError !== undefined) {
|
|
446
459
|
return reportClosedDispatchError(handler, this.#closedError);
|
|
447
460
|
}
|
|
448
|
-
const
|
|
461
|
+
const normalizedOptions = normalizeUndiciDispatchOptions(options);
|
|
462
|
+
const url = resolveUndiciDispatchUrl(normalizedOptions);
|
|
449
463
|
const proxyUrl = url === undefined ? undefined : resolveAmbientProxyForUrl(url, this.#env);
|
|
450
464
|
const dispatcher = proxyUrl === undefined ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl);
|
|
451
|
-
return dispatcher.dispatch(
|
|
465
|
+
return dispatcher.dispatch(normalizedOptions, handler);
|
|
452
466
|
}
|
|
453
467
|
close(callback) {
|
|
454
468
|
const closing = this.#closeAll();
|
package/dist/shared.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare class ProxylineError extends Error {
|
|
|
6
6
|
readonly code: string;
|
|
7
7
|
constructor(code: string, message: string);
|
|
8
8
|
}
|
|
9
|
+
/** Decode proxy userinfo; invalid percent-encoding must not throw URIError mid-CONNECT. */
|
|
10
|
+
export declare function decodeProxyUserinfoComponent(value: string): string;
|
|
9
11
|
export declare function resolveProxyTlsCa(options: ProxylineTlsOptions | undefined): string | undefined;
|
|
10
12
|
export declare function formatUrl(value: string | URL): string;
|
|
11
13
|
export declare function redactProxyUrl(value: string | URL): string;
|
package/dist/shared.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEH,qBAAa,cAAe,SAAQ,KAAK;IACvC,SAAgB,IAAI,EAAE,MAAM,CAAC;IAE7B,YAAmB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAI/C;CACF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW9F;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAErD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAO1D"}
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEH,qBAAa,cAAe,SAAQ,KAAK;IACvC,SAAgB,IAAI,EAAE,MAAM,CAAC;IAE7B,YAAmB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAI/C;CACF;AAED,2FAA2F;AAC3F,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASlE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW9F;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAErD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAO1D"}
|
package/dist/shared.js
CHANGED
|
@@ -7,6 +7,15 @@ export class ProxylineError extends Error {
|
|
|
7
7
|
this.code = code;
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
+
/** Decode proxy userinfo; invalid percent-encoding must not throw URIError mid-CONNECT. */
|
|
11
|
+
export function decodeProxyUserinfoComponent(value) {
|
|
12
|
+
try {
|
|
13
|
+
return decodeURIComponent(value);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new ProxylineError("INVALID_PROXY_USERINFO", "Proxy username or password contains invalid percent-encoding.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
10
19
|
export function resolveProxyTlsCa(options) {
|
|
11
20
|
if (!options) {
|
|
12
21
|
return undefined;
|
package/docs/api-reference.md
CHANGED
|
@@ -264,7 +264,7 @@ type OpenProxyConnectTunnelOptions = Readonly<{
|
|
|
264
264
|
- `proxyUrl` — `http://` or `https://`. Userinfo becomes a `Proxy-Authorization: Basic` header.
|
|
265
265
|
- `proxyTls` — CA trust for HTTPS proxies. See [Proxy TLS](./proxy-tls.md).
|
|
266
266
|
- `targetHost` / `targetPort` — what to ask the proxy to connect to.
|
|
267
|
-
- `timeoutMs` — overall budget for the CONNECT handshake.
|
|
267
|
+
- `timeoutMs` — overall budget for the CONNECT handshake. Defaults to `30000` when omitted. Pass `0` for no timeout.
|
|
268
268
|
- `signal` — optional caller cancellation; aborting rejects the handshake and destroys its active proxy socket.
|
|
269
269
|
|
|
270
270
|
### `AmbientNodeProxyAgentOptions`
|
package/docs/surfaces.md
CHANGED
|
@@ -93,7 +93,7 @@ Properties:
|
|
|
93
93
|
- HTTPS proxies use ALPN `http/1.1`. SNI is the proxy hostname unless that is an IP literal.
|
|
94
94
|
- Userinfo in the `proxyUrl` becomes a `Proxy-Authorization: Basic ...` header.
|
|
95
95
|
- A bounded `16 KiB` header buffer protects against malicious or runaway proxy responses.
|
|
96
|
-
- `timeoutMs` is enforced and emits a `CONNECT_FAILED` error on expiry.
|
|
96
|
+
- `timeoutMs` is enforced and emits a `CONNECT_FAILED` error on expiry. When omitted, the default is 30 seconds; pass `0` for no timeout.
|
|
97
97
|
- `signal` aborts an in-progress handshake and destroys its active proxy socket.
|
|
98
98
|
- Bytes the proxy sends after the response headers are re-injected with `socket.unshift()` so the caller sees the full target stream.
|
|
99
99
|
- Non-2xx status lines, header overrun, premature close, and socket errors are all surfaced as `ProxylineError` with code `CONNECT_FAILED`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/proxyline",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Process-global proxy routing for Node.js.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"url": "https://github.com/openclaw/proxyline/issues"
|
|
18
18
|
},
|
|
19
19
|
"homepage": "https://proxyline.dev",
|
|
20
|
-
"packageManager": "pnpm@11.
|
|
20
|
+
"packageManager": "pnpm@11.18.0",
|
|
21
21
|
"exports": {
|
|
22
22
|
".": {
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
@@ -62,12 +62,12 @@
|
|
|
62
62
|
"crabbox:warmup": "crabbox warmup"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@types/node": "26.1.
|
|
65
|
+
"@types/node": "26.1.2",
|
|
66
66
|
"@types/ws": "8.18.1",
|
|
67
|
-
"tsx": "4.23.
|
|
67
|
+
"tsx": "4.23.1",
|
|
68
68
|
"typescript": "^7.0.2",
|
|
69
|
-
"undici": "8.
|
|
70
|
-
"ws": "8.21.
|
|
69
|
+
"undici": "8.9.0",
|
|
70
|
+
"ws": "8.21.1"
|
|
71
71
|
},
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=22.19.0"
|
package/src/connect.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import tls from "node:tls";
|
|
3
|
-
import { ProxylineError, type ProxylineTlsOptions, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
3
|
+
import { ProxylineError, decodeProxyUserinfoComponent, type ProxylineTlsOptions, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
4
4
|
|
|
5
5
|
export type OpenProxyConnectTunnelOptions = Readonly<{
|
|
6
6
|
proxyUrl: string | URL;
|
|
7
7
|
proxyTls?: ProxylineTlsOptions;
|
|
8
8
|
targetHost: string;
|
|
9
9
|
targetPort: number;
|
|
10
|
+
/**
|
|
11
|
+
* Overall budget for the CONNECT handshake.
|
|
12
|
+
* Omit for the default (30s). Pass `0` (or a negative value) for no timeout.
|
|
13
|
+
*/
|
|
10
14
|
timeoutMs?: number;
|
|
11
15
|
signal?: AbortSignal;
|
|
12
16
|
}>;
|
|
13
17
|
|
|
18
|
+
/** Default CONNECT handshake budget when `timeoutMs` is omitted. */
|
|
19
|
+
const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the CONNECT timeout to apply.
|
|
23
|
+
* - `undefined` → default 30s
|
|
24
|
+
* - `<= 0` → no timeout (unbounded)
|
|
25
|
+
* - positive → truncated milliseconds
|
|
26
|
+
*/
|
|
27
|
+
function resolveProxyConnectTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
|
28
|
+
if (timeoutMs === undefined) {
|
|
29
|
+
return DEFAULT_PROXY_CONNECT_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
return timeoutMs > 0 ? Math.trunc(timeoutMs) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
15
35
|
const INVALID_CONNECT_AUTHORITY_PATTERN = /[\u0000-\u0020\u007f]/;
|
|
16
36
|
const INVALID_CONNECT_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
|
|
@@ -32,8 +52,8 @@ function resolveProxyAuthorization(proxy: URL): string | undefined {
|
|
|
32
52
|
if (!proxy.username && !proxy.password) {
|
|
33
53
|
return undefined;
|
|
34
54
|
}
|
|
35
|
-
const username =
|
|
36
|
-
const password =
|
|
55
|
+
const username = decodeProxyUserinfoComponent(proxy.username);
|
|
56
|
+
const password = decodeProxyUserinfoComponent(proxy.password);
|
|
37
57
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
38
58
|
}
|
|
39
59
|
|
|
@@ -142,7 +162,7 @@ export async function openProxyConnectTunnel(
|
|
|
142
162
|
settled = true;
|
|
143
163
|
cleanup();
|
|
144
164
|
socket?.destroy();
|
|
145
|
-
reject(failConnect(proxy, error));
|
|
165
|
+
reject(error instanceof ProxylineError ? error : failConnect(proxy, error));
|
|
146
166
|
};
|
|
147
167
|
|
|
148
168
|
const succeed = (connectedSocket: ProxySocket, tunneledBytes: Buffer | undefined): void => {
|
|
@@ -163,7 +183,11 @@ export async function openProxyConnectTunnel(
|
|
|
163
183
|
fail(new Error("proxy socket missing after connect"));
|
|
164
184
|
return;
|
|
165
185
|
}
|
|
166
|
-
|
|
186
|
+
try {
|
|
187
|
+
writeConnectRequest(socket, proxy, target);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
fail(error);
|
|
190
|
+
}
|
|
167
191
|
};
|
|
168
192
|
|
|
169
193
|
const onData = (chunk: Buffer): void => {
|
|
@@ -215,10 +239,11 @@ export async function openProxyConnectTunnel(
|
|
|
215
239
|
onAbort();
|
|
216
240
|
return;
|
|
217
241
|
}
|
|
218
|
-
|
|
242
|
+
const connectTimeoutMs = resolveProxyConnectTimeoutMs(options.timeoutMs);
|
|
243
|
+
if (connectTimeoutMs !== undefined) {
|
|
219
244
|
timeout = setTimeout(() => {
|
|
220
|
-
fail(new Error(`proxy CONNECT timed out after ${
|
|
221
|
-
},
|
|
245
|
+
fail(new Error(`proxy CONNECT timed out after ${connectTimeoutMs}ms`));
|
|
246
|
+
}, connectTimeoutMs);
|
|
222
247
|
}
|
|
223
248
|
socket = connectToProxy(proxy, options.proxyTls);
|
|
224
249
|
socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
package/src/node-http.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ProxyEnvSnapshot,
|
|
10
10
|
} from "./env.js";
|
|
11
11
|
import { formatConnectAuthority } from "./connect.js";
|
|
12
|
-
import { ProxylineError, resolveProxyTlsCa, type ProxylineTlsOptions } from "./shared.js";
|
|
12
|
+
import { ProxylineError, decodeProxyUserinfoComponent, resolveProxyTlsCa, type ProxylineTlsOptions } from "./shared.js";
|
|
13
13
|
import type { ProxylineSurface, ProxyResolver } from "./types.js";
|
|
14
14
|
|
|
15
15
|
export type NodeHttpRequestOptions = http.RequestOptions & https.RequestOptions & {
|
|
@@ -198,8 +198,8 @@ function proxyAuthorization(proxy: URL): string | undefined {
|
|
|
198
198
|
if (!proxy.username && !proxy.password) {
|
|
199
199
|
return undefined;
|
|
200
200
|
}
|
|
201
|
-
const username =
|
|
202
|
-
const password =
|
|
201
|
+
const username = decodeProxyUserinfoComponent(proxy.username);
|
|
202
|
+
const password = decodeProxyUserinfoComponent(proxy.password);
|
|
203
203
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -442,6 +442,9 @@ class ProxylineHttpForwardAgent extends http.Agent {
|
|
|
442
442
|
callback?: (error: Error | null, socket: net.Socket) => void,
|
|
443
443
|
): net.Socket {
|
|
444
444
|
const socket = connectToProxy(this.#proxy, this.#proxyTls);
|
|
445
|
+
// When Node supplies an async callback, deliver the socket only through that
|
|
446
|
+
// path. Returning the same socket as well double-invokes Agent setup and can
|
|
447
|
+
// hand an unready TLS proxy socket to plain HTTP forward traffic.
|
|
445
448
|
if (callback !== undefined) {
|
|
446
449
|
const onError = (error: Error): void => {
|
|
447
450
|
callback(error, socket);
|
|
@@ -452,6 +455,7 @@ class ProxylineHttpForwardAgent extends http.Agent {
|
|
|
452
455
|
};
|
|
453
456
|
socket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
454
457
|
socket.once("error", onError);
|
|
458
|
+
return undefined as unknown as net.Socket;
|
|
455
459
|
}
|
|
456
460
|
return socket;
|
|
457
461
|
}
|
|
@@ -591,25 +595,22 @@ class ProxylineConnectAgent extends http.Agent {
|
|
|
591
595
|
};
|
|
592
596
|
|
|
593
597
|
const onConnected = (): void => {
|
|
594
|
-
let target: { host: string; port: number };
|
|
595
598
|
try {
|
|
596
|
-
|
|
599
|
+
const { host, port } = connectTarget(options);
|
|
600
|
+
const authority = formatConnectAuthority(host, port);
|
|
601
|
+
const headers = [
|
|
602
|
+
`CONNECT ${authority} HTTP/1.1`,
|
|
603
|
+
`Host: ${authority}`,
|
|
604
|
+
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
|
|
605
|
+
];
|
|
606
|
+
const authorization = proxyAuthorization(this.#proxy);
|
|
607
|
+
if (authorization !== undefined) {
|
|
608
|
+
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
609
|
+
}
|
|
610
|
+
proxySocket.write([...headers, "", ""].join("\r\n"));
|
|
597
611
|
} catch (error) {
|
|
598
612
|
fail(error instanceof Error ? error : new Error(String(error)));
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
const { host, port } = target;
|
|
602
|
-
const authority = formatConnectAuthority(host, port);
|
|
603
|
-
const headers = [
|
|
604
|
-
`CONNECT ${authority} HTTP/1.1`,
|
|
605
|
-
`Host: ${authority}`,
|
|
606
|
-
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
|
|
607
|
-
];
|
|
608
|
-
const authorization = proxyAuthorization(this.#proxy);
|
|
609
|
-
if (authorization !== undefined) {
|
|
610
|
-
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
611
613
|
}
|
|
612
|
-
proxySocket.write([...headers, "", ""].join("\r\n"));
|
|
613
614
|
};
|
|
614
615
|
|
|
615
616
|
const onData = (chunk: Buffer): void => {
|
package/src/runtime.ts
CHANGED
|
@@ -524,14 +524,29 @@ function createUndiciProxyAgent(
|
|
|
524
524
|
...(net.isIP(proxyHostname) === 6 ? { checkServerIdentity: checkProxyServerIdentity } : {}),
|
|
525
525
|
}
|
|
526
526
|
: undefined;
|
|
527
|
+
const dispatcherFactory = createProxyClientFactory();
|
|
527
528
|
return new UndiciProxyAgent({
|
|
528
529
|
...resolveUndiciBaseOptions(options.undici),
|
|
529
530
|
uri: proxyUrl,
|
|
530
|
-
clientFactory:
|
|
531
|
+
clientFactory: dispatcherFactory,
|
|
532
|
+
factory: dispatcherFactory,
|
|
531
533
|
...(proxyTls !== undefined ? { proxyTls } : {}),
|
|
532
534
|
} as ConstructorParameters<typeof UndiciProxyAgent>[0]);
|
|
533
535
|
}
|
|
534
536
|
|
|
537
|
+
function normalizeUndiciDispatchOptions(
|
|
538
|
+
options: Dispatcher.DispatchOptions,
|
|
539
|
+
): Dispatcher.DispatchOptions {
|
|
540
|
+
if (options.origin === undefined || !/^https?:\/\//i.test(options.path)) {
|
|
541
|
+
return options;
|
|
542
|
+
}
|
|
543
|
+
const pathUrl = new URL(options.path);
|
|
544
|
+
return {
|
|
545
|
+
...options,
|
|
546
|
+
path: `${pathUrl.pathname}${pathUrl.search}`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
535
550
|
function createUndiciProxyDispatcher(
|
|
536
551
|
options:
|
|
537
552
|
| { mode: "managed"; resolver: ProxyResolver }
|
|
@@ -585,10 +600,11 @@ class ManagedUndiciDispatcher extends Dispatcher {
|
|
|
585
600
|
if (this.#closedError !== undefined) {
|
|
586
601
|
return reportClosedDispatchError(handler, this.#closedError);
|
|
587
602
|
}
|
|
588
|
-
const
|
|
603
|
+
const normalizedOptions = normalizeUndiciDispatchOptions(options);
|
|
604
|
+
const url = resolveUndiciDispatchUrl(normalizedOptions);
|
|
589
605
|
const proxyUrl = url === undefined ? "" : this.#resolver.getProxyForUrl(url, "undici");
|
|
590
606
|
const dispatcher = proxyUrl === "" ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl);
|
|
591
|
-
return dispatcher.dispatch(
|
|
607
|
+
return dispatcher.dispatch(normalizedOptions, handler);
|
|
592
608
|
}
|
|
593
609
|
|
|
594
610
|
public override close(callback: () => void): void;
|
|
@@ -671,10 +687,11 @@ class AmbientUndiciDispatcher extends Dispatcher {
|
|
|
671
687
|
if (this.#closedError !== undefined) {
|
|
672
688
|
return reportClosedDispatchError(handler, this.#closedError);
|
|
673
689
|
}
|
|
674
|
-
const
|
|
690
|
+
const normalizedOptions = normalizeUndiciDispatchOptions(options);
|
|
691
|
+
const url = resolveUndiciDispatchUrl(normalizedOptions);
|
|
675
692
|
const proxyUrl = url === undefined ? undefined : resolveAmbientProxyForUrl(url, this.#env);
|
|
676
693
|
const dispatcher = proxyUrl === undefined ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl);
|
|
677
|
-
return dispatcher.dispatch(
|
|
694
|
+
return dispatcher.dispatch(normalizedOptions, handler);
|
|
678
695
|
}
|
|
679
696
|
|
|
680
697
|
public override close(callback: () => void): void;
|
package/src/shared.ts
CHANGED
|
@@ -15,6 +15,18 @@ export class ProxylineError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Decode proxy userinfo; invalid percent-encoding must not throw URIError mid-CONNECT. */
|
|
19
|
+
export function decodeProxyUserinfoComponent(value: string): string {
|
|
20
|
+
try {
|
|
21
|
+
return decodeURIComponent(value);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new ProxylineError(
|
|
24
|
+
"INVALID_PROXY_USERINFO",
|
|
25
|
+
"Proxy username or password contains invalid percent-encoding.",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export function resolveProxyTlsCa(options: ProxylineTlsOptions | undefined): string | undefined {
|
|
19
31
|
if (!options) {
|
|
20
32
|
return undefined;
|