add: adding cache package

This commit is contained in:
Patrick Stewart 2024-12-15 14:34:16 -07:00
parent c1372244c4
commit fd9e5c32c5
15 changed files with 865 additions and 0 deletions

63
packages/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,63 @@
# See https://www.dartlang.org/tools/private-files.html
# Files and directories created by pub
.packages
.pub/
build/
# If you're building an application, you may want to check-in your pubspec.lock
pubspec.lock
# Directory created by dartdoc
# If you don't generate documentation locally you can remove this line.
doc/api/
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
.dart_tool

12
packages/cache/AUTHORS.md vendored Normal file
View file

@ -0,0 +1,12 @@
Primary Authors
===============
* __[Thomas Hii](dukefirehawk.apps@gmail.com)__
Thomas is the current maintainer of the code base. He has refactored and migrated the
code base to support NNBD.
* __[Tobe O](thosakwe@gmail.com)__
Tobe has written much of the original code prior to NNBD migration. He has moved on and
is no longer involved with the project.

64
packages/cache/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,64 @@
# Change Log
## 8.2.0
* Require Dart >= 3.3
* Updated `lints` to 4.0.0
## 8.1.1
* Updated repository link
## 8.1.0
* Updated `lints` to 3.0.0
* Fixed linter warnings
## 8.0.0
* Require Dart >= 3.0
## 7.0.0
* Require Dart >= 2.17
## 6.0.0
* Require Dart >= 2.16
## 5.0.0
* Skipped release
## 4.0.3
* Updated linter to `package:lints`
## 4.0.2
* Updated README
* Added home page link
* All 7 unit tests passed
## 4.0.1
* Updated pubspec description
* Fixed: Return `200` with cached data instead of `403`
* Updated broken unit tests
## 4.0.0
* Migrated to support Dart >= 2.12 NNBD
## 3.0.0
* Migrated to work with Dart >= 2.12 Non NNBD
## 2.0.1
* Add `ignoreQueryAndFragment` to `ResponseCache`.
* Rename `CacheService.ignoreQuery` to `ignoreParams`.
## 1.0.0
* First version

29
packages/cache/LICENSE vendored Normal file
View file

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2021, dukefirehawk.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

91
packages/cache/README.md vendored Normal file
View file

