Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Options bind from the `ViteManifest` section of `appsettings.json`:
| `DefaultEntry` | `index.html` | The logical entry assumed by the parameterless service getters. |
| `DefaultBasePath` | `/` | The base path hashed assets are served from when none is supplied. |

Registration overloads (`ViteServiceExtensions`):
Registration overloads (`ViteServiceCollectionExtensions`):

```csharp
// Defaults only
Expand Down Expand Up @@ -141,10 +141,38 @@ Returned URLs are prefixed with the supplied base path. Pass `""` as the base pa
| `GetCssFiles()` / `(entry, basePath)` / `(ViteManifestEntry, basePath)` | `IReadOnlyList<string>`| Resolved CSS URLs. |
| `GetJsFile()` / `(entry, basePath)` / `(ViteManifestEntry, basePath)` | `string?` | Resolved JS URL. |
| `GetModulePreloadFiles(ViteManifestEntry, basePath)` | `IReadOnlyList<string>`| Transitively-imported chunk URLs (for modulepreload). |
| `TryResolveHashedAsset(requestPath, out hashedPath)` | `bool` | Resolve an unhashed asset path to its hashed build output. |
| `RenderCss(entry, basePath, preload)` | `IHtmlContent` | CSS `<link>` tags only. |
| `RenderJs(entry, basePath)` | `IHtmlContent` | `<link rel="modulepreload">` for imported chunks + the module `<script>` tag. |
| `RenderEntry(entry, basePath, preloadCss, assets, devServer)` | `IHtmlContent` | Full render; handles dev-server + not-found cases. |

## Redirecting unhashed asset requests

Vite fingerprints its output (`main-COZv9l4K.css`), which is great for caching but awkward when
something needs to reference an asset by a stable, unhashed name (`/assets/main.css`). Register the
redirect middleware and those requests are 302-redirected to the current hashed file:

```csharp
app.UseViteUnhashedAssetRedirects(); // register BEFORE app.UseStaticFiles()
app.UseStaticFiles();
```

Registering the middleware is the only switch — there is no config flag. Leave the call out and the
feature is off (and its manifest lookup is never built).

- The unhashed → hashed lookup is derived once from the manifest (every entry/chunk JS file and
all CSS, including imported-chunk CSS), so it always tracks the latest build.
- A **302** (never 301) is used because the hashed target changes every build — the unhashed URL
must not be cached permanently.
- The middleware only acts on GET/HEAD requests whose path is a known unhashed asset; everything
else passes straight through.
- Dehashing recognises Vite's default `[name]-[hash].[ext]` pattern (hash ≥ 8 chars). If two
build outputs dehash to the same unhashed name the first wins and a warning is logged — an
ambiguous redirect is never emitted.

The service also exposes `TryResolveHashedAsset(requestPath, out hashedPath)` if you need the same
resolution outside the middleware.

## Development mode

When the environment is `Development` **and** a `dev-server` is supplied, `RenderEntry` skips the manifest entirely and points straight at the Vite dev server for HMR:
Expand Down
44 changes: 32 additions & 12 deletions Vite.dotnet.Tests/ManifestTestContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Vite.Configuration;
using Vite.Services;
Expand All @@ -16,29 +16,32 @@ namespace Vite.dotnet.Tests;
public sealed class ManifestTestContext : IDisposable
{
private readonly string _webRoot;
private readonly CapturingLogger _logger;

private ManifestTestContext(string webRoot, ViteManifestService service)
private ManifestTestContext(string webRoot, ViteManifestService service, CapturingLogger logger)
{
_webRoot = webRoot;
_logger = logger;
Service = service;
}

public ViteManifestService Service { get; }

/// <summary>Warning-level (and above) log messages captured from the service.</summary>
public IReadOnlyList<string> Warnings => _logger.Warnings;

public static ManifestTestContext Create(string manifestJson, ViteManifestOptions? options = null)
{
var webRoot = Path.Combine(Path.GetTempPath(), "vite-dotnet-tests", Guid.NewGuid().ToString("N"));
var viteDir = Path.Combine(webRoot, ".vite");
Directory.CreateDirectory(viteDir);
File.WriteAllText(Path.Combine(viteDir, "manifest.json"), manifestJson);

var logger = new CapturingLogger();
var env = new FakeWebHostEnvironment { WebRootPath = webRoot };
var service = new ViteManifestService(
env,
NullLogger<ViteManifestService>.Instance,
Options.Create(options ?? new ViteManifestOptions()));
var service = new ViteManifestService(env, logger, Options.Create(options ?? new ViteManifestOptions()));

return new ManifestTestContext(webRoot, service);
return new ManifestTestContext(webRoot, service, logger);
}

/// <summary>Creates a context whose manifest file does not exist on disk.</summary>
Expand All @@ -47,13 +50,11 @@ public static ManifestTestContext CreateWithoutManifest(ViteManifestOptions? opt
var webRoot = Path.Combine(Path.GetTempPath(), "vite-dotnet-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(webRoot);

var logger = new CapturingLogger();
var env = new FakeWebHostEnvironment { WebRootPath = webRoot };
var service = new ViteManifestService(
env,
NullLogger<ViteManifestService>.Instance,
Options.Create(options ?? new ViteManifestOptions()));
var service = new ViteManifestService(env, logger, Options.Create(options ?? new ViteManifestOptions()));

return new ManifestTestContext(webRoot, service);
return new ManifestTestContext(webRoot, service, logger);
}

public void Dispose()
Expand All @@ -71,6 +72,25 @@ public void Dispose()
}
}

private sealed class CapturingLogger : ILogger<ViteManifestService>
{
private readonly List<string> _warnings = [];

public IReadOnlyList<string> Warnings => _warnings;

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel >= LogLevel.Warning)
{
_warnings.Add(formatter(state, exception));
}
}
}

private sealed class FakeWebHostEnvironment : IWebHostEnvironment
{
public string WebRootPath { get; set; } = "";
Expand Down
184 changes: 184 additions & 0 deletions Vite.dotnet.Tests/UnhashedAssetRedirectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using Microsoft.AspNetCore.Http;
using Vite;
using Vite.Configuration;
using Xunit;

namespace Vite.dotnet.Tests;

public class UnhashedAssetRedirectTests
{
private const string Manifest = """
{
"_client.gen-WrdE9yCN.js": {
"file": "assets/client.gen-WrdE9yCN.js",
"name": "client.gen",
"css": ["assets/client-CYzq3z7c.css"]
},
"index.html": {
"file": "assets/main-espf9ZVg.js",
"name": "main",
"isEntry": true,
"imports": ["_client.gen-WrdE9yCN.js"],
"css": ["assets/main-COZv9l4K.css"]
}
}
""";

private static ViteManifestOptions WithBasePath(string basePath) =>
new() { DefaultBasePath = basePath };

[Fact]
public void TryResolveHashedAsset_MapsUnhashedJsEntryToHashedFile()
{
using var ctx = ManifestTestContext.Create(Manifest);

Assert.True(ctx.Service.TryResolveHashedAsset("/assets/main.js", out var hashed));
Assert.Equal("/assets/main-espf9ZVg.js", hashed);
}

[Fact]
public void TryResolveHashedAsset_MapsUnhashedCssToHashedFile()
{
using var ctx = ManifestTestContext.Create(Manifest);

Assert.True(ctx.Service.TryResolveHashedAsset("/assets/main.css", out var hashed));
Assert.Equal("/assets/main-COZv9l4K.css", hashed);
}

[Fact]
public void TryResolveHashedAsset_MapsCssFromImportedChunk()
{
using var ctx = ManifestTestContext.Create(Manifest);

Assert.True(ctx.Service.TryResolveHashedAsset("/assets/client.css", out var hashed));
Assert.Equal("/assets/client-CYzq3z7c.css", hashed);
}

[Fact]
public void TryResolveHashedAsset_KeepsNamesContainingDots()
{
using var ctx = ManifestTestContext.Create(Manifest);

Assert.True(ctx.Service.TryResolveHashedAsset("/assets/client.gen.js", out var hashed));
Assert.Equal("/assets/client.gen-WrdE9yCN.js", hashed);
}

[Fact]
public void TryResolveHashedAsset_AppliesConfiguredBasePath()
{
using var ctx = ManifestTestContext.Create(Manifest, WithBasePath("/dist/"));

Assert.True(ctx.Service.TryResolveHashedAsset("/dist/assets/main.css", out var hashed));
Assert.Equal("/dist/assets/main-COZv9l4K.css", hashed);
}

[Fact]
public void TryResolveHashedAsset_ReturnsFalseForUnknownPath()
{
using var ctx = ManifestTestContext.Create(Manifest);

Assert.False(ctx.Service.TryResolveHashedAsset("/assets/nope.css", out _));
}

[Fact]
public void TryResolveHashedAsset_DoesNotMatchTheHashedPathItself()
{
using var ctx = ManifestTestContext.Create(Manifest);

// The already-hashed file is served by static files, not redirected.
Assert.False(ctx.Service.TryResolveHashedAsset("/assets/main-COZv9l4K.css", out _));
}

[Fact]
public void BuildMap_SkipsAndWarnsOnAmbiguousCollision()
{
// Two hashed files in the same folder that dehash to the same unhashed name.
const string manifest = """
{
"a.html": { "file": "assets/app-AAAAAAAA.js", "isEntry": true },
"b.html": { "file": "assets/app-BBBBBBBB.js", "isEntry": true }
}
""";
using var ctx = ManifestTestContext.Create(manifest);

Assert.True(ctx.Service.TryResolveHashedAsset("/assets/app.js", out var hashed));
// First-seen wins; exactly one of the two, never both.
Assert.Contains(hashed, new[] { "/assets/app-AAAAAAAA.js", "/assets/app-BBBBBBBB.js" });
Assert.Contains(ctx.Warnings, w => w.Contains("Ambiguous unhashed asset"));
}

[Fact]
public void BuildMap_IgnoresFilesWithoutAHashSegment()
{
const string manifest = """
{ "index.html": { "file": "assets/plain.js", "isEntry": true, "css": ["assets/plain.css"] } }
""";
using var ctx = ManifestTestContext.Create(manifest);

Assert.False(ctx.Service.TryResolveHashedAsset("/assets/plain.js", out _));
Assert.False(ctx.Service.TryResolveHashedAsset("/assets/plain.css", out _));
}

[Fact]
public async Task Middleware_Redirects302ToHashedFile()
{
using var ctx = ManifestTestContext.Create(Manifest);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/assets/main.css";
var nextCalled = false;

var middleware = new ViteUnhashedAssetRedirectMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, ctx.Service);
await middleware.InvokeAsync(context);

Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
Assert.Equal("/assets/main-COZv9l4K.css", context.Response.Headers.Location);
}

[Fact]
public async Task Middleware_PreservesQueryString()
{
using var ctx = ManifestTestContext.Create(Manifest);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/assets/main.css";
context.Request.QueryString = new QueryString("?v=1");

var middleware = new ViteUnhashedAssetRedirectMiddleware(_ => Task.CompletedTask, ctx.Service);
await middleware.InvokeAsync(context);

Assert.Equal("/assets/main-COZv9l4K.css?v=1", context.Response.Headers.Location);
}

[Fact]
public async Task Middleware_PassesThroughUnknownPath()
{
using var ctx = ManifestTestContext.Create(Manifest);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/assets/unknown.css";
var nextCalled = false;

var middleware = new ViteUnhashedAssetRedirectMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, ctx.Service);
await middleware.InvokeAsync(context);

Assert.True(nextCalled);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}

[Fact]
public async Task Middleware_PassesThroughNonGetRequests()
{
using var ctx = ManifestTestContext.Create(Manifest);
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Post;
context.Request.Path = "/assets/main.css";
var nextCalled = false;

var middleware = new ViteUnhashedAssetRedirectMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, ctx.Service);
await middleware.InvokeAsync(context);

Assert.True(nextCalled);
}
}
75 changes: 75 additions & 0 deletions Vite.dotnet/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using Vite;
using Vite.Configuration;
using Vite.Services;

// Extension methods are placed in the namespace of the type they extend so they surface
// without an extra `using`: service registrations under Microsoft.Extensions.DependencyInjection,
// middleware registration under Microsoft.AspNetCore.Builder.

namespace Microsoft.Extensions.DependencyInjection
{
public static class ViteServiceCollectionExtensions
{
extension(IServiceCollection services)
{
/// <summary>
/// Registers the Vite manifest service used by the &lt;vite&gt; tag helper.
/// Registered as a singleton so the manifest is read and parsed once and
/// cached for the lifetime of the application.
/// </summary>
/// <param name="configure">Optional configuration of <see cref="ViteManifestOptions"/> (e.g. the default entry and base path).</param>
public IServiceCollection AddViteManifest(Action<ViteManifestOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}

return services.AddViteManifestCore();
}

/// <summary>
/// Registers the Vite manifest service, binding <see cref="ViteManifestOptions"/> from the
/// "<see cref="ViteManifestOptions.SectionName"/>" configuration section (e.g. appsettings.json).
/// </summary>
/// <param name="configuration">Application configuration to bind options from.</param>
/// <param name="configure">Optional code-based overrides applied on top of the bound configuration.</param>
public IServiceCollection AddViteManifest(IConfiguration configuration, Action<ViteManifestOptions>? configure = null)
{
services.Configure<ViteManifestOptions>(configuration.GetSection(ViteManifestOptions.SectionName));

if (configure is not null)
{
services.Configure(configure);
}

return services.AddViteManifestCore();
}

private IServiceCollection AddViteManifestCore()
{
services.AddSingleton<IViteManifestService, ViteManifestService>();
return services;
}
}
}
}

namespace Microsoft.AspNetCore.Builder
{
public static class ViteApplicationBuilderExtensions
{
extension(IApplicationBuilder app)
{
/// <summary>
/// Adds middleware that 302-redirects unhashed asset requests (e.g. <c>/assets/main.css</c>)
/// to their current hashed build output. Registering this call is what enables the feature.
/// Register it before <c>UseStaticFiles</c> so unhashed paths are redirected rather than
/// falling through to a 404.
/// </summary>
public IApplicationBuilder UseViteUnhashedAssetRedirects()
=> app.UseMiddleware<ViteUnhashedAssetRedirectMiddleware>();
}
}
}
Loading
Loading