close method
Close the request for input. Returns the value of done.
Implementation
@override
Future<HttpClientResponse> close() async {
double? contextId = WebFHttpOverrides.getContextHeader(headers);
HttpClientRequest request = this;
// Get the loading state dumper for tracking
final dumper = contextId != null ? LoadingStateRegistry.instance.getDumper(contextId) : null;
// Track request start if not already tracked by NetworkBundle
if (dumper != null && ownerBundle == null) {
final requestHeaders = <String, String>{};
headers.forEach((name, values) {
requestHeaders[name] = values.join(', ');
});
// Check if this is a Fetch/XHR request by looking for the marker header
final isFetchRequest = headers.value('X-WebF-Request-Type') == 'fetch';
dumper.recordNetworkRequestStart(
_uri.toString(),
method: _method,
headers: requestHeaders,
isXHR: isFetchRequest,
protocol: _uri.scheme,
remotePort: _uri.hasAuthority ? _uri.port : null,
);
}
if (contextId != null) {
// Standard reference: https://datatracker.ietf.org/doc/html/rfc7231#section-5.5.2
// Most general-purpose user agents do not send the
// Referer header field when the referring resource is a local "file" or
// "data" URI. A user agent MUST NOT send a Referer header field in an
// unsecured HTTP request if the referring page was received with a
// secure protocol.
Uri referrer = getEntrypointUri(contextId);
bool isUnsafe = referrer.isScheme('https') && !uri.isScheme('https');
bool isLocalRequest = uri.isScheme('file') || uri.isScheme('data') || uri.isScheme('assets');
if (!isUnsafe && !isLocalRequest) {
headers.set(HttpHeaders.refererHeader, referrer.toString());
}
// Standard reference: https://fetch.spec.whatwg.org/#origin-header
// `if request’s method is neither `GET` nor `HEAD`, then follow referrer policy to append origin.`
// @TODO: Apply referrer policy.
String origin = getOrigin(referrer);
if (method != 'GET' && method != 'HEAD') {
headers.set(_httpHeadersOrigin, origin);
}
// Step 1: Prepare request (no custom interceptor).
await CookieManager.loadForRequest(_uri, request.cookies);
// Step 2: Handle cache-control and expires,
// if hit, no need to open request.
HttpCacheObject? cacheObject;
// Per-controller cache toggle: allow a controller to override global cache mode.
bool cacheEnabled = true;
final ctrl = WebFController.getControllerOfJSContextId(contextId);
final bool controllerWantsCache = ctrl?.networkOptions?.effectiveEnableHttpCache == true;
final bool controllerForbidsCache = ctrl?.networkOptions?.effectiveEnableHttpCache == false;
final bool globalCacheOn = HttpCacheController.mode != HttpCacheMode.NO_CACHE;
cacheEnabled = controllerForbidsCache ? false : (controllerWantsCache ? true : globalCacheOn);
if (cacheEnabled) {
HttpCacheController cacheController = HttpCacheController.instance(origin);
cacheObject = await cacheController.getCacheObject(request.uri);
if (cacheObject.hitLocalCache(request)) {
HttpClientResponse? cacheResponse = await cacheObject.toHttpClientResponse(_nativeHttpClient);
ownerBundle?.setLoadingFromCache();
if (cacheResponse != null) {
// Track cache hit
dumper?.recordNetworkRequestCacheInfo(_uri.toString(),
cacheHit: true,
cacheType: 'disk',
cacheEntryTime: cacheObject.lastUsed,
cacheHeaders: {},
);
// Track completion for cache hit
final responseHeaders = <String, String>{};
String? contentType;
cacheResponse.headers.forEach((name, values) {
final headerValue = values.join(', ');
responseHeaders[name] = headerValue;
if (name.toLowerCase() == 'content-type') {
contentType = headerValue;
}
});
dumper?.recordNetworkRequestComplete(_uri.toString(),
statusCode: cacheResponse.statusCode,
responseHeaders: responseHeaders,
contentType: contentType,
);
return cacheResponse;
}
}
// Step 3: Handle negotiate cache request header.
if (cacheObject.valid &&
headers.ifModifiedSince == null &&
headers.value(HttpHeaders.ifNoneMatchHeader) == null) {
// ETag has higher priority of lastModified.
if (cacheObject.eTag != null) {
headers.set(HttpHeaders.ifNoneMatchHeader, cacheObject.eTag!);
} else if (cacheObject.lastModified != null) {
headers.set(HttpHeaders.ifModifiedSinceHeader, HttpDate.format(cacheObject.lastModified!));
}
}
}
request = await _createBackendClientRequest();
// Send the real data to backend client.
if (_data.isNotEmpty) {
await request.addStream(Stream.value(_data));
_data.clear();
}
// Step 4: Send network request
late HttpClientResponse response;
bool hitNegotiateCache = false;
// If cache only, but no cache hit (we'd have returned earlier), abort.
if (HttpCacheController.mode == HttpCacheMode.CACHE_ONLY) {
final errorMsg = 'CACHE_ONLY mode but no cache hit';
// Check if this is a Fetch/XHR request by looking for the marker header
final isFetchRequest = headers.value('X-WebF-Request-Type') == 'fetch';
dumper?.recordNetworkRequestError(_uri.toString(), errorMsg, isXHR: isFetchRequest);
throw FlutterError('HttpCacheMode is CACHE_ONLY, but no cache hit for $uri');
}
// Handle response and 304 negotiation
HttpClientResponse rawResponse;
try {
rawResponse = await request.close();
// Track redirects if any occurred
if (rawResponse.redirects.isNotEmpty && dumper != null && ownerBundle == null) {
for (final redirect in rawResponse.redirects) {
dumper.recordNetworkRequestRedirect(
_uri.toString(),
redirect.location.toString(),
statusCode: redirect.statusCode,
);
}
}
} catch (e) {
// If still failing, log and rethrow
networkLogger.warning('Error closing HTTP request for $uri', e);
// Check if this is a Fetch/XHR request by looking for the marker header
final isFetchRequest = headers.value('X-WebF-Request-Type') == 'fetch';
dumper?.recordNetworkRequestError(_uri.toString(), e.toString(), isXHR: isFetchRequest);
rethrow;
}
response = cacheObject == null
? rawResponse
: await HttpCacheController.instance(origin)
.interceptResponse(request, rawResponse, cacheObject, _nativeHttpClient, ownerBundle);
hitNegotiateCache = rawResponse != response;
// Step 5: Save cookies from response.
await CookieManager.saveFromResponseRaw(uri, response.headers[HttpHeaders.setCookieHeader]);
// Track 304 Not Modified response
if (hitNegotiateCache && response.statusCode == HttpStatus.notModified) {
dumper?.recordNetworkRequestCacheInfo(_uri.toString(),
cacheHit: true,
cacheType: 'network_validated',
cacheHeaders: {},
);
}
// Track redirects from the final response if any occurred
if (response.redirects.isNotEmpty && dumper != null && ownerBundle == null) {
for (final redirect in response.redirects) {
dumper.recordNetworkRequestRedirect(
_uri.toString(),
redirect.location.toString(),
statusCode: redirect.statusCode,
);
}
}
// Track final response
if (dumper != null && ownerBundle == null) {
final responseHeaders = <String, String>{};
String? contentType;
response.headers.forEach((name, values) {
final headerValue = values.join(', ');
responseHeaders[name] = headerValue;
if (name.toLowerCase() == 'content-type') {
contentType = headerValue;
}
});
dumper.recordNetworkRequestComplete(_uri.toString(),
statusCode: response.statusCode,
responseHeaders: responseHeaders,
contentType: contentType,
);
}
// Check match cache, and then return cache.
if (hitNegotiateCache) {
return Future.value(response);
}
if (cacheObject != null) {
// Step 6: Intercept response by cache controller (handle 304).
// Note: No need to negotiate cache here, this is final response, hit or not hit.
return HttpCacheController.instance(origin)
.interceptResponse(request, response, cacheObject, _nativeHttpClient, ownerBundle);
} else {
return response;
}
} else {
request = await _createBackendClientRequest();
// Not using request.add, because large data will cause core exception.
if (_data.isNotEmpty) {
await request.addStream(Stream.value(_data));
_data.clear();
}
}
return await request.close();
}