Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ final sdk = await GBSDKBuilderApp(
// Advanced Features
remoteEval: false, // Server-side evaluation
encryptionKey: "...", // For encrypted features

// Networking (see "Custom Hosts and Request Headers")
streamingHost: "https://proxy.example.com",
apiHostRequestHeaders: {'X-Gateway-Auth': gatewayToken},
streamingHostRequestHeaders: {'X-Gateway-Auth': gatewayToken},
).initialize();
```

Expand Down Expand Up @@ -484,6 +489,38 @@ final sdk = await GBSDKBuilderApp(
// No need to restart the app or refresh manually
```

### Custom Hosts and Request Headers

By default every request goes to `hostURL` with only the headers the SDK manages itself. Two options
cover deployments where that is not enough — both match the JavaScript SDK.

**A separate streaming host.** Set `streamingHost` when the SSE endpoint is served somewhere other
than the API, for example features from a CDN while streaming goes through a GrowthBook Proxy. It
applies only to the streaming connection; features and remote evaluation stay on `hostURL`, and
without it streaming uses `hostURL` as before.

**Custom request headers.** `apiHostRequestHeaders` is sent with the features request and with every
remote-evaluation request; `streamingHostRequestHeaders` is sent with the streaming connection,
including its reconnects. Typical uses are an authenticated gateway in front of a self-hosted
GrowthBook, or proxy directives such as `X-Accel-Buffering` for SSE through nginx.

```dart
final sdk = await GBSDKBuilderApp(
apiKey: "your_api_key",
hostURL: "https://cdn.example.com",
streamingHost: "https://proxy.example.com",
backgroundSync: true,
// Read secrets from your configuration, never hardcode them
apiHostRequestHeaders: {'X-Gateway-Auth': gatewayToken},
streamingHostRequestHeaders: {'X-Accel-Buffering': 'no'},
).initialize();
```

`User-Agent`, `If-None-Match` and `Cache-Control` are managed by the SDK — supplying any of them, in
any letter case, throws an `ArgumentError` from `initialize()` instead of silently breaking ETag
revalidation. A `streamingHost` that is not an absolute `http`/`https` URL is rejected the same way,
so a misconfiguration is visible at startup rather than as a failed fetch later.

### Tracking Plugins

Plugins observe SDK lifecycle events (feature evaluated, experiment viewed) and can implement custom side effects such as forwarding events to an analytics backend. The SDK ships with `GrowthBookTrackingPlugin`, which batches events and sends them to the GrowthBook ingest endpoint.
Expand Down
14 changes: 12 additions & 2 deletions lib/src/Features/feature_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class FeatureDataSource {
FeaturedDataModel.fromJson(response),
),
onError,
headers: context.streamingHostRequestHeaders,
)
: await client.consumeGetRequest(
_getEndpoint(
Expand All @@ -52,6 +53,7 @@ class FeatureDataSource {
FeaturedDataModel.fromJson(response),
),
onError,
headers: context.apiHostRequestHeaders,
);
}

Expand All @@ -74,14 +76,22 @@ class FeatureDataSource {
FeaturedDataModel.fromJson(response),
),
onError,
// Remote evaluation runs against the API host, so it carries the same
// headers as the features request.
headers: context.apiHostRequestHeaders,
);
}

String _getEndpoint(
{required GBContext context,
FeatureRefreshStrategy featureRefreshStrategy =
FeatureRefreshStrategy.STALE_WHILE_REVALIDATE}) {
return FeatureURLBuilder.buildUrl(context.hostURL, context.apiKey,
featureRefreshStrategy: featureRefreshStrategy);
final url =
featureRefreshStrategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS
? context.getStreamingURL()
: context.getFeaturesURL();
// A missing host or client key is a configuration error the SDK cannot
// recover from here; the empty URL surfaces it as a request failure.
return url ?? '';
}
}
51 changes: 35 additions & 16 deletions lib/src/Model/context.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart';
import 'package:growthbook_sdk_flutter/src/Utils/feature_url_builder.dart';

/// Defines the GrowthBook context.
class GBContext {
Expand All @@ -20,6 +21,9 @@ class GBContext {
this.backgroundSync = false,
this.savedGroups,
this.url,
this.streamingHost,
this.apiHostRequestHeaders,
this.streamingHostRequestHeaders,
});

