diff --git a/packages/sdk/browser/__tests__/platform/Backoff.test.ts b/packages/sdk/browser/__tests__/platform/Backoff.test.ts new file mode 100644 index 000000000..49cdd901c --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/Backoff.test.ts @@ -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); + }, +); diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 1d1c44388..3f6f646b6 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -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", @@ -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", @@ -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" } } diff --git a/packages/sdk/browser/rollup.config.js b/packages/sdk/browser/rollup.config.js new file mode 100644 index 000000000..d78a55a79 --- /dev/null +++ b/packages/sdk/browser/rollup.config.js @@ -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(), + ], + }, +]; diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index a31204dbd..e73632036 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -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'); diff --git a/packages/sdk/browser/src/platform/Backoff.ts b/packages/sdk/browser/src/platform/Backoff.ts new file mode 100644 index 000000000..f90bcd7c4 --- /dev/null +++ b/packages/sdk/browser/src/platform/Backoff.ts @@ -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; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index 4bdedead8..34dfccaef 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -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) { diff --git a/packages/sdk/browser/src/platform/BrowserRequests.ts b/packages/sdk/browser/src/platform/BrowserRequests.ts new file mode 100644 index 000000000..5e7346784 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -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 { + // @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, + }; + } +} diff --git a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts new file mode 100644 index 000000000..b9084349b --- /dev/null +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -0,0 +1,106 @@ +import { + EventListener, + EventName, + EventSourceInitDict, + HttpErrorResponse, + EventSource as LDEventSource, +} from '@launchdarkly/js-client-sdk-common'; + +import Backoff from './Backoff'; + +/** + * Implementation Notes: + * + * This event source does not support a read-timeout. + * This event source does not support customized verbs. + * This event source does not support headers. + */ + +/** + * Browser event source implementation which extends the built-in event + * source with additional reconnection logic. + */ +export default class DefaultBrowserEventSource implements LDEventSource { + private es?: EventSource; + private backoff: Backoff; + private errorFilter: (err: HttpErrorResponse) => boolean; + + // The type of the handle can be platform specific and we treat is opaquely. + private reconnectTimeoutHandle?: any; + + private listeners: Record = {}; + + constructor( + private readonly url: string, + options: EventSourceInitDict, + ) { + this.backoff = new Backoff(options.initialRetryDelayMillis, options.retryResetIntervalMillis); + this.errorFilter = options.errorFilter; + this.openConnection(); + } + + onclose: (() => void) | undefined; + + onerror: ((err?: HttpErrorResponse) => void) | undefined; + + onopen: (() => void) | undefined; + + onretrying: ((e: { delayMillis: number }) => void) | undefined; + + private openConnection() { + this.es = new EventSource(this.url); + this.es.onopen = () => { + this.backoff.success(); + this.onopen?.(); + }; + // The error could be from a polyfill, or from the browser event source, so we are loose on the + // typing. + this.es.onerror = (err: any) => { + this.handleError(err); + this.onerror?.(err); + }; + Object.entries(this.listeners).forEach(([eventName, listeners]) => { + listeners.forEach((listener) => { + this.es?.addEventListener(eventName, listener); + }); + }); + } + + addEventListener(type: EventName, listener: EventListener): void { + this.listeners[type] ??= []; + this.listeners[type].push(listener); + this.es?.addEventListener(type, listener); + } + + close(): void { + // Ensure any pending retry attempts are not done. + clearTimeout(this.reconnectTimeoutHandle); + this.reconnectTimeoutHandle = undefined; + + // Close the event source and notify any listeners. + this.es?.close(); + this.onclose?.(); + } + + private tryConnect(delayMs: number) { + this.onretrying?.({ delayMillis: delayMs }); + this.reconnectTimeoutHandle = setTimeout(() => { + this.openConnection(); + }, delayMs); + } + + private handleError(err: any): void { + this.close(); + + // The event source may not produce a status. But the LaunchDarkly + // polyfill can. If we can get the status, then we should stop retrying + // on certain error codes. + if (err.status && typeof err.status === 'number' && !this.errorFilter(err)) { + // If we encounter an unrecoverable condition, then we do not want to + // retry anymore. + return; + } + + this.tryConnect(this.backoff.fail()); + } +} diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index 79420d3d4..66b350fda 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -11,6 +11,7 @@ "resolveJsonModule": true, // Uses "." so it can load package.json. "rootDir": ".", + "outDir": "dist", "skipLibCheck": true, // enables importers to jump to source "sourceMap": true, diff --git a/packages/sdk/browser/vite.config.ts b/packages/sdk/browser/vite.config.ts deleted file mode 100644 index 74378ce66..000000000 --- a/packages/sdk/browser/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -// This file intentionally uses dev dependencies as it is a build file. -import { resolve } from 'path'; -import { defineConfig } from 'vite'; -import dts from 'vite-plugin-dts'; - -export default defineConfig({ - plugins: [dts()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.ts'), - name: '@launchdarkly/js-client-sdk', - fileName: (format) => `index.${format}.js`, - formats: ['cjs', 'es'], - }, - rollupOptions: {}, - }, -});