@openclaw/proxyline 0.3.5 → 0.3.7

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7 - 2026-08-02
4
+
5
+ - Fixed Node HTTP/HTTPS proxy agents to apply the default 30-second CONNECT timeout when callers omit a request timeout; explicit `0` remains unbounded. Thanks @SebTardif.
6
+ - Updated Undici and tsx, pnpm and npm release tooling, and pinned GitHub Actions to their latest stable releases.
7
+
8
+ ## 0.3.6 - 2026-08-02
9
+
10
+ - Fixed HTTP forwarding through HTTPS proxies to wait for the proxy TLS handshake before assigning the socket to Node's agent. Thanks @SebTardif.
11
+ - Added a 30-second default timeout to `openProxyConnectTunnel` when `timeoutMs` is omitted; pass `0` for unbounded waits. Thanks @SebTardif.
12
+
3
13
  ## 0.3.5 - 2026-08-01
4
14
 
5
15
  - Fixed malformed percent-encoding in proxy credentials to fail through CONNECT promises and Node request errors with `INVALID_PROXY_USERINFO`. Thanks @SebTardif.
package/README.md CHANGED
@@ -1,133 +1,97 @@
1
- # 🌐 Proxyline
1
+ # Proxyline 🌐 — Keep every Node request in line.
2
2
 
