From d3400f7cb23d83ae93b536a5fc9f46fc2274ce68 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 6 May 2019 18:09:26 -0700 Subject: [PATCH] Fix: #6882 - Explode UseBlazor (#9449) This changes the recipe for client-side blazor to use similar primitives to server side applications. --- I ignored auto-rebuild support because it's currently dead code until we have that in VS. If we add auto-rebuild to ASP.NET Core - we'd probably want to make that a separate gesture inside `IsDevelopement()` like other dev-time features anyway. --- The static files hookup is a special thing because creating the file server for a client-side Blazor app involves some non-trivial work. We plan to make this better in the future. What's nice about this pattern is that the implementation is pretty simple and literal, and it scales fine if you have multiple Blazor client-side apps. I didn't provide a lot of options here, it's pretty much the same as UseBlazor. --- I feel pretty good about the wireup with routing to use the `index.html` from the client app. I think it's pretty to-the-point. --- .../Blazor/DevServer/src/Server/Startup.cs | 19 +- ....AspNetCore.Blazor.Server.netcoreapp3.0.cs | 18 +- .../Blazor/Server/src/BlazorConfig.cs | 27 +++ .../BlazorApplicationBuilderExtensions.cs | 162 --------------- ...azorHostingApplicationBuilderExtensions.cs | 98 +++++++++ ...orHostingEndpointRouteBuilderExtensions.cs | 193 ++++++++++++++++++ .../Server/src/Builder/BlazorOptions.cs | 17 -- .../BlazorHosted-CSharp.Server/Startup.cs | 5 +- .../HostedInAspNet.Server/Startup.cs | 10 +- .../Blazor/testassets/MonoSanity/Startup.cs | 18 +- .../ServerFixtures/AspNetSiteServerFixture.cs | 7 +- .../E2ETest/Tests/ClientSideHostingTest.cs | 77 +++++++ .../test/testassets/TestServer/Program.cs | 8 +- .../test/testassets/TestServer/Startup.cs | 10 +- ...tartupWithMapFallbackToClientSideBlazor.cs | 84 ++++++++ ...rosoft.AspNetCore.Routing.netcoreapp3.0.cs | 1 + .../FallbackEndpointRouteBuilderExtensions.cs | 5 + 17 files changed, 549 insertions(+), 210 deletions(-) delete mode 100644 src/Components/Blazor/Server/src/Builder/BlazorApplicationBuilderExtensions.cs create mode 100644 src/Components/Blazor/Server/src/Builder/BlazorHostingApplicationBuilderExtensions.cs create mode 100644 src/Components/Blazor/Server/src/Builder/BlazorHostingEndpointRouteBuilderExtensions.cs delete mode 100644 src/Components/Blazor/Server/src/Builder/BlazorOptions.cs create mode 100644 src/Components/test/E2ETest/Tests/ClientSideHostingTest.cs create mode 100644 src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs diff --git a/src/Components/Blazor/DevServer/src/Server/Startup.cs b/src/Components/Blazor/DevServer/src/Server/Startup.cs index d5de91abb8..257d6327c6 100644 --- a/src/Components/Blazor/DevServer/src/Server/Startup.cs +++ b/src/Components/Blazor/DevServer/src/Server/Startup.cs @@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Blazor.DevServer.Server public void ConfigureServices(IServiceCollection services) { services.AddRouting(); + services.AddResponseCompression(options => { options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] @@ -30,15 +31,22 @@ namespace Microsoft.AspNetCore.Blazor.DevServer.Server }); } - public void Configure(IApplicationBuilder app, IConfiguration configuration) + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, IConfiguration configuration) { app.UseDeveloperExceptionPage(); app.UseResponseCompression(); EnableConfiguredPathbase(app, configuration); - var clientAssemblyPath = FindClientAssemblyPath(app); - app.UseBlazor(new BlazorOptions { ClientAssemblyPath = clientAssemblyPath }); app.UseBlazorDebugging(); + + app.UseClientSideBlazorFiles(FindClientAssemblyPath(environment)); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor(FindClientAssemblyPath(environment), "index.html"); + }); } private static void EnableConfiguredPathbase(IApplicationBuilder app, IConfiguration configuration) @@ -66,10 +74,9 @@ namespace Microsoft.AspNetCore.Blazor.DevServer.Server } } - private static string FindClientAssemblyPath(IApplicationBuilder app) + private static string FindClientAssemblyPath(IWebHostEnvironment environment) { - var env = app.ApplicationServices.GetRequiredService(); - var contentRoot = env.ContentRootPath; + var contentRoot = environment.ContentRootPath; var binDir = FindClientBinDir(contentRoot); var appName = Path.GetFileName(contentRoot); // TODO: Allow for the possibility that the assembly name has been overridden var assemblyPath = Path.Combine(binDir, $"{appName}.dll"); diff --git a/src/Components/Blazor/Server/ref/Microsoft.AspNetCore.Blazor.Server.netcoreapp3.0.cs b/src/Components/Blazor/Server/ref/Microsoft.AspNetCore.Blazor.Server.netcoreapp3.0.cs index deb4cfda2d..74cf8a6a8b 100644 --- a/src/Components/Blazor/Server/ref/Microsoft.AspNetCore.Blazor.Server.netcoreapp3.0.cs +++ b/src/Components/Blazor/Server/ref/Microsoft.AspNetCore.Blazor.Server.netcoreapp3.0.cs @@ -3,18 +3,20 @@ namespace Microsoft.AspNetCore.Builder { - public static partial class BlazorApplicationBuilderExtensions + public static partial class BlazorHostingApplicationBuilderExtensions { - public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseBlazor(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, Microsoft.AspNetCore.Builder.BlazorOptions options) { throw null; } - public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseBlazor(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) { throw null; } + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseClientSideBlazorFiles(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, string clientAssemblyFilePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseClientSideBlazorFiles(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) { throw null; } + } + public static partial class BlazorHostingEndpointRouteBuilderExtensions + { + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToClientSideBlazor(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string clientAssemblyFilePath, string filePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToClientSideBlazor(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string clientAssemblyFilePath, string pattern, string filePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToClientSideBlazor(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string filePath) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToClientSideBlazor(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, string filePath) { throw null; } } public static partial class BlazorMonoDebugProxyAppBuilderExtensions { public static void UseBlazorDebugging(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) { } } - public partial class BlazorOptions - { - public BlazorOptions() { } - public string ClientAssemblyPath { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } - } } diff --git a/src/Components/Blazor/Server/src/BlazorConfig.cs b/src/Components/Blazor/Server/src/BlazorConfig.cs index 79bf6ac6bb..5438a7e36a 100644 --- a/src/Components/Blazor/Server/src/BlazorConfig.cs +++ b/src/Components/Blazor/Server/src/BlazorConfig.cs @@ -47,5 +47,32 @@ namespace Microsoft.AspNetCore.Blazor.Server EnableAutoRebuilding = configLines.Contains("autorebuild:true", StringComparer.Ordinal); EnableDebugging = configLines.Contains("debug:true", StringComparer.Ordinal); } + + public string FindIndexHtmlFile() + { + // Before publishing, the client project may have a wwwroot directory. + // If so, and if it contains index.html, use that. + if (!string.IsNullOrEmpty(WebRootPath)) + { + var wwwrootIndexHtmlPath = Path.Combine(WebRootPath, "index.html"); + if (File.Exists(wwwrootIndexHtmlPath)) + { + return wwwrootIndexHtmlPath; + } + } + + // After publishing, the client project won't have a wwwroot directory. + // The contents from that dir will have been copied to "dist" during publish. + // So if "dist/index.html" now exists, use that. + var distIndexHtmlPath = Path.Combine(DistPath, "index.html"); + if (File.Exists(distIndexHtmlPath)) + { + return distIndexHtmlPath; + } + + // Since there's no index.html, we'll use the default DefaultPageStaticFileOptions, + // hence we'll look for index.html in the host server app's wwwroot. + return null; + } } } diff --git a/src/Components/Blazor/Server/src/Builder/BlazorApplicationBuilderExtensions.cs b/src/Components/Blazor/Server/src/Builder/BlazorApplicationBuilderExtensions.cs deleted file mode 100644 index 42e0ee2bb8..0000000000 --- a/src/Components/Blazor/Server/src/Builder/BlazorApplicationBuilderExtensions.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Net.Mime; -using Microsoft.AspNetCore.Blazor.Server; -using Microsoft.Extensions.FileProviders; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Hosting; - -namespace Microsoft.AspNetCore.Builder -{ - /// - /// Provides extension methods that add Blazor-related middleware to the ASP.NET pipeline. - /// - public static class BlazorApplicationBuilderExtensions - { - const string DevServerApplicationName = "blazor-devserver"; - - /// - /// Configures the middleware pipeline to work with Blazor. - /// - /// Any type from the client app project. This is used to identify the client app assembly. - /// The . - /// The . - public static IApplicationBuilder UseBlazor( - this IApplicationBuilder app) - { - var clientAssemblyInServerBinDir = typeof(TProgram).Assembly; - return app.UseBlazor(new BlazorOptions - { - ClientAssemblyPath = clientAssemblyInServerBinDir.Location, - }); - } - - /// - /// Configures the middleware pipeline to work with Blazor. - /// - /// The . - /// Options to configure the middleware. - /// The . - public static IApplicationBuilder UseBlazor( - this IApplicationBuilder app, - BlazorOptions options) - { - // TODO: Make the .blazor.config file contents sane - // Currently the items in it are bizarre and don't relate to their purpose, - // hence all the path manipulation here. We shouldn't be hardcoding 'dist' here either. - var env = (IWebHostEnvironment)app.ApplicationServices.GetService(typeof(IWebHostEnvironment)); - var config = BlazorConfig.Read(options.ClientAssemblyPath); - - if (env.IsDevelopment() && config.EnableAutoRebuilding) - { - if (env.ApplicationName.Equals(DevServerApplicationName, StringComparison.OrdinalIgnoreCase)) - { - app.UseDevServerAutoRebuild(config); - } - else - { - app.UseHostedAutoRebuild(config, env.ContentRootPath); - } - } - - // First, match the request against files in the client app dist directory - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(config.DistPath), - ContentTypeProvider = CreateContentTypeProvider(config.EnableDebugging), - OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders, - }); - - // * Before publishing, we serve the wwwroot files directly from source - // (and don't require them to be copied into dist). - // In this case, WebRootPath will be nonempty if that directory exists. - // * After publishing, the wwwroot files are already copied to 'dist' and - // will be served by the above middleware, so we do nothing here. - // In this case, WebRootPath will be empty (the publish process sets this). - if (!string.IsNullOrEmpty(config.WebRootPath)) - { - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(config.WebRootPath), - OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders, - }); - } - - // Finally, use SPA fallback routing (serve default page for anything else, - // excluding /_framework/*) - app.MapWhen(IsNotFrameworkDir, childAppBuilder => - { - var indexHtmlPath = FindIndexHtmlFile(config); - var indexHtmlStaticFileOptions = string.IsNullOrEmpty(indexHtmlPath) - ? null : new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(Path.GetDirectoryName(indexHtmlPath)), - OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders, - }; - - childAppBuilder.UseSpa(spa => - { - spa.Options.DefaultPageStaticFileOptions = indexHtmlStaticFileOptions; - }); - }); - - return app; - } - - private static string FindIndexHtmlFile(BlazorConfig config) - { - // Before publishing, the client project may have a wwwroot directory. - // If so, and if it contains index.html, use that. - if (!string.IsNullOrEmpty(config.WebRootPath)) - { - var wwwrootIndexHtmlPath = Path.Combine(config.WebRootPath, "index.html"); - if (File.Exists(wwwrootIndexHtmlPath)) - { - return wwwrootIndexHtmlPath; - } - } - - // After publishing, the client project won't have a wwwroot directory. - // The contents from that dir will have been copied to "dist" during publish. - // So if "dist/index.html" now exists, use that. - var distIndexHtmlPath = Path.Combine(config.DistPath, "index.html"); - if (File.Exists(distIndexHtmlPath)) - { - return distIndexHtmlPath; - } - - // Since there's no index.html, we'll use the default DefaultPageStaticFileOptions, - // hence we'll look for index.html in the host server app's wwwroot. - return null; - } - - private static bool IsNotFrameworkDir(HttpContext context) - => !context.Request.Path.StartsWithSegments("/_framework"); - - private static IContentTypeProvider CreateContentTypeProvider(bool enableDebugging) - { - var result = new FileExtensionContentTypeProvider(); - AddMapping(result, ".dll", MediaTypeNames.Application.Octet); - - if (enableDebugging) - { - AddMapping(result, ".pdb", MediaTypeNames.Application.Octet); - } - - return result; - } - - private static void AddMapping(FileExtensionContentTypeProvider provider, string name, string mimeType) - { - if (!provider.Mappings.ContainsKey(name)) - { - provider.Mappings.Add(name, mimeType); - } - } - } -} diff --git a/src/Components/Blazor/Server/src/Builder/BlazorHostingApplicationBuilderExtensions.cs b/src/Components/Blazor/Server/src/Builder/BlazorHostingApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..74cb3c7b21 --- /dev/null +++ b/src/Components/Blazor/Server/src/Builder/BlazorHostingApplicationBuilderExtensions.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net.Mime; +using Microsoft.AspNetCore.Blazor.Server; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Provides extension methods for hosting client-side Blazor applications in ASP.NET Core. + /// + public static class BlazorHostingApplicationBuilderExtensions + { + /// + /// Adds a that will serve static files from the client-side Blazor application + /// specified by . + /// + /// A type in the client-side application. + /// The . + /// The . + public static IApplicationBuilder UseClientSideBlazorFiles(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + UseClientSideBlazorFiles(app, typeof(TClientApp).Assembly.Location); + return app; + } + + /// + /// Adds a that will serve static files from the client-side Blazor application + /// specified by . + /// + /// The file path of the client-side Blazor application assembly. + /// The . + /// The . + public static IApplicationBuilder UseClientSideBlazorFiles(this IApplicationBuilder app, string clientAssemblyFilePath) + { + if (clientAssemblyFilePath == null) + { + throw new ArgumentNullException(nameof(clientAssemblyFilePath)); + } + + var fileProviders = new List(); + + // TODO: Make the .blazor.config file contents sane + // Currently the items in it are bizarre and don't relate to their purpose, + // hence all the path manipulation here. We shouldn't be hardcoding 'dist' here either. + var config = BlazorConfig.Read(clientAssemblyFilePath); + + // First, match the request against files in the client app dist directory + fileProviders.Add(new PhysicalFileProvider(config.DistPath)); + + // * Before publishing, we serve the wwwroot files directly from source + // (and don't require them to be copied into dist). + // In this case, WebRootPath will be nonempty if that directory exists. + // * After publishing, the wwwroot files are already copied to 'dist' and + // will be served by the above middleware, so we do nothing here. + // In this case, WebRootPath will be empty (the publish process sets this). + if (!string.IsNullOrEmpty(config.WebRootPath)) + { + fileProviders.Add(new PhysicalFileProvider(config.WebRootPath)); + } + + // We can't modify an IFileContentTypeProvider, so we have to decorate. + var contentTypeProvider = new FileExtensionContentTypeProvider(); + AddMapping(contentTypeProvider, ".dll", MediaTypeNames.Application.Octet); + if (config.EnableDebugging) + { + AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet); + } + + var options = new StaticFileOptions() + { + ContentTypeProvider = contentTypeProvider, + FileProvider = new CompositeFileProvider(fileProviders), + OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders, + }; + + app.UseStaticFiles(options); + return app; + + static void AddMapping(FileExtensionContentTypeProvider provider, string name, string mimeType) + { + if (!provider.Mappings.ContainsKey(name)) + { + provider.Mappings.Add(name, mimeType); + } + } + } + } +} diff --git a/src/Components/Blazor/Server/src/Builder/BlazorHostingEndpointRouteBuilderExtensions.cs b/src/Components/Blazor/Server/src/Builder/BlazorHostingEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..f899f2576f --- /dev/null +++ b/src/Components/Blazor/Server/src/Builder/BlazorHostingEndpointRouteBuilderExtensions.cs @@ -0,0 +1,193 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.AspNetCore.Blazor.Server; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Provides extension methods for hosting client-side Blazor applications in ASP.NET Core. + /// + public static class BlazorHostingEndpointRouteBuilderExtensions + { + /// + /// Adds a low-priority endpoint that will serve the the file specified by from the client-side + /// Blazor application specified by . + /// + /// A type in the client-side application. + /// The . + /// + /// The relative path to the entry point of the client-side application. The path is relative to the + /// , commonly wwwroot. + /// + /// The . + /// + /// + /// This method is intended to handle cases where URL path of the request does not contain a filename, and no other + /// endpoint has matched. This is convenient for routing requests for dynamic content to the client-side blazor + /// application, while also allowing requests for non-existent files to result in an HTTP 404. + /// + /// + public static IEndpointConventionBuilder MapFallbackToClientSideBlazor(this IEndpointRouteBuilder endpoints, string filePath) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return MapFallbackToClientSideBlazor(endpoints, typeof(TClientApp).Assembly.Location, FallbackEndpointRouteBuilderExtensions.DefaultPattern, filePath); + } + + /// + /// Adds a low-priority endpoint that will serve the the file specified by from the client-side + /// Blazor application specified by . + /// + /// The . + /// The file path of the client-side Blazor application assembly. + /// + /// The relative path to the entry point of the client-side application. The path is relative to the + /// , commonly wwwroot. + /// + /// The . + /// + /// + /// This method is intended to handle cases where URL path of the request does not contain a filename, and no other + /// endpoint has matched. This is convenient for routing requests for dynamic content to the client-side blazor + /// application, while also allowing requests for non-existent files to result in an HTTP 404. + /// + /// + public static IEndpointConventionBuilder MapFallbackToClientSideBlazor(this IEndpointRouteBuilder endpoints, string clientAssemblyFilePath, string filePath) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (clientAssemblyFilePath == null) + { + throw new ArgumentNullException(nameof(clientAssemblyFilePath)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return MapFallbackToClientSideBlazor(endpoints, clientAssemblyFilePath, FallbackEndpointRouteBuilderExtensions.DefaultPattern, filePath); + } + + /// + /// Adds a low-priority endpoint that will serve the the file specified by from the client-side + /// Blazor application specified by . + /// + /// A type in the client-side application. + /// The . + /// The route pattern to match. + /// + /// The relative path to the entry point of the client-side application. The path is relative to the + /// , commonly wwwroot. + /// + /// The . + /// + /// + /// This method is intended to handle cases where URL path of the request does not contain a filename, and no other + /// endpoint has matched. This is convenient for routing requests for dynamic content to the client-side blazor + /// application, while also allowing requests for non-existent files to result in an HTTP 404. + /// + /// + public static IEndpointConventionBuilder MapFallbackToClientSideBlazor(this IEndpointRouteBuilder endpoints, string pattern, string filePath) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + return MapFallbackToClientSideBlazor(endpoints, typeof(TClientApp).Assembly.Location, pattern, filePath); + } + + /// + /// Adds a low-priority endpoint that will serve the the file specified by from the client-side + /// Blazor application specified by . + /// + /// The file path of the client-side Blazor application assembly. + /// The . + /// The route pattern to match. + /// + /// The relative path to the entry point of the client-side application. The path is relative to the + /// , commonly wwwroot. + /// + /// The . + /// + /// + /// This method is intended to handle cases where URL path of the request does not contain a filename, and no other + /// endpoint has matched. This is convenient for routing requests for dynamic content to the client-side blazor + /// application, while also allowing requests for non-existent files to result in an HTTP 404. + /// + /// + public static IEndpointConventionBuilder MapFallbackToClientSideBlazor(this IEndpointRouteBuilder endpoints, string clientAssemblyFilePath, string pattern, string filePath) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (clientAssemblyFilePath == null) + { + throw new ArgumentNullException(nameof(clientAssemblyFilePath)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + var config = BlazorConfig.Read(clientAssemblyFilePath); + + // We want to serve "index.html" from whichever directory contains it in this priority order: + // 1. Client app "dist" directory + // 2. Client app "wwwroot" directory + // 3. Server app "wwwroot" directory + var directory = endpoints.ServiceProvider.GetRequiredService().WebRootPath; + var indexHtml = config.FindIndexHtmlFile(); + if (indexHtml != null) + { + directory = Path.GetDirectoryName(indexHtml); + } + + var options = new StaticFileOptions() + { + FileProvider = new PhysicalFileProvider(directory), + OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders, + }; + + return endpoints.MapFallbackToFile(pattern, filePath, options); + } + } +} diff --git a/src/Components/Blazor/Server/src/Builder/BlazorOptions.cs b/src/Components/Blazor/Server/src/Builder/BlazorOptions.cs deleted file mode 100644 index 2b60b1a0de..0000000000 --- a/src/Components/Blazor/Server/src/Builder/BlazorOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -namespace Microsoft.AspNetCore.Builder -{ - /// - /// Provides configuration options to the - /// middleware. - /// - public class BlazorOptions - { - /// - /// Full path to the client assembly. - /// - public string ClientAssemblyPath { get; set; } - } -} diff --git a/src/Components/Blazor/Templates/src/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs b/src/Components/Blazor/Templates/src/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs index 6dfe8c1bb2..acb05b6294 100644 --- a/src/Components/Blazor/Templates/src/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs +++ b/src/Components/Blazor/Templates/src/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs @@ -33,14 +33,15 @@ namespace BlazorHosted_CSharp.Server app.UseBlazorDebugging(); } + app.UseBlazorClientSideFiles(); + app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapDefaultControllerRoute(); + endpoints.MapFallbackToClientSideBlazor("index.html"); }); - - app.UseBlazor(); } } } diff --git a/src/Components/Blazor/testassets/HostedInAspNet.Server/Startup.cs b/src/Components/Blazor/testassets/HostedInAspNet.Server/Startup.cs index 87b023f95b..24e99f9691 100644 --- a/src/Components/Blazor/testassets/HostedInAspNet.Server/Startup.cs +++ b/src/Components/Blazor/testassets/HostedInAspNet.Server/Startup.cs @@ -25,7 +25,15 @@ namespace HostedInAspNet.Server app.UseBlazorDebugging(); } - app.UseBlazor(); + app.UseStaticFiles(); + app.UseClientSideBlazorFiles(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor("index.html"); + }); } } } diff --git a/src/Components/Blazor/testassets/MonoSanity/Startup.cs b/src/Components/Blazor/testassets/MonoSanity/Startup.cs index 6457a5fce2..84614a890a 100644 --- a/src/Components/Blazor/testassets/MonoSanity/Startup.cs +++ b/src/Components/Blazor/testassets/MonoSanity/Startup.cs @@ -2,17 +2,27 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; namespace MonoSanity { public class Startup { - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + public void ConfigureServices(IServiceCollection services) + { + } + + public void Configure(IApplicationBuilder app) { app.UseDeveloperExceptionPage(); - app.UseFileServer(new FileServerOptions { EnableDefaultFiles = true }); - app.UseBlazor(); + app.UseFileServer(new FileServerOptions() { EnableDefaultFiles = true, }); + app.UseStaticFiles(); + app.UseClientSideBlazorFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor("index.html"); + }); } } } diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs index 7d3e3c3ad8..dfadf38985 100644 --- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs +++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Reflection; using Microsoft.AspNetCore.Hosting; namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures @@ -11,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures { public delegate IWebHost BuildWebHost(string[] args); + public Assembly ApplicationAssembly { get; set; } + public BuildWebHost BuildWebHostMethod { get; set; } public AspNetEnvironment Environment { get; set; } = AspNetEnvironment.Production; @@ -23,8 +26,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures $"No value was provided for {nameof(BuildWebHostMethod)}"); } - var sampleSitePath = FindSampleOrTestSitePath( - BuildWebHostMethod.Method.DeclaringType.Assembly.FullName); + var assembly = ApplicationAssembly ?? BuildWebHostMethod.Method.DeclaringType.Assembly; + var sampleSitePath = FindSampleOrTestSitePath(assembly.FullName); return BuildWebHostMethod(new[] { diff --git a/src/Components/test/E2ETest/Tests/ClientSideHostingTest.cs b/src/Components/test/E2ETest/Tests/ClientSideHostingTest.cs new file mode 100644 index 0000000000..3097d06dca --- /dev/null +++ b/src/Components/test/E2ETest/Tests/ClientSideHostingTest.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using Microsoft.AspNetCore.Hosting; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + /// + /// Tests for various MapFallbackToClientSideBlazor overloads. We're just verifying that things render correctly. + /// That means that routing and file serving is working for the startup pattern under test. + /// + public class ClientSideHostingTest : ServerTestBase + { + public ClientSideHostingTest( + BrowserFixture browserFixture, + AspNetSiteServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + serverFixture.ApplicationAssembly = typeof(TestServer.Program).Assembly; + serverFixture.BuildWebHostMethod = BuildWebHost; + } + + private IWebHost BuildWebHost(string[] args) + { + return TestServer.Program.BuildWebHost(args); + } + + [Fact] + public void MapFallbackToClientSideBlazor_FilePath() + { + Navigate("/filepath"); + WaitUntilLoaded(); + Assert.NotNull(Browser.FindElement(By.Id("test-selector"))); + } + + [Fact] + public void MapFallbackToClientSideBlazor_Pattern_FilePath() + { + Navigate("/pattern_filepath/test"); + WaitUntilLoaded(); + Assert.NotNull(Browser.FindElement(By.Id("test-selector"))); + } + + [Fact] + public void MapFallbackToClientSideBlazor_AssemblyPath_FilePath() + { + Navigate("/assemblypath_filepath"); + WaitUntilLoaded(); + Assert.NotNull(Browser.FindElement(By.Id("test-selector"))); + } + + [Fact] + public void MapFallbackToClientSideBlazor_AssemblyPath_Pattern_FilePath() + { + Navigate("/assemblypath_pattern_filepath/test"); + WaitUntilLoaded(); + Assert.NotNull(Browser.FindElement(By.Id("test-selector"))); + } + + + private void WaitUntilLoaded() + { + new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until( + driver => driver.FindElement(By.TagName("app")).Text != "Loading..."); + } + } +} diff --git a/src/Components/test/testassets/TestServer/Program.cs b/src/Components/test/testassets/TestServer/Program.cs index aaad4cef42..fc2a4d4d2e 100644 --- a/src/Components/test/testassets/TestServer/Program.cs +++ b/src/Components/test/testassets/TestServer/Program.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -11,12 +11,14 @@ namespace TestServer BuildWebHost(args).Run(); } - public static IWebHost BuildWebHost(string[] args) => + public static IWebHost BuildWebHost(string[] args) => BuildWebHost(args); + + public static IWebHost BuildWebHost(string[] args) where TStartup : class => WebHost.CreateDefaultBuilder(args) .UseConfiguration(new ConfigurationBuilder() .AddCommandLine(args) .Build()) - .UseStartup() + .UseStartup() .Build(); } } diff --git a/src/Components/test/testassets/TestServer/Startup.cs b/src/Components/test/testassets/TestServer/Startup.cs index 1e06fad17e..18acb716a6 100644 --- a/src/Components/test/testassets/TestServer/Startup.cs +++ b/src/Components/test/testassets/TestServer/Startup.cs @@ -49,11 +49,12 @@ namespace TestServer .AllowCredentials(); }); - app.UseRouting(); // Mount the server-side Blazor app on /subdir app.Map("/subdir", subdirApp => { + subdirApp.UseClientSideBlazorFiles(); + // The following two lines are equivalent to: // endpoints.MapComponentsHub(); // @@ -66,13 +67,12 @@ namespace TestServer subdirApp.UseEndpoints(endpoints => { endpoints.MapHub(ComponentHub.DefaultPath).AddComponent(typeof(Index), selector: "root"); + endpoints.MapFallbackToClientSideBlazor("index.html"); }); - - subdirApp.MapWhen( - ctx => ctx.Features.Get()?.Endpoint == null, - blazorBuilder => blazorBuilder.UseBlazor()); }); + app.UseRouting(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs b/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs new file mode 100644 index 0000000000..483dd00d78 --- /dev/null +++ b/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicTestApp; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace TestServer +{ + // Used for E2E tests that verify different overloads of MapFallbackToClientSideBlazor. + public class StartupWithMapFallbackToClientSideBlazor + { + public StartupWithMapFallbackToClientSideBlazor(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + // The client-side files middleware needs to be here because the base href in hardcoded to /subdir/ + app.Map("/subdir", app => + { + app.UseClientSideBlazorFiles(); + }); + + // The calls to `Map` allow us to test each of these overloads, while keeping them isolated. + app.Map("/filepath", app => + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor("index.html"); + }); + }); + + app.Map("/pattern_filepath", app => + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor("test/{*path:nonfile}", "index.html"); + }); + }); + + app.Map("/assemblypath_filepath", app => + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Startup).Assembly.Location, "index.html"); + }); + }); + + app.Map("/assemblypath_pattern_filepath", app => + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Startup).Assembly.Location, "test/{*path:nonfile}", "index.html"); + }); + }); + } + } +} diff --git a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs index 5dae735d85..fc0a07386f 100644 --- a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs +++ b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs @@ -21,6 +21,7 @@ namespace Microsoft.AspNetCore.Builder } public static partial class FallbackEndpointRouteBuilderExtensions { + public static readonly string DefaultPattern; public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallback(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, Microsoft.AspNetCore.Http.RequestDelegate requestDelegate) { throw null; } public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallback(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, Microsoft.AspNetCore.Http.RequestDelegate requestDelegate) { throw null; } } diff --git a/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs index 354d0e400e..ac907a1f5b 100644 --- a/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs @@ -12,6 +12,11 @@ namespace Microsoft.AspNetCore.Builder /// public static class FallbackEndpointRouteBuilderExtensions { + /// + /// The default route pattern used by fallback routing. {*path:nonfile} + /// + public static readonly string DefaultPattern = "{*path:nonfile}"; + /// /// Adds a specialized to the that will match /// requests for non-file-names with the lowest possible priority.