Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/sdk/server-node/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Using this build of `@launchdarkly/node-server-sdk` in your app

This guide explains how to consume this local fork/build of the Node server SDK in a
separate test application — for example, to try out the SOCKS proxy support on the
`socks-proxy-support` branch before it is published to npm.

The package is `@launchdarkly/node-server-sdk` (currently version `9.11.2`). It lives in
a monorepo, so you must build it (and its workspace dependencies) before consuming it.

## 1. Build the SDK

From the **root** of the monorepo, build this package and everything it depends on.
Running `yarn build` inside the package directory alone will **not** rebuild its
dependencies.

```bash
yarn # install workspace deps (first time only)
yarn workspaces foreach -pR --topological-dev \
--from '@launchdarkly/node-server-sdk' run build
```

This produces the compiled output in `packages/sdk/server-node/dist`.

## 2a. Install via tarball (recommended)

The tarball method most closely mirrors a real `npm install` from the registry. It
bundles only the files that would actually be published, and it avoids the symlink
pitfalls of a monorepo (see the note at the end).

From `packages/sdk/server-node`:

```bash
yarn pack # creates package.tgz in this directory
```

Then, in your **test app**, install the tarball by absolute or relative path:

```bash
npm install /path/to/js-core/packages/sdk/server-node/package.tgz
# or with yarn:
yarn add /path/to/js-core/packages/sdk/server-node/package.tgz
```

Re-run `yarn pack` and re-install after every change you want to test.

> Tip: `yarn pack --filename ld-node-sdk.tgz` lets you give the archive a stable name
> so your test app's `package.json` reference doesn't change between builds.

## 2b. Install via `file:` reference (fast iteration)

For rapid local iteration, point your test app's `package.json` directly at the built
package directory:

```jsonc
{
"dependencies": {
"@launchdarkly/node-server-sdk": "file:/path/to/js-core/packages/sdk/server-node"
}
}
```

Then `npm install` / `yarn install` in the test app. With this approach you only need to
re-run the **build** (step 1) after a change — no re-pack, no re-install.

> Caveat: because this is a monorepo, the package's own dependencies
> (`@launchdarkly/js-server-sdk-common`, etc.) are symlinked under the workspace root,
> not inside `packages/sdk/server-node/node_modules`. A `file:` install copies/links the
> package but may not resolve those workspace deps the way the registry would. If you hit
> "module not found" errors for `@launchdarkly/*` packages, use the tarball method in 2a
> instead, which inlines the correct dependency versions.

## 3. Use the SDK in your app

Standard usage is unchanged from the published SDK:

```js
const { init } = require('@launchdarkly/node-server-sdk');

const client = init('your-sdk-key');

await client.waitForInitialization({ timeout: 10 });
const value = await client.variation('your-flag-key', { key: 'user-123' }, false);
console.log(value);
```

## 4. Testing the SOCKS proxy support

This branch adds SOCKS proxy support via the existing `proxyOptions` config. Set
`scheme` to a SOCKS scheme; `host` and `port` identify the SOCKS proxy, and `auth`
(if needed) carries the `username:password` credentials.

Supported schemes: `socks`, `socks4`, `socks4a`, `socks5`, `socks5h`
(plus the existing `http` / `https` HTTP-proxy schemes).

```js
const { init } = require('@launchdarkly/node-server-sdk');

const client = init('your-sdk-key', {
proxyOptions: {
scheme: 'socks5',
host: '127.0.0.1',
port: 1080,
// Optional. The password may contain ':' — only the first ':' splits user/password.
auth: 'proxyuser:proxypassword',
},
});

await client.waitForInitialization({ timeout: 10 });
console.log(await client.variation('your-flag-key', { key: 'user-123' }, false));
```

A single SOCKS agent handles both the streaming (HTTPS) and event-delivery connections,
so no separate configuration is required for each.