@ -0,0 +1,91 @@
# Angel3 HTTP Cache
![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_cache?include_prereleases)
[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety)
[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM)
[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/cache/LICENSE)
A service that provides HTTP caching to the response data for [Angel3 framework](https://pub.dev/packages/angel3).
## `CacheService`
A `Service` class that caches data from one service, storing it in another. An imaginable use case is storing results from MongoDB or another database in Memcache/Redis.
## `cacheSerializationResults`
A middleware that enables the caching of response serialization.
This can improve the performance of sending objects that are complex to serialize. You can pass a [shouldCache] callback to determine which values should be cached.
```dart
void main() async {
var app = Angel()..lazyParseBodies = true;
app.use(
'/api/todos',
CacheService(
database: AnonymousService(
index: ([params]) {
print('Fetched directly from the underlying service at ${DateTime.now()}!');
return ['foo', 'bar', 'baz'];
},
read: (id, [params]) {
return {id: '$id at ${DateTime.now()}'};
}
),
),
);
}
```
## `ResponseCache`
A flexible response cache for Angel3.
Use this to improve real and perceived response of Web applications, as well as to memorize expensive responses.
Supports the `If-Modified-Since` header, as well as storing the contents of response buffers in memory.
To initialize a simple cache:
```dart
Future configureServer(Angel app) async {
// Simple instance.
var cache = ResponseCache();
// You can also pass an invalidation timeout.
var cache = ResponseCache(timeout: const Duration(days: 2));
// Close the cache when the application closes.
app.shutdownHooks.add((_) => cache.close());
// Use `patterns` to specify which resources should be cached.
cache.patterns.addAll([
'robots.txt',
RegExp(r'\.(png|jpg|gif|txt)$'),
Glob('public/**/*'),
]);
// REQUIRED: The middleware that serves cached responses
app.use(cache.handleRequest);
// REQUIRED: The response finalizer that saves responses to the cache
app.responseFinalizers.add(cache.responseFinalizer);
}
```
### Purging the Cache
Call `invalidate` to remove a resource from a `ResponseCache`.
Some servers expect a reverse proxy or caching layer to support `PURGE` requests. If this is your case, make sure to include some sort of validation (maybe IP-based) to ensure no arbitrary attacker can hack your cache:
```dart
Future configureServer(Angel app) async {
app.addRoute('PURGE', '*', (req, res) {
if (req.ip != '127.0.0.1')
throw AngelHttpException.forbidden();
return cache.purge(req.uri.path);
});
}
```

1
packages/cache/analysis_options.yaml vendored Normal file
View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,24 @@
import 'package:platform_cache/platform_cache.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
void main() async {
var app = Application();
app.use(
'/api/todos',
CacheService(
cache: MapService(),
database: AnonymousService(index: ([params]) {
print(
'Fetched directly from the underlying service at ${DateTime.now()}!');
return ['foo', 'bar', 'baz'];
}, read: (dynamic id, [params]) {
return {id: '$id at ${DateTime.now()}'};
})),
);
var http = PlatformHttp(app);
var server = await http.startServer('127.0.0.1', 3000);
print('Listening at http://${server.address.address}:${server.port}');
}

37
packages/cache/example/main.dart vendored Normal file
View file

@ -0,0 +1,37 @@
import 'package:platform_cache/platform_cache.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
void main() async {
var app = Application();
// Cache a glob
var cache = ResponseCache()
..patterns.addAll([
RegExp('^/?\\w+\\.txt'),
]);
// Handle `if-modified-since` header, and also send cached content
app.fallback(cache.handleRequest);
// A simple handler that returns a different result every time.
app.get(
'/date.txt', (req, res) => res.write(DateTime.now().toIso8601String()));
// Support purging the cache.
app.addRoute('PURGE', '*', (req, res) {
if (req.ip != '127.0.0.1') {
throw PlatformHttpException.forbidden();
}
cache.purge(req.uri!.path);
print('Purged ${req.uri!.path}');
});
// The response finalizer that actually saves the content
app.responseFinalizers.add(cache.responseFinalizer);
var http = PlatformHttp(app);
var server = await http.startServer('127.0.0.1', 3000);
print('Listening at http://${server.address.address}:${server.port}');
}

View file

@ -0,0 +1,3 @@
export 'src/cache.dart';
export 'src/cache_service.dart';
export 'src/serializer.dart';

203
packages/cache/lib/src/cache.dart vendored Normal file
View file

@ -0,0 +1,203 @@
import 'dart:async';
import 'dart:io' show HttpDate;
import 'package:platform_foundation/core.dart';
import 'package:pool/pool.dart';
import 'package:logging/logging.dart';
/// A flexible response cache for Angel.
///
/// Use this to improve real and perceived response of Web applications,
/// as well as to memorize expensive responses.
class ResponseCache {
/// A set of [Patterns] for which responses will be cached.
///
/// For example, you can pass a `Glob` matching `**/*.png` files to catch all PNG images.
final List<Pattern> patterns = [];
/// An optional timeout, after which a given response will be removed from the cache, and the contents refreshed.
final Duration timeout;
final Map<String, _CachedResponse> _cache = {};
final Map<String, Pool> _writeLocks = {};
/// If `true` (default: `false`), then caching of results will discard URI query parameters and fragments.
final bool ignoreQueryAndFragment;
final log = Logger('ResponseCache');
ResponseCache(
{this.timeout = const Duration(minutes: 10),
this.ignoreQueryAndFragment = false});
/// Closes all internal write-locks, and closes the cache.
Future close() async {
_writeLocks.forEach((_, p) => p.close());
}
/// Removes an entry from the response cache.
void purge(String path) => _cache.remove(path);
/// A middleware that handles requests with an `If-Modified-Since` header.
///
/// This prevents the server from even having to access the cache, and plays very well with static assets.
Future<bool> ifModifiedSince(RequestContext req, ResponseContext res) async {
if (req.method != 'GET' && req.method != 'HEAD') {
return true;
}
var modifiedSince = req.headers?.ifModifiedSince;
if (modifiedSince != null) {
// Check if there is a cache entry.
for (var pattern in patterns) {
var reqPath = _getEffectivePath(req);
if (pattern.allMatches(reqPath).isNotEmpty &&
_cache.containsKey(reqPath)) {
var response = _cache[reqPath];
//log.info('timestamp ${response?.timestamp} vs since $modifiedSince');
if (response != null &&
response.timestamp.compareTo(modifiedSince) <= 0) {
// If the cache timeout has been met, don't send the cached response.
var timeDiff =
DateTime.now().toUtc().difference(response.timestamp);
//log.info(
// 'Time Diff: ${timeDiff.inMilliseconds} >= ${timeout.inMilliseconds}');
if (timeDiff.inMilliseconds >= timeout.inMilliseconds) {
return true;
}
// Old code: res.statusCode = 304;
// Return the response stored in the cache
_setCachedHeaders(response.timestamp, req, res);
res
..headers.addAll(response.headers)
..add(response.body);
await res.close();
return false;
}
}
}
}
return true;
}
String _getEffectivePath(RequestContext req) {
if (req.uri == null) {
log.severe('Request URI is null');
throw ArgumentError('Request URI is null');
}
return ignoreQueryAndFragment == true ? req.uri!.path : req.uri.toString();
}
/// Serves content from the cache, if applicable.
Future<bool> handleRequest(RequestContext req, ResponseContext res) async {
if (!await ifModifiedSince(req, res)) return false;
if (req.method != 'GET' && req.method != 'HEAD') return true;
if (!res.isOpen) return true;
// Check if there is a cache entry.
//
// If `if-modified-since` is present, this check has already been performed.
if (req.headers?.ifModifiedSince == null) {
for (var pattern in patterns) {
if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty) {
var now = DateTime.now().toUtc();
if (_cache.containsKey(_getEffectivePath(req))) {
var response = _cache[_getEffectivePath(req)];
if (response == null ||
now.difference(response.timestamp) >= timeout) {
return true;
}
_setCachedHeaders(response.timestamp, req, res);
res
..headers.addAll(response.headers)
..add(response.body);
await res.close();
return false;
} else {
_setCachedHeaders(now, req, res);
}
}
}
}
return true;
}
/// A response finalizer that saves responses to the cache.
Future<bool> responseFinalizer(
RequestContext req, ResponseContext res) async {
if (res.statusCode == 304) {
return true;
}
if (req.method != 'GET' && req.method != 'HEAD') {
return true;
}
// Check if there is a cache entry.
for (var pattern in patterns) {
var reqPath = _getEffectivePath(req);
if (pattern.allMatches(reqPath).isNotEmpty) {
var now = DateTime.now().toUtc();
// Invalidate the response, if need be.
if (_cache.containsKey(reqPath)) {
// If there is no timeout, don't invalidate.
//if (timeout == null) return true;
// Otherwise, don't invalidate unless the timeout has been exceeded.
var response = _cache[reqPath];
if (response == null ||
now.difference(response.timestamp) < timeout) {
return true;
}
// If the cache entry should be invalidated, then invalidate it.
purge(reqPath);
}
// Save the response.
var writeLock = _writeLocks.putIfAbsent(reqPath, () => Pool(1));
await writeLock.withResource(() {
if (res.buffer != null) {
_cache[reqPath] = _CachedResponse(
Map.from(res.headers), res.buffer!.toBytes(), now);
}
});
_setCachedHeaders(now, req, res);
}
}
return true;
}
void _setCachedHeaders(
DateTime modified, RequestContext req, ResponseContext res) {
var privacy = 'public';
res.headers
..['cache-control'] = '$privacy, max-age=${timeout.inSeconds}'
..['last-modified'] = HttpDate.format(modified);
var expiry = DateTime.now().add(timeout);
res.headers['expires'] = HttpDate.format(expiry);
}
}
class _CachedResponse {
final Map<String, String> headers;
final List<int> body;
final DateTime timestamp;
_CachedResponse(this.headers, this.body, this.timestamp);
}

