Skip to content

Commit 017c120

Browse files
committed
dfeed.web.web.statics: Switch to hash-based URLs for static files
Modification times can be fragile with different build/deployment strategies.
1 parent 984bbf1 commit 017c120

1 file changed

Lines changed: 41 additions & 4 deletions

File tree

src/dfeed/web/web/statics.d

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import std.regex : replaceAll, replaceFirst;
3333
static import std.file;
3434
import std.regex : Regex, matchAll;
3535

36+
import std.ascii : LetterCase;
37+
import std.digest.md : md5Of, toHexString;
38+
3639
import ae.net.http.responseex : HttpResponseEx;
3740
import ae.sys.data : Data, DataVec;
3841
import ae.utils.meta : isDebug;
@@ -41,7 +44,8 @@ import ae.utils.regex : re;
4144
import dfeed.paths : resolveSiteFileBase, resolveSiteFile, resolveStaticFileBase;
4245
import dfeed.web.web.config : config;
4346

44-
/// Caching wrapper
47+
/// Caching wrapper for file modification time.
48+
/// Caches stat calls for a few seconds to reduce filesystem overhead.
4549
SysTime timeLastModified(string path)
4650
{
4751
static if (is(MonoTimeImpl!(ClockType.coarse)))
@@ -67,12 +71,38 @@ SysTime timeLastModified(string path)
6771
return cache[path] = std.file.timeLastModified(path);
6872
}
6973

74+
/// Cached content hash for cache-busting static URLs.
75+
/// Only recomputes hash when file modification time changes.
76+
/// Uses the timeLastModified cache to minimize stat calls.
77+
string fileContentHash(string path)
78+
{
79+
static struct CacheEntry
80+
{
81+
long mtime;
82+
string hash;
83+
}
84+
static CacheEntry[string] hashCache;
85+
86+
auto mtime = timeLastModified(path).stdTime;
87+
88+
if (auto entry = path in hashCache)
89+
if (entry.mtime == mtime)
90+
return entry.hash;
91+
92+
// Compute hash - use first 16 hex chars of MD5 (64 bits)
93+
auto content = cast(ubyte[]) std.file.read(path);
94+
auto hash = md5Of(content).toHexString!(LetterCase.lower)[0..16].idup;
95+
96+
hashCache[path] = CacheEntry(mtime, hash);
97+
return hash;
98+
}
99+
70100
string staticPath(string path)
71101
{
72102
auto resolvedBase = resolveStaticFileBase(path)
73103
.enforce("Static file not found: " ~ path);
74104
auto resolvedFile = resolvedBase ~ path;
75-
auto url = "/static/" ~ text(timeLastModified(resolvedFile).stdTime) ~ path;
105+
auto url = "/static/" ~ fileContentHash(resolvedFile) ~ path;
76106
if (config.staticDomain !is null)
77107
url = "//" ~ config.staticDomain ~ url;
78108
return url;
@@ -122,8 +152,15 @@ string createBundles(string page, Regex!char re)
122152
if (paths.length == 0)
123153
return page;
124154

125-
auto maxTime = paths.map!(path => path[8..26].to!long).reduce!max;
126-
string bundleUrl = "/static-bundle/%d/%-(%s+%)".format(maxTime, paths.map!(path => path[27..$]));
155+
// Extract hashes and compute combined bundle hash
156+
// URL format: /static/<16-char-hash>/<path>
157+
// Positions: 0-7="/static/", 8-23=hash, 24="/", 25+=path
158+
import std.algorithm.iteration : joiner;
159+
import std.array : array;
160+
auto hashes = paths.map!(path => path[8..24]);
161+
auto combinedHash = md5Of(cast(ubyte[]) hashes.joiner.array)
162+
.toHexString!(LetterCase.lower)[0..16];
163+
string bundleUrl = "/static-bundle/%s/%-(%s+%)".format(combinedHash, paths.map!(path => path[25..$]));
127164
int index;
128165
page = page.replaceAll!(captures => index++ ? null : captures[0].replace(captures[1], bundleUrl))(re);
129166
return page;

0 commit comments

Comments
 (0)