-
Notifications
You must be signed in to change notification settings - Fork 8
feat: Adds support for cache busting #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 ed3af1f
feat: Cleaned up example and structure.
vlidholt 857ce1c
refactor: Moves cache busting to io/static directory.
vlidholt 7f7e8b2
fix: Renames asset to bust.
vlidholt 3490dc3
fix: Cleaned up code and fixed the tests.
vlidholt 181c8d1
docs: Cleans up comments in middleware.
vlidholt ea59dd1
refactor: Moves tests to correct directory.
vlidholt 08af514
test: Improved tests.
vlidholt 154865b
docs: Cleans up example.
vlidholt 5ecd930
test: Improved test coverage.
vlidholt 71cb46e
fix: Removes unused code.
vlidholt 372d0b2
test: Adds test for coverage.
vlidholt 11b1b04
fix: Fixes a potential security issue, where etags could have been ac…
vlidholt cc1d3da
fix: Updates example to show error message if running from incorrect …
vlidholt 9e140d4
test: Extending code coverage.
vlidholt 527907c
test: Moves to Given-When-Then structure for naming the tests.
vlidholt 1c5ef42
fix: Adds cache control to the example.
vlidholt 23e82b4
fix: Renames the bust method to assetPath.
vlidholt b472b39
feat: Makes separator configurable.
vlidholt b5ab934
fix: Splits test in two.
vlidholt 7da74c5
test: Splits up tests in two files.
vlidholt 591cc00
refactor: Rename to match assetPath.
SandPod 7176d53
fix: Refactor tests and add additional validation on separator and fi…
SandPod bf071fd
Merge branch 'main' into cache-busting
SandPod a7fd708
test: Add test to validate mount prefix.
SandPod 5c209f8
test: Refactor tests to follow given when then pattern.
SandPod 96da68c
test: Add missing (failing) test scenario.
SandPod 455fe45
test: Use explicit separator for all CacheBustingConfigs in test.
SandPod f2d0a7b
Merge branch 'main' into cache-busting
SandPod be2de2f
fix: Expose tryStripHashFromFilename from CacheBustingConfig.
SandPod b74b3f9
feat: Add cache busting directly to static handler.
SandPod 1e421af
fix: Remove old middleware and update documentation and example.
SandPod 3a97f33
refactor: Convert to proper validation style for separator and file s…
SandPod 003a5c1
refactor: Convert tryStripHashFromFilename into static method to hide…
SandPod 6477fe8
fix: Update example to allow head requests.
SandPod fb5df35
fix(review): Add check for "/" separator.
SandPod 9290bdf
test(review): Add valid separator to test right thing.
SandPod ea63512
fix(review): Add a hidden extension for stripping hash from filename.
SandPod 9180ea0
test(review): Add additional tests for "/" separator.
SandPod File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Hello! :) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.