Goal
Use .NET features (especially dotnet watch) as a setup for a client-side Svelte application, starting from a simple C# console app.
Why
I was going through Frontend Masters' Svelte Fundamentals and I wondered "Would it be possible to substitute npm run dev
with dotnet watch
, at least to some extend (i.e. without the full fledged functionality that SvelteKit provides)? So, out of curiosity, I shall give it a try...
IMPORTANT
This is for educational purposes only. I am just trying to piece together some things and see what happens.๐
From Console to Web
Creating a console application using the dotnet CLI
is more or less straightforward - dotnet new console
with some flags (project name, directory, etc). I shall name the project (surprise, surprise) server
.
Removing the Console.WriteLine(...)
and adding
var builder = WebApplicaiton.CreateBuilder(args);
throws the expected error (i.e. the static class WebApplication
is nowhere to be found).
In order for this to be fixed, the .csproj
file should be edited and a using
directive provided. Adding
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
makes the .csproj
file look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>_server</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
and then in the Program.cs
:
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateBuilder(args);
So far, so good - dotnet build
executes successfully๐
Trying something else...
Going through the documentation, it appears that the WebApplication.CreateBuilder
creates an application with a lot of things inside it (i.e., logging, reading configuration from a number of sources, etc.). Not needing any of those, let's try the WebApplication.CreateEmptyBuilder
.
Just changing the method triggers a compilation error since CreateEmptyBuilder
does not accept a string array but requires an object of type WebApplicationOptions
. So, in order to comply, let's pass an instance of it, build the container (i.e. the builder) and finally start the web application.
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions());
var app = builder.Build();
app.Run();
It builds successfully, but let's try actually running it. And... Unhandled exception๐
No service for type 'Microsoft.AspNetCore.Hosting.Server.IServer' has been registered.
I guess that CreateEmptyBuilder
does what is says, so let's try something else - changing it with CreateSlimBuilder
. Building, starting and... no errors!
Granted, it does nothing but it's a start๐
Returning some content
Let's add an index.html
file in the build directory (i.e. /bin/Debug/{.NET Version}/index.html).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mega cool app</title>
</head>
<body>
<h1>Hello world</h1>
</body>
</html>
Then, make the server return it.
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions());
var app = builder.Build();
app.MapGet("/{*rest}", async (ctx) =>
{
ctx.Response.ContentType = "text/html";
using var file = File.OpenRead("./test.html");
await file.CopyToAsync(ctx.Response.Body);
});
app.Run();
Build, run and any request made should return the index.html
file๐
It's Svelte time
Next to the server
folder let's create a src
folder that is going to hold any Svelte files.
// App.svelte
<h1>Hello from Svelte</h1>
Now, the browser would not render this (or rather it renders it like text). Hence, the Svelte application needs to be build. For this, Rollup will be used (if I am not mistaken, Rollup is used somewhere in the Svelte's production pipeline).
But first
npm init -y
Then
npm i -D rollup
That is a good start, but in order to make Rollup play nicely with Svelte, a plugin is required:
npm i -D rollup-plugin-svelte
According to its documentation, one more plugin is required:
npm i -D @rollup/plugin-node-resolve
Thus, this makes the development dependencies in package.json
look like
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"rollup": "^4.10.0",
"rollup-plugin-svelte": "^7.1.6"
}
The next step is configuring Rollup. Using a configuration file seems like a good option - the result of going through Rollup's and Svelte's pulgin documentation is:
//rollup.cofig.js in the root foder
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
export default {
input: 'src/main.js',
output: [
{
file: 'public/build/bundle.js',
format: 'iife',
name: 'app',
},
],
plugins: [
svelte({
include: 'src/**/*.svelte',
}),
resolve({ browser: true }),
]
};
Since the entry point is src/main.js
let's create it.
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;
Editing the script
section of package.json
(removing the never useful test
one ๐):
"scripts": {
"dev": "rollup -c -w"
}
(the -c
flag tells Rollup to use a config file (i.e. rollup.config.js by default) and the -w
flag tells Rollup to create a new bundle when the source files change).
The moment of truth:
npm run dev
...and...
Cannot use import statement outside a module
๐ฑ
Fortunately, the error description is quite human readable. Introducing a change to package.json
solves the problem:
...
"type": "module",
...
Running npm run dev
once again and there is the bundle.js
file created in the /public/build
path.๐
Opening the file and scrolling to line 482(๐
) I see
h1 = element("h1");
h1.textContent = "Hello from Svelte";
๐ฅณ
Displaying Svelte's content
In order to display what has been generated, let's edit the index.html
file.
<head>
...
<script src="./public/bundle.js"><script>
...
</head>
Firing the server, making a request and... a blank page.
Going into the 'Network' tab of the Development tools of the browser I see that a request is made for bundle.js
and the response's header Content-Type
has the value of text/html
. This makes perfect sense, since this is how the server has been implemented, i.e. for every type of request it returns the contents of index.html
file. Let's change that.
First, adding the capability to serve static content with .UseStaticFiles()
(grated, it is possible to check what is being requested, read a file and return it - as with everything else, there are multiple ways to achieve the same result).
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions());
var app = builder.Build();
app.MapGet("/{*rest}", async (ctx) =>
{
ctx.Response.ContentType = "text/html";
Console.WriteLine(app.Environment.ContentRootPath);
using var file = File.OpenRead("./index.html");
await file.CopyToAsync(ctx.Response.Body);
});
app.UseStaticFiles(); // added capability
app.Run();
Trying anew and... again a blank page. Could it be that the request is served before reaching the serving static file capability? Let's try moving it before the app.MapGet...
. Still nothing... It seems that the problem is having a catch all route (i.e. "/{*rest}"
matches everything) and serving of static content (but more on that later). Let's try changing this to match everything without requests beginning with public
in the path.
app.UseStaticFiles();
app.MapGet("/{path:regex([^public])}/{*rest}", async (ctx) =>
{
...
}
Trying it and... in the server logs is a red fail
message with a great stack trace. Fortunately, in it it is explained what one must do so let's copy-paste it in the code.
// Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing; // newly added
using Microsoft.AspNetCore.Routing.Constraints; // newly added
using Microsoft.Extensions.DependencyInjection; // newly added
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions());
builder.Services.Configure<RouteOptions>(opt => opt.SetParameterPolicy<RegexInlineRouteConstraint>("regex")); // newly added
var app = builder.Build();
app.UseStaticFiles();
app.MapGet("/{path:regex([^public])}/{*rest}", async (ctx) =>
{
...
}
(route constraints can be registered by calling app.Services.AddRouting()
as well)
Now the server can use RegEx patterns when figuring out what endpoint to execute for a given request.
Making a request and... the response is 404 for both "/"
path and the bundle.js
resource. Trying "/foo"
as a path and... partial success!!! The html
is returned (seen in the Response tab under Network in the browser's development tools) and 404 is received for bundle.js
. Let's add an empty string as the default value for the path so that 404 is not returned for "/"
.
...
app.MapGet("/{path:regex([^public])=\"\"}/{*rest}", async (ctx) =>
{
...
}
Now, time to figure out where bundle.js
has been hiding all along.
Serving bundle.js
as a static file
Currently, when the server is started, a well formed warning is logged:
The WebRootPath was not found: {path}. Static files may be unavailable.
This seems to be because by default the folder from which static content is served is wwwroot
- and there is no such folder. Let's do some configuration for UseStaticFiles
:
//Program.cs
...
using Microsoft.Extensions.FileProviders; //newly added
...
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(""), //the absolute path to the build directory, where bundle.js resides
RequestPath = "/public"
});
This configuration serves static content when the request path has public
from the directory that is passed in the constructor of the PhysicalFileProvider
.
We run the server (notice that the warning is not there anymore), make a request to "/" and... a I stare at a blank page.
Looking at the network tab in the browser's developer tools I see status code 200 for everything I am interested in, which is nice... But the Console
in the browser has the not uncommon Cannot read properties of undefined (reading '$$')
(well, apart from the '$$') painted in red. Not sure what this is all about.
Giving some thought of what I am exactly doing, though, suggests what might the problem be. In the index.html
file the bundle.js
(which, from the rollup.config.js is actually an IIFE - Immediately Invoked Function Expression) is in the <head>
. This basically means that the browser gets the file, parses it and executes it, right there and then.
This in itself means, that the JavaScript code is appending the heading to the <body>
element. The problem is that there is no spoon body element at that moment because the HTML has not yet been parsed by the browser.
This can be solved either by using the old school technique of moving the script tag at the end of the html file, or adding the defer
boolean attribute (which will cause the script to be downloaded now, but executed after parsing the HTML) to the script tag. Let's go with the second approach:
//index.html
...
<head>
...
<script defer src="./public/bundle.js"></script>
...
</head>
...
Restarting the server, requesting "/" and... SUCCESS! We see a beautiful heading containing our text!
Making use of dotnet watch
Let's now try to use some hot reloading, provided by dotnet watch
. Going into the root directory of the server (i.e. where the .sln
file is located) using the command line and execuing dotnet watch
. Good, all seems OK for the moment, no errors are in the logs (well, nothing much is done, but still...). Trying to access the "/" URL and... an exception in the server. Not a good start...
Going through the error message, I understand that it was a mistake to place the index.html
file directly in the Debug
folder because now the code is trying to access is in a different location - the root folder of the server project.
Because of this, let's copy the file from the Debug
folder, place in the root and just for good measure edit it's properties and change the Copy to Output directory
one to Copy if newer
.
Restarting the server (Ctrl + C, then dotnet watch
), requesting "/"... looks way better now - no errors and the heading is there!
Making Svelte changes
Let's see whether some code changes will be picked up and automatically displayed in the browser (since this is the final goal, after all).
// /scr/App.svelte
<h1>Hello from Svelte!!!</h1>
Saving the file... and nothing, still the old text. Doing a hard reload from the browser serves the new content. So, the manual hot reload seems to be working, but not the automatic one.
According to dotnet watch
's documentation, the command injects a script tag in the HTML that takes care of refreshing the browser. Looking at the received HTML, there seems to be only the bundle.js
and nothing else. Manually inserting the script (as suggested by a certain AI tool) did not help.
This seems to be a server infrastructure issue. Could it be that something that dotnet watch
is requiring is not present because of the WebApplication.CreateSlimBuilder
call? Let's change that part to WebApplication.CreateBuilder
//var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions());
var builder = WebApplication.CreateBuilder();
Stopping dotnet watch
, starting it, checking the generated HTML - still nothing...
Giving it a little thought, this is a console application with ASP.NET Core capabilities - could this be the issue - this not being a 'genuine' web application? Let's change the .csproj
file to make this a 'real' web application.
<Project Sdk="Microsoft.NET.Sdk.Web"> // .Web is added
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>_server</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<!--<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>-->
<ItemGroup>
<None Update="index.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
IMPORTANT
After making this change and executing dotnet watch
you default browser will be fired up and request made to the server - if you are like me and have a ton of open tabs with information that you want to go through later (like that is going to happen) be careful not to close everything by accident. While I was testing this thing I changed my default browser just to be on the safe side.
Stopping the server, running dotnet watch
checking the response - and there it is, we have it
<body>
<script src="/_framework/aspnetcore-browser-refresh.js"></script>
...
<body>
Let's do some changes to App.svelte
// App.svelte
<h1>Hello from Svelte</h1>
Saving the changes... and they are not reflected in the browser...
Well, according to dotnet watch
's documentation, it 'observes' some files by default and the files that we are changing are not on the list. Adding them requires changes to the '.csproj' file
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>_server</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Update="index.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Watch Include="{absolute path to the build directory, where bundle.js gets generated upon changing the App.svelte file}\*.js" />
</ItemGroup>
</Project>
Stopping, starting, checking the browser and making a change to App.svelte
- and the change is still not there... Looking at the terminal, though, dotnet watch
is asking something interesting - it has detected that the bundle.js
file has been changed and there are some options present - pressing 'a' tells dotnet watch
to always restart the application when it detects changes to bundle.js
. Now the changes get reflected in the browser whenever the App.svelte
file is changed!
Almost there
So, we have 'hot reload' working with text but what about styling? Let's increase the awesomeness of our awesome application by underlying the heading!
// App.svelte
<h1>Hello from Svelte!!!</h1>
<style>
h1 {
text-decoration: underline;
}
</style>
Saving the changes and...nothing... Looking at the logs from the server - no errors... But what about roolup
? Yep, something definitely is wrong with the build process...
Going through svelte-plugin's documentation, there is an option emitCss
- not sure what exactly it does but I assume it allowing the CSS to be consumed by other tools in the pipeline. Since there are none, let's give this option the value of false
which makes the 'rollup.config.js' look like
// rollup.config.js
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
export default {
input: 'src/main.js',
output: [
{
file: 'public/build/bundle.js',
format: 'iife',
name: 'app',
},
],
plugins: [
svelte({
include: 'src/**/*.svelte',
emitCss: false // new option
}),
resolve({browser: true})
]
};
Saving and... now even the styling works๐
Conclusion
Starting from a console application we managed to make use of dotnet watch
to observe changes in a JavaScript file, generated by Rollup from Svelte files.
Was this a meaningful exercise - I am not sure... But it definitely was a fun one๐
Cheers!
Top comments (0)