If you just want to confirm the proxy path works without a real SDK key, the repo's own
test harness spins up an in-process SOCKS server — see
[`__tests__/socksProxyServer.ts`](__tests__/socksProxyServer.ts) and
[`__tests__/LDClientNode.socksProxy.test.ts`](__tests__/LDClientNode.socksProxy.test.ts).
Run them with:

```bash
yarn workspace @launchdarkly/node-server-sdk test
```
117 changes: 117 additions & 0 deletions packages/sdk/server-node/__tests__/LDClientNode.socksProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { AsyncQueue, SSEItem, TestHttpHandlers, TestHttpServer } from 'launchdarkly-js-test-helpers';

import { basicLogger, LDLogger } from '../src';
import LDClientNode from '../src/LDClientNode';
import { SocksProxyServer, startSocksProxyServer } from './socksProxyServer';

const sdkKey = 'sdkKey';
const flagKey = 'flagKey';
const expectedFlagValue = 'yes';
const flag = {
key: flagKey,
version: 1,
on: false,
offVariation: 0,
variations: [expectedFlagValue, 'no'],
};
const allData = { flags: { flagKey: flag }, segments: {} };

describe('When using a SOCKS proxy', () => {
let logger: LDLogger;
let closeable: { close: () => void }[];

beforeEach(() => {
closeable = [];
logger = basicLogger({
destination: () => {},
});
});

afterEach(() => {
closeable.forEach((item) => item.close());
});

it('can use a SOCKS proxy in polling mode', async () => {
const proxy: SocksProxyServer = await startSocksProxyServer();
const server = await TestHttpServer.start();
server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));

const client = new LDClientNode(sdkKey, {
baseUri: server.url,
proxyOptions: {
host: proxy.hostname,
port: proxy.port,
scheme: 'socks5',
},
stream: false,
sendEvents: false,
logger,
});

closeable.push(proxy, server, client);

await client.waitForInitialization({ timeout: 10 });
expect(client.initialized()).toBe(true);

// If the SOCKS proxy did not see a connection then the SDK did not actually use it.
expect(proxy.requestCount()).toBeGreaterThanOrEqual(1);
});

it('can use a SOCKS proxy in streaming mode', async () => {
const proxy: SocksProxyServer = await startSocksProxyServer();
const server = await TestHttpServer.start();
const events = new AsyncQueue<SSEItem>();
events.add({ type: 'put', data: JSON.stringify({ data: allData }) });
server.forMethodAndPath('get', '/all', TestHttpHandlers.sseStream(events));

const client = new LDClientNode(sdkKey, {
streamUri: server.url,
proxyOptions: {
host: proxy.hostname,
port: proxy.port,
scheme: 'socks5',
},
sendEvents: false,
logger,
});

closeable.push(proxy, server, events, client);

await client.waitForInitialization({ timeout: 10 });
expect(client.initialized()).toBe(true);

expect(proxy.requestCount()).toBeGreaterThanOrEqual(1);
});

it('can use a SOCKS proxy with username/password authentication', async () => {
// The password contains a colon to verify that everything after the first colon in `auth` is
// treated as the password.
const proxy: SocksProxyServer = await startSocksProxyServer({
username: 'user',
password: 'p@ss:word',
});
const server = await TestHttpServer.start();
server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));

const client = new LDClientNode(sdkKey, {
baseUri: server.url,
proxyOptions: {
host: proxy.hostname,
port: proxy.port,
scheme: 'socks5',
auth: 'user:p@ss:word',
},
stream: false,
sendEvents: false,
logger,
});

closeable.push(proxy, server, client);

await client.waitForInitialization({ timeout: 10 });
expect(client.initialized()).toBe(true);

expect(proxy.requestCount()).toBeGreaterThanOrEqual(1);
expect(proxy.authFailures()).toEqual([]);
});
});
119 changes: 119 additions & 0 deletions packages/sdk/server-node/__tests__/socksProxyServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import * as net from 'net';

