Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fd040f4
Refactor compression negotiation: generic EndpointGroup<TState>, grou…
javiercn Apr 13, 2026
ffbf011
Add zstd compression support for static web assets (publish-only)
javiercn Apr 13, 2026
e92e9c6
Add Compression Dictionary Transport (CDT) support for static web ass…
javiercn Apr 13, 2026
8da25c2
Refactor CDT dictionary model to route-based matching with match patt…
javiercn Apr 13, 2026
55dbfbd
Add CDT integration test and dictionary path fixes
javiercn Apr 13, 2026
61ea82b
Strengthen CDT integration test with comprehensive endpoint assertions
javiercn Apr 13, 2026
3e8cfa6
Add Use-As-Dictionary to all content-negotiated endpoints per RFC 9842
javiercn Apr 13, 2026
8b00c2e
Address PR review feedback: bug fixes, style, and baseline regeneration
javiercn Apr 13, 2026
6a89e88
Fix Windows-hardcoded paths in ResolveDictionaryCandidatesTest
javiercn Apr 13, 2026
9c81814
Apply deferred PR review items: extract phase helpers, strengthen ass…
javiercn Apr 13, 2026
4c9cd0e
Fix net472 build: use string overload for StartsWith
javiercn Apr 14, 2026
c7c0348
Dictionary-Hash as endpoint property, fix BlazorWebAssembly tests for…
javiercn Apr 14, 2026
346b599
Address second review: bug fixes, CDT correctness, dead code removal,…
javiercn Apr 14, 2026
879f2e1
Fix dcz format condition and atomic compression output
javiercn Apr 14, 2026
1af3a93
Address fourth review: fix double-slash, dcz exclusion, and cleanup
javiercn Apr 14, 2026
3e0fbbe
Implement remaining review feedback: named types, dcz naming, pack va…
javiercn Apr 14, 2026
a895a3a
Style fixes, test corrections for dcz naming and Dictionary-Hash prop…
javiercn Apr 14, 2026
f7717e3
Address round 5 review: refactor ExecuteCore, rename methods, flatten…
javiercn Apr 15, 2026
9bbbe28
Delete documentation/general/zstd-dictionary-compression-research.md
javiercn Apr 15, 2026
fcf737e
Address round 6 review: remove dead code, add .zst assertion, strengt…
javiercn Apr 15, 2026
da120e7
Address SWA zstd and CDT review feedback
javiercn Apr 16, 2026
91455c1
Address iteration 7 SWA review feedback
javiercn Apr 16, 2026
4ea202a
Fix SWA negotiation path lookups
javiercn Apr 16, 2026
e5b6423
Fix SWA ResolveCompressedAssets test expectation
javiercn Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

623 changes: 480 additions & 143 deletions src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs

Large diffs are not rendered by default.

152 changes: 106 additions & 46 deletions src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

public class DiscoverPrecompressedAssets : Task
{
private const string GzipAssetTraitValue = "gzip";
private const string BrotliAssetTraitValue = "br";

public ITaskItem[] CandidateAssets { get; set; }

// The compression formats to recognize. Each item's Identity is the format name.
// Required metadata: FileExtension (e.g., ".gz"), ContentEncoding (e.g., "gzip", "br").
public ITaskItem[] CompressionFormats { get; set; }

[Output]
public ITaskItem[] DiscoveredCompressedAssets { get; set; }

Expand All @@ -29,82 +30,141 @@ public override bool Execute()
return true;
}

// Build extension → contentEncoding lookup from CompressionFormats items, sorted by descending extension length for longest-match.
var formatsByExtension = BuildFormatsByExtension();
if (formatsByExtension is null)
{
// Error already logged
return false;
}

var candidates = StaticWebAsset.FromTaskItemGroup(CandidateAssets);
var assetsToUpdate = new List<ITaskItem>();

var candidatesByIdentity = candidates.ToDictionary(asset => asset.Identity, OSPath.PathComparer);

