blob: 79c6b6210f3db4664e93b3d8268fae40f6811379 [file] [edit]
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:js_interop';
import 'package:web/web.dart'
show
AbortController,
DOMException,
HeadersInit,
ReadableStreamDefaultReader,
ReadableStreamReadResult,
RequestInfo,
RequestInit,
Response;
import 'abortable.dart';
import 'base_client.dart';
import 'base_request.dart';
import 'exception.dart';
import 'streamed_response.dart';
/// Create a [BrowserClient].
///
/// Used from conditional imports, matches the definition in `client_stub.dart`.
BaseClient createClient() {
if (const bool.fromEnvironment('no_default_http_client')) {
throw StateError('no_default_http_client was defined but runWithClient '
'was not used to configure a Client implementation.');
}
return BrowserClient();
}
@JS('fetch')
external JSPromise<Response> _fetch(
RequestInfo input, [
RequestInit init,
]);
/// The browser `fetch` credentials mode represented by [RequestCredentials].
///
/// Controls whether the browser sends credentials such as cookies, TLS client
/// certificates, or authorization headers with a request.
///
/// See also:
/// - https://fetch.spec.whatwg.org/#requestcredentials
enum RequestCredentials {
/// Never send credentials with the request and never include credentials in
/// the response.
///
/// This corresponds to the browser `fetch` credentials mode `omit`.
omit('omit'),
/// Send credentials for same-origin requests only and only include
/// credentials in same-origin replies.
///
/// This corresponds to the browser `fetch` credentials mode `same-origin`.
sameOrigin('same-origin'),
/// Always send credentials, even for cross-origin requests, and include them
/// in all responses.
///
/// This corresponds to the browser `fetch` credentials mode `include`.
include('include');
const RequestCredentials(this._value);
/// The value passed to the browser `fetch` `RequestInit.credentials` field.
final String _value;
}
/// A `package:web`-based HTTP client that runs in the browser and is backed by
/// [`window.fetch`](https://fetch.spec.whatwg.org/).
///
/// This client inherits some limitations of `window.fetch`:
///
/// - [BaseRequest.persistentConnection] is ignored;
/// - Setting [BaseRequest.followRedirects] to `false` will cause
/// [ClientException] when a redirect is encountered;
/// - The value of [BaseRequest.maxRedirects] is ignored.
///
/// Responses are streamed but requests are not. A request will only be sent
/// once all the data is available.
class BrowserClient extends BaseClient {
/// The internal browser `fetch` credentials mode used for requests.
RequestCredentials _requestCredentials;
/// Create a [BrowserClient].
///
/// By default, credentials are sent for same-origin requests only.
BrowserClient(
{RequestCredentials requestCredentials = RequestCredentials.sameOrigin})
: _requestCredentials = requestCredentials;
/// Whether to send credentials such as cookies or authorization headers for
/// cross-site requests.
///
/// Defaults to `false`.
///
/// This property is deprecated because it can only represent two of the three
/// browser `fetch` credentials modes (`same-origin` and `include`).
/// Use the constructor parameter instead to also support
/// [RequestCredentials.omit].
///
/// Reading this property returns `true` only when [_requestCredentials] is
/// [RequestCredentials.include].
@Deprecated('Use the requestCredentials constructor parameter instead.')
bool get withCredentials => _requestCredentials == RequestCredentials.include;
/// Whether to send credentials such as cookies or authorization headers for
/// cross-site requests.
///
/// Setting this to `true` sets [_requestCredentials] to
/// [RequestCredentials.include].
///
/// Setting this to `false` sets [_requestCredentials] to
/// [RequestCredentials.sameOrigin].
@Deprecated('Use the requestCredentials constructor parameter instead.')
set withCredentials(bool value) {
_requestCredentials =
value ? RequestCredentials.include : RequestCredentials.sameOrigin;
}
bool _isClosed = false;
final _openRequestAbortControllers = <AbortController>[];
/// Sends an HTTP request and asynchronously returns the response.
@override
Future<StreamedResponse> send(BaseRequest request) async {
if (_isClosed) {
throw ClientException(
'HTTP request failed. Client is already closed.', request.url);
}
final abortController = AbortController();
_openRequestAbortControllers.add(abortController);
final bodyBytes = await request.finalize().toBytes();
try {
if (request case Abortable(:final abortTrigger?)) {
// Tear-offs of external extension type interop members are disallowed
// ignore: unnecessary_lambdas
unawaited(abortTrigger.whenComplete(() => abortController.abort()));
}
final response = await _fetch(
'${request.url}'.toJS,
RequestInit(
method: request.method,
body: bodyBytes.isNotEmpty ? bodyBytes.toJS : null,
credentials: _requestCredentials._value,
headers: {
if (request.contentLength case final contentLength?)
'content-length': contentLength,
for (var header in request.headers.entries)
header.key: header.value,
}.jsify()! as HeadersInit,
signal: abortController.signal,
redirect: request.followRedirects ? 'follow' : 'error',
),
).toDart;
final contentLengthHeader = response.headers.get('content-length');
final contentLength = contentLengthHeader != null
? int.tryParse(contentLengthHeader)
: null;
if (contentLength == null && contentLengthHeader != null) {
throw ClientException(
'Invalid content-length header [$contentLengthHeader].',
request.url,
);
}
final headers = <String, String>{};
(response.headers as _IterableHeaders)
.forEach((String value, String header, [JSAny? _]) {
headers[header.toLowerCase()] = value;
}.toJS);
return StreamedResponseV2(
_bodyToStream(request, response),
response.status,
headers: headers,
request: request,
contentLength: contentLength,
url: Uri.parse(response.url),
reasonPhrase: response.statusText,
);
} catch (e, st) {
_rethrowAsClientException(e, st, request);
} finally {
_openRequestAbortControllers.remove(abortController);
}
}
/// Closes the client.
///
/// This terminates all active requests, which may cause them to throw
/// [RequestAbortedException] or [ClientException].
@override
void close() {
for (final abortController in _openRequestAbortControllers) {
abortController.abort();
}
_isClosed = true;
}
}
Object _toClientException(Object e, BaseRequest request) {
if (e case DOMException(name: 'AbortError')) {
return RequestAbortedException(request.url);
}
if (e is! ClientException) {
var message = e.toString();
if (message.startsWith('TypeError: ')) {
message = message.substring('TypeError: '.length);
}
e = ClientException(message, request.url);
}
return e;
}
Never _rethrowAsClientException(Object e, StackTrace st, BaseRequest request) {
Error.throwWithStackTrace(_toClientException(e, request), st);
}
Stream<List<int>> _bodyToStream(BaseRequest request, Response response) =>
Stream.multi(
isBroadcast: false,
(listener) => _readStreamBody(request, response, listener),
);
Future<void> _readStreamBody(BaseRequest request, Response response,
MultiStreamController<List<int>> controller) async {
final reader = response.body?.getReader() as ReadableStreamDefaultReader?;
if (reader == null) {
// No response? Treat that as an empty stream.
await controller.close();
return;
}
Completer<void>? resumeSignal;
var cancelled = false;
var hadError = false;
controller
..onResume = () {
if (resumeSignal case final resume?) {
resumeSignal = null;
resume.complete();
}
}
..onCancel = () async {
try {
cancelled = true;
// We only cancel the reader when the subscription is cancelled - we
// don't need to do that for normal done events because the stream is in
// a completed state at that point.
await reader.cancel().toDart;
} catch (e, s) {
// It is possible for reader.cancel() to throw. This happens either
// because the stream has already been in an error state (in which case
// we would have called addErrorSync() before and don't need to re-
// report the error here), or because of an issue here (MDN says the
// method can throw if "The source object is not a
// ReadableStreamDefaultReader, or the stream has no owner."). Both of
// these don't look applicable here, but we want to ensure a new error
// in cancel() is surfaced to the caller.
if (!hadError) {
_rethrowAsClientException(e, s, request);
}
}
};
// Async loop reading chunks from `bodyStreamReader` and sending them to
// `controller`.
// Checks for pause/cancel after delivering each event.
// Exits if stream closes or becomes an error, or if cancelled.
while (true) {
final ReadableStreamReadResult chunk;
try {
chunk = await reader.read().toDart;
} catch (e, s) {
// After a stream was cancelled, adding error events would result in
// unhandled async errors. This is most likely an AbortError anyway, so
// not really an exceptional state. We report errors of .cancel() in
// onCancel, that should cover this case.
if (!cancelled) {
hadError = true;
controller.addErrorSync(_toClientException(e, request), s);
await controller.close();
}
break;
}
if (chunk.done) {
// Sync because we're forwarding an async event.
controller.closeSync();
break;
} else {
// Handle chunk whether paused, cancelled or not.
// If subscription is cancelled, it's a no-op to add events.
// If subscription is paused, events will be buffered until resumed,
// which is what we need.
// We can use addSync here because we're only forwarding this async
// event.
controller.addSync((chunk.value! as JSUint8Array).toDart);
}
// Check pause/cancel state immediately *after* delivering event,
// listener might have paused or cancelled.
if (controller.isPaused) {
// Will never complete if cancelled before resumed.
await (resumeSignal ??= Completer<void>()).future;
}
if (!controller.hasListener) break; // Is cancelled.
}
}
/// Workaround for `Headers` not providing a way to iterate the headers.
@JS()
extension type _IterableHeaders._(JSObject _) implements JSObject {
external void forEach(JSFunction fn);
}