Smidge 5 is out
Smidge 5 is out
Smidge 5.0.0 is out on NuGet!
It's been about a year since the last release, so sorry 'bout that 😅. This one turned into a proper major version rather than another collection of small fixes: Smidge is now on .NET 10, NUglify is the only minifier, the MVC dependency is gone, the package structure is simpler, and a few long-running production bugs have finally been tracked down properly.
The full release notes have every detail. This is the story behind the parts that matter most.
One minifier instead of three
Smidge has historically shipped with its own hand-rolled JavaScript and CSS minifiers, JsMinifier and CssMinifier, while also offering NUglify through the separate Smidge.Nuglify package.
That made sense when those minifiers were first added, but over time it meant maintaining two different approaches to the same job. The built-in implementations had their own edge cases and bugs, while NUglify was already the better maintained and more capable option.
In v5 the old JSMin and CSSMin implementations are gone. NUglify is now included directly in the main Smidge package and is used automatically by the normal AddSmidge() and UseSmidge() setup. The separate Smidge.Nuglify package, along with AddSmidgeNuglify() and UseSmidgeNuglify(), is no longer needed.
This is a breaking change, but it's also a much simpler default. There is one minification pipeline, one set of behaviour to test, and source maps work without having to opt into another package.
.NET 10 and no more MVC dependency
Smidge now targets .NET 10.
The old MVC controllers used to serve bundle, composite-file and source-map requests. Those have been replaced with lightweight minimal API endpoints, with the old action-filter behaviour moved into endpoint filters.
That removes the MVC dependency entirely. If you're using Smidge in a small ASP.NET Core app you no longer need to pull MVC into the application just to serve a CSS or JavaScript bundle.
As part of that work, the old Smidge.Core project has also been folded into Smidge. There isn't much value in making consumers understand the internal package split, and having the public surface spread across assemblies made the dependency graph harder to follow than it needed to be.
The practical result is fewer packages, fewer setup methods and less framework machinery underneath it all.
The random bundle crash was actually a race condition
There have been a couple of reports over the years of Smidge suddenly throwing a NullReferenceException from BundleFileSetGenerator.ValidateFile. The frustrating part was that it only seemed to happen in production, often after an application had been running for a while, and recycling the app pool would make it disappear.
The immediate symptom was a null file entry inside a bundle, but there was no obvious path that intentionally added one. I added defensive checks so a bad entry could be logged and skipped instead of taking down the entire bundle response, but that still didn't explain how it got there.
The missing clue came from somebody who found that wrapping their view-based bundle registration in an application-level lock made the problem go away.
That pointed at the real problem. BundleManager is a singleton and its bundle dictionary was thread-safe, but each bundle stored its files in a normal List<IWebFile>. View-based declarations can run from multiple concurrent requests, which meant multiple threads could call List<T>.Add on the same list at the same time. List<T> doesn't support concurrent writers, and if two additions collided during an internal resize the list could lose entries or end up with null slots.
It wasn't the cache buster, app pool idle state or a missing source file. It was memory corruption inside a concurrently mutated list.
Bundle.Files is now backed by an immutable list and updated with ImmutableInterlocked.Update. Reads get a complete, unchanging snapshot with no locking cost, and concurrent writers use a compare-and-swap loop rather than corrupting the same backing array. There are also defensive null checks at bundle creation and file-set generation, because a bad input still shouldn't turn into a broken page.
A concurrency test that registers files from 50 tasks at once reliably lost entries against the old implementation. With the new implementation every file is present and there are no nulls.
This is probably the most important fix in the release even if it's not the flashiest one.
Missing cache files now return 404
The other production hardening work is around stale or invalid bundle URLs.
Composite bundle URLs contain references to already-processed cache files. If the cache was cleared, an in-memory cache disappeared after an application restart, or somebody deliberately requested a made-up file hash, Smidge used to call a throwing file lookup. That surfaced as a 500 and could be triggered repeatedly, which made it a fairly easy denial-of-service path.
The HTTP-facing composite and source-map handlers now use non-throwing lookups. If a requested cache file isn't there, Smidge returns a normal 404 instead of throwing. Internal invariants still throw where they should - a source file configured in a real bundle disappearing is different from an arbitrary client asking for a stale cache path.
The same work fixed an older source-map issue where an unexpected sourceMappingURL request could fail with Could not parse ... as a valid smidge path. Invalid or missing source-map requests now just return 404, which is what the browser expects anyway.
Globs and directory bundles
There are a couple of smaller file-matching fixes that are worth calling out.
Pointing a bundle at a bare directory used to expand it to *.*. That could pull generated files such as .js.gz or .map into a JavaScript bundle and then hand binary gzip data to NUglify, which ended about as well as you'd expect. Bare directory bundles are now constrained to the bundle type, so JavaScript bundles get .js files and CSS bundles get .css files.
Recursive glob patterns are supported as well:
bundles.CreateCss("site", "~/assets/css/**/*.css");
They already worked for the common physical-file-provider case through .NET's built-in globbing support, but the fallback used for embedded and composite file providers didn't understand the **.css form correctly. Both **.css and **/*.css now behave consistently across providers.
Breaking changes
This is a major version, so there are a few things to account for when upgrading:
- Smidge now targets .NET 10.
Smidge.Corehas been merged intoSmidgeand the separate assembly no longer exists.Smidge.Nuglify,AddSmidgeNuglify()andUseSmidgeNuglify()are no longer needed. NUglify is built into the normal Smidge setup.- The old built-in
JsMinifierandCssMinifierare gone. - A few old MVC handler and filter types are now internal implementation details.
Bundle.Filesis nowIReadOnlyList<IWebFile>rather than a mutableList<IWebFile>. UseBundle.AddFile(IWebFile)if you need to add files programmatically.
For most applications the upgrade should mainly be removing the old NUglify-specific setup and package reference, then rebuilding against .NET 10. If you only used the standard AddSmidge() / UseSmidge() APIs and predefined bundles, there shouldn't be much else to change.
Build and release cleanup
The dependency set has been brought up to date, including the Microsoft.Extensions packages, SourceLink, xUnit, the test SDK and Moq.
I've also replaced GitVersion with MinVer. The version now comes directly from the Git tag: normal builds produce a 5.0.0-ci.<height> prerelease version and the v5.0.0 tag produces exactly 5.0.0. It's considerably less machinery for what Smidge needs.
Give it a go
Smidge 5.0.0 is available on NuGet, and the source and full release notes are on GitHub.
This release took longer than it should have, but it also ended up being more substantial than I'd originally planned. The package setup is simpler, the runtime surface is smaller, and the two worst classes of production failure - corrupted bundle registrations and attacker-reachable cache misses turning into 500s - are now properly fixed rather than worked around.
If you run into anything while upgrading, please open an issue. And thanks to everyone who kept reporting the weird, hard-to-reproduce failures over the years. Those are never the fun ones to investigate, but in this case they led to a much better fix than another null check 🙂