Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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