From 53af412dd244c202ea16d3ba1bf7d99d2c511ae0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:31:48 -0700 Subject: [PATCH 01/19] feat: Implement support for browser requests. --- packages/sdk/browser/src/platform/Backoff.ts | 27 ++++++ .../src/platform/BrowserEventSourceShim.ts | 91 +++++++++++++++++++ .../browser/src/platform/BrowserPlatform.ts | 4 +- .../browser/src/platform/BrowserRequests.ts | 18 ++++ .../src/platform/PlatformRequests.ts | 9 ++ .../server-node/src/platform/NodeRequests.ts | 9 ++ .../common/src/api/platform/Requests.ts | 20 ++++ 7 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/browser/src/platform/Backoff.ts create mode 100644 packages/sdk/browser/src/platform/BrowserEventSourceShim.ts create mode 100644 packages/sdk/browser/src/platform/BrowserRequests.ts diff --git a/packages/sdk/browser/src/platform/Backoff.ts b/packages/sdk/browser/src/platform/Backoff.ts new file mode 100644 index 0000000000..30d272e128 --- /dev/null +++ b/packages/sdk/browser/src/platform/Backoff.ts @@ -0,0 +1,27 @@ +const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds. +const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. + +function jitter(computedDelayMillis: number): number { + return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis); +} + +export default class Backoff { + private retryCount: number = 0; + + constructor(private readonly initialRetryDelayMillis: number) {} + + reset(): void { + this.retryCount = 0; + } + + backoff(): number { + const delay = this.initialRetryDelayMillis * 2 ** this.retryCount; + return delay > maxRetryDelay ? maxRetryDelay : delay; + } + + getNextRetryDelay(): number { + const delay = jitter(this.backoff()); + this.retryCount += 1; + return delay; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts b/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts new file mode 100644 index 0000000000..c479a34e11 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts @@ -0,0 +1,91 @@ +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. + */ + +export default class BrowserEventSourceShim 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; + + constructor( + private readonly url: string, + options: EventSourceInitDict, + ) { + this.backoff = new Backoff(options.initialRetryDelayMillis); + this.errorFilter = options.errorFilter; + this.createEventSource(); + } + + onclose: (() => void) | undefined; + + onerror: ((err?: HttpErrorResponse) => void) | undefined; + + onopen: (() => void) | undefined; + + onretrying: ((e: { delayMillis: number }) => void) | undefined; + + private createEventSource() { + this.es = new EventSource(this.url); + this.es.onopen = () => { + this.backoff.reset(); + 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); + }; + } + + addEventListener(type: EventName, listener: EventListener): void { + // TODO: Cache listeners so they can be re-added. + this.es?.addEventListener(type, listener); + } + + close(): void { + // Ensure any pending retry attempts are not done. + clearTimeout(this.reconnectTimeoutHandle); + this.reconnectTimeoutHandle = undefined; + this.es?.close(); + } + + private tryConnect(delayMs: number) { + this.reconnectTimeoutHandle = setTimeout(() => { + this.createEventSource(); + }, delayMs); + } + + private handleError(err: any): void { + // 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. + this.close(); + return; + } + + const delay = this.backoff.getNextRetryDelay(); + + this.close(); + this.tryConnect(delay); + } +} diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index 8865ebc3c8..dc9d7054d9 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -2,10 +2,12 @@ import { Crypto, /* platform */ LDOptions, + Requests, Storage, } from '@launchdarkly/js-client-sdk-common'; import BrowserCrypto from './BrowserCrypto'; +import BrowserRequests from './BrowserRequests'; import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; export default class BrowserPlatform /* implements platform.Platform */ { @@ -13,7 +15,7 @@ export default class BrowserPlatform /* implements platform.Platform */ { // info: Info; // 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 0000000000..917b6ac4aa --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -0,0 +1,18 @@ +import { + EventSourceInitDict, + EventSource as LDEventSource, + Options, + Requests, + Response, +} from '@launchdarkly/js-client-sdk-common'; + +import BrowserEventSourceShim from './BrowserEventSourceShim'; + +export default class BrowserRequests implements Requests { + fetch(url: string, options?: Options): Promise { + return this.fetch(url, options); + } + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource { + return new BrowserEventSourceShim(url, eventSourceInitDict); + } +} diff --git a/packages/sdk/react-native/src/platform/PlatformRequests.ts b/packages/sdk/react-native/src/platform/PlatformRequests.ts index 5b345d2950..6651a7bb1c 100644 --- a/packages/sdk/react-native/src/platform/PlatformRequests.ts +++ b/packages/sdk/react-native/src/platform/PlatformRequests.ts @@ -1,6 +1,7 @@ import type { EventName, EventSource, + EventSourceCapabilities, EventSourceInitDict, LDLogger, Options, @@ -21,6 +22,14 @@ export default class PlatformRequests implements Requests { }); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: true, + customVerb: true, + }; + } + fetch(url: string, options?: Options): Promise { // @ts-ignore return fetch(url, options); diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index c951185195..3e83278b67 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -7,6 +7,7 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent'; import { EventSource as LDEventSource } from 'launchdarkly-eventsource'; import { + EventSourceCapabilities, LDLogger, LDProxyOptions, LDTLSOptions, @@ -158,6 +159,14 @@ export default class NodeRequests implements platform.Requests { return new LDEventSource(url, expandedOptions); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: true, + headers: true, + customVerb: true, + }; + } + usingProxy(): boolean { return this.hasProxy; } diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index 96168012b6..32ef8149e8 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -78,11 +78,31 @@ export interface Options { timeout?: number; } +export interface EventSourceCapabilities { + /** + * If true the event source supports read timeouts. + */ + readTimeout: boolean; + + /** + * If true the event source supports customized verbs POST/REPORT instead of + * only the default GET. + */ + customVerb: boolean; + + /** + * If true the event source supports setting HTTP headers. + */ + headers: boolean; +} + export interface Requests { fetch(url: string, options?: Options): Promise; createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource; + getEventSourceCapabilities(): EventSourceCapabilities; + /** * Returns true if a proxy is configured. */ From bf164dd8dc75bfc6e33ce28ca07b1ac64d6c3e43 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:46:26 -0700 Subject: [PATCH 02/19] feat: Add support for conditional event source capabilities. --- .../src/platform/PlatformRequests.ts | 9 +++++++++ .../server-node/src/platform/NodeRequests.ts | 9 +++++++++ .../common/src/api/platform/Requests.ts | 20 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/packages/sdk/react-native/src/platform/PlatformRequests.ts b/packages/sdk/react-native/src/platform/PlatformRequests.ts index 5b345d2950..6add1e7be3 100644 --- a/packages/sdk/react-native/src/platform/PlatformRequests.ts +++ b/packages/sdk/react-native/src/platform/PlatformRequests.ts @@ -1,6 +1,7 @@ import type { EventName, EventSource, + EventSourceCapabilities, EventSourceInitDict, LDLogger, Options, @@ -21,6 +22,14 @@ export default class PlatformRequests implements Requests { }); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: true, + customMethod: true, + }; + } + fetch(url: string, options?: Options): Promise { // @ts-ignore return fetch(url, options); diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index c951185195..ede410b63e 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -7,6 +7,7 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent'; import { EventSource as LDEventSource } from 'launchdarkly-eventsource'; import { + EventSourceCapabilities, LDLogger, LDProxyOptions, LDTLSOptions, @@ -158,6 +159,14 @@ export default class NodeRequests implements platform.Requests { return new LDEventSource(url, expandedOptions); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: true, + headers: true, + customMethod: true, + }; + } + usingProxy(): boolean { return this.hasProxy; } diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index 96168012b6..a771dcc454 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -78,11 +78,31 @@ export interface Options { timeout?: number; } +export interface EventSourceCapabilities { + /** + * If true the event source supports read timeouts. + */ + readTimeout: boolean; + + /** + * If true the event source supports customized verbs POST/REPORT instead of + * only the default GET. + */ + customMethod: boolean; + + /** + * If true the event source supports setting HTTP headers. + */ + headers: boolean; +} + export interface Requests { fetch(url: string, options?: Options): Promise; createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource; + getEventSourceCapabilities(): EventSourceCapabilities; + /** * Returns true if a proxy is configured. */ From 8307b032231bc054b312f7a4383a9d129a8bf76a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:57:59 -0700 Subject: [PATCH 03/19] Add more comments. --- packages/shared/common/src/api/platform/Requests.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index a771dcc454..219d775f41 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -80,7 +80,14 @@ export interface Options { export interface EventSourceCapabilities { /** - * If true the event source supports read timeouts. + * If true the event source supports read timeouts. A read timeout for an + * event source represents the maximum time between receiving any data. + * If you receive 1 byte, and then a period of time greater than the read + * time out elapses, and you do not receive a second byte, then that would + * cause the event source to timeout. + * + * It is not a timeout for the read of the entire body, which should be + * indefinite. */ readTimeout: boolean; From c23bf45842403573711925ed0fc9eb11c4453948 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:03:53 -0700 Subject: [PATCH 04/19] Implement for edge SDKs. --- .../sdk/react-native/src/platform/PlatformRequests.ts | 4 ++-- .../akamai-edgeworker-sdk/src/platform/requests.ts | 9 +++++++++ packages/shared/sdk-server-edge/src/platform/requests.ts | 9 +++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react-native/src/platform/PlatformRequests.ts b/packages/sdk/react-native/src/platform/PlatformRequests.ts index 6add1e7be3..ae5c3ab2c7 100644 --- a/packages/sdk/react-native/src/platform/PlatformRequests.ts +++ b/packages/sdk/react-native/src/platform/PlatformRequests.ts @@ -25,8 +25,8 @@ export default class PlatformRequests implements Requests { getEventSourceCapabilities(): EventSourceCapabilities { return { readTimeout: false, - headers: true, - customMethod: true, + headers: false, + customMethod: false, }; } diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts index 5a6b728b00..453344e13c 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts @@ -6,6 +6,7 @@ import type { Options, Requests, Response, + EventSourceCapabilities, } from '@launchdarkly/js-server-sdk-common'; class NoopResponse implements Response { @@ -41,4 +42,12 @@ export default class EdgeRequests implements Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new NullEventSource(url, eventSourceInitDict); } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } } diff --git a/packages/shared/sdk-server-edge/src/platform/requests.ts b/packages/shared/sdk-server-edge/src/platform/requests.ts index 2bc8010dc5..413bbb6b32 100644 --- a/packages/shared/sdk-server-edge/src/platform/requests.ts +++ b/packages/shared/sdk-server-edge/src/platform/requests.ts @@ -1,6 +1,7 @@ import { NullEventSource } from '@launchdarkly/js-server-sdk-common'; import type { EventSource, + EventSourceCapabilities, EventSourceInitDict, Options, Requests, @@ -16,4 +17,12 @@ export default class EdgeRequests implements Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new NullEventSource(url, eventSourceInitDict); } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } } From a084b9a5d1d3602acedbe665b446a46b1129d29a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:20:54 -0700 Subject: [PATCH 05/19] Lint --- packages/sdk/react-native/src/platform/PlatformRequests.ts | 4 ++-- .../shared/akamai-edgeworker-sdk/src/platform/requests.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdk/react-native/src/platform/PlatformRequests.ts b/packages/sdk/react-native/src/platform/PlatformRequests.ts index ae5c3ab2c7..6add1e7be3 100644 --- a/packages/sdk/react-native/src/platform/PlatformRequests.ts +++ b/packages/sdk/react-native/src/platform/PlatformRequests.ts @@ -25,8 +25,8 @@ export default class PlatformRequests implements Requests { getEventSourceCapabilities(): EventSourceCapabilities { return { readTimeout: false, - headers: false, - customMethod: false, + headers: true, + customMethod: true, }; } diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts index 453344e13c..69cbeeb5e6 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts @@ -2,11 +2,11 @@ import { Headers, NullEventSource } from '@launchdarkly/js-server-sdk-common'; import type { EventSource, + EventSourceCapabilities, EventSourceInitDict, Options, Requests, Response, - EventSourceCapabilities, } from '@launchdarkly/js-server-sdk-common'; class NoopResponse implements Response { From 6acc5f47aa8c4bb2f272bb8afce024a22cf79272 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:23:05 -0700 Subject: [PATCH 06/19] Add getEventSourceCapabilities to mocks. --- packages/shared/mocks/src/platform.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index ef138ebd56..e3b0b9ecc5 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -49,6 +49,7 @@ export const createBasicPlatform = () => ({ requests: { fetch: jest.fn(), createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, storage: { get: jest.fn(), From b89e14e5eac45bd14825d7891357ce27d9c585a7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:26:45 -0700 Subject: [PATCH 07/19] Test updates. --- ...{PollingProcessot.test.ts => PollingProcessor.test.ts} | 8 ++++++++ 1 file changed, 8 insertions(+) rename packages/shared/sdk-client/__tests__/polling/{PollingProcessot.test.ts => PollingProcessor.test.ts} (98%) diff --git a/packages/shared/sdk-client/__tests__/polling/PollingProcessot.test.ts b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts similarity index 98% rename from packages/shared/sdk-client/__tests__/polling/PollingProcessot.test.ts rename to packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts index b6a98ae3eb..33cfa40771 100644 --- a/packages/shared/sdk-client/__tests__/polling/PollingProcessot.test.ts +++ b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts @@ -2,6 +2,7 @@ import { waitFor } from '@testing-library/dom'; import { EventSource, + EventSourceCapabilities, EventSourceInitDict, Info, PlatformData, @@ -45,6 +46,13 @@ function makeRequests(): Requests { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + }, }; } From 5becb5b410b5366c7ab82d9dda51775c9a4a990c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:27:57 -0700 Subject: [PATCH 08/19] More test updates. --- .../sdk/react-native/__tests__/ReactNativeLDClient.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts b/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts index 7a4f594c68..56ddebcbfc 100644 --- a/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts +++ b/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts @@ -42,6 +42,7 @@ jest.mock('../src/platform', () => ({ requests: { createEventSource: jest.fn(), fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -70,6 +71,7 @@ it('uses correct default diagnostic url', () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -97,6 +99,7 @@ it('uses correct default analytics event url', async () => { requests: { createEventSource: createMockEventSource, fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -128,6 +131,7 @@ it('uses correct default polling url', async () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -158,6 +162,7 @@ it('uses correct default streaming url', (done) => { requests: { createEventSource: mockedCreateEventSource, fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -197,6 +202,7 @@ it('includes authorization header for polling', async () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -231,6 +237,7 @@ it('includes authorization header for streaming', (done) => { requests: { createEventSource: mockedCreateEventSource, fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), From 8f63e45643ecdb78348b0a247547a287df24071d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:30:23 -0700 Subject: [PATCH 09/19] More tests. --- .../sdk-client/__tests__/polling/PollingProcessor.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts index 33cfa40771..8e6a39c931 100644 --- a/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts +++ b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts @@ -177,6 +177,13 @@ it('stops polling when stopped', (done) => { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + }, }; const dataCallback = jest.fn(); const errorCallback = jest.fn(); From 1c5450f0a3d392584f1d2956b7632b6aa96f1efc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:34:43 -0700 Subject: [PATCH 10/19] More test updates. --- .../sdk-client/__tests__/flag-manager/FlagPersistence.test.ts | 1 + .../shared/sdk-server/__tests__/data_sources/Requestor.test.ts | 3 +++ .../shared/sdk-server/__tests__/events/EventProcessor.test.ts | 3 +++ 3 files changed, 7 insertions(+) diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts index 6e90c10af3..cd31ef46c4 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts @@ -329,6 +329,7 @@ function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { requests: { fetch: jest.fn(), createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, }; } diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index 1fb0558155..46a0640ac8 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -75,6 +75,9 @@ describe('given a requestor', () => { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities() { + throw new Error('Function not implemented.'); + }, }; requestor = new Requestor( diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index 8aed40555f..41fa4135ef 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -111,6 +111,9 @@ function makePlatform(requestState: RequestState) { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities() { + throw new Error('Function not implemented.'); + }, }; return { info, From f0a51c6f452a455a7b6a2468ee9fa91343995aa6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:37:46 -0700 Subject: [PATCH 11/19] feat: Implement browser platform. --- .../src/platform/BrowserEventSourceShim.ts | 21 +++++++++++++++---- .../browser/src/platform/BrowserRequests.ts | 10 +++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts b/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts index c479a34e11..b5e07b1a17 100644 --- a/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts +++ b/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts @@ -24,13 +24,15 @@ export default class BrowserEventSourceShim implements LDEventSource { // 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); this.errorFilter = options.errorFilter; - this.createEventSource(); + this.openConnection(); } onclose: (() => void) | undefined; @@ -41,7 +43,7 @@ export default class BrowserEventSourceShim implements LDEventSource { onretrying: ((e: { delayMillis: number }) => void) | undefined; - private createEventSource() { + private openConnection() { this.es = new EventSource(this.url); this.es.onopen = () => { this.backoff.reset(); @@ -51,11 +53,18 @@ export default class BrowserEventSourceShim implements LDEventSource { // 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 { - // TODO: Cache listeners so they can be re-added. + this.listeners[type] ??= []; + this.listeners[type].push(listener); this.es?.addEventListener(type, listener); } @@ -63,12 +72,16 @@ export default class BrowserEventSourceShim implements LDEventSource { // 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.createEventSource(); + this.openConnection(); }, delayMs); } diff --git a/packages/sdk/browser/src/platform/BrowserRequests.ts b/packages/sdk/browser/src/platform/BrowserRequests.ts index 917b6ac4aa..ca4d42f51d 100644 --- a/packages/sdk/browser/src/platform/BrowserRequests.ts +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -1,4 +1,5 @@ import { + EventSourceCapabilities, EventSourceInitDict, EventSource as LDEventSource, Options, @@ -12,7 +13,16 @@ export default class BrowserRequests implements Requests { fetch(url: string, options?: Options): Promise { return this.fetch(url, options); } + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource { return new BrowserEventSourceShim(url, eventSourceInitDict); } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + customMethod: false, + readTimeout: false, + headers: false, + }; + } } From d2597913b835b8c213df5e7b2323858fdae0a025 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:16:19 -0700 Subject: [PATCH 12/19] Rename browser event source. --- packages/sdk/browser/src/platform/BrowserRequests.ts | 4 ++-- ...ventSourceShim.ts => DefaultBrowserEventSource.ts} | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) rename packages/sdk/browser/src/platform/{BrowserEventSourceShim.ts => DefaultBrowserEventSource.ts} (93%) diff --git a/packages/sdk/browser/src/platform/BrowserRequests.ts b/packages/sdk/browser/src/platform/BrowserRequests.ts index ca4d42f51d..52d4d0dbd4 100644 --- a/packages/sdk/browser/src/platform/BrowserRequests.ts +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -7,7 +7,7 @@ import { Response, } from '@launchdarkly/js-client-sdk-common'; -import BrowserEventSourceShim from './BrowserEventSourceShim'; +import DefaultBrowserEventSource from './DefaultBrowserEventSource'; export default class BrowserRequests implements Requests { fetch(url: string, options?: Options): Promise { @@ -15,7 +15,7 @@ export default class BrowserRequests implements Requests { } createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource { - return new BrowserEventSourceShim(url, eventSourceInitDict); + return new DefaultBrowserEventSource(url, eventSourceInitDict); } getEventSourceCapabilities(): EventSourceCapabilities { diff --git a/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts similarity index 93% rename from packages/sdk/browser/src/platform/BrowserEventSourceShim.ts rename to packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts index b5e07b1a17..be791ad9b4 100644 --- a/packages/sdk/browser/src/platform/BrowserEventSourceShim.ts +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -16,7 +16,11 @@ import Backoff from './Backoff'; * This event source does not support headers. */ -export default class BrowserEventSourceShim implements LDEventSource { +/** + * 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; @@ -86,19 +90,18 @@ export default class BrowserEventSourceShim implements LDEventSource { } 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. - this.close(); return; } const delay = this.backoff.getNextRetryDelay(); - - this.close(); this.tryConnect(delay); } } From 679b813640bd096425abb5a6e229e6fda3daf2e1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:04:42 -0700 Subject: [PATCH 13/19] Add backoff tests. --- .../__tests__/platform/Backoff.test.ts | 67 +++++++++++++++++ packages/sdk/browser/src/platform/Backoff.ts | 74 +++++++++++++++---- .../src/platform/DefaultBrowserEventSource.ts | 5 +- 3 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 packages/sdk/browser/__tests__/platform/Backoff.test.ts 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 0000000000..3d32614ae8 --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/Backoff.test.ts @@ -0,0 +1,67 @@ +import Backoff from '../../src/platform/Backoff'; + +const noJitter = (): number => 0; +const maxJitter = (): number => 1; + +it.each([1, 1000, 5000])('has the correct starting delay', (initialDelay) => { + const backoff = new Backoff(initialDelay, noJitter); + expect(backoff.fail()).toEqual(initialDelay); +}); + +it.each([1, 1000, 5000])('doubles delay on consecutive failures', (initialDelay) => { + const backoff = new Backoff(initialDelay, 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, 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, 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, noJitter); + expect(backoff.fail()).toEqual(30000); +}); + +it('jitters the backoff value', () => { + const backoff = new Backoff(1000, 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('resets the delay when the last successful connection was connected greater than RESET_INTERVAL', () => { + const backoff = new Backoff(1000, noJitter); + expect(backoff.fail(1000)).toEqual(1000); + backoff.success(2000); + expect(backoff.fail(62001)).toEqual(1000); + expect(backoff.fail(62002)).toEqual(2000); + backoff.success(64002); + expect(backoff.fail(124003)).toEqual(1000); +}); + +it('does not reset the delay when the connection did not persist longer than the RESET_INTERVAL', () => { + const backoff = new Backoff(1000, noJitter); + expect(backoff.fail(1000)).toEqual(1000); + backoff.success(2000); + expect(backoff.fail(61000)).toEqual(2000); + expect(backoff.fail(120000)).toEqual(4000); + backoff.success(124000); + expect(backoff.fail(183000)).toEqual(8000); +}); diff --git a/packages/sdk/browser/src/platform/Backoff.ts b/packages/sdk/browser/src/platform/Backoff.ts index 30d272e128..59e28b5b82 100644 --- a/packages/sdk/browser/src/platform/Backoff.ts +++ b/packages/sdk/browser/src/platform/Backoff.ts @@ -1,26 +1,72 @@ -const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds. -const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. - -function jitter(computedDelayMillis: number): number { - return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis); -} +const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds. +const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time. +const RESET_INTERVAL = 60 * 1000; // Reset interval in seconds. +/** + * 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(private readonly initialRetryDelayMillis: number) {} + constructor( + initialRetryDelayMillis: 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); + } - reset(): void { - this.retryCount = 0; + private jitter(computedDelayMillis: number): number { + return computedDelayMillis - Math.trunc(this.random() * JITTER_RATIO * computedDelayMillis); } - backoff(): number { - const delay = this.initialRetryDelayMillis * 2 ** this.retryCount; - return delay > maxRetryDelay ? maxRetryDelay : delay; + /** + * 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; } - getNextRetryDelay(): number { - const delay = jitter(this.backoff()); + /** + * 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 > RESET_INTERVAL) { + 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/DefaultBrowserEventSource.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts index be791ad9b4..c560c86daf 100644 --- a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -50,7 +50,7 @@ export default class DefaultBrowserEventSource implements LDEventSource { private openConnection() { this.es = new EventSource(this.url); this.es.onopen = () => { - this.backoff.reset(); + this.backoff.success(); this.onopen?.(); }; // The error could be from a polyfill, or from the browser event source, so we are loose on the @@ -101,7 +101,6 @@ export default class DefaultBrowserEventSource implements LDEventSource { return; } - const delay = this.backoff.getNextRetryDelay(); - this.tryConnect(delay); + this.tryConnect(this.backoff.fail()); } } From 0434103da5fb5cf1f36097cbebfd87e5f0b6d3d3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:15:07 -0700 Subject: [PATCH 14/19] Enable encoding. --- packages/sdk/browser/src/platform/BrowserPlatform.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index dc9d7054d9..827566d6a3 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -1,5 +1,6 @@ import { Crypto, + Encoding, /* platform */ LDOptions, Requests, @@ -7,11 +8,12 @@ import { } from '@launchdarkly/js-client-sdk-common'; import BrowserCrypto from './BrowserCrypto'; +import BrowserEncoding from './BrowserEncoding'; import BrowserRequests from './BrowserRequests'; import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; export default class BrowserPlatform /* implements platform.Platform */ { - // encoding?: Encoding; + encoding: Encoding = new BrowserEncoding(); // info: Info; // fileSystem?: Filesystem; crypto: Crypto = new BrowserCrypto(); From 7c66b7ba7b171e202e93858d6b1040062475334d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:32:02 -0700 Subject: [PATCH 15/19] Move to rollup. --- packages/sdk/browser/package.json | 13 +++--- packages/sdk/browser/rollup.config.js | 61 +++++++++++++++++++++++++++ packages/sdk/browser/src/index.ts | 6 +++ packages/sdk/browser/tsconfig.json | 1 + packages/sdk/browser/vite.config.ts | 18 -------- 5 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 packages/sdk/browser/rollup.config.js delete mode 100644 packages/sdk/browser/vite.config.ts diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 1d1c44388e..60ae7d9f05 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,10 @@ }, "devDependencies": { "@launchdarkly/private-js-mocks": "0.0.1", + "@rollup/plugin-commonjs": "^25.0.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 +58,10 @@ "jest-environment-jsdom": "^29.7.0", "prettier": "^3.0.0", "rimraf": "^5.0.5", + "rollup": "^3.23.0", + "rollup-plugin-generate-package-json": "^3.2.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 0000000000..d395193e4a --- /dev/null +++ b/packages/sdk/browser/rollup.config.js @@ -0,0 +1,61 @@ +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 generatePackageJson from 'rollup-plugin-generate-package-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: [ + generatePackageJson({ + baseContents: (pkg) => ({ + name: pkg.name, + version: pkg.version, + type: 'module', + }), + }), + typescript({ + module: 'esnext', + }), + common({ + transformMixedEsModules: true, + esmExternals: true, + }), + resolve(), + terser(), + ], + }, + { + ...getSharedConfig('cjs', 'dist/index.cjs.js'), + plugins: [ + generatePackageJson({ + baseContents: (pkg) => ({ + name: pkg.name, + version: pkg.version, + type: 'commonjs', + }), + }), + typescript(), + common(), + resolve(), + terser(), + ], + }, +]; diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index a31204dbd8..89e7f67b95 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -1,3 +1,9 @@ +import DefaultBrowserEventSource from './platform/DefaultBrowserEventSource'; + +// Temporary exports for testing the events source in a browser. +export { DefaultBrowserEventSource }; +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/tsconfig.json b/packages/sdk/browser/tsconfig.json index 79420d3d43..66b350fda3 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 74378ce665..0000000000 --- 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: {}, - }, -}); From fd8d7d63e5e52646e57d521ea022683ed59542ec Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:39:42 -0700 Subject: [PATCH 16/19] Validate package.json imports. --- packages/sdk/browser/package.json | 2 +- packages/sdk/browser/rollup.config.js | 18 +++--------------- packages/sdk/browser/src/index.ts | 5 +++-- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 60ae7d9f05..3f6f646b60 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -40,6 +40,7 @@ "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", @@ -59,7 +60,6 @@ "prettier": "^3.0.0", "rimraf": "^5.0.5", "rollup": "^3.23.0", - "rollup-plugin-generate-package-json": "^3.2.0", "ts-jest": "^29.1.1", "typedoc": "0.25.0", "typescript": "^5.5.3" diff --git a/packages/sdk/browser/rollup.config.js b/packages/sdk/browser/rollup.config.js index d395193e4a..d78a55a79d 100644 --- a/packages/sdk/browser/rollup.config.js +++ b/packages/sdk/browser/rollup.config.js @@ -2,7 +2,7 @@ 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 generatePackageJson from 'rollup-plugin-generate-package-json'; +import json from '@rollup/plugin-json'; const getSharedConfig = (format, file) => ({ input: 'src/index.ts', @@ -24,13 +24,6 @@ export default [ { ...getSharedConfig('es', 'dist/index.es.js'), plugins: [ - generatePackageJson({ - baseContents: (pkg) => ({ - name: pkg.name, - version: pkg.version, - type: 'module', - }), - }), typescript({ module: 'esnext', }), @@ -40,22 +33,17 @@ export default [ }), resolve(), terser(), + json(), ], }, { ...getSharedConfig('cjs', 'dist/index.cjs.js'), plugins: [ - generatePackageJson({ - baseContents: (pkg) => ({ - name: pkg.name, - version: pkg.version, - type: 'commonjs', - }), - }), typescript(), common(), resolve(), terser(), + json(), ], }, ]; diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 89e7f67b95..e736320369 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -1,7 +1,8 @@ +import BrowserInfo from './platform/BrowserInfo'; import DefaultBrowserEventSource from './platform/DefaultBrowserEventSource'; -// Temporary exports for testing the events source in a browser. -export { DefaultBrowserEventSource }; +// Temporary exports for testing in a browser. +export { DefaultBrowserEventSource, BrowserInfo }; export * from '@launchdarkly/js-client-sdk-common'; export function Hello() { From 61a36316bb3961dcc6a5ba45c1b00fb95ca5a3a9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:08:18 -0700 Subject: [PATCH 17/19] Configurable reset time. --- .../__tests__/platform/Backoff.test.ts | 68 ++++++++++++------- packages/sdk/browser/src/platform/Backoff.ts | 7 +- .../src/platform/DefaultBrowserEventSource.ts | 2 +- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/packages/sdk/browser/__tests__/platform/Backoff.test.ts b/packages/sdk/browser/__tests__/platform/Backoff.test.ts index 3d32614ae8..49cdd901cb 100644 --- a/packages/sdk/browser/__tests__/platform/Backoff.test.ts +++ b/packages/sdk/browser/__tests__/platform/Backoff.test.ts @@ -2,27 +2,28 @@ 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, noJitter); + 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, noJitter); + 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, noJitter); + 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, noJitter); + const backoff2 = new Backoff(1000, defaultResetInterval, noJitter); expect(backoff2.fail()).toEqual(1000); expect(backoff2.fail()).toEqual(2000); expect(backoff2.fail()).toEqual(4000); @@ -32,12 +33,12 @@ it('stops increasing delay when the max backoff is encountered', () => { }); it('handles an initial retry delay longer than the maximum retry delay', () => { - const backoff = new Backoff(40000, noJitter); + const backoff = new Backoff(40000, defaultResetInterval, noJitter); expect(backoff.fail()).toEqual(30000); }); it('jitters the backoff value', () => { - const backoff = new Backoff(1000, maxJitter); + const backoff = new Backoff(1000, defaultResetInterval, maxJitter); expect(backoff.fail()).toEqual(500); expect(backoff.fail()).toEqual(1000); expect(backoff.fail()).toEqual(2000); @@ -46,22 +47,41 @@ it('jitters the backoff value', () => { expect(backoff.fail()).toEqual(15000); }); -it('resets the delay when the last successful connection was connected greater than RESET_INTERVAL', () => { - const backoff = new Backoff(1000, noJitter); - expect(backoff.fail(1000)).toEqual(1000); - backoff.success(2000); - expect(backoff.fail(62001)).toEqual(1000); - expect(backoff.fail(62002)).toEqual(2000); - backoff.success(64002); - expect(backoff.fail(124003)).toEqual(1000); -}); +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('does not reset the delay when the connection did not persist longer than the RESET_INTERVAL', () => { - const backoff = new Backoff(1000, noJitter); - expect(backoff.fail(1000)).toEqual(1000); - backoff.success(2000); - expect(backoff.fail(61000)).toEqual(2000); - expect(backoff.fail(120000)).toEqual(4000); - backoff.success(124000); - expect(backoff.fail(183000)).toEqual(8000); -}); +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/src/platform/Backoff.ts b/packages/sdk/browser/src/platform/Backoff.ts index 59e28b5b82..f90bcd7c4d 100644 --- a/packages/sdk/browser/src/platform/Backoff.ts +++ b/packages/sdk/browser/src/platform/Backoff.ts @@ -1,6 +1,5 @@ const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds. const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time. -const RESET_INTERVAL = 60 * 1000; // Reset interval in seconds. /** * Implements exponential backoff and jitter. This class tracks successful connections and failures @@ -24,6 +23,7 @@ export default class Backoff { constructor( initialRetryDelayMillis: number, + private readonly retryResetIntervalMillis: number, private readonly random = Math.random, ) { // Initial retry delay cannot be 0. @@ -62,7 +62,10 @@ export default class Backoff { 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 > RESET_INTERVAL) { + if ( + this.activeSince !== undefined && + timeStampMs - this.activeSince > this.retryResetIntervalMillis + ) { this.retryCount = 0; } this.activeSince = undefined; diff --git a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts index c560c86daf..b9084349bc 100644 --- a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -34,7 +34,7 @@ export default class DefaultBrowserEventSource implements LDEventSource { private readonly url: string, options: EventSourceInitDict, ) { - this.backoff = new Backoff(options.initialRetryDelayMillis); + this.backoff = new Backoff(options.initialRetryDelayMillis, options.retryResetIntervalMillis); this.errorFilter = options.errorFilter; this.openConnection(); } From af341cb7a42e71d8253c822d920ade1aab26682a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:26:32 -0700 Subject: [PATCH 18/19] Implements platform. --- packages/sdk/browser/src/platform/BrowserPlatform.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index 3267969c40..34dfccaefa 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -2,8 +2,8 @@ import { Crypto, Encoding, Info, - /* platform */ LDOptions, + Platform, Requests, Storage, } from '@launchdarkly/js-client-sdk-common'; @@ -14,7 +14,7 @@ import BrowserInfo from './BrowserInfo'; import BrowserRequests from './BrowserRequests'; import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; -export default class BrowserPlatform /* implements platform.Platform */ { +export default class BrowserPlatform implements Platform { encoding: Encoding = new BrowserEncoding(); info: Info = new BrowserInfo(); // fileSystem?: Filesystem; From ff1dc23d4bead265a92077e99424a04a269b3b57 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:58:35 -0700 Subject: [PATCH 19/19] Correct fetch. --- packages/sdk/browser/src/platform/BrowserRequests.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/browser/src/platform/BrowserRequests.ts b/packages/sdk/browser/src/platform/BrowserRequests.ts index 52d4d0dbd4..5e73467843 100644 --- a/packages/sdk/browser/src/platform/BrowserRequests.ts +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -11,7 +11,8 @@ import DefaultBrowserEventSource from './DefaultBrowserEventSource'; export default class BrowserRequests implements Requests { fetch(url: string, options?: Options): Promise { - return this.fetch(url, options); + // @ts-ignore + return fetch(url, options); } createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource {