fetch method

  1. @override
Future<ResponseBody> fetch(
  1. RequestOptions options,
  2. Stream<Uint8List>? requestStream,
  3. Future<void>? cancelFuture
)
override

Implement this method to make real HTTP requests.

options are the request options.

requestStream is the request stream. It will not be null only when the request body is not empty. Use requestStream if your code rely on RequestOptions.onSendProgress.

cancelFuture corresponds to CancelToken handling. When the request is canceled, cancelFuture will be resolved. To await if a request has been canceled:

cancelFuture?.then((_) => print('request cancelled!'));

Implementation

@override
Future<ResponseBody> fetch(
  RequestOptions options,
  Stream<Uint8List>? requestStream,
  Future<void>? cancelFuture,
) async {
  final xhr = web.XMLHttpRequest();
  xhrs.add(xhr);
  xhr
    ..open(options.method, '${options.uri}')
    ..responseType = 'arraybuffer';

  final withCredentialsOption = options.extra['withCredentials'];
  if (withCredentialsOption != null) {
    xhr.withCredentials = withCredentialsOption == true;
  } else {
    xhr.withCredentials = withCredentials;
  }

  options.headers.remove(Headers.contentLengthHeader);
  options.headers.forEach((key, v) {
    if (v is Iterable) {
      xhr.setRequestHeader(key, v.join(', '));
    } else {
      xhr.setRequestHeader(key, v.toString());
    }
  });

  final onSendProgress = options.onSendProgress;
  final sendTimeout = options.sendTimeout ?? Duration.zero;
  final connectTimeout = options.connectTimeout ?? Duration.zero;
  final receiveTimeout = options.receiveTimeout ?? Duration.zero;

  final xhrTimeout = (connectTimeout + receiveTimeout).inMilliseconds;
  xhr.timeout = xhrTimeout;

  // Detect configurations that will trigger a CORS preflight request so we
  // can warn the developer and enrich the eventual error message.
  // See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
  final willRegisterUploadListener = requestStream != null &&
      (sendTimeout > Duration.zero || onSendProgress != null);
  final preflightReasons = collectCorsPreflightReasons(
    options,
    willRegisterUploadListener: willRegisterUploadListener,
    withCredentials: xhr.withCredentials,
  );
  if (preflightReasons.isNotEmpty && enableCORSWarning) {
    warningLog(
      'This request is not a CORS "simple request" and will trigger a '
      'preflight (OPTIONS) request: ${preflightReasons.join('; ')}. '
      'If the server does not handle CORS preflight, the request will fail. '
      'See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests',
      StackTrace.current,
    );
  }

  // Whether response headers have already been received, meaning the
  // connection was established. Tracked via `readystatechange` because
  // the native XHR timeout event fires after the spec has already moved
  // the request to `DONE`, so `xhr.readyState` cannot be consulted there.
  // The `DONE` state itself must not count: the request error steps for
  // a timeout or an error fire `readystatechange` at `DONE` right before
  // the `timeout`/`error` events, which would defeat the flag.
  var headersReceived = false;
  xhr.onReadyStateChange.listen((_) {
    final readyState = xhr.readyState;
    if (!headersReceived &&
        readyState >= web.XMLHttpRequest.HEADERS_RECEIVED &&
        readyState < web.XMLHttpRequest.DONE) {
      headersReceived = true;
    }
  });

  final completer = Completer<ResponseBody>();

  xhr.onLoad.first.then((_) {
    final ByteBuffer body = (xhr.response as JSArrayBuffer).toDart;
    completer.complete(
      ResponseBody.fromBytes(
        body.asUint8List(),
        xhr.status,
        headers: xhr.getResponseHeaders(),
        statusMessage: xhr.statusText,
        isRedirect: xhr.status == 302 ||
            xhr.status == 301 ||
            options.uri.toString() != xhr.responseURL,
      ),
    );
  });

  Timer? connectTimeoutTimer;
  if (connectTimeout > Duration.zero) {
    connectTimeoutTimer = Timer(
      connectTimeout,
      () {
        connectTimeoutTimer = null;
        if (completer.isCompleted) {
          // connectTimeout is triggered after the fetch has been completed.
          return;
        }
        // Only treat as connection timeout if headers have not been received.
        // readyState < HEADERS_RECEIVED means the connection is not yet
        // established, so this is a genuine connection timeout.
        // If headers were already received, the receive timeout timer
        // will handle the timeout instead.
        if (xhr.readyState < web.XMLHttpRequest.HEADERS_RECEIVED) {
          xhr.abort();
          completer.completeError(
            DioException.connectionTimeout(
              requestOptions: options,
              timeout: connectTimeout,
            ),
            StackTrace.current,
          );
        }
      },
    );
  }

  // This code is structured to call `xhr.upload.onProgress.listen` only when
  // absolutely necessary, because registering an xhr upload listener prevents
  // the request from being classified as a "simple request" by the CORS spec.
  // Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
  // Upload progress events only get triggered if the request body exists,
  // so we can check it beforehand.
  if (requestStream != null) {
    final xhrUploadProgressStream =
        web.EventStreamProviders.progressEvent.forTarget(xhr.upload);

    if (connectTimeoutTimer != null) {
      xhrUploadProgressStream.listen((_) {
        connectTimeoutTimer?.cancel();
        connectTimeoutTimer = null;
      });
    }

    if (sendTimeout > Duration.zero) {
      final uploadStopwatch = Stopwatch();
      xhrUploadProgressStream.listen((_) {
        if (!uploadStopwatch.isRunning) {
          uploadStopwatch.start();
        }
        final duration = uploadStopwatch.elapsed;
        if (duration > sendTimeout) {
          uploadStopwatch.stop();
          completer.completeError(
            DioException.sendTimeout(
              timeout: sendTimeout,
              requestOptions: options,
            ),
            StackTrace.current,
          );
          xhr.abort();
        }
      });
    }

    if (onSendProgress != null) {
      xhrUploadProgressStream.listen((event) {
        onSendProgress(event.loaded, event.total);
      });
    }
  } else {
    if (sendTimeout > Duration.zero) {
      warningLog(
        'sendTimeout cannot be used without a request body to send on Web',
        StackTrace.current,
      );
    }
    if (onSendProgress != null) {
      warningLog(
        'onSendProgress cannot be used without a request body to send on Web',
        StackTrace.current,
      );
    }
  }

  final receiveStopwatch = Stopwatch();
  Timer? receiveTimer;

  void stopWatchReceiveTimeout() {
    receiveTimer?.cancel();
    receiveTimer = null;
    receiveStopwatch.stop();
  }

  void watchReceiveTimeout() {
    if (receiveTimeout <= Duration.zero) {
      return;
    }
    receiveStopwatch.reset();
    if (!receiveStopwatch.isRunning) {
      receiveStopwatch.start();
    }
    receiveTimer?.cancel();
    receiveTimer = Timer(receiveTimeout, () {
      if (!completer.isCompleted) {
        xhr.abort();
        completer.completeError(
          DioException.receiveTimeout(
            timeout: receiveTimeout,
            requestOptions: options,
          ),
          StackTrace.current,
        );
      }
      stopWatchReceiveTimeout();
    });
  }

  xhr.onProgress.listen(
    (event) {
      if (connectTimeoutTimer != null) {
        connectTimeoutTimer!.cancel();
        connectTimeoutTimer = null;
      }
      watchReceiveTimeout();
      if (options.onReceiveProgress != null) {
        options.onReceiveProgress!(event.loaded, event.total);
      }
    },
    onDone: () => stopWatchReceiveTimeout(),
  );

  xhr.onError.first.then((_) {
    connectTimeoutTimer?.cancel();
    // Unfortunately, the underlying XMLHttpRequest API doesn't expose any
    // specific information about the error itself.
    // See also: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestEventTarget/onerror
    completer.completeError(
      DioException.connectionError(
        requestOptions: options,
        reason: corsEnrichedErrorReason(
          'The XMLHttpRequest onError callback was called. '
          'This typically indicates an error on the network layer.',
          preflightReasons,
        ),
      ),
      StackTrace.current,
    );
  });

  web.EventStreamProviders.timeoutEvent.forTarget(xhr).first.then((_) {
    // The native XHR timeout covers the whole request (connect + receive),
    // and the XHR spec has already terminated the request and moved it to
    // `DONE` before this event fires, so `xhr.readyState` cannot be
    // consulted here. Classify from the tracked connection phase and the
    // configured timeouts instead:
    // - headers already received: the connection was established, so the
    //   deadline expired while receiving the body;
    // - headers not received and a connectTimeout is configured: the
    //   connection was not established in time;
    // - headers not received and no connectTimeout is configured: the
    //   native deadline stood in for the receive deadline, since the
    //   connect phase was not bounded.
    connectTimeoutTimer?.cancel();
    if (!completer.isCompleted) {
      if (!headersReceived && connectTimeout > Duration.zero) {
        completer.completeError(
          DioException.connectionTimeout(
            timeout: connectTimeout,
            requestOptions: options,
          ),
          StackTrace.current,
        );
      } else {
        completer.completeError(
          DioException.receiveTimeout(
            timeout: receiveTimeout,
            requestOptions: options,
          ),
          StackTrace.current,
        );
      }
    }
  });

  cancelFuture?.then((_) {
    if (xhr.readyState < web.XMLHttpRequest.DONE &&
        xhr.readyState > web.XMLHttpRequest.UNSENT) {
      connectTimeoutTimer?.cancel();
      try {
        xhr.abort();
      } catch (_) {}
      if (!completer.isCompleted) {
        completer.completeError(
          DioException.requestCancelled(
            requestOptions: options,
            reason: 'The XMLHttpRequest was aborted.',
          ),
        );
      }
    }
  });

  if (requestStream != null) {
    if (options.method == 'GET') {
      warningLog(
        'GET request with a body data are not support on the '
        'web platform. Use POST/PUT instead.',
        StackTrace.current,
      );
    }
    final completer = Completer<Uint8List>();
    final sink = ByteConversionSink.withCallback(
      (bytes) => completer.complete(
        bytes is Uint8List ? bytes : Uint8List.fromList(bytes),
      ),
    );
    requestStream.listen(
      sink.add,
      onError: (Object e, StackTrace s) => completer.completeError(e, s),
      onDone: sink.close,
      cancelOnError: true,
    );
    final bytes = await completer.future;
    xhr.send(bytes.toJS);
  } else {
    xhr.send();
  }
  return completer.future.whenComplete(() {
    xhrs.remove(xhr);
  });
}