3
- ![Proxyline banner](docs/assets/readme-banner.jpg)
4
-
5
- [![npm](https://img.shields.io/npm/v/%40openclaw%2Fproxyline.svg)](https://www.npmjs.com/package/@openclaw/proxyline)
6
- [![node](https://img.shields.io/node/v/%40openclaw%2Fproxyline.svg)](https://nodejs.org/)
7
- [![license](https://img.shields.io/npm/l/%40openclaw%2Fproxyline.svg)](./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
+ [![CI](https://img.shields.io/github/actions/workflow/status/openclaw/proxyline/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/openclaw/proxyline/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40openclaw%2Fproxyline?style=flat-square)](https://www.npmjs.com/package/@openclaw/proxyline)
5
+ [![Node.js](https://img.shields.io/node/v/%40openclaw%2Fproxyline?style=flat-square)](https://nodejs.org/)
6
+ [![License](https://img.shields.io/github/license/openclaw/proxyline?style=flat-square)](./LICENSE)
10
7
 
11
- Proxyline exists to make proxy behavior **explicit, observable, and hard to bypass accidentally** — so that "all egress goes through this gateway" is something you encode in code rather than hope for from environment variables.
8
+ ![Proxyline banner](docs/assets/readme-banner.jpg)
12
9
 
13
- Proxyline's runtime assurances assume it is installed before application and plugin networking code is loaded. Code that captured networking functions before installation, uses raw sockets, or owns a private/native transport stack is outside the normal Proxyline model.
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
- Website: [proxyline.dev](https://proxyline.dev)
12
+ Documentation is available at [proxyline.dev](https://proxyline.dev).
16
13
 
17
- ## Highlights
14
+ ## Install
18
15
 
19
- - **Two modes.** `managed` forces traffic through a configured proxy and fails closed on bad config. `ambient` reads `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` for tooling that needs environment compatibility.
20
- - **Covers the surfaces that matter.** `http.request`, `http.get`, `https.request`, `https.get`, both global agents, the undici global dispatcher, and helpers for WebSocket agents and HTTP CONNECT sockets.
21
- - **Replaces caller agents.** In managed mode and active ambient mode, a per-request `http.Agent` passed by a library does not bypass the proxy. TLS options on the caller agent (`ca`, `cert`, `key`, `rejectUnauthorized`, …) are preserved so destination TLS still validates.
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
- ## Install
20
+ Or with npm:
29
21
 
30
- ```bash
31
- pnpm add @openclaw/proxyline
32
- # or
33
- npm install @openclaw/proxyline
22
+ ```sh
23
+ npm install @openclaw/proxyline undici@^8.5.0
34
24
  ```
35
25
 
36
- Requires Node 22.19.0+ and a host `undici` dependency compatible with `>=8.5.0 <9`.
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
- ### Managed mode
30
+ Save this as `proxy.mjs`:
41
31
 
42
- ```ts
32
+ ```js
43
33
  import { installGlobalProxy } from "@openclaw/proxyline";
44
34
 
45
35
  const proxy = installGlobalProxy({
46
36
  mode: "managed",
47
- proxyUrl: "https://proxy.corp.example:8443",
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
- console.log(proxy.explain("https://api.example.com/"));
43
+ ```sh
44
+ node proxy.mjs
45
+ # managed-proxy-active
53
46
  ```
54
47
 
55
- ### Ambient mode
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
- ```ts
58
- import { installGlobalProxy } from "@openclaw/proxyline";
50
+ ## Choose a mode
59
51
 
60
- const proxy = installGlobalProxy({ mode: "ambient" });
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
- ### WebSocket
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
- ```ts
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
- const socket = new WebSocket("wss://events.example.com/", {
72
- agent: proxy.createWebSocketAgent(),
73
- });
74
- ```
61
+ ## Covered traffic
75
62
 
76
- ### Explicit HTTP CONNECT
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
- ```ts
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
- const controller = new AbortController();
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
- ### Conditional Node agent
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
- ```ts
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
- const agent = createAmbientNodeProxyAgent({
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
- ## Product coverage
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
- - `http.request` / `http.get`: covered by global method patching and global agent replacement.
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
- ## Why not just env vars?
84
+ ## Security boundary
119
85
 
120
- Environment-based proxies are best-effort. A missing variable, a stale shell, a `NO_PROXY` typo, or a library that built its own `Dispatcher` quietly turns "always through the proxy" into "sometimes direct." Proxyline encodes the policy in code, replaces caller-built agents, and exposes a structured decision so logs can prove every request went the right way.
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
- For tooling that *should* honor whatever the operator configured, ambient mode keeps the conventional behavior — with the same observability and the same credential redaction.
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) — managed vs ambient
130
- - [Surfaces](./docs/surfaces.md) — per-API behavior
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
- ## Limits
103
+ ## Development
140
104
 
141
- Proxyline is a Node-process runtime, not an operating-system sandbox. Code can still bypass it by using raw `net`, raw `tls`, custom native networking, or a library that owns a private transport stack. Anything that captured `http.request` or `https.request` before Proxyline installed also bypasses it — install before loading third-party integrations when proxy routing is a security policy. See [`docs/security.md`](./docs/security.md) for the full threat model.
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,9 +6,22 @@ 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
  }>;
16
+ /** Default CONNECT handshake budget when `timeoutMs` is omitted. */
17
+ export declare const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 30000;
18
+ /**
19
+ * Resolve the CONNECT timeout to apply.
20
+ * - `undefined` → default 30s
21
+ * - `<= 0` → no timeout (unbounded)
22
+ * - positive → truncated milliseconds
23
+ */
24
+ export declare function resolveProxyConnectTimeoutMs(timeoutMs: number | undefined): number | undefined;
12
25
  type ProxySocket = net.Socket | tls.TLSSocket;
13
26
  export declare function formatConnectAuthority(targetHost: string, targetPort: number): string;
14
27
  export declare function openProxyConnectTunnel(options: OpenProxyConnectTunnelOptions): Promise<ProxySocket>;
@@ -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,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,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,CAAC,CAAC;AAMH,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,CA4HtB"}
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;AAEH,oEAAoE;AACpE,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAK9F;AAMD,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
3
  import { ProxylineError, decodeProxyUserinfoComponent, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
4
+ /** Default CONNECT handshake budget when `timeoutMs` is omitted. */
5
+ export 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
+ export 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 = /[/:?#@\\]/;
@@ -177,10 +191,11 @@ export async function openProxyConnectTunnel(options) {
177
191
  onAbort();
178
192
  return;
179
193
  }
180
- if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
194
+ const connectTimeoutMs = resolveProxyConnectTimeoutMs(options.timeoutMs);
195
+ if (connectTimeoutMs !== undefined) {
181
196
  timeout = setTimeout(() => {
182
- fail(new Error(`proxy CONNECT timed out after ${Math.trunc(options.timeoutMs ?? 0)}ms`));
183
- }, Math.trunc(options.timeoutMs));
197
+ fail(new Error(`proxy CONNECT timed out after ${connectTimeoutMs}ms`));
198
+ }, connectTimeoutMs);
184
199
  }
185
200
  socket = connectToProxy(proxy, options.proxyTls);
186
201
  socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
@@ -1 +1 @@
1
- {"version":3,"file":"node-http.d.ts","sourceRoot":"","sources":["../src/node-http.ts"],"names":[],"mappings":"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"}
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
@@ -4,7 +4,7 @@ import net from "node:net";
4
4
  import tls from "node:tls";
5
5
  import { domainToASCII } from "node:url";
6
6
  import { readProxyEnv, resolveAmbientProxyForUrl, } from "./env.js";
7
- import { formatConnectAuthority } from "./connect.js";
7
+ import { formatConnectAuthority, resolveProxyConnectTimeoutMs } from "./connect.js";
8
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 = /[/:?#@\\]/;
@@ -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
  }
@@ -541,10 +545,22 @@ class ProxylineConnectAgent extends http.Agent {
541
545
  };
542
546
  request.setTimeout = hookedRequestSetTimeout;
543
547
  }
548
+ // Prefer explicit agent/request timeout. Default 30s only when both are omitted.
549
+ // Explicit values go through normalizedPositiveInteger first so fractional/invalid
550
+ // timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
544
551
  const requestTimeout = request?.timeout;
545
- const timeoutMs = normalizedPositiveInteger(options.timeout ?? requestTimeout);
546
- if (timeoutMs !== undefined) {
547
- startPendingTimeout(timeoutMs);
552
+ const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
553
+ if (rawTimeout === undefined) {
554
+ const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
555
+ if (connectTimeoutMs !== undefined) {
556
+ startPendingTimeout(connectTimeoutMs);
557
+ }
558
+ }
559
+ else {
560
+ const timeoutMs = normalizedPositiveInteger(rawTimeout);
561
+ if (timeoutMs !== undefined) {
562
+ startPendingTimeout(timeoutMs);
563
+ }
548
564
  }
549
565
  request?.once("abort", onRequestClosed);
550
566
  request?.once("close", onRequestClosed);
@@ -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.5",
3
+ "version": "0.3.7",
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.18.0",
20
+ "packageManager": "pnpm@11.20.0",
21
21
  "exports": {
22
22
  ".": {
23
23
  "types": "./dist/index.d.ts",
@@ -64,9 +64,9 @@
64
64
  "devDependencies": {
65
65
  "@types/node": "26.1.2",
66
66
  "@types/ws": "8.18.1",
67
- "tsx": "4.23.1",
67
+ "tsx": "4.23.5",
68
68
  "typescript": "^7.0.2",
69
- "undici": "8.9.0",
69
+ "undici": "8.10.0",
70
70
  "ws": "8.21.1"
71
71
  },
72
72
  "engines": {
package/src/connect.ts CHANGED
@@ -7,10 +7,30 @@ export type OpenProxyConnectTunnelOptions = Readonly<{
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
+ export 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
+ export 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 = /[/:?#@\\]/;
@@ -219,10 +239,11 @@ export async function openProxyConnectTunnel(
219
239
  onAbort();
220
240
  return;
221
241
  }
222
- if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
242
+ const connectTimeoutMs = resolveProxyConnectTimeoutMs(options.timeoutMs);
243
+ if (connectTimeoutMs !== undefined) {
223
244
  timeout = setTimeout(() => {
224
- fail(new Error(`proxy CONNECT timed out after ${Math.trunc(options.timeoutMs ?? 0)}ms`));
225
- }, Math.trunc(options.timeoutMs));
245
+ fail(new Error(`proxy CONNECT timed out after ${connectTimeoutMs}ms`));
246
+ }, connectTimeoutMs);
226
247
  }
227
248
  socket = connectToProxy(proxy, options.proxyTls);
228
249
  socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
package/src/node-http.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  resolveAmbientProxyForUrl,
9
9
  type ProxyEnvSnapshot,
10
10
  } from "./env.js";
11
- import { formatConnectAuthority } from "./connect.js";
11
+ import { formatConnectAuthority, resolveProxyConnectTimeoutMs } from "./connect.js";
12
12
  import { ProxylineError, decodeProxyUserinfoComponent, resolveProxyTlsCa, type ProxylineTlsOptions } from "./shared.js";
13
13
  import type { ProxylineSurface, ProxyResolver } from "./types.js";
14
14
 
@@ -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
  }
@@ -695,10 +699,21 @@ class ProxylineConnectAgent extends http.Agent {
695
699
  request.setTimeout = hookedRequestSetTimeout;
696
700
  }
697
701
 
702
+ // Prefer explicit agent/request timeout. Default 30s only when both are omitted.
703
+ // Explicit values go through normalizedPositiveInteger first so fractional/invalid
704
+ // timeouts keep the historical no-pending-timer behavior (not Math.trunc to 1ms).
698
705
  const requestTimeout = (request as { timeout?: unknown } | undefined)?.timeout;
699
- const timeoutMs = normalizedPositiveInteger(options.timeout ?? requestTimeout);
700
- if (timeoutMs !== undefined) {
701
- startPendingTimeout(timeoutMs);
706
+ const rawTimeout = options.timeout !== undefined ? options.timeout : requestTimeout;
707
+ if (rawTimeout === undefined) {
708
+ const connectTimeoutMs = resolveProxyConnectTimeoutMs(undefined);
709
+ if (connectTimeoutMs !== undefined) {
710
+ startPendingTimeout(connectTimeoutMs);
711
+ }
712
+ } else {
713
+ const timeoutMs = normalizedPositiveInteger(rawTimeout);
714
+ if (timeoutMs !== undefined) {
715
+ startPendingTimeout(timeoutMs);
716
+ }
702
717
  }
703
718
  request?.once("abort", onRequestClosed);
704
719
  request?.once("close", onRequestClosed);