Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/http cache should not intercept multi times #619

Merged
merged 7 commits into from Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 3 additions & 4 deletions kraken/lib/src/foundation/http_cache.dart
Expand Up @@ -93,18 +93,17 @@ class HttpCacheController {
HttpClientRequest request,
HttpClientResponse response,
HttpCacheObject cacheObject) async {

await cacheObject.updateIndex(response);

// Handle with HTTP 304
// Negotiate cache with HTTP 304
if (response.statusCode == HttpStatus.notModified) {
HttpClientResponse? cachedResponse = await cacheObject.toHttpClientResponse();
if (cachedResponse != null) {
return cachedResponse;
}
}

if (response.statusCode == HttpStatus.ok && response is! HttpClientCachedResponse) {
if (response.statusCode == HttpStatus.ok) {
// Create cache object.
HttpCacheObject cacheObject = HttpCacheObject
.fromResponse(
Expand Down Expand Up @@ -162,7 +161,7 @@ class HttpClientCachedResponse extends Stream<List<int>> implements HttpClientRe
void Function(List<int> event)? onData, {
Function? onError, void Function()? onDone, bool? cancelOnError
}) {
_blobSink = cacheObject.openBlobWrite();
_blobSink ??= cacheObject.openBlobWrite();

void _handleData(List<int> data) {
if (onData != null) onData(data);
Expand Down
54 changes: 18 additions & 36 deletions kraken/lib/src/foundation/http_cache_object.dart
Expand Up @@ -8,12 +8,14 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;

import 'http_client_response.dart';

class HttpCacheObject {
static const _httpHeaderCacheHits = 'cache-hits';
static const _httpCacheHit = 'HIT';

// The cached url of resource.
String url;

Expand Down Expand Up @@ -112,6 +114,8 @@ class HttpCacheObject {
static int NetworkType = 0x01;
static int Reserved = 0x00;

// This method write bytes in [Endian.little] order.
// Reference: https://en.wikipedia.org/wiki/Endianness
static void writeString(BytesBuilder bytesBuilder, String str, int size) {
final int strLength = str.length;
for (int i = 0; i < size; i++) {
Expand All @@ -127,30 +131,13 @@ class HttpCacheObject {
}
}

static List<int> fromBytes(List<int> list, int size) {
if (list.length % size != 0) {
throw ArgumentError('Wrong size');
}

final result = <int>[];
for (var i = 0; i < list.length; i += size) {
var value = 0;
for (var j = 0; j < size; j++) {
var byte = list[i + j];
final val = (byte & 0xff) << (j * 8);
value |= val;
}

result.add(value);
}

return result;
}

bool isDateTimeValid() => expiredTime != null && expiredTime!.isAfter(DateTime.now());

// Validate the cache-control and expires.
bool hitLocalCache(HttpClientRequest request) {
Future<bool> hitLocalCache(HttpClientRequest request) async {
if (!valid) {
await read();
}
return isDateTimeValid();
}

Expand All @@ -164,41 +151,38 @@ class HttpCacheObject {

try {
Uint8List bytes = await _file.readAsBytes();
ByteData byteData = bytes.buffer.asByteData();
int index = 0;

// Reserved units.
index += 4;

// Read expiredTime.
Uint8List expiredTimestamp = bytes.sublist(index, index + 8);
expiredTime = DateTime.fromMillisecondsSinceEpoch(fromBytes(expiredTimestamp, 8).single);
expiredTime = DateTime.fromMillisecondsSinceEpoch(byteData.getUint64(index, Endian.little));
index += 8;

// Read lastUsed.
Uint8List lastUsedTimestamp = bytes.sublist(index, index + 8);
lastUsed = DateTime.fromMillisecondsSinceEpoch(fromBytes(lastUsedTimestamp, 8).single);
lastUsed = DateTime.fromMillisecondsSinceEpoch(byteData.getUint64(index, Endian.little));
index += 8;

// Read lastModified.
Uint8List lastModifiedTimestamp = bytes.sublist(index, index + 8);
lastModified = DateTime.fromMillisecondsSinceEpoch(fromBytes(lastModifiedTimestamp, 8).single);
lastModified = DateTime.fromMillisecondsSinceEpoch(byteData.getUint64(index, Endian.little));
index += 8;

// Read contentLength.
contentLength = fromBytes(bytes.sublist(index, index + 4), 4).single;
contentLength = byteData.getUint32(index, Endian.little);
index += 4;

// Read url.
Uint8List urlLengthValue = bytes.sublist(index, index + 4);
int urlLength = fromBytes(urlLengthValue, 4).single;
int urlLength = byteData.getUint32(index, Endian.little);
index += 4;

Uint8List urlValue = bytes.sublist(index, index + urlLength);
url = utf8.decode(urlValue);
index += urlLength;

// Read eTag.
int eTagLength = fromBytes(bytes.sublist(index, index + 2), 2).single;
int eTagLength = byteData.getUint16(index, Endian.little);
index += 2;

Uint8List eTagValue = bytes.sublist(index, index + eTagLength);
Expand All @@ -221,7 +205,6 @@ class HttpCacheObject {
NetworkType, Reserved, Reserved, Reserved,
]);


// | ExpiredTimeStamp x 8 |
final int expiredTimeStamp = (expiredTime ?? alwaysExpired).millisecondsSinceEpoch;
writeInteger(bytesBuilder, expiredTimeStamp, 8);
Expand Down Expand Up @@ -271,7 +254,7 @@ class HttpCacheObject {
if (expiredTime != null) HttpHeaders.expiresHeader: HttpDate.format(expiredTime!),
if (contentLength != null) HttpHeaders.contentLengthHeader: contentLength.toString(),
if (lastModified != null) HttpHeaders.lastModifiedHeader: HttpDate.format(lastModified!),
if (kDebugMode) 'x-kraken-cache': 'From http cache',
_httpHeaderCacheHits: _httpCacheHit,
};
}

Expand All @@ -284,8 +267,7 @@ class HttpCacheObject {
return await _blob.exists();
}

Future<HttpClientResponse?> toHttpClientResponse({
HttpClientResponse? originalResponse }) async {
Future<HttpClientResponse?> toHttpClientResponse() async {
if (!await _exists) {
return null;
}
Expand Down
29 changes: 24 additions & 5 deletions kraken/lib/src/foundation/http_client_request.dart
Expand Up @@ -117,7 +117,7 @@ class ProxyHttpClientRequest extends HttpClientRequest {
// if hit, no need to open request.
HttpCacheController cacheController = HttpCacheController.instance(origin);
HttpCacheObject cacheObject = await cacheController.getCacheObject(request.uri);
if (cacheObject.hitLocalCache(request)) {
if (await cacheObject.hitLocalCache(request)) {
HttpClientResponse? cacheResponse = await cacheObject.toHttpClientResponse();
if (cacheResponse != null) {
return cacheResponse;
Expand Down Expand Up @@ -146,16 +146,35 @@ class ProxyHttpClientRequest extends HttpClientRequest {
response = await _shouldInterceptRequest(clientInterceptor, request);
}

bool hitInterceptorResponse = response != null;
bool hitNegotiateCache = false;

// After this, response should not be null.
response ??= await _requestQueue.add(() async => cacheController
.interceptResponse(request, await request.close(), cacheObject));
if (!hitInterceptorResponse) {
// Handle 304 here.
final HttpClientResponse rawResponse = await request.close();
response = await _requestQueue.add(() async => cacheController
.interceptResponse(request, rawResponse, cacheObject));

hitNegotiateCache = rawResponse != response;
}

// Step 5: Lifecycle of afterResponse.
if (clientInterceptor != null) {
response = await _afterResponse(clientInterceptor, request, response!) ?? response;
final HttpClientResponse? interceptorResponse = await _afterResponse(clientInterceptor, request, response!);
if (interceptorResponse != null) {
hitInterceptorResponse = true;
response = interceptorResponse;
}
}

// Check match cache, and then return cache.
if (hitInterceptorResponse || hitNegotiateCache) {
return Future.value(response);
}

// Step 6: Intercept response by cache controller (handle 304).
// Step 6: Intercept response by cache controller.
// Note: No need to negotiate cache here, this is final response, hit or not hit.
return cacheController.interceptResponse(request, response!, cacheObject);
} else {
_clientRequest.add(_data);
Expand Down
4 changes: 2 additions & 2 deletions kraken/lib/src/foundation/http_client_response.dart
Expand Up @@ -226,7 +226,7 @@ class HttpClientStreamResponse extends Stream<List<int>> implements HttpClientRe
HttpConnectionInfo get connectionInfo => _HttpConnectionInfo(80, InternetAddress.loopbackIPv4, 80);

@override
int get contentLength => -1;
int get contentLength => headers.contentLength;

@override
List<Cookie> get cookies => [];
Expand Down Expand Up @@ -255,6 +255,6 @@ class HttpClientStreamResponse extends Stream<List<int>> implements HttpClientRe

@override
StreamSubscription<List<int>> listen(void Function(List<int> event)? onData, { Function? onError, void Function()? onDone, bool? cancelOnError }) {
return data.listen(onData, onDone: onDone, cancelOnError: cancelOnError);
return data.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
}
188 changes: 188 additions & 0 deletions kraken/test/fixtures/GET_js_over_128k

Large diffs are not rendered by default.

48 changes: 31 additions & 17 deletions kraken/test/src/foundation/http_cache.dart
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:test/test.dart';
import 'package:kraken/foundation.dart';
import '../../local_http_server.dart';
Expand All @@ -24,7 +24,7 @@ void main() {
expect(response.headers.value(HttpHeaders.expiresHeader),
'Mon, 16 Aug 2221 10:17:45 GMT');

var data = await sinkStream(response);
var data = await consolidateHttpClientResponseBytes(response);
var content = jsonDecode(String.fromCharCodes(data));
expect(content, {
'method': 'GET',
Expand All @@ -38,7 +38,7 @@ void main() {
server.getUri('json_with_content_length_expires_etag_last_modified'));
KrakenHttpOverrides.setContextHeader(requestSecond, contextId);
var responseSecond = await requestSecond.close();
assert(responseSecond.headers.value('x-kraken-cache') != null);
expect(responseSecond.headers.value('cache-hits'), 'HIT');
});

test('Negotiation cache last-modified', () async {
Expand All @@ -48,7 +48,7 @@ void main() {
KrakenHttpOverrides.setContextHeader(req, contextId);
req.headers.ifModifiedSince = HttpDate.parse('Sun, 15 Mar 2020 11:32:20 GMT');
var res = await req.close();
expect(String.fromCharCodes(await sinkStream(res)), 'CachedData');
expect(String.fromCharCodes(await consolidateHttpClientResponseBytes(res)), 'CachedData');

HttpCacheController cacheController = HttpCacheController.instance(req.headers.value('origin')!);
var cacheObject = await cacheController.getCacheObject(req.uri);
Expand All @@ -65,25 +65,39 @@ void main() {
req.headers.set(HttpHeaders.ifNoneMatchHeader, '"foo"');

var res = await req.close();
expect(String.fromCharCodes(await sinkStream(res)), 'CachedData');
expect(String.fromCharCodes(await consolidateHttpClientResponseBytes(res)), 'CachedData');

HttpCacheController cacheController = HttpCacheController.instance(req.headers.value('origin')!);
var cacheObject = await cacheController.getCacheObject(req.uri);
await cacheObject.read();

assert(cacheObject.valid);
});
});
}

Future<Uint8List> sinkStream(Stream<List<int>> stream) {
var completer = Completer<Uint8List>();
var buffer = BytesBuilder();
stream
.listen(buffer.add)
..onDone(() {
completer.complete(buffer.takeBytes());
})
..onError(completer.completeError);
return completer.future;
// Solve problem that consuming response multi times,
// causing cache file > 2 * chunk (each chunk default to 64kb) will be truncated.
test('File over 128K', () async {
Uri uri = server.getUri('js_over_128k');

// Local request to save cache.
var req = await httpClient.openUrl('GET', uri);
KrakenHttpOverrides.setContextHeader(req, contextId);
var res = await req.close();
Uint8List bytes = await consolidateHttpClientResponseBytes(res);
expect(bytes.lengthInBytes, res.contentLength);

// Assert cache object.
HttpCacheController cacheController = HttpCacheController.instance(req.headers.value('origin')!);
var cacheObject = await cacheController.getCacheObject(req.uri);
await cacheObject.read();
assert(cacheObject.valid);

var response = await cacheObject.toHttpClientResponse();
assert(response != null);
expect(response!.headers.value('cache-hits'), 'HIT');

Uint8List bytesFromCache = await consolidateHttpClientResponseBytes(response);
expect(bytesFromCache.length, response.contentLength);
});
});
}