Support overriding how boot resources are loaded. Fixes #5462

This commit is contained in:
Steve Sanderson 2020-04-16 12:14:54 +01:00
parent fcb96b6edd
commit e95264f237
12 changed files with 114 additions and 39 deletions

File diff suppressed because one or more lines are too long

View File

@ -9,14 +9,14 @@ import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel, Logger } from './Platform/Logging/Logger';
import { discoverComponents, CircuitDescriptor } from './Platform/Circuits/CircuitManager';
import { setEventDispatcher } from './Rendering/RendererEventDispatcher';
import { resolveOptions, BlazorOptions } from './Platform/Circuits/BlazorOptions';
import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
let renderingFailed = false;
let started = false;
async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
async function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {
if (started) {
throw new Error('Blazor has already started.');
}
@ -72,7 +72,7 @@ async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
logger.log(LogLevel.Information, 'Blazor server-side application started.');
}
async function initializeConnection(options: BlazorOptions, logger: Logger, circuit: CircuitDescriptor): Promise<signalR.HubConnection> {
async function initializeConnection(options: CircuitStartOptions, logger: Logger, circuit: CircuitDescriptor): Promise<signalR.HubConnection> {
const hubProtocol = new MessagePackHubProtocol();
(hubProtocol as unknown as { name: string }).name = 'blazorpack';

View File

@ -10,10 +10,11 @@ import { WebAssemblyResourceLoader } from './Platform/WebAssemblyResourceLoader'
import { WebAssemblyConfigLoader } from './Platform/WebAssemblyConfigLoader';
import { BootConfigResult } from './Platform/BootConfig';
import { Pointer } from './Platform/Platform';
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
let started = false;
async function boot(options?: any): Promise<void> {
async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
if (started) {
throw new Error('Blazor has already started.');
@ -43,7 +44,7 @@ async function boot(options?: any): Promise<void> {
const bootConfigResult = await BootConfigResult.initAsync();
const [resourceLoader] = await Promise.all([
WebAssemblyResourceLoader.initAsync(bootConfigResult.bootConfig),
WebAssemblyResourceLoader.initAsync(bootConfigResult.bootConfig, options || {}),
WebAssemblyConfigLoader.initAsync(bootConfigResult)]);
try {

View File

@ -1,13 +1,13 @@
import { LogLevel } from '../Logging/Logger';
export interface BlazorOptions {
export interface CircuitStartOptions {
configureSignalR: (builder: signalR.HubConnectionBuilder) => void;
logLevel: LogLevel;
reconnectionOptions: ReconnectionOptions;
reconnectionHandler?: ReconnectionHandler;
}
export function resolveOptions(userOptions?: Partial<BlazorOptions>): BlazorOptions {
export function resolveOptions(userOptions?: Partial<CircuitStartOptions>): CircuitStartOptions {
const result = { ...defaultOptions, ...userOptions };
// The spread operator can't be used for a deep merge, so do the same for subproperties
@ -29,7 +29,7 @@ export interface ReconnectionHandler {
onConnectionUp(): void;
}
const defaultOptions: BlazorOptions = {
const defaultOptions: CircuitStartOptions = {
configureSignalR: (_) => { },
logLevel: LogLevel.Warning,
reconnectionOptions: {

View File

@ -1,4 +1,4 @@
import { ReconnectionHandler, ReconnectionOptions } from './BlazorOptions';
import { ReconnectionHandler, ReconnectionOptions } from './CircuitStartOptions';
import { ReconnectDisplay } from './ReconnectDisplay';
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';

View File

@ -3,6 +3,7 @@ import { showErrorNotification } from '../../BootErrors';
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
import { Platform, System_Array, Pointer, System_Object, System_String } from '../Platform';
import { loadTimezoneData } from './TimezoneDataFile';
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
let mono_string_get_utf8: (managedString: System_String) => Pointer;
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
@ -138,17 +139,30 @@ function addScriptTagsToDocument(resourceLoader: WebAssemblyResourceLoader) {
const dotnetJsResourceName = Object
.keys(resourceLoader.bootConfig.resources.runtime)
.filter(n => n.startsWith('dotnet.') && n.endsWith('.js'))[0];
const dotnetJsContentHash = resourceLoader.bootConfig.resources.runtime[dotnetJsResourceName];
const scriptElem = document.createElement('script');
scriptElem.src = `_framework/wasm/${dotnetJsResourceName}`;
scriptElem.defer = true;
// For consistency with WebAssemblyResourceLoader, we only enforce SRI if caching is allowed
if (resourceLoader.bootConfig.cacheBootResources) {
const contentHash = resourceLoader.bootConfig.resources.runtime[dotnetJsResourceName];
scriptElem.integrity = contentHash;
scriptElem.integrity = dotnetJsContentHash;
scriptElem.crossOrigin = 'anonymous';
}
// Allow overriding the URI from which the dotnet.*.js file is loaded
if (resourceLoader.startOptions.loadBootResource) {
const resourceType: WebAssemblyBootResourceType = 'dotnetjs';
const customSrc = resourceLoader.startOptions.loadBootResource(
resourceType, dotnetJsResourceName, scriptElem.src, dotnetJsContentHash);
if (typeof(customSrc) === 'string') {
scriptElem.src = customSrc;
} else if (customSrc) {
// Since we must load this via a <script> tag, it's only valid to supply a URI (and not a Request, say)
throw new Error(`For a ${resourceType} resource, custom loaders must supply a URI string.`);
}
}
document.body.appendChild(scriptElem);
}
@ -186,12 +200,13 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
// Begin loading the .dll/.pdb/.wasm files, but don't block here. Let other loading processes run in parallel.
const dotnetWasmResourceName = 'dotnet.wasm';
const assembliesBeingLoaded = resourceLoader.loadResources(resources.assembly, filename => `_framework/_bin/${filename}`);
const pdbsBeingLoaded = resourceLoader.loadResources(resources.pdb || {}, filename => `_framework/_bin/${filename}`);
const assembliesBeingLoaded = resourceLoader.loadResources(resources.assembly, filename => `_framework/_bin/${filename}`, 'assembly');
const pdbsBeingLoaded = resourceLoader.loadResources(resources.pdb || {}, filename => `_framework/_bin/${filename}`, 'pdb');
const wasmBeingLoaded = resourceLoader.loadResource(
/* name */ dotnetWasmResourceName,
/* url */ `_framework/wasm/${dotnetWasmResourceName}`,
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName]);
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName],
/* type */ 'dotnetwasm');
const dotnetTimeZoneResourceName = 'dotnet.timezones.dat';
let timeZoneResource: LoadingResource | undefined;
@ -199,7 +214,8 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
timeZoneResource = resourceLoader.loadResource(
dotnetTimeZoneResourceName,
`_framework/wasm/${dotnetTimeZoneResourceName}`,
resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName]);
resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName],
'timezonedata');
}
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
@ -243,7 +259,7 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
if (satelliteResources) {
const resourcePromises = Promise.all(culturesToLoad
.filter(culture => satelliteResources.hasOwnProperty(culture))
.map(culture => resourceLoader.loadResources(satelliteResources[culture], fileName => `_framework/_bin/${fileName}`))
.map(culture => resourceLoader.loadResources(satelliteResources[culture], fileName => `_framework/_bin/${fileName}`, 'assembly'))
.reduce((previous, next) => previous.concat(next), new Array<LoadingResource>())
.map(async resource => (await resource.response).arrayBuffer()));

View File

@ -1,5 +1,6 @@
import { toAbsoluteUri } from '../Services/NavigationManager';
import { BootJsonData, ResourceList } from './BootConfig';
import { WebAssemblyStartOptions, WebAssemblyBootResourceType } from './WebAssemblyStartOptions';
const networkFetchCacheMode = 'no-cache';
export class WebAssemblyResourceLoader {
@ -7,27 +8,23 @@ export class WebAssemblyResourceLoader {
private networkLoads: { [name: string]: LoadLogEntry } = {};
private cacheLoads: { [name: string]: LoadLogEntry } = {};
static async initAsync(bootConfig: BootJsonData): Promise<WebAssemblyResourceLoader> {
static async initAsync(bootConfig: BootJsonData, startOptions: Partial<WebAssemblyStartOptions>): Promise<WebAssemblyResourceLoader> {
const cache = await getCacheToUseIfEnabled(bootConfig);
return new WebAssemblyResourceLoader(bootConfig, cache);
return new WebAssemblyResourceLoader(bootConfig, cache, startOptions);
}
constructor(readonly bootConfig: BootJsonData, readonly cacheIfUsed: Cache | null) {
constructor(readonly bootConfig: BootJsonData, readonly cacheIfUsed: Cache | null, readonly startOptions: Partial<WebAssemblyStartOptions>) {
}
loadResources(resources: ResourceList, url: (name: string) => string): LoadingResource[] {
loadResources(resources: ResourceList, url: (name: string) => string, resourceType: WebAssemblyBootResourceType): LoadingResource[] {
return Object.keys(resources)
.map(name => this.loadResource(name, url(name), resources[name]));
.map(name => this.loadResource(name, url(name), resources[name], resourceType));
}
loadResource(name: string, url: string, contentHash: string): LoadingResource {
// Note that if cacheBootResources was explicitly disabled, we also bypass hash checking
// This is to give developers an easy opt-out from the entire caching/validation flow if
// there's anything they don't like about it.
loadResource(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): LoadingResource {
const response = this.cacheIfUsed
? this.loadResourceWithCaching(this.cacheIfUsed, name, url, contentHash)
: fetch(url, { cache: networkFetchCacheMode, integrity: this.bootConfig.cacheBootResources ? contentHash : undefined });
? this.loadResourceWithCaching(this.cacheIfUsed, name, url, contentHash, resourceType)
: this.loadResourceWithoutCaching(name, url, contentHash, resourceType);
return { name, url, response };
}
@ -77,7 +74,7 @@ export class WebAssemblyResourceLoader {
}
}
private async loadResourceWithCaching(cache: Cache, name: string, url: string, contentHash: string) {
private async loadResourceWithCaching(cache: Cache, name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType) {
// Since we are going to cache the response, we require there to be a content hash for integrity
// checking. We don't want to cache bad responses. There should always be a hash, because the build
// process generates this data.
@ -96,12 +93,34 @@ export class WebAssemblyResourceLoader {
return cachedResponse;
} else {
// It's not in the cache. Fetch from network.
const networkResponse = await fetch(url, { cache: networkFetchCacheMode, integrity: contentHash });
const networkResponse = await this.loadResourceWithoutCaching(name, url, contentHash, resourceType);
this.addToCacheAsync(cache, name, cacheKey, networkResponse); // Don't await - add to cache in background
return networkResponse;
}
}
private loadResourceWithoutCaching(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): Promise<Response> {
// Allow developers to override how the resource is loaded
if (this.startOptions.loadBootResource) {
const customLoadResult = this.startOptions.loadBootResource(resourceType, name, url, contentHash);
if (customLoadResult instanceof Promise) {
// They are supplying an entire custom response, so just use that
return customLoadResult;
} else if (typeof customLoadResult === 'string') {
// They are supplying a custom URL, so use that with the default fetch behavior
url = customLoadResult;
}
}
// Note that if cacheBootResources was explicitly disabled, we also bypass hash checking
// This is to give developers an easy opt-out from the entire caching/validation flow if
// there's anything they don't like about it.
return fetch(url, {
cache: networkFetchCacheMode,
integrity: this.bootConfig.cacheBootResources ? contentHash : undefined
});
}
private async addToCacheAsync(cache: Cache, name: string, cacheKey: string, response: Response) {
// We have to clone in order to put this in the cache *and* not prevent other code from
// reading the original response stream.

View File

@ -0,0 +1,17 @@
export interface WebAssemblyStartOptions {
/**
* Overrides the built-in boot resource loading mechanism so that boot resources can be fetched
* from a custom source, such as an external CDN.
* @param type The type of the resource to be loaded.
* @param name The name of the resource to be loaded.
* @param defaultUri The URI from which the framework would fetch the resource by default. The URI may be relative or absolute.
* @param integrity The integrity string representing the expected content in the response.
* @returns A URI string or a Response promise to override the loading process, or null/undefined to allow the default loading behavior.
*/
loadBootResource(type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string) : string | Promise<Response> | null | undefined;
}
// This type doesn't have to align with anything in BootConfig.
// Instead, this represents the public API through which certain aspects
// of boot resource loading can be customized.
export type WebAssemblyBootResourceType = 'assembly' | 'pdb' | 'dotnetjs' | 'dotnetwasm' | 'timezonedata';

View File

@ -7,6 +7,20 @@
<body>
<app>Loading...</app>
<script src="customJsFileForTests.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<!--
To show we can customize the boot resource loading process, the server looks for these
flags when collecting logs, and E2E tests check the right entries were seen.
-->
<script>
Blazor.start({
loadBootResource: function (type, name, defaultUri, integrity) {
return type === 'dotnetjs'
? `${defaultUri}?customizedbootresource=true`
: fetch(defaultUri, { integrity: integrity, cache: 'no-cache', headers: { 'customizedbootresource': 'true' } });
}
});
</script>
</body>
</html>

View File

@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http;
namespace HostedInAspNet.Server
{
public class RequestLog
public class BootResourceRequestLog
{
private List<string> _requestPaths = new List<string>();

View File

@ -14,16 +14,22 @@ namespace HostedInAspNet.Server
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<RequestLog>();
services.AddSingleton<BootResourceRequestLog>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RequestLog requestLog)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, BootResourceRequestLog bootResourceRequestLog)
{
app.Use((context, next) =>
{
// This is used by E2E tests to verify that the correct resources were fetched
requestLog.AddRequest(context.Request);
// This is used by E2E tests to verify that the correct resources were fetched,
// and that it was possible to override the loading mechanism
if (context.Request.Query.ContainsKey("customizedbootresource")
|| context.Request.Headers.ContainsKey("customizedbootresource")
|| context.Request.Path.Value.EndsWith("/blazor.boot.json"))
{
bootResourceRequestLog.AddRequest(context.Request);
}
return next();
});

View File

@ -46,6 +46,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var initialResourcesRequested = GetAndClearRequestedPaths();
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/dotnet.timezones.dat")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".js")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".dll")));
@ -57,11 +58,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var subsequentResourcesRequested = GetAndClearRequestedPaths();
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith("/dotnet.timezones.dat")));
Assert.NotEmpty(subsequentResourcesRequested.Where(path => path.EndsWith(".js")));
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith(".dll")));
}
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/20154")]
[Fact]
public void IncrementallyUpdatesCache()
{
// Perform a first load to populate the cache
@ -137,7 +139,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
private IReadOnlyCollection<string> GetAndClearRequestedPaths()
{
var requestLog = _serverFixture.Host.Services.GetRequiredService<RequestLog>();
var requestLog = _serverFixture.Host.Services.GetRequiredService<BootResourceRequestLog>();
var result = requestLog.RequestPaths.ToList();
requestLog.Clear();
return result;