Skip to content
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

[browser] Minimal blazor.boot.json integration #84296

Merged
merged 18 commits into from
Apr 18, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/mono/wasm/runtime/blazor/BootConfig.ts
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 { Module } from "../imports";
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;
}

const applicationEnvironment = environment || (Module.getApplicationEnvironment && Module.getApplicationEnvironment(bootConfigResponse)) || "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 | undefined;
readonly runtimeOptions: string[] | undefined;

// 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
}
223 changes: 223 additions & 0 deletions src/mono/wasm/runtime/blazor/WebAssemblyResourceLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// 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";

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: toAbsoluteUri(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(`%cdotnet%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 = `dotnet-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 interface LoadingResource {
name: string;
url: string;
response: Promise<Response>;
}
30 changes: 30 additions & 0 deletions src/mono/wasm/runtime/blazor/WebAssemblyStartOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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;

/**
* Override built-in environment setting on start.
*/
environment?: string;

/**
* Gets the application culture. This is a name specified in the BCP 47 format. See https://tools.ietf.org/html/bcp47
*/
applicationCulture?: string;
}

// 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" | "globalization" | "manifest" | "configuration";
Loading