Serenity 6.2.0 Release Notes (2022-10-10)

We'll publish these release notes for versions that deserve more explanation than short descriptions available in our change log.

Implemented Logging Support for SqlHelper Methods

We used to have a ILog interface before switching to .NET Core and its dependency injection mechanism. The logger could be configured via web.config or appsettings.json files, and could be redirected to a directory under App_Data/Log folder by default.

We are using .NET Core's own ILoggerFactory and ILogger interfaces since v5.x. These loggers are usually injected to the target classes via constructor injection.

Unfortunately, our SqlHelper methods that used to log information about queries executed are static. They have no way to get a logger instance via dependency injection. So, logging was not working properly because of that.

To workaround that, we used a logger that is passed to those extensions via IDbConnection interface. Of course, IDbConnection does not have logger property, but luckily our connections are usually IWrappedConnection instances.

So we defined a new interface called IHasLogger:

namespace Serenity.Data
{
    public interface IHasLogger
    {
        ILogger Logger { get; }
    }
}

and implemented this in WrappedConnection class:

public class WrappedConnection : IDbConnection, IHasActualConnection, IHasCommandTimeout, 
        IHasCurrentTransaction, IHasDialect, IHasLogger, IHasOpenedOnce
{
    // logger is null by default as we don't want to break direct users of WrappedConnection constructor
    // if anyone did that before (e.g. in tests etc.)
    public WrappedConnection(IDbConnection actualConnection, ISqlDialect dialect, ILogger logger = null)
    {
    }

    // ...

    public ILogger Logger { get; };
}

This wrapped connection instances are created from ISqlConnections providers, which DefaultSqlConnections by default:

public class DefaultSqlConnections : ISqlConnections
{
    // ...
    public DefaultSqlConnections(IConnectionStrings connectionStrings, IConnectionProfiler profiler = null, 
        ILoggerFactory loggerFactory = null)
    {
    }

    public virtual IDbConnection New(string connectionString, string providerName, ISqlDialect dialect)
    {
        // ...

        if (profiler != null)
            return new WrappedConnection(profiler.Profile(connection), dialect, loggerFactory.CreateLogger<ISqlConnections>());

        return new WrappedConnection(connection, dialect, loggerFactory.CreateLogger<ISqlConnections>());
    }    
}

This default sql connections provider, gets a ILoggerFactory instance via constructor injection, and creates and passes a ILogger isntance to the wrapped connection instances.

This way, static methods in SqlHelper can get a logger instance via their IDbConnection arguments, if they implement IHasLogger interface.

We are using WrappedConnection for similar purposes, like getting a dialect from the connection, accessing the current transaction, setting command timeout for that connection etc, all via specialized interfaces like IHasCurrentTransaction etc. so that another connection class may implement them if desired, thus we don't create a hard wrapped dependency on WrappedConnection itself.

Ok, so what we get in the console (Kestrel console, or developer console in Visual Studio):

info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\Sandbox\StartSharp\StartSharp\StartSharp.Web
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
      Failed to determine the https port for redirect.
dbug: Serenity.Data.ISqlConnections[0]
      SQL - ExecuteReader[20961011] - START

      SELECT
T0.[Id] AS [Id],
T0.[LanguageName] AS [LanguageName],
T0.[LanguageId] AS [LanguageId]
FROM [Languages] T0
ORDER BY T0.[LanguageName]

dbug: Serenity.Data.ISqlConnections[0]
      SQL - ExecuteReader[20961011] - END - 4 ms

We see the contents for all SQL commands executed via Serenity entity system (or SqlHelper methods), and can see when it started and ended, and how much time it took. The number [20961011] is a hashcode for the DBCommand instance, which can be used to match START and END logs when there are multiple threads executing SQL statements.

To enable logging, your default logging level in appsettings.json should be Debug or higher:

"Logging": {
    "LogLevel": {
        "Default": "Debug",
    }
}

Or you can set it to debug only for Serenity.Data namespace:

"Logging": {
    "LogLevel": {
        "Serenity.Data": "Debug"
    }
}

We think it is better to enable debug logging only in debug environments by default, and not in production, so it might be better to add a appsettings.Development.json file if you don't have one already and override logging level to Debug only in that file.

Here is the one we created for StartSharp 6.2.0:

// appsettings.Development.json
{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Serenity.Data": "Debug"
    }
  }
}

and base appsettings.json file:

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

So logging level for is by default is Information for environments other than Development. In Development, e.g. while debugging it becomes Debug for Serenity.Data only.

See .NET Core logging documentation for more information:

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/logging/?view=aspnetcore-6.0

Added Brotli Compression Support to Dynamic Scripts and Script Bundles

