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
8 changes: 8 additions & 0 deletions .github/actions/ci/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ runs:
shell: bash
run: melos run test

- name: Web SSE Client Header Tests (Chrome)
shell: bash
# SSEClient.http sends different default headers on web. The Dart VM
# cannot run that code, so this test runs on Chrome.
run: |
cd packages/event_source_client
dart test test/http_sse_headers_test.dart -p chrome --test-randomize-ordering-seed=random

- name: Platform Canonicalization Tests (Chrome)
shell: bash
# Run RFC 8785 canonicalization tests on Chrome to verify platform-independent behavior.
Expand Down
6 changes: 3 additions & 3 deletions apps/sse_contract_test_service/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ packages:
dependency: "direct dev"
description:
name: http
sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525"
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
Expand Down Expand Up @@ -311,7 +311,7 @@ packages:
path: "../../packages/event_source_client"
relative: true
source: path
version: "2.0.1"
version: "3.0.1"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: updating this to the latest in main

lints:
dependency: "direct dev"
description:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import 'src/events.dart';
import 'src/sse_client_stub.dart'
if (dart.library.io) 'src/sse_client_http.dart'
if (dart.library.js_interop) 'src/sse_client_html.dart';
import 'src/http_sse_headers.dart';
import 'src/sse_client_http.dart' as http_client show HttpSseClient;
import 'src/test_sse_client.dart';