View file

@ -0,0 +1,133 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:platform_foundation/core.dart';
/// An Angel [Service] that caches data from another service.
///
/// This is useful for applications of scale, where network latency
/// can have real implications on application performance.
class CacheService<Id, Data> extends Service<Id, Data> {
/// The underlying [Service] that represents the original data store.
final Service<Id, Data> database;
/// The [Service] used to interface with a caching layer.
///
/// If not provided, this defaults to a [MapService].
final Service<Id, Data> cache;
/// If `true` (default: `false`), then result caching will discard parameters passed to service methods.
///
/// If you want to return a cached result more-often-than-not, you may want to enable this.
final bool ignoreParams;
final Duration timeout;
final Map<Id, _CachedItem<Data>> _cache = {};
_CachedItem<List<Data>>? _indexed;
CacheService(
{required this.database,
required this.cache,
this.timeout = const Duration(minutes: 10),
this.ignoreParams = false});
Future<T> _getCached<T>(
Map<String, dynamic> params,
_CachedItem? Function() get,
FutureOr<T> Function() getFresh,
FutureOr<T> Function() getCached,
FutureOr<T> Function(T data, DateTime now) save) async {
var cached = get();
var now = DateTime.now().toUtc();
if (cached != null) {
// If the entry has expired, don't send from the cache
var expired = now.difference(cached.timestamp) >= timeout;
if (!expired) {
// Read from the cache if necessary
var queryEqual = ignoreParams == true ||
(cached.params != null &&
const MapEquality().equals(
params['query'] as Map, cached.params['query'] as Map));
if (queryEqual) {
return await getCached();
}
}
}
// If we haven't fetched from the cache by this point,
// let's fetch from the database.
var data = await getFresh();
await save(data, now);
return data;
}
@override
Future<List<Data>> index([Map<String, dynamic>? params]) {
return _getCached(
params ?? {},
() => _indexed,
() => database.index(params),
() => _indexed?.data ?? [],
(data, now) async {
_indexed = _CachedItem(params, now, data);
return data;
},
);
}
@override
Future<Data> read(Id id, [Map<String, dynamic>? params]) async {
return _getCached<Data>(
params ?? {},
() => _cache[id],
() => database.read(id, params),
() => cache.read(id),
(data, now) async {
_cache[id] = _CachedItem(params, now, data);
return await cache.modify(id, data);
},
);
}
@override
Future<Data> create(data, [Map<String, dynamic>? params]) {
_indexed = null;
return database.create(data, params);
}
@override
Future<Data> modify(Id id, Data data, [Map<String, dynamic>? params]) {
_indexed = null;
_cache.remove(id);
return database.modify(id, data, params);
}
@override
Future<Data> update(Id id, Data data, [Map<String, dynamic>? params]) {
_indexed = null;
_cache.remove(id);
return database.modify(id, data, params);
}
@override
Future<Data> remove(Id id, [Map<String, dynamic>? params]) {
_indexed = null;
_cache.remove(id);
return database.remove(id, params);
}
}
class _CachedItem<Data> {
final dynamic params;
final DateTime timestamp;
final Data? data;
_CachedItem(this.params, this.timestamp, [this.data]);
@override
String toString() {
return '$timestamp:$params:$data';
}
}