You may not be aware of it, but the scripts served by DynamicScriptMiddleware (/DynJS.axd) like lookups, and script bundles etc. are automatically compressed on the fly with GZIP compression (on demand). This is enabled for content that is larger than 4096 bytes by default.

The browser receives compressed version if it sends an Accept header containing gzip while requesting the URL, which almost all current browsers do.

Brotli is a more effective format resulting in smaller files around 15%-25% on average. And for comparable compression levels both the compression and decompression times are faster for brotli. For decompression, it is about 40% faster.

Not all browsers support Brotli yet, but the coverage is around 96.39% according to https://caniuse.com/brotli which can be considered pretty high.

You don't need to do anything to enable Brotli for DynamicScriptMiddleware other than updating Serenity packages. As long as the browser supports it, the content will be compressed with brotli instead of gzip by default.

We tested this in our Serenity Demo Dashboard by loading it while Brotli is disabled first:

Brotli Disabled

and then with Brotli enabled:

Brotli Enabled

It gave us about 107KB reduction in size. This is a nice amount considering this only affects the content served by Dynamic Script Middleware and for ones that are larger than 4K.

The script and css files in our demo are about 500K compressed which was 607K before enabling brotli.

Converted JSON Local Text Files to Static Web Assets and Moved Them To Relevant Packages

Translations for Serenity itself are distributed via Serenity.Scripts NuGet package. The source files can be found at https://github.com/serenity-is/Serenity/tree/master/src/Serenity.Scripts/wwwroot/texts

Sergen restore command used to locate them from the NuGet package and copy them under YourProject.Web/wwwroot/Scripts/serenity/texts folder.

In StartSharp and Serene's Startup.cs file we had a code block that loads them into local text registry:

services.AddAllTexts(new[]
{
    Path.Combine(env.WebRootPath, "Scripts", "serenity", "texts"),
    Path.Combine(env.WebRootPath, "Scripts", "site", "texts"),
    Path.Combine(env.ContentRootPath, "App_Data", "texts")
});

The first line loads the Serenity local texts, while the following two loads your application specific texts.

This meant, Serenity texts could only be loaded from the disk, and unless dotnet sergen restore command is run after updating Serenity.Scripts, you could have older set of texts in that folder.

Another problem is this only works with Serenity.Scripts and other feature packages like Serenity.Demo.Northwind, Serenity.Extensions etc. could not ship their own set of local text files.

To make it even worse, this is not unit testable, and requires physical disk access.

We've been using Static Web Assets feature of Razor SDK for our script and css files for some time, and we decided to make use of same functionality for local text files as well. So we are shipping JSON local text files as static web assets now, and there is no need for dotnet sergen restore anymore for them.

After updating Serenity, you may remove Scripts/serenity/texts folder. You just need to do a simple change in Startup.cs:

services.AddBaseTexts(env.WebRootFileProvider)
    .AddJsonTexts(env.WebRootFileProvider, "Scripts/site/texts")
    .AddJsonTexts(env.ContentRootFileProvider, "App_Data/texts");

The AddBaseTexts call in first line, adds what AddAllTexts did in the prior version, but additionally it can load all JSON text files stored as static web assets from referenced packages via ITypeSource implementation. This includes Serenity.Scripts in addition to any other assembly with localized texts like Serenity.Demo.Northwind, Serenity.Pro.Meeting etc. All those assemblies should have a Serenity.ComponentModel.JsonLocalTextAssets attribute.

If you would like to pack your JSON files also for your own feature projects, you would need to add a target like following (preferably in a Directory.Build.targets file):

  <Target Name="AddJsonLocalTextAssetsAttribute" BeforeTargets="CoreGenerateAssemblyInfo" Condition="Exists('wwwroot\texts')">
    <ItemGroup>
      <AssemblyAttribute Include="Serenity.ComponentModel.JsonLocalTextAssets">
        <_Parameter1>$(StaticWebAssetBasePath)/texts</_Parameter1>
      </AssemblyAttribute>
    </ItemGroup>
  </Target>

This creates an attribute in your project during compilation, identifiying the path to local text files. Make sure you put your .json files under wwwroot\texts.

Note that this is only useful for feature projects like Serenity.Pro.Meeting, not YourProject.Web.

Converted All Pages in StartSharp to Modular TypeScript Style

We originally used namespace style TypeScript, as that was the preferred way back then (about 9 years ago).

