diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9e3f25..16adfd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ on: jobs: from-template: - uses: Unleash/.github/.github/workflows/npm-release.yml@meta/update-npm-release-to-use-github-bot + uses: Unleash/.github/.github/workflows/npm-release.yml@v2.0.0 with: version: ${{ github.event.inputs.version }} tag: ${{ github.event.inputs.tag }} diff --git a/.gitignore b/.gitignore index c274848..fba9326 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .vscode/ coverage/ .idea +.DS_Store diff --git a/package.json b/package.json index 2202bd3..1b27973 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unleash-proxy-client", - "version": "3.7.0", + "version": "3.7.5", "description": "A browser client that can be used together with Unleash Edge or the Unleash Frontend API.", "type": "module", "main": "./build/index.cjs", @@ -46,6 +46,7 @@ "@babel/runtime": "^7.23.1", "@rollup/plugin-commonjs": "^25.0.5", "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.5", "@types/jest": "^29.5.5", diff --git a/rollup.config.mjs b/rollup.config.mjs index 6b176b4..9fb2001 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -3,6 +3,10 @@ import nodeResolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import terser from '@rollup/plugin-terser'; import nodePolyfills from 'rollup-plugin-node-polyfills'; +import replace from '@rollup/plugin-replace'; +import fs from 'fs'; + +const version = JSON.parse(fs.readFileSync('./package.json', 'UTF-8')).version; export default { input: './src/index.ts', @@ -25,6 +29,10 @@ export default { } ], plugins: [ + replace({ + '__VERSION__': version, + preventAssignment: true + }), typescript({ compilerOptions: { lib: ['es5', 'es6', 'dom'], diff --git a/src/index.test.ts b/src/index.test.ts index 8c4ef3c..68a8485 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,4 @@ import { FetchMock } from 'jest-fetch-mock'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const packageJSON = require('../package.json'); import 'jest-localstorage-mock'; import * as data from './test/testdata.json'; import IStorageProvider from './storage-provider'; @@ -66,7 +64,7 @@ test('Should perform an initial fetch as POST', async () => { expect(request.method).toBe('POST'); expect(body.context.appName).toBe('webAsPOST'); expect(request.headers).toMatchObject({ - 'Content-Type': 'application/json', + 'content-type': 'application/json', }); }); @@ -319,6 +317,51 @@ test('Should bootstrap data when bootstrap is provided', async () => { expect(localStorage.getItem(storeKey)).toBe(JSON.stringify(bootstrap)); }); +it('should return correct variant if called asynchronously multiple times', async () => { + const bootstrap = [ + { + name: 'foo', + enabled: true, + variant: { + name: 'A', + enabled: true, + payload: { + type: 'string', + value: 'FOO', + }, + }, + impressionData: false, + }, + ]; + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + refreshInterval: 0, + metricsInterval: 0, + disableRefresh: true, + bootstrapOverride: true, + bootstrap, + createAbortController: () => null, + }; + const client = new UnleashClient(config); + + for (let i = 0; i < 12; i++) { + await true; + + expect(client.getVariant('foo')).toEqual({ + name: 'A', + enabled: true, + feature_enabled: true, + payload: { + type: 'string', + value: 'FOO', + }, + }); + } +}); + test('Should set internal toggle state when bootstrap is set, before client is started', async () => { localStorage.clear(); const storeKey = 'unleash:repository:repo'; @@ -652,7 +695,7 @@ test('Should abort previous request', async () => { client.updateContext({ userId: '456' }); // abort 2 await client.updateContext({ userId: '789' }); - expect(abortSpy).toBeCalledTimes(2); + expect(abortSpy).toHaveBeenCalledTimes(2); abortSpy.mockRestore(); }); @@ -692,7 +735,7 @@ test('Should run without abort controller', async () => { client.updateContext({ userId: '456' }); await client.updateContext({ userId: '789' }); - expect(abortSpy).toBeCalledTimes(0); + expect(abortSpy).toHaveBeenCalledTimes(0); abortSpy.mockRestore(); }); @@ -785,7 +828,7 @@ test('Should include etag in second request', async () => { expect(firstRequest.headers).toMatchObject({}); expect(secondRequest.headers).toMatchObject({ - 'If-None-Match': etag, + 'if-none-match': etag, }); }); @@ -807,7 +850,7 @@ test('Should add clientKey as Authorization header', async () => { const request = getTypeSafeRequest(fetchMock); expect(request.headers).toMatchObject({ - Authorization: 'some123key', + authorization: 'some123key', }); }); @@ -1039,7 +1082,7 @@ test('Updating context should wait on asynchronous start', async () => { userId: '123', }); - expect(fetchMock).toBeCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); }); test('Should not replace sessionId when updating context', async () => { @@ -1256,7 +1299,7 @@ test('Initializing client twice should show a console warning', async () => { await client.start(); await client.start(); // Expect console.error to be called once before start runs. - expect(console.error).toBeCalledTimes(2); + expect(console.error).toHaveBeenCalledTimes(2); }); test('Should pass under custom header clientKey', async () => { @@ -1358,7 +1401,7 @@ test('Should pass custom headers', async () => { }); }); -test('Should add `x-unleash` headers', async () => { +test('Should add unleash identification headers', async () => { fetchMock.mockResponses( [JSON.stringify(data), { status: 200 }], [JSON.stringify(data), { status: 200 }] @@ -1383,13 +1426,14 @@ test('Should add `x-unleash` headers', async () => { /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const expectedHeaders = { - 'x-unleash-sdk': `unleash-js@${packageJSON.version}`, - 'x-unleash-connection-id': expect.stringMatching(uuidFormat), - 'x-unleash-appname': appName, + // will be replaced at build time with the actual version + 'unleash-sdk': 'unleash-client-js:__VERSION__', + 'unleash-connection-id': expect.stringMatching(uuidFormat), + 'unleash-appname': appName, }; const getConnectionId = (request: any) => - request.headers['x-unleash-connection-id']; + request.headers['unleash-connection-id']; expect(featureRequest.headers).toMatchObject(expectedHeaders); expect(metricsRequest.headers).toMatchObject(expectedHeaders); diff --git a/src/index.ts b/src/index.ts index 7e82219..f901a99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import { TinyEmitter } from 'tiny-emitter'; -import { v4 as uuidv4 } from 'uuid'; import Metrics from './metrics'; import type IStorageProvider from './storage-provider'; import InMemoryStorageProvider from './storage-provider-inmemory'; @@ -7,12 +6,10 @@ import LocalStorageProvider from './storage-provider-local'; import EventsHandler from './events-handler'; import { computeContextHashValue, - notNullOrUndefined, + parseHeaders, urlWithContextAsQuery, } from './util'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const packageJSON = require('../package.json'); +import { uuidv4 } from './uuidv4'; const DEFINED_FIELDS = [ 'userId', @@ -398,12 +395,12 @@ export class UnleashClient extends TinyEmitter { const sessionId = await this.resolveSessionId(); this.context = { sessionId, ...this.context }; - this.toggles = (await this.storage.get(storeKey)) || []; + const storedToggles = (await this.storage.get(storeKey)) || []; this.lastRefreshTimestamp = await this.getLastRefreshTimestamp(); if ( this.bootstrap && - (this.bootstrapOverride || this.toggles.length === 0) + (this.bootstrapOverride || storedToggles.length === 0) ) { await this.storage.save(storeKey, this.bootstrap); this.toggles = this.bootstrap; @@ -413,6 +410,8 @@ export class UnleashClient extends TinyEmitter { await this.storeLastRefreshTimestamp(); this.setReady(); + } else { + this.toggles = storedToggles; } this.sdkState = 'healthy'; @@ -472,24 +471,15 @@ export class UnleashClient extends TinyEmitter { } private getHeaders() { - const isPOST = this.usePOSTrequests; - const headers = { - [this.headerName]: this.clientKey, - Accept: 'application/json', - 'x-unleash-sdk': `unleash-js@${packageJSON.version}`, - 'x-unleash-connection-id': this.connectionId, - 'x-unleash-appname': this.context.appName, - }; - if (isPOST) { - headers['Content-Type'] = 'application/json'; - } - if (this.etag) { - headers['If-None-Match'] = this.etag; - } - Object.entries(this.customHeaders) - .filter(notNullOrUndefined) - .forEach(([name, value]) => (headers[name] = value)); - return headers; + return parseHeaders({ + clientKey: this.clientKey, + connectionId: this.connectionId, + appName: this.context.appName, + customHeaders: this.customHeaders, + headerName: this.headerName, + etag: this.etag, + isPost: this.usePOSTrequests, + }); } private async storeToggles(toggles: IToggle[]): Promise { diff --git a/src/metrics.test.ts b/src/metrics.test.ts index a2f9dc6..3702e66 100644 --- a/src/metrics.test.ts +++ b/src/metrics.test.ts @@ -91,7 +91,7 @@ test('should send metrics with custom auth header', async () => { expect(fetchMock.mock.calls.length).toEqual(1); expect(requestBody.headers).toMatchObject({ - NotAuthorization: '123', + notauthorization: '123', }); }); @@ -271,7 +271,7 @@ describe('Custom headers for metrics', () => { test('Custom headers should override preset headers', async () => { const customHeaders = { - Authorization: 'definitely-not-the-client-key', + authorization: 'definitely-not-the-client-key', }; const requestBody = await runMetrics(customHeaders); @@ -303,4 +303,35 @@ describe('Custom headers for metrics', () => { expect(requestBody.headers).not.toMatchObject(customHeaders); } ); + + test('Should use case-insensitive headers', () => { + const metrics = new Metrics({ + onError: console.error, + appName: 'test', + metricsInterval: 5, + disableMetrics: false, + url: 'http://localhost:3000', + clientKey: '123', + fetch: fetchMock, + headerName: 'Authorization', + customHeaders: { + 'Custom-Header': '123', + 'custom-header': '456', + 'unleash-APPname': 'override', + 'unleash-connection-id': 'override', + }, + connectionId: '123', + metricsIntervalInitial: 2, + }); + + metrics.count('foo', true); + metrics.sendMetrics(); + + const requestBody = getTypeSafeRequest(fetchMock); + expect(requestBody.headers).toMatchObject({ + 'custom-header': '456', + 'unleash-appname': 'override', + 'unleash-connection-id': '123', + }); + }); }); diff --git a/src/metrics.ts b/src/metrics.ts index 29e2c9f..01d40d0 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,8 +1,6 @@ // Simplified version of: https://github.com/Unleash/unleash-client-node/blob/main/src/metrics.ts -import { notNullOrUndefined } from './util'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const packageJSON = require('../package.json'); +import { parseHeaders } from './util'; export interface MetricsOptions { onError: OnError; @@ -109,7 +107,7 @@ export default class Metrics { public stop() { if (this.timer) { - clearTimeout(this.timer); + clearInterval(this.timer); delete this.timer; } } @@ -123,20 +121,14 @@ export default class Metrics { } private getHeaders() { - const headers = { - [this.headerName]: this.clientKey, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'x-unleash-sdk': `unleash-js@${packageJSON.version}`, - 'x-unleash-connection-id': this.connectionId, - 'x-unleash-appname': this.appName, - }; - - Object.entries(this.customHeaders) - .filter(notNullOrUndefined) - .forEach(([name, value]) => (headers[name] = value)); - - return headers; + return parseHeaders({ + clientKey: this.clientKey, + appName: this.appName, + connectionId: this.connectionId, + customHeaders: this.customHeaders, + headerName: this.headerName, + isPost: true, + }); } public async sendMetrics(): Promise { diff --git a/src/util.test.ts b/src/util.test.ts index a05fe4f..f846dd3 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -3,6 +3,7 @@ import { computeContextHashValue, contextString, urlWithContextAsQuery, + parseHeaders, } from './util'; test('should not add paramters to URL', async () => { @@ -119,3 +120,119 @@ describe('computeContextHashValue', () => { ); }); }); + +describe('parseHeaders', () => { + const clientKey = 'test-client-key'; + const appName = 'appName'; + const connectionId = '1234-5678'; + + test('should set basic headers correctly', () => { + const result = parseHeaders({ + clientKey, + appName, + connectionId, + }); + + expect(result).toEqual({ + authorization: clientKey, + 'unleash-sdk': 'unleash-client-js:__VERSION__', + 'unleash-appname': appName, + accept: 'application/json', + 'unleash-connection-id': connectionId, + }); + }); + + test('should set custom client key header name', () => { + const result = parseHeaders({ + clientKey, + appName, + connectionId, + headerName: 'auth', + }); + + expect(result).toMatchObject({ auth: clientKey }); + expect(Object.keys(result)).not.toContain('authorization'); + }); + + test('should add custom headers', () => { + const customHeaders = { + 'custom-header': 'custom-value', + 'unleash-connection-id': 'should-not-be-overwritten', + 'unleash-appname': 'new-app-name', + }; + const result = parseHeaders({ + clientKey, + appName, + connectionId, + customHeaders, + }); + + expect(Object.keys(result)).toHaveLength(6); + expect(result).toMatchObject({ + 'custom-header': 'custom-value', + 'unleash-connection-id': connectionId, + 'unleash-appname': 'new-app-name', + }); + }); + + test('should include etag if provided', () => { + const etag = '12345'; + const result = parseHeaders({ + clientKey, + appName, + connectionId, + etag, + }); + + expect(result['if-none-match']).toBe(etag); + }); + + test('should handle custom headers in a case-insensitive way and normalize them', () => { + const customHeaders = { + 'custom-HEADER': 'custom-value-1', + 'Custom-Header': 'custom-value-2', + }; + const result = parseHeaders({ + clientKey, + appName, + connectionId, + customHeaders, + }); + + expect(result).toMatchObject({ + 'custom-header': 'custom-value-2', + }); + }); + + test('should ignore null or undefined custom headers', () => { + const customHeaders = { + header1: 'value1', + header2: null as any, + header3: undefined as any, + }; + const result = parseHeaders({ + clientKey, + appName, + connectionId, + customHeaders, + }); + + expect(result).toEqual( + expect.not.objectContaining({ + header2: expect.anything(), + header3: expect.anything(), + }) + ); + }); + + test('should include content-type for POST requests', () => { + const result = parseHeaders({ + clientKey, + appName, + connectionId, + isPost: true, + }); + + expect(result['content-type']).toBe('application/json'); + }); +}); diff --git a/src/util.ts b/src/util.ts index 7a6c35b..3839d66 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,5 @@ import { IContext } from '.'; +import { sdkVersion } from './version'; export const notNullOrUndefined = ([, value]: [string, string]) => value !== undefined && value !== null; @@ -70,3 +71,46 @@ export const computeContextHashValue = async (obj: IContext) => { return value; } }; + +export const parseHeaders = ({ + clientKey, + appName, + connectionId, + customHeaders, + headerName = 'authorization', + etag, + isPost, +}: { + clientKey: string; + connectionId: string; + appName: string; + customHeaders?: Record; + headerName?: string; + etag?: string; + isPost?: boolean; +}): Record => { + const headers: Record = { + accept: 'application/json', + [headerName.toLocaleLowerCase()]: clientKey, + 'unleash-sdk': sdkVersion, + 'unleash-appname': appName, + }; + + if (isPost) { + headers['content-type'] = 'application/json'; + } + + if (etag) { + headers['if-none-match'] = etag; + } + + Object.entries(customHeaders || {}) + .filter(notNullOrUndefined) + .forEach( + ([name, value]) => (headers[name.toLocaleLowerCase()] = value) + ); + + headers['unleash-connection-id'] = connectionId; + + return headers; +}; diff --git a/src/uuidv4.ts b/src/uuidv4.ts new file mode 100644 index 0000000..7929359 --- /dev/null +++ b/src/uuidv4.ts @@ -0,0 +1,14 @@ +/** + * This function generates a UUID using Math.random(). + * The distribution of unique values is not guaranteed to be as robust + * as with a crypto module but works across all platforms (Node, React Native, browser JS). + * + * We use it for connection id generation which is not critical for security. + */ +export const uuidv4 = (): string => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..de28b7c --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const sdkVersion = `unleash-client-js:__VERSION__`; diff --git a/yarn.lock b/yarn.lock index cf404e4..7dce0f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,6 +642,14 @@ is-module "^1.0.0" resolve "^1.22.1" +"@rollup/plugin-replace@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz#2f565d312d681e4570ff376c55c5c08eb6f1908d" + integrity sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ== + dependencies: + "@rollup/pluginutils" "^5.0.1" + magic-string "^0.30.3" + "@rollup/plugin-terser@^0.4.4": version "0.4.4" resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962" @@ -2977,9 +2985,9 @@ rollup-pluginutils@^2.8.1: estree-walker "^0.6.1" rollup@^3.29.4: - version "3.29.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" - integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== + version "3.29.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" + integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== optionalDependencies: fsevents "~2.3.2"