foreach (var candidate in candidates)
{
if (HasCompressionExtension(candidate.RelativePath) &&
// We only care about assets that are not already considered compressed
!IsCompressedAsset(candidate) &&
// The candidate doesn't already have a related asset
string.IsNullOrEmpty(candidate.RelatedAsset))
if (IsCompressedAsset(candidate) || !string.IsNullOrEmpty(candidate.RelatedAsset))
{
Log.LogMessage(
MessageImportance.Low,
"The asset '{0}' was detected as compressed but it didn't specify a related asset.",
candidate.Identity);
var relatedAsset = FindRelatedAsset(candidate, candidatesByIdentity);
if (relatedAsset is null)
{
Log.LogMessage(
MessageImportance.Low,
"The asset '{0}' was detected as compressed but the related asset with relative path '{1}' was not found.",
candidate.Identity,
Path.GetFileNameWithoutExtension(candidate.RelativePath));
continue;
}
continue;
}

if (!TryGetCompressionExtension(candidate.RelativePath, formatsByExtension, out var matchedExtension, out var contentEncoding))
{
continue;
}

Log.LogMessage(
MessageImportance.Low,
"The asset '{0}' was detected as compressed but it didn't specify a related asset.",
candidate.Identity);

var relatedAsset = FindRelatedAsset(candidate, candidatesByIdentity, matchedExtension);
if (relatedAsset is null)
{
Log.LogMessage(
"The asset '{0}' was detected as compressed and the related asset '{1}' was found.",
MessageImportance.Low,
"The asset '{0}' was detected as compressed but the related asset with relative path '{1}' was not found.",
candidate.Identity,
relatedAsset.Identity);
UpdateCompressedAsset(candidate, relatedAsset);
assetsToUpdate.Add(candidate.ToTaskItem());
Path.GetFileNameWithoutExtension(candidate.RelativePath));
continue;
}

Log.LogMessage(
"The asset '{0}' was detected as compressed and the related asset '{1}' was found.",
candidate.Identity,
relatedAsset.Identity);
UpdateCompressedAsset(candidate, relatedAsset, matchedExtension, contentEncoding);
assetsToUpdate.Add(candidate.ToTaskItem());
}

DiscoveredCompressedAssets = [.. assetsToUpdate];

return !Log.HasLoggedErrors;
}

