Skip to content

Commit e509abb

Browse files
authored
Switch browser_client.dart to use fetch (#1401)
Issue #595
1 parent 2f954e1 commit e509abb

File tree

5 files changed

+125
-87
lines changed

5 files changed

+125
-87
lines changed

pkgs/http/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
## 1.2.3-wip
1+
## 1.3.0-wip
22

3-
* Fixed unintended HTML tags in doc comments.
3+
* Fixed unintended HTML tags in doc comments.
4+
* Switched `BrowserClient` to use Fetch API instead of `XMLHttpRequest`.
45

56
## 1.2.2
67

pkgs/http/lib/src/browser_client.dart

Lines changed: 119 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
55
import 'dart:async';
66
import 'dart:js_interop';
77

8-
import 'package:web/web.dart' show XHRGetters, XMLHttpRequest;
8+
import 'package:web/web.dart'
9+
show
10+
AbortController,
11+
HeadersInit,
12+
ReadableStreamDefaultReader,
13+
RequestInit,
14+
Response,
15+
window;
916

1017
import 'base_client.dart';
1118
import 'base_request.dart';
12-
import 'byte_stream.dart';
1319
import 'exception.dart';
1420
import 'streamed_response.dart';
1521

16-
final _digitRegex = RegExp(r'^\d+$');
17-
1822
/// Create a [BrowserClient].
1923
///
2024
/// Used from conditional imports, matches the definition in `client_stub.dart`.
@@ -27,18 +31,19 @@ BaseClient createClient() {
2731
}
2832

2933
/// A `package:web`-based HTTP client that runs in the browser and is backed by
30-
/// [XMLHttpRequest].
34+
/// [`window.fetch`](https://fetch.spec.whatwg.org/).
35+
///
36+
/// This client inherits some limitations of `window.fetch`:
37+
///
38+
/// - [BaseRequest.persistentConnection] is ignored;
39+
/// - Setting [BaseRequest.followRedirects] to `false` will cause
40+
/// [ClientException] when a redirect is encountered;
41+
/// - The value of [BaseRequest.maxRedirects] is ignored.
3142
///
32-
/// This client inherits some of the limitations of XMLHttpRequest. It ignores
33-
/// the [BaseRequest.contentLength], [BaseRequest.persistentConnection],
34-
/// [BaseRequest.followRedirects], and [BaseRequest.maxRedirects] fields. It is
35-
/// also unable to stream requests or responses; a request will only be sent and
36-
/// a response will only be returned once all the data is available.
43+
/// Responses are streamed but requests are not. A request will only be sent
44+
/// once all the data is available.
3745
class BrowserClient extends BaseClient {
38-
/// The currently active XHRs.
39-
///
40-
/// These are aborted if the client is closed.
41-
final _xhrs = <XMLHttpRequest>{};
46+
final _abortController = AbortController();
4247

4348
/// Whether to send credentials such as cookies or authorization headers for
4449
/// cross-site requests.
@@ -55,55 +60,58 @@ class BrowserClient extends BaseClient {
5560
throw ClientException(
5661
'HTTP request failed. Client is already closed.', request.url);
5762
}
58-
var bytes = await request.finalize().toBytes();
59-
var xhr = XMLHttpRequest();
60-
_xhrs.add(xhr);
61-
xhr
62-
..open(request.method, '${request.url}', true)
63-
..responseType = 'arraybuffer'
64-
..withCredentials = withCredentials;
65-
for (var header in request.headers.entries) {
66-
xhr.setRequestHeader(header.key, header.value);
67-
}
6863

69-
var completer = Completer<StreamedResponse>();
70-
71-
unawaited(xhr.onLoad.first.then((_) {
72-
if (xhr.responseHeaders['content-length'] case final contentLengthHeader
73-
when contentLengthHeader != null &&
74-
!_digitRegex.hasMatch(contentLengthHeader)) {
75-
completer.completeError(ClientException(
64+
final bodyBytes = await request.finalize().toBytes();
65+
try {
66+
final response = await window
67+
.fetch(
68+
'${request.url}'.toJS,
69+
RequestInit(
70+
method: request.method,
71+
body: bodyBytes.isNotEmpty ? bodyBytes.toJS : null,
72+
credentials: withCredentials ? 'include' : 'same-origin',
73+
headers: {
74+
if (request.contentLength case final contentLength?)
75+
'content-length': contentLength,
76+
for (var header in request.headers.entries)
77+
header.key: header.value,
78+
}.jsify()! as HeadersInit,
79+
signal: _abortController.signal,
80+
redirect: request.followRedirects ? 'follow' : 'error',
81+
),
82+
)
83+
.toDart;
84+
85+
final contentLengthHeader = response.headers.get('content-length');
86+
87+
final contentLength = contentLengthHeader != null
88+
? int.tryParse(contentLengthHeader)
89+
: null;
90+
91+
if (contentLength == null && contentLengthHeader != null) {
92+
throw ClientException(
7693
'Invalid content-length header [$contentLengthHeader].',
7794
request.url,
78-
));
79-
return;
95+
);
8096
}
81-
var body = (xhr.response as JSArrayBuffer).toDart.asUint8List();
82-
var responseUrl = xhr.responseURL;
83-
var url = responseUrl.isNotEmpty ? Uri.parse(responseUrl) : request.url;
84-
completer.complete(StreamedResponseV2(
85-
ByteStream.fromBytes(body), xhr.status,
86-
contentLength: body.length,
87-
request: request,
88-
url: url,
89-
headers: xhr.responseHeaders,
90-
reasonPhrase: xhr.statusText));
91-
}));
92-
93-
unawaited(xhr.onError.first.then((_) {
94-
// Unfortunately, the underlying XMLHttpRequest API doesn't expose any
95-
// specific information about the error itself.
96-
completer.completeError(
97-
ClientException('XMLHttpRequest error.', request.url),
98-
StackTrace.current);
99-
}));
100-
101-
xhr.send(bytes.toJS);
10297

103-
try {
104-
return await completer.future;
105-
} finally {
106-
_xhrs.remove(xhr);
98+
final headers = <String, String>{};
99+
(response.headers as _IterableHeaders)
100+
.forEach((String value, String header, [JSAny? _]) {
101+
headers[header.toLowerCase()] = value;
102+
}.toJS);
103+
104+
return StreamedResponseV2(
105+
_readBody(request, response),
106+
response.status,
107+
headers: headers,
108+
request: request,
109+
contentLength: contentLength,
110+
url: Uri.parse(response.url),
111+
reasonPhrase: response.statusText,
112+
);
113+
} catch (e, st) {
114+
_rethrowAsClientException(e, st, request);
107115
}
108116
}
109117

@@ -113,36 +121,66 @@ class BrowserClient extends BaseClient {
113121
@override
114122
void close() {
115123
_isClosed = true;
116-
for (var xhr in _xhrs) {
117-
xhr.abort();
124+
_abortController.abort();
125+
}
126+
}
127+
128+
Never _rethrowAsClientException(Object e, StackTrace st, BaseRequest request) {
129+
if (e is! ClientException) {
130+
var message = e.toString();
131+
if (message.startsWith('TypeError: ')) {
132+
message = message.substring('TypeError: '.length);
118133
}
119-
_xhrs.clear();
134+
e = ClientException(message, request.url);
120135
}
136+
Error.throwWithStackTrace(e, st);
121137
}
122138

123-
extension on XMLHttpRequest {
124-
Map<String, String> get responseHeaders {
125-
// from Closure's goog.net.Xhrio.getResponseHeaders.
126-
var headers = <String, String>{};
127-
var headersString = getAllResponseHeaders();
128-
var headersList = headersString.split('\r\n');
129-
for (var header in headersList) {
130-
if (header.isEmpty) {
131-
continue;
132-
}
139+
Stream<List<int>> _readBody(BaseRequest request, Response response) async* {
140+
final bodyStreamReader =
141+
response.body?.getReader() as ReadableStreamDefaultReader?;
142+
143+
if (bodyStreamReader == null) {
144+
return;
145+
}
133146

134-
var splitIdx = header.indexOf(': ');
135-
if (splitIdx == -1) {
136-
continue;
147+
var isDone = false, isError = false;
148+
try {
149+
while (true) {
150+
final chunk = await bodyStreamReader.read().toDart;
151+
if (chunk.done) {
152+
isDone = true;
153+
break;
137154
}
138-
var key = header.substring(0, splitIdx).toLowerCase();
139-
var value = header.substring(splitIdx + 2);
140-
if (headers.containsKey(key)) {
141-
headers[key] = '${headers[key]}, $value';
142-
} else {
143-
headers[key] = value;
155+
yield (chunk.value! as JSUint8Array).toDart;
156+
}
157+
} catch (e, st) {
158+
isError = true;
159+
_rethrowAsClientException(e, st, request);
160+
} finally {
161+
if (!isDone) {
162+
try {
163+
// catchError here is a temporary workaround for
164+
// http://dartbug.com/57046: an exception from cancel() will
165+
// clobber an exception which is currently in flight.
166+
await bodyStreamReader
167+
.cancel()
168+
.toDart
169+
.catchError((_) => null, test: (_) => isError);
170+
} catch (e, st) {
171+
// If we have already encountered an error swallow the
172+
// error from cancel and simply let the original error to be
173+
// rethrown.
174+
if (!isError) {
175+
_rethrowAsClientException(e, st, request);
176+
}
144177
}
145178
}
146-
return headers;
147179
}
148180
}
181+
182+
/// Workaround for `Headers` not providing a way to iterate the headers.
183+
@JS()
184+
extension type _IterableHeaders._(JSObject _) implements JSObject {
185+
external void forEach(JSFunction fn);
186+
}

pkgs/http/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: http
2-
version: 1.2.3-wip
2+
version: 1.3.0-wip
33
description: A composable, multi-platform, Future-based API for HTTP requests.
44
repository: https://github.com/dart-lang/http/tree/master/pkgs/http
55

pkgs/http/test/html/client_conformance_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ void main() {
1313
testAll(BrowserClient.new,
1414
redirectAlwaysAllowed: true,
1515
canStreamRequestBody: false,
16-
canStreamResponseBody: false,
16+
canStreamResponseBody: true,
1717
canWorkInIsolates: false,
1818
supportsMultipartRequest: false);
1919
}

pkgs/http/test/html/client_test.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ void main() {
3434
var url = Uri.http('http.invalid', '');
3535
var request = http.StreamedRequest('POST', url);
3636

37-
expect(
38-
client.send(request), throwsClientException('XMLHttpRequest error.'));
37+
expect(client.send(request), throwsClientException());
3938

4039
request.sink.add('{"hello": "world"}'.codeUnits);
4140
request.sink.close();

0 commit comments

Comments
 (0)