Umbraco backoffice cache busting when UmbracoVersion changes

Eventually got around to trying something more CI/CD led by the git commit hash set to the semantic version… adding to locall extension projects and injecting v={pacakgeVersion} for any 3rd party packages that didn’t do the manual approach, with a little cacheing as it’s a bit more than just reading json for every backoffice url visited. :wink:

dotnet build "%DEPLOYMENT_SOURCE%\Web.csproj" -c Release -r win-x64 -f net10.0 --no-restore --self-contained false -m:1 --disable-build-servers -p:InformationalVersion=%SCM_COMMIT_ID%

using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Manifest;
using Umbraco.Cms.Infrastructure.Manifest;
namespace Central.Code.Extensions.ResponseCacheing
{
    public class GlobalPackageManifestInterceptor : IPackageManifestReader
    {
        private readonly IEnumerable<IPackageManifestReader> _underlyingReaders;
        private readonly IEntryAssemblyMetadata _entryAssemblyMetadata;

        // Lightweight cache to avoid parsing JSON strings on every single backoffice visit
        private IEnumerable<PackageManifest>? _cachedManifests;
        private readonly SemaphoreSlim _cacheLock = new(1, 1);

        // Accepts the isolated array of original readers directly from the composer
        public GlobalPackageManifestInterceptor(
            IEnumerable<IPackageManifestReader> underlyingReaders,
            IEntryAssemblyMetadata entryAssemblyMetadata)
        {
            _underlyingReaders = underlyingReaders;
            _entryAssemblyMetadata = entryAssemblyMetadata;
        }

        public async Task<IEnumerable<PackageManifest>> ReadPackageManifestsAsync()
        {
            if (_cachedManifests != null)
            {
                return _cachedManifests;
            }

            await _cacheLock.WaitAsync();
            try
            {
                if (_cachedManifests != null)
                {
                    return _cachedManifests;
                }

                // 1. Safely run the original scanners without duplicating results in the global container
                var manifests = new List<PackageManifest>();
                foreach (var reader in _underlyingReaders)
                {
                    manifests.AddRange(await reader.ReadPackageManifestsAsync());
                }

                // 2. Grab your version safely from Umbraco's metadata tracker
                var commitVersion = _entryAssemblyMetadata.InformationalVersion ?? "1.0.0";

                if (commitVersion.Length > 10 && !commitVersion.Contains("-local"))
                {
                    commitVersion = commitVersion.Substring(0, 10);
                }

                foreach (var manifest in manifests)
                {
                    // Skip your own package if you are already handling it natively
                    //if (manifest.Id == "SM.UmbracoForms.CIJApiM") continue;

                    // Determine the best version context for this specific manifest
                    var targetVersion = commitVersion;
                    if (!string.IsNullOrWhiteSpace(manifest.Version) &&
                        manifest.Version != "0.0.0" &&
                        manifest.Version != "1.0.0")
                    {
                        targetVersion = manifest.Version;
                    }

                    if (manifest.Extensions == null) continue;

                    var updatedExtensions = new List<object>();

                    foreach (var extension in manifest.Extensions)
                    {
                        var jsonString = JsonSerializer.Serialize(extension);
                        if (JsonNode.Parse(jsonString) is JsonObject jsonObject)
                        {
                            bool modified = false;

                            if (jsonObject["js"] is JsonValue jsValue && jsValue.TryGetValue<string>(out var jsPath))
                            {
                                jsonObject["js"] = AppendCacheBuster(jsPath, targetVersion);
                                modified = true;
                            }

                            if (jsonObject["css"] is JsonValue cssValue && cssValue.TryGetValue<string>(out var cssPath))
                            {
                                jsonObject["css"] = AppendCacheBuster(cssPath, targetVersion);
                                modified = true;
                            }

                            // Account for modern property editors and custom elements
                            if (jsonObject["element"] is JsonValue elementValue && elementValue.TryGetValue<string>(out var elementPath))
                            {
                                jsonObject["element"] = AppendCacheBuster(elementPath, targetVersion);
                                modified = true;
                            }

                            updatedExtensions.Add(modified ? jsonObject : extension);
                        }
                        else
                        {
                            updatedExtensions.Add(extension);
                        }
                    }

                    manifest.Extensions = updatedExtensions.ToArray();
                    manifest.Version = targetVersion;
                }

                _cachedManifests = manifests;
                return _cachedManifests;
            }
            finally
            {
                _cacheLock.Release();
            }
        }

        private static string AppendCacheBuster(string path, string version)
        {
            if (string.IsNullOrWhiteSpace(path)) return path;

            var splitPath = path.Split('?');
            var basePath = splitPath[0];

            if (splitPath.Length > 1)
            {
                var queryString = splitPath[1];
                var parts = queryString.Split('&');
                var updatedParts = new List<string>();
                bool hasVersionParam = false;
                bool shouldKeepOriginal = false;

                foreach (var part in parts)
                {
                    if (part.StartsWith("v=", StringComparison.OrdinalIgnoreCase))
                    {
                        hasVersionParam = true;
                        var val = part.Substring(2);
                        if (!string.IsNullOrEmpty(val) && val != "0.0.0" && val != "1.0.0")
                        {
                            shouldKeepOriginal = true;
                            break;
                        }
                        updatedParts.Add($"v={version}");
                    }
                    else
                    {
                        updatedParts.Add(part);
                    }
                }

                if (shouldKeepOriginal)
                {
                    return path;
                }

                if (!hasVersionParam)
                {
                    updatedParts.Add($"v={version}");
                }

                return $"{basePath}?{string.Join("&", updatedParts)}";
            }

            // No query string existed at all, append our target version
            return $"{basePath}?v={version}";
        }
    }


    public class CacheBusterComposer : IComposer
    {
        public void Compose(IUmbracoBuilder builder)
        {
            // 1. Find all currently registered manifest readers
            var descriptorTargets = builder.Services
                .Where(x => x.ServiceType == typeof(IPackageManifestReader))
                .ToList();

            // 2. Remove them from the global DI pool so Umbraco doesn't run them twice
            foreach (var descriptor in descriptorTargets)
            {
                builder.Services.Remove(descriptor);
            }

            // 3. Register your interceptor as the single source of truth, passing the original descriptors to it
            builder.Services.AddSingleton<IPackageManifestReader>(provider =>
            {
                var originalReaders = new List<IPackageManifestReader>();

                foreach (var descriptor in descriptorTargets)
                {
                    if (descriptor.ImplementationType != null)
                    {
                        var reader = (IPackageManifestReader)ActivatorUtilities.GetServiceOrCreateInstance(provider, descriptor.ImplementationType);
                        originalReaders.Add(reader);
                    }
                    else if (descriptor.ImplementationFactory != null)
                    {
                        var reader = (IPackageManifestReader)descriptor.ImplementationFactory(provider);
                        originalReaders.Add(reader);
                    }
                    else if (descriptor.ImplementationInstance != null)
                    {
                        originalReaders.Add((IPackageManifestReader)descriptor.ImplementationInstance);
                    }
                }

                // Explicitly pass the isolated core readers into your interceptor
                return ActivatorUtilities.CreateInstance<GlobalPackageManifestInterceptor>(provider, (IEnumerable<IPackageManifestReader>)originalReaders);
            });
        }
    }
}