add http_get_cache

This commit is contained in:
2026-01-20 23:18:39 -08:00
parent 572c0789b7
commit 811e884481
168 changed files with 37335 additions and 0 deletions
@@ -0,0 +1,221 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart';
import 'package:path/path.dart' as p;
import '../constants.dart';
import '../database/database.dart';
import '../date_parser.dart';
import '../request_headers.dart';
import 'base.dart';
class SqliteHttpCacheStore extends HttpCacheStore {
final HttpGetCacheDatabase database;
SqliteHttpCacheStore(this.database);
static Future<String> getCacheDir(HttpGetCacheDatabase database) async {
if (kIsWeb) return '';
final cachePath = database.settings.cachePath;
final cacheDir = Directory(cachePath);
if (!cacheDir.existsSync()) {
await cacheDir.create(recursive: true);
}
return cacheDir.path;
}
static Future<void> deleteCacheDir(HttpGetCacheDatabase database) async {
if (kIsWeb) return;
final cachePath = await getCacheDir(database);
if (cachePath.isEmpty) return;
final cacheDir = Directory(cachePath);
if (cacheDir.existsSync()) {
cacheDir.deleteSync(recursive: true);
}
}
static Future<int> getCacheDirSize(HttpGetCacheDatabase database) async {
if (kIsWeb) return 0;
final cachePath = await getCacheDir(database);
if (cachePath.isEmpty) return 0;
final cacheDir = Directory(cachePath);
if (cacheDir.existsSync()) {
final files = await cacheDir.list(recursive: true).toList();
final dirSize =
files.fold(0, (int sum, file) => sum + file.statSync().size);
return dirSize;
}
return 0;
}
static Future<void> deleteExpired(HttpGetCacheDatabase database) async {
await database.deleteHttpCacheStale();
}
Stream<List<int>> _streamAndSaveBytes(
BaseRequest req,
StreamedResponse res,
File file,
) async* {
final date = res.requestHeaders.created ?? getDateInt();
if (!file.existsSync()) {
await file.create(recursive: true);
}
final writer = file.openWrite();
await for (final event in res.stream) {
yield event;
writer.add(event);
}
await writer.flush();
await writer.close();
final vary = res.headers['vary'] ?? '';
final requestHeaders = <String, String>{};
if (vary.isNotEmpty && vary != '*') {
final keys =
vary.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty);
for (final key in keys) {
requestHeaders[key] = req.headers[key] ?? '';
}
}
await database.setHttpCache(
url: req.url.key,
headers: jsonEncode(res.headers),
requestHeaders: jsonEncode(requestHeaders),
body: file.path,
maxAge: res.cacheControl.maxAge,
staleWhileRevalidate: res.cacheControl.staleWhileRevalidate.$1,
staleIfError: res.cacheControl.staleIfError.$1,
immutable: res.cacheControl.immutable,
date: date,
);
}
@override
Future<StreamedResponse> set(
BaseRequest request,
StreamedResponse response,
) async {
if (kIsWeb) return response;
// Don't cache if Vary is *
if (response.headers['vary'] == '*') {
return response;
}
if (response.cacheControl.noStore || request.cacheControl.noStore) {
return response;
}
final dirPath = await getCacheDir(database);
if (dirPath.isEmpty) return response;
final dir = Directory(dirPath);
// Use a unique file name to allow multiple responses per URL.
// We can't rely just on URL key anymore.
// We'll append a random string or use UUID if we had it, but here just use random hash or
// simply use the existing method but append request headers hash.
// To safe space and keep it simple, let's use a combination.
// However, if we change the file naming, we orphan old files or we need to manage them.
// Since we delete by DB entry (which stores path), it's fine.
// Just need a unique name.
// Simple unique name strategy: hash(url + vary request headers)
// But we don't know vary headers yet... wait, we do get them from response.
// We computed `requestHeaders` in _streamAndSaveBytes.
// Ideally we compute path there too? But we pass file into it.
// Let's modify behavior: Generate a random ID for the file?
// Or just make `key` unique-ish.
// sha256(url + timestamp + random) is safe enough.
final uniqueId = sha256
.convert(utf8.encode(
'${request.url.key}${DateTime.now().microsecondsSinceEpoch}'))
.toString();
final file = File(p.join(dir.path, uniqueId));
return StreamedResponse(
_streamAndSaveBytes(request, response, file),
response.statusCode,
contentLength: response.contentLength,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
}
@override
Future<StreamedResponse?> get(BaseRequest request) async {
final candidates = await database //
.getHttpCache(url: request.url.key)
.get();
for (final candidate in candidates) {
final responseHeaders = _parseHeaders(candidate.headers);
final vary = responseHeaders['vary'];
if (vary == '*') {
continue; // Should not happen if we don't store it, but check anyway.
}
bool match = true;
if (vary != null && vary.isNotEmpty) {
final storedRequestHeaders = _parseHeaders(candidate.requestHeaders);
final varyKeys =
vary.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty);
for (final key in varyKeys) {
if (request.headers[key] != storedRequestHeaders[key]) {
match = false;
break;
}
}
}
if (match) {
final path = candidate.body;
final file = File(path);
if (await file.exists()) {
final stats = await file.stat();
final h = {...responseHeaders};
final rq = RequestHeaders(h);
if (rq.created == null) {
final d = DateTime.fromMillisecondsSinceEpoch(candidate.date);
h['Date'] = d.toIso8601String();
}
return StreamedResponse(
file.openRead(),
200,
contentLength: stats.size,
headers: h,
);
}
}
}
return null;
}
}
// extension on BaseRequest {
// String get key => sha256.convert(utf8.encode(url.key)).toString();
// }
extension on Uri {
String get key => toString();
}
Map<String, String> _parseHeaders(String key) {
final headers = <String, String>{};
if (key.isNotEmpty) {
final data = jsonDecode(key) as Map<String, Object?>;
for (final entry in data.entries) {
headers[entry.key] = entry.value?.toString() ?? '';
}
}
return headers;
}