Skip to content

Commit 5100b5b

Browse files
authored
[browser] Minimal blazor.boot.json integration (#84296)
1 parent a3ed77d commit 5100b5b

10 files changed

+695
-14
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
import { Module } from "../imports";
5+
import { WebAssemblyBootResourceType } from "./WebAssemblyStartOptions";
6+
7+
type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string) => string | Promise<Response> | null | undefined;
8+
9+
export class BootConfigResult {
10+
private constructor(public bootConfig: BootJsonData, public applicationEnvironment: string) {
11+
}
12+
13+
static async initAsync(loadBootResource?: LoadBootResourceCallback, environment?: string): Promise<BootConfigResult> {
14+
const loaderResponse = loadBootResource !== undefined ?
15+
loadBootResource("manifest", "blazor.boot.json", "_framework/blazor.boot.json", "") :
16+
defaultLoadBlazorBootJson("_framework/blazor.boot.json");
17+
18+
let bootConfigResponse: Response;
19+
20+
if (!loaderResponse) {
21+
bootConfigResponse = await defaultLoadBlazorBootJson("_framework/blazor.boot.json");
22+
} else if (typeof loaderResponse === "string") {
23+
bootConfigResponse = await defaultLoadBlazorBootJson(loaderResponse);
24+
} else {
25+
bootConfigResponse = await loaderResponse;
26+
}
27+
28+
const applicationEnvironment = environment || (Module.getApplicationEnvironment && Module.getApplicationEnvironment(bootConfigResponse)) || "Production";
29+
const bootConfig: BootJsonData = await bootConfigResponse.json();
30+
bootConfig.modifiableAssemblies = bootConfigResponse.headers.get("DOTNET-MODIFIABLE-ASSEMBLIES");
31+
bootConfig.aspnetCoreBrowserTools = bootConfigResponse.headers.get("ASPNETCORE-BROWSER-TOOLS");
32+
33+
return new BootConfigResult(bootConfig, applicationEnvironment);
34+
35+
function defaultLoadBlazorBootJson(url: string): Promise<Response> {
36+
return fetch(url, {
37+
method: "GET",
38+
credentials: "include",
39+
cache: "no-cache",
40+
});
41+
}
42+
}
43+
}
44+
45+
// Keep in sync with Microsoft.NET.Sdk.WebAssembly.BootJsonData from the WasmSDK
46+
export interface BootJsonData {
47+
readonly entryAssembly: string;
48+
readonly resources: ResourceGroups;
49+
/** Gets a value that determines if this boot config was produced from a non-published build (i.e. dotnet build or dotnet run) */
50+
readonly debugBuild: boolean;
51+
readonly linkerEnabled: boolean;
52+
readonly cacheBootResources: boolean;
53+
readonly config: string[];
54+
readonly icuDataMode: ICUDataMode;
55+
readonly startupMemoryCache: boolean | undefined;
56+
readonly runtimeOptions: string[] | undefined;
57+
58+
// These properties are tacked on, and not found in the boot.json file
59+
modifiableAssemblies: string | null;
60+
aspnetCoreBrowserTools: string | null;
61+
}
62+
63+
export type BootJsonDataExtension = { [extensionName: string]: ResourceList };
64+
65+
export interface ResourceGroups {
66+
readonly assembly: ResourceList;
67+
readonly lazyAssembly: ResourceList;
68+
readonly pdb?: ResourceList;
69+
readonly runtime: ResourceList;
70+
readonly satelliteResources?: { [cultureName: string]: ResourceList };
71+
readonly libraryInitializers?: ResourceList,
72+
readonly extensions?: BootJsonDataExtension
73+
readonly runtimeAssets: ExtendedResourceList;
74+
}
75+
76+
export type ResourceList = { [name: string]: string };
77+
export type ExtendedResourceList = {
78+
[name: string]: {
79+
hash: string,
80+
behavior: string
81+
}
82+
};
83+
84+
export enum ICUDataMode {
85+
Sharded,
86+
All,
87+
Invariant,
88+
Custom
89+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
import { toAbsoluteUri } from "./_Polyfill";
5+
import { BootJsonData, ResourceList } from "./BootConfig";
6+
import { WebAssemblyStartOptions, WebAssemblyBootResourceType } from "./WebAssemblyStartOptions";
7+
const networkFetchCacheMode = "no-cache";
8+
9+
export class WebAssemblyResourceLoader {
10+
private usedCacheKeys: { [key: string]: boolean } = {};
11+
12+
private networkLoads: { [name: string]: LoadLogEntry } = {};
13+
14+
private cacheLoads: { [name: string]: LoadLogEntry } = {};
15+
16+
static async initAsync(bootConfig: BootJsonData, startOptions: Partial<WebAssemblyStartOptions>): Promise<WebAssemblyResourceLoader> {
17+
const cache = await getCacheToUseIfEnabled(bootConfig);
18+
return new WebAssemblyResourceLoader(bootConfig, cache, startOptions);
19+
}
20+
21+
constructor(readonly bootConfig: BootJsonData, readonly cacheIfUsed: Cache | null, readonly startOptions: Partial<WebAssemblyStartOptions>) {
22+
}
23+
24+
loadResources(resources: ResourceList, url: (name: string) => string, resourceType: WebAssemblyBootResourceType): LoadingResource[] {
25+
return Object.keys(resources)
26+
.map(name => this.loadResource(name, url(name), resources[name], resourceType));
27+
}
28+
29+
loadResource(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): LoadingResource {
30+
const response = this.cacheIfUsed
31+
? this.loadResourceWithCaching(this.cacheIfUsed, name, url, contentHash, resourceType)
32+
: this.loadResourceWithoutCaching(name, url, contentHash, resourceType);
33+
34+
return { name, url: toAbsoluteUri(url), response };
35+
}
36+
37+
logToConsole(): void {
38+
const cacheLoadsEntries = Object.values(this.cacheLoads);
39+
const networkLoadsEntries = Object.values(this.networkLoads);
40+
const cacheResponseBytes = countTotalBytes(cacheLoadsEntries);
41+
const networkResponseBytes = countTotalBytes(networkLoadsEntries);
42+
const totalResponseBytes = cacheResponseBytes + networkResponseBytes;
43+
if (totalResponseBytes === 0) {
44+
// We have no perf stats to display, likely because caching is not in use.
45+
return;
46+
}
47+
48+
const linkerDisabledWarning = this.bootConfig.linkerEnabled ? "%c" : "\n%cThis application was built with linking (tree shaking) disabled. Published applications will be significantly smaller.";
49+
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;");
50+
51+
if (cacheLoadsEntries.length) {
52+
console.groupCollapsed(`Loaded ${toDataSizeString(cacheResponseBytes)} resources from cache`);
53+
console.table(this.cacheLoads);
54+
console.groupEnd();
55+
}
56+
57+
if (networkLoadsEntries.length) {
58+
console.groupCollapsed(`Loaded ${toDataSizeString(networkResponseBytes)} resources from network`);
59+
console.table(this.networkLoads);
60+
console.groupEnd();
61+
}
62+
63+
console.groupEnd();
64+
}
65+
66+
async purgeUnusedCacheEntriesAsync(): Promise<void> {
67+
// We want to keep the cache small because, even though the browser will evict entries if it
68+
// gets too big, we don't want to be considered problematic by the end user viewing storage stats
69+
const cache = this.cacheIfUsed;
70+
if (cache) {
71+
const cachedRequests = await cache.keys();
72+
const deletionPromises = cachedRequests.map(async cachedRequest => {
73+
if (!(cachedRequest.url in this.usedCacheKeys)) {
74+
await cache.delete(cachedRequest);
75+
}
76+
});
77+
78+
await Promise.all(deletionPromises);
79+
}
80+
}
81+
82+
private async loadResourceWithCaching(cache: Cache, name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType) {
83+
// Since we are going to cache the response, we require there to be a content hash for integrity
84+
// checking. We don't want to cache bad responses. There should always be a hash, because the build
85+
// process generates this data.
86+
if (!contentHash || contentHash.length === 0) {
87+
throw new Error("Content hash is required");
88+
}
89+
90+
const cacheKey = toAbsoluteUri(`${url}.${contentHash}`);
91+
this.usedCacheKeys[cacheKey] = true;
92+
93+
let cachedResponse: Response | undefined;
94+
try {
95+
cachedResponse = await cache.match(cacheKey);
96+
} catch {
97+
// Be tolerant to errors reading from the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where
98+
// chromium browsers may sometimes throw when working with the cache.
99+
}
100+
101+
if (cachedResponse) {
102+
// It's in the cache.
103+
const responseBytes = parseInt(cachedResponse.headers.get("content-length") || "0");
104+
this.cacheLoads[name] = { responseBytes };
105+
return cachedResponse;
106+
} else {
107+
// It's not in the cache. Fetch from network.
108+
const networkResponse = await this.loadResourceWithoutCaching(name, url, contentHash, resourceType);
109+
this.addToCacheAsync(cache, name, cacheKey, networkResponse); // Don't await - add to cache in background
110+
return networkResponse;
111+
}
112+
}
113+
114+
private loadResourceWithoutCaching(name: string, url: string, contentHash: string, resourceType: WebAssemblyBootResourceType): Promise<Response> {
115+
// Allow developers to override how the resource is loaded
116+
if (this.startOptions.loadBootResource) {
117+
const customLoadResult = this.startOptions.loadBootResource(resourceType, name, url, contentHash);
118+
if (customLoadResult instanceof Promise) {
119+
// They are supplying an entire custom response, so just use that
120+
return customLoadResult;
121+
} else if (typeof customLoadResult === "string") {
122+
// They are supplying a custom URL, so use that with the default fetch behavior
123+
url = customLoadResult;
124+
}
125+
}
126+
127+
// Note that if cacheBootResources was explicitly disabled, we also bypass hash checking
128+
// This is to give developers an easy opt-out from the entire caching/validation flow if
129+
// there's anything they don't like about it.
130+
return fetch(url, {
131+
cache: networkFetchCacheMode,
132+
integrity: this.bootConfig.cacheBootResources ? contentHash : undefined,
133+
});
134+
}
135+
136+
private async addToCacheAsync(cache: Cache, name: string, cacheKey: string, response: Response) {
137+
// We have to clone in order to put this in the cache *and* not prevent other code from
138+
// reading the original response stream.
139+
const responseData = await response.clone().arrayBuffer();
140+
141+
// Now is an ideal moment to capture the performance stats for the request, since it
142+
// only just completed and is most likely to still be in the buffer. However this is
143+
// only done on a 'best effort' basis. Even if we do receive an entry, some of its
144+
// properties may be blanked out if it was a CORS request.
145+
const performanceEntry = getPerformanceEntry(response.url);
146+
const responseBytes = (performanceEntry && performanceEntry.encodedBodySize) || undefined;
147+
this.networkLoads[name] = { responseBytes };
148+
149+
// Add to cache as a custom response object so we can track extra data such as responseBytes
150+
// We can't rely on the server sending content-length (ASP.NET Core doesn't by default)
151+
const responseToCache = new Response(responseData, {
152+
headers: {
153+
"content-type": response.headers.get("content-type") || "",
154+
"content-length": (responseBytes || response.headers.get("content-length") || "").toString(),
155+
},
156+
});
157+
158+
try {
159+
await cache.put(cacheKey, responseToCache);
160+
} catch {
161+
// Be tolerant to errors writing to the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where
162+
// chromium browsers may sometimes throw when performing cache operations.
163+
}
164+
}
165+
}
166+
167+
async function getCacheToUseIfEnabled(bootConfig: BootJsonData): Promise<Cache | null> {
168+
// caches will be undefined if we're running on an insecure origin (secure means https or localhost)
169+
if (!bootConfig.cacheBootResources || typeof caches === "undefined") {
170+
return null;
171+
}
172+
173+
// cache integrity is compromised if the first request has been served over http (except localhost)
174+
// in this case, we want to disable caching and integrity validation
175+
if (window.isSecureContext === false) {
176+
return null;
177+
}
178+
179+
// Define a separate cache for each base href, so we're isolated from any other
180+
// Blazor application running on the same origin. We need this so that we're free
181+
// to purge from the cache anything we're not using and don't let it keep growing,
182+
// since we don't want to be worst offenders for space usage.
183+
const relativeBaseHref = document.baseURI.substring(document.location.origin.length);
184+
const cacheName = `dotnet-resources-${relativeBaseHref}`;
185+
186+
try {
187+
// There's a Chromium bug we need to be aware of here: the CacheStorage APIs say that when
188+
// caches.open(name) returns a promise that succeeds, the value is meant to be a Cache instance.
189+
// However, if the browser was launched with a --user-data-dir param that's "too long" in some sense,
190+
// then even through the promise resolves as success, the value given is `undefined`.
191+
// See https://stackoverflow.com/a/46626574 and https://bugs.chromium.org/p/chromium/issues/detail?id=1054541
192+
// If we see this happening, return "null" to mean "proceed without caching".
193+
return (await caches.open(cacheName)) || null;
194+
} catch {
195+
// There's no known scenario where we should get an exception here, but considering the
196+
// Chromium bug above, let's tolerate it and treat as "proceed without caching".
197+
return null;
198+
}
199+
}
200+
201+
function countTotalBytes(loads: LoadLogEntry[]) {
202+
return loads.reduce((prev, item) => prev + (item.responseBytes || 0), 0);
203+
}
204+
205+
function toDataSizeString(byteCount: number) {
206+
return `${(byteCount / (1024 * 1024)).toFixed(2)} MB`;
207+
}
208+
209+
function getPerformanceEntry(url: string): PerformanceResourceTiming | undefined {
210+
if (typeof performance !== "undefined") {
211+
return performance.getEntriesByName(url)[0] as PerformanceResourceTiming;
212+
}
213+
}
214+
215+
interface LoadLogEntry {
216+
responseBytes: number | undefined;
217+
}
218+
219+
export interface LoadingResource {
220+
name: string;
221+
url: string;
222+
response: Promise<Response>;
223+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
export interface WebAssemblyStartOptions {
5+
/**
6+
* Overrides the built-in boot resource loading mechanism so that boot resources can be fetched
7+
* from a custom source, such as an external CDN.
8+
* @param type The type of the resource to be loaded.
9+
* @param name The name of the resource to be loaded.
10+
* @param defaultUri The URI from which the framework would fetch the resource by default. The URI may be relative or absolute.
11+
* @param integrity The integrity string representing the expected content in the response.
12+
* @returns A URI string or a Response promise to override the loading process, or null/undefined to allow the default loading behavior.
13+
*/
14+
loadBootResource(type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string): string | Promise<Response> | null | undefined;
15+
16+
/**
17+
* Override built-in environment setting on start.
18+
*/
19+
environment?: string;
20+
21+
/**
22+
* Gets the application culture. This is a name specified in the BCP 47 format. See https://tools.ietf.org/html/bcp47
23+
*/
24+
applicationCulture?: string;
25+
}
26+
27+
// This type doesn't have to align with anything in BootConfig.
28+
// Instead, this represents the public API through which certain aspects
29+
// of boot resource loading can be customized.
30+
export type WebAssemblyBootResourceType = "assembly" | "pdb" | "dotnetjs" | "dotnetwasm" | "globalization" | "manifest" | "configuration";

0 commit comments

Comments
 (0)