Skip to content

Commit

Permalink
feat(node): Update and vendor https-proxy-agent (#10088)
Browse files Browse the repository at this point in the history
Closes #9199

This PR vendors the `https-proxy-agent` code and in the process updates
to v7.0.0.

`https-proxy-agent` is our last remaining cjs-only dependency so this is
required for #10046.

This removes the following dependencies:
- `https-proxy-agent@5.0.1`
- `agent-base@6.0.2`
- `debug@4.3.4`
- `ms@2.1.2`

The vendored code has been modified to use the Sentry logger rather than
`debug`.

Initially, rather than modify the vendored code substantially just to
pass our tight lint rules, I've disabled a few of the less important
lints that would make it particularly tricky to pull in upstream bug
fixes:
```ts
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable jsdoc/require-jsdoc */
``` 

## Min supported Node version

`https-proxy-agent` has a `@types/node@14.18.45` dev dependency but
apart from adding an import for `URL`, I can't find anything that would
stop it working on older versions of node and there is nothing in the
changelogs that would suggest the min supported version has changed.

---------

Co-authored-by: Abhijeet Prasad <aprasad@sentry.io>
  • Loading branch information
timfish and AbhiPrasad committed Jan 18, 2024
1 parent 8c7b5b5 commit acf58d3
Show file tree
Hide file tree
Showing 9 changed files with 593 additions and 8 deletions.
3 changes: 1 addition & 2 deletions packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@
"@sentry-internal/tracing": "7.93.0",
"@sentry/core": "7.93.0",
"@sentry/types": "7.93.0",
"@sentry/utils": "7.93.0",
"https-proxy-agent": "^5.0.0"
"@sentry/utils": "7.93.0"
},
"devDependencies": {
"@types/cookie": "0.5.2",
Expand Down
151 changes: 151 additions & 0 deletions packages/node/src/proxy/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7
* With the following licence:
*
* (The MIT License)
*
* Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net>*
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* 'Software'), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:*
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.*
*
* THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

/* eslint-disable @typescript-eslint/explicit-member-accessibility */
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable jsdoc/require-jsdoc */
import * as http from 'http';
import type * as net from 'net';
import type { Duplex } from 'stream';
import type * as tls from 'tls';

export * from './helpers';

interface HttpConnectOpts extends net.TcpNetConnectOpts {
secureEndpoint: false;
protocol?: string;
}

interface HttpsConnectOpts extends tls.ConnectionOptions {
secureEndpoint: true;
protocol?: string;
port: number;
}

export type AgentConnectOpts = HttpConnectOpts | HttpsConnectOpts;

const INTERNAL = Symbol('AgentBaseInternalState');

interface InternalState {
defaultPort?: number;
protocol?: string;
currentSocket?: Duplex;
}

export abstract class Agent extends http.Agent {
private [INTERNAL]: InternalState;

// Set by `http.Agent` - missing from `@types/node`
options!: Partial<net.TcpNetConnectOpts & tls.ConnectionOptions>;
keepAlive!: boolean;

constructor(opts?: http.AgentOptions) {
super(opts);
this[INTERNAL] = {};
}

abstract connect(
req: http.ClientRequest,
options: AgentConnectOpts,
): Promise<Duplex | http.Agent> | Duplex | http.Agent;

/**
* Determine whether this is an `http` or `https` request.
*/
isSecureEndpoint(options?: AgentConnectOpts): boolean {
if (options) {
// First check the `secureEndpoint` property explicitly, since this
// means that a parent `Agent` is "passing through" to this instance.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
if (typeof (options as any).secureEndpoint === 'boolean') {
return options.secureEndpoint;
}

// If no explicit `secure` endpoint, check if `protocol` property is
// set. This will usually be the case since using a full string URL
// or `URL` instance should be the most common usage.
if (typeof options.protocol === 'string') {
return options.protocol === 'https:';
}
}

// Finally, if no `protocol` property was set, then fall back to
// checking the stack trace of the current call stack, and try to
// detect the "https" module.
const { stack } = new Error();
if (typeof stack !== 'string') return false;
return stack.split('\n').some(l => l.indexOf('(https.js:') !== -1 || l.indexOf('node:https:') !== -1);
}

createSocket(req: http.ClientRequest, options: AgentConnectOpts, cb: (err: Error | null, s?: Duplex) => void): void {
const connectOpts = {
...options,
secureEndpoint: this.isSecureEndpoint(options),
};
Promise.resolve()
.then(() => this.connect(req, connectOpts))
.then(socket => {
if (socket instanceof http.Agent) {
// @ts-expect-error `addRequest()` isn't defined in `@types/node`
return socket.addRequest(req, connectOpts);
}
this[INTERNAL].currentSocket = socket;
// @ts-expect-error `createSocket()` isn't defined in `@types/node`
super.createSocket(req, options, cb);
}, cb);
}

createConnection(): Duplex {
const socket = this[INTERNAL].currentSocket;
this[INTERNAL].currentSocket = undefined;
if (!socket) {
throw new Error('No socket was returned in the `connect()` function');
}
return socket;
}

get defaultPort(): number {
return this[INTERNAL].defaultPort ?? (this.protocol === 'https:' ? 443 : 80);
}

set defaultPort(v: number) {
if (this[INTERNAL]) {
this[INTERNAL].defaultPort = v;
}
}

get protocol(): string {
return this[INTERNAL].protocol ?? (this.isSecureEndpoint() ? 'https:' : 'http:');
}

set protocol(v: string) {
if (this[INTERNAL]) {
this[INTERNAL].protocol = v;
}
}
}
71 changes: 71 additions & 0 deletions packages/node/src/proxy/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7
* With the following licence:
*
* (The MIT License)
*
* Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net>*
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* 'Software'), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:*
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.*
*
* THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

/* eslint-disable jsdoc/require-jsdoc */
import * as http from 'http';
import * as https from 'https';
import type { Readable } from 'stream';
// TODO (v8): Remove this when Node < 12 is no longer supported
import type { URL } from 'url';

export type ThenableRequest = http.ClientRequest & {
then: Promise<http.IncomingMessage>['then'];
};

export async function toBuffer(stream: Readable): Promise<Buffer> {
let length = 0;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
length += (chunk as Buffer).length;
chunks.push(chunk);
}
return Buffer.concat(chunks, length);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function json(stream: Readable): Promise<any> {
const buf = await toBuffer(stream);
const str = buf.toString('utf8');
try {
return JSON.parse(str);
} catch (_err: unknown) {
const err = _err as Error;
err.message += ` (input: ${str})`;
throw err;
}
}

export function req(url: string | URL, opts: https.RequestOptions = {}): ThenableRequest {
const href = typeof url === 'string' ? url : url.href;
const req = (href.startsWith('https:') ? https : http).request(url, opts) as ThenableRequest;
const promise = new Promise<http.IncomingMessage>((resolve, reject) => {
req.once('response', resolve).once('error', reject).end() as unknown as ThenableRequest;
});
req.then = promise.then.bind(promise);
return req;
}
Loading

0 comments on commit acf58d3

Please sign in to comment.