Skip to content

Commit 887548a

Browse files
authored
feat: Implement support for browser requests. (#578)
1 parent fe82500 commit 887548a

File tree

10 files changed

+376
-27
lines changed

10 files changed

+376
-27
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Backoff from '../../src/platform/Backoff';
2+
3+
const noJitter = (): number => 0;
4+
const maxJitter = (): number => 1;
5+
const defaultResetInterval = 60 * 1000;
6+
7+
it.each([1, 1000, 5000])('has the correct starting delay', (initialDelay) => {
8+
const backoff = new Backoff(initialDelay, defaultResetInterval, noJitter);
9+
expect(backoff.fail()).toEqual(initialDelay);
10+
});
11+
12+
it.each([1, 1000, 5000])('doubles delay on consecutive failures', (initialDelay) => {
13+
const backoff = new Backoff(initialDelay, defaultResetInterval, noJitter);
14+
expect(backoff.fail()).toEqual(initialDelay);
15+
expect(backoff.fail()).toEqual(initialDelay * 2);
16+
expect(backoff.fail()).toEqual(initialDelay * 4);
17+
});
18+
19+
it('stops increasing delay when the max backoff is encountered', () => {
20+
const backoff = new Backoff(5000, defaultResetInterval, noJitter);
21+
expect(backoff.fail()).toEqual(5000);
22+
expect(backoff.fail()).toEqual(10000);
23+
expect(backoff.fail()).toEqual(20000);
24+
expect(backoff.fail()).toEqual(30000);
25+
26+
const backoff2 = new Backoff(1000, defaultResetInterval, noJitter);
27+
expect(backoff2.fail()).toEqual(1000);
28+
expect(backoff2.fail()).toEqual(2000);
29+
expect(backoff2.fail()).toEqual(4000);
30+
expect(backoff2.fail()).toEqual(8000);
31+
expect(backoff2.fail()).toEqual(16000);
32+
expect(backoff2.fail()).toEqual(30000);
33+
});
34+
35+
it('handles an initial retry delay longer than the maximum retry delay', () => {
36+
const backoff = new Backoff(40000, defaultResetInterval, noJitter);
37+
expect(backoff.fail()).toEqual(30000);
38+
});
39+
40+
it('jitters the backoff value', () => {
41+
const backoff = new Backoff(1000, defaultResetInterval, maxJitter);
42+
expect(backoff.fail()).toEqual(500);
43+
expect(backoff.fail()).toEqual(1000);
44+
expect(backoff.fail()).toEqual(2000);
45+
expect(backoff.fail()).toEqual(4000);
46+
expect(backoff.fail()).toEqual(8000);
47+
expect(backoff.fail()).toEqual(15000);
48+
});
49+
50+
it.each([10 * 1000, 60 * 1000])(
51+
'resets the delay when the last successful connection was connected greater than the retry reset interval',
52+
(retryResetInterval) => {
53+
let time = 1000;
54+
const backoff = new Backoff(1000, retryResetInterval, noJitter);
55+
expect(backoff.fail(time)).toEqual(1000);
56+
time += 1;
57+
backoff.success(time);
58+
time = time + retryResetInterval + 1;
59+
expect(backoff.fail(time)).toEqual(1000);
60+
time += 1;
61+
expect(backoff.fail(time)).toEqual(2000);
62+
time += 1;
63+
backoff.success(time);
64+
time = time + retryResetInterval + 1;
65+
expect(backoff.fail(time)).toEqual(1000);
66+
},
67+
);
68+
69+
it.each([10 * 1000, 60 * 1000])(
70+
'does not reset the delay when the connection did not persist longer than the retry reset interval',
71+
(retryResetInterval) => {
72+
const backoff = new Backoff(1000, retryResetInterval, noJitter);
73+
74+
let time = 1000;
75+
expect(backoff.fail(time)).toEqual(1000);
76+
time += 1;
77+
backoff.success(time);
78+
time += retryResetInterval;
79+
expect(backoff.fail(time)).toEqual(2000);
80+
time += retryResetInterval;
81+
expect(backoff.fail(time)).toEqual(4000);
82+
time += 1;
83+
backoff.success(time);
84+
time += retryResetInterval;
85+
expect(backoff.fail(time)).toEqual(8000);
86+
},
87+
);

packages/sdk/browser/package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
],
2828
"scripts": {
2929
"clean": "rimraf dist",
30-
"build": "tsc --noEmit && vite build",
30+
"build": "rollup -c rollup.config.js",
3131
"lint": "eslint . --ext .ts,.tsx",
3232
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
3333
"test": "jest",
@@ -39,6 +39,11 @@
3939
},
4040
"devDependencies": {
4141
"@launchdarkly/private-js-mocks": "0.0.1",
42+
"@rollup/plugin-commonjs": "^25.0.0",
43+
"@rollup/plugin-json": "^6.1.0",
44+
"@rollup/plugin-node-resolve": "^15.0.2",
45+
"@rollup/plugin-terser": "^0.4.3",
46+
"@rollup/plugin-typescript": "^11.1.1",
4247
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
4348
"@types/jest": "^29.5.11",
4449
"@typescript-eslint/eslint-plugin": "^6.20.0",
@@ -54,11 +59,9 @@
5459
"jest-environment-jsdom": "^29.7.0",
5560
"prettier": "^3.0.0",
5661
"rimraf": "^5.0.5",
62+
"rollup": "^3.23.0",
5763
"ts-jest": "^29.1.1",
58-
"ts-node": "^10.9.2",
5964
"typedoc": "0.25.0",
60-
"typescript": "^5.5.3",
61-
"vite": "^5.4.1",
62-
"vite-plugin-dts": "^4.0.3"
65+
"typescript": "^5.5.3"
6366
}
6467
}

