diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 59269c2cbb..4835da05a2 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -115,9 +115,29 @@ jobs: with: test_service_port: 8000 token: ${{ secrets.GITHUB_TOKEN }} + stop_service: 'false' version: v3 extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt' + # Reopen the entity with the fetch EventSource toggle. The adapter recreates its REST + # server when the new page connects over the WebSocket. + - name: Restart browser app with the fetch EventSource + run: | + kill $(cat /tmp/playwright.pid) || true + node packages/sdk/browser/contract-tests/entity/open-browser.mjs 'http://localhost:5173/?eventsource=fetch' > /tmp/playwright-fetch.log 2>&1 & + echo $! > /tmp/playwright.pid + sleep 5 # Give the browser time to initialize and connect via WebSocket + + # This run proves the fetch-based EventSource against the full suite. It uses its own + # skip list, which does not contain the native EventSource transport limits. + - name: Run contract tests (FDv2, fetch EventSource) + uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1 + with: + test_service_port: 8000 + token: ${{ secrets.GITHUB_TOKEN }} + version: v3 + extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions_custom_eventsource.txt' + - name: Print logs on failure if: failure() run: | @@ -127,6 +147,8 @@ jobs: cat /tmp/http-server.log || echo "No http-server log" echo "=== Playwright Log ===" cat /tmp/playwright.log || echo "No playwright log" + echo "=== Playwright Log (fetch EventSource) ===" + cat /tmp/playwright-fetch.log || echo "No fetch playwright log" - name: Cleanup contract test services if: always() diff --git a/.github/workflows/react.yml b/.github/workflows/react.yml index 014027f59b..a51ae29986 100644 --- a/.github/workflows/react.yml +++ b/.github/workflows/react.yml @@ -37,7 +37,7 @@ jobs: # Skip Electron binary download to avoid flaky race condition during concurrent installs. ELECTRON_SKIP_BINARY_DOWNLOAD: '1' run: | - yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-sdk-contract-tests' install + yarn workspaces focus @launchdarkly/react-sdk-contract-tests yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-sdk-contract-tests' run build - name: Install Playwright browsers diff --git a/package.json b/package.json index 1a66c54eb2..e0ad23a433 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "packages/sdk/shopify-oxygen/example", "packages/sdk/browser/example", "packages/sdk/browser/example-fdv2", + "packages/sdk/browser/examples/features/custom-eventsource", "packages/sdk/openfeature-node-server", "packages/sdk/openfeature-node-server/examples/getting-started", "packages/sdk/openfeature-cloudflare-server", diff --git a/packages/sdk/browser/__tests__/BrowserClient.eventSource.test.ts b/packages/sdk/browser/__tests__/BrowserClient.eventSource.test.ts new file mode 100644 index 0000000000..8c992cb80b --- /dev/null +++ b/packages/sdk/browser/__tests__/BrowserClient.eventSource.test.ts @@ -0,0 +1,65 @@ +import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import { makeClient } from '../src/BrowserClient'; + +const mockPlatformConstructor = jest.fn(); + +jest.mock('../src/platform/BrowserPlatform', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((logger, options, storage, eventSourceFactory) => { + mockPlatformConstructor(logger, options, storage, eventSourceFactory); + const { makeBasicPlatform } = require('./BrowserClient.mocks'); + const platform = makeBasicPlatform(options); + return platform; + }), +})); + +let logger: LDLogger; + +beforeEach(() => { + jest.clearAllMocks(); + logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +}); + +function getWiredEventSource() { + return mockPlatformConstructor.mock.calls[0][3]; +} + +it('passes a valid event source factory through to the platform unchanged', () => { + const eventSource = { createEventSource: jest.fn() }; + + makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { + streaming: false, + logger, + diagnosticOptOut: true, + eventSource, + }); + + expect(getWiredEventSource()).toBe(eventSource); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('does not pass an event source factory when none is configured', () => { + makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { + streaming: false, + logger, + diagnosticOptOut: true, + }); + + expect(getWiredEventSource()).toBeUndefined(); +}); + +it('drops an invalid event source factory and warns', () => { + makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, { + streaming: false, + logger, + diagnosticOptOut: true, + // @ts-ignore + eventSource: 'not an object', + }); + + expect(getWiredEventSource()).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "eventSource" should be of type object, got string, using default value', + ); +}); diff --git a/packages/sdk/browser/__tests__/fetch-eventsource/fetchBrowserEventSource.test.ts b/packages/sdk/browser/__tests__/fetch-eventsource/fetchBrowserEventSource.test.ts new file mode 100644 index 0000000000..8b4b3b1c35 --- /dev/null +++ b/packages/sdk/browser/__tests__/fetch-eventsource/fetchBrowserEventSource.test.ts @@ -0,0 +1,56 @@ +import { createEventSource as mockedCreateEventSource } from '@launchdarkly/eventsource'; +import { EventSourceInitDict } from '@launchdarkly/js-client-sdk-common'; + +import fetchBrowserEventSource from '../../src/fetch-eventsource/fetchBrowserEventSource'; + +// The fetch-based EventSource needs a streaming fetch, which jsdom does not provide. Only the +// adapter is under test here; the implementation itself is covered by the eventsource package's +// own suite. +jest.mock('@launchdarkly/eventsource', () => ({ + createEventSource: jest.fn(), +})); + +const mockCreateEventSource = mockedCreateEventSource as unknown as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('declares support for headers, a custom method, and a read timeout', () => { + expect(fetchBrowserEventSource.capabilities).toEqual({ + customMethod: true, + readTimeout: true, + headers: true, + }); +}); + +it('passes the url and every option through, plus the backoff defaults', () => { + const urlBuilder = () => 'http://example.com/stream?basis=2'; + const errorFilter = () => true; + const initDict: EventSourceInitDict = { + method: 'REPORT', + headers: { authorization: 'sdk-key' }, + body: '{"kind":"user"}', + errorFilter, + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + urlBuilder, + }; + + fetchBrowserEventSource.createEventSource('http://example.com/stream', initDict); + + expect(mockCreateEventSource).toHaveBeenCalledTimes(1); + expect(mockCreateEventSource).toHaveBeenCalledWith('http://example.com/stream', { + method: 'REPORT', + headers: { authorization: 'sdk-key' }, + body: '{"kind":"user"}', + errorFilter, + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + urlBuilder, + maxBackoffMillis: 30000, + jitterRatio: 0.5, + }); +}); diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts index 59ca837a1a..b30032fac0 100644 --- a/packages/sdk/browser/__tests__/options.test.ts +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -119,3 +119,42 @@ it('passes an empty dataSystem through to base options', () => { const result = filterToBaseOptionsWithDefaults(opts); expect((result as any).dataSystem).toEqual({}); }); + +it('accepts an event source factory', () => { + const eventSource = { createEventSource: jest.fn() } as unknown as NonNullable< + Parameters[0]['eventSource'] + >; + + const opts = validateBrowserOptions({ eventSource }, logger); + + expect(opts.eventSource).toBe(eventSource); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('warns and drops an event source that is not an object', () => { + const opts = validateBrowserOptions( + { + // @ts-ignore + eventSource: 'not an object', + }, + logger, + ); + + expect(opts.eventSource).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "eventSource" should be of type object, got string, using default value', + ); +}); + +it('defaults the event source factory to undefined', () => { + const opts = validateBrowserOptions({}, logger); + expect(opts.eventSource).toBeUndefined(); +}); + +it('strips the event source factory from base options', () => { + const eventSource = { createEventSource: jest.fn() } as unknown as NonNullable< + Parameters[0]['eventSource'] + >; + const baseOpts = filterToBaseOptionsWithDefaults({ eventSource }); + expect(baseOpts).not.toHaveProperty('eventSource'); +}); diff --git a/packages/sdk/browser/__tests__/platform/BrowserPlatform.test.ts b/packages/sdk/browser/__tests__/platform/BrowserPlatform.test.ts index f86bb4018e..32887a853d 100644 --- a/packages/sdk/browser/__tests__/platform/BrowserPlatform.test.ts +++ b/packages/sdk/browser/__tests__/platform/BrowserPlatform.test.ts @@ -1,4 +1,9 @@ -import { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; +import { + EventSourceInitDict, + LDEventSourceFactory, + LDLogger, + Storage, +} from '@launchdarkly/js-client-sdk-common'; import BrowserPlatform from '../../src/platform/BrowserPlatform'; import LocalStorage, { isLocalStorageSupported } from '../../src/platform/LocalStorage'; @@ -63,3 +68,38 @@ it('uses the provided storage override even when localStorage is unsupported', ( expect(platform.storage).toBe(custom); expect(mockLocalStorage).not.toHaveBeenCalled(); }); + +it('reports no optional event source capabilities when no factory override is provided', () => { + const platform = new BrowserPlatform(logger, {}); + + expect(platform.requests.getEventSourceCapabilities()).toEqual({ + customMethod: false, + readTimeout: false, + headers: false, + }); +}); + +it('uses the provided event source factory override', () => { + const created = { close: jest.fn() }; + const factory: LDEventSourceFactory = { + createEventSource: jest.fn().mockReturnValue(created), + capabilities: { customMethod: true, readTimeout: true, headers: true }, + }; + + const platform = new BrowserPlatform(logger, {}, undefined, factory); + const initDict: EventSourceInitDict = { + headers: { authorization: 'sdk-key' }, + errorFilter: () => true, + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }; + + expect(platform.requests.createEventSource('http://example.com/stream', initDict)).toBe(created); + expect(factory.createEventSource).toHaveBeenCalledWith('http://example.com/stream', initDict); + expect(platform.requests.getEventSourceCapabilities()).toEqual({ + customMethod: true, + readTimeout: true, + headers: true, + }); +}); diff --git a/packages/sdk/browser/__tests__/platform/BrowserRequests.test.ts b/packages/sdk/browser/__tests__/platform/BrowserRequests.test.ts new file mode 100644 index 0000000000..47ac30658a --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/BrowserRequests.test.ts @@ -0,0 +1,69 @@ +import { EventSourceInitDict, LDEventSourceFactory } from '@launchdarkly/js-client-sdk-common'; + +import BrowserRequests from '../../src/platform/BrowserRequests'; +import DefaultBrowserEventSource from '../../src/platform/DefaultBrowserEventSource'; + +const initDict: EventSourceInitDict = { + headers: { authorization: 'sdk-key' }, + errorFilter: () => true, + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, +}; + +beforeEach(() => { + // jsdom does not implement EventSource, and DefaultBrowserEventSource constructs one as soon as + // it is created. + // @ts-ignore + globalThis.EventSource = jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + })); +}); + +it('reports no optional capabilities by default', () => { + const requests = new BrowserRequests(); + + expect(requests.getEventSourceCapabilities()).toEqual({ + customMethod: false, + readTimeout: false, + headers: false, + }); +}); + +it('creates a native browser event source by default', () => { + const requests = new BrowserRequests(); + + const es = requests.createEventSource('http://example.com/stream', initDict); + + expect(es).toBeInstanceOf(DefaultBrowserEventSource); + es.close(); +}); + +it('uses an injected factory and its declared capabilities', () => { + const created = { close: jest.fn() }; + const factory: LDEventSourceFactory = { + createEventSource: jest.fn().mockReturnValue(created), + capabilities: { customMethod: true, readTimeout: true, headers: true }, + }; + const requests = new BrowserRequests(factory); + + expect(requests.createEventSource('http://example.com/stream', initDict)).toBe(created); + expect(factory.createEventSource).toHaveBeenCalledWith('http://example.com/stream', initDict); + expect(requests.getEventSourceCapabilities()).toEqual({ + customMethod: true, + readTimeout: true, + headers: true, + }); +}); + +it('reports no optional capabilities for a factory that declares none', () => { + const factory: LDEventSourceFactory = { createEventSource: jest.fn() }; + const requests = new BrowserRequests(factory); + + expect(requests.getEventSourceCapabilities()).toEqual({ + customMethod: false, + readTimeout: false, + headers: false, + }); +}); diff --git a/packages/sdk/browser/contract-tests/README.md b/packages/sdk/browser/contract-tests/README.md index 9e2924c9df..e471fca85e 100644 --- a/packages/sdk/browser/contract-tests/README.md +++ b/packages/sdk/browser/contract-tests/README.md @@ -80,7 +80,18 @@ go run . --url http://localhost:8000 \ #### Suppression files -Two skip-lists live alongside this README; pass the appropriate one to `-skip-from`: +Three skip-lists live alongside this README; pass the appropriate one to `-skip-from`: -- **`suppressions.txt`** — the standard skip list for the Browser SDK contract tests. -- **`suppressions_datamode_changes.txt`** — a broader skip list used when running against the FDv2 (data system / "data mode") client-side work. In addition to the standard skips, it excludes request tests that are not expected to pass in that configuration yet (the HTTP `REPORT` streaming/polling request variants and the `tags` streaming/polling request tests), giving a clean baseline on that branch. +- **`suppressions.txt`** — the standard skip list for the Browser SDK contract tests (harness v2). It skips tests that the native browser EventSource can never pass: `REPORT` streaming and the `tags` stream-request header assertions. +- **`suppressions_datamode_changes.txt`** — the skip list for the FDv2 (data system / "data mode") run (harness v3). It skips the `tags` request tests that fail because the SDK's FDv2 path does not send the tags header. +- **`suppressions_custom_eventsource.txt`** — the skip list for the fetch EventSource run (harness v3). In this run the entity forces the fetch-based EventSource for every client, so no native transport limits apply. Only the FDv2-path `tags` gaps remain. + +#### Fetch EventSource run + +Open the entity with `?eventsource=fetch` to make it use the fetch-based EventSource for every client and advertise the `client-use-post` capability: + +```bash +node packages/sdk/browser/contract-tests/entity/open-browser.mjs 'http://localhost:5173/?eventsource=fetch' & +``` + +Then run the v3 harness with `suppressions_custom_eventsource.txt`. This proves the custom EventSource against the full suite, including `usePost` and `REPORT` streaming. diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 124d31ae41..07ec86f112 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -7,6 +7,7 @@ import { ModeDefinition, SynchronizerEntry, } from '@launchdarkly/js-client-sdk'; +import { fetchBrowserEventSource } from '@launchdarkly/js-client-sdk/fetch-eventsource'; import { CommandParams, CommandType, @@ -24,6 +25,15 @@ import { export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); +/** + * True when the test page was opened with `?eventsource=fetch`. A dedicated CI run opens the + * entity with this query parameter to run the full suite against the fetch-based EventSource. + * Other runs use the SDK defaults. + */ +export function useFetchEventSource(): boolean { + return new URLSearchParams(window.location.search).get('eventsource') === 'fetch'; +} + function translateInitializer(init: SDKConfigDataInitializer): InitializerEntry | undefined { if (init.polling) { return { @@ -109,6 +119,15 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { useReport: options.clientSide.useReport ?? undefined, }; + // In the fetch EventSource run, every client uses the fetch-based EventSource. This proves + // the custom EventSource against the full suite, including usePost and REPORT streaming. + if (useFetchEventSource()) { + cf.eventSource = fetchBrowserEventSource; + } + if (options.clientSide.usePost) { + cf.usePost = true; + } + if (options.serviceEndpoints) { cf.streamUri = options.serviceEndpoints.streaming; cf.baseUri = options.serviceEndpoints.polling; diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 9bde09419b..00e8365e9a 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,7 +1,7 @@ import { LDLogger } from '@launchdarkly/js-client-sdk'; import { makeLogger } from '@launchdarkly/js-contract-test-utils/client'; -import { ClientEntity, newSdkClientEntity } from './ClientEntity'; +import { ClientEntity, newSdkClientEntity, useFetchEventSource } from './ClientEntity'; export default class TestHarnessWebSocket { private _ws?: WebSocket; @@ -49,6 +49,12 @@ export default class TestHarnessWebSocket { 'bootstrap', ]; + // The native browser EventSource cannot send a POST request. Only advertise usePost + // support when the run uses the fetch-based EventSource. + if (useFetchEventSource()) { + resData.capabilities.push('client-use-post'); + } + break; case 'createClient': try { diff --git a/packages/sdk/browser/contract-tests/suppressions.txt b/packages/sdk/browser/contract-tests/suppressions.txt index a4b3ab831b..c6134a91e9 100644 --- a/packages/sdk/browser/contract-tests/suppressions.txt +++ b/packages/sdk/browser/contract-tests/suppressions.txt @@ -1,9 +1,14 @@ +# This run uses the SDK defaults, so streaming uses the native browser EventSource. +# The native EventSource can only send GET requests. The REPORT streaming tests can +# never pass in this configuration. The fetch EventSource run covers them. streaming/requests/method and headers/REPORT/http streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT streaming/requests/context properties/single kind minimal/REPORT streaming/requests/context properties/single kind with all attributes/REPORT streaming/requests/context properties/multi-kind/REPORT +# The native EventSource cannot send request headers, so the tags header never +# reaches the stream endpoint. tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} @@ -15,4 +20,4 @@ tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null} tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""} tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} -tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} \ No newline at end of file +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} diff --git a/packages/sdk/browser/contract-tests/suppressions_custom_eventsource.txt b/packages/sdk/browser/contract-tests/suppressions_custom_eventsource.txt new file mode 100644 index 0000000000..8fe2868b57 --- /dev/null +++ b/packages/sdk/browser/contract-tests/suppressions_custom_eventsource.txt @@ -0,0 +1,36 @@ +# Skip list for the fetch EventSource run (harness v3). The entity forces the +# fetch-based EventSource for every client, so no transport limits apply here. +# The remaining suppressions are gaps in the SDK's FDv2 path, not in the transport. +# +# Under FDv2 (dataSystem configured) the SDK does not send the tags header on any +# request, so every tags assertion that expects a non-empty header value fails. This +# includes polling requests, which can send headers -- the gap is in the SDK's FDv2 +# path, not in the transport. +tags/FDv1 fallback directive requests +tags/disallowed characters +tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"","applicationVersion":null} +tags/stream requests/{"applicationId":"","applicationVersion":""} +tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} diff --git a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt index cf43b4eb45..e1283dc9b9 100644 --- a/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt +++ b/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt @@ -1,33 +1,9 @@ -# Browser's native EventSource cannot read response headers/status on the streaming -# connection, so it can never observe the directive this test relies on. +# Under FDv2 (dataSystem configured) the SDK does not send the tags header on any +# request, so every tags assertion that expects a non-empty header value fails. This +# includes polling requests, which can send headers -- the gap is in the SDK's FDv2 +# path, not in the transport. tags/FDv1 fallback directive requests - -# Same root cause as above: DefaultBrowserEventSource reports headers: false -# unconditionally on both open and error events, so none of the FDv1 fallback -# directive scenarios below can be observed on browser, not just the error path. -streaming/fdv2/FDv1 fallback directive/directive on streaming error engages FDv1 fallback -streaming/fdv2/FDv1 fallback directive/directive on streaming success applies payload then engages FDv1 -streaming/fdv2/FDv1 fallback directive/directive without FDv1 fallback configured halts the data system -streaming/fdv2/FDv1 fallback directive/directed fallback is terminal and does not revisit FDv2 sources - -streaming/requests/method and headers/REPORT/http -streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT -streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -streaming/requests/query parameters/evaluationReasons set to [none]/REPORT -streaming/requests/query parameters/evaluationReasons set to false/REPORT -streaming/requests/query parameters/evaluationReasons set to true/REPORT -streaming/requests/context properties/single kind minimal/REPORT -streaming/requests/context properties/single kind with all attributes/REPORT -streaming/requests/context properties/multi-kind/REPORT -polling/requests/method and headers/REPORT/http -polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT -polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -polling/requests/query parameters/evaluationReasons set to [none]/REPORT -polling/requests/query parameters/evaluationReasons set to false/REPORT -polling/requests/query parameters/evaluationReasons set to true/REPORT -polling/requests/context properties/single kind minimal/REPORT -polling/requests/context properties/single kind with all attributes/REPORT -polling/requests/context properties/multi-kind/REPORT +tags/disallowed characters tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} tags/stream requests/{"applicationId":"","applicationVersion":null} @@ -54,4 +30,3 @@ tags/poll requests/{"applicationId":"___________________________________________ tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":""} tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} -tags/disallowed characters diff --git a/packages/sdk/browser/examples/features/custom-eventsource/.env.template b/packages/sdk/browser/examples/features/custom-eventsource/.env.template new file mode 100644 index 0000000000..75b8c17e11 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/.env.template @@ -0,0 +1,5 @@ +# Set LAUNCHDARKLY_CLIENT_SIDE_ID to your LaunchDarkly client-side ID +LAUNCHDARKLY_CLIENT_SIDE_ID= + +# Set LAUNCHDARKLY_FLAG_KEY to the feature flag key you want to evaluate +LAUNCHDARKLY_FLAG_KEY= diff --git a/packages/sdk/browser/examples/features/custom-eventsource/README.md b/packages/sdk/browser/examples/features/custom-eventsource/README.md new file mode 100644 index 0000000000..2651b5bdd4 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/README.md @@ -0,0 +1,78 @@ +# LaunchDarkly Browser SDK custom EventSource example + +A minimal vanilla-TypeScript app that demonstrates the Browser SDK's injectable +`eventSource` option. + +## What it demonstrates + +The Browser SDK defaults to the native browser `EventSource` API for its streaming +connection. That API has no way to send a custom HTTP method or a request body, so with +`usePost: true` alone the SDK logs a warning and the streaming connection falls back to +GET; only polling requests use POST. + +This example passes `fetchBrowserEventSource` (from +`@launchdarkly/js-client-sdk/fetch-eventsource`) as the `eventSource` option, in +[`src/app.ts`](./src/app.ts). That option opts into a `fetch()`-based EventSource +implementation which _can_ send a POST request with a body, which is what lets +`usePost` actually take effect. Open your browser's devtools Network tab while running +this example and look for a `POST` request to the streaming endpoint, instead of the +`GET`-based request the default transport would make. + +This example also enables the FDv2 data system (`dataSystem: {}`). `usePost` only applies +to FDv2's streaming synchronizer -- the FDv1 data source uses `useReport` instead, which +this example does not set. + +`fetchBrowserEventSource` lives behind its own entry point specifically so that neither it +nor its dependency (`@launchdarkly/eventsource`) ends up in the SDK's default bundle -- +only an application that imports this path pays for it. `@launchdarkly/eventsource` is an +optional dependency of the SDK. If your install omits optional dependencies (for example +`npm install --omit=optional`), install it directly before you import this entry point. + +## Prerequisites + +Nodejs 20.6.0 or later + +## Build instructions + +1. From the repository root, build the SDK and its dependencies first: + + ```bash + yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/js-client-sdk' run build + ``` + +2. Make a copy of the `.env.template` and name it `.env` + + ``` + cp .env.template .env + ``` + +3. Set the variables in `.env` to your specific LD values + + ``` + # Set LAUNCHDARKLY_CLIENT_SIDE_ID to your LaunchDarkly client-side ID + LAUNCHDARKLY_CLIENT_SIDE_ID= + + # Set LAUNCHDARKLY_FLAG_KEY to the feature flag key you want to evaluate + LAUNCHDARKLY_FLAG_KEY= + ``` + + > [!NOTE] + > Setting these values is equivalent to modifying the `clientSideID` and `flagKey` + > in [app.ts](./src/app.ts). + +4. Install and build the example: + + ```bash + yarn workspace @launchdarkly/browser-example-custom-eventsource build + ``` + +5. Run the example: + ```bash + yarn workspace @launchdarkly/browser-example-custom-eventsource start + ``` + > [!NOTE] + > The `start` script simply runs `open index.html`. If that is not working for you, you + > can open the `index.html` file in a browser for the same results. + +The application will run continuously and react to flag changes in LaunchDarkly, streaming +over the fetch-based EventSource the whole time. diff --git a/packages/sdk/browser/examples/features/custom-eventsource/index.css b/packages/sdk/browser/examples/features/custom-eventsource/index.css new file mode 100644 index 0000000000..90dc2b50f1 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + background: #373841; + color: white; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', + 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-align: center; +} diff --git a/packages/sdk/browser/examples/features/custom-eventsource/index.html b/packages/sdk/browser/examples/features/custom-eventsource/index.html new file mode 100644 index 0000000000..b469548c25 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/index.html @@ -0,0 +1,11 @@ + + + + + + LaunchDarkly custom EventSource example + + + + + diff --git a/packages/sdk/browser/examples/features/custom-eventsource/package.json b/packages/sdk/browser/examples/features/custom-eventsource/package.json new file mode 100644 index 0000000000..7ec87e20a3 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/package.json @@ -0,0 +1,25 @@ +{ + "name": "@launchdarkly/browser-example-custom-eventsource", + "version": "0.0.0", + "private": true, + "description": "LaunchDarkly example for the JavaScript Browser SDK's injectable EventSource", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/browser/examples/features/custom-eventsource", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "start": "open index.html", + "clean": "rm -rf dist dist-static", + "build": "npm run clean && tsdown" + }, + "dependencies": { + "@launchdarkly/js-client-sdk": "4.11.2" + }, + "devDependencies": { + "tsdown": "^0.17.0-beta.4", + "typescript": "^5.9.3" + } +} diff --git a/packages/sdk/browser/examples/features/custom-eventsource/src/app.ts b/packages/sdk/browser/examples/features/custom-eventsource/src/app.ts new file mode 100644 index 0000000000..de4fd26cf7 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/src/app.ts @@ -0,0 +1,105 @@ +import { createClient } from '@launchdarkly/js-client-sdk'; +import { fetchBrowserEventSource } from '@launchdarkly/js-client-sdk/fetch-eventsource'; + +// Set clientSideID to your LaunchDarkly client-side ID +const clientSideID = 'LD_CLIENT_SIDE_ID'; + +// Set flagKey to the feature flag key you want to evaluate +const flagKey = 'LD_FLAG_KEY'; + +// Set up the evaluation context. This context should appear on your +// LaunchDarkly contexts dashboard soon after you run the demo. +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Sandy', +}; + +const div = document.createElement('div'); +const statusBox = document.createElement('div'); +const transportBox = document.createElement('div'); + +document.body.appendChild(statusBox); +document.body.appendChild(transportBox); +document.body.appendChild(div); + +div.appendChild(document.createTextNode('No flag evaluations yet')); +statusBox.appendChild(document.createTextNode('Initializing...')); +transportBox.appendChild(document.createTextNode('')); + +const main = async () => { + // The native browser EventSource API cannot send a custom HTTP method or a request body. + // With `usePost: true` alone the SDK warns and the stream falls back to GET. Passing + // `fetchBrowserEventSource` as the `eventSource` option opts into a fetch()-based transport + // that can send POST with a body, which lets `usePost` take effect. Open your browser's + // devtools Network tab and look for a POST request to the streaming endpoint. + // + // The `eventSource` option applies to both data systems, but `usePost` only applies to the + // FDv2 (`dataSystem`) data source -- FDv1 uses `useReport` instead. + const ldclient = createClient(clientSideID, context, { + usePost: true, + eventSource: fetchBrowserEventSource, + dataSystem: {}, + }); + + const render = () => { + const flagValue = ldclient.variation(flagKey, false); + const label = `The ${flagKey} feature flag evaluates to ${flagValue}.`; + document.body.style.background = flagValue ? '#00844B' : '#373841'; + div.replaceChild(document.createTextNode(label), div.firstChild as Node); + }; + + ldclient.on('error', () => { + statusBox.replaceChild( + document.createTextNode('Error caught in client SDK'), + statusBox.firstChild as Node, + ); + }); + + // Listen for flag changes + ldclient.on('change', () => { + render(); + }); + + ldclient.start(); + + const { status } = await ldclient.waitForInitialization(); + + // Both calls are required to open a stream. setStreaming forces the foreground mode to + // 'streaming' instead of the browser default of 'one-shot' (a single fetch with no ongoing + // connection). setConnectionMode overrides the resolved mode outright. + ldclient.setStreaming(true); + ldclient.setConnectionMode('streaming'); + + if (status === 'complete') { + statusBox.replaceChild( + document.createTextNode(`Initialized with context: ${JSON.stringify(ldclient.getContext())}`), + statusBox.firstChild as Node, + ); + transportBox.replaceChild( + document.createTextNode( + 'Streaming via the fetch()-based EventSource under FDv2 (dataSystem), with POST enabled by usePost.', + ), + transportBox.firstChild as Node, + ); + } else if (status === 'failed') { + statusBox.replaceChild( + document.createTextNode('Error identifying client'), + statusBox.firstChild as Node, + ); + } else if (status === 'timeout') { + statusBox.replaceChild( + document.createTextNode('Timeout identifying client'), + statusBox.firstChild as Node, + ); + } else { + statusBox.replaceChild( + document.createTextNode('Unknown error identifying client'), + statusBox.firstChild as Node, + ); + } + + render(); +}; + +main(); diff --git a/packages/sdk/browser/examples/features/custom-eventsource/tsconfig.json b/packages/sdk/browser/examples/features/custom-eventsource/tsconfig.json new file mode 100644 index 0000000000..a1aec48ce3 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "lib": ["ES2017", "dom"], + "module": "ESNext", + "moduleResolution": "node", + "noImplicitOverride": true, + "resolveJsonModule": true, + "rootDir": ".", + "outDir": "dist", + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true, + "strict": true, + "stripInternal": true, + "target": "ES2017", + "allowJs": true + }, + "include": ["src"] +} diff --git a/packages/sdk/browser/examples/features/custom-eventsource/tsdown.config.ts b/packages/sdk/browser/examples/features/custom-eventsource/tsdown.config.ts new file mode 100644 index 0000000000..d9d9feb224 --- /dev/null +++ b/packages/sdk/browser/examples/features/custom-eventsource/tsdown.config.ts @@ -0,0 +1,40 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import fs from 'node:fs'; +import path from 'node:path'; +import { loadEnvFile } from 'node:process'; +import { defineConfig } from 'tsdown'; + +if (fs.existsSync('.env')) { + loadEnvFile('.env'); +} + +const ENTRY_FILE = path.join('src', 'app.ts'); +const OUTPUT_FILE = path.join('dist', 'app.js'); +const { LAUNCHDARKLY_CLIENT_SIDE_ID, LAUNCHDARKLY_FLAG_KEY } = process.env; + +const CLIENT_SIDE_ID_PLACEHOLDER = 'LD_CLIENT_SIDE_ID'; +const FLAG_KEY_PLACEHOLDER = 'LD_FLAG_KEY'; + +export default defineConfig({ + entry: ENTRY_FILE, + platform: 'browser', + outDir: 'dist', + // The fetch-eventsource subpath needs its own entry here. noExternal matches the exact + // specifier, not the package prefix. With only the package root listed, this subpath's + // `import` stays unbundled in the output, and a plain `