Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement support for browser requests. #578

Merged
merged 21 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 87 additions & 0 deletions packages/sdk/browser/__tests__/platform/Backoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Backoff from '../../src/platform/Backoff';

const noJitter = (): number => 0;
const maxJitter = (): number => 1;
const defaultResetInterval = 60 * 1000;

it.each([1, 1000, 5000])('has the correct starting delay', (initialDelay) => {
const backoff = new Backoff(initialDelay, defaultResetInterval, noJitter);
expect(backoff.fail()).toEqual(initialDelay);
});

it.each([1, 1000, 5000])('doubles delay on consecutive failures', (initialDelay) => {
const backoff = new Backoff(initialDelay, defaultResetInterval, noJitter);
expect(backoff.fail()).toEqual(initialDelay);
expect(backoff.fail()).toEqual(initialDelay * 2);
expect(backoff.fail()).toEqual(initialDelay * 4);
});

it('stops increasing delay when the max backoff is encountered', () => {
const backoff = new Backoff(5000, defaultResetInterval, noJitter);
expect(backoff.fail()).toEqual(5000);
expect(backoff.fail()).toEqual(10000);
expect(backoff.fail()).toEqual(20000);
expect(backoff.fail()).toEqual(30000);

const backoff2 = new Backoff(1000, defaultResetInterval, noJitter);
expect(backoff2.fail()).toEqual(1000);
expect(backoff2.fail()).toEqual(2000);
expect(backoff2.fail()).toEqual(4000);
expect(backoff2.fail()).toEqual(8000);
expect(backoff2.fail()).toEqual(16000);
expect(backoff2.fail()).toEqual(30000);
});

it('handles an initial retry delay longer than the maximum retry delay', () => {
const backoff = new Backoff(40000, defaultResetInterval, noJitter);
expect(backoff.fail()).toEqual(30000);
});

it('jitters the backoff value', () => {
const backoff = new Backoff(1000, defaultResetInterval, maxJitter);
expect(backoff.fail()).toEqual(500);
expect(backoff.fail()).toEqual(1000);
expect(backoff.fail()).toEqual(2000);
expect(backoff.fail()).toEqual(4000);
expect(backoff.fail()).toEqual(8000);
expect(backoff.fail()).toEqual(15000);
});

it.each([10 * 1000, 60 * 1000])(
'resets the delay when the last successful connection was connected greater than the retry reset interval',
(retryResetInterval) => {
let time = 1000;
const backoff = new Backoff(1000, retryResetInterval, noJitter);
expect(backoff.fail(time)).toEqual(1000);
time += 1;
backoff.success(time);
time = time + retryResetInterval + 1;
expect(backoff.fail(time)).toEqual(1000);
time += 1;
expect(backoff.fail(time)).toEqual(2000);
time += 1;
backoff.success(time);
time = time + retryResetInterval + 1;
expect(backoff.fail(time)).toEqual(1000);
},
);

it.each([10 * 1000, 60 * 1000])(
'does not reset the delay when the connection did not persist longer than the retry reset interval',
(retryResetInterval) => {
const backoff = new Backoff(1000, retryResetInterval, noJitter);

let time = 1000;
expect(backoff.fail(time)).toEqual(1000);
time += 1;
backoff.success(time);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(2000);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(4000);
time += 1;
backoff.success(time);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(8000);
},
);
13 changes: 8 additions & 5 deletions packages/sdk/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
],
"scripts": {
"clean": "rimraf dist",
"build": "tsc --noEmit && vite build",
"build": "rollup -c rollup.config.js",
"lint": "eslint . --ext .ts,.tsx",
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
"test": "jest",
Expand All @@ -39,6 +39,11 @@
},
"devDependencies": {
"@launchdarkly/private-js-mocks": "0.0.1",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.2",
"@rollup/plugin-terser": "^0.4.3",
"@rollup/plugin-typescript": "^11.1.1",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.20.0",
Expand All @@ -54,11 +59,9 @@
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.0.0",
"rimraf": "^5.0.5",
"rollup": "^3.23.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typedoc": "0.25.0",
"typescript": "^5.5.3",
"vite": "^5.4.1",
"vite-plugin-dts": "^4.0.3"
"typescript": "^5.5.3"
}
}
49 changes: 49 additions & 0 deletions packages/sdk/browser/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import common from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';

const getSharedConfig = (format, file) => ({
input: 'src/index.ts',
output: [
{
format: format,
sourcemap: true,
file: file,
},
],
onwarn: (warning) => {
if (warning.code !== 'CIRCULAR_DEPENDENCY') {
console.error(`(!) ${warning.message}`);
}
},
});