packages/sdk/browser/rollup.config.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import common from '@rollup/plugin-commonjs';
2+
import resolve from '@rollup/plugin-node-resolve';
3+
import terser from '@rollup/plugin-terser';
4+
import typescript from '@rollup/plugin-typescript';
5+
import json from '@rollup/plugin-json';
6+
7+
const getSharedConfig = (format, file) => ({
8+
input: 'src/index.ts',
9+
output: [
10+
{
11+
format: format,
12+
sourcemap: true,
13+
file: file,
14+
},
15+
],
16+
onwarn: (warning) => {
17+
if (warning.code !== 'CIRCULAR_DEPENDENCY') {
18+
console.error(`(!) ${warning.message}`);
19+
}
20+
},
21+
});
22+
23+
export default [
24+
{
25+
...getSharedConfig('es', 'dist/index.es.js'),
26+
plugins: [
27+
typescript({
28+
module: 'esnext',
29+
}),
30+
common({
31+
transformMixedEsModules: true,
32+
esmExternals: true,
33+
}),
34+
resolve(),
35+
terser(),
36+
json(),
37+
],
38+
},
39+
{
40+
...getSharedConfig('cjs', 'dist/index.cjs.js'),
41+
plugins: [
42+
typescript(),
43+
common(),
44+
resolve(),
45+
terser(),
46+
json(),
47+
],
48+
},
49+
];

