Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0048dc6
feat: replace make-fetch-happen with ky and refresh tests/deps
gastonfournier Nov 26, 2025
7e5149b
Revert unnecessary changes
gastonfournier Nov 26, 2025
09c2cf5
chore: break support with node 16 and include node LTS: 24
gastonfournier Nov 26, 2025
4b527b2
feat: replace make-fetch-happen with ky and refresh tests/deps
gastonfournier Nov 26, 2025
dd74dcb
Revert unnecessary changes
gastonfournier Nov 26, 2025
15b82cd
fix: missing linting after rebasing from main
chriswk Dec 1, 2025
cb6875f
Merged from main
gastonfournier Dec 4, 2025
6ffc01c
Fixes after merge
gastonfournier Dec 4, 2025
b5882b0
Keep node 18
gastonfournier Dec 4, 2025
58eea3c
Revert "Keep node 18"
gastonfournier Dec 4, 2025
7b59ffe
Test fix
gastonfournier Dec 4, 2025
3ff32ad
Some improvements suggested by copilot
gastonfournier Dec 4, 2025
f2864c6
Remove behavior test
gastonfournier Dec 4, 2025
f102c22
fix: keep per-request TLS/proxy handling with ky on Node fetch
gastonfournier Dec 4, 2025
4a71bc6
Refactor types
gastonfournier Dec 5, 2025
b21b89a
Refactor to reduce verbosity and param handling
gastonfournier Dec 5, 2025
cd8d6e3
Standardize headers param
gastonfournier Dec 5, 2025
c7371ae
Set max retries configurable to use it in tests
gastonfournier Dec 5, 2025
ddb41ac
Use dispatcher instead of agent
gastonfournier Dec 5, 2025
2c2f517
Attempt to fix tests by avoiding override retries
gastonfournier Dec 5, 2025
6ce22fe
Keep CommonJS compatibility
gastonfournier Dec 9, 2025
6d06858
WIP
gastonfournier Dec 10, 2025
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
8 changes: 3 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@
},
"homepage": "https://github.com/Unleash/unleash-node-sdk",
"dependencies": {
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"ip-address": "^9.0.5",
"ky": "^1.14.0",
"launchdarkly-eventsource": "2.2.0",
"make-fetch-happen": "^13.0.1",
"murmurhash3js": "^3.0.1",
"proxy-from-env": "^1.1.0",
"undici": "^6.21.0",
"semver": "^7.7.3"
},
"engines": {
Expand All @@ -49,10 +48,9 @@
],
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@tsconfig/node18": "^18.2.6",
"@tsconfig/node20": "^20.1.3",
"@types/express": "^4.17.25",
"@types/jsbn": "^1.2.33",
"@types/make-fetch-happen": "^10.0.4",
"@types/murmurhash3js": "^3.0.7",
"@types/nock": "^11.1.0",
"@types/node": "^20.17.17",
Expand Down
38 changes: 38 additions & 0 deletions src/client-spec-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import semver from 'semver';

const packageJsonPath = join(__dirname, '..', 'package.json');

const resolveSpecVersion = (): string | undefined => {
try {
const raw = readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};

const specDependencyVersion =
packageJson.dependencies?.['@unleash/client-specification'] ??
packageJson.devDependencies?.['@unleash/client-specification'];

if (!specDependencyVersion) {
return undefined;
}

if (semver.valid(specDependencyVersion)) {
return specDependencyVersion;
}

if (semver.validRange(specDependencyVersion)) {
return semver.minVersion(specDependencyVersion)?.version;
}

return semver.coerce(specDependencyVersion)?.version;
} catch (_err: unknown) {
// Ignore filesystem/parse errors and fall back to undefined.
return undefined;
}
};

export const supportedClientSpecVersion = resolveSpecVersion();
5 changes: 3 additions & 2 deletions src/http-options.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Agent } from 'node:http';
import type { URL } from 'node:url';
import type { Dispatcher } from 'undici';