export 'src/errors.dart' show SseHttpError;
Expand Down Expand Up @@ -68,11 +70,14 @@ enum SseHttpMethod {
/// In certain cases, unrecoverable errors will be reported on the [stream] at
/// which point the stream will be done.
///
/// On `html` platforms the client is incapable of reporting unrecoverable
/// errors: the browser's native `EventSource` exposes no HTTP status codes
/// or response headers, so every failure is treated as recoverable and
/// retried with backoff indefinitely, and no error is ever reported on the
/// [stream].
/// On `html` platforms the default client is incapable of reporting
/// unrecoverable errors: the browser's native `EventSource` exposes no HTTP
/// status codes or response headers, so every failure is treated as
/// recoverable and retried with backoff indefinitely, and no error is ever
/// reported on the [stream]. The client from [SSEClient.http] does not have
/// this limit. On web, it sees a status code only when the CORS policy of the
/// server allows it. A response that fails a CORS check is a recoverable
/// connection error, not an [SseHttpError].
///
/// The [SSEClient] will make best effort to maintain the streaming connection.
abstract class SSEClient {
Expand Down Expand Up @@ -151,13 +156,54 @@ abstract class SSEClient {
EventSourceLogger? logger,
Uri Function()? uriProvider}) {
// merge headers so consumer gets reasonable defaults
var mergedHeaders = <String, String>{};
mergedHeaders.addAll(defaultHeaders);
mergedHeaders.addAll(headers);
final mergedHeaders = <String, String>{...defaultHeaders, ...headers};
return getSSEClient(uri, eventTypes, mergedHeaders, connectTimeout,
readTimeout, body, httpMethod.toString(), logger, uriProvider);
}

/// Factory constructor that always returns the HTTP-based implementation,
/// on every platform including web.
///
/// On web, the [SSEClient] default factory uses the browser native
/// `EventSource`, which cannot send a request body, an HTTP method other
/// than `GET`, or custom headers, and which cannot report HTTP errors.
/// This constructor uses `package:http`, which streams with `fetch()` in
/// the browser, so it supports all of these. Use it when an application
/// needs those features on web.
///
/// On web, a custom header must be allowed by the CORS configuration of
/// the server, or the browser blocks the request. On web, the default
/// headers are `accept` and `cache-control`. The `user-agent` header is
/// left out, because a browser forbids it. The `cache-control` header
/// stops a browser from serving a cached stream response on a reconnect.
/// It is not CORS-safelisted, so the browser sends a preflight request,
/// which the server must allow. The LaunchDarkly streaming service allows
/// it. This matches the LaunchDarkly browser SDK. Any header in [headers]
/// is still sent.
///
/// The [headers], [connectTimeout], [readTimeout], [body], [httpMethod] and
/// [uriProvider] arguments are used on every platform. The other arguments
/// have the same meaning as in the default factory.
factory SSEClient.http(Uri uri, Set<String> eventTypes,
{Map<String, String> headers = const {},
Duration connectTimeout = defaultConnectTimeout,
Duration readTimeout = defaultReadTimeout,
String? body,
SseHttpMethod httpMethod = SseHttpMethod.get,
EventSourceLogger? logger,
Uri Function()? uriProvider}) {
return http_client.HttpSseClient(
uri,
eventTypes,
httpSseClientHeaders(headers),
connectTimeout,
readTimeout,
body,
httpMethod.toString(),
logger,
uriProvider: uriProvider);
}

/// Get an SSE client for use in unit tests.
///
/// Most parameters are the same as those of the main SSEClient factory, but
Expand Down
25 changes: 25 additions & 0 deletions packages/event_source_client/lib/src/http_sse_headers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import '../launchdarkly_event_source_client.dart';
import 'http_consts.dart';

/// True when compiled for a browser.
const bool _isWeb = bool.fromEnvironment('dart.library.js_interop');

/// The default headers of [SSEClient.http] on web.
///
/// A browser forbids the `user-agent` header, so it is left out. The
/// `cache-control` header stops a browser from serving a cached stream
/// response on a reconnect. It is not CORS-safelisted, so the browser sends
/// a preflight request, which the server must allow. The LaunchDarkly
/// streaming service allows it. The LaunchDarkly browser SDK sends the same
/// headers.
const Map<String, String> webDefaultHeaders = {
HttpHeaders.acceptHeader: MimeTypes.textEventStream,
HttpHeaders.cacheControlHeader: HttpHeaders.noCacheHeaderValue,
};

/// Returns the request headers of [SSEClient.http]: the default headers of
/// the platform, with [headers] on top.
Map<String, String> httpSseClientHeaders(Map<String, String> headers) => {
...(_isWeb ? webDefaultHeaders : SSEClient.defaultHeaders),
...headers,
};
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class StateConnecting {
} on TimeoutException {
// didn't connect in a timely manner, so backoff then we'll try again

client.close;
client.close();
return () {
svo.logger.info(
'Connection TimeoutException occurred, will retry with backoff.');
Expand Down
2 changes: 1 addition & 1 deletion packages/event_source_client/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ environment:
sdk: ">=3.4.0 <4.0.0"

dependencies:
http: ^1.1.0
http: ^1.6.0
web: ^1.1.1

dev_dependencies:
Expand Down
28 changes: 28 additions & 0 deletions packages/event_source_client/test/http_sse_headers_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart';
import 'package:launchdarkly_event_source_client/src/http_sse_headers.dart';
import 'package:test/test.dart';

// The web case runs with `dart test test/http_sse_headers_test.dart -p chrome`.
void main() {
test('on web, sends accept and cache-control and no user-agent', () {
final headers = httpSseClientHeaders({'authorization': 'key'});

expect(headers, {
'accept': 'text/event-stream',
'cache-control': 'no-cache',
'authorization': 'key',
});
}, testOn: 'browser');

test('on other platforms, sends the default headers', () {
final headers = httpSseClientHeaders({'authorization': 'key'});

expect(headers, {...SSEClient.defaultHeaders, 'authorization': 'key'});
}, testOn: 'vm');

test('a caller header replaces a default header', () {
final headers = httpSseClientHeaders({'accept': 'application/json'});

expect(headers['accept'], 'application/json');
});
}
11 changes: 11 additions & 0 deletions packages/event_source_client/test/sse_capability_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ void main() {
});
});

group('SSEClient.http', () {
test('reports SSECapability.requestHeaders as supported', () {
final client = SSEClient.http(Uri.parse('https://example.test'), {'put'});
try {
expect(client.hasCapability(SSECapability.requestHeaders), isTrue);
} finally {
client.close();
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
});
});

group('TestSseClient', () {
test(
'defaults to supporting SSECapability.requestHeaders so existing '
Expand Down
Loading