|
| 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 | +} |
0 commit comments