🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@arcjet/transport

Package Overview
Dependencies
Maintainers
2
Versions
47
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@arcjet/transport - npm Package Compare versions

Comparing version
1.7.0
to
1.8.0-rc.0
+7
dist/bun.d.ts
import { ProxyEnvironment, TransportLogger, TransportOptions } from "./detect-proxy.js";
import { Transport } from "@connectrpc/connect";
//#region src/bun.d.ts
declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
//#endregion
export { type ProxyEnvironment, type TransportLogger, type TransportOptions, createTransport };
import { detectProxy } from "./detect-proxy.js";
import { createConnectTransport } from "@connectrpc/connect-web";
//#region src/bun.ts
function createTransport(baseUrl, options) {
detectProxy(new URL(baseUrl), options);
return createConnectTransport({ baseUrl });
}
//#endregion
export { createTransport };
import { ProxyEnvironment, TransportLogger, TransportOptions } from "./detect-proxy.js";
import { Transport } from "@connectrpc/connect";
//#region src/deno.d.ts
declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
//#endregion
export { type ProxyEnvironment, type TransportLogger, type TransportOptions, createTransport };
import { detectProxy } from "./detect-proxy.js";
import { createConnectTransport } from "@connectrpc/connect-web";
//#region src/deno.ts
function createTransport(baseUrl, options) {
detectProxy(new URL(baseUrl), options);
return createConnectTransport({
baseUrl,
fetch: fetchProxy
});
}
function fetchProxy(input, init) {
return fetch(input, {
...init,
redirect: "follow"
});
}
//#endregion
export { createTransport };
//#region src/detect-proxy.d.ts
/**
* Map of environment variables used to detect an outbound proxy.
*
* This is the same shape as `process.env`.
*/
type ProxyEnvironment = Record<string, string | undefined>;
/**
* Minimal logger used to print a line when a proxy is detected.
*/
interface TransportLogger {
/**
* Log an informational message.
*
* @param message
* Template.
* @param interpolationValues
* Parameters to interpolate.
* @returns
* Nothing.
*/
info(message: string, ...interpolationValues: unknown[]): void;
}
/**
* Configuration shared by all transports.
*/
interface TransportOptions {
/**
* Logger used to print a line at startup when a proxy is detected (optional).
*
* Defaults to a logger configured from the `ARCJET_LOG_LEVEL` environment
* variable.
*/
log?: TransportLogger | undefined;
/**
* Environment variables used to detect an outbound proxy (optional).
*
* Defaults to `process.env` so standard proxy environment variables
* (`HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`) are auto-detected. Pass
* `false` to ignore proxy environment variables entirely.
*/
proxyEnv?: ProxyEnvironment | false | undefined;
/**
* HTTP version to use when a proxy is in use, on Node.js (optional).
*
* Has no effect when no proxy applies, and no effect on Bun, Deno, or the
* edge runtimes (which proxy through their `fetch` instead). Ignored for
* direct connections, which always use HTTP/2.
*
* - `"1.1"` (default) routes through the proxy over HTTP/1.1 using the
* built-in proxy support of the Node.js HTTP agent. This works with any
* proxy the agent supports, but loses the latency benefits of HTTP/2.
* - `"2"` establishes an HTTP `CONNECT` tunnel and keeps HTTP/2 to the origin
* end-to-end. This requires a tunneling (`CONNECT`) proxy — the common kind
* for HTTPS egress — and a proxy that does not buffer the tunnel (see the
* proxy support notes in the README). A proxy that terminates TLS and
* speaks HTTP/1.1 to origins cannot preserve HTTP/2 regardless.
*
* Defaults to `"1.1"`.
*/
proxyHttpVersion?: "1.1" | "2" | undefined;
}
/**
* Detect the proxy that applies to a URL and log a line when one is found.
*
* Standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`,
* respecting `NO_PROXY`) are auto-detected. When a proxy applies, a single line
* is logged at startup so it is easy to know when a proxy is being used. The
* proxy URL itself is not logged, since it can contain credentials.
*
* Takes an already-parsed `URL` so callers that also need it (e.g. to pick an
* HTTP vs HTTPS agent) don't parse the base URL twice.
*
* @param url
* URL that requests will be made to.
* @param options
* Configuration (optional).
* @returns
* Proxy URL that applies to `url`, or `undefined` when no proxy applies.
*/
declare function detectProxy(url: URL, options?: TransportOptions): string | undefined;
//#endregion
export { ProxyEnvironment, TransportLogger, TransportOptions, detectProxy };
import process from "node:process";
import { logLevel } from "@arcjet/env";
import { Logger } from "@arcjet/logger";
//#region src/detect-proxy.ts
/**
* Detect the proxy that applies to a URL and log a line when one is found.
*
* Standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`,
* respecting `NO_PROXY`) are auto-detected. When a proxy applies, a single line
* is logged at startup so it is easy to know when a proxy is being used. The
* proxy URL itself is not logged, since it can contain credentials.
*
* Takes an already-parsed `URL` so callers that also need it (e.g. to pick an
* HTTP vs HTTPS agent) don't parse the base URL twice.
*
* @param url
* URL that requests will be made to.
* @param options
* Configuration (optional).
* @returns
* Proxy URL that applies to `url`, or `undefined` when no proxy applies.
*/
function detectProxy(url, options) {
const proxyEnv = options?.proxyEnv === false ? void 0 : options?.proxyEnv ?? process.env;
let proxyUrl;
try {
proxyUrl = proxyEnv ? proxyForUrl(url, proxyEnv) : void 0;
} catch {
return;
}
if (typeof proxyUrl === "string") {
let log = options?.log;
if (!log) try {
log = new Logger({ level: logLevel({ ARCJET_LOG_LEVEL: process.env.ARCJET_LOG_LEVEL }) });
} catch {}
log?.info("Connecting to the Arcjet API through a proxy");
}
return proxyUrl;
}
/**
* Find the proxy that should be used for a URL, if any.
*
* Honors `NO_PROXY` so the result reflects the connection that will actually be
* made.
*
* @param url
* URL that requests will be made to.
* @param proxyEnv
* Environment variables to inspect.
* @returns
* Proxy URL to use, or `undefined` when no proxy applies.
*/
function proxyForUrl(url, proxyEnv) {
const httpProxy = proxyEnv["REQUEST_METHOD"] === void 0 ? firstValue(proxyEnv["http_proxy"], proxyEnv["HTTP_PROXY"]) : firstValue(proxyEnv["http_proxy"]);
const proxyUrl = url.protocol === "https:" ? firstValue(proxyEnv["https_proxy"], proxyEnv["HTTPS_PROXY"]) : httpProxy;
if (typeof proxyUrl !== "string") return;
if (isNoProxy(url, firstValue(proxyEnv["no_proxy"], proxyEnv["NO_PROXY"]))) return;
return proxyUrl;
}
/**
* Determine whether a URL should bypass the proxy because of `NO_PROXY`.
*
* Supports the common `NO_PROXY` syntax: a comma- or space-separated list of
* host suffixes, an optional leading `.` or `*.`, an optional `:port`, and `*`
* to match everything. Entries are matched as host names; IP/CIDR ranges (e.g.
* `10.0.0.0/8`) are not supported, the same as curl.
*
* @param url
* URL that requests will be made to.
* @param noProxy
* Value of the `NO_PROXY` environment variable.
* @returns
* Whether the proxy should be bypassed.
*/
function isNoProxy(url, noProxy) {
if (typeof noProxy !== "string") return false;
const hostname = url.hostname.toLowerCase().replaceAll(/^\[|\]$/g, "");
const port = url.port === "" ? url.protocol === "https:" ? "443" : "80" : url.port;
for (const raw of noProxy.split(/[\s,]+/)) {
if (raw === "") continue;
if (raw === "*") return true;
const entry = parseNoProxyEntry(raw);
if (entry.port !== void 0 && entry.port !== port) continue;
if (entry.host !== "" && hostMatches(hostname, entry.host)) return true;
}
return false;
}
/**
* Parse one `NO_PROXY` entry into its host and optional port.
*
* @param raw
* A single entry from the `NO_PROXY` list (already split out and non-empty).
* @returns
* The lowercased host (with any `*.`/`.` wildcard prefix and IPv6 brackets
* removed) and the explicit `:port`, if the entry had one.
*/
function parseNoProxyEntry(raw) {
const entry = raw.toLowerCase();
let host = entry;
let port;
const bracketed = entry.match(/^\[(.+)\](?::([0-9]+))?$/);
if (bracketed === null) {
const colon = entry.lastIndexOf(":");
if (colon !== -1 && colon === entry.indexOf(":") && /^[0-9]+$/.test(entry.slice(colon + 1))) {
host = entry.slice(0, colon);
port = entry.slice(colon + 1);
}
} else {
host = bracketed[1] ?? "";
port = bracketed[2];
}
return {
host: host.replace(/^\*?\./, ""),
port
};
}
/**
* Whether a host name matches a `NO_PROXY` entry host, exactly or as a
* subdomain.
*
* @param hostname
* Host name of the URL being requested.
* @param host
* Host parsed from a `NO_PROXY` entry.
* @returns
* Whether the host name is, or is a subdomain of, the entry host.
*/
function hostMatches(hostname, host) {
return hostname === host || hostname.endsWith("." + host);
}
/**
* Get the first non-empty string from a list of values.
*
* @param values
* Values to inspect.
* @returns
* First non-empty string, or `undefined`.
*/
function firstValue(...values) {
for (const value of values) if (typeof value === "string" && value !== "") return value;
}
//#endregion
export { detectProxy };
import { ProxyEnvironment, TransportLogger, TransportOptions } from "./detect-proxy.js";
import { Transport } from "@connectrpc/connect";
//#region src/edge-light.d.ts
declare function createTransport(baseUrl: string, _options?: TransportOptions): Transport;
//#endregion
export { type ProxyEnvironment, type TransportLogger, type TransportOptions, createTransport };
import { createConnectTransport } from "@connectrpc/connect-web";
//#region src/edge-light.ts
function createTransport(baseUrl, _options) {
return createConnectTransport({
baseUrl,
fetch: fetchProxy
});
}
function fetchProxy(input, init) {
return fetch(input, {
...init,
redirect: "follow"
});
}
//#endregion
export { createTransport };
import { ProxyEnvironment, TransportLogger, TransportOptions } from "./detect-proxy.js";
import { Transport } from "@connectrpc/connect";
//#region src/index.d.ts
/**
* Create a transport that talks to the Arcjet API using Connect RPC.
*
* A thin wrapper around {@linkcode createConnectTransport}.
*
* When a standard proxy environment variable (`HTTP_PROXY` or `HTTPS_PROXY`,
* respecting `NO_PROXY`) is detected, the transport routes requests through the
* proxy and logs a line at startup. By default it proxies over HTTP/1.1 using
* the built-in proxy support of the Node.js HTTP agent; set
* `options.proxyHttpVersion` to `"2"` to instead tunnel HTTP/2 to the origin
* via `CONNECT` (see {@linkcode TransportOptions.proxyHttpVersion}). Without a
* proxy it always connects directly over HTTP/2.
*
* @param baseUrl
* Base URI for all HTTP requests (example: `https://example.com/my-api`).
* @param options
* Configuration (optional).
* @returns
* Connect transport used to make RPC calls.
*/
declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
//#endregion
export { type ProxyEnvironment, type TransportLogger, type TransportOptions, createTransport };
import { detectProxy } from "./detect-proxy.js";
import { createTunnelingConnection } from "./proxy-tunnel.js";
import * as http from "node:http";
import * as https from "node:https";
import { Http2SessionManager, createConnectTransport } from "@connectrpc/connect-node";
//#region src/index.ts
/**
* Create a transport that talks to the Arcjet API using Connect RPC.
*
* A thin wrapper around {@linkcode createConnectTransport}.
*
* When a standard proxy environment variable (`HTTP_PROXY` or `HTTPS_PROXY`,
* respecting `NO_PROXY`) is detected, the transport routes requests through the
* proxy and logs a line at startup. By default it proxies over HTTP/1.1 using
* the built-in proxy support of the Node.js HTTP agent; set
* `options.proxyHttpVersion` to `"2"` to instead tunnel HTTP/2 to the origin
* via `CONNECT` (see {@linkcode TransportOptions.proxyHttpVersion}). Without a
* proxy it always connects directly over HTTP/2.
*
* @param baseUrl
* Base URI for all HTTP requests (example: `https://example.com/my-api`).
* @param options
* Configuration (optional).
* @returns
* Connect transport used to make RPC calls.
*/
function createTransport(baseUrl, options) {
const url = new URL(baseUrl);
const proxyUrl = detectProxy(url, options);
if (typeof proxyUrl === "string") {
if (options?.proxyHttpVersion === "2") return createHttp2Transport(baseUrl, createTunnelingConnection(proxyUrl));
const isHttps = url.protocol === "https:";
const agentOptions = {
keepAlive: true,
proxyEnv: isHttps ? { HTTPS_PROXY: proxyUrl } : { HTTP_PROXY: proxyUrl }
};
return createConnectTransport({
baseUrl,
httpVersion: "1.1",
nodeOptions: { agent: isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) }
});
}
return createHttp2Transport(baseUrl);
}
/**
* Build a direct HTTP/2 transport with an optimistically pre-connecting session
* manager.
*
* When `createConnection` is supplied the session is tunneled through it (used
* to route HTTP/2 through a proxy via `CONNECT`); otherwise it connects directly
* to `baseUrl`. Either way pings and the idle timeout behave identically — only
* the underlying connection differs.
*
* @param baseUrl
* Base URI for all HTTP requests.
* @param createConnection
* Optional connection factory passed through to `http2.connect` (optional).
* @returns
* Connect transport that talks HTTP/2 to `baseUrl`.
*/
function createHttp2Transport(baseUrl, createConnection) {
const sessionManager = new Http2SessionManager(baseUrl, { idleConnectionTimeoutMs: 340 * 1e3 }, createConnection ? { createConnection } : void 0);
if (!("Deno" in globalThis)) sessionManager.connect();
return createConnectTransport({
baseUrl,
httpVersion: "2",
sessionManager
});
}
//#endregion
export { createTransport };
import { Duplex } from "node:stream";
import { SecureClientSessionOptions } from "node:http2";
//#region src/proxy-tunnel.d.ts
/**
* Route an HTTP/2 session through a forward proxy using an HTTP `CONNECT`
* tunnel, preserving HTTP/2 to the origin.
*
* Node's built-in HTTP agent proxy support (and the `https-proxy-agent` family)
* only wire a proxy into the HTTP/1.1 agent, which is why proxying otherwise
* forces a downgrade from HTTP/2. But HTTP/2 survives a `CONNECT` tunnel
* end-to-end: the proxy is told to open a raw TCP tunnel and thereafter only
* blindly forwards bytes (RFC 9110 §9.3.6), so the TLS handshake — including the
* ALPN negotiation that selects `h2` — happens directly with the origin. The
* proxy never sees, and so cannot downgrade, the negotiated protocol.
*
* The one wrinkle is that {@linkcode http2.connect}'s `createConnection`
* callback must return a {@linkcode Duplex} synchronously, but the `CONNECT`
* handshake is asynchronous. We bridge that gap with a small `Duplex` that
* buffers whatever the consumer writes (the TLS `ClientHello`, or the HTTP/2
* client preface for a cleartext target) until the proxy answers `2xx`, then
* splices itself onto the proxy socket. Because the contract stays synchronous,
* this drops into `@connectrpc/connect-node`'s default `Http2SessionManager`
* via `nodeOptions.createConnection` with no fork — reconnection, pings, and the
* idle timeout all keep working.
*
* This is Node-only. Bun and Deno don't implement the agent option this sits
* alongside, and their `fetch` is used for proxying instead.
*
* @param proxyUrl
* Proxy to route through (for example `http://127.0.0.1:3128`). An HTTPS proxy
* (TLS to the proxy itself) is supported too.
* @returns
* A `createConnection` callback for `http2.connect(..., { createConnection })`
* (and therefore for connect-node's `nodeOptions.createConnection`).
*/
declare function createTunnelingConnection(proxyUrl: string): (authority: URL, options: SecureClientSessionOptions) => Duplex;
//#endregion
export { createTunnelingConnection };
import * as net from "node:net";
import { Duplex } from "node:stream";
import * as tls from "node:tls";
//#region src/proxy-tunnel.ts
/**
* Route an HTTP/2 session through a forward proxy using an HTTP `CONNECT`
* tunnel, preserving HTTP/2 to the origin.
*
* Node's built-in HTTP agent proxy support (and the `https-proxy-agent` family)
* only wire a proxy into the HTTP/1.1 agent, which is why proxying otherwise
* forces a downgrade from HTTP/2. But HTTP/2 survives a `CONNECT` tunnel
* end-to-end: the proxy is told to open a raw TCP tunnel and thereafter only
* blindly forwards bytes (RFC 9110 §9.3.6), so the TLS handshake — including the
* ALPN negotiation that selects `h2` — happens directly with the origin. The
* proxy never sees, and so cannot downgrade, the negotiated protocol.
*
* The one wrinkle is that {@linkcode http2.connect}'s `createConnection`
* callback must return a {@linkcode Duplex} synchronously, but the `CONNECT`
* handshake is asynchronous. We bridge that gap with a small `Duplex` that
* buffers whatever the consumer writes (the TLS `ClientHello`, or the HTTP/2
* client preface for a cleartext target) until the proxy answers `2xx`, then
* splices itself onto the proxy socket. Because the contract stays synchronous,
* this drops into `@connectrpc/connect-node`'s default `Http2SessionManager`
* via `nodeOptions.createConnection` with no fork — reconnection, pings, and the
* idle timeout all keep working.
*
* This is Node-only. Bun and Deno don't implement the agent option this sits
* alongside, and their `fetch` is used for proxying instead.
*
* @param proxyUrl
* Proxy to route through (for example `http://127.0.0.1:3128`). An HTTPS proxy
* (TLS to the proxy itself) is supported too.
* @returns
* A `createConnection` callback for `http2.connect(..., { createConnection })`
* (and therefore for connect-node's `nodeOptions.createConnection`).
*/
function createTunnelingConnection(proxyUrl) {
const proxy = new URL(proxyUrl);
const proxyIsHttps = proxy.protocol === "https:";
const proxyPort = Number(proxy.port) || (proxyIsHttps ? 443 : 80);
const proxyAuthorization = proxy.username === "" ? void 0 : "Basic " + Buffer.from(decodeURIComponent(proxy.username) + ":" + decodeURIComponent(proxy.password)).toString("base64");
return function createConnection(authority, options) {
const originIsHttps = authority.protocol === "https:";
const originPort = Number(authority.port) || (originIsHttps ? 443 : 80);
const originAuthority = authority.hostname + ":" + originPort;
let tunnelReady = false;
const pending = [];
const bridge = new Duplex({
read() {},
write(chunk, _encoding, callback) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (tunnelReady) proxySocket.write(buffer, callback);
else pending.push({
chunk: buffer,
callback
});
}
});
const proxySocket = proxyIsHttps ? tls.connect({
host: proxy.hostname,
port: proxyPort,
servername: proxy.hostname
}) : net.connect({
host: proxy.hostname,
port: proxyPort
});
proxySocket.setNoDelay(true);
proxySocket.once(proxyIsHttps ? "secureConnect" : "connect", () => {
let request = "CONNECT " + originAuthority + " HTTP/1.1\r\n";
request += "Host: " + originAuthority + "\r\n";
if (proxyAuthorization !== void 0) request += "Proxy-Authorization: " + proxyAuthorization + "\r\n";
request += "\r\n";
proxySocket.write(request);
});
let head = Buffer.alloc(0);
function onData(chunk) {
head = Buffer.concat([head, chunk]);
const terminator = head.indexOf("\r\n\r\n");
if (terminator === -1) return;
proxySocket.off("data", onData);
const statusLine = head.subarray(0, head.indexOf("\r\n")).toString("latin1");
const status = Number(statusLine.split(" ")[1]);
if (!(status >= 200 && status < 300)) {
const error = /* @__PURE__ */ new Error("Proxy CONNECT failed with status: " + statusLine.trim());
proxySocket.destroy(error);
bridge.destroy(error);
return;
}
const leftover = head.subarray(terminator + 4);
if (leftover.length > 0) bridge.push(leftover);
if (originIsHttps) proxySocket.on("data", (data) => bridge.push(data));
else {
const maxFramePayload = 2 ** 20;
let inbound = Buffer.alloc(0);
proxySocket.on("data", (data) => {
inbound = Buffer.concat([inbound, data]);
while (inbound.length >= 9) {
const payloadLength = inbound.readUIntBE(0, 3);
if (payloadLength > maxFramePayload) {
bridge.destroy(/* @__PURE__ */ new Error("Proxy tunnel received an oversized HTTP/2 frame"));
return;
}
const frameLength = 9 + payloadLength;
if (inbound.length < frameLength) break;
const frame = inbound.subarray(0, frameLength);
inbound = inbound.subarray(frameLength);
setImmediate(() => {
if (!bridge.destroyed) bridge.push(frame);
});
}
});
}
proxySocket.on("end", () => setImmediate(() => {
if (!bridge.destroyed) bridge.push(null);
}));
tunnelReady = true;
for (const { chunk: queued, callback } of pending) proxySocket.write(queued, callback);
pending.length = 0;
head = Buffer.alloc(0);
}
proxySocket.on("data", onData);
proxySocket.on("error", (error) => bridge.destroy(error));
bridge.on("close", () => proxySocket.destroy());
if (!originIsHttps) return bridge;
const bareHostname = authority.hostname.startsWith("[") && authority.hostname.endsWith("]") ? authority.hostname.slice(1, -1) : authority.hostname;
const originIsIpLiteral = net.isIP(bareHostname) !== 0;
try {
return tls.connect({
...options,
socket: bridge,
host: bareHostname,
servername: originIsIpLiteral ? void 0 : bareHostname,
ALPNProtocols: ["h2"]
});
} catch (error) {
proxySocket.destroy();
bridge.destroy(error);
throw error;
}
};
}
//#endregion
export { createTunnelingConnection };
import { ProxyEnvironment, TransportLogger, TransportOptions } from "./detect-proxy.js";
import { Transport } from "@connectrpc/connect";
//#region src/workerd.d.ts
declare function createTransport(baseUrl: string, _options?: TransportOptions): Transport;
//#endregion
export { type ProxyEnvironment, type TransportLogger, type TransportOptions, createTransport };
import { createConnectTransport } from "@connectrpc/connect-web";
//#region src/workerd.ts
function createTransport(baseUrl, _options) {
return createConnectTransport({
baseUrl,
fetch: fetchProxy
});
}
function fetchProxy(input, init) {
return fetch(input, {
...init,
redirect: "follow"
});
}
//#endregion
export { createTransport };
+51
-49
{
"name": "@arcjet/transport",
"version": "1.7.0",
"version": "1.8.0-rc.0",
"description": "Transport mechanisms for the Arcjet protocol",

@@ -8,12 +8,6 @@ "keywords": [

"transport",
"utility",
"util"
"util",
"utility"
],
"license": "Apache-2.0",
"homepage": "https://arcjet.com",
"repository": {
"type": "git",
"url": "git+https://github.com/arcjet/arcjet-js.git",
"directory": "transport"
},
"bugs": {

@@ -23,2 +17,3 @@ "url": "https://github.com/arcjet/arcjet-js/issues",

},
"license": "Apache-2.0",
"author": {

@@ -29,43 +24,54 @@ "name": "Arcjet",

},
"engines": {
"node": ">=22.21.0 <23 || >=24.5.0"
"repository": {
"type": "git",
"url": "git+https://github.com/arcjet/arcjet-js.git",
"directory": "transport"
},
"files": [
"dist"
],
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"bun": "./bun.js",
"deno": "./deno.js",
"edge-light": "./edge-light.js",
"workerd": "./workerd.js",
"default": "./index.js"
".": {
"bun": {
"types": "./dist/bun.d.ts",
"default": "./dist/bun.js"
},
"deno": {
"types": "./dist/deno.d.ts",
"default": "./dist/deno.js"
},
"edge-light": {
"types": "./dist/edge-light.d.ts",
"default": "./dist/edge-light.js"
},
"workerd": {
"types": "./dist/workerd.d.ts",
"default": "./dist/workerd.js"
},
"default": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./package.json": "./package.json"
},
"files": [
"bun.d.ts",
"bun.js",
"deno.d.ts",
"deno.js",
"detect-proxy.d.ts",
"detect-proxy.js",
"edge-light.d.ts",
"edge-light.js",
"index.d.ts",
"index.js",
"proxy-tunnel.d.ts",
"proxy-tunnel.js",
"workerd.d.ts",
"workerd.js"
],
"publishConfig": {
"access": "public",
"tag": "latest"
},
"scripts": {
"build": "rollup --config rollup.config.js",
"lint": "eslint .",
"test-api": "node --test -- test/*.test.js",
"test-coverage": "node --experimental-test-coverage --test -- test/*.test.js",
"build": "tsdown",
"typecheck": "tsgo --noEmit",
"test-api": "node --test -- test/*.test.ts",
"test-coverage": "node --experimental-test-coverage --test -- test/*.test.ts",
"test-runtime-bun": "npm run build && HTTPS_PROXY=http://127.0.0.1:49219 bun test test/runtime/proxy.bun.test.ts",
"test-runtime-deno": "npm run build && HTTPS_PROXY=http://127.0.0.1:49219 deno test --allow-all --no-check test/runtime/proxy.deno.test.ts",
"test": "npm run build && npm run lint && npm run test-coverage"
"test": "npm run build && npm run test-coverage"
},
"dependencies": {
"@arcjet/env": "1.7.0",
"@arcjet/logger": "1.7.0",
"@arcjet/env": "1.8.0-rc.0",
"@arcjet/logger": "1.8.0-rc.0",
"@bufbuild/protobuf": "2.12.0",

@@ -77,13 +83,9 @@ "@connectrpc/connect": "2.1.2",

"devDependencies": {
"@arcjet/eslint-config": "1.7.0",
"@arcjet/rollup-config": "1.7.0",
"@rollup/wasm-node": "4.62.2",
"@types/node": "22.19.21",
"eslint": "9.39.4",
"typescript": "5.9.3"
"tsdown": "0.22.3",
"typescript": "6.0.3"
},
"publishConfig": {
"access": "public",
"tag": "latest"
"engines": {
"node": ">=22.21.0 <23 || >=24.5.0"
}
}
import type { Transport } from "@connectrpc/connect";
export type { ProxyEnvironment, TransportLogger, TransportOptions, } from "./detect-proxy.js";
import type { TransportOptions } from "./detect-proxy.js";
export declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
import { createConnectTransport } from '@connectrpc/connect-web';
import { detectProxy } from './detect-proxy.js';
function createTransport(baseUrl, options) {
// Bun's `fetch` performs the proxying itself; we detect to log a line.
detectProxy(new URL(baseUrl), options);
return createConnectTransport({
baseUrl,
});
}
export { createTransport };
import type { Transport } from "@connectrpc/connect";
export type { ProxyEnvironment, TransportLogger, TransportOptions, } from "./detect-proxy.js";
import type { TransportOptions } from "./detect-proxy.js";
export declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
import { createConnectTransport } from '@connectrpc/connect-web';
import { detectProxy } from './detect-proxy.js';
function createTransport(baseUrl, options) {
// Deno's `fetch` performs the proxying itself; we detect to log a line.
detectProxy(new URL(baseUrl), options);
return createConnectTransport({
baseUrl,
fetch: fetchProxy,
});
}
function fetchProxy(input, init) {
return fetch(input, { ...init, redirect: "follow" });
}
export { createTransport };
/**
* Map of environment variables used to detect an outbound proxy.
*
* This is the same shape as `process.env`.
*/
export type ProxyEnvironment = Record<string, string | undefined>;
/**
* Minimal logger used to print a line when a proxy is detected.
*/
export interface TransportLogger {
/**
* Log an informational message.
*
* @param message
* Template.
* @param interpolationValues
* Parameters to interpolate.
* @returns
* Nothing.
*/
info(message: string, ...interpolationValues: unknown[]): void;
}
/**
* Configuration shared by all transports.
*/
export interface TransportOptions {
/**
* Logger used to print a line at startup when a proxy is detected (optional).
*
* Defaults to a logger configured from the `ARCJET_LOG_LEVEL` environment
* variable.
*/
log?: TransportLogger | undefined;
/**
* Environment variables used to detect an outbound proxy (optional).
*
* Defaults to `process.env` so standard proxy environment variables
* (`HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`) are auto-detected. Pass
* `false` to ignore proxy environment variables entirely.
*/
proxyEnv?: ProxyEnvironment | false | undefined;
/**
* HTTP version to use when a proxy is in use, on Node.js (optional).
*
* Has no effect when no proxy applies, and no effect on Bun, Deno, or the
* edge runtimes (which proxy through their `fetch` instead). Ignored for
* direct connections, which always use HTTP/2.
*
* - `"1.1"` (default) routes through the proxy over HTTP/1.1 using the
* built-in proxy support of the Node.js HTTP agent. This works with any
* proxy the agent supports, but loses the latency benefits of HTTP/2.
* - `"2"` establishes an HTTP `CONNECT` tunnel and keeps HTTP/2 to the origin
* end-to-end. This requires a tunneling (`CONNECT`) proxy — the common kind
* for HTTPS egress — and a proxy that does not buffer the tunnel (see the
* proxy support notes in the README). A proxy that terminates TLS and
* speaks HTTP/1.1 to origins cannot preserve HTTP/2 regardless.
*
* Defaults to `"1.1"`.
*/
proxyHttpVersion?: "1.1" | "2" | undefined;
}
/**
* Detect the proxy that applies to a URL and log a line when one is found.
*
* Standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`,
* respecting `NO_PROXY`) are auto-detected. When a proxy applies, a single line
* is logged at startup so it is easy to know when a proxy is being used. The
* proxy URL itself is not logged, since it can contain credentials.
*
* Takes an already-parsed `URL` so callers that also need it (e.g. to pick an
* HTTP vs HTTPS agent) don't parse the base URL twice.
*
* @param url
* URL that requests will be made to.
* @param options
* Configuration (optional).
* @returns
* Proxy URL that applies to `url`, or `undefined` when no proxy applies.
*/
export declare function detectProxy(url: URL, options?: TransportOptions): string | undefined;
import process from 'node:process';
import { logLevel } from '@arcjet/env';
import { Logger } from '@arcjet/logger';
/**
* Detect the proxy that applies to a URL and log a line when one is found.
*
* Standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`,
* respecting `NO_PROXY`) are auto-detected. When a proxy applies, a single line
* is logged at startup so it is easy to know when a proxy is being used. The
* proxy URL itself is not logged, since it can contain credentials.
*
* Takes an already-parsed `URL` so callers that also need it (e.g. to pick an
* HTTP vs HTTPS agent) don't parse the base URL twice.
*
* @param url
* URL that requests will be made to.
* @param options
* Configuration (optional).
* @returns
* Proxy URL that applies to `url`, or `undefined` when no proxy applies.
*/
function detectProxy(url, options) {
// Default to detecting proxy configuration from `process.env`. Passing
// `false` disables proxy detection entirely.
const proxyEnv = options?.proxyEnv === false
? undefined
: (options?.proxyEnv ?? process.env);
let proxyUrl;
try {
proxyUrl = proxyEnv ? proxyForUrl(url, proxyEnv) : undefined;
}
catch {
// Reading proxy environment variables can throw on runtimes that gate
// environment access behind a permission (e.g. Deno without `--allow-env`).
// Treat that as "no proxy" rather than failing transport creation.
return undefined;
}
if (typeof proxyUrl === "string") {
// Log a line at startup so it is easy to know when a proxy is being used.
// We deliberately do not log the proxy URL itself: it can contain
// credentials, and not logging it is simpler and safer than redacting it.
let log = options?.log;
if (!log) {
try {
log = new Logger({
level: logLevel({ ARCJET_LOG_LEVEL: process.env.ARCJET_LOG_LEVEL }),
});
}
catch {
// Building the default logger reads `ARCJET_LOG_LEVEL`, which can throw
// on runtimes that gate environment access (e.g. Deno without
// `--allow-env`) when the proxy came from an explicit `proxyEnv`. Skip
// the startup line rather than failing transport creation; the proxy is
// still returned below.
}
}
log?.info("Connecting to the Arcjet API through a proxy");
}
return proxyUrl;
}
// ---------------------------------------------------------------------------
// Keep the proxy-resolution logic below in sync with the copy in
// `@arcjet/guard` (`arcjet-guard/src/detect-proxy.ts`). The two packages
// intentionally duplicate it rather than share a module: `@arcjet/guard`
// bundles a fetch transport that runs on edge runtimes without `process` or
// these dependencies, so it keeps an edge-safe copy with no imports. Only the
// `detectProxy` entry point above differs between the copies; the helpers
// below should stay logically identical (the two may differ only in line
// wrapping, since each package runs a different formatter).
// ---------------------------------------------------------------------------
/**
* Find the proxy that should be used for a URL, if any.
*
* Honors `NO_PROXY` so the result reflects the connection that will actually be
* made.
*
* @param url
* URL that requests will be made to.
* @param proxyEnv
* Environment variables to inspect.
* @returns
* Proxy URL to use, or `undefined` when no proxy applies.
*/
function proxyForUrl(url, proxyEnv) {
// httpoxy mitigation: under CGI the inbound `Proxy` request header is exposed
// as the `HTTP_PROXY` environment variable, so honoring uppercase `HTTP_PROXY`
// for HTTP targets could let a request control outbound proxying. When a CGI
// environment is detected (`REQUEST_METHOD` is set), ignore it and use only
// the lowercase `http_proxy`. See https://httpoxy.org.
const httpProxy = proxyEnv["REQUEST_METHOD"] === undefined
? firstValue(proxyEnv["http_proxy"], proxyEnv["HTTP_PROXY"])
: firstValue(proxyEnv["http_proxy"]);
const proxyUrl = url.protocol === "https:"
? firstValue(proxyEnv["https_proxy"], proxyEnv["HTTPS_PROXY"])
: httpProxy;
if (typeof proxyUrl !== "string") {
return undefined;
}
if (isNoProxy(url, firstValue(proxyEnv["no_proxy"], proxyEnv["NO_PROXY"]))) {
return undefined;
}
return proxyUrl;
}
/**
* Determine whether a URL should bypass the proxy because of `NO_PROXY`.
*
* Supports the common `NO_PROXY` syntax: a comma- or space-separated list of
* host suffixes, an optional leading `.` or `*.`, an optional `:port`, and `*`
* to match everything. Entries are matched as host names; IP/CIDR ranges (e.g.
* `10.0.0.0/8`) are not supported, the same as curl.
*
* @param url
* URL that requests will be made to.
* @param noProxy
* Value of the `NO_PROXY` environment variable.
* @returns
* Whether the proxy should be bypassed.
*/
function isNoProxy(url, noProxy) {
if (typeof noProxy !== "string") {
return false;
}
// `url.hostname` wraps IPv6 addresses in brackets (e.g. `[::1]`); strip them
// so entries can be written with or without brackets.
const hostname = url.hostname.toLowerCase().replaceAll(/^\[|\]$/g, "");
const port = url.port === "" ? (url.protocol === "https:" ? "443" : "80") : url.port;
for (const raw of noProxy.split(/[\s,]+/)) {
if (raw === "") {
continue;
}
// `*` bypasses the proxy for every host.
if (raw === "*") {
return true;
}
const entry = parseNoProxyEntry(raw);
// A port on the entry must match the target's (default) port.
if (entry.port !== undefined && entry.port !== port) {
continue;
}
if (entry.host !== "" && hostMatches(hostname, entry.host)) {
return true;
}
}
return false;
}
/**
* Parse one `NO_PROXY` entry into its host and optional port.
*
* @param raw
* A single entry from the `NO_PROXY` list (already split out and non-empty).
* @returns
* The lowercased host (with any `*.`/`.` wildcard prefix and IPv6 brackets
* removed) and the explicit `:port`, if the entry had one.
*/
function parseNoProxyEntry(raw) {
const entry = raw.toLowerCase();
// Split off an optional `:port`. A bracketed IPv6 entry (`[::1]:8080`) keeps
// its port outside the brackets, a bare IPv6 entry (`::1`) has no port, and
// everything else treats a single trailing `:<digits>` as the port (so IPv6
// colons are not mistaken for one).
let host = entry;
let port;
const bracketed = entry.match(/^\[(.+)\](?::([0-9]+))?$/);
if (bracketed === null) {
const colon = entry.lastIndexOf(":");
if (colon !== -1 &&
colon === entry.indexOf(":") &&
/^[0-9]+$/.test(entry.slice(colon + 1))) {
host = entry.slice(0, colon);
port = entry.slice(colon + 1);
}
}
else {
host = bracketed[1] ?? "";
port = bracketed[2];
}
// Strip a leading `*.` or `.` so `.example.com`, `*.example.com`, and
// `example.com` all match the domain and its subdomains.
return { host: host.replace(/^\*?\./, ""), port };
}
/**
* Whether a host name matches a `NO_PROXY` entry host, exactly or as a
* subdomain.
*
* @param hostname
* Host name of the URL being requested.
* @param host
* Host parsed from a `NO_PROXY` entry.
* @returns
* Whether the host name is, or is a subdomain of, the entry host.
*/
function hostMatches(hostname, host) {
return hostname === host || hostname.endsWith("." + host);
}
/**
* Get the first non-empty string from a list of values.
*
* @param values
* Values to inspect.
* @returns
* First non-empty string, or `undefined`.
*/
function firstValue(...values) {
for (const value of values) {
if (typeof value === "string" && value !== "") {
return value;
}
}
return undefined;
}
export { detectProxy };
import type { Transport } from "@connectrpc/connect";
export type { ProxyEnvironment, TransportLogger, TransportOptions, } from "./detect-proxy.js";
import type { TransportOptions } from "./detect-proxy.js";
export declare function createTransport(baseUrl: string, _options?: TransportOptions): Transport;
import { createConnectTransport } from '@connectrpc/connect-web';
function createTransport(baseUrl,
// These edge runtimes don't support outbound proxy environment variables, so
// the options are accepted for API parity with the other entry points but no
// proxy is detected or used.
_options) {
return createConnectTransport({
baseUrl,
fetch: fetchProxy,
});
}
function fetchProxy(input, init) {
return fetch(input, { ...init, redirect: "follow" });
}
export { createTransport };
import type { Transport } from "@connectrpc/connect";
export type { ProxyEnvironment, TransportLogger, TransportOptions, } from "./detect-proxy.js";
import type { TransportOptions } from "./detect-proxy.js";
/**
* Create a transport that talks to the Arcjet API using Connect RPC.
*
* A thin wrapper around {@linkcode createConnectTransport}.
*
* When a standard proxy environment variable (`HTTP_PROXY` or `HTTPS_PROXY`,
* respecting `NO_PROXY`) is detected, the transport routes requests through the
* proxy and logs a line at startup. By default it proxies over HTTP/1.1 using
* the built-in proxy support of the Node.js HTTP agent; set
* `options.proxyHttpVersion` to `"2"` to instead tunnel HTTP/2 to the origin
* via `CONNECT` (see {@linkcode TransportOptions.proxyHttpVersion}). Without a
* proxy it always connects directly over HTTP/2.
*
* @param baseUrl
* Base URI for all HTTP requests (example: `https://example.com/my-api`).
* @param options
* Configuration (optional).
* @returns
* Connect transport used to make RPC calls.
*/
export declare function createTransport(baseUrl: string, options?: TransportOptions): Transport;
import { createConnectTransport, Http2SessionManager } from '@connectrpc/connect-node';
import * as http from 'node:http';
import * as https from 'node:https';
import { detectProxy } from './detect-proxy.js';
import { createTunnelingConnection } from './proxy-tunnel.js';
/**
* Create a transport that talks to the Arcjet API using Connect RPC.
*
* A thin wrapper around {@linkcode createConnectTransport}.
*
* When a standard proxy environment variable (`HTTP_PROXY` or `HTTPS_PROXY`,
* respecting `NO_PROXY`) is detected, the transport routes requests through the
* proxy and logs a line at startup. By default it proxies over HTTP/1.1 using
* the built-in proxy support of the Node.js HTTP agent; set
* `options.proxyHttpVersion` to `"2"` to instead tunnel HTTP/2 to the origin
* via `CONNECT` (see {@linkcode TransportOptions.proxyHttpVersion}). Without a
* proxy it always connects directly over HTTP/2.
*
* @param baseUrl
* Base URI for all HTTP requests (example: `https://example.com/my-api`).
* @param options
* Configuration (optional).
* @returns
* Connect transport used to make RPC calls.
*/
function createTransport(baseUrl, options) {
const url = new URL(baseUrl);
const proxyUrl = detectProxy(url, options);
if (typeof proxyUrl === "string") {
if (options?.proxyHttpVersion === "2") {
// HTTP/2 through the proxy: open a `CONNECT` tunnel and keep HTTP/2 to
// the origin end-to-end. The proxy only blindly forwards the tunnel, so
// ALPN is negotiated directly with the origin — see `./proxy-tunnel.ts`.
return createHttp2Transport(baseUrl, createTunnelingConnection(proxyUrl));
}
// HTTP/1.1 through the proxy (default). Hand the agent only the single
// proxy variable we resolved (not the whole environment) so it routes
// through exactly the proxy our detection chose, honoring our `NO_PROXY`
// handling. `keepAlive` lets the agent reuse the connection to the proxy
// across requests, like the long-lived session of the direct HTTP/2 path.
//
// Type the literal with the exact proxy variable names so a misspelled key
// is a compile error. The agent's `proxyEnv` option only exists in
// @types/node 24.x, but this source is also type-checked on the 22.x line
// (e.g. when bundled into @arcjet/next or @arcjet/sveltekit), so `proxyEnv`
// is added through an intersection type rather than relying on it being a
// known `AgentOptions` property. The `as unknown as ProcessEnv` is needed
// because some augmentations (e.g. Next.js) make `ProcessEnv` require
// `NODE_ENV`; the object is correct at runtime.
const isHttps = url.protocol === "https:";
const proxyEnvironment = isHttps ? { HTTPS_PROXY: proxyUrl } : { HTTP_PROXY: proxyUrl };
const agentOptions = {
keepAlive: true,
proxyEnv: proxyEnvironment,
};
const agent = isHttps
? new https.Agent(agentOptions)
: new http.Agent(agentOptions);
// Node's built-in proxy support only works over HTTP/1.1.
return createConnectTransport({
baseUrl,
httpVersion: "1.1",
nodeOptions: { agent },
});
}
// No proxy: connect directly over HTTP/2.
return createHttp2Transport(baseUrl);
}
/**
* Build a direct HTTP/2 transport with an optimistically pre-connecting session
* manager.
*
* When `createConnection` is supplied the session is tunneled through it (used
* to route HTTP/2 through a proxy via `CONNECT`); otherwise it connects directly
* to `baseUrl`. Either way pings and the idle timeout behave identically — only
* the underlying connection differs.
*
* @param baseUrl
* Base URI for all HTTP requests.
* @param createConnection
* Optional connection factory passed through to `http2.connect` (optional).
* @returns
* Connect transport that talks HTTP/2 to `baseUrl`.
*/
function createHttp2Transport(baseUrl, createConnection) {
const sessionManager = new Http2SessionManager(baseUrl,
// AWS Global Accelerator doesn't support PING so we use a very high idle
// timeout. Ref:
// https://docs.aws.amazon.com/global-accelerator/latest/dg/introduction-how-it-works.html#about-idle-timeout
{ idleConnectionTimeoutMs: 340 * 1000 }, createConnection ? { createConnection } : undefined);
// This is an optimistic pre-connect. In Deno, the Node HTTP/2 compatibility
// layer can surface background session failures as uncaught test errors, so
// we only warm the connection in Node.
if (!("Deno" in globalThis)) {
sessionManager.connect();
}
return createConnectTransport({ baseUrl, httpVersion: "2", sessionManager });
}
export { createTransport };
import type { SecureClientSessionOptions } from "node:http2";
import { Duplex } from "node:stream";
/**
* Route an HTTP/2 session through a forward proxy using an HTTP `CONNECT`
* tunnel, preserving HTTP/2 to the origin.
*
* Node's built-in HTTP agent proxy support (and the `https-proxy-agent` family)
* only wire a proxy into the HTTP/1.1 agent, which is why proxying otherwise
* forces a downgrade from HTTP/2. But HTTP/2 survives a `CONNECT` tunnel
* end-to-end: the proxy is told to open a raw TCP tunnel and thereafter only
* blindly forwards bytes (RFC 9110 §9.3.6), so the TLS handshake — including the
* ALPN negotiation that selects `h2` — happens directly with the origin. The
* proxy never sees, and so cannot downgrade, the negotiated protocol.
*
* The one wrinkle is that {@linkcode http2.connect}'s `createConnection`
* callback must return a {@linkcode Duplex} synchronously, but the `CONNECT`
* handshake is asynchronous. We bridge that gap with a small `Duplex` that
* buffers whatever the consumer writes (the TLS `ClientHello`, or the HTTP/2
* client preface for a cleartext target) until the proxy answers `2xx`, then
* splices itself onto the proxy socket. Because the contract stays synchronous,
* this drops into `@connectrpc/connect-node`'s default `Http2SessionManager`
* via `nodeOptions.createConnection` with no fork — reconnection, pings, and the
* idle timeout all keep working.
*
* This is Node-only. Bun and Deno don't implement the agent option this sits
* alongside, and their `fetch` is used for proxying instead.
*
* @param proxyUrl
* Proxy to route through (for example `http://127.0.0.1:3128`). An HTTPS proxy
* (TLS to the proxy itself) is supported too.
* @returns
* A `createConnection` callback for `http2.connect(..., { createConnection })`
* (and therefore for connect-node's `nodeOptions.createConnection`).
*/
export declare function createTunnelingConnection(proxyUrl: string): (authority: URL, options: SecureClientSessionOptions) => Duplex;
import * as net from 'node:net';
import { Duplex } from 'node:stream';
import * as tls from 'node:tls';
/**
* Route an HTTP/2 session through a forward proxy using an HTTP `CONNECT`
* tunnel, preserving HTTP/2 to the origin.
*
* Node's built-in HTTP agent proxy support (and the `https-proxy-agent` family)
* only wire a proxy into the HTTP/1.1 agent, which is why proxying otherwise
* forces a downgrade from HTTP/2. But HTTP/2 survives a `CONNECT` tunnel
* end-to-end: the proxy is told to open a raw TCP tunnel and thereafter only
* blindly forwards bytes (RFC 9110 §9.3.6), so the TLS handshake — including the
* ALPN negotiation that selects `h2` — happens directly with the origin. The
* proxy never sees, and so cannot downgrade, the negotiated protocol.
*
* The one wrinkle is that {@linkcode http2.connect}'s `createConnection`
* callback must return a {@linkcode Duplex} synchronously, but the `CONNECT`
* handshake is asynchronous. We bridge that gap with a small `Duplex` that
* buffers whatever the consumer writes (the TLS `ClientHello`, or the HTTP/2
* client preface for a cleartext target) until the proxy answers `2xx`, then
* splices itself onto the proxy socket. Because the contract stays synchronous,
* this drops into `@connectrpc/connect-node`'s default `Http2SessionManager`
* via `nodeOptions.createConnection` with no fork — reconnection, pings, and the
* idle timeout all keep working.
*
* This is Node-only. Bun and Deno don't implement the agent option this sits
* alongside, and their `fetch` is used for proxying instead.
*
* @param proxyUrl
* Proxy to route through (for example `http://127.0.0.1:3128`). An HTTPS proxy
* (TLS to the proxy itself) is supported too.
* @returns
* A `createConnection` callback for `http2.connect(..., { createConnection })`
* (and therefore for connect-node's `nodeOptions.createConnection`).
*/
function createTunnelingConnection(proxyUrl) {
const proxy = new URL(proxyUrl);
const proxyIsHttps = proxy.protocol === "https:";
const proxyPort = Number(proxy.port) || (proxyIsHttps ? 443 : 80);
// `Proxy-Authorization` header from any credentials embedded in the proxy URL.
const proxyAuthorization = proxy.username === ""
? undefined
: "Basic " +
Buffer.from(decodeURIComponent(proxy.username) +
":" +
decodeURIComponent(proxy.password)).toString("base64");
return function createConnection(authority, options) {
const originIsHttps = authority.protocol === "https:";
const originPort = Number(authority.port) || (originIsHttps ? 443 : 80);
const originAuthority = authority.hostname + ":" + originPort;
// The bridge is the underlying transport the HTTP/2 client writes into. We
// hold those bytes until the `CONNECT` tunnel is established, then flush and
// splice the bridge onto the proxy socket.
let tunnelReady = false;
const pending = [];
const bridge = new Duplex({
read() {
// Push-driven; see the splice below.
},
write(chunk, _encoding, callback) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (tunnelReady) {
proxySocket.write(buffer, callback);
}
else {
pending.push({ chunk: buffer, callback });
}
},
});
// 1. Open the connection to the proxy itself. Typed as the common
// `net.Socket` supertype (a `tls.TLSSocket` is one) so `.on(...)` event
// listeners resolve against a single typed event map rather than a union
// that would leave their parameters implicitly `any`.
const proxySocket = proxyIsHttps
? tls.connect({
host: proxy.hostname,
port: proxyPort,
servername: proxy.hostname,
})
: net.connect({ host: proxy.hostname, port: proxyPort });
// Disable Nagle's algorithm on the tunnel. HTTP/2 sends many small,
// dependent control frames; left on, Nagle interacts with the peer's
// delayed ACK to add ~40ms per round trip. Node sets this on its own HTTP/2
// sockets, but since we supply the socket we must set it ourselves. Note
// that an intermediate proxy that buffers the tunnel can reintroduce the
// same penalty regardless of this setting.
proxySocket.setNoDelay(true);
// 2. Once connected to the proxy, ask it to tunnel to the origin authority.
proxySocket.once(proxyIsHttps ? "secureConnect" : "connect", () => {
let request = "CONNECT " + originAuthority + " HTTP/1.1\r\n";
request += "Host: " + originAuthority + "\r\n";
if (proxyAuthorization !== undefined) {
request += "Proxy-Authorization: " + proxyAuthorization + "\r\n";
}
request += "\r\n";
proxySocket.write(request);
});
// 3. Read the proxy's response. Buffer until the header terminator, check
// the status line, and only on `2xx` splice the tunnel onto the bridge.
let head = Buffer.alloc(0);
function onData(chunk) {
head = Buffer.concat([head, chunk]);
const terminator = head.indexOf("\r\n\r\n");
if (terminator === -1) {
return; // Wait for the full response head.
}
proxySocket.off("data", onData);
const statusLine = head
.subarray(0, head.indexOf("\r\n"))
.toString("latin1");
const status = Number(statusLine.split(" ")[1]);
if (!(status >= 200 && status < 300)) {
const error = new Error("Proxy CONNECT failed with status: " + statusLine.trim());
proxySocket.destroy(error);
bridge.destroy(error);
return;
}
// Anything past the header terminator is already tunnel data (rare for a
// fresh handshake, but never drop it).
const leftover = head.subarray(terminator + 4);
if (leftover.length > 0) {
bridge.push(leftover);
}
// Splice: proxy -> bridge, so origin bytes become readable by the client.
//
// For a cleartext (h2c) origin the bridge *is* the HTTP/2 socket, so these
// bytes are HTTP/2 frames. A proxy can coalesce a whole response
// (HEADERS + DATA + END_STREAM) into one chunk; delivered as a single
// synchronous read, the HTTP/2 client surfaces the DATA and the stream end
// in one turn, which races `@connectrpc/connect-node`'s async-iterator
// response reader and intermittently throws `ERR_STREAM_PREMATURE_CLOSE`
// on Node >= 26 (a real or TLS-wrapped socket avoids this — it delivers
// frames across separate reads). So for h2c we split the stream on HTTP/2
// frame boundaries and push one frame per tick, preserving order; the
// client then reads the DATA before observing END_STREAM. For an HTTPS
// origin the bytes are TLS records (the origin TLS socket reframes them),
// so we forward them unchanged.
if (originIsHttps) {
proxySocket.on("data", (data) => bridge.push(data));
}
else {
// HTTP/2's default `SETTINGS_MAX_FRAME_SIZE` is 16 KiB and we never raise
// it, so any frame larger than this generous cap is already a protocol
// violation. Bounding the buffer keeps a malicious or broken peer from
// using the 24-bit length field (up to ~16 MiB) to make us buffer an
// oversized frame before forwarding anything.
const maxFramePayload = 2 ** 20; // 1 MiB
let inbound = Buffer.alloc(0);
proxySocket.on("data", (data) => {
inbound = Buffer.concat([inbound, data]);
// An HTTP/2 frame is a 9-byte header (24-bit length, then type, flags,
// and stream id) followed by `length` bytes of payload.
while (inbound.length >= 9) {
const payloadLength = inbound.readUIntBE(0, 3);
if (payloadLength > maxFramePayload) {
bridge.destroy(new Error("Proxy tunnel received an oversized HTTP/2 frame"));
return;
}
const frameLength = 9 + payloadLength;
if (inbound.length < frameLength) {
break; // Wait for the rest of this frame.
}
const frame = inbound.subarray(0, frameLength);
inbound = inbound.subarray(frameLength);
setImmediate(() => {
if (!bridge.destroyed) {
bridge.push(frame);
}
});
}
});
}
proxySocket.on("end", () => setImmediate(() => {
if (!bridge.destroyed) {
bridge.push(null);
}
}));
// Flush whatever the client buffered while the tunnel was establishing.
tunnelReady = true;
for (const { chunk: queued, callback } of pending) {
proxySocket.write(queued, callback);
}
pending.length = 0;
// The CONNECT response head is no longer needed; release it so it isn't
// retained for the lifetime of the (long-lived) connection.
head = Buffer.alloc(0);
}
proxySocket.on("data", onData);
// Propagate failures in both directions so the session manager observes them.
proxySocket.on("error", (error) => bridge.destroy(error));
bridge.on("close", () => proxySocket.destroy());
// 4. For an HTTPS origin, run TLS *to the origin* over the tunnel, offering
// h2 via ALPN; ALPN is negotiated with the origin, not the proxy. For a
// cleartext origin, the bridge itself carries HTTP/2 (h2c) over the
// tunnel.
if (!originIsHttps) {
return bridge;
}
// `URL.hostname` wraps an IPv6 literal in brackets (e.g. `[::1]`), which
// `net.isIP` does not recognise. Strip them so an IPv6 origin is detected as
// an IP (and so the certificate is matched against the bare address — an IP
// SAN has no brackets).
const bareHostname = authority.hostname.startsWith("[") && authority.hostname.endsWith("]")
? authority.hostname.slice(1, -1)
: authority.hostname;
const originIsIpLiteral = net.isIP(bareHostname) !== 0;
try {
return tls.connect({
...options,
socket: bridge,
// Validate the certificate against the origin host. For a hostname this
// is also sent as SNI below; for an IP it is the identity to match
// (since SNI is omitted), so the cert's IP SAN is still checked.
host: bareHostname,
// RFC 6066 §3 forbids an IP literal in the TLS SNI `servername` — it must
// be a DNS hostname — so we omit SNI for an IP origin (IPv4 or IPv6) and
// rely on `host` above for certificate validation. Node <= 24 tolerated
// an IP here (deprecation warning); Node >= 26 enforces the RFC and
// throws `ERR_INVALID_ARG_VALUE`.
servername: originIsIpLiteral ? undefined : bareHostname,
ALPNProtocols: ["h2"],
});
}
catch (error) {
// Constructing the origin TLS connection can throw *synchronously* (for
// example, Node >= 26 rejects an IP-literal `servername` per RFC 6066).
// We have already opened `proxySocket`; if we let the throw escape we
// strand that open handle, which keeps the event loop — and the whole
// process — alive (this is what hung CI). Tear both sides down before
// rethrowing so a failed setup fails fast and cleanly.
proxySocket.destroy();
bridge.destroy(error);
throw error;
}
};
}
export { createTunnelingConnection };
import type { Transport } from "@connectrpc/connect";
export type { ProxyEnvironment, TransportLogger, TransportOptions, } from "./detect-proxy.js";
import type { TransportOptions } from "./detect-proxy.js";
export declare function createTransport(baseUrl: string, _options?: TransportOptions): Transport;
import { createConnectTransport } from '@connectrpc/connect-web';
function createTransport(baseUrl,
// These edge runtimes don't support outbound proxy environment variables, so
// the options are accepted for API parity with the other entry points but no
// proxy is detected or used.
_options) {
return createConnectTransport({
baseUrl,
fetch: fetchProxy,
});
}
function fetchProxy(input, init) {
return fetch(input, { ...init, redirect: "follow" });
}
export { createTransport };