dotnet/aspnetcore

Blazor unified project design

SteveSandersonMS opened this issue ยท 68 comments

In .NET 8 we plan to add a new project template, Blazor Web Application, that covers all combinations of server-hosted projects (traditional Blazor Server apps, Blazor WebAssembly hosted, and the new unified architecture that allows use of Server, WebAssembly, and SSR in a single project). It will work by multitargeting over net8.0 and net8.0-browser.

However we still have to decide on the conventions for how such a project should be structured. The biggest question is what determines which files/references are included in which compilation?

Goals

  • Make it obvious what ends up in which compilation. In particular, avoid unintended inclusion in the WebAssembly compilation, because (1) it bloats the app size, and (2) in the worst case, a developer might include sensitive business logic/secrets without realising it.
  • Stay compatible with existing ASP.NET Core conventions. We don't want to break all existing docs/tutorials/etc for MVC, gRPC, auth, EF, and so on. Nor do we want people upgrading an existing nontrivial ASP.NET Core app to have to make a lot of changes to their existing code.
  • Maximize clarity; minimize the concept count. Try to make the structure intuitively obvious and pleasant for people who don't know why all this stuff works as it does. In particular, be very careful about terminology given the amount of ambiguity (e.g., client vs browser vs WebAssembly all might seem to mean the same thing here, but all of them also seem wrong in some cases).
  • Work without tying people to particular project structures. We're not designing just for the default templates; we're looking for a system that will make sense even when people change their project structures a lot, or are upgrading existing real-world projects that involve a lot of unrelated concepts they might not even fully understand.

Possible designs

I think there are two main approaches to pick from, as depicted here:

image

Benefits of approach A ("exclude by default")

  • Does not break existing ASP.NET Core server apps. Upgrading is easy, because nothing goes into your WebAssembly compilation by default, so it's not going to give you hundreds of build errors and force you to rename files or add #if or special exclusions to your .csproj.
  • As you add more components/classes to the project, you're forced to make an explicit choice if you want to make it available to WebAssembly. There is minimal risk of accidental disclosure, and the trimmed wasm bundle will be as minimal as possible.

Its main drawbacks are that the concept of ClientShared is nonobvious (and I spent ages coming up with that name, as almost everything else fails to communicate the idea that you're making stuff available to both client and server whereas otherwise it's server only - better name suggestions are welcome, but don't just say "client" or similar).

Benefits of approach B ("include by default")

  • Most similar to MAUI
    • We could even have Platforms/Browser but that's largely pointless since for almost everything you include in browser, you also want it to be available in server (otherwise, for example, you can't even route to @page components that aren't in the server build and would get 404s).
    • We could have Platforms/Server but again that's quite bad because people don't want to restructure their ASP.NET Core projects to move everything for the server into that subdir.
  • Looks simpler, because there are fewer folders
  • More obvious that what determines each component's render mode is @rendermode and not which folder it is in

Its main drawback is that it is incompatible with typical ASP.NET Core projects, at least until developers manually exclude everything that can't work in WebAssembly, and then as you work on the project you have to keep excluding more things or unintentionally include them in the WebAssembly build. In the above example, all the .razor components end up in the wasm build pointlessly, increasing its size just because it's painful to keep excluding things.

Proposal

As you can probably tell, I'm currently leaning towards option A but am looking for feedback on what is wrong or missing from this analysis.

cc @dotnet/aspnet-blazor-eng

BTW the way I think option A could best be implemented would be declaring an MSBuild property BrowserCompilationRoot with default value ClientShared. This actually means we support both A and B, since B is just a special case where BrowserCompilationRoot is set to "empty" or . (and of course then people can just use a different folder name if they want).

Project and Package references

There's also the question of references to other projects/packages. The same concern applies: for the WebAssembly build, we really don't want to include anything unnecessary by mistake, because (1) it can bloat the app size, and (2) it could lead to accidental disclosure of sensitive assets.

I propose we introduce a rule: For projects that multitarget net8.0 and net8.0-browser, all project/package references need to be annotated with ClientOnly, ServerOnly, or ServerAndClient, otherwise we log a warning.

For example, this is what would be in the .csproj by default:

	<ItemGroup>
		<PackageReference ClientOnly="true" Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.0-preview.7.23324.1" />
		<PackageReference ServerOnly="true" Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.0-preview.7.23324.1" />
	</ItemGroup>

... and if you add a new reference via tooling, then by default it won't have any such annotation, and so by default you'll see a warning like this:

image

The point of it being a warning is not to break unrelated tutorials (e.g., for EF, gRPC, auth, etc, especially for newcomers who might not yet know how to edit a csproj) but make clear that we expect developers to make a conscious choice about it at some point.

@SteveSandersonMS Instead of having to annotate each and every package/reference, could we have some sort of more general rule about what references are allowed by default and what references are not allowed?

Something akin to what happens https://github.com/dotnet/sdk/blob/main/src/StaticWebAssetsSdk/Sdk/Sdk.StaticWebAssets.StaticAssets.ProjectSystem.props#L30

So that we can say something like

<PropertyGroup>
  <DefaultExcludedReferences>MyCompany.*</DefaultExcludedReferences>
</PropertyGroup>

For the vast majority of cases, if you even care about preventing a .dll from being sent down to the browser, it's likely going to be a dll from your org. If we can avoid you having to annotate Newtonsoft.Json, that would be great.

@SteveSandersonMS - you may already have got the solution to "I spent ages coming up with that name, as almost everything else fails to communicate the idea that you're making stuff available to both client and server", - "Unified" - the other options being (dare I say it) "Common" or "Combined"

@johndhunter Those are definitely reasonable suggestions too. "Common" was in fact an earlier iteration I used - the part about I thought may be worth changing is being more explicit (as in, what is in common with what, since the project maybe used for many things besides just Blazor so there are a lot of different concepts - MVC, Razor Pages, gRPC, auth, etc.).

For the vast majority of cases, if you even care about preventing a .dll from being sent down to the browser, it's likely going to be a dll from your org.

I'm not sure. While that's probably true for the "unintended disclosure" concern, it's not true for the "bloating the wasm payload" concern. The particular example you chose of Newtonsoft.Json is a great one to think about because that's precisely the top example of something you really don't want in your wasm bundle for no reason (it's very big) but @danroth27 said he has seen people include it by accident in existing Blazor WebAssembly apps.

