├── .gitignore ├── .pubignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── dart_test.yaml ├── example ├── fetch_client_example.dart ├── fetch_client_redirect_modes.dart └── fetch_client_stream_request.dart ├── lib ├── fetch_client.dart └── src │ ├── cancel_callback.dart │ ├── fetch_client.dart │ ├── fetch_client_io_shim.dart │ ├── fetch_request.dart │ ├── fetch_response.dart │ ├── on_done.dart │ ├── redirect_policy.dart │ └── request_canceled_exception.dart ├── pubspec.yaml └── test ├── cancel_test.dart ├── client_conformance_test.dart ├── integrity_test.dart └── vm_shim_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | 9 | web/ 10 | 11 | pubspec_overrides.yaml 12 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *.code-workspace 3 | 4 | doc/ 5 | web/ 6 | test/ 7 | analysis_options.yaml 8 | dart_test.yaml 9 | .gitignore 10 | pubspec_overrides.yaml 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.4 2 | 3 | - Fix lint errors. 4 | - Use `package:zekfad_lints/lib.yaml` which aligns with core lints. 5 | 6 | ## 1.1.3 7 | 8 | - Fix possible unhandled promise rejection if underlying data stream is errored. 9 | - Update license years. 10 | - Throw `RequestCanceledException` with reason when using 11 | > Semantic is currently undefined and this is the implementation specific 12 | `FetchResponse.cancel` or client is closed with request in-progress. 13 | > behavior. 14 | > 15 | > See [http#1192](https://github.com/dart-lang/http/issues/1192) for more 16 | > info. 17 | 18 | ## 1.1.2 19 | 20 | - Bumped `fetch_api` to 2.2.0. 21 | - Fixed WASM support. 22 | 23 | ## 1.1.1 24 | 25 | - Bumped `fetch_api` to 2.1.0. 26 | - Create internal shim for non-JS environments: you can now import the package 27 | without conditional import and use enumerations in a VM. 28 | This makes it easier to use `FetchClient` in Flutter via `kIsWeb`. 29 | 30 | ## 1.1.0 31 | 32 | > Requires Dart 3.3 33 | 34 | - Migrate to [`fetch_api`](https://pub.dev/packages/fetch_api) 2.0.0. 35 | This requires Dart 3.3, but makes the package WASM ready. 36 | - Update [`http`](https://pub.dev/packages/http) constraint to `^1.2.0`. 37 | - **BREAKING**: `FetchResponse` `url` now is `Uri` instead of `String`. 38 | - `FetchResponse` now implements `BaseResponseWithUrl`. 39 | - Fix unclosed requests after client is closed in-between fetch request. 40 | - Fix `HEAD` request in FireFox. 41 | - Handle response length checks. 42 | - Add `FetchRequest` class that wraps other `Request` to provide fetch options 43 | overrides. 44 | - Removed `integrity` from `FetchClient` constructor as it wasn't used, use 45 | `FetchRequest.integrity` instead. 46 | 47 | 48 | ## 1.0.2 49 | 50 | - Update docs to clarify compatibility restrictions. 51 | 52 | ## 1.0.1 53 | 54 | - Update [`http`](https://pub.dev/packages/http) constraint 55 | to `>=0.13.5 <2.0.0`. 56 | - Update tests. 57 | 58 | ## 1.0.0 59 | 60 | - Public stable release. 61 | - Bumped `fetch_api` to 1.0.0. 62 | 63 | ## 1.0.0-dev.5 64 | 65 | - Added `RedirectPolicy`, that will make it possible to partially emulate how 66 | redirects are returned on `io` platforms. 67 | - Added `streamRequests` option to `FetchClient`. This allows you to use Fetch 68 | request body streaming utilizing half-duplex connection. 69 | - Fixed dev dependency versions to allow running on Dart 2.19. 70 | 71 | ## 1.0.0-dev.4 72 | 73 | - Bumped `fetch_api` dependency to `^1.0.0-dev.4`. 74 | - Added conformance test. 75 | - Full conformance with http client (with exclusion of streamed requests and 76 | redirects, due to API limitations). 77 | - `FetchResponse` 78 | - `isRedirect` now is always `false` (because with disabled redirects 79 | exception is thrown on redirect, and browser always follows redirects 80 | otherwise) 81 | - Added `redirected` flag, that indicates whether request was redirected. 82 | - Added docs. 83 | 84 | ## 1.0.0-dev.3 85 | 86 | - Bumped `fetch_api` dependency to `^1.0.0-dev.3`. 87 | - Use `fetch_api.compatibility_layer` to support Dart 2.19. 88 | - Fixed name of example file. 89 | 90 | ## 1.0.0-dev.2 91 | 92 | - Bumped `fetch_api` dependency to `^1.0.0-dev.2`. 93 | - Downgraded `js` dependency to `^0.6.5`. 94 | 95 | ## 1.0.0-dev.1 96 | 97 | - Initial version. 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023-2025 Yaroslav Vorobev and contributors. All rights reserved. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fetch Client 2 | 3 | [![pub package](https://img.shields.io/pub/v/fetch_client.svg)](https://pub.dev/packages/fetch_client) 4 | [![package publisher](https://img.shields.io/pub/publisher/fetch_client.svg)](https://pub.dev/packages/fetch_client/publisher) 5 | 6 | This package provides [package:http](https://pub.dev/packages/http) client based 7 | on [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) with 8 | WASM support. 9 | 10 | It's a drop-in solution for extensions with 11 | [Manifest V3](https://developer.chrome.com/docs/extensions/mv3/intro/#introducing-manifest-v3). 12 | 13 | ## Features 14 | 15 | * WASM-ready internals. 16 | * Cancel requests. 17 | * Support data streaming: 18 | * Get response as `Stream`. 19 | * Optionally, send `Stream` as request body (supported only in Chromium 105+ 20 | based browsers). 21 | * Get access to redirect URL and status. 22 | * Support non-`200` responses (`fetch` will only fail on network errors). 23 | * Simulate redirects responses via probe request and artificial `location` 24 | header. 25 | 26 | ## Notes 27 | 28 | ### Large payload 29 | 30 | This module maps `keepalive` to [`BaseRequest.persistentConnection`](https://pub.dev/documentation/http/latest/http/BaseRequest/persistentConnection.html) 31 | which is **`true`** by default. 32 | 33 | Fetch spec says that maximum request size with `keepalive` flag is 64KiB: 34 | 35 | > __4.5. HTTP-network-or-cache fetch__ 36 | > 37 | > > 8.10.5: If the sum of _contentLength_ and _inflightKeepaliveBytes_ is greater 38 | > > than 64 kibibytes, then return a [network error](https://fetch.spec.whatwg.org/#concept-network-error). 39 | > 40 | > _Source: [Fetch. Living Standard — Last Updated 19 June 2023](https://fetch.spec.whatwg.org/#http-network-or-cache-fetch)_ 41 | 42 | Therefore if your request is larger than 64KiB (this includes some other data, 43 | such as headers) [`BaseRequest.persistentConnection`](https://pub.dev/documentation/http/latest/http/BaseRequest/persistentConnection.html) 44 | will be ignored and treated as `false`. 45 | 46 | ### Request streaming 47 | 48 | Request streaming is supported only in Chromium 105+ based browsers and 49 | requires the server to use HTTP/2 or HTTP/3. 50 | 51 | See [MDN compatibility chart](https://developer.mozilla.org/en-US/docs/Web/API/Request#browser_compatibility) 52 | and [Chrome Developers' blog](https://developer.chrome.com/articles/fetch-streaming-requests/#doesnt-work-on-http1x) for more info. 53 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:zekfad_lints/lib.yaml 2 | -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | platforms: [chrome,firefox,vm] 2 | compilers: [dart2wasm,dart2js,exe] 3 | -------------------------------------------------------------------------------- /example/fetch_client_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:fetch_client/fetch_client.dart'; 4 | import 'package:http/http.dart'; 5 | 6 | 7 | void main() async { 8 | final client = FetchClient(mode: RequestMode.cors); 9 | final uri = Uri.https('jsonplaceholder.typicode.com', '/todos/1'); 10 | final response = await client.send(Request('GET', uri)); 11 | 12 | print(response.redirected); 13 | print(response.url); 14 | 15 | print(await utf8.decodeStream(response.stream)); 16 | } 17 | -------------------------------------------------------------------------------- /example/fetch_client_redirect_modes.dart: -------------------------------------------------------------------------------- 1 | import 'package:fetch_client/fetch_client.dart'; 2 | import 'package:http/http.dart'; 3 | 4 | 5 | void main() async { 6 | final client = FetchClient( 7 | mode: RequestMode.cors, 8 | redirectPolicy: RedirectPolicy.probeHead, // or RedirectPolicy.probe 9 | ); 10 | final uri = Uri.https('jsonplaceholder.typicode.com', 'guide'); 11 | final response = await client.send( 12 | Request('GET', uri)..followRedirects = false, 13 | ); 14 | 15 | print(response.headers['location']); 16 | } 17 | -------------------------------------------------------------------------------- /example/fetch_client_stream_request.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:fetch_client/fetch_client.dart'; 5 | import 'package:http/http.dart'; 6 | 7 | 8 | void main(List args) async { 9 | final client = FetchClient( 10 | mode: RequestMode.cors, 11 | streamRequests: true, 12 | ); 13 | 14 | final uri = Uri.https('api.restful-api.dev', 'objects'); 15 | 16 | final stream = (() async* { 17 | yield Uint8List.fromList( 18 | ''' 19 | { 20 | "name": "My cool data", 21 | "data": { 22 | "data_part_1": "part_1", 23 | '''.codeUnits, 24 | ); 25 | await Future.delayed(const Duration(seconds: 1)); 26 | yield Uint8List.fromList( 27 | ''' 28 | "data_part_2": "part_2" 29 | } 30 | } 31 | '''.codeUnits, 32 | ); 33 | })(); 34 | 35 | final request = StreamedRequest('POST', uri)..headers.addAll({ 36 | 'content-type': 'application/json', 37 | }); 38 | 39 | stream.listen( 40 | request.sink.add, 41 | onDone: request.sink.close, 42 | onError: request.sink.addError, 43 | ); 44 | 45 | final response = await client.send(request); 46 | 47 | print(await utf8.decodeStream(response.stream)); 48 | } 49 | -------------------------------------------------------------------------------- /lib/fetch_client.dart: -------------------------------------------------------------------------------- 1 | /// Fetch based HTTP client. 2 | /// Exports necessary fetch request options, request, client and response 3 | /// classes. 4 | // ignore: unnecessary_library_name 5 | library fetch_client; 6 | 7 | 8 | export 'package:fetch_api/enums.dart' show RequestCache, RequestCredentials, RequestMode, RequestReferrerPolicy; 9 | 10 | export 'src/fetch_client.dart' if (dart.library.io) 'src/fetch_client_io_shim.dart'; 11 | export 'src/fetch_request.dart'; 12 | export 'src/fetch_response.dart'; 13 | export 'src/redirect_policy.dart'; 14 | export 'src/request_canceled_exception.dart'; 15 | -------------------------------------------------------------------------------- /lib/src/cancel_callback.dart: -------------------------------------------------------------------------------- 1 | import 'request_canceled_exception.dart'; 2 | 3 | 4 | /// Type of cancel method, [reason] is passed to [RequestCanceledException.reason]. 5 | typedef CancelCallback = void Function([ String? reason, ]); 6 | -------------------------------------------------------------------------------- /lib/src/fetch_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:js_interop'; 3 | import 'dart:typed_data'; 4 | 5 | import 'package:fetch_api/fetch_api.dart'; 6 | import 'package:http/http.dart' show BaseClient, BaseRequest, ClientException; 7 | import 'cancel_callback.dart'; 8 | import 'fetch_request.dart'; 9 | import 'fetch_response.dart'; 10 | import 'on_done.dart'; 11 | import 'redirect_policy.dart'; 12 | import 'request_canceled_exception.dart'; 13 | 14 | 15 | /// HTTP client based on Fetch API. 16 | /// It does support streaming and can handle non 200 responses. 17 | /// 18 | /// {@template fetch_client_docs} 19 | /// This implementation has some restrictions: 20 | /// * [BaseRequest.persistentConnection] is translated to 21 | /// [FetchOptions.keepalive] (if [streamRequests] is disabled). 22 | /// * [BaseRequest.contentLength] is ignored. 23 | /// * When [BaseRequest.followRedirects] is `true`, you can get redirect 24 | /// information via [FetchResponse.redirected] and [FetchResponse.url]). 25 | /// If [BaseRequest.followRedirects] is `false` [redirectPolicy] takes place 26 | /// and dictates [FetchClient] actions. 27 | /// * [BaseRequest.maxRedirects] is ignored. 28 | /// * [FetchClient.streamRequests] is supported only in __Chromium 105+__ based 29 | /// browsers and requires server to be HTTP/2 or HTTP/3. 30 | /// 31 | /// See [compatibility chart](https://developer.mozilla.org/en-US/docs/Web/API/Request#browser_compatibility) 32 | /// and [Chrome Developers' blog](https://developer.chrome.com/articles/fetch-streaming-requests/#doesnt-work-on-http1x) 33 | /// for more info. 34 | /// {@endtemplate} 35 | class FetchClient extends BaseClient { 36 | /// Create new HTTP client based on Fetch API. 37 | /// 38 | /// {@macro fetch_client_docs} 39 | FetchClient({ 40 | this.mode = RequestMode.noCors, 41 | this.credentials = RequestCredentials.sameOrigin, 42 | this.cache = RequestCache.byDefault, 43 | this.referrer = '', 44 | this.referrerPolicy = RequestReferrerPolicy.strictOriginWhenCrossOrigin, 45 | this.redirectPolicy = RedirectPolicy.alwaysFollow, 46 | this.streamRequests = false, 47 | }); 48 | 49 | /// The default request mode. 50 | /// 51 | /// Mode is used to determine if cross-origin requests lead to valid 52 | /// responses, and which properties of the response are readable. 53 | final RequestMode mode; 54 | 55 | /// The default credentials mode, defines what browsers do with credentials 56 | /// (cookies, HTTP authentication entries, and TLS client certificates). 57 | final RequestCredentials credentials; 58 | 59 | /// The default cache mode which controls how requests will interact with 60 | /// the browser's HTTP cache. 61 | final RequestCache cache; 62 | 63 | /// The default referrer. 64 | /// This can be a same-origin URL, `about:client`, or an empty string. 65 | final String referrer; 66 | 67 | /// The default referrer policy. 68 | final RequestReferrerPolicy referrerPolicy; 69 | 70 | /// The default redirect policy, defines how client should handle 71 | /// [BaseRequest.followRedirects]. 72 | final RedirectPolicy redirectPolicy; 73 | 74 | /// Whether to use [ReadableStream] as body for requests streaming. 75 | /// 76 | /// **NOTICE**: This feature is supported only in __Chromium 105+__ based browsers and 77 | /// requires the server to be HTTP/2 or HTTP/3. 78 | /// 79 | /// See [compatibility chart](https://developer.mozilla.org/en-US/docs/Web/API/Request#browser_compatibility) 80 | /// and [Chrome Developers' blog](https://developer.chrome.com/articles/fetch-streaming-requests/#doesnt-work-on-http1x) 81 | /// for more info. 82 | final bool streamRequests; 83 | 84 | final _abortCallbacks = []; 85 | 86 | var _closed = false; 87 | 88 | @override 89 | Future send(BaseRequest request) async { 90 | if (_closed) { 91 | throw ClientException('Client is closed', request.url); 92 | } 93 | final requestMethod = request.method.toUpperCase(); 94 | final byteStream = request.finalize(); 95 | final RequestBody? body; 96 | final int bodySize; 97 | if (['GET', 'HEAD'].contains(requestMethod)) { 98 | body = null; 99 | bodySize = 0; 100 | } else if (streamRequests) { 101 | body = RequestBody.fromReadableStream( 102 | ReadableStream( 103 | ReadableStreamSource.fromStream( 104 | byteStream.transform( 105 | StreamTransformer.fromHandlers( 106 | handleData: (data, sink) => sink.add( 107 | (data is Uint8List 108 | ? data 109 | : Uint8List.fromList(data)).toJS, 110 | ), 111 | ), 112 | ), 113 | ), 114 | ), 115 | ); 116 | bodySize = -1; 117 | } else { 118 | final bytes = await byteStream.toBytes(); 119 | body = bytes.isEmpty 120 | ? null 121 | : RequestBody.fromJSTypedArray(bytes.toJS); 122 | bodySize = bytes.lengthInBytes; 123 | } 124 | 125 | final abortController = AbortController(); 126 | 127 | final fetchRequest = request is! FetchRequest ? null : request; 128 | final init = FetchOptions( 129 | body: body, 130 | method: request.method, 131 | redirect: ( 132 | request.followRedirects || 133 | (fetchRequest?.redirectPolicy ?? redirectPolicy) == RedirectPolicy.alwaysFollow 134 | ) 135 | ? RequestRedirect.follow 136 | : RequestRedirect.manual, 137 | headers: Headers.fromMap(request.headers), 138 | mode: fetchRequest?.mode ?? mode, 139 | credentials: fetchRequest?.credentials ?? credentials, 140 | cache: fetchRequest?.cache ?? cache, 141 | referrer: fetchRequest?.referrer ?? referrer, 142 | referrerPolicy: fetchRequest?.referrerPolicy ?? referrerPolicy, 143 | integrity: fetchRequest?.integrity ?? '', 144 | keepalive: bodySize < 63 * 1024 && !streamRequests && request.persistentConnection, 145 | signal: abortController.signal, 146 | duplex: !streamRequests ? null : RequestDuplex.half, 147 | ); 148 | 149 | final Response response; 150 | try { 151 | response = await _abortOnCloseSafeGuard( 152 | () => fetch(request.url.toString(), init), 153 | abortController, 154 | ); 155 | 156 | if ( 157 | response.type == 'opaqueredirect' && 158 | !request.followRedirects && 159 | redirectPolicy != RedirectPolicy.alwaysFollow 160 | ) { 161 | return _probeRedirect( 162 | request: request, 163 | initialResponse: response, 164 | init: init, 165 | abortController: abortController, 166 | ); 167 | } 168 | } catch (e) { 169 | throw ClientException('Failed to execute fetch: $e', request.url); 170 | } 171 | 172 | if (response.status == 0) { 173 | throw ClientException( 174 | 'Fetch response status code 0', 175 | request.url, 176 | ); 177 | } 178 | 179 | if (response.body == null && requestMethod != 'HEAD') { 180 | throw StateError('Invalid state: missing body with non-HEAD request.'); 181 | } 182 | 183 | final reader = response.body?.getReader(); 184 | 185 | late final CancelCallback abort; 186 | abort = ([ reason, ]) { 187 | _abortCallbacks.remove(abort); 188 | // if stream is errored cancel rethrows error reason, 189 | // here we dont really care about it 190 | reader?.cancel().ignore(); 191 | abortController.abort(reason?.toJS); 192 | }; 193 | _abortCallbacks.add(abort); 194 | 195 | final int? contentLength; 196 | final int? expectedBodyLength; 197 | if (response.headers.get('Content-Length') case final value?) { 198 | contentLength = int.tryParse(value); 199 | if (contentLength == null || contentLength < 0) { 200 | throw ClientException('Content-Length header must be a positive integer value.', request.url); 201 | } 202 | 203 | // Although `identity` SHOULD NOT be used in the Content-Encoding 204 | // according to [RFC 2616](https://www.rfc-editor.org/rfc/rfc2616#section-3.5), 205 | // we'll handle this edge case anyway. 206 | final encoding = response.headers.get('Content-Encoding'); 207 | if (response.responseType == ResponseType.cors) { 208 | // For cors response we should ensure that we actually have access to 209 | // Content-Encoding header, otherwise response can be encoded but 210 | // we won't be able to detect it. 211 | final exposedHeaders = response.headers.get('Access-Control-Expose-Headers')?.toLowerCase(); 212 | if (exposedHeaders != null && ( 213 | exposedHeaders.contains('*') || 214 | exposedHeaders.contains('content-encoding') 215 | ) && ( 216 | encoding == null || 217 | encoding.toLowerCase() == 'identity' 218 | )) { 219 | expectedBodyLength = contentLength; 220 | } else { 221 | expectedBodyLength = null; 222 | } 223 | } else { 224 | // In non-cors response we have access to Content-Encoding header 225 | if (encoding == null || encoding.toLowerCase() == 'identity') { 226 | expectedBodyLength = contentLength; 227 | } else { 228 | expectedBodyLength = null; 229 | } 230 | } 231 | } else { 232 | contentLength = null; 233 | expectedBodyLength = null; 234 | } 235 | 236 | final stream = onDone( 237 | reader == null 238 | ? const Stream.empty() 239 | : _readAsStream( 240 | reader: reader, 241 | expectedLength: expectedBodyLength, 242 | uri: request.url, 243 | abortController: abortController, 244 | ), 245 | abort, 246 | ); 247 | 248 | return FetchResponse( 249 | stream, 250 | response.status, 251 | cancel: abort, 252 | url: Uri.parse(response.url), 253 | redirected: response.redirected, 254 | request: request, 255 | headers: { 256 | for (final (name, value) in response.headers.entries()) 257 | name: value, 258 | }, 259 | isRedirect: false, 260 | persistentConnection: false, 261 | reasonPhrase: response.statusText, 262 | contentLength: contentLength, 263 | ); 264 | } 265 | 266 | /// Makes probe request and returns "redirect" response. 267 | Future _probeRedirect({ 268 | required BaseRequest request, 269 | required Response initialResponse, 270 | required FetchOptions init, 271 | required AbortController abortController, 272 | }) async { 273 | init.requestRedirect = RequestRedirect.follow; 274 | 275 | if (redirectPolicy == RedirectPolicy.probeHead) { 276 | init.method = 'HEAD'; 277 | } else { 278 | init.method = 'GET'; 279 | } 280 | 281 | final Response response; 282 | try { 283 | response = await _abortOnCloseSafeGuard( 284 | () => fetch(request.url.toString(), init), 285 | abortController, 286 | ); 287 | 288 | // Cancel before even reading response 289 | if (redirectPolicy == RedirectPolicy.probe) { 290 | abortController.abort(); 291 | } 292 | } catch (e) { 293 | throw ClientException('Failed to execute probe fetch: $e', request.url); 294 | } 295 | 296 | return FetchResponse( 297 | const Stream.empty(), 298 | 302, 299 | cancel: ([ reason, ]) {}, 300 | url: Uri.parse(initialResponse.url), 301 | redirected: false, 302 | request: request, 303 | headers: { 304 | for (final (name, value) in response.headers.entries()) 305 | name: value, 306 | 'location': response.url, 307 | }, 308 | isRedirect: true, 309 | persistentConnection: false, 310 | reasonPhrase: 'Found', 311 | contentLength: null, 312 | ); 313 | } 314 | 315 | /// Aborts [abortController] if [close] is called while performing an [action]. 316 | Future _abortOnCloseSafeGuard( 317 | Future Function() action, 318 | AbortController abortController, 319 | ) async { 320 | late final CancelCallback abortOnCloseSafeGuard; 321 | abortOnCloseSafeGuard = ([ reason, ]) { 322 | _abortCallbacks.remove(abortOnCloseSafeGuard); 323 | abortController.abort(reason?.toJS); 324 | }; 325 | _abortCallbacks.add(abortOnCloseSafeGuard); 326 | try { 327 | // Await is mandatory here. 328 | return await action(); 329 | } finally { 330 | // Abort wont make a difference anymore, so we remove unnecessary 331 | // reference. 332 | _abortCallbacks.remove(abortOnCloseSafeGuard); 333 | } 334 | } 335 | 336 | /// Reads [reader] via [ReadableStreamDefaultReader.readAsStream] and 337 | /// optionally checks that read data have [expectedLength]. 338 | Stream _readAsStream({ 339 | required ReadableStreamDefaultReader reader, 340 | required int? expectedLength, 341 | required Uri uri, 342 | required AbortController abortController, 343 | }) async* { 344 | final stream = reader.readAsStream(); 345 | var length = 0; 346 | 347 | try { 348 | await for (final JSUint8Array(toDart: chunk) in stream) { 349 | yield chunk; 350 | length += chunk.lengthInBytes; 351 | if (expectedLength != null && length > expectedLength) { 352 | throw ClientException('Content-Length is smaller than actual response length.', uri); 353 | } 354 | } 355 | // check if closed after stream is read, because canceling just forces 356 | // reader to close shortly without throwing an exception 357 | if (abortController.signal case AbortSignal(aborted: true, :final reason)) { 358 | throw RequestCanceledException(reason?.toDart ?? '', uri); 359 | } 360 | if (expectedLength != null && length < expectedLength) { 361 | throw ClientException('Content-Length is larger than actual response length.', uri); 362 | } 363 | } on ClientException { 364 | rethrow; 365 | } catch (e) { 366 | throw ClientException('Error occurred while reading response body: $e', uri); 367 | } 368 | } 369 | 370 | /// Closes the client and cleans up any resources associated with it. 371 | /// 372 | /// Once [close] is called, no other methods should be called. 373 | /// If [close] is called while other asynchronous methods are running, 374 | /// all associated active requests will be terminated immediately. 375 | @override 376 | void close() { 377 | if (!_closed) { 378 | _closed = true; 379 | for (final abort in _abortCallbacks.toList()) { 380 | abort('Client closed'); 381 | } 382 | } 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /lib/src/fetch_client_io_shim.dart: -------------------------------------------------------------------------------- 1 | /// This shim library mimics FetchClient to allow imports from non-JS platforms. 2 | /// @nodoc 3 | // ignore: unnecessary_library_name 4 | library fetch_client_io_shim; 5 | 6 | import 'package:fetch_api/enums.dart'; 7 | import 'package:http/http.dart' show BaseClient, BaseRequest; 8 | import 'fetch_response.dart'; 9 | import 'redirect_policy.dart'; 10 | 11 | 12 | /// @nodoc 13 | class FetchClient extends BaseClient { 14 | /// @nodoc 15 | FetchClient({ 16 | this.mode = RequestMode.noCors, 17 | this.credentials = RequestCredentials.sameOrigin, 18 | this.cache = RequestCache.byDefault, 19 | this.referrer = '', 20 | this.referrerPolicy = RequestReferrerPolicy.strictOriginWhenCrossOrigin, 21 | this.redirectPolicy = RedirectPolicy.alwaysFollow, 22 | this.streamRequests = false, 23 | }) { 24 | throw UnsupportedError('Unsupported platform'); 25 | } 26 | 27 | /// @nodoc 28 | final RequestMode mode; 29 | 30 | /// @nodoc 31 | final RequestCredentials credentials; 32 | 33 | /// @nodoc 34 | final RequestCache cache; 35 | 36 | /// @nodoc 37 | final String referrer; 38 | 39 | /// @nodoc 40 | final RequestReferrerPolicy referrerPolicy; 41 | 42 | /// @nodoc 43 | final RedirectPolicy redirectPolicy; 44 | 45 | /// @nodoc 46 | final bool streamRequests; 47 | 48 | /// @nodoc 49 | @override 50 | Future send(BaseRequest request) async => throw UnsupportedError('Unsupported platform'); 51 | 52 | /// @nodoc 53 | @override 54 | void close() => throw UnsupportedError('Unsupported platform'); 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/fetch_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:fetch_api/enums.dart'; 2 | import 'package:http/http.dart'; 3 | 4 | import 'fetch_client.dart' if (dart.library.io) 'fetch_client_io_shim.dart'; 5 | import 'fetch_response.dart'; 6 | import 'on_done.dart'; 7 | import 'redirect_policy.dart'; 8 | 9 | 10 | /// Wraps request to provide fetch options overrides. 11 | class FetchRequest implements BaseRequest { 12 | /// Create new fetch request by wrapping existing [request]. 13 | FetchRequest(this.request); 14 | 15 | /// Inner request to send. 16 | final T request; 17 | 18 | @override 19 | String get method => request.method; 20 | 21 | @override 22 | Uri get url => request.url; 23 | 24 | @override 25 | int? get contentLength => request.contentLength; 26 | @override 27 | set contentLength(int? value) => request.contentLength = value; 28 | 29 | @override 30 | bool get persistentConnection => request.persistentConnection; 31 | @override 32 | set persistentConnection(bool value) => request.persistentConnection = value; 33 | 34 | @override 35 | bool get followRedirects => request.followRedirects; 36 | @override 37 | set followRedirects(bool value) => request.followRedirects = value; 38 | 39 | @override 40 | int get maxRedirects => request.maxRedirects; 41 | @override 42 | set maxRedirects(int value) => request.maxRedirects = value; 43 | 44 | @override 45 | Map get headers => request.headers; 46 | 47 | @override 48 | bool get finalized => request.finalized; 49 | 50 | /// The subresource integrity value of the request 51 | /// (e.g.,`sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE=`). 52 | String? get integrity => _integrity; 53 | String? _integrity; 54 | 55 | set integrity(String? value) { 56 | _checkFinalized(); 57 | _integrity = value; 58 | } 59 | 60 | /// The mode of the request. 61 | RequestMode? get mode => _mode; 62 | RequestMode? _mode; 63 | 64 | set mode(RequestMode? value) { 65 | _checkFinalized(); 66 | _mode = value; 67 | } 68 | 69 | /// The credentials mode, defines what browsers do with credentials (cookies, 70 | /// HTTP authentication entries, and TLS client certificates). 71 | RequestCredentials? get credentials => _credentials; 72 | RequestCredentials? _credentials; 73 | 74 | set credentials(RequestCredentials? value) { 75 | _checkFinalized(); 76 | _credentials = value; 77 | } 78 | 79 | /// The cache mode which controls how the request will interact with 80 | /// the browser's HTTP cache. 81 | RequestCache? get cache => _cache; 82 | RequestCache? _cache; 83 | 84 | set cache(RequestCache? value) { 85 | _checkFinalized(); 86 | _cache = value; 87 | } 88 | 89 | /// The referrer of the request. 90 | /// This can be a same-origin URL, `about:client`, or an empty string. 91 | String? get referrer => _referrer; 92 | String? _referrer; 93 | 94 | set referrer(String? value) { 95 | _checkFinalized(); 96 | _referrer = value; 97 | } 98 | 99 | /// The referrer policy of the request. 100 | RequestReferrerPolicy? get referrerPolicy => _referrerPolicy; 101 | RequestReferrerPolicy? _referrerPolicy; 102 | 103 | set referrerPolicy(RequestReferrerPolicy? value) { 104 | _checkFinalized(); 105 | _referrerPolicy = value; 106 | } 107 | 108 | /// The redirect policy of the request, defines how client should handle 109 | /// [BaseRequest.followRedirects]. 110 | RedirectPolicy? get redirectPolicy => _redirectPolicy; 111 | RedirectPolicy? _redirectPolicy; 112 | 113 | set redirectPolicy(RedirectPolicy? value) { 114 | _checkFinalized(); 115 | _redirectPolicy = value; 116 | } 117 | 118 | /// Throws an error if this request has been finalized. 119 | void _checkFinalized() { 120 | if (!finalized) { 121 | return; 122 | } 123 | throw StateError("Can't modify a finalized Request."); 124 | } 125 | 126 | @override 127 | ByteStream finalize() => request.finalize(); 128 | 129 | @override 130 | Future send() async { 131 | final client = FetchClient(); 132 | 133 | try { 134 | final response = await client.send(this); 135 | final stream = onDone(response.stream, client.close); 136 | return FetchResponse( 137 | stream, 138 | response.statusCode, 139 | cancel: response.cancel, 140 | url: response.url, 141 | redirected: response.redirected, 142 | request: response.request, 143 | headers: response.headers, 144 | isRedirect: response.isRedirect, 145 | persistentConnection: response.persistentConnection, 146 | reasonPhrase: response.reasonPhrase, 147 | contentLength: response.contentLength, 148 | ); 149 | } catch(_) { 150 | client.close(); 151 | rethrow; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/src/fetch_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart' show BaseResponseWithUrl, StreamedResponse; 2 | 3 | import 'cancel_callback.dart'; 4 | import 'request_canceled_exception.dart'; 5 | 6 | 7 | /// [StreamedResponse] with additional capability to [cancel] request and access 8 | /// to final (after redirects) request [url]. 9 | class FetchResponse extends StreamedResponse implements BaseResponseWithUrl { 10 | /// Creates a new cancelable streaming response. 11 | /// 12 | /// [stream] should be a single-subscription stream. 13 | FetchResponse(super.stream, super.statusCode, { 14 | required this.cancel, 15 | required this.url, 16 | required this.redirected, 17 | super.contentLength, 18 | super.request, 19 | super.headers, 20 | super.isRedirect, 21 | super.persistentConnection, 22 | super.reasonPhrase, 23 | }); 24 | 25 | /// Cancels current request and causes it to throw [RequestCanceledException] 26 | /// with provided reason. 27 | final CancelCallback cancel; 28 | 29 | /// Target resource url (the one after redirects, if there were any). 30 | @override 31 | final Uri url; 32 | 33 | /// Whether browser was redirected before loading actual resource. 34 | final bool redirected; 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/on_done.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | 4 | /// Calls [onDone] once [stream] (a single-subscription [Stream]) is finished. 5 | /// 6 | /// The return value, also a single-subscription [Stream] should be used in 7 | /// place of [stream] after calling this method. 8 | Stream onDone(Stream stream, void Function() onDone) => 9 | stream.transform( 10 | StreamTransformer.fromHandlers( 11 | handleDone: (sink) { 12 | sink.close(); 13 | onDone(); 14 | }, 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /lib/src/redirect_policy.dart: -------------------------------------------------------------------------------- 1 | import 'package:fetch_api/fetch_api.dart' /* show RequestRedirect, Response */ if (dart.library.io) ''; 2 | 3 | import 'fetch_client.dart' if (dart.library.io) 'fetch_client_io_shim.dart'; 4 | import 'fetch_response.dart'; 5 | 6 | 7 | /// Policy that determines how [FetchClient] should handle redirects. 8 | enum RedirectPolicy { 9 | /// Default policy - always follow redirects. 10 | /// If redirect is occurred, the only way to know about it is via 11 | /// [FetchResponse.redirected] and [FetchResponse.url]. 12 | alwaysFollow, 13 | /// Probe via HTTP `GET` request. 14 | /// 15 | /// In this mode request is made with [RequestRedirect.manual], with 16 | /// no redirects, normal response is returned as usual. 17 | /// 18 | /// If redirect is occurred, additional `GET` request will be sent and 19 | /// canceled before body will be available. Returning response with only 20 | /// headers and artificial `Location` header crafted from [Response.url]. 21 | /// 22 | /// Note that such response will always be crafted as `302 Found` and there's 23 | /// no way to get intermediate redirects, so you will get the targeted 24 | /// redirect as if the original server returned it. 25 | probe, 26 | /// Same as [probe] but using `HEAD` method and therefore no cancel is needed. 27 | probeHead; 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/request_canceled_exception.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | 3 | 4 | /// An exception caused by canceling the request. 5 | class RequestCanceledException extends ClientException { 6 | /// Create new request cancelled exception. 7 | RequestCanceledException(this.reason, Uri uri) : super( 8 | 'request canceled${(reason?.isEmpty ?? true) ? '' : ': $reason'}', 9 | uri, 10 | ); 11 | 12 | /// Reason caused the request to be canceled. 13 | final String? reason; 14 | } 15 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fetch_client 2 | description: > 3 | Client for http package based on Fetch API, enables streamed and cancelable 4 | requests on web and more. 5 | version: 1.1.4 6 | homepage: https://github.com/Zekfad/fetch_client 7 | repository: https://github.com/Zekfad/fetch_client 8 | issue_tracker: https://github.com/Zekfad/fetch_client/issues 9 | # documentation: https://pub.dev/documentation/fetch_client/latest/ 10 | topics: 11 | - web 12 | - fetch 13 | - http 14 | 15 | platforms: 16 | web: 17 | 18 | environment: 19 | sdk: '>=3.3.0 <4.0.0' 20 | 21 | dependencies: 22 | fetch_api: ^2.2.0 23 | http: ^1.2.0 24 | 25 | dev_dependencies: 26 | build_runner: '>=2.4.10' 27 | build_web_compilers: '>=4.0.10' 28 | http_client_conformance_tests: 29 | git: 30 | url: https://github.com/dart-lang/http 31 | ref: master 32 | path: pkgs/http_client_conformance_tests 33 | test: ^1.25.5 34 | zekfad_lints: ^2.3.0 35 | -------------------------------------------------------------------------------- /test/cancel_test.dart: -------------------------------------------------------------------------------- 1 | @TestOn('browser') 2 | library; 3 | 4 | import 'package:fetch_client/fetch_client.dart'; 5 | import 'package:http/http.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | 9 | void main() { 10 | group('cancel test', () { 11 | final client = FetchClient(mode: RequestMode.cors); 12 | final uri = Uri.parse('https://raw.githubusercontent.com/Zekfad/fetch_client/22c3a2732c4a89ef284827cba4a7e62a01535776/LICENSE'); 13 | 14 | test('throw error when request is canceled', () async { 15 | final request = FetchRequest( 16 | Request('GET', uri), 17 | ); 18 | final response = await client.send(request); 19 | response.cancel(); 20 | 21 | await expectLater( 22 | response.stream.bytesToString(), 23 | throwsA(isA()), 24 | ); 25 | }); 26 | 27 | test('throw error when request is canceled with provided reason', () async { 28 | const reason = 'cancel reason'; 29 | final request = FetchRequest( 30 | Request('GET', uri), 31 | ); 32 | final response = await client.send(request); 33 | response.cancel(reason); 34 | 35 | await expectLater( 36 | response.stream.bytesToString(), 37 | throwsA( 38 | const TypeMatcher().having( 39 | (e) => e.reason, 40 | 'reason must be as provided', 41 | equals(reason), 42 | ), 43 | ), 44 | ); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /test/client_conformance_test.dart: -------------------------------------------------------------------------------- 1 | @TestOn('browser') 2 | library; 3 | 4 | import 'package:fetch_client/fetch_client.dart'; 5 | import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | 9 | void main() { 10 | group('client conformance tests', () { 11 | testAll( 12 | () => FetchClient(mode: RequestMode.cors), 13 | canStreamRequestBody: false, 14 | canStreamResponseBody: true, 15 | redirectAlwaysAllowed: true, 16 | canWorkInIsolates: false, 17 | canReceiveSetCookieHeaders: false, 18 | canSendCookieHeaders: false, 19 | preservesMethodCase: false, 20 | ); 21 | }); 22 | 23 | group('client conformance tests with probe mode', () { 24 | testAll( 25 | () => FetchClient( 26 | mode: RequestMode.cors, 27 | redirectPolicy: RedirectPolicy.probe, 28 | ), 29 | canStreamRequestBody: false, 30 | canStreamResponseBody: true, 31 | redirectAlwaysAllowed: true, 32 | canWorkInIsolates: false, 33 | canReceiveSetCookieHeaders: false, 34 | canSendCookieHeaders: false, 35 | preservesMethodCase: false, 36 | ); 37 | }); 38 | 39 | group('client conformance tests with probeHead mode', () { 40 | testAll( 41 | () => FetchClient( 42 | mode: RequestMode.cors, 43 | redirectPolicy: RedirectPolicy.probeHead, 44 | ), 45 | canStreamRequestBody: false, 46 | canStreamResponseBody: true, 47 | redirectAlwaysAllowed: true, 48 | canWorkInIsolates: false, 49 | canReceiveSetCookieHeaders: false, 50 | canSendCookieHeaders: false, 51 | preservesMethodCase: false, 52 | ); 53 | }); 54 | 55 | // Fails with ERR_H2_OR_QUIC_REQUIRED 56 | // That means server must support request streaming in some special form 57 | // or something. 58 | // group('client conformance tests with streaming mode', () { 59 | // testAll( 60 | // () => FetchClient( 61 | // mode: RequestMode.cors, 62 | // streamRequests: true, 63 | // ), 64 | // canStreamRequestBody: true, 65 | // canStreamResponseBody: true, 66 | // redirectAlwaysAllowed: true, 67 | // canWorkInIsolates: false, 68 | // ); 69 | // }); 70 | } 71 | -------------------------------------------------------------------------------- /test/integrity_test.dart: -------------------------------------------------------------------------------- 1 | @TestOn('browser') 2 | library; 3 | 4 | import 'package:fetch_client/fetch_client.dart'; 5 | import 'package:http/http.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | 9 | void main() { 10 | group('integrity test', () { 11 | final client = FetchClient(mode: RequestMode.cors); 12 | final uri = Uri.parse('https://raw.githubusercontent.com/Zekfad/fetch_client/22c3a2732c4a89ef284827cba4a7e62a01535776/LICENSE'); 13 | 14 | test('throw error when integrity mismatch', () async { 15 | const integrity = 'sha256-0'; 16 | final request = FetchRequest( 17 | Request('GET', uri), 18 | )..integrity = integrity; 19 | await expectLater( 20 | client.send(request), 21 | throwsA(isA()), 22 | ); 23 | }); 24 | 25 | test('succeed with correct integrity', () async { 26 | const integrity = 'sha256-NTaW0fWGbetqbg/iB0CfyvrxlEvm4rk3f1MXq+Zu0S8='; 27 | final request = FetchRequest( 28 | Request('GET', uri), 29 | )..integrity = integrity; 30 | final response = await client.send(request); 31 | expect(response.statusCode, 200); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /test/vm_shim_test.dart: -------------------------------------------------------------------------------- 1 | @TestOn('vm') 2 | library; 3 | 4 | import 'package:fetch_client/fetch_client.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | 8 | void main() { 9 | group('vm shim test', () { 10 | test('doesn\'t prevent import in vm environment', () { 11 | expect(RequestMode.cors.toString(), equals('cors')); 12 | }); 13 | 14 | test('throws unsupported error in vm', () { 15 | expect(FetchClient.new, anyOf(throwsUnsupportedError)); 16 | }); 17 | }); 18 | } 19 | --------------------------------------------------------------------------------