From fdf83b060c45cdc0833a565f3022ade3b2af98d8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:54:36 -0700 Subject: [PATCH] fix: browser FDv2 contract test support - Add start:headless script for running contract test entity without auto-opening a browser window - Handle top-level dataSystem initializers/synchronizers in the contract test entity by wrapping them into a streaming connection mode - Fix browser EventSource onerror firing for server-sent "error" SSE events by checking for MessageEvent instances - Add urlBuilder to EventSourceInitDict so the browser EventSource can refresh the URL (including basis param) on reconnection - Pass buildStreamUri as urlBuilder in StreamingFDv2Base --- .gitignore | 1 + .../contract-tests/entity/package.json | 1 + .../contract-tests/entity/src/ClientEntity.ts | 51 ++++++++++++------- .../src/platform/DefaultBrowserEventSource.ts | 15 +++++- .../common/src/api/platform/EventSource.ts | 7 +++ .../src/datasource/fdv2/StreamingFDv2Base.ts | 1 + 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 6f7b40da26..5f456b045f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ stats.html .env.local .env.*.local .claude/worktrees +.mcp.json diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index c0b3c1c2eb..05dca863be 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -7,6 +7,7 @@ "scripts": { "install-playwright-browsers": "playwright install --with-deps chromium", "start": "tsc --noEmit && vite --open=true", + "start:headless": "tsc --noEmit && vite", "build": "tsc --noEmit && vite build", "lint": "eslint ./src", "start:adapter": "sdk-testharness-server adapter", diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 6e68ff061e..17a88b61fc 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -103,6 +103,24 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { if (options.dataSystem) { const dataSystem: any = {}; + // Helper to apply endpoint overrides from a mode definition to global URIs. + const applyEndpointOverrides = (modeDef: SDKConfigModeDefinition) => { + (modeDef.synchronizers ?? []).forEach((sync) => { + if (sync.streaming?.baseUri) { + cf.streamUri = sync.streaming.baseUri; + cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); + } + if (sync.polling?.baseUri) { + cf.baseUri = sync.polling.baseUri; + } + }); + (modeDef.initializers ?? []).forEach((init) => { + if (init.polling?.baseUri) { + cf.baseUri = init.polling.baseUri; + } + }); + }; + if (options.dataSystem.connectionModeConfig) { const connMode = options.dataSystem.connectionModeConfig; dataSystem.automaticModeSwitching = connMode.initialConnectionMode @@ -113,26 +131,25 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { const connectionModes: Record = {}; Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => { connectionModes[modeName] = translateModeDefinition(modeDef); - - // Per-entry endpoint overrides also set global URIs for ServiceEndpoints - // compatibility. These override the serviceEndpoints values above. - (modeDef.synchronizers ?? []).forEach((sync) => { - if (sync.streaming?.baseUri) { - cf.streamUri = sync.streaming.baseUri; - cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); - } - if (sync.polling?.baseUri) { - cf.baseUri = sync.polling.baseUri; - } - }); - (modeDef.initializers ?? []).forEach((init) => { - if (init.polling?.baseUri) { - cf.baseUri = init.polling.baseUri; - } - }); + applyEndpointOverrides(modeDef); }); dataSystem.connectionModes = connectionModes; } + } else if (options.dataSystem.initializers || options.dataSystem.synchronizers) { + // Top-level initializers/synchronizers (no connection modes). Wrap them + // into a single 'streaming' connection mode for the browser SDK. + const modeDef: SDKConfigModeDefinition = { + initializers: options.dataSystem.initializers, + synchronizers: options.dataSystem.synchronizers, + }; + dataSystem.automaticModeSwitching = { + type: 'manual', + initialConnectionMode: 'streaming', + }; + dataSystem.connectionModes = { + streaming: translateModeDefinition(modeDef), + }; + applyEndpointOverrides(modeDef); } (cf as any).dataSystem = dataSystem; diff --git a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts index 55878a5968..4a2ea36998 100644 --- a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -23,6 +23,7 @@ export default class DefaultBrowserEventSource implements LDEventSource { private _es?: EventSource; private _backoff: DefaultBackoff; private _errorFilter: (err: HttpErrorResponse) => boolean; + private _urlBuilder?: () => string; // The type of the handle can be platform specific and we treat is opaquely. private _reconnectTimeoutHandle?: any; @@ -30,7 +31,7 @@ export default class DefaultBrowserEventSource implements LDEventSource { private _listeners: Record = {}; constructor( - private readonly _url: string, + private _url: string, options: EventSourceInitDict, ) { this._backoff = new DefaultBackoff( @@ -38,6 +39,7 @@ export default class DefaultBrowserEventSource implements LDEventSource { options.retryResetIntervalMillis, ); this._errorFilter = options.errorFilter; + this._urlBuilder = options.urlBuilder; this._openConnection(); } @@ -50,6 +52,9 @@ export default class DefaultBrowserEventSource implements LDEventSource { onretrying: ((e: { delayMillis: number }) => void) | undefined; private _openConnection() { + if (this._urlBuilder) { + this._url = this._urlBuilder(); + } this._es = new EventSource(this._url); this._es.onopen = () => { this._backoff.success(); @@ -58,6 +63,14 @@ export default class DefaultBrowserEventSource implements LDEventSource { // 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) => { + // In browsers, a server-sent "event: error" SSE message fires both + // addEventListener('error', ...) AND onerror. We must not treat it as a + // connection failure. A server-sent error arrives as a MessageEvent while + // the connection is still open; a real connection error is a plain Event + // with readyState !== OPEN. + if (err instanceof MessageEvent) { + return; + } this._handleError(err); this.onerror?.(err); }; diff --git a/packages/shared/common/src/api/platform/EventSource.ts b/packages/shared/common/src/api/platform/EventSource.ts index 3c0d920700..559581a19e 100644 --- a/packages/shared/common/src/api/platform/EventSource.ts +++ b/packages/shared/common/src/api/platform/EventSource.ts @@ -25,4 +25,11 @@ export interface EventSourceInitDict { initialRetryDelayMillis: number; readTimeoutMillis: number; retryResetIntervalMillis: number; + /** + * Optional callback that returns a fresh URL on each reconnection attempt. + * When provided, the EventSource implementation should call this instead of + * reusing the original URL. This allows query parameters (e.g. `basis`) to + * be updated between reconnections. + */ + urlBuilder?: () => string; } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts index e6d3486767..8a77fda52b 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts @@ -270,6 +270,7 @@ export function createStreamingBase(config: { initialRetryDelayMillis: config.initialRetryDelayMillis, readTimeoutMillis: 5 * 60 * 1000, retryResetIntervalMillis: 60 * 1000, + urlBuilder: buildStreamUri, }); eventSource = es;