Skip to content

Commit fdf83b0

Browse files
committed
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
1 parent 109efb4 commit fdf83b0

6 files changed

Lines changed: 58 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ stats.html
2828
.env.local
2929
.env.*.local
3030
.claude/worktrees
31+
.mcp.json

packages/sdk/browser/contract-tests/entity/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"install-playwright-browsers": "playwright install --with-deps chromium",
99
"start": "tsc --noEmit && vite --open=true",
10+
"start:headless": "tsc --noEmit && vite",
1011
"build": "tsc --noEmit && vite build",
1112
"lint": "eslint ./src",
1213
"start:adapter": "sdk-testharness-server adapter",

packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) {
103103
if (options.dataSystem) {
104104
const dataSystem: any = {};
105105

106+
// Helper to apply endpoint overrides from a mode definition to global URIs.
107+
const applyEndpointOverrides = (modeDef: SDKConfigModeDefinition) => {
108+
(modeDef.synchronizers ?? []).forEach((sync) => {
109+
if (sync.streaming?.baseUri) {
110+
cf.streamUri = sync.streaming.baseUri;
111+
cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs);
112+
}
113+
if (sync.polling?.baseUri) {
114+
cf.baseUri = sync.polling.baseUri;
115+
}
116+
});
117+
(modeDef.initializers ?? []).forEach((init) => {
118+
if (init.polling?.baseUri) {
119+
cf.baseUri = init.polling.baseUri;
120+
}
121+
});
122+
};
123+
106124
if (options.dataSystem.connectionModeConfig) {
107125
const connMode = options.dataSystem.connectionModeConfig;
108126
dataSystem.automaticModeSwitching = connMode.initialConnectionMode
@@ -113,26 +131,25 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) {
113131
const connectionModes: Record<string, any> = {};
114132
Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => {
115133
connectionModes[modeName] = translateModeDefinition(modeDef);
116-
117-
// Per-entry endpoint overrides also set global URIs for ServiceEndpoints
118-
// compatibility. These override the serviceEndpoints values above.
119-
(modeDef.synchronizers ?? []).forEach((sync) => {
120-
if (sync.streaming?.baseUri) {
121-
cf.streamUri = sync.streaming.baseUri;
122-
cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs);
123-
}
124-
if (sync.polling?.baseUri) {
125-
cf.baseUri = sync.polling.baseUri;
126-
}
127-
});
128-
(modeDef.initializers ?? []).forEach((init) => {
129-
if (init.polling?.baseUri) {
130-
cf.baseUri = init.polling.baseUri;
131-
}
132-
});
134+
applyEndpointOverrides(modeDef);
133135
});
134136
dataSystem.connectionModes = connectionModes;
135137
}
138+
} else if (options.dataSystem.initializers || options.dataSystem.synchronizers) {
139+
// Top-level initializers/synchronizers (no connection modes). Wrap them
140+
// into a single 'streaming' connection mode for the browser SDK.
141+
const modeDef: SDKConfigModeDefinition = {
142+
initializers: options.dataSystem.initializers,
143+
synchronizers: options.dataSystem.synchronizers,
144+
};
145+
dataSystem.automaticModeSwitching = {
146+
type: 'manual',
147+
initialConnectionMode: 'streaming',
148+
};
149+
dataSystem.connectionModes = {
150+
streaming: translateModeDefinition(modeDef),
151+
};
152+
applyEndpointOverrides(modeDef);
136153
}
137154

138155
(cf as any).dataSystem = dataSystem;

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,23 @@ export default class DefaultBrowserEventSource implements LDEventSource {
2323
private _es?: EventSource;
2424
private _backoff: DefaultBackoff;
2525
private _errorFilter: (err: HttpErrorResponse) => boolean;
26+
private _urlBuilder?: () => string;
2627

2728
// The type of the handle can be platform specific and we treat is opaquely.
2829
private _reconnectTimeoutHandle?: any;
2930

3031
private _listeners: Record<string, EventListener[]> = {};
3132

3233
constructor(
33-
private readonly _url: string,
34+
private _url: string,
3435
options: EventSourceInitDict,
3536
) {
3637
this._backoff = new DefaultBackoff(
3738
options.initialRetryDelayMillis,
3839
options.retryResetIntervalMillis,
3940
);
4041
this._errorFilter = options.errorFilter;
42+
this._urlBuilder = options.urlBuilder;
4143
this._openConnection();
4244
}
4345

@@ -50,6 +52,9 @@ export default class DefaultBrowserEventSource implements LDEventSource {
5052
onretrying: ((e: { delayMillis: number }) => void) | undefined;
5153

5254
private _openConnection() {
55+
if (this._urlBuilder) {
56+
this._url = this._urlBuilder();
57+
}
5358
this._es = new EventSource(this._url);
5459
this._es.onopen = () => {
5560
this._backoff.success();
@@ -58,6 +63,14 @@ export default class DefaultBrowserEventSource implements LDEventSource {
5863
// The error could be from a polyfill, or from the browser event source, so we are loose on the
5964
// typing.
6065
this._es.onerror = (err: any) => {
66+
// In browsers, a server-sent "event: error" SSE message fires both
67+
// addEventListener('error', ...) AND onerror. We must not treat it as a
68+
// connection failure. A server-sent error arrives as a MessageEvent while
69+
// the connection is still open; a real connection error is a plain Event
70+
// with readyState !== OPEN.
71+
if (err instanceof MessageEvent) {
72+
return;
73+
}
6174
this._handleError(err);
6275
this.onerror?.(err);
6376
};

packages/shared/common/src/api/platform/EventSource.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,11 @@ export interface EventSourceInitDict {
2525
initialRetryDelayMillis: number;
2626
readTimeoutMillis: number;
2727
retryResetIntervalMillis: number;
28+
/**
29+
* Optional callback that returns a fresh URL on each reconnection attempt.
30+
* When provided, the EventSource implementation should call this instead of
31+
* reusing the original URL. This allows query parameters (e.g. `basis`) to
32+
* be updated between reconnections.
33+
*/
34+
urlBuilder?: () => string;
2835
}

packages/shared/sdk-client/src/datasource/fdv2/StreamingFDv2Base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export function createStreamingBase(config: {
270270
initialRetryDelayMillis: config.initialRetryDelayMillis,
271271
readTimeoutMillis: 5 * 60 * 1000,
272272
retryResetIntervalMillis: 60 * 1000,
273+
urlBuilder: buildStreamUri,
273274
});
274275
eventSource = es;
275276

0 commit comments

Comments
 (0)