export interface HttpOptions {
agent?: (url: URL) => Agent;
dispatcher?: (url: URL) => Dispatcher; // this is a breaking change from 'agent'. Ref: https://github.com/Unleash/unleash-node-sdk/pull/332
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking change documentation: While the inline comment mentions this is a breaking change and references PR #332, consider documenting this breaking change more prominently in the PR description or changelog. The change from agent (which used Node.js http/https Agent) to dispatcher (which uses undici Dispatcher) requires users to update their code.

Additionally, consider adding a deprecation period or providing a migration guide for users who were using the agent option.

Suggested change
dispatcher?: (url: URL) => Dispatcher; // this is a breaking change from 'agent'. Ref: https://github.com/Unleash/unleash-node-sdk/pull/332
/**
* @deprecated Use `dispatcher` instead. The `agent` option (Node.js http/https Agent) has been replaced by `dispatcher` (undici Dispatcher).
* See migration guide: https://github.com/Unleash/unleash-node-sdk/pull/332
*/
agent?: any;
/**
* Custom dispatcher for HTTP requests.
* This replaces the deprecated `agent` option.
* See migration guide: https://github.com/Unleash/unleash-node-sdk/pull/332
*/
dispatcher?: (url: URL) => Dispatcher;

Copilot uses AI. Check for mistakes.
rejectUnauthorized?: boolean;
maxRetries?: number;
}
33 changes: 14 additions & 19 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getAppliedJitter } from './helpers';
import type { HttpOptions } from './http-options';
import type { CollectedMetric, ImpactMetricsDataSource } from './impact-metrics/metric-types';
import { SUPPORTED_SPEC_VERSION } from './repository';
import { post } from './request';
import { createHttpClient, type HttpClient } from './request';
import { resolveUrl, suffixSlash } from './url-utils';