29
packages/cache/lib/src/serializer.dart vendored Normal file
View file

@ -0,0 +1,29 @@
import 'dart:async';
import 'package:platform_foundation/core.dart';
/// A middleware that enables the caching of response serialization.
///
/// This can improve the performance of sending objects that are complex to serialize.
///
/// You can pass a [shouldCache] callback to determine which values should be cached.
RequestHandler cacheSerializationResults(
{Duration? timeout,
FutureOr<bool> Function(RequestContext, ResponseContext, Object)?
shouldCache}) {
return (RequestContext req, ResponseContext res) async {
var oldSerializer = res.serializer;
// TODO: Commented out as it is not doing anything useful
var cache = <dynamic, String>{};
res.serializer = (value) {
if (shouldCache == null) {
return cache.putIfAbsent(value, () => oldSerializer(value) as String);
}
return oldSerializer(value);
};
return true;
};
}

42
packages/cache/pubspec.yaml vendored Normal file
View file

@ -0,0 +1,42 @@
name: platform_cache
version: 8.2.0
description: A service that provides HTTP caching to the response data for Angel3
homepage: https://angel3-framework.web.app/
repository: https://github.com/dart-backend/angel/tree/master/packages/cache
environment:
sdk: '>=3.4.0 <4.0.0'
dependencies:
platform_foundation: ^8.0.0
collection: ^1.17.0
meta: ^1.9.0
pool: ^1.5.0
logging: ^1.2.0
dev_dependencies:
platform_testing: ^8.0.0
glob: ^2.0.1
http: ^1.0.0
test: ^1.24.0
lints: ^4.0.0
# dependency_overrides:
# angel3_container:
# path: ../container/angel_container
# angel3_framework:
# path: ../framework
# angel3_http_exception:
# path: ../http_exception
# angel3_model:
# path: ../model
# angel3_route:
# path: ../route
# angel3_mock_request:
# path: ../mock_request
# angel3_test:
# path: ../test
# platform_websocket:
# path: ../websocket
# platform_client:
# path: ../client
# angel3_auth:
# path: ../auth
# platform_validation:
# path: ../validate

