From ee62bd8d4509860c577d5cf9d2215c3ddad92a96 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Fri, 22 Jun 2018 16:23:43 -0700 Subject: [PATCH] Add startup for client/mono Blazor This change adds a host builder, and the startup pattern for client-side Blazor apps running in mono/wasm. This will help us align better with server side Blazor. --- samples/StandaloneApp/Program.cs | 16 +- samples/StandaloneApp/Startup.cs | 20 ++ .../BlazorApplicationBuilderExtensions.cs | 26 +++ .../Builder/IBlazorApplicationBuilder.cs | 27 +++ .../WebAssemblyBlazorApplicationBuilder.cs | 50 +++++ .../Hosting/BlazorWebAssemblyHost.cs | 20 ++ .../Hosting/ConventionBasedStartup.cs | 117 ++++++++++ .../Hosting/IBlazorStartup.cs | 16 ++ .../Hosting/IWebAssemblyHost.cs | 34 +++ .../Hosting/IWebAssemblyHostBuilder.cs | 34 +++ .../Hosting/WebAssemblyHost.cs | 101 +++++++++ .../Hosting/WebAssemblyHostBuilder.cs | 92 ++++++++ .../Hosting/WebAssemblyHostBuilderContext.cs | 27 +++ .../WebAssemblyHostBuilderExtensions.cs | 73 ++++++ .../Hosting/WebAssemblyHostExtensions.cs | 25 +++ .../Microsoft.AspNetCore.Blazor.csproj | 2 +- .../Hosting/ConventionBasedStartupTest.cs | 209 ++++++++++++++++++ .../Hosting/WebAssemblyHostBuilderTest.cs | 80 +++++++ .../Hosting/WebAssemblyHostTest.cs | 81 +++++++ 19 files changed, 1040 insertions(+), 10 deletions(-) create mode 100644 samples/StandaloneApp/Startup.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Builder/BlazorApplicationBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Builder/IBlazorApplicationBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Builder/WebAssemblyBlazorApplicationBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/BlazorWebAssemblyHost.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/ConventionBasedStartup.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IBlazorStartup.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHost.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHostBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHost.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderContext.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostExtensions.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/ConventionBasedStartupTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostBuilderTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostTest.cs diff --git a/samples/StandaloneApp/Program.cs b/samples/StandaloneApp/Program.cs index dea7a06e10..530de72870 100644 --- a/samples/StandaloneApp/Program.cs +++ b/samples/StandaloneApp/Program.cs @@ -1,8 +1,7 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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 Microsoft.AspNetCore.Blazor.Browser.Rendering; -using Microsoft.AspNetCore.Blazor.Browser.Services; +using Microsoft.AspNetCore.Blazor.Hosting; namespace StandaloneApp { @@ -10,12 +9,11 @@ namespace StandaloneApp { public static void Main(string[] args) { - var serviceProvider = new BrowserServiceProvider(configure => - { - // Add any custom services here - }); - - new BrowserRenderer(serviceProvider).AddComponent("app"); + CreateHostBuilder(args).Build().Run(); } + + public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) => + BlazorWebAssemblyHost.CreateDefaultBuilder() + .UseBlazorStartup(); } } diff --git a/samples/StandaloneApp/Startup.cs b/samples/StandaloneApp/Startup.cs new file mode 100644 index 0000000000..91d4e2dcd2 --- /dev/null +++ b/samples/StandaloneApp/Startup.cs @@ -0,0 +1,20 @@ +// 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 Microsoft.AspNetCore.Blazor.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace StandaloneApp +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + } + + public void Configure(IBlazorApplicationBuilder app) + { + app.AddComponent("app"); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Builder/BlazorApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/BlazorApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..415abcbad8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/BlazorApplicationBuilderExtensions.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.AspNetCore.Blazor.Components; + +namespace Microsoft.AspNetCore.Blazor.Builder +{ + /// + /// Provides extension methods for . + /// + public static class BlazorApplicationBuilderExtensions + { + /// + /// Associates the component type with the application, + /// causing it to be displayed in the specified DOM element. + /// + /// The . + /// The type of the component. + /// A CSS selector that uniquely identifies a DOM element. + public static void AddComponent(this IBlazorApplicationBuilder app, string domElementSelector) + where TComponent : IComponent + { + app.AddComponent(typeof(TComponent), domElementSelector); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Builder/IBlazorApplicationBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/IBlazorApplicationBuilder.cs new file mode 100644 index 0000000000..3a4c24bdb4 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/IBlazorApplicationBuilder.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.AspNetCore.Blazor.Components; + +namespace Microsoft.AspNetCore.Blazor.Builder +{ + /// + /// A builder for constructing a Blazor application. + /// + public interface IBlazorApplicationBuilder + { + /// + /// Gets the application services. + /// + IServiceProvider Services { get; } + + /// + /// Associates the with the application, + /// causing it to be displayed in the specified DOM element. + /// + /// The type of the component. + /// A CSS selector that uniquely identifies a DOM element. + void AddComponent(Type componentType, string domElementSelector); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Builder/WebAssemblyBlazorApplicationBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/WebAssemblyBlazorApplicationBuilder.cs new file mode 100644 index 0000000000..3555700751 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Builder/WebAssemblyBlazorApplicationBuilder.cs @@ -0,0 +1,50 @@ +// 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 Microsoft.AspNetCore.Blazor.Browser.Rendering; +using Microsoft.AspNetCore.Blazor.Builder; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + internal class WebAssemblyBlazorApplicationBuilder : IBlazorApplicationBuilder + { + public WebAssemblyBlazorApplicationBuilder(IServiceProvider services) + { + Entries = new List<(Type componentType, string domElementSelector)>(); + Services = services; + } + + public List<(Type componentType, string domElementSelector)> Entries { get; } + + public IServiceProvider Services { get; } + + public void AddComponent(Type componentType, string domElementSelector) + { + if (componentType == null) + { + throw new ArgumentNullException(nameof(componentType)); + } + + if (domElementSelector == null) + { + throw new ArgumentNullException(nameof(domElementSelector)); + } + + Entries.Add((componentType, domElementSelector)); + } + + public BrowserRenderer CreateRenderer() + { + var renderer = new BrowserRenderer(Services); + for (var i = 0; i < Entries.Count; i++) + { + var entry = Entries[i]; + renderer.AddComponent(entry.componentType, entry.domElementSelector); + } + + return renderer; + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/BlazorWebAssemblyHost.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/BlazorWebAssemblyHost.cs new file mode 100644 index 0000000000..224b731c13 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/BlazorWebAssemblyHost.cs @@ -0,0 +1,20 @@ +// 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.Blazor.Hosting +{ + /// + /// Used to to create instances a Blazor host builder for a Browser application. + /// + public static class BlazorWebAssemblyHost + { + /// + /// Creates a an instance of . + /// + /// The . + public static IWebAssemblyHostBuilder CreateDefaultBuilder() + { + return new WebAssemblyHostBuilder(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/ConventionBasedStartup.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/ConventionBasedStartup.cs new file mode 100644 index 0000000000..26a0ee72a8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/ConventionBasedStartup.cs @@ -0,0 +1,117 @@ +// 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.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Blazor.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + // Keeping this simple for now to focus on predictable and reasonable behaviors. + // Startup in WebHost supports lots of things we don't yet support, and some we + // may never support. + // + // Possible additions: + // - environments + // - case-insensitivity (makes sense with environments) + // + // Likely never: + // - statics + // - DI into constructor + internal class ConventionBasedStartup : IBlazorStartup + { + private readonly object _instance; + + public ConventionBasedStartup(object instance) + { + _instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + public void Configure(IBlazorApplicationBuilder app, IServiceProvider services) + { + try + { + var method = GetConfigureMethod(); + Debug.Assert(method != null); + + var parameters = method.GetParameters(); + var arguments = new object[parameters.Length]; + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + arguments[i] = parameter.ParameterType == typeof(IBlazorApplicationBuilder) + ? app + : services.GetRequiredService(parameter.ParameterType); + } + + method.Invoke(_instance, arguments); + } + catch (Exception ex) + { + if (ex is TargetInvocationException) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + + throw; + } + } + + internal MethodInfo GetConfigureMethod() + { + var methods = _instance.GetType() + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(m => string.Equals(m.Name, "Configure", StringComparison.Ordinal)) + .ToArray(); + + if (methods.Length == 1) + { + return methods[0]; + } + else if (methods.Length == 0) + { + throw new InvalidOperationException("The startup class must define a 'Configure' method."); + } + else + { + throw new InvalidOperationException("Overloading the 'Configure' method is not supported."); + } + } + + public void ConfigureServices(IServiceCollection services) + { + try + { + var method = GetConfigureServicesMethod(); + if (method != null) + { + method.Invoke(_instance, new object[] { services }); + } + } + catch (Exception ex) + { + if (ex is TargetInvocationException) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + + throw; + } + } + + internal MethodInfo GetConfigureServicesMethod() + { + return _instance.GetType() + .GetMethod( + "ConfigureServices", + BindingFlags.Public | BindingFlags.Instance, + null, + new Type[] { typeof(IServiceCollection), }, + Array.Empty()); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IBlazorStartup.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IBlazorStartup.cs new file mode 100644 index 0000000000..ab9cabca36 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IBlazorStartup.cs @@ -0,0 +1,16 @@ +// 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 Microsoft.AspNetCore.Blazor.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + internal interface IBlazorStartup + { + void ConfigureServices(IServiceCollection services); + + void Configure(IBlazorApplicationBuilder app, IServiceProvider services); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHost.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHost.cs new file mode 100644 index 0000000000..8f21f93421 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHost.cs @@ -0,0 +1,34 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + /// + /// A program abstraction. + /// + public interface IWebAssemblyHost : IDisposable + { + /// + /// The programs configured services. + /// + IServiceProvider Services { get; } + + /// + /// Start the program. + /// + /// Used to abort program start. + /// + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Attempts to gracefully stop the program. + /// + /// Used to indicate when stop should no longer be graceful. + /// + Task StopAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHostBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHostBuilder.cs new file mode 100644 index 0000000000..4b9b8cff56 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/IWebAssemblyHostBuilder.cs @@ -0,0 +1,34 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + /// + /// Abstraction for configuring a Blazor browser-based application. + /// + public interface IWebAssemblyHostBuilder + { + /// + /// A central location for sharing state between components during the host building process. + /// + IDictionary Properties { get; } + + /// + /// Adds services to the container. This can be called multiple times and the results will be additive. + /// + /// The delegate for configuring the that will be used + /// to construct the . + /// The same instance of the for chaining. + IWebAssemblyHostBuilder ConfigureServices(Action configureDelegate); + + /// + /// Run the given actions to initialize the host. This can only be called once. + /// + /// An initialized + IWebAssemblyHost Build(); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHost.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHost.cs new file mode 100644 index 0000000000..270b5cc42d --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHost.cs @@ -0,0 +1,101 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Blazor.Browser.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + internal class WebAssemblyHost : IWebAssemblyHost + { + private readonly IJSRuntime _runtime; + + private IServiceScope _scope; + private BrowserRenderer _renderer; + + public WebAssemblyHost(IServiceProvider services, IJSRuntime runtime) + { + Services = services ?? throw new ArgumentNullException(nameof(services)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public IServiceProvider Services { get; } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + // We need to do this as early as possible, it eliminates a bunch of problems. Note that what we do + // is a bit fragile. If you see things breaking because JSRuntime.Current isn't set, then it's likely + // that something on the startup path went wrong. + // + // We want to the JSRuntime created here to be the 'ambient' runtime when JS calls back into .NET. When + // this happens in the browser it will be a direct call from Mono. We effectively needs to set the + // JSRuntime in the 'root' execution context which implies that we want to do as part of a direct + // call from Program.Main, and before any 'awaits'. + JSRuntime.SetCurrentJSRuntime(_runtime); + + var scopeFactory = Services.GetRequiredService(); + _scope = scopeFactory.CreateScope(); + + try + { + var startup = _scope.ServiceProvider.GetService(); + if (startup == null) + { + var message = + $"Could not find a registered Blazor Startup class. " + + $"Using {nameof(IWebAssemblyHost)} requires a call to {nameof(IWebAssemblyHostBuilder)}.UseBlazorStartup."; + throw new InvalidOperationException(message); + } + + // Note that we differ from the WebHost startup path here by using a 'scope' for the app builder + // as well as the Configure method. + var builder = new WebAssemblyBlazorApplicationBuilder(_scope.ServiceProvider); + startup.Configure(builder, _scope.ServiceProvider); + + _renderer = builder.CreateRenderer(); + } + catch + { + _scope.Dispose(); + _scope = null; + + if (_renderer != null) + { + _renderer.Dispose(); + _renderer = null; + } + + throw; + } + + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + if (_scope != null) + { + _scope.Dispose(); + _scope = null; + } + + if (_renderer != null) + { + _renderer.Dispose(); + _renderer = null; + } + + return Task.CompletedTask; + } + + public void Dispose() + { + (Services as IDisposable)?.Dispose(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilder.cs new file mode 100644 index 0000000000..4ecb4151fc --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilder.cs @@ -0,0 +1,92 @@ +// 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.Http; +using Microsoft.AspNetCore.Blazor.Browser.Http; +using Microsoft.AspNetCore.Blazor.Browser.Services; +using Microsoft.AspNetCore.Blazor.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Mono.WebAssembly.Interop; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + // + // This code was taken virtually as-is from the Microsoft.Extensions.Hosting project in aspnet/Hosting and then + // lots of things were removed. + // + internal class WebAssemblyHostBuilder : IWebAssemblyHostBuilder + { + private List> _configureServicesActions = new List>(); + private bool _hostBuilt; + private WebAssemblyHostBuilderContext _BrowserHostBuilderContext; + private IServiceProvider _appServices; + + /// + /// A central location for sharing state between components during the host building process. + /// + public IDictionary Properties { get; } = new Dictionary(); + + /// + /// Adds services to the container. This can be called multiple times and the results will be additive. + /// + /// + /// The same instance of the for chaining. + public IWebAssemblyHostBuilder ConfigureServices(Action configureDelegate) + { + _configureServicesActions.Add(configureDelegate ?? throw new ArgumentNullException(nameof(configureDelegate))); + return this; + } + + /// + /// Run the given actions to initialize the host. This can only be called once. + /// + /// An initialized + public IWebAssemblyHost Build() + { + if (_hostBuilt) + { + throw new InvalidOperationException("Build can only be called once."); + } + _hostBuilt = true; + + CreateBrowserHostBuilderContext(); + CreateServiceProvider(); + + return _appServices.GetRequiredService(); + } + + private void CreateBrowserHostBuilderContext() + { + _BrowserHostBuilderContext = new WebAssemblyHostBuilderContext(Properties); + } + + private void CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(_BrowserHostBuilderContext); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(s => + { + // Creating the URI helper needs to wait until the JS Runtime is initialized, so defer it. + var uriHelper = s.GetRequiredService(); + return new HttpClient(new BrowserHttpMessageHandler()) + { + BaseAddress = new Uri(uriHelper.GetBaseUri()) + }; + }); + + foreach (var configureServicesAction in _configureServicesActions) + { + configureServicesAction(_BrowserHostBuilderContext, services); + } + + _appServices = services.BuildServiceProvider(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderContext.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderContext.cs new file mode 100644 index 0000000000..c7b7dd6f19 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderContext.cs @@ -0,0 +1,27 @@ +// 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.Collections.Generic; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + /// + /// Context containing the common services on the . Some properties may be null until set by the . + /// + public sealed class WebAssemblyHostBuilderContext + { + /// + /// Creates a new . + /// + /// The property collection. + public WebAssemblyHostBuilderContext(IDictionary properties) + { + Properties = properties ?? throw new System.ArgumentNullException(nameof(properties)); + } + + /// + /// A central location for sharing state between components during the host building process. + /// + public IDictionary Properties { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderExtensions.cs new file mode 100644 index 0000000000..9b03c09766 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostBuilderExtensions.cs @@ -0,0 +1,73 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + /// + /// Provides Blazor-specific support for . + /// + public static class WebAssemblyHostBuilderExtensions + { + private const string BlazorStartupKey = "Blazor.Startup"; + + /// + /// Adds services to the container. This can be called multiple times and the results will be additive. + /// + /// The to configure. + /// + /// The same instance of the for chaining. + public static IWebAssemblyHostBuilder ConfigureServices(this IWebAssemblyHostBuilder hostBuilder, Action configureDelegate) + { + return hostBuilder.ConfigureServices((context, collection) => configureDelegate(collection)); + } + + /// + /// Configures the to use the provided startup class. + /// + /// The . + /// A type that configures a Blazor application. + /// The . + public static IWebAssemblyHostBuilder UseBlazorStartup(this IWebAssemblyHostBuilder builder, Type startupType) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (builder.Properties.ContainsKey(BlazorStartupKey)) + { + throw new InvalidOperationException("A startup class has already been registered."); + } + + // It would complicate the implementation to allow multiple startup classes, and we don't + // really have a need for it. + builder.Properties.Add(BlazorStartupKey, bool.TrueString); + + var startup = new ConventionBasedStartup(Activator.CreateInstance(startupType)); + + builder.ConfigureServices(startup.ConfigureServices); + builder.ConfigureServices(s => s.AddSingleton(startup)); + + return builder; + } + + /// + /// Configures the to use the provided startup class. + /// + /// A type that configures a Blazor application. + /// The . + /// The . + public static IWebAssemblyHostBuilder UseBlazorStartup(this IWebAssemblyHostBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return UseBlazorStartup(builder, typeof(TStartup)); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostExtensions.cs new file mode 100644 index 0000000000..988ea88ac0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Hosting/WebAssemblyHostExtensions.cs @@ -0,0 +1,25 @@ +// 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.Blazor.Hosting +{ + /// + /// Extension methods for . + /// + public static class WebAssemblyHostExtensions + { + /// + /// Runs the application. + /// + /// The to run. + /// + /// Currently, Blazor applications running in the browser don't have a lifecycle - the application does not + /// get a chance to gracefully shut down. For now, simply starts the host + /// and allows execution to continue. + /// + public static void Run(this IWebAssemblyHost host) + { + host.StartAsync().GetAwaiter().GetResult(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor/Microsoft.AspNetCore.Blazor.csproj b/src/Microsoft.AspNetCore.Blazor/Microsoft.AspNetCore.Blazor.csproj index 81f1173b55..f1de126dde 100644 --- a/src/Microsoft.AspNetCore.Blazor/Microsoft.AspNetCore.Blazor.csproj +++ b/src/Microsoft.AspNetCore.Blazor/Microsoft.AspNetCore.Blazor.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 diff --git a/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/ConventionBasedStartupTest.cs b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/ConventionBasedStartupTest.cs new file mode 100644 index 0000000000..ea5b114d9d --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/ConventionBasedStartupTest.cs @@ -0,0 +1,209 @@ +// 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 Microsoft.AspNetCore.Blazor.Builder; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + public class ConventionBasedStartupTest + { + [Fact] + public void ConventionBasedStartup_GetConfigureServicesMethod_FindsConfigureServices() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup1()); + + // Act + var method = startup.GetConfigureServicesMethod(); + + // Assert + Assert.Equal(typeof(IServiceCollection), method.GetParameters()[0].ParameterType); + } + + private class MyStartup1 + { + public void ConfigureServices(IServiceCollection services) + { + } + + // Ignored + public void ConfigureServices(DateTime x) + { + + } + + // Ignored + private void ConfigureServices(int x) + { + } + + // Ignored + public static void ConfigureServices(string x) + { + } + } + + [Fact] + public void ConventionBasedStartup_GetConfigureServicesMethod_NoMethodFound() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup2()); + + // Act + var method = startup.GetConfigureServicesMethod(); + + // Assert + Assert.Null(method); + } + + private class MyStartup2 + { + } + + [Fact] + public void ConventionBasedStartup_ConfigureServices_CallsMethod() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup3()); + var services = new ServiceCollection(); + + // Act + startup.ConfigureServices(services); + + // Assert + Assert.NotEmpty(services); + } + + private class MyStartup3 + { + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton("foo"); + } + } + + [Fact] + public void ConventionBasedStartup_ConfigureServices_NoMethodFound() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup4()); + var services = new ServiceCollection(); + + // Act + startup.ConfigureServices(services); + + // Assert + Assert.Empty(services); + } + + private class MyStartup4 + { + } + + [Fact] + public void ConventionBasedStartup_GetConfigureMethod_FindsConfigure() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup5()); + + // Act + var method = startup.GetConfigureMethod(); + + // Assert + Assert.Empty(method.GetParameters()); + } + + private class MyStartup5 + { + public void Configure() + { + } + + // Ignored + private void Configure(int x) + { + } + + // Ignored + public static void Configure(string x) + { + } + } + + [Fact] + public void ConventionBasedStartup_GetConfigureMethod_NoMethodFoundThrows() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup6()); + + // Act + var ex = Assert.Throws(() => startup.GetConfigureMethod()); + + // Assert + Assert.Equal("The startup class must define a 'Configure' method.", ex.Message); + } + + private class MyStartup6 + { + } + + [Fact] + public void ConventionBasedStartup_GetConfigureMethod_OverloadedThrows() + { + // Arrange + var startup = new ConventionBasedStartup(new MyStartup7()); + + // Act + var ex = Assert.Throws(() => startup.GetConfigureMethod()); + + // Assert + Assert.Equal("Overloading the 'Configure' method is not supported.", ex.Message); + } + + private class MyStartup7 + { + public void Configure() + { + } + + public void Configure(string x) + { + } + } + + [Fact] + public void ConventionBasedStartup_Configure() + { + // Arrange + var instance = new MyStartup8(); + var startup = new ConventionBasedStartup(instance); + + var services = new ServiceCollection().AddSingleton("foo").BuildServiceProvider(); + var builder = new WebAssemblyBlazorApplicationBuilder(services); + + // Act + startup.Configure(builder, services); + + // Assert + Assert.Collection( + instance.Arguments, + a => Assert.Same(builder, a), + a => Assert.Equal("foo", a)); + } + + private class MyStartup8 + { + public List Arguments { get; } = new List(); + + public void Configure(IBlazorApplicationBuilder app, string foo) + { + Arguments.Add(app); + Arguments.Add(foo); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostBuilderTest.cs b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostBuilderTest.cs new file mode 100644 index 0000000000..c9f9596d98 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostBuilderTest.cs @@ -0,0 +1,80 @@ +// 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.Text; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + public class WebAssemblyHostBuilderTest + { + [Fact] + public void HostBuilder_CanCallBuild_BuildsServices() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + + // Act + var host = builder.Build(); + + // Assert + Assert.NotNull(host.Services.GetService(typeof(IWebAssemblyHost))); + } + + [Fact] + public void HostBuilder_CanConfigureAdditionalServices() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + builder.ConfigureServices((c, s) => s.AddSingleton("foo")); + builder.ConfigureServices((c, s) => s.AddSingleton(new StringBuilder("bar"))); + + // Act + var host = builder.Build(); + + // Assert + Assert.Equal("foo", host.Services.GetService(typeof(string))); + Assert.Equal("bar", host.Services.GetService(typeof(StringBuilder)).ToString()); + } + + [Fact] + public void HostBuilder_UseBlazorStartup_CanConfigureAdditionalServices() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + builder.UseBlazorStartup(); + builder.ConfigureServices((c, s) => s.AddSingleton(new StringBuilder("bar"))); + + // Act + var host = builder.Build(); + + // Assert + Assert.Equal("foo", host.Services.GetService(typeof(string))); + Assert.Equal("bar", host.Services.GetService(typeof(StringBuilder)).ToString()); + } + + [Fact] + public void HostBuilder_UseBlazorStartup_DoesNotAllowMultiple() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + builder.UseBlazorStartup(); + + // Act + var ex = Assert.Throws(() => builder.UseBlazorStartup()); + + // Assert + Assert.Equal("A startup class has already been registered.", ex.Message); + } + + private class MyStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton("foo"); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostTest.cs b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostTest.cs new file mode 100644 index 0000000000..cb9d14812d --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Browser.Test/Hosting/WebAssemblyHostTest.cs @@ -0,0 +1,81 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Blazor.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Mono.WebAssembly.Interop; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Hosting +{ + public class WebAssemblyHostTest + { + [Fact] + public async Task BrowserHost_StartAsync_ThrowsWithoutStartup() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + var host = builder.Build(); + + // Act + var ex = await Assert.ThrowsAsync(async () => await host.StartAsync()); + + // Assert + Assert.Equal( + "Could not find a registered Blazor Startup class. " + + "Using IWebAssemblyHost requires a call to IWebAssemblyHostBuilder.UseBlazorStartup.", + ex.Message); + } + + [Fact] + public async Task BrowserHost_StartAsync_RunsConfigureMethod() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + + var startup = new MockStartup(); + builder.ConfigureServices((c, s) => { s.AddSingleton(startup); }); + + var host = builder.Build(); + + // Act + await host.StartAsync(); + + // Assert + Assert.True(startup.ConfigureCalled); + } + + [Fact] + public async Task BrowserHost_StartAsync_SetsJSRuntime() + { + // Arrange + var builder = new WebAssemblyHostBuilder(); + builder.UseBlazorStartup(); + + var host = builder.Build(); + + // Act + await host.StartAsync(); + + // Assert + Assert.IsType(JSRuntime.Current); + } + + private class MockStartup : IBlazorStartup + { + public bool ConfigureCalled { get; set; } + + public void Configure(IBlazorApplicationBuilder app, IServiceProvider services) + { + ConfigureCalled = true; + } + + public void ConfigureServices(IServiceCollection services) + { + } + } + } +}