Umbraco backoffice cache busting when UmbracoVersion changes

I’ve been noticing that on each deployment of umbracoVersion updates, having done 17.4.0, 17.4.1 and 17.4.2 deployments since friday last week.

That each deployment the backoffice workspaces are blank with console errors logging 404’s for manangement api endpoints.
A hard reset, or cache purge in the browser seems to fix things.

I had conicidentally seen cloudflare increase it’s cached files percentages, so thought that could be related (now have a cache rule to ignore the /umbraco/* urls..)

However, just observed it running locally when updating to uForms 17.3.2 as well as (umb 17.4.2) that first run the workspaces are blank…


opening developer tools and setting cache disabled on the network tab again sorted the issue.

It seems to suggest that the entrypoint js is cached, and so refereing to now none existent hashed manifests?

With smidge gone.. (which used to add a cachebusting querystring param based on the umbracoversion for the backoffice bundles) I know we now have lit compiling with a hash when it thinks files have changed (I believe based on a file contents hash) However I only see that for bundled manifests and associated files.. the entrypoint being set in the package-json doesn’t get the same treatment..

Are we missing a trick in the backoffice for core packages/ HQ extensions etc.. that also means we aren’t cache busting packages between versions?

eg the recommendation from Kevin Jumps Battle scared developers guide springs to mind..
Battle scarred developer’s guide to Umbraco v17 - Setup - DEV Community
(see below for code sample to add a cache busting version)

Before I start digging any deeper is anyone else seeing this? Or have workarounds?
(ps the CI/CD deployments include adding app-offline.htm file so the instances are forced to recycle, though seeing it locally after a stop and rebuild cycle suggests something cache wise is occuring)

public Task<IEnumerable<PackageManifest>> ReadPackageManifestsAsync()
{
  var version = Assembly.GetAssembly(typeof(DoStuffPackageManifestReader))?
   .GetName()
   .Version?.ToString() ?? "1.0.0";

  return Task.FromResult<IEnumerable<PackageManifest>>(new[]
  {
    new PackageManifest
    {
      Id = "DoStuff.Client",
      Name = "DoStuff with Umbraco client",
      AllowTelemetry = true,
      Version = version,
      Extensions = [
        new {
          name = "DoStuff Client Bundle",
          alias = "DoStuff.Client.Bundle",
          type = "bundle",
          js = "/App_Plugins/DoStuffClient/do-stuff-client.js?v=" + version
        }
     ]
   }
  });
}

Hi @mistyn8

It may be worth checking what Cache-Control headers are being returned for those assets and whether you are setting any Cache-Control headers that is also affecting the backoffice. I’ve not noticed this but will check next time I do an update.

Also, you may want to adjust your rule as the packages (i.e. Umbraco Forms) would be served form /App_Plugins not /umbraco, so you may also need a rule to exclude /App_Plugins/*.

Justin

I’ve also noticed issues with client files during upgrades (especially extension entry points, bundles).

There is currently no built in way to automatically handle cache busters for extensions so it’s up to the package developer to ensure that a cache buster is added to entry points, bundles and import maps when shipping a new version.

I know I pointed out ideas around this, eg that the core would use the package version or something as a cache buster automatically. At the same time I saw that the core has mechanisms that should use a hash based on the installed version but it could be something wrong with that.

Are you saying that you have these problems also when updating ONLY the Umbraco version without touching any packages?

Did you notice which kind of files that where effected? Files for the “core backoffice” or any extension?

One could use a different cache-control but that would potentially slow down loading of the backoffice for editors - but might be a decent work around while investigating the underlying issue.

It very much sounds like something needs a cache buster :slight_smile: if this is just minor updates of the CMS and no package changes I would definitely post a issue on GitHub for this because that “should” work :slight_smile:

You can add %CACHE_BUSTER% to the string to apply the generated version-hash:

{
  "name": "MyPackage"
  "extensions": [
    {
      "type": "backofficeEntryPoint",
      "alias": "my.entrypoint",
      "name": "My Entrypoint",
      "js": "/App_Plugins/MyExtension/entrypoint.js?v=%CACHE_BUSTER%"
  ]
}

Edit: Follow-up. Since you yourself suggested it in this issue, which got fixed for 17.3: Make `BackOfficeCacheBustHash` usable for packages · Issue #16893 · umbraco/Umbraco-CMS · GitHub, I am not sure if you were talking about something else when you said there is no built-in way to handle cache-busting?

:musical_notes: “When there’s something the same… in the neighbourhood…”

I think I read that as in previous versions of umbraco we have the runtime minification settings that allowed the consumer to set cache busting requirements on the backoffice and all packages..

Cache buster

Specifies mechanism for cache invalidation.

The options are:

  • Version - Caches will be busted when your assembly version changes, when the upstream Umbraco version changes and when the version string specified in Configuration changes.

  • AppDomain - Caches will be busted when the app restarts.

  • Timestamp - Caches will be busted based on a timestamp of the bundled files.

with v17 we’ve lost that overarching functionality, and have to rely on the package developer who didn’t have anything in core till v17.3 and I can’t see that the docs have been updated to reflect this which presumably should now be best practice?

%CACHE_BUSTER% looks great… does it follow the previous version implementation?

        // Assembly Name adds a bit of uniqueness across sites when version missing from config.
        // Adds a bit of security through obscurity that was asked for in standup.
        var prefix = _runtimeMinificationSettings.Value.Version ?? _entryAssemblyMetadata.Name;
        var umbracoVersion = _umbracoVersion.SemanticVersion.ToString();
        var downstreamVersion = _entryAssemblyMetadata.InformationalVersion;

        _cacheBusterValue = $"{prefix}_{umbracoVersion}_{downstreamVersion}".GenerateHash();

Can it also be affected by DEBUG, or maybe allow a timestamp\appdomain by setting again like the previous implementation?

Looks like it’s just the umbraco semantic version..

    public static string GetCacheBustHash(IHostingEnvironment hostingEnvironment, IUmbracoVersion umbracoVersion)
    {
        // make a hash of umbraco and client dependency version
        // in case the user bypasses the installer and just bumps the web.config or client dependency config

        // if in debug mode, always burst the cache
        if (hostingEnvironment.IsDebugMode)
        {
            return DateTime.Now.Ticks.ToString(CultureInfo.InvariantCulture).GenerateHash();
        }

        var version = umbracoVersion.SemanticVersion.ToSemanticString();
        return $"{version}".GenerateHash();
    }

Umbraco-CMS/src/Umbraco.Web.Common/Extensions/UrlHelperExtensions.cs at main · umbraco/Umbraco-CMS
[nice it still refers to client dependency version in the comment… ;-)]

The value of %CACHE_BUSTER% depends on the version, as you found out. It’s the same value that we use for Backoffice assets in the /umbraco/backoffice/<hash> part of the URLs, only now made available directly in the umbraco-package.json files for developers to specify. I don’t recall which options were available to package developers before the new Backoffice - perhaps we appended a ?v=123 string to all files? Seems dangerous if we did. Does anyone remember?

So as a package developer it’s cache is only busted on an umbraco core version update when using this.. don’t we really need to include the package version too?

Sorry for all the questions.. just trying to understand what the current implementation is so I can either leverage that, or indeed find why my core workspaces are blank after a release deploy until browser caches are manually deleted. :slight_smile:

On previous <14 smidge bundled all backoffice including packages so we had the choice (timestamp/apprecycle/assembly+umbracosemver).. but I guess you are asking post smidge and prior to v17.3? (I’ve only jumped LTS to LTS so don’t know on that one.)

Yes, you are right. It doesn’t make much sense to use the Backoffice’s cache-busting in this case, if it’s just based on the version. If it was more dynamic, i.e., resetting after each deploy, then it would be a different case. Would be interesting to have some more configuration in this area, like we used to have with Smidge.
Your initial post, taking the assembly version and using that both for the “version” field as well as the “?v” parameter, is quite ideal for packages, at least.

Do you know if Smidge also loaded and bundled the assets defined in the then package.manifest files? If I recall correctly, those were still loaded directly in the backoffice, but there may have been a way to append the cache-busting hash to those, too.

Yep.. Package.Manifests got bundled into

<script src="/sb/umbraco-backoffice-extensions-js.js.vea5882032811acf4ffc8475bfdacfb15411a154c" class="lazyload" charset="utf-8"></script>

I guess something more customisable or overridable would require some heavy lifting..
because extension methods are compile-time sugar, not DI-replaceable behavior…

public static class HtmlHelperBackOfficeExtensions

In the mean time I guess with
\Umbraco.Cms.StaticAssets\umbraco\UmbracoBackOffice\Index.cshtml being a RCL I could override that file and call a different
@await Html.BackOfficeImportMapScriptAsync(JsonSerializer, BackOfficePathGenerator, PackageManifestService, CspNonceService)

and play with

var importmapScript = sb.ToString()
    .Replace(backOfficePathGenerator.BackOfficeVirtualDirectory, backOfficePathGenerator.BackOfficeAssetsPath)
    .Replace(Constants.Web.CacheBusterToken, backOfficePathGenerator.BackOfficeCacheBustHash);

That’s just for the <script type="importmap"> generation. Those also use cache-busters. But for extensions, they are being lazily loaded in the Backoffice directly after being served through the /extensions/private endpoint. There isn’t really any RCL you can overwrite to modify that behavior, unfortunately.

Oh.. probably need to look deeper then as that was the only place I could find for

.Replace(Constants.Web.CacheBusterToken, backOfficePathGenerator.BackOfficeCacheBustHash);

in use.

What about??

using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.Manifest;
using Umbraco.Cms.Core;

namespace Umbraco.Cms.Api.Management.Controllers.Manifest;

/// <summary>
/// Serves as the base controller for manifest operations in the management API.
/// </summary>
[VersionedApiBackOfficeRoute("manifest")]
[ApiExplorerSettings(GroupName = "Manifest")]
public abstract class ManifestControllerBase : ManagementApiControllerBase
{
    protected static void ReplaceCacheBusterTokens(
        IEnumerable<ManifestResponseModel> models, string cacheBustHash)
    {
        foreach (ManifestResponseModel model in models)
        {
            if (model.Extensions.Length == 0)
            {
                continue;
            }

            var json = JsonSerializer.Serialize(model.Extensions);
            if (json.Contains(Constants.Web.CacheBusterToken) is false)
            {
                continue;
            }

            json = json.Replace(Constants.Web.CacheBusterToken, JsonEncodedText.Encode(cacheBustHash).ToString());
            model.Extensions = JsonSerializer.Deserialize<object[]>(json) ?? model.Extensions;
        }
    }
}

and it’s use in (all|public|private)ManifestControllers?

You got it, that’s the base method the manifest controller calls. The manifest controller is the one the backoffice calls to find its third-party extensions.

:slight_smile: Just the hurdle that it relies on Constants.Web.CacheBusterToken for a find and replace…

Thank you for clarifying @jacob!

I think I was more referring to that there is no “magic” thing that is automatically applied for updated packages.

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);
            });
        }
    }
}

Wow @mistyn8!

Impressive! Thanks for sharing that!

Personally, I think that it would be very nice to be able to provide a environment variable or appsettings config to indicate the cache buster value to be used for all files loaded by the backoffice (entry points, bundles, import maps etc). Before v13, we used a unique hash per release for this (not really an issue that editors had to re-download the backoffice files, it better than random cache-problems).

@jacob, I think that the %CACHE_BUSTER% feature is kind of “hidden” not even used in the HQ extension template

It would be really nice to find a way to make cache busting work even if the package dev did not consider that when shipping the package.

The problem with a single value approach is that it invalidates the cache for files that haven’t changed too.

This is a problem that the JS world solved a long time ago with hashing. It’s the only method of cache invalidation you can practically trust and it guarantees the most efficient caching/invalidation strategy possible. I.E. if the file is different, it’s a new URL, so files can be cached indefinitely with no risk.

That’s what Vite is doing, and we should either leverage that or or do something very similar.

I think I’d prefer to solve this problem with a Vite plugin that updates the package.json at build time with a hash. Then Umbraco can call URL + hash.

Using a separate property means Umbraco could calculate the hash where not provided - I can’t imagine that being a huge performance hit to do server-side and we can have warnings in logs for packages that don’t provide a value.

@JasonElkin … Discussion around a pr from HQ in the works.. and think the options address your immediate concern regarding scoping cache busting to where needed. (or indeed for a hail mary nuclear option for a consumer)

Package Manifests: Per-package cache-busting for /App_Plugins assets (importmap + extensions) by iOvergaard · Pull Request #23094 · umbraco/Umbraco-CMS

Some extra thoughts..
JS hashing brings other issues though too?
CI/CD at some point needs to clean deployed assets that are no longer needed..
We have a mixture of filename and path segment hashing..

We might not want to have the compute cost/time/complexity of hashing during CI/CD so would like to generate locally and then constantly changing file names makes source repositories noisy…

If you are file hashing then you shift the responsibility to the provider of a package rather than the consumer, I guess the intention is immutability and we don’t have override staticwebassets in the same easy way as cshtml RCL.. needing an intercepting middleware.
And if a package creator fails to add cache busting to the entrypoint (nothing in the umbraco docs for how package devs should do it.. so I’m guessing plenty of existing packages don’t cache bust.. hence the need for a consumer approach too)

But would love to hear your take on this new front end world that is the backoffice. :slight_smile:
All new to me… but simple initial reading…
cdn’s seem to like https://cdn.example.com/[email protected]/dist/index.js so path segments?
vite would handle it by altering your consuming index.html… but for packages because the consumer of your package expects a stable import path (e.g., import { MyComponent } from 'my-library'). would shift the onus to the consumer… :person_shrugging:

Cool, so this is what Jacob is kind of getting at here:

The way to close the loop is to make the manifest a build output —
have Vite emit umbraco-package.json itself with the hashed filenames written in (a
small generateBundle/writeBundle plugin, driven off Vite’s manifest: true). Once
the filename is the cache key you don’t need a query string at all: immutable,
CDN-safe (sidesteps the ?umb__rnd query caveat entirely), and content-addressed so it
doesn’t lean on bumping a version.

This could be a nice little vite plugin that ships as part of the package starter.

A good CI/CD process is idempotent so this shouldn’t be a problem anyway - you need to clear out old bin files etc. on deploy. Just nuke everything every time.

This is is not that big of a deal and Vite is doing it anyway when packages are built so it’s not a problem there.

I’m actually suggesting hashing at runtime. Have C# inspect the entry point when manifests are loaded. It’s not going to be that much work.

Packages should already be being handling this internally with Vite beyond the entry point anyway.

If you’re shipping a package without pre-optimized (i.e. minified), hashed etc. JS files then something’s gone wrong.