Skip to content

Commit

Permalink
[browser] Generate boot config as javascript module (#112947)
Browse files Browse the repository at this point in the history
  • Loading branch information
maraf authored Mar 4, 2025
1 parent 9f3c32d commit bb146ad
Show file tree
Hide file tree
Showing 19 changed files with 159 additions and 63 deletions.
5 changes: 4 additions & 1 deletion src/mono/browser/runtime/dotnet.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ type ResourceList = {
* @returns A URI string or a Response promise to override the loading process, or null/undefined to allow the default loading behavior.
* When returned string is not qualified with `./` or absolute URL, it will be resolved against the application base URI.
*/
type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string, behavior: AssetBehaviors) => string | Promise<Response> | null | undefined;
type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string, behavior: AssetBehaviors) => string | Promise<Response> | Promise<BootModule> | null | undefined;
type BootModule = {
config: MonoConfig;
};
interface LoadingResource {
name: string;
url: string;
Expand Down
6 changes: 3 additions & 3 deletions src/mono/browser/runtime/loader/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import WasmEnableThreads from "consts:wasmEnableThreads";

import { PThreadPtrNull, type AssetEntryInternal, type PThreadWorker, type PromiseAndController } from "../types/internal";
import { type AssetBehaviors, type AssetEntry, type LoadingResource, type ResourceList, type SingleAssetBehaviors as SingleAssetBehaviors, type WebAssemblyBootResourceType } from "../types";
import { BootModule, type AssetBehaviors, type AssetEntry, type LoadingResource, type ResourceList, type SingleAssetBehaviors as SingleAssetBehaviors, type WebAssemblyBootResourceType } from "../types";
import { ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_SHELL, ENVIRONMENT_IS_WEB, ENVIRONMENT_IS_WORKER, loaderHelpers, mono_assert, runtimeHelpers } from "./globals";
import { createPromiseController } from "./promise-controller";
import { mono_log_debug, mono_log_warn } from "./logging";
Expand Down Expand Up @@ -725,7 +725,7 @@ function fetchResource (asset: AssetEntryInternal): Promise<Response> {
const customLoadResult = invokeLoadBootResource(asset);
if (customLoadResult instanceof Promise) {
// They are supplying an entire custom response, so just use that
return customLoadResult;
return customLoadResult as Promise<Response>;
} else if (typeof customLoadResult === "string") {
url = customLoadResult;
}
Expand Down Expand Up @@ -766,7 +766,7 @@ const monoToBlazorAssetTypeMap: { [key: string]: WebAssemblyBootResourceType | u
"js-module-threads": "dotnetjs"
};

function invokeLoadBootResource (asset: AssetEntryInternal): string | Promise<Response> | null | undefined {
function invokeLoadBootResource (asset: AssetEntryInternal): string | Promise<Response> | Promise<BootModule> | null | undefined {
if (loaderHelpers.loadBootResource) {
const requestHash = asset.hash ?? "";
const url = asset.resolvedUrl!;
Expand Down
50 changes: 37 additions & 13 deletions src/mono/browser/runtime/loader/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import BuildConfiguration from "consts:configuration";
import WasmEnableThreads from "consts:wasmEnableThreads";

import { type DotnetModuleInternal, type MonoConfigInternal, JSThreadBlockingMode } from "../types/internal";
import type { DotnetModuleConfig, MonoConfig, ResourceGroups, ResourceList } from "../types";
import type { BootModule, DotnetModuleConfig, MonoConfig, ResourceGroups, ResourceList } from "../types";
import { exportedRuntimeAPI, loaderHelpers, runtimeHelpers } from "./globals";
import { mono_log_error, mono_log_debug } from "./logging";
import { importLibraryInitializers, invokeLibraryInitializers } from "./libraryInitializers";
Expand Down Expand Up @@ -240,7 +240,10 @@ export async function mono_wasm_load_config (module: DotnetModuleInternal): Prom
try {
if (!module.configSrc && (!loaderHelpers.config || Object.keys(loaderHelpers.config).length === 0 || (!loaderHelpers.config.assets && !loaderHelpers.config.resources))) {
// if config file location nor assets are provided
module.configSrc = "./blazor.boot.json";
// Temporal way for tests to opt-in for using boot.js
module.configSrc = (globalThis as any)["__DOTNET_INTERNAL_BOOT_CONFIG_SRC"]
?? globalThis.window?.document?.documentElement?.getAttribute("data-dotnet_internal_boot_config_src")
?? "./blazor.boot.json";
}

configFilePath = module.configSrc;
Expand Down Expand Up @@ -289,24 +292,45 @@ export function isDebuggingSupported (): boolean {
async function loadBootConfig (module: DotnetModuleInternal): Promise<void> {
const defaultConfigSrc = loaderHelpers.locateFile(module.configSrc!);

const loaderResponse = loaderHelpers.loadBootResource !== undefined ?
loaderHelpers.loadBootResource("manifest", "blazor.boot.json", defaultConfigSrc, "", "manifest") :
defaultLoadBootConfig(defaultConfigSrc);

let loadConfigResponse: Response;
let loaderResponse = null;
if (loaderHelpers.loadBootResource !== undefined) {
loaderResponse = loaderHelpers.loadBootResource("manifest", "blazor.boot.json", defaultConfigSrc, "", "manifest");
}

let loadedConfigResponse: Response | null = null;
let loadedConfig: MonoConfig;
if (!loaderResponse) {
loadConfigResponse = await defaultLoadBootConfig(appendUniqueQuery(defaultConfigSrc, "manifest"));
if (defaultConfigSrc.includes(".json")) {
loadedConfigResponse = await fetchBootConfig(appendUniqueQuery(defaultConfigSrc, "manifest"));
loadedConfig = await readBootConfigResponse(loadedConfigResponse);
} else {
loadedConfig = (await import(appendUniqueQuery(defaultConfigSrc, "manifest"))).config;
}
} else if (typeof loaderResponse === "string") {
loadConfigResponse = await defaultLoadBootConfig(makeURLAbsoluteWithApplicationBase(loaderResponse));
if (loaderResponse.includes(".json")) {
loadedConfigResponse = await fetchBootConfig(makeURLAbsoluteWithApplicationBase(loaderResponse));
loadedConfig = await readBootConfigResponse(loadedConfigResponse);
} else {
loadedConfig = (await import(makeURLAbsoluteWithApplicationBase(loaderResponse))).config;
}
} else {
loadConfigResponse = await loaderResponse;
const loadedResponse = await loaderResponse;
if (typeof (loadedResponse as Response).json == "function") {
loadedConfigResponse = loadedResponse as Response;
loadedConfig = await readBootConfigResponse(loadedConfigResponse);
} else {
// If the response doesn't contain .json(), consider it an imported module.
loadedConfig = (loadedResponse as BootModule).config;
}
}

const loadedConfig: MonoConfig = await readBootConfigResponse(loadConfigResponse);
deep_merge_config(loaderHelpers.config, loadedConfig);

function defaultLoadBootConfig (url: string): Promise<Response> {
if (!loaderHelpers.config.applicationEnvironment) {
loaderHelpers.config.applicationEnvironment = "Production";
}

function fetchBootConfig (url: string): Promise<Response> {
return loaderHelpers.fetch_like(url, {
method: "GET",
credentials: "include",
Expand All @@ -320,7 +344,7 @@ async function readBootConfigResponse (loadConfigResponse: Response): Promise<Mo
const loadedConfig: MonoConfig = await loadConfigResponse.json();

if (!config.applicationEnvironment) {
loadedConfig.applicationEnvironment = loadConfigResponse.headers.get("Blazor-Environment") || loadConfigResponse.headers.get("DotNet-Environment") || "Production";
loadedConfig.applicationEnvironment = loadConfigResponse.headers.get("Blazor-Environment") || loadConfigResponse.headers.get("DotNet-Environment") || undefined;
}

if (!loadedConfig.environmentVariables)
Expand Down
6 changes: 5 additions & 1 deletion src/mono/browser/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,11 @@ export type ResourceList = { [name: string]: string | null | "" };
* @returns A URI string or a Response promise to override the loading process, or null/undefined to allow the default loading behavior.
* When returned string is not qualified with `./` or absolute URL, it will be resolved against the application base URI.
*/
export type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string, behavior: AssetBehaviors) => string | Promise<Response> | null | undefined;
export type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string, behavior: AssetBehaviors) => string | Promise<Response> | Promise<BootModule> | null | undefined;

export type BootModule = {
config: MonoConfig
}

export interface LoadingResource {
name: string;
Expand Down
2 changes: 1 addition & 1 deletion src/mono/wasm/Wasm.Build.Tests/Blazor/MiscTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public void BugRegression_60479_WithRazorClassLib()
BlazorBuild(info, config);

// will relink
BlazorPublish(info, config, new PublishOptions(UseCache: false));
BlazorPublish(info, config, new PublishOptions(UseCache: false, BootConfigFileName: "blazor.boot.json"));

// publish/wwwroot/_framework/blazor.boot.json
string frameworkDir = GetBlazorBinFrameworkDir(config, forPublish: true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public BuildOptions(
bool WarnAsError = true,
RuntimeVariant RuntimeType = RuntimeVariant.SingleThreaded,
IDictionary<string, string>? ExtraBuildEnvironmentVariables = null,
string BootConfigFileName = "blazor.boot.json",
string BootConfigFileName = "dotnet.boot.js",
string NonDefaultFrameworkDir = "",
string ExtraMSBuildArgs = "",
bool FeaturePerfTracing = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public abstract record MSBuildOptions
bool WarnAsError = true,
RuntimeVariant RuntimeType = RuntimeVariant.SingleThreaded,
IDictionary<string, string>? ExtraBuildEnvironmentVariables = null,
string BootConfigFileName = "blazor.boot.json",
string BootConfigFileName = "dotnet.boot.js",
string NonDefaultFrameworkDir = "",
string ExtraMSBuildArgs = "",
bool FeaturePerfTracing = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public PublishOptions(
bool WarnAsError = true,
RuntimeVariant RuntimeType = RuntimeVariant.SingleThreaded,
IDictionary<string, string>? ExtraBuildEnvironmentVariables = null,
string BootConfigFileName = "blazor.boot.json",
string BootConfigFileName = "dotnet.boot.js",
string NonDefaultFrameworkDir = "",
string ExtraMSBuildArgs = "",
bool BuildOnlyAfterPublish = true,
Expand Down
13 changes: 5 additions & 8 deletions src/mono/wasm/Wasm.Build.Tests/ModuleConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,12 @@ public async Task OutErrOverrideWorks()
[InlineData(Configuration.Release, false)]
public async Task OverrideBootConfigName(Configuration config, bool isPublish)
{
ProjectInfo info = CopyTestAsset(config, false, TestAsset.WasmBasicTestApp, "OverrideBootConfigName");
(string _, string _) = isPublish ?
PublishProject(info, config) :
BuildProject(info, config);
ProjectInfo info = CopyTestAsset(config, false, TestAsset.WasmBasicTestApp, $"OverrideBootConfigName_{isPublish}");

string extraArgs = "-p:WasmBootConfigFileName=boot.json";
(string _, string _) = isPublish ?
PublishProject(info, config, new PublishOptions(BootConfigFileName: "boot.json", UseCache: false, ExtraMSBuildArgs: extraArgs)) :
BuildProject(info, config, new BuildOptions(BootConfigFileName: "boot.json", UseCache: false, ExtraMSBuildArgs: extraArgs));
if (isPublish)
PublishProject(info, config, new PublishOptions(BootConfigFileName: "boot.json", UseCache: false));
else
BuildProject(info, config, new BuildOptions(BootConfigFileName: "boot.json", UseCache: false));

var runOptions = new BrowserRunOptions(
Configuration: config,
Expand Down
51 changes: 37 additions & 14 deletions src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ private string[] GetFilesMatchingNameConsideringFingerprinting(string filePath,

// filter files with a single fingerprint segment, e.g. "dotnet*.js" should not catch "dotnet.native.d1au9i.js" but should catch "dotnet.js"
string pattern = $@"^{Regex.Escape(fileNameWithoutExtensionAndFingerprinting)}(\.[^.]+)?{Regex.Escape(fileExtension)}$";
var tmp = files.Where(f => Regex.IsMatch(Path.GetFileName(f), pattern)).ToArray();
var tmp = files.Where(f => Regex.IsMatch(Path.GetFileName(f), pattern)).Where(f => !f.Contains("dotnet.boot")).ToArray();
return tmp;
}

Expand Down Expand Up @@ -373,7 +373,7 @@ private string[] GetFilesMatchingNameConsideringFingerprinting(string filePath,

if (IsFingerprintingEnabled)
{
string bootJsonPath = Path.Combine(paths.BinFrameworkDir, "blazor.boot.json");
string bootJsonPath = Path.Combine(paths.BinFrameworkDir, "dotnet.boot.js");
BootJsonData bootJson = GetBootJson(bootJsonPath);
var keysToUpdate = new List<string>();
var updates = new List<(string oldKey, string newKey, (string fullPath, bool unchanged) value)>();
Expand Down Expand Up @@ -524,8 +524,8 @@ public BootJsonData AssertBootJson(AssertBundleOptions options)
var knownSet = GetAllKnownDotnetFilesToFingerprintMap(options);
foreach (string expectedFilename in expected)
{
// FIXME: Find a systematic solution for skipping dotnet.js from boot json check
if (expectedFilename == "dotnet.js" || Path.GetExtension(expectedFilename) == ".map")
// FIXME: Find a systematic solution for skipping dotnet.js & dotnet.boot.js from boot json check
if (expectedFilename == "dotnet.js" || expectedFilename == "dotnet.boot.js" || Path.GetExtension(expectedFilename) == ".map")
continue;

bool expectFingerprint = knownSet[expectedFilename];
Expand Down Expand Up @@ -568,17 +568,40 @@ public BootJsonData AssertBootJson(AssertBundleOptions options)
return bootJson;
}

public static BootJsonData ParseBootData(string bootJsonPath)
public static BootJsonData ParseBootData(string bootConfigPath)
{
using FileStream stream = File.OpenRead(bootJsonPath);
stream.Position = 0;
var serializer = new DataContractJsonSerializer(
typeof(BootJsonData),
new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true });

var config = (BootJsonData?)serializer.ReadObject(stream);
Assert.NotNull(config);
return config;
string startComment = "/*json-start*/";
string endComment = "/*json-end*/";

string moduleContent = File.ReadAllText(bootConfigPath);
int startCommentIndex = moduleContent.IndexOf(startComment);
int endCommentIndex = moduleContent.IndexOf(endComment);
if (startCommentIndex >= 0 && endCommentIndex >= 0)
{
// boot.js
int startJsonIndex = startCommentIndex + startComment.Length;
string jsonContent = moduleContent.Substring(startJsonIndex, endCommentIndex - startJsonIndex);
using var ms = new MemoryStream(Encoding.UTF8.GetBytes(jsonContent));
ms.Position = 0;
return LoadConfig(ms);
}
else
{
using FileStream stream = File.OpenRead(bootConfigPath);
stream.Position = 0;
return LoadConfig(stream);
}

static BootJsonData LoadConfig(Stream stream)
{
var serializer = new DataContractJsonSerializer(
typeof(BootJsonData),
new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true });

var config = (BootJsonData?)serializer.ReadObject(stream);
Assert.NotNull(config);
return config;
}
}

private void AssertFileNames(IEnumerable<string> expected, IEnumerable<string> actual)
Expand Down
19 changes: 19 additions & 0 deletions src/mono/wasm/Wasm.Build.Tests/Templates/WasmTemplateTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,28 @@ public ProjectInfo CreateWasmTemplateProject(
.ExecuteWithCapturedOutput($"new {template.ToString().ToLower()} {extraArgs}")
.EnsureSuccessful();

UpdateBootJsInHtmlFiles();

string projectFilePath = Path.Combine(_projectDir, $"{projectName}.csproj");
UpdateProjectFile(projectFilePath, runAnalyzers, extraProperties, extraItems, insertAtEnd);
return new ProjectInfo(projectName, projectFilePath, logPath, nugetDir);
}

protected void UpdateBootJsInHtmlFiles()
{
foreach (var filePath in Directory.EnumerateFiles(_projectDir, "*.html", SearchOption.AllDirectories))
{
UpdateBootJsInHtmlFile(filePath);
}
}

protected void UpdateBootJsInHtmlFile(string filePath)
{
string fileContent = File.ReadAllText(filePath);
fileContent = StringReplaceWithAssert(fileContent, "<head>", "<head><script>window['__DOTNET_INTERNAL_BOOT_CONFIG_SRC'] = 'dotnet.boot.js';</script>");
File.WriteAllText(filePath, fileContent);
}

protected ProjectInfo CopyTestAsset(
Configuration config,
bool aot,
Expand Down Expand Up @@ -163,6 +180,8 @@ public virtual (string projectDir, string buildOutput) BuildProject(

buildOptions.ExtraBuildEnvironmentVariables["TreatPreviousAsCurrent"] = "false";

buildOptions = buildOptions with { ExtraMSBuildArgs = $"{buildOptions.ExtraMSBuildArgs} -p:WasmBootConfigFileName={buildOptions.BootConfigFileName}" };

(CommandResult res, string logFilePath) = BuildProjectWithoutAssert(configuration, info.ProjectName, buildOptions);

if (buildOptions.UseCache)
Expand Down
4 changes: 3 additions & 1 deletion src/mono/wasm/Wasm.Build.Tests/WasmRunOutOfAppBundleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ public async void RunOutOfAppBundle(Configuration config, bool aot)
string relativeMainJsPath = "./wwwroot/main.js";
if (!File.Exists(indexHtmlPath))
{
var html = $@"<!DOCTYPE html><html><body><script type=""module"" src=""{relativeMainJsPath}""></script></body></html>";
var html = $@"<!DOCTYPE html><html><head></head><body><script type=""module"" src=""{relativeMainJsPath}""></script></body></html>";
File.WriteAllText(indexHtmlPath, html);
}

UpdateBootJsInHtmlFile(indexHtmlPath);

RunResult result = await RunForPublishWithWebServer(new BrowserRunOptions(
config,
TestScenario: "DotnetRun",
Expand Down
Loading

0 comments on commit bb146ad

Please sign in to comment.