private static StaticWebAsset FindRelatedAsset(StaticWebAsset candidate, IDictionary<string, StaticWebAsset> candidates)
private List<(string Extension, string ContentEncoding)> BuildFormatsByExtension()
{
if (CompressionFormats is null || CompressionFormats.Length == 0)
{
return [];
}

var formats = new List<(string Extension, string ContentEncoding)>(CompressionFormats.Length);
var seenExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var format in CompressionFormats)
{
var extension = format.GetMetadata("FileExtension");
var contentEncoding = format.GetMetadata("ContentEncoding");

if (string.Equals(format.GetMetadata("UsesDictionary"), "true", StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (string.IsNullOrEmpty(extension) || string.IsNullOrEmpty(contentEncoding))
{
Log.LogError(
"Compression format '{0}' is missing required metadata. Extension='{1}', ContentEncoding='{2}'.",
format.ItemSpec, extension, contentEncoding);
return null;
}

if (!seenExtensions.Add(extension))
{
Log.LogError(
"Duplicate file extension '{0}' found in CompressionFormats (format '{1}'). Each extension must be unique.",
extension, format.ItemSpec);
return null;
}

formats.Add((extension, contentEncoding));
}

// Sort by descending extension length for longest-match-first semantics.
formats.Sort((a, b) => b.Extension.Length.CompareTo(a.Extension.Length));
return formats;
}

private static StaticWebAsset FindRelatedAsset(
StaticWebAsset candidate,
IDictionary<string, StaticWebAsset> candidates,
string matchedExtension)
{
// The only pattern that we support is a related asset that lives in the same directory, with the same name,
// but without the compression extension. In any other case we are not going to consider the assets related
// and an error will occur.
var identityWithoutExtension = candidate.Identity.Substring(0, candidate.Identity.Length - 3); // We take advantage we know the extension is .br or .gz.
var identityWithoutExtension = candidate.Identity.Substring(0, candidate.Identity.Length - matchedExtension.Length);
return candidates.TryGetValue(identityWithoutExtension, out var relatedAsset) ? relatedAsset : null;
}

private static bool HasCompressionExtension(string relativePath)
private static bool TryGetCompressionExtension(
string relativePath,
List<(string Extension, string ContentEncoding)> formatsByExtension,
out string matchedExtension,
out string contentEncoding)
{
return relativePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
relativePath.EndsWith(".br", StringComparison.OrdinalIgnoreCase);
foreach (var (ext, encoding) in formatsByExtension)
{
if (relativePath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
{
matchedExtension = ext;
contentEncoding = encoding;
return true;
}
}

matchedExtension = null;
contentEncoding = null;
return false;
}

private static bool IsCompressedAsset(StaticWebAsset asset)
=> string.Equals("Content-Encoding", asset.AssetTraitName, StringComparison.Ordinal);

private static void UpdateCompressedAsset(StaticWebAsset asset, StaticWebAsset relatedAsset)
private static void UpdateCompressedAsset(StaticWebAsset asset, StaticWebAsset relatedAsset, string fileExtension, string assetTraitValue)
{
string fileExtension;
string assetTraitValue;

if (!asset.RelativePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
{
fileExtension = ".br";
assetTraitValue = BrotliAssetTraitValue;
}
else
{
fileExtension = ".gz";
assetTraitValue = GzipAssetTraitValue;
}

var relativePath = relatedAsset.EmbedTokens(relatedAsset.RelativePath);

asset.RelativePath = $"{relativePath}{fileExtension}";
Expand Down
150 changes: 150 additions & 0 deletions src/StaticWebAssetsSdk/Tasks/Compression/GeneratePublishAssetPack.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.IO.Compression;
using Microsoft.Build.Framework;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

// Creates a zip archive containing the publish manifest and uncompressed original assets.
// This pack is used as the "previous version" input for Compression Dictionary Transport
// in subsequent builds.
public class GeneratePublishAssetPack : Task
{
[Required]
public string ManifestPath { get; set; }

[Required]
public ITaskItem[] Assets { get; set; }

[Required]
public string PackOutputPath { get; set; }

[Output]
public string GeneratedPackPath { get; set; }

public override bool Execute()
{
try
{
return ExecuteCore();
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, showStackTrace: true, showDetail: true, file: null);
return false;
}
}

private bool ExecuteCore()
{
if (!File.Exists(ManifestPath))
{
Log.LogError("Manifest file '{0}' does not exist.", ManifestPath);
return false;
}

var assets = StaticWebAsset.FromTaskItemGroup(Assets);

var outputDir = Path.GetDirectoryName(Path.GetFullPath(PackOutputPath));
if (!string.IsNullOrEmpty(outputDir))
{
Directory.CreateDirectory(outputDir);
}

// Delete existing pack to avoid stale entries
if (File.Exists(PackOutputPath))
{
File.Delete(PackOutputPath);
}

using var zipStream = new FileStream(PackOutputPath, FileMode.Create, FileAccess.Write);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create);

// Add the manifest
var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
using (var manifestSource = File.OpenRead(ManifestPath))
using (var manifestTarget = manifestEntry.Open())
{
manifestSource.CopyTo(manifestTarget);
}

// Add uncompressed original assets only
var addedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var assetCount = 0;

foreach (var asset in assets)
{
// Skip compressed assets — only include originals
if (!string.IsNullOrEmpty(asset.AssetTraitName) &&
string.Equals(asset.AssetTraitName, "Content-Encoding", StringComparison.Ordinal))
{
Log.LogMessage(
MessageImportance.Low,
"Skipping compressed asset '{0}' from pack.",
asset.Identity);
continue;
}

var relativePath = asset.ComputePathWithoutTokens(asset.RelativePath);
if (string.IsNullOrEmpty(relativePath))
{
Log.LogMessage(
MessageImportance.Low,
"Skipping asset '{0}' with empty relative path.",
asset.Identity);
continue;
}

// Incorporate BasePath to avoid collisions in multi-library apps.
var basePath = asset.BasePath ?? "";
if (basePath.StartsWith("/", StringComparison.Ordinal))
{
basePath = basePath.Substring(1);
}

// Normalize path for zip entry
var entryPath = string.IsNullOrEmpty(basePath)
? "assets/" + relativePath.Replace('\\', '/')
: "assets/" + basePath.Replace('\\', '/') + "/" + relativePath.Replace('\\', '/');

var fileInfo = asset.ResolveFile();

// Avoid duplicate entries
if (!addedPaths.Add(entryPath))
{
Log.LogMessage(
MessageImportance.Low,
"Skipping duplicate asset entry '{0}' for asset '{1}'.",
entryPath,
asset.Identity);
continue;
}

var assetEntry = archive.CreateEntry(entryPath, CompressionLevel.Optimal);
using (var sourceStream = fileInfo.OpenRead())
using (var targetStream = assetEntry.Open())
{
sourceStream.CopyTo(targetStream);
}

assetCount++;
Log.LogMessage(
MessageImportance.Low,
"Added asset '{0}' to pack as '{1}'.",
asset.Identity,
entryPath);
}

GeneratedPackPath = Path.GetFullPath(PackOutputPath);

Log.LogMessage(
"Generated asset pack at '{0}' with {1} assets.",
GeneratedPackPath,
assetCount);

return !Log.HasLoggedErrors;
}
}
Loading
Loading