/// Registered API key for GrowthBook SDK.
Expand All @@ -31,6 +35,18 @@ class GBContext {
/// Host URL for GrowthBook
String? hostURL;

/// Host serving the streaming (server-sent events) endpoint, when it differs
/// from [hostURL] — a GrowthBook Proxy in front of a CDN, for example.
/// Falls back to [hostURL] when null.
String? streamingHost;

/// Headers sent with every features request and remote-evaluation request.
/// Meant for deployments behind an authenticated gateway or proxy.
Map<String, String>? apiHostRequestHeaders;

/// Headers sent with the streaming connection, including its reconnects.
Map<String, String>? streamingHostRequestHeaders;

/// Switch to globally disable all experiments. Default true.
bool? enabled;

Expand Down Expand Up @@ -77,20 +93,23 @@ class GBContext {
///A URL string that is used for experiment evaluation, as well as forcing feature values.
String? url;

String? getFeaturesURL() => _buildUrl('/api/features/');

String? getRemoteEvalUrl() => _buildUrl('/api/eval/');

String? _buildUrl(String endpoint) {
if (hostURL == null || apiKey == null) return null;

final uri = Uri.parse(hostURL!);

var basePath = uri.path;
while (basePath.endsWith('/')) {
basePath = basePath.substring(0, basePath.length - 1);
}

return uri.replace(path: '$basePath$endpoint$apiKey').toString();
}
String? getFeaturesURL() => _urlBuilder.buildUrl(apiKey);

String? getRemoteEvalUrl() => _urlBuilder.buildUrl(
apiKey,
featureRefreshStrategy:
FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL,
);

/// Streaming endpoint, served from [streamingHost] when one is configured and
/// from [hostURL] otherwise.
String? getStreamingURL() => _urlBuilder.buildUrl(
apiKey,
featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_EVENTS,
);

FeatureURLBuilder get _urlBuilder => FeatureURLBuilder(
apiHost: hostURL,
streamingHost: streamingHost,
);
}
55 changes: 38 additions & 17 deletions lib/src/Network/network.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,30 @@ typedef OnError = void Function(Object error, StackTrace stackTrace);
abstract class BaseClient {
const BaseClient();

/// [headers] carries the caller-configured request headers
/// (`apiHostRequestHeaders` / `streamingHostRequestHeaders`). They are added
/// to the request, never in place of the headers the SDK manages itself.
Future<void> consumeGetRequest(
String url,
OnSuccess onSuccess,
OnError onError,
);
OnError onError, {
Map<String, String>? headers,
});

Future<void> consumePostRequest(
String baseUrl,
Map<String, dynamic> params,
OnSuccess onSuccess,
OnError onError,
);
OnError onError, {
Map<String, String>? headers,
});

Future<void> consumeSseConnections(
String url,
OnSuccess onSuccess,
OnError onError,
);
OnError onError, {
Map<String, String>? headers,
});
}

