Moving On From Gulp
When I created my website at the end of 2020 I built it as a static website on purpose.
In my day job I build CMS-managed websites in
Umbraco
all the time, so it would be easy enough for me to use a similar setup for my personal website. But for a website like this that only gets updated a few times a year it does seem an overkill. It would require the site to be hosted in IIS and use a database for storing the site structure and content, which requires extra resources that wouldn't be doing anything most of the time (let's face it, personal websites are not attracting a lot of traffic).
While creating this website my aim was to create something that would be easy to host and would still display as intended in many years time. That was also the reason I have gone for a very basic design without the need for extra custom web fonts or decorative images. And the pages are all created as static HTML files, since any web server can handle those regardless of the underlying operating system. Let's face it, if some of the
oldest websites
in the world are still working today using static HTML files, then that's a good case for longevity.
Since my frontend build experience in the past few years has been with Gulp , I created a workflow that would run a few tasks to compile the different components of the static website:
- Compile SCSS to minified CSS.
- Minify JS files.
- Create source map files for the minified CSS and JS.
- Compile Markdown content files into HTML using Handlebars templates.
-
Generate a
sitemap.xml
file based on the generated site pages. - Run a local web server to serve the compiled site to allow me to preview and test it.
- Run a watch task that triggers the above tasks whenever I make changes to the source files.
I was able to use existing Gulp plugins for the tasks described, and it worked quite well.
But as part of my effort to increase the longevity of the website I did think about how this all would work in a few years time.
The plan was store my site files in Git and the recommended approach there is not to store any Node modules in Git (Gulp and its plugins were all installed via
NPM
). But there is no guarantee that those modules will still be around in a few years time, so that could stop me from maintaining the website. Would storing the Node modules in my repository be an option then?
So I had a look at my node_modules
folder to see what it contained.
It turns out there were more than 14,500 files in there from all the modules and module dependencies used by Gulp and its plugins. And it took 85 MB of disk space.
It wouldn't be impossible to commit those all to my Git repository, but that is an awful lot of files for a relatively simple project.
As probably any developer would do, I started to think whether I could create something myself that could do the same thing, but with fewer resources.
Since I am a developer that is most familiar and experienced with .NET, that was where I started to look.
To create a console application that would run the same tasks as my Gulp workflow did was very feasible. There are a number of plugins available to provide some of the functionality that I needed:
- LibSassHost : to compile SCSS into CSS.
- NUglify : to minify JS.
- MarkDig : to parse Markdown files into HTML.
- Handlebars.Net : to compile Handlebars templates.
And since I planned to use .NET 6, I could also use the Kestrel web server to provide a light-weight local web server for previewing the compiled site.
While that all functioned fine, at this point it was a typical .NET application: it needs to have the .NET 6 runtime installed on the machine you wish to run the app, and all the plugin dependencies are supplied in DLLs that need to be copied with the executable.
Since .NET Core 3 there is the option to create
single file applications
, but the caveat with that one is that it will extract the dependencies when the application is run for the first time. So that has a small performance impact.
But then I read some
tweets
by David Fowler about Native AOT and that got me excited.
To understand why Native AOT is so exciting, it's probably good to remind ourselves how .NET applications normally work:
- You write code in C#.
- Visual Studio compiles it into an executable or DLL which contains Intermediate Language.
- When running the application the .NET runtime uses just-in-time compilation to turn the IL code into native machine code that can be executed by the CPU.
What Native AOT does is to provide ahead-of-time compilation of C# code into a native single-file executable. So it effectively combines steps 2 & 3 from the list above and does it as part of the application publish process.
The main advantage of this is that the generated executable only contains native machine code for the chosen platform (Windows, macOS or Linux) and therefore there is no need to even have the .NET runtime installed on the machine you are trying to run the application on. So it has a lot of potential to be a reliable option for long-term projects (one of my requirements).
Native AOT is currently on the
roadmap
to be included in .NET 7, which will be released later this year. But a lot of work has already been done as part of an
experimental project
. And that project can be used already in .NET 6 projects by following the
instructions
to include the experimental NuGet package.
That is exactly what I did to see if I could create a working single-file executable for my static website generator.
The first issue I ran into was to do with the LibSassHost reference.
LibSassHost works by making PInvoke calls to the native (unmanaged) libsass DLL. Native AOT supports
linking
of native unmanaged libraries, but it requires you to include a .lib
file of that library.
Since the LibSassHost package only includes a DLL in your project, I had to go ahead and compile the
native libsass project
myself locally to create the
static library
.
Once I had done that and referenced the .lib
file in my project, Native AOT was able to create a native executable successfully.
But when I ran the application to test if it worked, it threw some exceptions in the Handlebars.Net code.
When Native AOT compiles the application it tries to find all the methods called by the application, but that does not always work when method calls are made through reflection. And since the Handlebars.Net library uses reflection in a few places, the generated executable was missing some methods that caused the errors I was seeing.
To get round this problem you can
define
an rd.xml
file in the project where you can explicitly map out assemblies or methods used by your application, but were missed by Native AOT.
Based on the stack trace of the exceptions I was seeing, I was able to add the method signatures to my rd.xml
file:
<Directives xmlns="http://schemas.microsoft.com/netfx/2013/01/metadata">
<Application>
<Assembly Name="Handlebars">
<Type Name="HandlebarsDotNet.ObjectDescriptors.StringDictionaryObjectDescriptorProvider">
<Method Name="CreateDescriptor">
<GenericArgument Name="System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib" />
<GenericArgument Name="System.Object, System.Private.CoreLib" />
</Method>
</Type>
<Type Name="HandlebarsDotNet.ObjectDescriptors.EnumerableObjectDescriptor">
<Method Name="ArrayObjectDescriptorFactory">
<GenericArgument Name="System.Object, System.Private.CoreLib" />
</Method>
<Method Name="ListObjectDescriptorFactory">
<GenericArgument Name="System.Collections.Generic.List`1[[System.Object, System.Private.CoreLib]], System.Private.CoreLib" />
<GenericArgument Name="System.Object, System.Private.CoreLib" />
</Method>
</Type>
<Type Name="HandlebarsDotNet.MemberAccessors.EnumerableAccessors.ListMemberAccessor`2[[System.Collections.Generic.IList`1[[System.Object, System.Private.CoreLib]], System.Private.CoreLib],[System.Object, System.Private.CoreLib]]" Dynamic="Required All" />
</Assembly>
</Application>
</Directives>
After a few tries I got the right settings in place and the application was no longer throwing any errors.
When I confirmed that all the tasks were working as expected, it was time to start looking at the executable size. At this point the single-file executable was just over 18 MB in file size. Certainly a nice improvement compared to the Node modules, but surely I should be able to squeeze it down a bit more?
In this version of the app I was using a JSON config file to read the settings for each task (e.g. which input or output directories to use). But since I was also using YAML inside my Markdown files for the front matter of each page, it was making sense to also use YAML for the app config file and then remove all the JSON code. That reduced the executable by nearly 200 KB.
And one of the tasks in the app is responsible for generating the sitemap XML. For this I was using the XDocument
classes, but I realised that I didn't really need this. As I'm only generating the
standard sitemap XML format
, it's easy enough to just do this in a StringBuilder
instance instead.
When I then removed the XML references from my project the executable shrunk by a further 750 KB.
Next up I played around with a few of the Native AOT settings and .NET trimming options that can be used to optimise the output, which gave me the following improvements:
-
Set
IlcGenerateStackTraceData
tofalse
: 900 KB. -
Set
UseSystemResourceKeys
totrue
: 450 KB. -
Set
DebuggerSupport
,EnableUnsafeUTF7Encoding
andEventSourceSupport
all tofalse
: 700 KB.
After optimising all my managed code, I did also have a look at optimising the unmanaged code that I'm using (the libsass library). That led to two further optimisations:
- Use profile-guided optimization when building the library DLL: 10 KB.
- Use string pooling : 200 KB.
One other thing I did try was to disable reflection (which is one of the Native AOT options ). That did reduce the executable size even further, but it also broke my application since some libraries (Handlebars.Net in particular) rely on reflection methods that are not supported in reflection-free mode. So in the end I abandoned that option.
With the above optimisations in place it reduced the executable size to just over 15 MB. And that seemed to me like a a nice place to stop.
I suspect that when .NET 7 comes out later this year, and Native AOT will be one of the supported options out-of-the-box, some further reductions can be made.
But for now I'm very happy with a single-executable static site generator that I can keep with my site templates and content files in a Git repository, knowing that it will just work when I come to update my website.
[Update 27 September 2022]
A new NuGet package was released recently called
PublishAotCompressed
. This adds another step to the publish process where the resulting code gets compressed using
UPX
which resulted in an even smaller executable at just over 5 MB.
Even though it adds in-memory decompression of the executable at runtime, this is not noticeable at all.
[Update 10 November 2022]
Because .NET 7 was released this week I converted my project over to use that. There are no further reductions in file size to report (still just over 5 MB).
But at least you no longer have to use the experimental NuGet package, and you can use the
official instructions
instead.
Since I had to name my new application I decided to call it Swall, which is a portmanteau of 'swallow' (because it was inspired by Gulp) and 'small' (because I was looking for a smaller application).
The code of it is available on my
GitHub account
if you are interested to see it in more detail.