diff --git a/example/static_files/hello.txt b/example/static_files/hello.txt
new file mode 100644
index 00000000..4e745b20
--- /dev/null
+++ b/example/static_files/hello.txt
@@ -0,0 +1 @@
+Hello! :)
\ No newline at end of file
diff --git a/example/static_files/logo.svg b/example/static_files/logo.svg
new file mode 100644
index 00000000..55e963de
--- /dev/null
+++ b/example/static_files/logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/example/static_files_example.dart b/example/static_files_example.dart
new file mode 100644
index 00000000..d6458c75
--- /dev/null
+++ b/example/static_files_example.dart
@@ -0,0 +1,62 @@
+// ignore_for_file: avoid_print
+
+import 'dart:io';
+
+import 'package:relic/io_adapter.dart';
+import 'package:relic/relic.dart';
+
+/// A minimal server that serves static files with cache busting.
+///
+/// - Serves files under the URL prefix "/static" from `example/static_files`.
+/// - Try: http://localhost:8080/
+Future main() async {
+ final staticDir = Directory('static_files');
+ if (!staticDir.existsSync()) {
+ print('Please run this example from example directory (cd example).');
+ return;
+ }
+ final buster = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticDir,
+ );
+
+ // Setup router and a small index page showing cache-busted URLs. We're
+ // setting the cache control header to immutable for a year.
+ final router = Router()
+ ..get('/', respondWith((final _) async {
+ final helloUrl = await buster.assetPath('/static/hello.txt');
+ final logoUrl = await buster.assetPath('/static/logo.svg');
+ final html = ''
+ 'Static files with cache busting
'
+ ''
+ '';
+ return Response.ok(body: Body.fromString(html, mimeType: MimeType.html));
+ }))
+ ..anyOf(
+ {
+ Method.get,
+ Method.head,
+ },
+ '/static/**',
+ createStaticHandler(
+ staticDir.path,
+ cacheControl: (final _, final __) => CacheControlHeader(
+ maxAge: 31536000,
+ publicCache: true,
+ immutable: true,
+ ),
+ cacheBustingConfig: buster,
+ ));
+
+ // Setup a handler pipeline with logging, cache busting, and routing.
+ final handler = const Pipeline()
+ .addMiddleware(logRequests())
+ .addMiddleware(routeWith(router))
+ .addHandler(respondWith((final _) => Response.notFound()));
+
+ // Start the server
+ await serve(handler, InternetAddress.loopbackIPv4, 8080);
+}
diff --git a/lib/relic.dart b/lib/relic.dart
index b73d5376..f513056c 100644
--- a/lib/relic.dart
+++ b/lib/relic.dart
@@ -15,6 +15,7 @@ export 'src/headers/header_accessor.dart';
export 'src/headers/headers.dart';
export 'src/headers/standard_headers_extensions.dart';
export 'src/headers/typed/typed_headers.dart';
+export 'src/io/static/cache_busting_config.dart' show CacheBustingConfig;
export 'src/io/static/static_handler.dart';
export 'src/message/request.dart' show Request;
export 'src/message/response.dart' show Response;
diff --git a/lib/src/io/static/cache_busting_config.dart b/lib/src/io/static/cache_busting_config.dart
new file mode 100644
index 00000000..02bb9f5c
--- /dev/null
+++ b/lib/src/io/static/cache_busting_config.dart
@@ -0,0 +1,164 @@
+import 'dart:io';
+import 'package:path/path.dart' as p;
+
+import '../../../relic.dart';
+
+/// Cache-busting for asset URLs that embed a content hash.
+///
+/// Typical flow:
+/// - Outgoing URLs: call [CacheBustingConfig.assetPath] (or
+/// [CacheBustingConfig.tryAssetPath]) with a known mount prefix (e.g. "/static")
+/// to get "/static/name@hash.ext".
+/// - Incoming requests: add this [CacheBustingConfig] to the static file handler
+/// (see [createStaticHandler]). The handler will strip the hash so that static
+/// asset requests can be served without the hash.
+///
+/// Once added to the `createStaticHandler`, the handler will strip the hash
+/// from the last path segment before looking up the file. If no hash is found,
+/// the path is used as-is.
+///
+// Example:
+/// "/static/images/logo@abc123.png" → "/static/images/logo.png".
+/// "/static/images/logo.png" → "/static/images/logo.png".
+class CacheBustingConfig {
+ /// The URL prefix under which static assets are served (e.g., "/static").
+ final String mountPrefix;
+
+ /// Filesystem root corresponding to [mountPrefix].
+ final Directory fileSystemRoot;
+
+ /// Separator between base filename and hash (e.g., "@").
+ final String separator;
+
+ CacheBustingConfig({
+ required final String mountPrefix,
+ required final Directory fileSystemRoot,
+ this.separator = '@',
+ }) : mountPrefix = _normalizeMount(mountPrefix),
+ fileSystemRoot = fileSystemRoot.absolute {
+ _validateFileSystemRoot(fileSystemRoot.absolute);
+ _validateSeparator(separator);
+ }
+
+ /// Returns the cache-busted URL for the given [staticPath].
+ ///
+ /// Example: '/static/logo.svg' → '/static/logo@hash.svg'.
+ Future assetPath(final String staticPath) async {
+ if (!staticPath.startsWith(mountPrefix)) return staticPath;
+
+ final relative = staticPath.substring(mountPrefix.length);
+ final resolvedRootPath = fileSystemRoot.resolveSymbolicLinksSync();
+ final joinedPath = p.join(resolvedRootPath, relative);
+ final normalizedPath = p.normalize(joinedPath);
+
+ // Reject traversal before hitting the filesystem
+ if (!p.isWithin(resolvedRootPath, normalizedPath) &&
+ normalizedPath != resolvedRootPath) {
+ throw ArgumentError.value(
+ staticPath,
+ 'staticPath',
+ 'must stay within $mountPrefix',
+ );
+ }
+
+ // Ensure target exists (files only) before resolving symlinks
+ final entityType =
+ FileSystemEntity.typeSync(normalizedPath, followLinks: false);
+ if (entityType == FileSystemEntityType.notFound ||
+ entityType == FileSystemEntityType.directory) {
+ throw PathNotFoundException(
+ normalizedPath,
+ const OSError('No such file or directory', 2),
+ );
+ }
+
+ final resolvedFilePath = File(normalizedPath).resolveSymbolicLinksSync();
+ if (!p.isWithin(resolvedRootPath, resolvedFilePath)) {
+ throw ArgumentError.value(
+ staticPath,
+ 'staticPath',
+ 'must stay within $mountPrefix',
+ );
+ }
+
+ final info = await getStaticFileInfo(File(resolvedFilePath));
+
+ // Build the busted URL using URL path helpers for readability/portability
+ final directory = p.url.dirname(staticPath);
+ final baseName = p.url.basenameWithoutExtension(staticPath);
+ final ext = p.url.extension(staticPath); // includes leading dot or ''
+
+ final bustedName = '$baseName$separator${info.etag}$ext';
+ return directory == '.'
+ ? '/$bustedName'
+ : p.url.join(directory, bustedName);
+ }
+
+ /// Attempts to generate a cache-busted URL. If the file cannot be found or
+ /// read, returns [staticPath] unchanged.
+ Future tryAssetPath(final String staticPath) async {
+ try {
+ return await assetPath(staticPath);
+ } catch (_) {
+ return staticPath;
+ }
+ }
+}
+
+/// Ensures [mountPrefix] starts with '/' and ends with '/'.
+String _normalizeMount(final String mountPrefix) {
+ if (!mountPrefix.startsWith('/')) {
+ throw ArgumentError('mountPrefix must start with "/"');
+ }
+ return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/';
+}
+
+void _validateFileSystemRoot(final Directory dir) {
+ if (!dir.existsSync()) {
+ throw ArgumentError.value(dir.path, 'fileSystemRoot', 'does not exist');
+ }
+
+ final resolved = dir.absolute.resolveSymbolicLinksSync();
+ final entityType = FileSystemEntity.typeSync(resolved);
+ if (entityType != FileSystemEntityType.directory) {
+ throw ArgumentError.value(
+ dir.path,
+ 'fileSystemRoot',
+ 'is not a directory',
+ );
+ }
+}
+
+void _validateSeparator(final String separator) {
+ if (separator.isEmpty) {
+ throw ArgumentError('separator cannot be empty');
+ }
+
+ if (separator.contains('/')) {
+ throw ArgumentError('separator cannot contain "/"');
+ }
+}
+
+extension CacheBustingFilenameExtension on CacheBustingConfig {
+ /// Removes a trailing "``hash" segment from a [fileName], preserving any
+ /// extension. Matches both "`namehash`.ext" and "`namehash`".
+ ///
+ /// If no hash is found, returns [fileName] unchanged.
+ ///
+ /// Examples:
+ /// `logo@abc.png` -> `logo.png`
+ /// `logo@abc` -> `logo`
+ /// `logo.png` -> `logo.png` (no change)
+ String tryStripHashFromFilename(
+ final String fileName,
+ ) {
+ final ext = p.url.extension(fileName);
+ final base = p.url.basenameWithoutExtension(fileName);
+
+ final at = base.lastIndexOf(separator);
+ if (at <= 0) return fileName; // no hash or starts with separator
+
+ final cleanBase = base.substring(0, at);
+ return p.url.setExtension(cleanBase, ext);
+ }
+}
diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart
index 90d8dea0..e1fbc21c 100644
--- a/lib/src/io/static/static_handler.dart
+++ b/lib/src/io/static/static_handler.dart
@@ -11,6 +11,7 @@ import 'package:path/path.dart' as p;
import '../../../relic.dart';
import '../../router/lru_cache.dart';
+import 'cache_busting_config.dart';
/// The default resolver for MIME types based on file extensions.
final _defaultMimeTypeResolver = MimeTypeResolver();
@@ -39,6 +40,16 @@ typedef CacheControlFactory = CacheControlHeader? Function(
/// LRU cache for file information to avoid repeated file system operations.
final _fileInfoCache = LruCache(10000);
+/// Public accessor for retrieving [FileInfo] for a given [file].
+///
+/// Uses the same logic as the internal cache/population used by the static
+/// file handler and respects MIME type detection.
+Future getStaticFileInfo(
+ final File file, {
+ final MimeTypeResolver? mimeResolver,
+}) async =>
+ _getFileInfo(file, mimeResolver ?? _defaultMimeTypeResolver);
+
/// Creates a Relic [Handler] that serves files from the provided [fileSystemPath].
///
/// When a file is requested, it is served with appropriate headers including
@@ -57,11 +68,16 @@ final _fileInfoCache = LruCache(10000);
///
/// The [mimeResolver] can be provided to customize MIME type detection.
/// The [cacheControl] header can be customized using [cacheControl] callback.
+///
+/// If [cacheBustingConfig] is provided, the handler will strip cache-busting
+/// hashes from the last path segment before looking up any file.
+/// See [CacheBustingConfig] for details.
Handler createStaticHandler(
final String fileSystemPath, {
final Handler? defaultHandler,
final MimeTypeResolver? mimeResolver,
required final CacheControlFactory cacheControl,
+ final CacheBustingConfig? cacheBustingConfig,
}) {
final rootDir = Directory(fileSystemPath);
if (!rootDir.existsSync()) {
@@ -73,9 +89,30 @@ Handler createStaticHandler(
final fallbackHandler =
defaultHandler ?? respondWith((final _) => Response.notFound());
+ final resolveFilePath = switch (cacheBustingConfig) {
+ null =>
+ (final String resolvedRootPath, final List requestSegments) =>
+ p.joinAll([resolvedRootPath, ...requestSegments]),
+ final cfg =>
+ (final String resolvedRootPath, final List requestSegments) {
+ if (requestSegments.isEmpty) {
+ return resolvedRootPath;
+ }
+
+ final fileName = cfg.tryStripHashFromFilename(
+ requestSegments.last,
+ );
+ return p.joinAll([
+ resolvedRootPath,
+ ...requestSegments.sublist(0, requestSegments.length - 1),
+ fileName,
+ ]);
+ }
+ };
+
return (final NewContext ctx) async {
final filePath =
- p.joinAll([resolvedRootPath, ...ctx.remainingPath.segments]);
+ resolveFilePath(resolvedRootPath, ctx.remainingPath.segments);
// Ensure file exists and is not a directory
final entityType = FileSystemEntity.typeSync(filePath, followLinks: false);
diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart
new file mode 100644
index 00000000..cb6ba193
--- /dev/null
+++ b/test/static/cache_busting_config_test.dart
@@ -0,0 +1,441 @@
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:relic/src/io/static/cache_busting_config.dart';
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+void main() {
+ test(
+ 'Given mountPrefix not starting with "/" when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: 'static',
+ fileSystemRoot: staticRoot,
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given empty separator when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given "/" separator when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '/',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given separator starting with "/" when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '/@',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given separator ending with "/" when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@/',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given separator containing "/" when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@/@',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given no directory at staticRoot when creating CacheBustingConfig then it throws ArgumentError',
+ () {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'Given file at staticRoot when creating CacheBustingConfig then it throws ArgumentError',
+ () async {
+ await d.file('static', 'content').create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ expect(
+ () => CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ group('Given CacheBustingConfig configured for a directory without files',
+ () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', []).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ );
+ });
+
+ test(
+ 'when assetPath is called for a missing file then it throws PathNotFoundException',
+ () async {
+ expect(
+ cfg.assetPath('/static/does-not-exist.txt'),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'when tryAssetPath is called for a missing file then it returns the original path',
+ () async {
+ const original = '/static/does-not-exist.txt';
+ final result = await cfg.tryAssetPath(original);
+ expect(result, original);
+ });
+ });
+
+ group(
+ 'Given CacheBustingConfig without explicit separator configured for a directory with files',
+ () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ );
+ });
+
+ test(
+ 'when assetPath is called for existing file then default cache busting separator is "@"',
+ () async {
+ const original = '/static/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, contains('@'));
+ });
+ });
+
+ group('Given CacheBustingConfig configured for a directory with files', () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+ });
+
+ test(
+ 'when assetPath is called for an existing file then it returns a cache busted path',
+ () async {
+ const original = '/static/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/static/logo@'));
+ });
+
+ test(
+ 'when tryAssetPath is called for an existing file then it returns a cache busted path',
+ () async {
+ const original = '/static/logo.png';
+ final busted = await cfg.tryAssetPath(original);
+ expect(busted, startsWith('/static/logo@'));
+ });
+
+ test(
+ 'when assetPath is called with an absolute path segment after mount then argument error is thrown',
+ () async {
+ expect(
+ cfg.assetPath('/static//logo.png'),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'when tryAssetPath is called with an absolute path segment after mount then it returns the original path',
+ () async {
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ );
+
+ const original = '/static//logo.png';
+ expect(await cfg.tryAssetPath(original), original);
+ });
+ });
+
+ group('Given files outside of CacheBustingConfig fileSystemRoot', () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ await d.file('secret.txt', 'top-secret').create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ );
+ });
+
+ test(
+ 'when assetPath is called for a path that traverses outside of the mount prefix then it throws ArgumentError',
+ () async {
+ expect(
+ cfg.assetPath('/static/../secret.txt'),
+ throwsA(isA()),
+ );
+ });
+
+ test(
+ 'when assetPath is called for a path outside of the mount prefix then it returns it unchanged',
+ () async {
+ const outside = '/secret.txt';
+ expect(await cfg.assetPath(outside), outside);
+ });
+
+ test(
+ 'when tryAssetPath is called for a path outside of mount prefix then returns it unchanged',
+ () async {
+ const outside = '/secret.txt';
+ expect(await cfg.tryAssetPath(outside), outside);
+ });
+ });
+
+ test(
+ 'Given file without extension in CacheBustingConfig directory when calling assetPath then it returns a cache busting path',
+ () async {
+ await d.dir('static', [d.file('logo', 'content-noext')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+
+ const original = '/static/logo';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/static/logo@'));
+ });
+
+ group('Given a CacheBustingConfig with custom separator', () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '--',
+ );
+ });
+
+ test('when assetPath is called then it uses that separator', () async {
+ const original = '/static/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/static/logo--'));
+ });
+
+ test(
+ 'when tryStripHashFromFilename is called with busted path then hash is stripped from filename',
+ () async {
+ expect(cfg.tryStripHashFromFilename('logo--abc123.png'), 'logo.png');
+ });
+ });
+
+ test(
+ 'Given a CacheBustingConfig serving a directory where the directory name contains the separator when calling assetPath then only the filename is affected',
+ () async {
+ await d.dir(p.join('static', 'img@foo'), [
+ d.file('logo.png', 'dir-at-bytes'),
+ ]).create();
+
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+
+ const original = '/static/img@foo/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/static/img@foo/logo@'));
+ });
+
+ test(
+ 'Given a CacheBustingConfig serving a file starting with the separator when calling assetPath then cache busting path is created correctly',
+ () async {
+ await d.dir('static', [d.file('@logo.png', 'at-logo')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+
+ const original = '/static/@logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/static/@logo@'));
+ });
+
+ group('Given a CacheBustingConfig serving a symlink that escapes the root',
+ () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.file('outside.txt', 'outside').create();
+ await d.dir('static').create();
+ final outsidePath = p.join(d.sandbox, 'outside.txt');
+ final linkPath = p.join(d.sandbox, 'static', 'escape.txt');
+ Link(linkPath).createSync(outsidePath);
+
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ );
+ });
+ test('when calling assetPath then it throws ArgumentError', () async {
+ expect(
+ cfg.assetPath('/static/escape.txt'),
+ throwsA(isA()),
+ );
+ });
+
+ test('when calling tryAssetPath then asset path is returned unchanged',
+ () async {
+ expect(
+ await cfg.tryAssetPath('/static/escape.txt'), '/static/escape.txt');
+ });
+ });
+
+ group(
+ 'Given a CacheBustingConfig with a mountPrefix that does not match fileSystemRoot',
+ () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', [
+ d.file('logo.png', 'png-bytes'),
+ ]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/web',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+ });
+
+ test(
+ 'when assetPath is called for an existing file then it returns a cache busted path',
+ () async {
+ const original = '/web/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, startsWith('/web/logo@'));
+ });
+
+ test(
+ 'when assetPath is called for file using fileSystemRoot instead of mountPrefix as base then it returns path unchanged',
+ () async {
+ const original = '/static/logo.png';
+ final busted = await cfg.assetPath(original);
+ expect(busted, equals('/static/logo.png'));
+ });
+ });
+
+ group('Given cache busting config', () {
+ late CacheBustingConfig cfg;
+ setUp(() async {
+ await d.dir('static', []).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ cfg = CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ );
+ });
+
+ test(
+ 'when tryStripHashFromFilename is filename equals to the separator then same path is returned',
+ () async {
+ expect(cfg.tryStripHashFromFilename('@'), '@');
+ });
+
+ test(
+ 'when tryStripHashFromFilename is called with non busted filename then same filename is returned',
+ () async {
+ expect(cfg.tryStripHashFromFilename('logo.png'), 'logo.png');
+ });
+
+ test(
+ 'when tryStripHashFromFilename is called with busted filename then hash is stripped from filename',
+ () async {
+ expect(cfg.tryStripHashFromFilename('logo@abc123.png'), 'logo.png');
+ });
+
+ test(
+ 'when tryStripHashFromFilename is called with busted filename that has no extension then it strips the hash and keeps no extension',
+ () async {
+ expect(cfg.tryStripHashFromFilename('logo@abc123'), 'logo');
+ });
+
+ test(
+ 'when tryStripHashFromFilename is called with busted filename starting with separator then only trailing hash is stripped',
+ () async {
+ expect(cfg.tryStripHashFromFilename('@logo@abc123.png'), '@logo.png');
+ });
+ });
+}
diff --git a/test/static/cache_busting_static_handler_test.dart b/test/static/cache_busting_static_handler_test.dart
new file mode 100644
index 00000000..558b9721
--- /dev/null
+++ b/test/static/cache_busting_static_handler_test.dart
@@ -0,0 +1,317 @@
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:relic/relic.dart';
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import 'test_util.dart';
+
+void main() {
+ group(
+ 'Given a static asset served through a root-mounted cache busting static file handler',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response =
+ await makeRequest(handler, '/static/logo.png', handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/logo@abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+ });
+
+ group(
+ 'Given a static asset served through a router-mounted cache busting static handler',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final router = Router()
+ ..get(
+ '/static/**',
+ createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+
+ handler = const Pipeline()
+ .addMiddleware(routeWith(router))
+ .addHandler(respondWith((final _) => Response.notFound()));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/logo.png');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/logo@abc.png');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+ });
+
+ group('Given static asset served outside of cache busting mountPrefix', () {
+ late Handler handler;
+
+ setUp(() async {
+ await d.dir('other', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'other'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response =
+ await makeRequest(handler, '/other/logo.png', handlerPath: 'other');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a busted URL then it still serves the asset (handler-level cache busting)',
+ () async {
+ final response = await makeRequest(handler, '/other/logo@abc.png',
+ handlerPath: 'other');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+ });
+
+ group(
+ 'Given a static asset with a filename starting with separator served through cache busting static file handler',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [d.file('@logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/@logo.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/@logo@abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+ });
+
+ group(
+ 'Given a static asset without extension served through cache busting static file handler',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [d.file('logo', 'file-contents')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response =
+ await makeRequest(handler, '/static/logo', handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'file-contents');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it serves the asset',
+ () async {
+ final response =
+ await makeRequest(handler, '/static/logo@abc', handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'file-contents');
+ });
+ });
+
+ group(
+ 'Given a static asset served through cache busting static file handler with a custom separator',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [d.file('logo.png', 'png-bytes')]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '--',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response =
+ await makeRequest(handler, '/static/logo.png', handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL using the custom separator then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/logo--abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'png-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL using the default separator then it does not find the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/logo@abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.notFound);
+ });
+ });
+
+ group(
+ 'Given a static asset served through cache busting static handler in a nested directory containing the separator',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [
+ d.dir('@images', [d.file('logo.png', 'nested-bytes')])
+ ]).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/static',
+ fileSystemRoot: staticRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/@images/logo.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'nested-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(
+ handler, '/static/@images/logo@abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'nested-bytes');
+ });
+ });
+
+ group(
+ 'Given a static handler configured with CacheBustingConfig that has different fileSystemRoot',
+ () {
+ late Handler handler;
+ setUp(() async {
+ await d.dir('static', [
+ d.dir('images', [d.file('logo.png', 'nested-bytes')])
+ ]).create();
+ await d.dir('cache', []).create();
+ final staticRoot = Directory(p.join(d.sandbox, 'static'));
+ final cacheRoot = Directory(p.join(d.sandbox, 'cache'));
+ handler = const Pipeline().addHandler(createStaticHandler(
+ staticRoot.path,
+ cacheControl: (final _, final __) => null,
+ cacheBustingConfig: CacheBustingConfig(
+ mountPrefix: '/cache',
+ fileSystemRoot: cacheRoot,
+ separator: '@',
+ ),
+ ));
+ });
+
+ test('when requesting asset with a non-busted URL then it serves the asset',
+ () async {
+ final response = await makeRequest(handler, '/static/images/logo.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'nested-bytes');
+ });
+
+ test(
+ 'when requesting asset with a cache busted URL then it still serves the asset (handler-level cache busting)',
+ () async {
+ final response = await makeRequest(handler, '/static/images/logo@abc.png',
+ handlerPath: 'static');
+ expect(response.statusCode, HttpStatus.ok);
+ expect(await response.readAsString(), 'nested-bytes');
+ });
+ });
+}