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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,20 @@ final sdk = await GBSDKBuilderApp(
// Sensitive targeting rules never reach the client
```

`initialize()` throws an `ArgumentError` for a remote-evaluation configuration that cannot work:

- an empty `apiKey`;
- an `encryptionKey` (remote evaluation responses are not encrypted);
- a `hostURL` on GrowthBook Cloud (any `*.growthbook.io` host), which serves no remote-evaluation endpoint.

A sticky bucket service and `backgroundSync` are supported alongside remote evaluation; a streamed update triggers a fresh evaluation.

Changing the evaluation inputs (`setAttributes`, `setForcedFeatures`, `setForcedVariations`, `setUrl`) sends a new evaluation request. The current URL is part of the request, so URL-targeting rules work too:

```dart
await sdk.setUrl('https://example.com/pricing');
```

### Real-time Updates

The GrowthBook SDK supports streaming with Server-Sent Events (SSE). When enabled, changes to features within GrowthBook will be streamed to the SDK in realtime as they are published. This is only supported on GrowthBook Cloud or if running a GrowthBook Proxy Server.
Expand Down
1 change: 1 addition & 0 deletions lib/src/Features/feature_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class FeatureDataSource {
attributes: params?.attributes,
forcedFeatures: params?.forcedFeatures,
forcedVariations: params?.forcedVariations,
url: params?.url,
).toJson();

await client.consumePostRequest(
Expand Down
5 changes: 5 additions & 0 deletions lib/src/Model/remote_eval_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ class RemoteEvalModel {
this.attributes,
this.forcedFeatures,
this.forcedVariations,
this.url,
});

final Map<String, dynamic>? attributes;
final List<dynamic>? forcedFeatures;
final Map<String, dynamic>? forcedVariations;

/// The current page or screen URL, which URL-targeting rules are evaluated
/// against on the server.
final String? url;

factory RemoteEvalModel.fromJson(Map<String, dynamic> json) =>
_$RemoteEvalModelFromJson(json);

Expand Down
2 changes: 2 additions & 0 deletions lib/src/Model/remote_eval_model.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions lib/src/growth_book_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class GBSDKBuilderApp {
final List<GrowthBookPlugin> _plugins = [];

Future<GrowthBookSDK> initialize() async {
_validateRemoteEval();

final gbContext = GBContext(
apiKey: apiKey,
encryptionKey: encryptionKey,
Expand Down Expand Up @@ -85,6 +87,30 @@ class GBSDKBuilderApp {
return gb;
}

/// Rejects the remote-eval configurations the JS SDK rejects, and only
/// those: an encryption key, a missing client key, and a GrowthBook Cloud
/// host, which serves no remote-evaluation endpoint.
///
/// A sticky bucket service and streaming are allowed, as in the JS SDK; a
/// streamed event triggers a fresh remote evaluation.
void _validateRemoteEval() {
if (!remoteEval) return;

if (encryptionKey != null && encryptionKey!.isNotEmpty) {
throw ArgumentError('Encryption is not available for remoteEval');
}
if (apiKey.isEmpty) {
throw ArgumentError('remoteEval requires an apiKey');
}
// Matched on the hostname's suffix, as in the JS SDK, so every
// growthbook.io host is rejected while a URL that merely contains the
// string elsewhere is not.
final host = Uri.tryParse(hostURL)?.host.toLowerCase() ?? '';
if (host.endsWith('growthbook.io')) {
throw ArgumentError('Cannot use remoteEval on GrowthBook Cloud');
}
}

/// Registers a legacy refresh handler that only receives a boolean.
///
/// Prefer [setRefreshHandlerV2] which also receives the [GBError] that
Expand Down Expand Up @@ -524,6 +550,18 @@ class GrowthBookSDK extends FeaturesFlowDelegate {
refreshForRemoteEval();
}

/// Sets the URL that URL-targeting rules and URL-based forced variations are
/// evaluated against, like `setURL` in the JS SDK.
///
/// In remote-eval mode the URL is part of the evaluation payload, so the
/// returned Future completes after the fresh evaluation that carries it.
Future<void> setUrl(String url) async {
if (url == _context.url) return;
_context.url = url;
_updateEvaluationContext();
await refreshForRemoteEval();
}

@override
Future<void> featuresAPIModelSuccessfully(FeaturedDataModel model) async {
await refreshStickyBucketService(model);
Expand Down Expand Up @@ -558,6 +596,8 @@ class GrowthBookSDK extends FeaturesFlowDelegate {
forcedFeatures: _forcedFeatures,
forcedVariations:
_evaluationContext.userContext.forcedVariationsMap ?? {},
// Always a string, as in the JS SDK, which sends "" when unset.
url: _context.url ?? '',
),
);
}
Expand Down
3 changes: 2 additions & 1 deletion test/common_test/gb_sdk_remote_eval_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import '../mocks/network_mock.dart';
void main() {
group('GrowthBookSDK — remote eval flow', () {
const testApiKey = '<API_KEY>';
const testHostURL = 'https://example.growthbook.io';
// A self-hosted host: remote evaluation is rejected on GrowthBook Cloud.
const testHostURL = 'https://gb-proxy.example.com';
final cachingManager = CachingManager();

Future<GrowthBookSDK> buildSdk({
Expand Down
175 changes: 175 additions & 0 deletions test/common_test/remote_eval_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart';

import '../mocks/network_mock.dart';

/// Records the remote-eval POST payloads it serves.
class _SpyNetworkClient extends MockNetworkClient {
final List<Map<String, dynamic>> payloads = [];

int get postCallCount => payloads.length;

@override
Future<void> consumePostRequest(
String baseUrl,
Map<String, dynamic> params,
OnSuccess onSuccess,
OnError onError,
) async {
payloads.add(Map<String, dynamic>.from(params));
return super.consumePostRequest(baseUrl, params, onSuccess, onError);
}
}

void main() {
const selfHostedUrl = 'https://my-proxy.example.com';
const apiKey = 'test-key';

GBSDKBuilderApp builder({
String apiKey = apiKey,
String hostURL = selfHostedUrl,
String? encryptionKey,
bool backgroundSync = false,
String? url,
BaseClient client = const MockNetworkClient(),
}) {
return GBSDKBuilderApp(
apiKey: apiKey,
hostURL: hostURL,
encryptionKey: encryptionKey,
remoteEval: true,
backgroundSync: backgroundSync,
url: url,
client: client,
growthBookTrackingCallBack: (_) {},
);
}

Matcher argumentError(String message) => throwsA(
isA<ArgumentError>()
.having((e) => e.message, 'message', contains(message)),
);

// The rules mirror the GrowthBook constructor in the JS SDK.
group('remoteEval validation', () {
test('throws when apiKey is empty', () {
expect(() => builder(apiKey: '').initialize(),
argumentError('remoteEval requires an apiKey'));
});

test('throws when encryptionKey is set', () {
expect(() => builder(encryptionKey: 'some-key').initialize(),
argumentError('Encryption is not available for remoteEval'));
});

test('throws for every host on GrowthBook Cloud', () {
for (final cloudHost in const [
'https://cdn.growthbook.io',
'https://api.growthbook.io',
'https://proxy.acme.growthbook.io',
'https://CDN.GrowthBook.io',
]) {
expect(() => builder(hostURL: cloudHost).initialize(),
argumentError('Cannot use remoteEval on GrowthBook Cloud'),
reason: cloudHost);
}
});

test('does not throw for a host that merely contains the cloud domain',
() async {
await expectLater(
builder(hostURL: 'https://growthbook.io.example.com').initialize(),
completes);
await expectLater(
builder(hostURL: 'https://proxy.example.com/growthbook.io')
.initialize(),
completes);
});

test('does not throw when a sticky bucket service is set', () async {
await expectLater(
builder()
.setStickyBucketService(InMemoryStickyBucketService())
.initialize(),
completes,
);
});

test('does not throw when backgroundSync is true', () async {
await expectLater(builder(backgroundSync: true).initialize(), completes);
});

test('does not throw with a valid configuration', () async {
await expectLater(builder().initialize(), completes);
});

test('does not apply without remoteEval', () async {
await expectLater(
GBSDKBuilderApp(
apiKey: apiKey,
hostURL: 'https://cdn.growthbook.io',
encryptionKey: 'some-key',
client: const MockNetworkClient(),
growthBookTrackingCallBack: (_) {},
).initialize(),
completes,
);
});
});

group('url in the remote-eval payload', () {
test('is sent as an empty string when unset', () async {
final spy = _SpyNetworkClient();
await builder(client: spy).initialize();

expect(spy.payloads.last['url'], '');
});

test('is the configured url', () async {
final spy = _SpyNetworkClient();
await builder(client: spy, url: 'https://example.com/start').initialize();

expect(spy.payloads.last['url'], 'https://example.com/start');
});
});

group('setUrl', () {
test('updates the context url when remoteEval is false', () async {
final spy = _SpyNetworkClient();
final sdk = await GBSDKBuilderApp(
apiKey: apiKey,
hostURL: selfHostedUrl,
client: spy,
growthBookTrackingCallBack: (_) {},
).initialize();

await sdk.setUrl('https://example.com/page');

expect(sdk.context.url, 'https://example.com/page');
expect(spy.postCallCount, 0);
});

test('sends the new url in a fresh remote evaluation', () async {
final spy = _SpyNetworkClient();
final sdk = await builder(client: spy).initialize();
final callsBefore = spy.postCallCount;

await sdk.setUrl('https://example.com/new-page');

expect(sdk.context.url, 'https://example.com/new-page');
expect(spy.postCallCount, callsBefore + 1);
expect(spy.payloads.last['url'], 'https://example.com/new-page');
});

test('does nothing when the url is unchanged', () async {
final spy = _SpyNetworkClient();
final sdk = await builder(client: spy, url: 'https://example.com/page')
.initialize();
final callsBefore = spy.postCallCount;

await sdk.setUrl('https://example.com/page');

expect(spy.postCallCount, callsBefore);
});
});
}