Skip to content

Commit 1e90013

Browse files
Merge pull request #336 from splitio/enhanced_sdk_headers
[Enhanced SDK headers] Implementation
2 parents c08d5a8 + abeb377 commit 1e90013

File tree

10 files changed

+316
-168
lines changed

10 files changed

+316
-168
lines changed

CHANGES.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
1.17.0 (September XX, 2024)
1+
1.17.0 (September 6, 2024)
2+
- Added `sync.requestOptions.getHeaderOverrides` configuration option to enhance SDK HTTP request Headers for Authorization Frameworks.
23
- Added `isTimedout` and `lastUpdate` properties to IStatusInterface to keep track of the timestamp of the last SDK event, used on React and Redux SDKs.
34
- Updated some transitive dependencies for vulnerability fixes.
45

package-lock.json

Lines changed: 159 additions & 127 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "1.16.1",
3+
"version": "1.17.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ISettings } from '../../types';
2+
import { decorateHeaders } from '../decorateHeaders';
3+
4+
const HEADERS = {
5+
Authorization: 'Bearer SDK-KEY',
6+
SplitSDKVersion: 'JS' // Overriding is forbidden
7+
};
8+
9+
describe('decorateHeaders', () => {
10+
11+
test('should not decorate headers if getHeaderOverrides is not provided', () => {
12+
const headers = { ...HEADERS };
13+
const settings = { sync: {} };
14+
15+
expect(decorateHeaders(settings as unknown as ISettings, headers)).toEqual(HEADERS);
16+
});
17+
18+
test('should decorate headers with header overrides, ignoring forbidden headers', () => {
19+
const headers = { ...HEADERS };
20+
const settings = {
21+
sync: {
22+
requestOptions: {
23+
getHeaderOverrides: (context: { headers: Record<string, string> }) => {
24+
context.headers['Authorization'] = 'ignored';
25+
return { 'Authorization': 'updated', 'OTHER_HEADER': 'other_value', 'SplitSdkVersion': 'FORBIDDEN', 'splitsdkversion': 'FORBIDDEN TOO' };
26+
}
27+
}
28+
}
29+
};
30+
31+
expect(decorateHeaders(settings as unknown as ISettings, headers)).toEqual({ 'Authorization': 'updated', 'OTHER_HEADER': 'other_value', 'SplitSDKVersion': 'JS' });
32+
});
33+
34+
test('should handle errors when decorating headers', () => {
35+
const headers = { ...HEADERS };
36+
const settings = {
37+
sync: {
38+
requestOptions: {
39+
getHeaderOverrides: () => {
40+
throw new Error('Unexpected error');
41+
}
42+
}
43+
},
44+
log: { error: jest.fn() }
45+
};
46+
47+
expect(decorateHeaders(settings as unknown as ISettings, headers)).toEqual(HEADERS);
48+
expect(settings.log.error).toHaveBeenCalledWith('Problem adding custom headers to request decorator: Error: Unexpected error');
49+
});
50+
});

src/services/decorateHeaders.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { objectAssign } from '../utils/lang/objectAssign';
2+
import { _Set } from '../utils/lang/sets';
3+
import { ISettings } from '../types';
4+
5+
const FORBIDDEN_HEADERS = new _Set([
6+
'splitsdkclientkey',
7+
'splitsdkversion',
8+
'splitsdkmachineip',
9+
'splitsdkmachinename',
10+
'splitsdkimpressionsmode',
11+
'host',
12+
'referrer',
13+
'content-type',
14+
'content-length',
15+
'content-encoding',
16+
'accept',
17+
'keep-alive',
18+
'x-fastly-debug'
19+
]);
20+
21+
export function decorateHeaders(settings: ISettings, headers: Record<string, string>) {
22+
if (settings.sync.requestOptions?.getHeaderOverrides) {
23+
try {
24+
const headerOverrides = settings.sync.requestOptions.getHeaderOverrides({ headers: objectAssign({}, headers) });
25+
Object.keys(headerOverrides)
26+
.filter(key => !FORBIDDEN_HEADERS.has(key.toLowerCase()))
27+
.forEach(key => headers[key] = headerOverrides[key]);
28+
} catch (e) {
29+
settings.log.error('Problem adding custom headers to request decorator: ' + e);
30+
}
31+
}
32+
return headers;
33+
}

src/services/splitHttpClient.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { objectAssign } from '../utils/lang/objectAssign';
33
import { ERROR_HTTP, ERROR_CLIENT_CANNOT_GET_READY } from '../logger/constants';
44
import { ISettings } from '../types';
55
import { IPlatform } from '../sdkFactory/types';
6+
import { decorateHeaders } from './decorateHeaders';
67