class DioClient extends BaseClient {
Expand All @@ -47,12 +53,16 @@ class DioClient extends BaseClient {
required String url,
required OnSuccess onSuccess,
required OnError onError,
Map<String, String>? headers,
}) async {
try {
log('Establishing SSE connection to: $url');
final resp = await _dio.get(
url,
options: Options(responseType: ResponseType.stream),
options: Options(
responseType: ResponseType.stream,
headers: headers == null ? null : Map<String, String>.from(headers),
),
);

final data = resp.data;
Expand Down Expand Up @@ -96,6 +106,7 @@ class DioClient extends BaseClient {
url: url,
onError: onError,
onSuccess: onSuccess,
headers: headers,
);
}
},
Expand All @@ -115,23 +126,27 @@ class DioClient extends BaseClient {
Future<void> consumeGetRequest(
String url,
OnSuccess onSuccess,
OnError onError,
) async {
OnError onError, {
Map<String, String>? headers,
}) async {
try {
final headers = <String, String>{};
// Caller headers first, so the SDK-managed ones below always win. Names
// the SDK manages are rejected at configuration time anyway; this keeps
// the guarantee even for a client constructed some other way.
final requestHeaders = <String, String>{...?headers};

if (_featuresRegex.hasMatch(url)) {
final etag = _etagCache.get(url);
if (etag != null) {
headers["If-None-Match"] = etag;
requestHeaders["If-None-Match"] = etag;
}
headers["Cache-Control"] = "max-age=3600";
requestHeaders["Cache-Control"] = "max-age=3600";
}

final response = await _dio.get(
url,
options: Options(
headers: headers,
headers: requestHeaders,
validateStatus: (status) =>
status != null &&
((status >= 200 && status < 300) || status == 304),
Expand Down Expand Up @@ -172,12 +187,14 @@ class DioClient extends BaseClient {
Future<void> consumeSseConnections(
String url,
OnSuccess onSuccess,
OnError onError,
) async {
OnError onError, {
Map<String, String>? headers,
}) async {
await listenAndRetry(
url: url,
onError: onError,
onSuccess: onSuccess,
headers: headers,
);
}

Expand All @@ -186,14 +203,18 @@ class DioClient extends BaseClient {
String baseUrl,
Map<String, dynamic> params,
OnSuccess onSuccess,
OnError onError,
) async {
OnError onError, {
Map<String, String>? headers,
}) async {
try {
Response response = await _dio.post(
baseUrl,
data: params,
options: Options(
headers: {
// Caller headers first: the content negotiation below describes the
// body this method sends and must not be replaced.
...?headers,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
Expand Down
64 changes: 50 additions & 14 deletions lib/src/Utils/feature_url_builder.dart
Original file line number Diff line number Diff line change
@@ -1,32 +1,68 @@
// ignore_for_file: constant_identifier_names

/// Builds every endpoint the SDK talks to, so the host rules live in one place.
///
/// Streaming may be served from a different host than the API — a GrowthBook
/// Proxy in front of a CDN, for example — so [streamingHost] is applied to the
/// server-sent events endpoint and falls back to [apiHost] when it is not set,
/// mirroring the reference JS SDK's `getApiHosts()`.
class FeatureURLBuilder {
static const String featurePath = "api/features";
static const String eventsPath = "sub";
static const String remoteEvalPath = "api/eval";

static String buildUrl(
String? hostUrl,
String? apiKey, {
const FeatureURLBuilder({
required this.apiHost,
this.streamingHost,
});

/// Host serving the features and remote-evaluation endpoints.
final String? apiHost;

/// Host serving the streaming endpoint. Falls back to [apiHost] when null.
final String? streamingHost;

/// Returns the endpoint for [featureRefreshStrategy], or null when the host
/// or [clientKey] is missing.
///
/// A path on the host is preserved, so an SDK deployed under
/// `https://proxy.example.com/growthbook` keeps that prefix; trailing slashes
/// on the host are dropped rather than doubled.
String? buildUrl(
String? clientKey, {
FeatureRefreshStrategy featureRefreshStrategy =
FeatureRefreshStrategy.STALE_WHILE_REVALIDATE,
}) {
String endpoint = '';
final host =
featureRefreshStrategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS
? (streamingHost ?? apiHost)
: apiHost;

if (host == null || host.isEmpty || clientKey == null) return null;

final uri = Uri.tryParse(host);
if (uri == null) return null;

var basePath = uri.path;
while (basePath.endsWith('/')) {
basePath = basePath.substring(0, basePath.length - 1);
}

return uri
.replace(
path: '$basePath/${_endpoint(featureRefreshStrategy)}/$clientKey')
.toString();
}

static String _endpoint(FeatureRefreshStrategy featureRefreshStrategy) {
switch (featureRefreshStrategy) {
case FeatureRefreshStrategy.STALE_WHILE_REVALIDATE:
endpoint = featurePath;
break;
return featurePath;
case FeatureRefreshStrategy.SERVER_SENT_EVENTS:
endpoint = eventsPath;
break;
return eventsPath;
case FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL:
endpoint = remoteEvalPath;
break;
return remoteEvalPath;
}
String baseUrlWithFeaturePath =
hostUrl!.endsWith('/') ? '$hostUrl$endpoint' : '$hostUrl/$endpoint';

return '$baseUrlWithFeaturePath/$apiKey';
}
}

Expand Down
Loading
Loading