Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a98707e
feat: Initial working version of cache busting.
vlidholt Oct 6, 2025
ed3af1f
feat: Cleaned up example and structure.
vlidholt Oct 6, 2025
857ce1c
refactor: Moves cache busting to io/static directory.
vlidholt Oct 6, 2025
7f7e8b2
fix: Renames asset to bust.
vlidholt Oct 6, 2025
3490dc3
fix: Cleaned up code and fixed the tests.
vlidholt Oct 6, 2025
181c8d1
docs: Cleans up comments in middleware.
vlidholt Oct 6, 2025
ea59dd1
refactor: Moves tests to correct directory.
vlidholt Oct 6, 2025
08af514
test: Improved tests.
vlidholt Oct 6, 2025
154865b
docs: Cleans up example.
vlidholt Oct 6, 2025
5ecd930
test: Improved test coverage.
vlidholt Oct 6, 2025
71cb46e
fix: Removes unused code.
vlidholt Oct 6, 2025
372d0b2
test: Adds test for coverage.
vlidholt Oct 6, 2025
11b1b04
fix: Fixes a potential security issue, where etags could have been ac…
vlidholt Oct 7, 2025
cc1d3da
fix: Updates example to show error message if running from incorrect …
vlidholt Oct 7, 2025
9e140d4
test: Extending code coverage.
vlidholt Oct 7, 2025
527907c
test: Moves to Given-When-Then structure for naming the tests.
vlidholt Oct 7, 2025
1c5ef42
fix: Adds cache control to the example.
vlidholt Oct 7, 2025
23e82b4
fix: Renames the bust method to assetPath.
vlidholt Oct 7, 2025
b472b39
feat: Makes separator configurable.
vlidholt Oct 7, 2025
b5ab934
fix: Splits test in two.
vlidholt Oct 7, 2025
7da74c5
test: Splits up tests in two files.
vlidholt Oct 8, 2025
591cc00
refactor: Rename to match assetPath.
SandPod Oct 8, 2025
7176d53
fix: Refactor tests and add additional validation on separator and fi…
SandPod Oct 8, 2025
bf071fd
Merge branch 'main' into cache-busting
SandPod Oct 8, 2025
a7fd708
test: Add test to validate mount prefix.
SandPod Oct 8, 2025
5c209f8
test: Refactor tests to follow given when then pattern.
SandPod Oct 8, 2025
96da68c
test: Add missing (failing) test scenario.
SandPod Oct 8, 2025
455fe45
test: Use explicit separator for all CacheBustingConfigs in test.
SandPod Oct 8, 2025
f2d0a7b
Merge branch 'main' into cache-busting
SandPod Oct 10, 2025
be2de2f
fix: Expose tryStripHashFromFilename from CacheBustingConfig.
SandPod Oct 10, 2025
b74b3f9
feat: Add cache busting directly to static handler.
SandPod Oct 10, 2025
1e421af
fix: Remove old middleware and update documentation and example.
SandPod Oct 10, 2025
3a97f33
refactor: Convert to proper validation style for separator and file s…
SandPod Oct 10, 2025
003a5c1
refactor: Convert tryStripHashFromFilename into static method to hide…
SandPod Oct 10, 2025
6477fe8
fix: Update example to allow head requests.
SandPod Oct 10, 2025
fb5df35
fix(review): Add check for "/" separator.
SandPod Oct 13, 2025
9290bdf
test(review): Add valid separator to test right thing.
SandPod Oct 13, 2025
ea63512
fix(review): Add a hidden extension for stripping hash from filename.
SandPod Oct 13, 2025
9180ea0
test(review): Add additional tests for "/" separator.
SandPod Oct 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example/static_files/hello.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello! :)
25 changes: 25 additions & 0 deletions example/static_files/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions example/static_files_example.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<Handler>()
..get('/', respondWith((final _) async {
final helloUrl = await buster.assetPath('/static/hello.txt');
final logoUrl = await buster.assetPath('/static/logo.svg');
final html = '<html><body>'
'<h1>Static files with cache busting</h1>'
'<ul>'
'<li><a href="$helloUrl">hello.txt</a></li>'
'<li><img src="$logoUrl" alt="logo" height="64" /></li>'
'</ul>'
'</body></html>';
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);
}
1 change: 1 addition & 0 deletions lib/relic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
164 changes: 164 additions & 0 deletions lib/src/io/static/cache_busting_config.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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 "`<sep>`hash" segment from a [fileName], preserving any
/// extension. Matches both "`name<sep>hash`.ext" and "`name<sep>hash`".
///
/// 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);
}
}
39 changes: 38 additions & 1 deletion lib/src/io/static/static_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -39,6 +40,16 @@ typedef CacheControlFactory = CacheControlHeader? Function(
/// LRU cache for file information to avoid repeated file system operations.
final _fileInfoCache = LruCache<String, FileInfo>(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<FileInfo> 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
Expand All @@ -57,11 +68,16 @@ final _fileInfoCache = LruCache<String, FileInfo>(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()) {
Expand All @@ -73,9 +89,30 @@ Handler createStaticHandler(
final fallbackHandler =
defaultHandler ?? respondWith((final _) => Response.notFound());

final resolveFilePath = switch (cacheBustingConfig) {
null =>
(final String resolvedRootPath, final List<String> requestSegments) =>
p.joinAll([resolvedRootPath, ...requestSegments]),
final cfg =>
(final String resolvedRootPath, final List<String> 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);
Expand Down
Loading
Loading