Skip to content

Commit c74dae7

Browse files
authored
feat: add a custom proxyAgent option to the Node server SDK (#1438)
1 parent f97d80f commit c74dae7

5 files changed

Lines changed: 185 additions & 4 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as http from 'http';
2+
3+
import {
4+
AsyncQueue,
5+
SSEItem,
6+
TestHttpHandlers,
7+
TestHttpServer,
8+
} from 'launchdarkly-js-test-helpers';
9+
10+
import { basicLogger, LDLogger } from '../src';
11+
import LDClientNode from '../src/LDClientNode';
12+
13+
const sdkKey = 'sdkKey';
14+
const flagKey = 'flagKey';
15+
const expectedFlagValue = 'yes';
16+
const flag = {
17+
key: flagKey,
18+
version: 1,
19+
on: false,
20+
offVariation: 0,
21+
variations: [expectedFlagValue, 'no'],
22+
};
23+
const allData = { flags: { flagKey: flag }, segments: {} };
24+
25+
// This proves the proxyAgent option actually reaches the underlying request, through the full
26+
// LDOptions -> NodePlatform -> NodeRequests wiring, rather than only the NodeRequests constructor.
27+
describe('When using a custom proxyAgent', () => {
28+
let logger: LDLogger;
29+
let closeable: { close: () => void }[];
30+
31+
beforeEach(() => {
32+
closeable = [];
33+
logger = basicLogger({
34+
destination: () => {},
35+
});
36+
});
37+
38+
afterEach(() => {
39+
closeable.forEach((item) => item.close());
40+
});
41+
42+
it('uses the supplied proxyAgent for polling requests', async () => {
43+
const server = await TestHttpServer.start();
44+
server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));
45+
46+
const agent = new http.Agent({ keepAlive: false });
47+
// addRequest exists at runtime but is not part of the public http.Agent type.
48+
// @ts-ignore
49+
const addRequestSpy = jest.spyOn(agent, 'addRequest');
50+
51+
const client = new LDClientNode(sdkKey, {
52+
baseUri: server.url,
53+
proxyAgent: agent,
54+
stream: false,
55+
sendEvents: false,
56+
logger,
57+
});
58+
59+
closeable.push(server, client);
60+
61+
await client.waitForInitialization({ timeout: 10 });
62+
expect(client.initialized()).toBe(true);
63+
expect(addRequestSpy).toHaveBeenCalled();
64+
});
65+
66+
it('uses the supplied proxyAgent for streaming requests', async () => {
67+
const server = await TestHttpServer.start();
68+
const events = new AsyncQueue<SSEItem>();
69+
events.add({ type: 'put', data: JSON.stringify({ data: allData }) });
70+
server.forMethodAndPath('get', '/all', TestHttpHandlers.sseStream(events));
71+
72+
const agent = new http.Agent({ keepAlive: false });
73+
// addRequest exists at runtime but is not part of the public http.Agent type.
74+
// @ts-ignore
75+
const addRequestSpy = jest.spyOn(agent, 'addRequest');
76+
77+
const client = new LDClientNode(sdkKey, {
78+
streamUri: server.url,
79+
proxyAgent: agent,
80+
sendEvents: false,
81+
logger,
82+
});
83+
84+
closeable.push(server, events, client);
85+
86+
await client.waitForInitialization({ timeout: 10 });
87+
expect(client.initialized()).toBe(true);
88+
expect(addRequestSpy).toHaveBeenCalled();
89+
});
90+
});

packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ describe('given a request to a server that never responds', () => {
214214
});
215215