Even TypeScript itself is written using namespaces (https://github.com/microsoft/TypeScript/blob/main/src/compiler/core.ts) and still is. They are trying to convert it to modules.

Before TypeScript, we were using Script# followed by Saltaralle, which were both C# to Javascript compilers. To make switching to TypeScript easier, namespaces felt natural at that time, and improved interoperability with existing Saltaralle code.

As most of our users are C# developers, namespaces felt much more natural to us, and mapping between C# / TypeScript classes were simple.

Unfortunately, namespaces are starting to feel like they are second class citizens in both TypeScript / Javascript world in general. Most samples and libraries you may find in internet these days are written using EcmaScript modules and delivered as NPM packages.

We deliberately skipped options like WebPack etc. in the past as they were too complex for our purposes and slowed the build.

Another disadvantage of namespaces is revealed when you want to have different isolated parts in your web application, like frontend / backend, while sharing some code selectively between them. As the dependencies of a class can't be determined as easily as isolated EcmaScript modules, it is not easy to do that with namespaces.

Serenity 6.1+ came with modular TypeScript support, and we are making use of esbuild tool which is very fast for tasks like building, bundling and minification:

We'll have another blog post explaining the reasons behind switch to modular TypeScript and how to develop in that style.

In StartSharp 6.2.0, we completed conversion of all the remaining pages under Namespaces folder to modular TypeScript files under Modules folder. Here is a sample from old change password panel:

namespace StartSharp.Membership {

    @Serenity.Decorators.registerClass()
    export class ChangePasswordPanel extends Serenity.PropertyPanel<ChangePasswordRequest, any> {

        protected getFormKey() { return ChangePasswordForm.formKey; }

        private form: ChangePasswordForm;

        constructor(container: JQuery) {
            super(container);

            this.form = new ChangePasswordForm(this.idPrefix);
    //...

and new modular version:

import { ChangePasswordForm, ChangePasswordRequest } from "@/ServerTypes/Membership";
import { Texts } from "@/ServerTypes/Texts";
import { PropertyPanel } from "@serenity-is/corelib";
import { format, information, resolveUrl, serviceCall, text } from "@serenity-is/corelib/q";

$(function () {
    new ChangePasswordPanel($('#ChangePasswordPanel'));
});

class ChangePasswordPanel extends PropertyPanel<ChangePasswordRequest, any> {

    protected getFormKey() { return ChangePasswordForm.formKey; }

    private form: ChangePasswordForm;

    constructor(container: JQuery) {
        super(container);

        this.form = new ChangePasswordForm(this.idPrefix);

There is no namespaced code in StartSharp.Web project itself. We'll be converting our feature packages to modular style next.

Renamed ScriptInitialization.ts to ScriptInit.ts [StartSharp]

This is the modular TypeScript version of ScriptInitialization.ts, currently only enabled in StartSharp:

import { EntityDialog, HtmlContentEditor } from "@serenity-is/corelib";
import { Authorization, Config, ErrorHandling } from "@serenity-is/corelib/q";
import { IdleTimeout, setupUIOverrides } from "@serenity-is/pro.extensions";
import { siteLanguageList } from "./Helpers/LanguageList";

Config.rootNamespaces.push('StartSharp');
EntityDialog.defaultLanguageList = siteLanguageList;
//...

We changed the filename so it does not get confused with the classic namespace version. This script is loaded into both modular and namespace files from _LayoutHead.cshtml:

<script type="module" src="~/esm/Modules/Common/ScriptInit.js"></script>

Unlike namespace version, it is not possible to define a global script file that will be runned in all modular TypeScript pages.

It was possible to use inject feature of esbuild for this, but we still have non-modular, e.g. namespaced TypeScript code in some feature packages, so we'll go back to that after converting them all to modular.

Removed Imports/ServerTypings Folder

This folder was only used for namespace typings, and not used for modular TypeScript. As we converted all classic namespace code, to modular TypeScript in StartSharp, when you create a new project with StartSharp 6.2.0+, there will not be a Imports folder.

You may find a Modules/ServerTypes folder which contains a similar set of files, but in modular TypeScript style.

Passed keepNames Option of ESBuild as true in tsbuild.js

As Serenity Widget system adds css class names to the HTML elements based on class name of the widget, and esbuild removes them during minification, the CSS rules you might have based on CSS class name of widgets no longer applied:

.s-SomeDialog {
    background-color: red;
}

This would not apply to an element with s-SomeDialog CSS class, but as esbuild would rename the widget class to something like b during minification causing the element to have s-b CSS class instead of s-SomeDialog.

We added a keepNames: true option to tsbuild.js where we call esbuild to resolve this:

await esbuild.build({
    //...
    entryPoints: entryPoints,
    format: 'esm',
    keepNames: true,
    //...
}

It is recommended to do this change in your tsbuild.js file if your project is created before 6.2.0.

Fixed Clean Plugin in tsbuild.js

We have a Clean plugin for esbuild that deletes old files under wwwroot\esm after a successfull build so that folder does not get polluted with many unused files.

There was a typo in that plugin that caused old files to be not actually getting deleted. You should change this line:

if (!outputs || !existsSync(build.initialOptions.outDir))

with this one:

if (!outputs || !existsSync(build.initialOptions.outdir))