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'); + }); + }); +}