export interface MetricsOptions {
Expand Down Expand Up @@ -109,14 +109,12 @@ export default class Metrics extends EventEmitter {

private customHeadersFunction?: CustomHeadersFunction;

private timeout?: number;

private httpOptions?: HttpOptions;

private platformData: PlatformData;

private metricRegistry?: ImpactMetricsDataSource;

private httpClientPromise: Promise<HttpClient>;

constructor({
appName,
instanceId,
Expand Down Expand Up @@ -145,11 +143,16 @@ export default class Metrics extends EventEmitter {
this.headers = headers;
this.customHeadersFunction = customHeadersFunction;
this.started = new Date();
this.timeout = timeout;
this.bucket = this.createBucket();
this.httpOptions = httpOptions;
this.platformData = this.getPlatformData();
this.metricRegistry = metricRegistry;
this.httpClientPromise = createHttpClient({
appName,
instanceId,
connectionId,
timeout,
httpOptions,
Comment on lines +150 to +154
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turned this into an immutable configuration per client. Maybe we should still enable different timeouts per request, and perhaps some httpOptions, but I also don't think we need them

});
}

private getAppliedJitter(): number {
Expand Down Expand Up @@ -206,15 +209,11 @@ export default class Metrics extends EventEmitter {
const headers = this.customHeadersFunction ? await this.customHeadersFunction() : this.headers;

try {
const res = await post({
const httpClient = await this.httpClientPromise;
const res = await httpClient.post({
url,
json: payload as unknown as Record<string, unknown>,
appName: this.appName,
instanceId: this.instanceId,
connectionId: this.connectionId,
headers,
timeout: this.timeout,
httpOptions: this.httpOptions,
});
if (!res.ok) {
// status code outside 200 range
Expand Down Expand Up @@ -261,16 +260,12 @@ export default class Metrics extends EventEmitter {
const headers = this.customHeadersFunction ? await this.customHeadersFunction() : this.headers;

try {
const res = await post({
const httpClient = await this.httpClientPromise;
const res = await httpClient.post({
url,
json: payload as unknown as Record<string, unknown>,
appName: this.appName,
instanceId: this.instanceId,
connectionId: this.connectionId,
interval: this.metricsInterval,
headers,
timeout: this.timeout,
httpOptions: this.httpOptions,
});
if (!res.ok) {
if (res.status === 403 || res.status === 401) {
Expand Down
38 changes: 17 additions & 21 deletions src/repository/bootstrap-provider.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { promises } from 'node:fs';
import fetch from 'make-fetch-happen';
import type { ClientFeaturesResponse, FeatureInterface } from '../feature';
import type { CustomHeaders } from '../headers';
import { buildHeaders } from '../request';
import { createHttpClient } from '../request';
import type { Segment } from '../strategy/strategy';

export interface BootstrapProvider {
Expand All @@ -29,11 +28,12 @@ export class DefaultBootstrapProvider implements BootstrapProvider {

private segments?: Segment[];

private appName: string;

private instanceId: string;

constructor(options: BootstrapOptions, appName: string, instanceId: string) {
constructor(
options: BootstrapOptions,
readonly appName: string,
readonly instanceId: string,
readonly connectionId: string,
) {
this.url = options.url;
this.urlHeaders = options.urlHeaders;
this.filePath = options.filePath;
Expand All @@ -45,21 +45,13 @@ export class DefaultBootstrapProvider implements BootstrapProvider {
}

private async loadFromUrl(bootstrapUrl: string): Promise<ClientFeaturesResponse | undefined> {
const response = await fetch(bootstrapUrl, {
method: 'GET',
const httpClient = await createHttpClient({
appName: this.appName,
instanceId: this.instanceId,
connectionId: this.connectionId,
timeout: 10_000,
headers: buildHeaders({
appName: this.appName,
instanceId: this.instanceId,
etag: undefined,
contentType: undefined,
custom: this.urlHeaders,
}),
retry: {
retries: 2,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 is default retry

maxTimeout: 10_000,
},
});
const response = await httpClient.get({ url: bootstrapUrl, headers: this.urlHeaders });
if (response.ok) {
return response.json();
}
Expand Down Expand Up @@ -90,6 +82,10 @@ export function resolveBootstrapProvider(
options: BootstrapOptions,
appName: string,
instanceId: string,
connectionId: string,
): BootstrapProvider {
return options.bootstrapProvider || new DefaultBootstrapProvider(options, appName, instanceId);
return (
options.bootstrapProvider ||
new DefaultBootstrapProvider(options, appName, instanceId, connectionId)
);
}
14 changes: 3 additions & 11 deletions src/repository/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { EventEmitter } from 'node:events';
import type { ApiResponse } from '../feature';
import type { CustomHeaders, CustomHeadersFunction } from '../headers';
import type { HttpOptions } from '../http-options';
import type { CustomHeadersFunction } from '../headers';
import type { GetRequestOptions, SDKData } from '../request';
import type { TagFilter } from '../tags';
import type { Mode } from '../unleash-config';

Expand All @@ -12,12 +12,7 @@ export interface FetcherInterface extends EventEmitter {

export interface FetchingOptions extends PollingFetchingOptions, StreamingFetchingOptions {}

export interface CommonFetchingOptions {
url: string;
appName: string;
instanceId: string;
headers?: CustomHeaders;
connectionId: string;
export interface CommonFetchingOptions extends GetRequestOptions, SDKData {
onSave: (response: ApiResponse, fromApi: boolean) => Promise<void>;
onModeChange?: (mode: Mode['type']) => Promise<void>;
}
Expand All @@ -29,9 +24,6 @@ export interface PollingFetchingOptions extends CommonFetchingOptions {
mode: Mode;
namePrefix?: string;
projectName?: string;
etag?: string;
timeout?: number;
httpOptions?: HttpOptions;
}

export interface StreamingFetchingOptions extends CommonFetchingOptions {
Expand Down
20 changes: 12 additions & 8 deletions src/repository/polling-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events';
import { UnleashEvents } from '../events';
import { parseApiResponse } from '../feature';
import { get } from '../request';
import { createHttpClient, type HttpClient } from '../request';
import type { TagFilter } from '../tags';
import getUrl from '../url-utils';
import type { FetcherInterface, PollingFetchingOptions } from './fetcher';
Expand All @@ -17,10 +17,19 @@ export class PollingFetcher extends EventEmitter implements FetcherInterface {

private options: PollingFetchingOptions;

private httpClientPromise: Promise<HttpClient>;

constructor(options: PollingFetchingOptions) {
super();
this.options = options;
this.etag = options.etag;
this.httpClientPromise = createHttpClient({
appName: options.appName,
instanceId: options.instanceId,
connectionId: options.connectionId,
timeout: options.timeout,
httpOptions: options.httpOptions,
});
}

timedFetch(interval: number) {
Expand Down Expand Up @@ -133,17 +142,12 @@ export class PollingFetcher extends EventEmitter implements FetcherInterface {
const headers = this.options.customHeadersFunction
? await this.options.customHeadersFunction()
: this.options.headers;
const res = await get({
const httpClient = await this.httpClientPromise;
const res = await httpClient.get({
url,
etag: this.etag,
appName: this.options.appName,
timeout: this.options.timeout,
instanceId: this.options.instanceId,
connectionId: this.options.connectionId,
interval: this.options.refreshInterval,
headers,
httpOptions: this.options.httpOptions,
supportedSpecVersion: '5.2.0',
});
if (res.status === 304) {
this.emit(UnleashEvents.Unchanged);
Expand Down
7 changes: 3 additions & 4 deletions src/repository/streaming-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class StreamingFetcher extends EventEmitter implements FetcherInterface {

private readonly headers?: Record<string, string>;

private readonly connectionId?: string;
private readonly connectionId: string;

private readonly onSave: StreamingFetchingOptions['onSave'];

Expand All @@ -30,8 +30,8 @@ export class StreamingFetcher extends EventEmitter implements FetcherInterface {
url,
appName,
instanceId,
headers,
connectionId,
headers,
eventSource,
maxFailuresUntilFailover = 5,
failureWindowMs = 60_000,
Expand Down Expand Up @@ -178,8 +178,7 @@ export class StreamingFetcher extends EventEmitter implements FetcherInterface {
instanceId: this.instanceId,
etag: undefined,
contentType: undefined,
custom: this.headers,
specVersionSupported: '5.2.0',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was always out of sync so I added client-spec-version.ts helper (please review it)

headers: this.headers,
connectionId: this.connectionId,
}),
readTimeoutMillis: 60000,
Expand Down
Loading