78
const messageNoFetch = 'Global fetch API is not available.';
89

@@ -21,20 +22,20 @@ export function splitHttpClientFactory(settings: ISettings, { getOptions, getFet
2122
// if fetch is not available, log Error
2223
if (!fetch) log.error(ERROR_CLIENT_CANNOT_GET_READY, [messageNoFetch]);
2324

24-
const headers: Record<string, string> = {
25+
const commonHeaders: Record<string, string> = {
2526
'Accept': 'application/json',
2627
'Content-Type': 'application/json',
2728
'Authorization': `Bearer ${authorizationKey}`,
2829
'SplitSDKVersion': version
2930
};
3031

31-
if (ip) headers['SplitSDKMachineIP'] = ip;
32-
if (hostname) headers['SplitSDKMachineName'] = hostname;
32+
if (ip) commonHeaders['SplitSDKMachineIP'] = ip;
33+
if (hostname) commonHeaders['SplitSDKMachineName'] = hostname;
3334

3435
return function httpClient(url: string, reqOpts: IRequestOptions = {}, latencyTracker: (error?: NetworkError) => void = () => { }, logErrorsAsInfo: boolean = false): Promise<IResponse> {
3536

3637
const request = objectAssign({
37-
headers: reqOpts.headers ? objectAssign({}, headers, reqOpts.headers) : headers,
38+
headers: decorateHeaders(settings, objectAssign({}, commonHeaders, reqOpts.headers || {})),
3839
method: reqOpts.method || 'GET',
3940
body: reqOpts.body
4041
}, options);

src/sync/streaming/SSEClient/__tests__/index.spec.ts

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @ts-nocheck
22
import EventSourceMock from '../../../../__tests__/testUtils/eventSourceMock';
33
import { authDataSample, channelsQueryParamSample } from '../../__tests__/dataMocks';
4-
import { fullSettings as settings } from '../../../../utils/settingsValidation/__tests__/settings.mocks';
4+
import { fullSettings as settings, fullSettingsServerSide as settingsServerSide } from '../../../../utils/settingsValidation/__tests__/settings.mocks';
55
import { url } from '../../../../utils/settingsValidation/url';
66

77
import { SSEClient } from '../index';
@@ -18,12 +18,12 @@ const EXPECTED_HEADERS = {
1818

1919
test('SSClient / instance creation throws error if EventSource is not provided', () => {
2020
expect(() => { new SSEClient(settings); }).toThrow(Error);
21-
expect(() => { new SSEClient(settings, false, {}); }).toThrow(Error);
22-
expect(() => { new SSEClient(settings, false, { getEventSource: () => undefined }); }).toThrow(Error);
21+
expect(() => { new SSEClient(settings, {}); }).toThrow(Error);
22+
expect(() => { new SSEClient(settings, { getEventSource: () => undefined }); }).toThrow(Error);
2323
});
2424

2525
test('SSClient / instance creation success if EventSource is provided', () => {
26-
const instance = new SSEClient(settings, false, { getEventSource: () => EventSourceMock });
26+
const instance = new SSEClient(settings, { getEventSource: () => EventSourceMock });
2727
expect(instance.eventSource).toBe(EventSourceMock);
2828
});
2929

@@ -36,7 +36,7 @@ test('SSClient / setEventHandler, open and close methods', () => {
3636
};
3737

3838
// instance SSEClient
39-
const instance = new SSEClient(settings, false, { getEventSource: () => EventSourceMock });
39+
const instance = new SSEClient(settings, { getEventSource: () => EventSourceMock });
4040
instance.setEventHandler(handler);
4141

4242
// open connection
@@ -80,9 +80,9 @@ test('SSClient / setEventHandler, open and close methods', () => {
8080

8181
});
8282

83-
test('SSClient / open method: URL with metadata query params', () => {
83+
test('SSClient / open method on client-side: metadata as query params', () => {
8484

85-
const instance = new SSEClient(settings, false, { getEventSource: () => EventSourceMock });
85+
const instance = new SSEClient(settings, { getEventSource: () => EventSourceMock });
8686
instance.open(authDataSample);
8787

8888
const EXPECTED_BROWSER_URL = EXPECTED_URL + `&SplitSDKVersion=${settings.version}&SplitSDKClientKey=${EXPECTED_HEADERS.SplitSDKClientKey}`;
@@ -91,16 +91,25 @@ test('SSClient / open method: URL with metadata query params', () => {
9191
expect(instance.connection.__eventSourceInitDict).toEqual({}); // No headers are passed for streaming connection
9292
});
9393

94-
test('SSClient / open method: URL and metadata headers with IP and Hostname', () => {
94+
test('SSClient / open method on server-side: metadata as headers', () => {
95+
96+
const instance = new SSEClient(settingsServerSide, { getEventSource: () => EventSourceMock });
97+
instance.open(authDataSample);
98+
99+
expect(instance.connection.url).toBe(EXPECTED_URL); // URL is properly set for streaming connection
100+
expect(instance.connection.__eventSourceInitDict).toEqual({ headers: EXPECTED_HEADERS }); // Headers are properly set for streaming connection
101+
});
102+
103+
test('SSClient / open method on server-side: metadata with IP and Hostname as headers', () => {
95104

96105
const settingsWithRuntime = {
97-
...settings,
106+
...settingsServerSide,
98107
runtime: {
99108
ip: 'some ip',
100109
hostname: 'some hostname'
101110
}
102111
};
103-
const instance = new SSEClient(settingsWithRuntime, true, { getEventSource: () => EventSourceMock });
112+
const instance = new SSEClient(settingsWithRuntime, { getEventSource: () => EventSourceMock });
104113
instance.open(authDataSample);
105114

106115
expect(instance.connection.url).toBe(EXPECTED_URL); // URL is properly set for streaming connection
@@ -113,25 +122,44 @@ test('SSClient / open method: URL and metadata headers with IP and Hostname', ()
113122
}); // Headers are properly set for streaming connection
114123
});
115124

116-
test('SSClient / open method: URL and metadata headers without IP and Hostname', () => {
125+
test('SSClient / open method on server-side: metadata as headers and options', () => {
126+
const platform = { getEventSource: jest.fn(() => EventSourceMock), getOptions: jest.fn(() => ({ withCredentials: true })) };
117127

118-
const instance = new SSEClient(settings, true, { getEventSource: () => EventSourceMock });
128+
const instance = new SSEClient(settingsServerSide, platform);
119129
instance.open(authDataSample);
120130

121131
expect(instance.connection.url).toBe(EXPECTED_URL); // URL is properly set for streaming connection
122-
expect(instance.connection.__eventSourceInitDict).toEqual({ headers: EXPECTED_HEADERS }); // Headers are properly set for streaming connection
123-
});
132+
expect(instance.connection.__eventSourceInitDict).toEqual({ headers: EXPECTED_HEADERS, withCredentials: true }); // Headers and options are properly set for streaming connection
124133

125-
test('SSClient / open method: URL, metadata headers and options', () => {
126-
const platform = { getEventSource: jest.fn(() => EventSourceMock), getOptions: jest.fn(() => ({ withCredentials: true })) };
134+
// Assert that getEventSource and getOptions were called once with settings
135+
expect(platform.getEventSource.mock.calls).toEqual([[settingsServerSide]]);
136+
expect(platform.getOptions.mock.calls).toEqual([[settingsServerSide]]);
137+
});
127138

128-
const instance = new SSEClient(settings, true, platform);
139+
test('SSClient / open method with getHeaderOverrides: custom headers', () => {
140+
const settingsWithGetHeaderOverrides = {
141+
...settings,
142+
sync: {
143+
requestOptions: {
144+
getHeaderOverrides: (context) => {
145+
expect(context).toEqual({ headers: EXPECTED_HEADERS });
146+
context.headers['otherheader'] = 'customvalue';
147+
return {
148+
SplitSDKClientKey: '4321', // will not be overridden
149+
CustomHeader: 'custom-value'
150+
};
151+
}
152+
}
153+
}
154+
};
155+
const instance = new SSEClient(settingsWithGetHeaderOverrides, { getEventSource: () => EventSourceMock });
129156
instance.open(authDataSample);
130157

131158
expect(instance.connection.url).toBe(EXPECTED_URL); // URL is properly set for streaming connection
132-
expect(instance.connection.__eventSourceInitDict).toEqual({ headers: EXPECTED_HEADERS, withCredentials: true }); // Headers and options are properly set for streaming connection
133-
134-
// Assert that getEventSource and getOptions were called once with settings
135-
expect(platform.getEventSource.mock.calls).toEqual([[settings]]);
136-
expect(platform.getOptions.mock.calls).toEqual([[settings]]);
159+
expect(instance.connection.__eventSourceInitDict).toEqual({
160+
headers: {
161+
...EXPECTED_HEADERS,
162+
CustomHeader: 'custom-value'
163+
}
164+
}); // Headers are properly set for streaming connection
137165
});

src/sync/streaming/SSEClient/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IPlatform } from '../../../sdkFactory/types';
2+
import { decorateHeaders } from '../../../services/decorateHeaders';
23
import { IEventSourceConstructor } from '../../../services/types';
34
import { ISettings } from '../../../types';
45
import { isString } from '../../../utils/lang';
@@ -36,29 +37,23 @@ function buildSSEHeaders(settings: ISettings) {
3637
export class SSEClient implements ISSEClient {
3738
// Instance properties:
3839
eventSource?: IEventSourceConstructor;
39-
streamingUrl: string;
4040
connection?: InstanceType<IEventSourceConstructor>;
4141
handler?: ISseEventHandler;
42-
useHeaders?: boolean;
4342
headers: Record<string, string>;
4443
options?: object;
4544

4645
/**
4746
* SSEClient constructor.
4847
*
4948
* @param settings Validated settings.
50-
* @param useHeaders True to send metadata as headers or false to send as query params. If `true`, the provided EventSource must support headers.
5149
* @param platform object containing environment-specific dependencies
5250
* @throws 'EventSource API is not available.' if EventSource is not available.
5351
*/
54-
constructor(settings: ISettings, useHeaders: boolean, { getEventSource, getOptions }: IPlatform) {
52+
constructor(private settings: ISettings, { getEventSource, getOptions }: IPlatform) {
5553
this.eventSource = getEventSource && getEventSource(settings);
5654
// if eventSource is not available, throw an exception
5755
if (!this.eventSource) throw new Error('EventSource API is not available.');
5856

59-
this.streamingUrl = settings.urls.streaming + '/sse';
60-
// @TODO get `useHeaders` flag from `getEventSource`, to use EventSource headers on client-side SDKs when possible.
61-
this.useHeaders = useHeaders;
6257
this.headers = buildSSEHeaders(settings);
6358
this.options = getOptions && getOptions(settings);
6459
}
@@ -82,14 +77,16 @@ export class SSEClient implements ISSEClient {
8277
return encodeURIComponent(params + channel);
8378
}
8479
).join(',');
85-
const url = `${this.streamingUrl}?channels=${channelsQueryParam}&accessToken=${authToken.token}&v=${ABLY_API_VERSION}&heartbeats=true`; // same results using `&heartbeats=false`
80+
const url = `${this.settings.urls.streaming}/sse?channels=${channelsQueryParam}&accessToken=${authToken.token}&v=${ABLY_API_VERSION}&heartbeats=true`; // same results using `&heartbeats=false`
81+
// use headers in server-side or if getHeaderOverrides is defined
82+
const useHeaders = !this.settings.core.key || this.settings.sync.requestOptions?.getHeaderOverrides;
8683

8784
this.connection = new this.eventSource!(
8885
// For client-side SDKs, SplitSDKClientKey and SplitSDKClientKey metadata is passed as query params,
8986
// because native EventSource implementations for browser doesn't support headers.
90-
this.useHeaders ? url : url + `&SplitSDKVersion=${this.headers.SplitSDKVersion}&SplitSDKClientKey=${this.headers.SplitSDKClientKey}`,
87+
useHeaders ? url : url + `&SplitSDKVersion=${this.headers.SplitSDKVersion}&SplitSDKClientKey=${this.headers.SplitSDKClientKey}`,
9188
// For server-side SDKs, metadata is passed via headers. EventSource must support headers, like 'eventsource' package for Node.
92-
objectAssign(this.useHeaders ? { headers: this.headers } : {}, this.options)
89+
objectAssign(useHeaders ? { headers: decorateHeaders(this.settings, this.headers) } : {}, this.options)
9390
);
9491

9592
if (this.handler) { // no need to check if SSEClient is used only by PushManager

src/sync/streaming/pushManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function pushManagerFactory(
4242
let sseClient: ISSEClient;
4343
try {
4444
// `useHeaders` false for client-side, even if the platform EventSource supports headers (e.g., React Native).
45-
sseClient = new SSEClient(settings, userKey ? false : true, platform);
45+
sseClient = new SSEClient(settings, platform);
4646
} catch (e) {
4747
log.warn(STREAMING_FALLBACK, [e]);
4848
return;

src/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ export interface ISettings {
119119
__splitFiltersValidation: ISplitFiltersValidation,
120120
localhostMode?: SplitIO.LocalhostFactory,
121121
enabled: boolean,
122-
flagSpecVersion: string
122+
flagSpecVersion: string,
123+
requestOptions?: {
124+
getHeaderOverrides?: (context: { headers: Record<string, string> }) => Record<string, string>
125+
}
123126
},
124127
readonly runtime: {
125128
ip: string | false
@@ -218,7 +221,10 @@ interface ISharedSettings {
218221
* Enables synchronization.
219222
* @property {boolean} enabled
220223
*/
221-
enabled: boolean
224+
enabled?: boolean,
225+
requestOptions?: {
226+
getHeaderOverrides?: (context: { headers: Record<string, string> }) => Record<string, string>
227+
},
222228
}
223229
}
224230
/**

0 commit comments

Comments
 (0)