@SteveSandersonMS It would be great if it would also give warnings about transitive package dependencies. I know in my own usage, that's how a few large assemblies ended up in my Blazor WASM applications.

What is the benefit of having all of these in a single project as opposed to what we have now?

I like the two separate projects because you can't accidentally send server classes to the browser.

Having them as separate projects is 100% clear and immediately obvious, whereas both of these approaches seem like hidden behaviour for which you have to discover the rules.

@SteveSandersonMS I really like the ideas here! I definitely agree that Option A would be a better choice, purely because it requires intentionally making things available to the client.


Would the *.Client.* file name convention only apply within the ClientShared folder? I ask because, without reading any descriptions, the name ClientShared to me implied "everything in this folder is shared with the client". But I guess technically, what it means is "everything in this folder is shared with the client, unless a file uses the .Client file name convention, in which case it's actually not shared and only available on the client." That wasn't immediately intuitive to me because I wouldn't consider "available to the client only" to be a subcase of "shared between the client and the server."

Maybe an alternative to the .Client convention is another top-level ClientOnly folder? That way, there's a clear separation between what's "server only", "client only", and "shared between the server and client". Although, I can think of some downsides to that approach as well (there are two Program.cs files, there isn't just a single location for all client-available components, etc.).


The package reference idea seems good. My only concern there is that the mainstream scenario requires editing the project file manually to address warnings. Ideally, it would be nice to reach a point where the customer makes the decision at the time of adding the package (via the VS UI or a CLI option) rather than after the package was added.


@mrpmorris also brings up an interesting point. It does seem to me that a lot of the effort put into this design is trying to emulate features that would otherwise be achieved automatically by using multiple projects. Would separate .Client, .Server, and .Shared projects actually make things simpler because it's less for the customer to learn and less complexity for the framework to manage?

Perhaps the single project approach works well with MAUI because in that world, general application functionality is almost always applicable for each platform. And in the cases where it's not, there isn't as big a need to exclude it from a specific platform for bundle size and security reasons. Whereas, with Blazor, deciding where the code should run will end up being the first step for almost everything that gets added, whether it be Razor components, package references, etc.

And I could imagine the easiest way for the customer to make that decision would be to pick which project the thing they're adding is most relevant to, add it, and be done. Eliminating the "which project?" question actually just adds two more questions (which folder does this go in and what file name convention needs to be used?), and in the case of adding a package reference, adds a question in the form of a warning in the error list.

Not trying to say we shouldn't support the single project approach - I'm just playing the devil's advocate because I think these are interesting things to consider ๐Ÿ™‚

My vote for option A - I think separation by folders is much more visible compared to searching parts of each file name for .Server or .Client in it.
One Idea for the folder name, that instantly came to my mind as I read your description "...you're making stuff available to both client and server...": "ClientAndServer". And the next idea is to only have one more folder on the root level that is called "ServerOnly"