@@ -33,6 +33,9 @@ import std.regex : replaceAll, replaceFirst;
3333static import std.file ;
3434import std.regex : Regex, matchAll;
3535
36+ import std.ascii : LetterCase;
37+ import std.digest.md : md5Of, toHexString;
38+
3639import ae.net.http.responseex : HttpResponseEx;
3740import ae.sys.data : Data, DataVec;
3841import ae.utils.meta : isDebug;
@@ -41,7 +44,8 @@ import ae.utils.regex : re;
4144import dfeed.paths : resolveSiteFileBase, resolveSiteFile, resolveStaticFileBase;
4245import 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.
4549SysTime 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+
70100string 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