diff --git a/src/Components/Web.JS/@types/dotnet/dotnet.d.ts b/src/Components/Web.JS/@types/dotnet/dotnet.d.ts index bcfce611388f..3c35d593e671 100644 --- a/src/Components/Web.JS/@types/dotnet/dotnet.d.ts +++ b/src/Components/Web.JS/@types/dotnet/dotnet.d.ts @@ -132,6 +132,14 @@ type MonoConfig = { * initial number of workers to add to the emscripten pthread pool */ pthreadPoolSize?: number; + /** + * If true, the snapshot of runtime's memory will be stored in the browser and used for faster startup next time. Default is false. + */ + startupMemoryCache?: boolean; + /** + * application environment + */ + applicationEnvironment?: string; }; interface ResourceRequest { name: string; @@ -180,8 +188,10 @@ type DotnetModuleConfig = { disableDotnet6Compatibility?: boolean; config?: MonoConfig; configSrc?: string; - onConfigLoaded?: (config: MonoConfig) => void | Promise; + onConfigLoaded?: (config: MonoConfig & BootJsonData) => void | Promise; onDotnetReady?: () => void | Promise; + onDownloadResourceProgress?: (resourcesLoaded: number, totalResources: number) => void; + getApplicationEnvironment?: (bootConfigResponse: Response) => string | null; imports?: any; exports?: string[]; downloadResource?: (request: ResourceRequest) => LoadingResource | undefined; @@ -274,4 +284,49 @@ declare global { declare const dotnet: ModuleAPI["dotnet"]; declare const exit: ModuleAPI["exit"]; -export { AssetEntry, CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, IMemoryView, ModuleAPI, MonoConfig, ResourceRequest, RuntimeAPI, createDotnetRuntime as default, dotnet, exit }; +interface BootJsonData { + readonly entryAssembly: string; + readonly resources: ResourceGroups; + /** Gets a value that determines if this boot config was produced from a non-published build (i.e. dotnet build or dotnet run) */ + readonly debugBuild: boolean; + readonly linkerEnabled: boolean; + readonly cacheBootResources: boolean; + readonly config: string[]; + readonly icuDataMode: ICUDataMode; + readonly startupMemoryCache: boolean | undefined; + readonly runtimeOptions: string[] | undefined; + + // These properties are tacked on, and not found in the boot.json file + modifiableAssemblies: string | null; + aspnetCoreBrowserTools: string | null; +} + +type BootJsonDataExtension = { [extensionName: string]: ResourceList }; + +interface ResourceGroups { + readonly assembly: ResourceList; + readonly lazyAssembly: ResourceList; + readonly pdb?: ResourceList; + readonly runtime: ResourceList; + readonly satelliteResources?: { [cultureName: string]: ResourceList }; + readonly libraryInitializers?: ResourceList, + readonly extensions?: BootJsonDataExtension + readonly runtimeAssets: ExtendedResourceList; +} + +type ResourceList = { [name: string]: string }; +type ExtendedResourceList = { + [name: string]: { + hash: string, + behavior: string + } +}; + +declare const enum ICUDataMode { + Sharded, + All, + Invariant, + Custom +} + +export { AssetEntry, CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, IMemoryView, ModuleAPI, MonoConfig, ResourceRequest, RuntimeAPI, BootJsonData, ICUDataMode, createDotnetRuntime as default, dotnet, exit }; diff --git a/src/Components/Web.JS/src/Boot.WebAssembly.ts b/src/Components/Web.JS/src/Boot.WebAssembly.ts index b58da89634d5..c6a8b2151789 100644 --- a/src/Components/Web.JS/src/Boot.WebAssembly.ts +++ b/src/Components/Web.JS/src/Boot.WebAssembly.ts @@ -6,18 +6,14 @@ import { DotNet } from '@microsoft/dotnet-js-interop'; import { Blazor } from './GlobalExports'; import * as Environment from './Environment'; import { Module, BINDING, monoPlatform } from './Platform/Mono/MonoPlatform'; -import { renderBatch, getRendererer, attachRootComponentToElement, attachRootComponentToLogicalElement } from './Rendering/Renderer'; +import { renderBatch, getRendererer } from './Rendering/Renderer'; import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRenderBatch'; import { shouldAutoStart } from './BootCommon'; 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'; -import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher'; -import { discoverComponents, discoverPersistedState, WebAssemblyComponentDescriptor } from './Services/ComponentDescriptorDiscovery'; import { setDispatchEventMiddleware } from './Rendering/WebRendererInteropMethods'; -import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.WebAssembly'; +import { JSInitializer } from './JSInitializers/JSInitializers'; let started = false; @@ -30,7 +26,7 @@ async function boot(options?: Partial): Promise { if (inAuthRedirectIframe()) { // eslint-disable-next-line @typescript-eslint/no-empty-function - await new Promise(() => {}); // See inAuthRedirectIframe for explanation + await new Promise(() => { }); // See inAuthRedirectIframe for explanation } setDispatchEventMiddleware((browserRendererId, eventHandlerId, continuation) => { @@ -93,48 +89,12 @@ async function boot(options?: Partial): Promise { Blazor._internal.navigationManager.endLocationChanging(callId, shouldContinueNavigation); }); - const candidateOptions = options ?? {}; - - // Get the custom environment setting and blazorBootJson loader if defined - const environment = candidateOptions.environment; - - // Fetch the resources and prepare the Mono runtime - const bootConfigPromise = BootConfigResult.initAsync(candidateOptions.loadBootResource, environment); - - // Leverage the time while we are loading boot.config.json from the network to discover any potentially registered component on - // the document. - const discoveredComponents = discoverComponents(document, 'webassembly') as WebAssemblyComponentDescriptor[]; - const componentAttacher = new WebAssemblyComponentAttacher(discoveredComponents); - Blazor._internal.registeredComponents = { - getRegisteredComponentsCount: () => componentAttacher.getCount(), - getId: (index) => componentAttacher.getId(index), - getAssembly: (id) => componentAttacher.getAssembly(id), - getTypeName: (id) => componentAttacher.getTypeName(id), - getParameterDefinitions: (id) => componentAttacher.getParameterDefinitions(id) || '', - getParameterValues: (id) => componentAttacher.getParameterValues(id) || '', - }; - - Blazor._internal.getPersistedState = () => discoverPersistedState(document) || ''; - - Blazor._internal.attachRootComponentToElement = (selector, componentId, rendererId: any) => { - const element = componentAttacher.resolveRegisteredElement(selector); - if (!element) { - attachRootComponentToElement(selector, componentId, rendererId); - } else { - attachRootComponentToLogicalElement(rendererId, element, componentId, false); - } - }; - - const bootConfigResult: BootConfigResult = await bootConfigPromise; - const jsInitializer = await fetchAndInvokeInitializers(bootConfigResult.bootConfig, candidateOptions); - - const [resourceLoader] = await Promise.all([ - WebAssemblyResourceLoader.initAsync(bootConfigResult.bootConfig, candidateOptions || {}), - WebAssemblyConfigLoader.initAsync(bootConfigResult, candidateOptions || {}), - ]); - + let resourceLoader: WebAssemblyResourceLoader; + let jsInitializer: JSInitializer; try { - await platform.start(resourceLoader); + const api = await platform.start(options ?? {}); + resourceLoader = api.resourceLoader; + jsInitializer = api.jsInitializer; } catch (ex) { throw new Error(`Failed to start platform. Reason: ${ex}`); } diff --git a/src/Components/Web.JS/src/JSInitializers/JSInitializers.WebAssembly.ts b/src/Components/Web.JS/src/JSInitializers/JSInitializers.WebAssembly.ts index 4e82a94184d4..0d97deb1ff5d 100644 --- a/src/Components/Web.JS/src/JSInitializers/JSInitializers.WebAssembly.ts +++ b/src/Components/Web.JS/src/JSInitializers/JSInitializers.WebAssembly.ts @@ -1,11 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { BootJsonData } from '../Platform/BootConfig'; +import { BootJsonData } from 'dotnet'; import { WebAssemblyStartOptions } from '../Platform/WebAssemblyStartOptions'; import { JSInitializer } from './JSInitializers'; -export async function fetchAndInvokeInitializers(bootConfig: BootJsonData, options: Partial) : Promise { +export async function fetchAndInvokeInitializers(bootConfig: BootJsonData, options: Partial): Promise { const initializers = bootConfig.resources.libraryInitializers; const jsInitializer = new JSInitializer(); if (initializers) { diff --git a/src/Components/Web.JS/src/Platform/BootConfig.ts b/src/Components/Web.JS/src/Platform/BootConfig.ts deleted file mode 100644 index 17c3e6381a10..000000000000 --- a/src/Components/Web.JS/src/Platform/BootConfig.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import { WebAssemblyBootResourceType } from './WebAssemblyStartOptions'; - -type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string) => string | Promise | null | undefined; - -export class BootConfigResult { - private constructor(public bootConfig: BootJsonData, public applicationEnvironment: string) { - } - - static async initAsync(loadBootResource?: LoadBootResourceCallback, environment?: string): Promise { - const loaderResponse = loadBootResource !== undefined ? - loadBootResource('manifest', 'blazor.boot.json', '_framework/blazor.boot.json', '') : - defaultLoadBlazorBootJson('_framework/blazor.boot.json'); - - let bootConfigResponse: Response; - - if (!loaderResponse) { - bootConfigResponse = await defaultLoadBlazorBootJson('_framework/blazor.boot.json'); - } else if (typeof loaderResponse === 'string') { - bootConfigResponse = await defaultLoadBlazorBootJson(loaderResponse); - } else { - bootConfigResponse = await loaderResponse; - } - - // While we can expect an ASP.NET Core hosted application to include the environment, other - // hosts may not. Assume 'Production' in the absence of any specified value. - const applicationEnvironment = environment || bootConfigResponse.headers.get('Blazor-Environment') || 'Production'; - const bootConfig: BootJsonData = await bootConfigResponse.json(); - bootConfig.modifiableAssemblies = bootConfigResponse.headers.get('DOTNET-MODIFIABLE-ASSEMBLIES'); - bootConfig.aspnetCoreBrowserTools = bootConfigResponse.headers.get('ASPNETCORE-BROWSER-TOOLS'); - - return new BootConfigResult(bootConfig, applicationEnvironment); - - function defaultLoadBlazorBootJson(url: string) : Promise { - return fetch(url, { - method: 'GET', - credentials: 'include', - cache: 'no-cache', - }); - } - } -} - -// Keep in sync with bootJsonData from the BlazorWebAssemblySDK -export interface BootJsonData { - readonly entryAssembly: string; - readonly resources: ResourceGroups; - /** Gets a value that determines if this boot config was produced from a non-published build (i.e. dotnet build or dotnet run) */ - readonly debugBuild: boolean; - readonly linkerEnabled: boolean; - readonly cacheBootResources: boolean; - readonly config: string[]; - readonly icuDataMode: ICUDataMode; - readonly startupMemoryCache: boolean | null; - readonly runtimeOptions: string[] | null; - - // These properties are tacked on, and not found in the boot.json file - modifiableAssemblies: string | null; - aspnetCoreBrowserTools: string | null; -} - -export type BootJsonDataExtension = { [extensionName: string]: ResourceList }; - -export interface ResourceGroups { - readonly assembly: ResourceList; - readonly lazyAssembly: ResourceList; - readonly pdb?: ResourceList; - readonly runtime: ResourceList; - readonly satelliteResources?: { [cultureName: string]: ResourceList }; - readonly libraryInitializers?: ResourceList, - readonly extensions?: BootJsonDataExtension - readonly runtimeAssets: ExtendedResourceList; -} - -export type ResourceList = { [name: string]: string }; -export type ExtendedResourceList = { - [name: string]: { - hash: string, - behavior: string - } -}; - -export enum ICUDataMode { - Sharded, - All, - Invariant, - Custom -} diff --git a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts index b801e5e03959..074dea912620 100644 --- a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts +++ b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts @@ -8,12 +8,16 @@ import { DotNet } from '@microsoft/dotnet-js-interop'; import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger'; import { showErrorNotification } from '../../BootErrors'; import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader'; -import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform'; -import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions'; -import { BootJsonData, ICUDataMode } from '../BootConfig'; +import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock, PlatformApi } from '../Platform'; +import { WebAssemblyBootResourceType, WebAssemblyStartOptions } from '../WebAssemblyStartOptions'; import { Blazor } from '../../GlobalExports'; -import { DotnetModuleConfig, EmscriptenModule, AssetEntry, MonoConfig, ModuleAPI } from 'dotnet'; +import { DotnetModuleConfig, EmscriptenModule, MonoConfig, ModuleAPI, BootJsonData, ICUDataMode } from 'dotnet'; import { BINDINGType, MONOType } from 'dotnet/dotnet-legacy'; +import { WebAssemblyComponentDescriptor, discoverComponents, discoverPersistedState } from '../../Services/ComponentDescriptorDiscovery'; +import { attachRootComponentToElement, attachRootComponentToLogicalElement } from '../../Rendering/Renderer'; +import { WebAssemblyComponentAttacher } from '../WebAssemblyComponentAttacher'; +import { fetchAndInvokeInitializers } from '../../JSInitializers/JSInitializers.WebAssembly'; +import { WebAssemblyConfigLoader } from '../WebAssemblyConfigLoader'; // initially undefined and only fully initialized after createEmscriptenModuleInstance() export let BINDING: BINDINGType = undefined as any; @@ -26,6 +30,8 @@ const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from let currentHeapLock: MonoHeapLock | null = null; +let applicationEnvironment = 'Production'; + // Memory access helpers // The implementations are exactly equivalent to what the global getValue(addr, type) function does, // except without having to parse the 'type' parameter, and with less risk of mistakes at the call site @@ -51,10 +57,8 @@ function getValueU64(ptr: number) { } export const monoPlatform: Platform = { - start: async function start(resourceLoader: WebAssemblyResourceLoader) { - attachDebuggerHotkey(resourceLoader); - - await createRuntimeInstance(resourceLoader); + start: function start(options: Partial) { + return createRuntimeInstance(options); }, callEntryPoint: async function callEntryPoint(assemblyName: string): Promise { @@ -161,26 +165,63 @@ export const monoPlatform: Platform = { }, }; -function importDotnetJs(resourceLoader: WebAssemblyResourceLoader): Promise { +type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string) => string | Promise | null | undefined; + +async function loadBootConfigAsync(loadBootResource?: LoadBootResourceCallback, environment?: string): Promise { + const loaderResponse = loadBootResource !== undefined ? + loadBootResource('manifest', 'blazor.boot.json', '_framework/blazor.boot.json', '') : + defaultLoadBlazorBootJson('_framework/blazor.boot.json'); + + let bootConfigResponse: Response; + + if (!loaderResponse) { + bootConfigResponse = await defaultLoadBlazorBootJson('_framework/blazor.boot.json'); + } else if (typeof loaderResponse === 'string') { + bootConfigResponse = await defaultLoadBlazorBootJson(loaderResponse); + } else { + bootConfigResponse = await loaderResponse; + } + + // While we can expect an ASP.NET Core hosted application to include the environment, other + // hosts may not. Assume 'Production' in the absence of any specified value. + applicationEnvironment = environment || bootConfigResponse.headers.get('Blazor-Environment') || 'Production'; + const bootConfig: BootJsonData = await bootConfigResponse.json(); + bootConfig.modifiableAssemblies = bootConfigResponse.headers.get('DOTNET-MODIFIABLE-ASSEMBLIES'); + bootConfig.aspnetCoreBrowserTools = bootConfigResponse.headers.get('ASPNETCORE-BROWSER-TOOLS'); + + return bootConfig; + + function defaultLoadBlazorBootJson(url: string): Promise { + return fetch(url, { + method: 'GET', + credentials: 'include', + cache: 'no-cache', + }); + } +} + +async function importDotnetJs(startOptions: Partial): Promise { const browserSupportsNativeWebAssembly = typeof WebAssembly !== 'undefined' && WebAssembly.validate; if (!browserSupportsNativeWebAssembly) { throw new Error('This browser does not support WebAssembly.'); } + const bootConfig = await loadBootConfigAsync(startOptions.loadBootResource, startOptions.environment); + // The dotnet.*.js file has a version or hash in its name as a form of cache-busting. This is needed // because it's the only part of the loading process that can't use cache:'no-cache' (because it's // not a 'fetch') and isn't controllable by the developer (so they can't put in their own cache-busting // querystring). So, to find out the exact URL we have to search the boot manifest. const dotnetJsResourceName = Object - .keys(resourceLoader.bootConfig.resources.runtime) + .keys(bootConfig.resources.runtime) .filter(n => n.startsWith('dotnet.') && n.endsWith('.js'))[0]; - const dotnetJsContentHash = resourceLoader.bootConfig.resources.runtime[dotnetJsResourceName]; + const dotnetJsContentHash = bootConfig.resources.runtime[dotnetJsResourceName]; let src = `_framework/${dotnetJsResourceName}`; // Allow overriding the URI from which the dotnet.*.js file is loaded - if (resourceLoader.startOptions.loadBootResource) { + if (startOptions.loadBootResource) { const resourceType: WebAssemblyBootResourceType = 'dotnetjs'; - const customSrc = resourceLoader.startOptions.loadBootResource(resourceType, dotnetJsResourceName, src, dotnetJsContentHash); + const customSrc = startOptions.loadBootResource(resourceType, dotnetJsResourceName, src, dotnetJsContentHash); if (typeof (customSrc) === 'string') { src = customSrc; } else if (customSrc) { @@ -190,7 +231,7 @@ function importDotnetJs(resourceLoader: WebAssemblyResourceLoader): Promise, platformApi: any): DotnetModuleConfig { const config: MonoConfig = { - assets, - globalizationMode: 'icu', - environmentVariables: environmentVariables, - debugLevel: hasDebuggingEnabled() ? 1 : 0, maxParallelDownloads: 1000000, // disable throttling parallel downloads enableDownloadRetry: false, // disable retry downloads - }; - const monoToBlazorAssetTypeMap: { [key: string]: WebAssemblyBootResourceType | undefined } = { - 'assembly': 'assembly', - 'pdb': 'pdb', - 'icu': 'globalization', - 'vfs': 'globalization', - 'dotnetwasm': 'dotnetwasm', - }; - const behaviorByName = (name) => { - return name === 'dotnet.timezones.blat' ? 'vfs' - : (name.startsWith('dotnet.worker') && name.endsWith('.js')) ? 'js-module-threads' - : (name.startsWith('dotnet') && name.endsWith('.js')) ? 'js-module-dotnet' - : name.startsWith('icudt') ? 'icu' - : 'other'; + applicationEnvironment: applicationEnvironment, }; - // it would not `loadResource` on types for which there is no typesMap mapping - const downloadResource = (asset: AssetEntry): LoadingResource | undefined => { - // GOTCHA: the mapping to blazor asset type may not cover all mono owned asset types in the future in which case: - // A) we may need to add such asset types to the mapping and to WebAssemblyBootResourceType - // B) or we could add generic "runtime" type to WebAssemblyBootResourceType as fallback - // C) or we could return `undefined` and let the runtime to load the asset. In which case the progress will not be reported on it and blazor will not be able to cache it. - const type = monoToBlazorAssetTypeMap[asset.behavior]; - if (type !== undefined) { - const res = resourceLoader.loadResource(asset.name, asset.resolvedUrl!, asset.hash!, type); - asset.pendingDownload = res; - totalResources++; - res.response.then(setProgress); - return res; + const onConfigLoaded = async (bootConfig: BootJsonData & MonoConfig): Promise => { + if (!bootConfig.environmentVariables) { + bootConfig.environmentVariables = {}; } - return undefined; - }; - // any runtime owned assets, with proper behavior already set - for (const name in resources.runtimeAssets) { - const asset = resources.runtimeAssets[name] as AssetEntry; - asset.name = name; - asset.resolvedUrl = `_framework/${name}`; - assets.push(asset); - if (asset.behavior === 'dotnetwasm') { - // start the download as soon as possible - downloadResource(asset); - } - } - for (const name in resources.assembly) { - const asset: AssetEntry = { - name, - resolvedUrl: `_framework/${name}`, - hash: resources.assembly[name], - behavior: 'assembly', - }; - assets.push(asset); - // start the download as soon as possible - downloadResource(asset); - } - if (hasDebuggingEnabled() && resources.pdb) { - for (const name in resources.pdb) { - const asset: AssetEntry = { - name, - resolvedUrl: `_framework/${name}`, - hash: resources.pdb[name], - behavior: 'pdb', - }; - assets.push(asset); - downloadResource(asset); + if (bootConfig.icuDataMode === ICUDataMode.Sharded) { + bootConfig.environmentVariables['__BLAZOR_SHARDED_ICU'] = '1'; } - } - const applicationCulture = resourceLoader.startOptions.applicationCulture || (navigator.languages && navigator.languages[0]); - const icuDataResourceName = getICUResourceName(resourceLoader.bootConfig, applicationCulture); - let hasIcuData = false; - for (const name in resources.runtime) { - const behavior = behaviorByName(name) as any; - if (behavior === 'icu') { - if (resourceLoader.bootConfig.icuDataMode === ICUDataMode.Invariant) { - continue; - } - if (name !== icuDataResourceName) { - continue; - } - hasIcuData = true; - } else if (behavior === 'js-module-dotnet') { - continue; - } - if (resources.runtimeAssets.hasOwnProperty(name)) { - continue; + + if (bootConfig.aspnetCoreBrowserTools) { + // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 + bootConfig.environmentVariables['__ASPNETCORE_BROWSER_TOOLS'] = bootConfig.aspnetCoreBrowserTools; } - const asset: AssetEntry = { - name, - resolvedUrl: `_framework/${name}`, - hash: resources.runtime[name], - behavior, - }; - assets.push(asset); - downloadResource(asset); - } - if (!hasIcuData) { - config.globalizationMode = 'invariant'; - } + // Leverage the time while we are loading boot.config.json from the network to discover any potentially registered component on + // the document. + const discoveredComponents = discoverComponents(document, 'webassembly') as WebAssemblyComponentDescriptor[]; + const componentAttacher = new WebAssemblyComponentAttacher(discoveredComponents); + Blazor._internal.registeredComponents = { + getRegisteredComponentsCount: () => componentAttacher.getCount(), + getId: (index) => componentAttacher.getId(index), + getAssembly: (id) => componentAttacher.getAssembly(id), + getTypeName: (id) => componentAttacher.getTypeName(id), + getParameterDefinitions: (id) => componentAttacher.getParameterDefinitions(id) || '', + getParameterValues: (id) => componentAttacher.getParameterValues(id) || '', + }; - if (resourceLoader.bootConfig.modifiableAssemblies) { - // Configure the app to enable hot reload in Development. - environmentVariables['DOTNET_MODIFIABLE_ASSEMBLIES'] = resourceLoader.bootConfig.modifiableAssemblies; - } + Blazor._internal.getPersistedState = () => discoverPersistedState(document) || ''; - if (resourceLoader.bootConfig.icuDataMode === ICUDataMode.Sharded) { - environmentVariables['__BLAZOR_SHARDED_ICU'] = '1'; - } + Blazor._internal.attachRootComponentToElement = (selector, componentId, rendererId: any) => { + const element = componentAttacher.resolveRegisteredElement(selector); + if (!element) { + attachRootComponentToElement(selector, componentId, rendererId); + } else { + attachRootComponentToLogicalElement(rendererId, element, componentId, false); + } + }; - if (resourceLoader.startOptions.applicationCulture) { - // If a culture is specified via start options use that to initialize the Emscripten \ .NET culture. - environmentVariables['LANG'] = `${resourceLoader.startOptions.applicationCulture}.UTF-8`; - } + platformApi.jsInitializer = await fetchAndInvokeInitializers(bootConfig, options); - if (resourceLoader.bootConfig.aspnetCoreBrowserTools) { - // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 - environmentVariables['__ASPNETCORE_BROWSER_TOOLS'] = resourceLoader.bootConfig.aspnetCoreBrowserTools; - } + WebAssemblyConfigLoader.initAsync(bootConfig, bootConfig.applicationEnvironment!, options || {}); + }; const moduleConfig = (window['Module'] || {}) as typeof Module; // TODO (moduleConfig as any).preloadPlugins = []; // why do we need this ? const dotnetModuleConfig: DotnetModuleConfig = { ...moduleConfig, - configSrc: undefined, + onConfigLoaded, + onDownloadResourceProgress: setProgress, config, - downloadResource, - disableDotnet6Compatibility: false, print, printErr, }; @@ -353,20 +312,13 @@ function prepareRuntimeConfig(resourceLoader: WebAssemblyResourceLoader): Dotnet return dotnetModuleConfig; } -async function createRuntimeInstance(resourceLoader: WebAssemblyResourceLoader): Promise { - const { dotnet } = await importDotnetJs(resourceLoader); - const moduleConfig = prepareRuntimeConfig(resourceLoader); +async function createRuntimeInstance(options: Partial): Promise { + const platformApi: Partial = {}; + const { dotnet } = await importDotnetJs(options); + const moduleConfig = prepareRuntimeConfig(options, platformApi); const anyDotnet = (dotnet as any); - anyDotnet.withModuleConfig(moduleConfig); - - if (resourceLoader.bootConfig.startupMemoryCache !== undefined) { - anyDotnet.withStartupMemoryCache(resourceLoader.bootConfig.startupMemoryCache); - } - - if (resourceLoader.bootConfig.runtimeOptions) { - anyDotnet.withRuntimeOptions(resourceLoader.bootConfig.runtimeOptions); - } + anyDotnet.withStartupOptions(options).withModuleConfig(moduleConfig); const runtime = await dotnet.create(); const { MONO: mono, BINDING: binding, Module: module, setModuleImports, INTERNAL: mono_internal } = runtime; @@ -374,9 +326,13 @@ async function createRuntimeInstance(resourceLoader: WebAssemblyResourceLoader): BINDING = binding; MONO = mono; MONO_INTERNAL = mono_internal; + const resourceLoader = MONO_INTERNAL.resourceLoader; + platformApi.resourceLoader = resourceLoader; + + attachDebuggerHotkey(resourceLoader); Blazor._internal.dotNetCriticalError = printErr; - Blazor._internal.loadLazyAssembly = (assemblyNameToLoad) => loadLazyAssembly(resourceLoader, assemblyNameToLoad); + Blazor._internal.loadLazyAssembly = (assemblyNameToLoad) => loadLazyAssembly(MONO_INTERNAL.resourceLoader, assemblyNameToLoad); Blazor._internal.loadSatelliteAssemblies = (culturesToLoad, loader) => loadSatelliteAssemblies(resourceLoader, culturesToLoad, loader); setModuleImports('blazor-internal', { Blazor: { _internal: Blazor._internal }, @@ -388,16 +344,11 @@ async function createRuntimeInstance(resourceLoader: WebAssemblyResourceLoader): }, }); attachInteropInvoker(); - if (resourceLoader.bootConfig.debugBuild && resourceLoader.bootConfig.cacheBootResources) { - resourceLoader.logToConsole(); - } - resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background + + return platformApi as PlatformApi; } -let resourcesLoaded = 0; -let totalResources = 0; -function setProgress() { - resourcesLoaded++; +function setProgress(resourcesLoaded, totalResources) { const percentage = resourcesLoaded / totalResources * 100; document.documentElement.style.setProperty('--blazor-load-percentage', `${percentage}%`); document.documentElement.style.setProperty('--blazor-load-percentage-text', `"${Math.floor(percentage)}%"`); @@ -504,46 +455,6 @@ function attachInteropInvoker(): void { }); } -function getICUResourceName(bootConfig: BootJsonData, culture: string | undefined): string { - if (bootConfig.icuDataMode === ICUDataMode.Custom) { - const icuFiles = Object - .keys(bootConfig.resources.runtime) - .filter(n => n.startsWith('icudt') && n.endsWith('.dat')); - if (icuFiles.length === 1) { - const customIcuFile = icuFiles[0]; - return customIcuFile; - } - } - - const combinedICUResourceName = 'icudt.dat'; - if (!culture || bootConfig.icuDataMode === ICUDataMode.All) { - return combinedICUResourceName; - } - - const prefix = culture.split('-')[0]; - if (prefix === 'en' || - [ - 'fr', - 'fr-FR', - 'it', - 'it-IT', - 'de', - 'de-DE', - 'es', - 'es-ES', - ].includes(culture)) { - return 'icudt_EFIGS.dat'; - } - if ([ - 'zh', - 'ko', - 'ja', - ].includes(prefix)) { - return 'icudt_CJK.dat'; - } - return 'icudt_no_CJK.dat'; -} - function changeExtension(filename: string, newExtensionWithLeadingDot: string) { const lastDotIndex = filename.lastIndexOf('.'); if (lastDotIndex < 0) { diff --git a/src/Components/Web.JS/src/Platform/Platform.ts b/src/Components/Web.JS/src/Platform/Platform.ts index e7584e1706bc..10f42ff15a1f 100644 --- a/src/Components/Web.JS/src/Platform/Platform.ts +++ b/src/Components/Web.JS/src/Platform/Platform.ts @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. import { MonoObject, MonoString, MonoArray } from 'dotnet/dotnet-legacy'; +import { WebAssemblyStartOptions } from './WebAssemblyStartOptions'; import { WebAssemblyResourceLoader } from './WebAssemblyResourceLoader'; +import { JSInitializer } from '../JSInitializers/JSInitializers'; export interface Platform { - start(resourceLoader: WebAssemblyResourceLoader): Promise; + start(options: Partial): Promise; callEntryPoint(assemblyName: string): Promise; @@ -27,6 +29,11 @@ export interface Platform { invokeWhenHeapUnlocked(callback: Function): void; } +export type PlatformApi = { + resourceLoader: WebAssemblyResourceLoader, + jsInitializer: JSInitializer +} + export interface HeapLock { release(); } diff --git a/src/Components/Web.JS/src/Platform/WebAssemblyConfigLoader.ts b/src/Components/Web.JS/src/Platform/WebAssemblyConfigLoader.ts index 1fd2cc50ce33..b4331c391fcc 100644 --- a/src/Components/Web.JS/src/Platform/WebAssemblyConfigLoader.ts +++ b/src/Components/Web.JS/src/Platform/WebAssemblyConfigLoader.ts @@ -1,19 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { BootConfigResult } from './BootConfig'; import { WebAssemblyStartOptions } from './WebAssemblyStartOptions'; +import { BootJsonData } from 'dotnet'; import { Blazor } from '../GlobalExports'; export class WebAssemblyConfigLoader { - static async initAsync(bootConfigResult: BootConfigResult, startOptions: Partial): Promise { - Blazor._internal.getApplicationEnvironment = () => bootConfigResult.applicationEnvironment; + static async initAsync(bootConfig: BootJsonData, applicationEnvironment: string, startOptions: Partial): Promise { + Blazor._internal.getApplicationEnvironment = () => applicationEnvironment; - const configFiles = await Promise.all((bootConfigResult.bootConfig.config || []) - .filter(name => name === 'appsettings.json' || name === `appsettings.${bootConfigResult.applicationEnvironment}.json`) + const configFiles = await Promise.all((bootConfig.config || []) + .filter(name => name === 'appsettings.json' || name === `appsettings.${applicationEnvironment}.json`) .map(async name => ({ name, content: await getConfigBytes(name) }))); - Blazor._internal.getConfig = (fileName: string) : Uint8Array | undefined => { + Blazor._internal.getConfig = (fileName: string): Uint8Array | undefined => { const resolvedFile = configFiles.find(f => f.name === fileName); return resolvedFile ? resolvedFile.content : undefined; }; diff --git a/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts b/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts index 6d3c176c711e..ca9d588a30ad 100644 --- a/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts +++ b/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts @@ -1,226 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { toAbsoluteUri } from '../Services/NavigationManager'; -import { BootJsonData, ResourceList } from './BootConfig'; -import { WebAssemblyStartOptions, WebAssemblyBootResourceType } from './WebAssemblyStartOptions'; -const networkFetchCacheMode = 'no-cache'; +import { BootJsonData } from 'dotnet'; +import { WebAssemblyBootResourceType } from './WebAssemblyStartOptions'; -const anchorTagForAbsoluteUrlConversions = document.createElement('a'); -function toAbsoluteUrl(possiblyRelativeUrl: string) { - anchorTagForAbsoluteUrlConversions.href = possiblyRelativeUrl; - return anchorTagForAbsoluteUrlConversions.href; +export interface WebAssemblyResourceLoader { + readonly bootConfig: BootJsonData; + loadResources(resources: ResourceList, url: (name: string) => string, resourceType: WebAssemblyBootResourceType): LoadingResource[]; + loadResource(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): LoadingResource; } -export class WebAssemblyResourceLoader { - private usedCacheKeys: { [key: string]: boolean } = {}; - - private networkLoads: { [name: string]: LoadLogEntry } = {}; - - private cacheLoads: { [name: string]: LoadLogEntry } = {}; - - static async initAsync(bootConfig: BootJsonData, startOptions: Partial): Promise { - const cache = await getCacheToUseIfEnabled(bootConfig); - return new WebAssemblyResourceLoader(bootConfig, cache, startOptions); - } - - constructor(readonly bootConfig: BootJsonData, readonly cacheIfUsed: Cache | null, readonly startOptions: Partial) { - } - - loadResources(resources: ResourceList, url: (name: string) => string, resourceType: WebAssemblyBootResourceType): LoadingResource[] { - return Object.keys(resources) - .map(name => this.loadResource(name, url(name), resources[name], resourceType)); - } - - loadResource(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): LoadingResource { - const response = this.cacheIfUsed - ? this.loadResourceWithCaching(this.cacheIfUsed, name, url, contentHash, resourceType) - : this.loadResourceWithoutCaching(name, url, contentHash, resourceType); - - return { name, url: toAbsoluteUrl(url), response }; - } - - logToConsole(): void { - const cacheLoadsEntries = Object.values(this.cacheLoads); - const networkLoadsEntries = Object.values(this.networkLoads); - const cacheResponseBytes = countTotalBytes(cacheLoadsEntries); - const networkResponseBytes = countTotalBytes(networkLoadsEntries); - const totalResponseBytes = cacheResponseBytes + networkResponseBytes; - if (totalResponseBytes === 0) { - // We have no perf stats to display, likely because caching is not in use. - return; - } - - const linkerDisabledWarning = this.bootConfig.linkerEnabled ? '%c' : '\n%cThis application was built with linking (tree shaking) disabled. Published applications will be significantly smaller.'; - console.groupCollapsed(`%cblazor%c Loaded ${toDataSizeString(totalResponseBytes)} resources${linkerDisabledWarning}`, 'background: purple; color: white; padding: 1px 3px; border-radius: 3px;', 'font-weight: bold;', 'font-weight: normal;'); - - if (cacheLoadsEntries.length) { - console.groupCollapsed(`Loaded ${toDataSizeString(cacheResponseBytes)} resources from cache`); - console.table(this.cacheLoads); - console.groupEnd(); - } - - if (networkLoadsEntries.length) { - console.groupCollapsed(`Loaded ${toDataSizeString(networkResponseBytes)} resources from network`); - console.table(this.networkLoads); - console.groupEnd(); - } - - console.groupEnd(); - } - - async purgeUnusedCacheEntriesAsync(): Promise { - // We want to keep the cache small because, even though the browser will evict entries if it - // gets too big, we don't want to be considered problematic by the end user viewing storage stats - const cache = this.cacheIfUsed; - if (cache) { - const cachedRequests = await cache.keys(); - const deletionPromises = cachedRequests.map(async cachedRequest => { - if (!(cachedRequest.url in this.usedCacheKeys)) { - await cache.delete(cachedRequest); - } - }); - - await Promise.all(deletionPromises); - } - } - - 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. - if (!contentHash || contentHash.length === 0) { - throw new Error('Content hash is required'); - } - - const cacheKey = toAbsoluteUri(`${url}.${contentHash}`); - this.usedCacheKeys[cacheKey] = true; - - let cachedResponse: Response | undefined; - try { - cachedResponse = await cache.match(cacheKey); - } catch { - // Be tolerant to errors reading from the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where - // chromium browsers may sometimes throw when working with the cache. - } - - if (cachedResponse) { - // It's in the cache. - const responseBytes = parseInt(cachedResponse.headers.get('content-length') || '0'); - this.cacheLoads[name] = { responseBytes }; - return cachedResponse; - } else { - // It's not in the cache. Fetch from network. - 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 { - // 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. - const responseData = await response.clone().arrayBuffer(); - - // Now is an ideal moment to capture the performance stats for the request, since it - // only just completed and is most likely to still be in the buffer. However this is - // only done on a 'best effort' basis. Even if we do receive an entry, some of its - // properties may be blanked out if it was a CORS request. - const performanceEntry = getPerformanceEntry(response.url); - const responseBytes = (performanceEntry && performanceEntry.encodedBodySize) || undefined; - this.networkLoads[name] = { responseBytes }; - - // Add to cache as a custom response object so we can track extra data such as responseBytes - // We can't rely on the server sending content-length (ASP.NET Core doesn't by default) - const responseToCache = new Response(responseData, { - headers: { - 'content-type': response.headers.get('content-type') || '', - 'content-length': (responseBytes || response.headers.get('content-length') || '').toString(), - }, - }); - - try { - await cache.put(cacheKey, responseToCache); - } catch { - // Be tolerant to errors writing to the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where - // chromium browsers may sometimes throw when performing cache operations. - } - } -} - -async function getCacheToUseIfEnabled(bootConfig: BootJsonData): Promise { - // caches will be undefined if we're running on an insecure origin (secure means https or localhost) - if (!bootConfig.cacheBootResources || typeof caches === 'undefined') { - return null; - } - - // cache integrity is compromised if the first request has been served over http (except localhost) - // in this case, we want to disable caching and integrity validation - if (window.isSecureContext === false) { - return null; - } - - // Define a separate cache for each base href, so we're isolated from any other - // Blazor application running on the same origin. We need this so that we're free - // to purge from the cache anything we're not using and don't let it keep growing, - // since we don't want to be worst offenders for space usage. - const relativeBaseHref = document.baseURI.substring(document.location.origin.length); - const cacheName = `blazor-resources-${relativeBaseHref}`; - - try { - // There's a Chromium bug we need to be aware of here: the CacheStorage APIs say that when - // caches.open(name) returns a promise that succeeds, the value is meant to be a Cache instance. - // However, if the browser was launched with a --user-data-dir param that's "too long" in some sense, - // then even through the promise resolves as success, the value given is `undefined`. - // See https://stackoverflow.com/a/46626574 and https://bugs.chromium.org/p/chromium/issues/detail?id=1054541 - // If we see this happening, return "null" to mean "proceed without caching". - return (await caches.open(cacheName)) || null; - } catch { - // There's no known scenario where we should get an exception here, but considering the - // Chromium bug above, let's tolerate it and treat as "proceed without caching". - return null; - } -} - -function countTotalBytes(loads: LoadLogEntry[]) { - return loads.reduce((prev, item) => prev + (item.responseBytes || 0), 0); -} - -function toDataSizeString(byteCount: number) { - return `${(byteCount / (1024 * 1024)).toFixed(2)} MB`; -} - -function getPerformanceEntry(url: string): PerformanceResourceTiming | undefined { - if (typeof performance !== 'undefined') { - return performance.getEntriesByName(url)[0] as PerformanceResourceTiming; - } -} - -interface LoadLogEntry { - responseBytes: number | undefined; -} +export type ResourceList = { [name: string]: string }; export interface LoadingResource { name: string; diff --git a/src/Components/test/E2ETest/Tests/BootResourceCachingTest.cs b/src/Components/test/E2ETest/Tests/BootResourceCachingTest.cs index 46d4bd541c91..e93e55989408 100644 --- a/src/Components/test/E2ETest/Tests/BootResourceCachingTest.cs +++ b/src/Components/test/E2ETest/Tests/BootResourceCachingTest.cs @@ -19,7 +19,7 @@ public class BootResourceCachingTest : ServerTestBase { // The cache name is derived from the application's base href value (in this case, '/') - private const string CacheName = "blazor-resources-/"; + private const string CacheName = "dotnet-resources-/"; public BootResourceCachingTest( BrowserFixture browserFixture,