DEV Community

Deyan
Deyan

Posted on

Trying to use dotnet watch with Svelte

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

and then in the Program.cs:

using Microsoft.AspNetCore.Builder;

var builder = WebApplication.CreateBuilder(args);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then

npm i -D rollup
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

According to its documentation, one more plugin is required:

npm i -D @rollup/plugin-node-resolve
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 }),
  ]
};

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Editing the script section of package.json (removing the never useful test one 😁):

"scripts": {
  "dev": "rollup -c -w"
}
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

...and...

Cannot use import statement outside a module
Enter fullscreen mode Exit fullscreen mode

😱

Fortunately, the error description is quite human readable. Introducing a change to package.json solves the problem:

...
"type": "module",
...
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

🥳

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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) =>
{
...
}
Enter fullscreen mode Exit fullscreen mode

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) =>
{
...
}
Enter fullscreen mode Exit fullscreen mode

(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) =>
{
...
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"
});
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Let's do some changes to App.svelte

// App.svelte

<h1>Hello from Svelte</h1>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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})
  ]
};


Enter fullscreen mode Exit fullscreen mode

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)