134
packages/cache/test/cache_test.dart vendored Normal file
View file

@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:io';
import 'package:platform_cache/platform_cache.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_testing/testing.dart';
import 'package:http/http.dart' as http;
//import 'package:glob/glob.dart';
import 'package:test/test.dart';
import 'package:logging/logging.dart';
Future<void> main() async {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
print(
'${record.time}: ${record.level.name}: ${record.loggerName}: ${record.message}');
});
group('no timeout', () {
late TestClient client;
DateTime? lastModified;
late http.Response response1, response2;
setUp(() async {
var app = Application();
var cache = ResponseCache()
..patterns.addAll([
//Glob('/*.txt'), // Requires to create folders and files for testing
RegExp('^/?\\w+\\.txt'),
]);
app.fallback(cache.handleRequest);
app.get('/date.txt', (req, res) {
var data = DateTime.now().toIso8601String();
print('Res data: $data');
res
..useBuffer()
..write(data);
print('Generate results...');
});
app.addRoute('PURGE', '*', (req, res) {
if (req.uri != null) {
cache.purge(req.uri!.path);
print('Purged ${req.uri!.path}');
} else {
print('req.uri is null');
}
});
app.responseFinalizers.add(cache.responseFinalizer);
var oldHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
if (e.error == null) {
oldHandler(e, req, res);
}
return Zone.current
.handleUncaughtError(e.error as Object, e.stackTrace!);
};
client = await connectTo(app);
response1 = await client.get(Uri.parse('/date.txt'));
print('Response 1 status: ${response1.statusCode}');
print('Response 1 headers: ${response1.headers}');
print('Response 1 body: ${response1.body}');
response2 = await client.get(Uri.parse('/date.txt'));
print('Response 2 status: ${response2.statusCode}');
print('Response 2 headers: ${response2.headers}');
print('Response 2 body: ${response2.body}');
if (response2.headers['last-modified'] == null) {
print('last-modified is null');
} else {
lastModified = HttpDate.parse(response2.headers['last-modified']!);
}
});
tearDown(() => client.close());
test('saves content', () async {
expect(response2.body, response1.body);
});
test('saves headers', () async {
response1.headers.forEach((k, v) {
expect(response2.headers, containsPair(k, v));
});
});
test('first response is normal', () {
expect(response1.statusCode, 200);
});
test('sends last-modified', () {
expect(response2.headers.keys, contains('last-modified'));
});
test('invalidate', () async {
await client.sendUnstreamed('PURGE', '/date.txt', {});
var response = await client.get(Uri.parse('/date.txt'));
print('Response after invalidation: ${response.body}');
expect(response.body, isNot(response1.body));
});
test('sends 304 on if-modified-since', () async {
lastModified ??= DateTime.now();
var headers = {
'if-modified-since':
HttpDate.format(lastModified!.add(const Duration(days: 1)))
};
var response = await client.get(Uri.parse('/date.txt'), headers: headers);
print('Sending headers: $headers');
print('Response status: ${response.statusCode})');
print('Response headers: ${response.headers}');
print('Response body: ${response.body}');
//expect(response.statusCode, 304);
expect(response.statusCode, 200);
});
test('last-modified in the past', () async {
lastModified ??= DateTime.now();
var response = await client.get(Uri.parse('/date.txt'), headers: {
'if-modified-since':
HttpDate.format(lastModified!.subtract(const Duration(days: 10)))
});
print('Response: ${response.body}');
expect(response.statusCode, 200);
expect(response.body, isNot(response1.body));
});
});
group('with timeout', () {});
}

0
packages/cache/test/files/date.txt vendored Normal file
View file