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] Generate boot config as javascript module #112947

Merged
merged 20 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because we are letting people control where blazor.boot.json was being loaded from?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maraf is there a need for us to support that moving forward?

What scenario do we think still mandates we keep this around.

I'm saying this because the more we can simplify this process the better.

Caching and all those things we are handling through the importmap work we did, and I can't really see a reason for this being loaded from elsewhere

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be in use by non-blazor customers, but I don't have anything specific

Copy link
Member Author

@maraf maraf Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the original motivation? Modifying the URLs to point to some CDN or subpath on the server is still valid.
This callback processes all framework assets, not just JS modules

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the original motivation was for letting people control the URLs of the deployed app, when we had issues with the anti-virus.

I think it's still fine for things that are "data" like the webcil, but is there a reason why we couldn't rely on relative paths for the module bits? After all, you can already change these urls during the build process modifying the relative paths of the respective assets.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion. Since blazor users can configure .NET runtime through a callback on Blazor.start, they have all the flexibility to define the assets and their URLs in a defferent way than through the loadResource callback, on the other it's a breaking change.

}
} 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;
Comment on lines +597 to +603
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why you aren't using S.T.J?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno. This code was probably inspired by the similar on SDK tests. I'll try to use S.T.J in follow up

}
}

private void AssertFileNames(IEnumerable<string> expected, IEnumerable<string> actual)
Expand Down
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
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
Loading