packages/sdk/browser/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import BrowserInfo from './platform/BrowserInfo';
2+
import DefaultBrowserEventSource from './platform/DefaultBrowserEventSource';
3+
4+
// Temporary exports for testing in a browser.
5+
export { DefaultBrowserEventSource, BrowserInfo };
6+
export * from '@launchdarkly/js-client-sdk-common';
7+
18
export function Hello() {
29
// eslint-disable-next-line no-console
310
console.log('HELLO');
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds.
2+
const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time.
3+
4+
/**
5+
* Implements exponential backoff and jitter. This class tracks successful connections and failures
6+
* and produces a retry delay.
7+
*
8+
* It does not start any timers or directly control a connection.
9+
*
10+
* The backoff follows an exponential backoff scheme with 50% jitter starting at
11+
* initialRetryDelayMillis and capping at MAX_RETRY_DELAY. If RESET_INTERVAL has elapsed after a
12+
* success, without an intervening faulure, then the backoff is reset to initialRetryDelayMillis.
13+
*/
14+
export default class Backoff {
15+
private retryCount: number = 0;
16+
private activeSince?: number;
17+
private initialRetryDelayMillis: number;
18+
/**
19+
* The exponent at which the backoff delay will exceed the maximum.
20+
* Beyond this limit the backoff can be set to the max.
21+
*/
22+
private readonly maxExponent: number;
23+
24+
constructor(
25+
initialRetryDelayMillis: number,
26+
private readonly retryResetIntervalMillis: number,
27+
private readonly random = Math.random,
28+
) {
29+
// Initial retry delay cannot be 0.
30+
this.initialRetryDelayMillis = Math.max(1, initialRetryDelayMillis);
31+
this.maxExponent = Math.ceil(Math.log2(MAX_RETRY_DELAY / this.initialRetryDelayMillis));
32+
}
33+
34+
private backoff(): number {
35+
const exponent = Math.min(this.retryCount, this.maxExponent);
36+
const delay = this.initialRetryDelayMillis * 2 ** exponent;
37+
return Math.min(delay, MAX_RETRY_DELAY);
38+
}
39+
40+
private jitter(computedDelayMillis: number): number {
41+
return computedDelayMillis - Math.trunc(this.random() * JITTER_RATIO * computedDelayMillis);
42+
}
43+
44+
/**
45+
* This function should be called when a connection attempt is successful.
46+
*
47+
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
48+
* the current time is used.
49+
*/
50+
success(timeStampMs: number = Date.now()): void {
51+
this.activeSince = timeStampMs;
52+
}
53+
54+
/**
55+
* This function should be called when a connection fails. It returns the a delay, in
56+
* milliseconds, after which a reconnection attempt should be made.
57+
*
58+
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
59+
* the current time is used.
60+
* @returns The delay before the next connection attempt.
61+
*/
62+
fail(timeStampMs: number = Date.now()): number {
63+
// If the last successful connection was active for more than the RESET_INTERVAL, then we
64+
// return to the initial retry delay.
65+
if (
66+
this.activeSince !== undefined &&
67+
timeStampMs - this.activeSince > this.retryResetIntervalMillis
68+
) {
69+
this.retryCount = 0;
70+
}
71+
this.activeSince = undefined;
72+
const delay = this.jitter(this.backoff());
73+
this.retryCount += 1;
74+
return delay;
75+
}
76+
}

packages/sdk/browser/src/platform/BrowserPlatform.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1-
import { Crypto, Encoding, Info, LDOptions, Storage } from '@launchdarkly/js-client-sdk-common';
1+
import {
2+
Crypto,
3+
Encoding,
4+
Info,
5+
LDOptions,
6+
Platform,
7+
Requests,
8+
Storage,
9+
} from '@launchdarkly/js-client-sdk-common';
210

311
import BrowserCrypto from './BrowserCrypto';
412
import BrowserEncoding from './BrowserEncoding';
513
import BrowserInfo from './BrowserInfo';
14+
import BrowserRequests from './BrowserRequests';
615
import LocalStorage, { isLocalStorageSupported } from './LocalStorage';
716

8-
export default class BrowserPlatform /* implements platform.Platform */ {
9-
encoding?: Encoding = new BrowserEncoding();
17+
export default class BrowserPlatform implements Platform {
18+
encoding: Encoding = new BrowserEncoding();
1019
info: Info = new BrowserInfo();
1120
// fileSystem?: Filesystem;
1221
crypto: Crypto = new BrowserCrypto();
13-
// requests: Requests;
22+
requests: Requests = new BrowserRequests();
1423
storage?: Storage;
1524

1625
constructor(options: LDOptions) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
EventSourceCapabilities,
3+
EventSourceInitDict,
4+
EventSource as LDEventSource,
5+
Options,
6+
Requests,
7+
Response,
8+
} from '@launchdarkly/js-client-sdk-common';
9+
10+
import DefaultBrowserEventSource from './DefaultBrowserEventSource';
11+
12+
export default class BrowserRequests implements Requests {
13+
fetch(url: string, options?: Options): Promise<Response> {
14+
// @ts-ignore
15+
return fetch(url, options);
16+
}
17+
18+
createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource {
19+
return new DefaultBrowserEventSource(url, eventSourceInitDict);
20+
}
21+
22+
getEventSourceCapabilities(): EventSourceCapabilities {
23+
return {
24+
customMethod: false,
25+
readTimeout: false,
26+
headers: false,
27+
};
28+
}
29+
}

0 commit comments

Comments
 (0)