export default [
{
...getSharedConfig('es', 'dist/index.es.js'),
plugins: [
typescript({
module: 'esnext',
}),
common({
transformMixedEsModules: true,
esmExternals: true,
}),
resolve(),
terser(),
json(),
],
},
{
...getSharedConfig('cjs', 'dist/index.cjs.js'),
plugins: [
typescript(),
common(),
resolve(),
terser(),
json(),
],
},
];
7 changes: 7 additions & 0 deletions packages/sdk/browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import BrowserInfo from './platform/BrowserInfo';
import DefaultBrowserEventSource from './platform/DefaultBrowserEventSource';

// Temporary exports for testing in a browser.
export { DefaultBrowserEventSource, BrowserInfo };
export * from '@launchdarkly/js-client-sdk-common';

export function Hello() {
// eslint-disable-next-line no-console
console.log('HELLO');
Expand Down
76 changes: 76 additions & 0 deletions packages/sdk/browser/src/platform/Backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds.
const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time.

/**
* Implements exponential backoff and jitter. This class tracks successful connections and failures
* and produces a retry delay.
*
* It does not start any timers or directly control a connection.
*
* The backoff follows an exponential backoff scheme with 50% jitter starting at
* initialRetryDelayMillis and capping at MAX_RETRY_DELAY. If RESET_INTERVAL has elapsed after a
* success, without an intervening faulure, then the backoff is reset to initialRetryDelayMillis.
*/
export default class Backoff {
private retryCount: number = 0;
private activeSince?: number;
private initialRetryDelayMillis: number;
/**
* The exponent at which the backoff delay will exceed the maximum.
* Beyond this limit the backoff can be set to the max.
*/
private readonly maxExponent: number;

constructor(
initialRetryDelayMillis: number,
private readonly retryResetIntervalMillis: number,
private readonly random = Math.random,
) {
// Initial retry delay cannot be 0.
this.initialRetryDelayMillis = Math.max(1, initialRetryDelayMillis);
this.maxExponent = Math.ceil(Math.log2(MAX_RETRY_DELAY / this.initialRetryDelayMillis));
}

private backoff(): number {
const exponent = Math.min(this.retryCount, this.maxExponent);
const delay = this.initialRetryDelayMillis * 2 ** exponent;
return Math.min(delay, MAX_RETRY_DELAY);
}

private jitter(computedDelayMillis: number): number {
return computedDelayMillis - Math.trunc(this.random() * JITTER_RATIO * computedDelayMillis);
}

/**
* This function should be called when a connection attempt is successful.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
*/
success(timeStampMs: number = Date.now()): void {
this.activeSince = timeStampMs;
}

/**
* This function should be called when a connection fails. It returns the a delay, in
* milliseconds, after which a reconnection attempt should be made.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
* @returns The delay before the next connection attempt.
*/
fail(timeStampMs: number = Date.now()): number {
// If the last successful connection was active for more than the RESET_INTERVAL, then we
// return to the initial retry delay.
if (
this.activeSince !== undefined &&
timeStampMs - this.activeSince > this.retryResetIntervalMillis
) {
this.retryCount = 0;
}
this.activeSince = undefined;
const delay = this.jitter(this.backoff());
this.retryCount += 1;
return delay;
}
}
17 changes: 13 additions & 4 deletions packages/sdk/browser/src/platform/BrowserPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { Crypto, Encoding, Info, LDOptions, Storage } from '@launchdarkly/js-client-sdk-common';
import {
Crypto,
Encoding,
Info,
LDOptions,
Platform,
Requests,
Storage,
} from '@launchdarkly/js-client-sdk-common';

import BrowserCrypto from './BrowserCrypto';
import BrowserEncoding from './BrowserEncoding';
import BrowserInfo from './BrowserInfo';
import BrowserRequests from './BrowserRequests';
import LocalStorage, { isLocalStorageSupported } from './LocalStorage';

export default class BrowserPlatform /* implements platform.Platform */ {
encoding?: Encoding = new BrowserEncoding();
export default class BrowserPlatform implements Platform {
encoding: Encoding = new BrowserEncoding();
info: Info = new BrowserInfo();
// fileSystem?: Filesystem;
crypto: Crypto = new BrowserCrypto();
// requests: Requests;
requests: Requests = new BrowserRequests();
storage?: Storage;

constructor(options: LDOptions) {
Expand Down
29 changes: 29 additions & 0 deletions packages/sdk/browser/src/platform/BrowserRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
EventSourceCapabilities,
EventSourceInitDict,
EventSource as LDEventSource,
Options,
Requests,
Response,
} from '@launchdarkly/js-client-sdk-common';

import DefaultBrowserEventSource from './DefaultBrowserEventSource';

export default class BrowserRequests implements Requests {
fetch(url: string, options?: Options): Promise<Response> {
// @ts-ignore
return fetch(url, options);
}

createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource {
return new DefaultBrowserEventSource(url, eventSourceInitDict);
}

getEventSourceCapabilities(): EventSourceCapabilities {
return {
customMethod: false,
readTimeout: false,
headers: false,
};
}
}
Loading