216216
describe('given an instance of NodeRequests with enableEventCompression turned on', () => {
217-
const requests = new NodeRequests(undefined, undefined, undefined, true);
217+
const requests = new NodeRequests(undefined, undefined, undefined, undefined, true);
218218
it('can make a basic post with compressBodyIfPossible enabled', async () => {
219219
await requests.fetch(`http://localhost:${PORT}`, {
220220
method: 'POST',
@@ -239,3 +239,42 @@ describe('given an instance of NodeRequests with enableEventCompression turned o
239239
expect(serverResult.body).toEqual('BODY TEXT');
240240
});
241241
});
242+
243+
describe('given an instance of NodeRequests with a proxyAgent and proxyOptions both supplied', () => {
244+
it('logs a warning and uses the supplied proxyAgent instead of building one from proxyOptions', async () => {
245+
const agent = new http.Agent({ keepAlive: false });
246+
// addRequest exists at runtime but is not part of the public http.Agent type.
247+
// @ts-ignore
248+
const addRequestSpy = jest.spyOn(agent, 'addRequest');
249+
const logger = { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() };
250+
251+
const requests = new NodeRequests(
252+
undefined,
253+
{ host: 'proxy.invalid', port: 8080 },
254+
agent,
255+
logger,
256+
);
257+
await requests.fetch(`http://localhost:${PORT}`);
258+
259+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('proxyAgent'));
260+
expect(addRequestSpy).toHaveBeenCalled();
261+
});
262+
});
263+
264+
describe('given an instance of NodeRequests with only a proxyAgent supplied', () => {
265+
it('uses the supplied agent for requests and reports usingProxy as a best-effort true', async () => {
266+
const agent = new http.Agent({ keepAlive: false });
267+
// addRequest exists at runtime but is not part of the public http.Agent type.
268+
// @ts-ignore
269+
const addRequestSpy = jest.spyOn(agent, 'addRequest');
270+
271+
const requests = new NodeRequests(undefined, undefined, agent);
272+
await requests.fetch(`http://localhost:${PORT}`);
273+
274+
expect(addRequestSpy).toHaveBeenCalled();
275+
// The SDK can't verify a caller-supplied agent is actually proxying (it could just as easily
276+
// be for mTLS), but reporting true is the better default for this option's motivating case
277+
// (a SOCKS proxy), so usingProxy() treats any supplied proxyAgent as a best-effort signal.
278+
expect(requests.usingProxy()).toBe(true);
279+
});
280+
});

packages/sdk/server-node/src/api/LDOptions.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as http from 'http';
2+
import * as https from 'https';
3+
14
import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common';
25

36
import { LDPlugin } from './LDPlugin';
@@ -16,4 +19,24 @@ export interface LDOptions extends LDOptionsCommon {
1619
* Plugin support is currently experimental and subject to change.
1720
*/
1821
plugins?: LDPlugin[];
22+
23+
/**
24+
* An HTTP(S) agent used for all outgoing SDK connections.
25+
*
26+
* This is an extension point for proxy configurations the SDK does not build itself. For a
27+
* basic HTTP/HTTPS proxy, prefer {@link LDOptionsCommon.proxyOptions}. For other schemes (for
28+
* example a SOCKS proxy), construct the appropriate agent (such as `SocksProxyAgent` from the
29+
* `socks-proxy-agent` package) and supply it here.
30+
*
31+
* When this is set, `proxyOptions` and `tlsParams` are ignored, because the agent is
32+
* responsible for connection and TLS setup.
33+
*
34+
* @remarks
35+
* The SDK cannot inspect a caller-supplied agent, so diagnostic reporting treats any agent
36+
* supplied here as a best-effort signal that a proxy is in use. This may be inaccurate if the
37+
* agent is used for something other than proxying, such as custom certificates. Credentials
38+
* carried by the agent itself (for example a SOCKS URL's embedded username/password) are not
39+
* reflected in proxy-authentication diagnostics, since the SDK has no way to inspect them.
40+
*/
41+
proxyAgent?: https.Agent | http.Agent;
1942
}

packages/sdk/server-node/src/platform/NodePlatform.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { LDOptions, platform } from '@launchdarkly/js-server-sdk-common';
1+
import { platform } from '@launchdarkly/js-server-sdk-common';
22

3+
import { LDOptions } from '../api/LDOptions';
34
import NodeCrypto from './NodeCrypto';
45
import NodeFilesystem from './NodeFilesystem';
56
import NodeInfo from './NodeInfo';
@@ -19,6 +20,7 @@ export default class NodePlatform implements platform.Platform {
1920
this.requests = new NodeRequests(
2021
options.tlsParams,
2122
options.proxyOptions,
23+
options.proxyAgent,
2224
options.logger,
2325
options.enableEventCompression,
2426
);

packages/sdk/server-node/src/platform/NodeRequests.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ function createAgent(
9898
return undefined;
9999
}
100100

101+
// A caller-supplied agent is a completely different way of constructing the agent than the
102+
// tlsOptions/proxyOptions path above: it takes precedence and is used verbatim, covering proxy
103+
// schemes the SDK does not build itself (for example a SOCKS proxy via socks-proxy-agent). When
104+
// it is set, proxyOptions and tlsParams are ignored because the agent owns connection setup.
105+
function resolveAgent(
106+
tlsOptions?: LDTLSOptions,
107+
proxyOptions?: LDProxyOptions,
108+
proxyAgent?: https.Agent | http.Agent,
109+
logger?: LDLogger,
110+
): https.Agent | http.Agent | undefined {
111+
if (proxyAgent) {
112+
if (proxyOptions || tlsOptions) {
113+
logger?.warn(
114+
'Both proxyAgent and proxyOptions/tlsParams were provided; using proxyAgent and ignoring proxyOptions/tlsParams.',
115+
);
116+
}
117+
return proxyAgent;
118+
}
119+
return createAgent(tlsOptions, proxyOptions, logger);
120+
}
121+
101122
export default class NodeRequests implements platform.Requests {
102123
private _agent: https.Agent | http.Agent | undefined;
103124

@@ -112,11 +133,17 @@ export default class NodeRequests implements platform.Requests {
112133
constructor(
113134
tlsOptions?: LDTLSOptions,
114135
proxyOptions?: LDProxyOptions,
136+
proxyAgent?: https.Agent | http.Agent,
115137
logger?: LDLogger,
116138
enableEventCompression?: boolean,
117139
) {
118-
this._agent = createAgent(tlsOptions, proxyOptions, logger);
119-
this._hasProxy = !!proxyOptions;
140+
this._agent = resolveAgent(tlsOptions, proxyOptions, proxyAgent, logger);
141+
// A caller-supplied proxyAgent is treated as a best-effort proxy signal: the SDK cannot
142+
// inspect an opaque agent to know whether it actually proxies (it could just as easily be a
143+
// certificate-only agent for mTLS). Reporting true is the better default here because
144+
// proxying is this option's primary motivation, while other connection concerns (such as
145+
// custom TLS) are handled by tlsParams.
146+
this._hasProxy = !!proxyOptions || !!proxyAgent;
120147
this._hasProxyAuth = !!proxyOptions?.auth;
121148
this._enableBodyCompression = !!enableEventCompression;
122149
}

0 commit comments

Comments
 (0)