-
Couldn't load subscription status.
- Fork 5.2k
[browser] Minimal blazor.boot.json integration #84296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
81e3601
Map boot config to config in normalizeConfig.
maraf 684ed3e
Integrate WebAssemblyResourceLoader
maraf 3279ffa
Update browser template
maraf 7d0a9b2
Clean up
maraf ccf9bc7
Update with latest blazor bits
maraf c7e60d7
Introduce necessary hooks
maraf a1a4bc9
Drop WebAssemblyResourceLoader
maraf ee5c964
Update comment for hasDebuggingEnabled
maraf 71dcdef
Fix getApplicationEnvironment declaration
maraf d559cab
startupMemoryCache + runtimeOptions
maraf f250291
Drop same blazor responsibilities
maraf d9c5e61
ApplicationEnvironment in monoConfig
maraf 68f1d5a
LogToConsole
maraf d46828f
Merge boot-config with mono-config
maraf bbba07e
Fix console
maraf 3ce851d
Merge remote-tracking branch 'upstream/main' into WasmBootConfig
maraf 89a0acf
Revert "Update browser template"
maraf cef4a68
dotnet.d.ts
maraf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // 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<Response> | null | undefined; | ||
|
|
||
| export class BootConfigResult { | ||
| private constructor(public bootConfig: BootJsonData, public applicationEnvironment: string) { | ||
| } | ||
|
|
||
| static async initAsync(loadBootResource?: LoadBootResourceCallback, environment?: string): Promise<BootConfigResult> { | ||
| 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<Response> { | ||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| import { Module } from "../imports"; | ||
| import { BootConfigResult } from "./BootConfig"; | ||
| import { WebAssemblyStartOptions } from "./WebAssemblyStartOptions"; | ||
|
|
||
| export class WebAssemblyConfigLoader { | ||
| static async initAsync(bootConfigResult: BootConfigResult, startOptions: Partial<WebAssemblyStartOptions>): Promise<void> { | ||
| // TODO MF: Hook getApplicationEnvironment | ||
| // Blazor._internal.getApplicationEnvironment = () => bootConfigResult.applicationEnvironment; | ||
|
|
||
| const configFiles = await Promise.all((bootConfigResult.bootConfig.config || []) | ||
| .filter(name => name === "appsettings.json" || name === `appsettings.${bootConfigResult.applicationEnvironment}.json`) | ||
| .map(async name => ({ name, content: await getConfigBytes(name) }))); | ||
|
|
||
| const anyConfig = Module.config as any; | ||
| if (anyConfig.onAppConfigLoaded) { | ||
| anyConfig.onAppConfigLoaded(configFiles); | ||
| } | ||
|
|
||
| // TODO MF: Hook getConfig | ||
| // Blazor._internal.getConfig = (fileName: string) : Uint8Array | undefined => { | ||
maraf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // const resolvedFile = configFiles.find(f => f.name === fileName); | ||
| // return resolvedFile ? resolvedFile.content : undefined; | ||
| // }; | ||
|
|
||
| async function getConfigBytes(file: string): Promise<Uint8Array> { | ||
| // Allow developers to override how the config is loaded | ||
| if (startOptions.loadBootResource) { | ||
| const customLoadResult = startOptions.loadBootResource("configuration", file, file, ""); | ||
| if (customLoadResult instanceof Promise) { | ||
| // They are supplying an entire custom response, so just use that | ||
| return new Uint8Array(await (await customLoadResult).arrayBuffer()); | ||
| } else if (typeof customLoadResult === "string") { | ||
| // They are supplying a custom URL, so use that with the default fetch behavior | ||
| file = customLoadResult; | ||
| } | ||
| } | ||
|
|
||
| const response = await fetch(file, { | ||
| method: "GET", | ||
| credentials: "include", | ||
| cache: "no-cache", | ||
| }); | ||
|
|
||
| return new Uint8Array(await response.arrayBuffer()); | ||
| } | ||
| } | ||
| } | ||
229 changes: 229 additions & 0 deletions
229
src/mono/wasm/runtime/blazor/WebAssemblyResourceLoader.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| // 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 "./_Polyfill"; | ||
| import { BootJsonData, ResourceList } from "./BootConfig"; | ||
| import { WebAssemblyStartOptions, WebAssemblyBootResourceType } from "./WebAssemblyStartOptions"; | ||
| const networkFetchCacheMode = "no-cache"; | ||
|
|
||
| const anchorTagForAbsoluteUrlConversions = document.createElement("a"); | ||
| function toAbsoluteUrl(possiblyRelativeUrl: string) { | ||
| anchorTagForAbsoluteUrlConversions.href = possiblyRelativeUrl; | ||
| return anchorTagForAbsoluteUrlConversions.href; | ||
| } | ||
|
|
||
| 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<WebAssemblyStartOptions>): Promise<WebAssemblyResourceLoader> { | ||
| const cache = await getCacheToUseIfEnabled(bootConfig); | ||
| return new WebAssemblyResourceLoader(bootConfig, cache, startOptions); | ||
| } | ||
|
|
||
| constructor(readonly bootConfig: BootJsonData, readonly cacheIfUsed: Cache | null, readonly startOptions: Partial<WebAssemblyStartOptions>) { | ||
| } | ||
|
|
||
| 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<void> { | ||
| // 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<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. | ||
| 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<Cache | null> { | ||
| // 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}`; | ||
maraf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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 interface LoadingResource { | ||
| name: string; | ||
| url: string; | ||
| response: Promise<Response>; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.