export interface SocksProxyServer {
hostname: string;
port: number;
requestCount: () => number;
authFailures: () => { uname: string; passwd: string }[];
close: () => void;
}

export interface SocksProxyServerOptions {
username?: string;
password?: string;
}

// A minimal SOCKS5 proxy server for tests. It implements just enough of RFC 1928 (and the
// username/password auth method from RFC 1929) to let the SDK's SOCKS support be exercised
// end-to-end: it negotiates a method, optionally checks credentials, handles a CONNECT command,
// and then pipes bytes between the client and the real target server.
export function startSocksProxyServer(
options: SocksProxyServerOptions = {},
): Promise<SocksProxyServer> {
const expectedUser = options.username;
const expectedPassword = options.password;
const requireAuth = !!expectedUser;

let connectionCount = 0;
const authFailures: { uname: string; passwd: string }[] = [];

const server = net.createServer((clientSocket) => {
function handleConnect(request: Buffer) {
// VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2)
const atyp = request[3];
let host: string;
let portOffset: number;
if (atyp === 0x01) {
host = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
portOffset = 8;
} else if (atyp === 0x03) {
const len = request[4];
host = request.slice(5, 5 + len).toString();
portOffset = 5 + len;
} else if (atyp === 0x04) {
// IPv6: 16 bytes formatted as eight colon-separated hextets. The SOCKS client resolves
// hostnames such as "localhost" itself, and on many systems that yields ::1, so we have
// to handle this address type as well as IPv4.
const groups: string[] = [];
for (let i = 0; i < 8; i += 1) {
groups.push(request.readUInt16BE(4 + i * 2).toString(16));
}
host = groups.join(':');
portOffset = 4 + 16;
} else {
// Unsupported address type
clientSocket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
clientSocket.end();
return;
}
const port = request.readUInt16BE(portOffset);

const targetSocket = net.connect(port, host, () => {
connectionCount += 1;
// Success reply with a dummy bound address/port (0.0.0.0:0)
clientSocket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
clientSocket.pipe(targetSocket);
targetSocket.pipe(clientSocket);
});
targetSocket.on('error', () => {
clientSocket.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
clientSocket.end();
});
}

clientSocket.once('data', () => {
// greeting: VER(1) NMETHODS(1) METHODS(NMETHODS)
const chosenMethod = requireAuth ? 0x02 : 0x00;
clientSocket.write(Buffer.from([0x05, chosenMethod]));

const afterAuth = () => clientSocket.once('data', handleConnect);

if (requireAuth) {
clientSocket.once('data', (authData) => {
// VER(1) ULEN(1) UNAME PLEN(1) PASSWD
const ulen = authData[1];
const uname = authData.slice(2, 2 + ulen).toString();
const plen = authData[2 + ulen];
const passwd = authData.slice(3 + ulen, 3 + ulen + plen).toString();
const ok = uname === expectedUser && passwd === expectedPassword;
if (!ok) {
authFailures.push({ uname, passwd });
}
clientSocket.write(Buffer.from([0x01, ok ? 0x00 : 0x01]));
if (ok) {
afterAuth();
} else {
clientSocket.end();
}
});
} else {
afterAuth();
}
});

clientSocket.on('error', () => {});
});

return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const address = server.address() as net.AddressInfo;
resolve({
hostname: '127.0.0.1',
port: address.port,
requestCount: () => connectionCount,
authFailures: () => authFailures,
close: () => server.close(),
});
});
});
}
3 changes: 2 additions & 1 deletion packages/sdk/server-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"dependencies": {
"@launchdarkly/js-server-sdk-common": "2.19.1",
"https-proxy-agent": "^7.0.6",
"launchdarkly-eventsource": "2.2.0"
"launchdarkly-eventsource": "2.2.0",
"socks-proxy-agent": "^8.0.5"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
Expand Down
Loading
Loading