Skip to content

Commit bce024c

Browse files
authored
chore: Add Node platform abstraction for node-client (#1393)
## Summary - Adds the Node platform abstraction layer for `@launchdarkly/node-client-sdk`: `HeaderWrapper`, `NodeCrypto`, `NodeEncoding`, `NodeInfo`, `NodePlatform`, `NodeRequests`, `NodeResponse`, `NodeStorage`. These implement the `Platform` contract from `@launchdarkly/js-client-sdk-common`. - Adds runtime dependencies the platform code imports: `@launchdarkly/js-client-sdk-common`, `https-proxy-agent`, `launchdarkly-eventsource`. - Modernizes `packages/sdk/node-client/tsconfig.json` to `module: ESNext` + `moduleResolution: bundler` (tsup handles emit), adds `esModuleInterop`, `types: ["jest", "node"]`, `skipLibCheck`. Matches the pattern used by `packages/sdk/server-ai`. - Part of the SDK-2195 stacked migration of `launchdarkly-node-client-sdk` into js-core. Follows PR #1352 (scaffold). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New TLS, HTTP streaming, and on-disk cache behavior affect runtime networking and local persistence, though it is isolated behind platform interfaces and covered by unit tests; the public client API is not wired yet. > > **Overview** > Adds the **Node platform layer** for `@launchdarkly/node-client-sdk` so the package can plug into `@launchdarkly/js-client-sdk-common`: `NodePlatform` wires **info**, **crypto**, **encoding**, **file-backed storage**, and **HTTP/HTTPS + EventSource** requests. New `NodeOptions` covers **TLS** (`tlsParams`), optional **POST gzip** for events, and **`localStoragePath`** (default `ldclient-user-cache`). > > `NodeRequests` / `NodeResponse` implement fetch-like behavior (timeouts, gzip on GET, optional body compression, custom CA for HTTPS) via `launchdarkly-eventsource`. `NodeStorage` persists a JSON cache with atomic writes and a process singleton. > > Also adds **Jest** coverage for the platform modules, a **`sdk/node-client` GitHub Actions** job (Node 18 & 22), package **test** script and dependencies, **tsconfig** shift to `ESNext` / `bundler`, and **release-please** bumping `NodeInfo.ts` for version stamps. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit eb89832. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ccd6249 commit bce024c

24 files changed

Lines changed: 1185 additions & 3 deletions

.github/workflows/node-client.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: sdk/node-client
2+
3+
on:
4+
push:
5+
branches: [main, 'feat/**']
6+
paths-ignore:
7+
- '**.md' #Do not need to run CI for markdown changes.
8+
pull_request:
9+
branches: [main, 'feat/**']
10+
paths-ignore:
11+
- '**.md'
12+
13+
jobs:
14+
build-test-node-client:
15+
runs-on: ubuntu-latest
16+
17+
strategy:
18+
matrix:
19+
# Node versions to run on.
20+
version: [18, 22]
21+
22+
steps:
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
25+
with:
26+
node-version: ${{ matrix.version }}
27+
registry-url: 'https://registry.npmjs.org'
28+
- id: shared
29+
name: Shared CI Steps
30+
uses: ./actions/ci
31+
with:
32+
workspace_name: '@launchdarkly/node-client-sdk'
33+
workspace_path: packages/sdk/node-client
34+
# TODO: Add contract tests
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Default cache directory name
2+
ldclient-user-cache/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import HeaderWrapper from '../../src/platform/HeaderWrapper';
2+
3+
it('returns the header value when present', () => {
4+
const wrapper = new HeaderWrapper({ 'content-type': 'application/json' });
5+
expect(wrapper.get('content-type')).toBe('application/json');
6+
});
7+
8+
it('returns null when the header is absent', () => {
9+
const wrapper = new HeaderWrapper({});
10+
expect(wrapper.get('missing')).toBeNull();
11+
});
12+
13+
it('joins array-valued headers with a comma separator', () => {
14+
const wrapper = new HeaderWrapper({ 'set-cookie': ['a=1', 'b=2'] });
15+
expect(wrapper.get('set-cookie')).toBe('a=1, b=2');
16+
});
17+
18+
it('returns null when a header is explicitly undefined', () => {
19+
const wrapper = new HeaderWrapper({ 'x-empty': undefined });
20+
expect(wrapper.get('x-empty')).toBeNull();
21+
});
22+
23+
it('iterates keys, values, and entries', () => {
24+
const wrapper = new HeaderWrapper({
25+
'content-type': 'application/json',
26+
'set-cookie': ['a=1', 'b=2'],
27+
});
28+
29+
expect(Array.from(wrapper.keys()).sort()).toEqual(['content-type', 'set-cookie']);
30+
expect(Array.from(wrapper.values()).sort()).toEqual(['a=1, b=2', 'application/json']);
31+
expect(Array.from(wrapper.entries()).sort()).toEqual([
32+
['content-type', 'application/json'],
33+
['set-cookie', 'a=1, b=2'],
34+
]);
35+
});
36+
37+
it('skips undefined headers when iterating values and entries', () => {
38+
const wrapper = new HeaderWrapper({
39+
'content-type': 'application/json',
40+
'x-empty': undefined,
41+
});
42+
43+
expect(Array.from(wrapper.values())).toEqual(['application/json']);
44+
expect(Array.from(wrapper.entries())).toEqual([['content-type', 'application/json']]);
45+
});
46+
47+
it('reports presence with has()', () => {
48+
const wrapper = new HeaderWrapper({ 'content-type': 'application/json' });
49+
expect(wrapper.has('content-type')).toBe(true);
50+
expect(wrapper.has('missing')).toBe(false);
51+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import NodeCrypto from '../../src/platform/NodeCrypto';
2+
3+
it('produces a sha256 digest matching the known fixture', () => {
4+
const crypto = new NodeCrypto();
5+
const hasher = crypto.createHash('sha256');
6+
hasher.update('the quick brown fox');
7+
expect(hasher.digest!('hex')).toBe(
8+
'9ecb36561341d18eb65484e833efea61edc74b84cf5e6ae1b81c63533e25fc8f',
9+
);
10+
});
11+
12+
it('produces a deterministic hmac digest for the same key and message', () => {
13+
const crypto = new NodeCrypto();
14+
const hmacA = crypto.createHmac!('sha256', 'key');
15+
hmacA.update('message');
16+
const hmacB = crypto.createHmac!('sha256', 'key');
17+
hmacB.update('message');
18+
expect(hmacA.digest('hex')).toBe(hmacB.digest('hex'));
19+
});
20+
21+
it('produces different hmac digests for different keys', () => {
22+
const crypto = new NodeCrypto();
23+
const hmacA = crypto.createHmac!('sha256', 'keyA');
24+
hmacA.update('message');
25+
const hmacB = crypto.createHmac!('sha256', 'keyB');
26+
hmacB.update('message');
27+
expect(hmacA.digest('hex')).not.toBe(hmacB.digest('hex'));
28+
});
29+
30+
it('generates a UUID matching the v4 format', () => {
31+
const crypto = new NodeCrypto();
32+
expect(crypto.randomUUID()).toMatch(
33+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
34+
);
35+
});
36+
37+
it('generates distinct UUIDs across calls', () => {
38+
const crypto = new NodeCrypto();
39+
expect(crypto.randomUUID()).not.toBe(crypto.randomUUID());
40+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import NodeEncoding from '../../src/platform/NodeEncoding';
2+
3+
it('can base64 a basic ASCII string', () => {
4+
const encoding = new NodeEncoding();
5+
expect(encoding.btoa('toaster')).toEqual('dG9hc3Rlcg==');
6+
});
7+
8+
it('can base64 a unicode string containing multi-byte characters', () => {
9+
const encoding = new NodeEncoding();
10+
expect(encoding.btoa('✇⽊❽⾵⊚▴ⶊ↺➹≈⋟⚥⤅⊈ⲏⷨ⾭Ⲗ⑲▯ⶋₐℛ⬎⿌🦄')).toEqual(
11+
'4pyH4r2K4p294r614oqa4pa04raK4oa64p654omI4ouf4pql4qSF4oqI4rKP4reo4r6t4rKW4pGy4pav4raL4oKQ4oSb4qyO4r+M8J+mhA==',
12+
);
13+
});
14+
15+
it('returns an empty string when input is empty', () => {
16+
const encoding = new NodeEncoding();
17+
expect(encoding.btoa('')).toEqual('');
18+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as os from 'os';
2+
3+
import NodeInfo from '../../src/platform/NodeInfo';
4+
5+
const realOs = jest.requireActual('os');
6+
7+
jest.mock('os', () => {
8+
const actual = jest.requireActual('os');
9+
return {
10+
...actual,
11+
platform: jest.fn(actual.platform),
12+
release: jest.fn(actual.release),
13+
arch: jest.fn(actual.arch),
14+
};
15+
});
16+
17+
const mockedOs = jest.mocked(os);
18+
19+
afterEach(() => {
20+
mockedOs.platform.mockImplementation(realOs.platform);
21+
mockedOs.release.mockImplementation(realOs.release);
22+
mockedOs.arch.mockImplementation(realOs.arch);
23+
});
24+
25+
it('returns sdk data with the package name, version, and user-agent base', () => {
26+
const info = new NodeInfo();
27+
expect(info.sdkData()).toEqual({
28+
name: 'node-client-sdk',
29+
version: '0.0.1',
30+
userAgentBase: 'NodeClient',
31+
});
32+
});
33+
34+
it('reports the runtime node version under platformData additional', () => {
35+
const info = new NodeInfo();
36+
const data = info.platformData();
37+
expect(data.name).toBe('Node');
38+
expect(data.additional?.nodeVersion).toBe(process.versions.node);
39+
});
40+
41+
it.each([
42+
['darwin', 'MacOS'],
43+
['win32', 'Windows'],
44+
['linux', 'Linux'],
45+
])('maps the %s os platform to %s', (raw, mapped) => {
46+
mockedOs.platform.mockReturnValue(raw as NodeJS.Platform);
47+
mockedOs.release.mockReturnValue('1.2.3');
48+
mockedOs.arch.mockReturnValue('x64');
49+
50+
const info = new NodeInfo();
51+
expect(info.platformData().os).toEqual({ name: mapped, version: '1.2.3', arch: 'x64' });
52+
});
53+
54+
it('passes through an unknown os platform name', () => {
55+
mockedOs.platform.mockReturnValue('freebsd' as NodeJS.Platform);
56+
mockedOs.release.mockReturnValue('14.0');
57+
mockedOs.arch.mockReturnValue('arm64');
58+
59+
const info = new NodeInfo();
60+
expect(info.platformData().os).toEqual({ name: 'freebsd', version: '14.0', arch: 'arm64' });
61+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as fs from 'fs/promises';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
5+
import { createMockLogger } from '../testHelpers';
6+
import { resetNodeStorage } from '../../src/platform/NodeStorage';
7+
import NodePlatform from '../../src/platform/NodePlatform';
8+
9+
let tmpRoot: string;
10+
let logger: ReturnType<typeof createMockLogger>;
11+
12+
beforeEach(async () => {
13+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'node-platform-test-'));
14+
resetNodeStorage();
15+
logger = createMockLogger();
16+
});
17+
18+
afterEach(async () => {
19+
resetNodeStorage();
20+
await fs.rm(tmpRoot, { recursive: true, force: true });
21+
});
22+
23+
it('exposes info, crypto, encoding, storage, and requests', () => {
24+
const platform = new NodePlatform(logger, { localStoragePath: tmpRoot });
25+
expect(platform.info).toBeDefined();
26+
expect(platform.crypto).toBeDefined();
27+
expect(platform.encoding).toBeDefined();
28+
expect(platform.storage).toBeDefined();
29+
expect(platform.requests).toBeDefined();
30+
});
31+
32+
it('round-trips storage values through the file-backed NodeStorage', async () => {
33+
const platform = new NodePlatform(logger, { localStoragePath: tmpRoot });
34+
await platform.storage.set('alpha', 'one');
35+
await expect(platform.storage.get('alpha')).resolves.toBe('one');
36+
await platform.storage.clear('alpha');
37+
await expect(platform.storage.get('alpha')).resolves.toBeNull();
38+
});
39+
40+
it('forwards the logger to NodeStorage so storage failures surface', async () => {
41+
const platform = new NodePlatform(logger, {
42+
localStoragePath: path.join(tmpRoot, 'never-created', '\0bad'),
43+
});
44+
await expect(platform.storage.get('alpha')).resolves.toBeNull();
45+
expect(logger.error).toHaveBeenCalledWith(
46+
expect.stringContaining('Error getting key from storage'),
47+
);
48+
});
49+

0 commit comments

Comments
 (0)