Skip to content

Commit e1c404c

Browse files
committed
perf: implement all 10 frontend+backend performance optimizations
P0 (High Impact): - Selective HeroUI CSS imports: 387KB → 40KB original (-90%) - Pre-compress static assets with vite-plugin-compression (Brotli q11) - Serve pre-compressed .br files via custom middleware CSS: 70KB → 6.5KB, JS total: 143KB → 101KB transferred P1 (Medium Impact): - Immutable cache headers (max-age=365d) for content-hashed assets - HTTP/2 via Kestrel ConfigureEndpointDefaults - ETag + Cache-Control: no-cache on index.html (304 support) P2 (Polish): - DNS prefetch + preconnect for cdn.jsdelivr.net - Remove duplicate SVG preload hint - Remove dead SignalR refs from vite.config.ts - Combined /api/init endpoint (single request for config + monitors)
1 parent df14cbb commit e1c404c

15 files changed

Lines changed: 238 additions & 34 deletions

File tree

src/cs/Deucalion.Api/Application.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ public static WebApplication ConfigureApplication(this WebApplication app)
9393
app.MapGet("/api/configuration", (DeucalionOptions options) =>
9494
Results.Ok(new PageConfigurationDto(options.PageTitle, options.PageDescription)));
9595

96+
app.MapGet("/api/init", async (DeucalionOptions options, IStorage storage, CancellationToken cancellationToken) =>
97+
{
98+
var tasks = applicationConfiguration.Monitors
99+
.Select(kvp => BuildMonitorDtoAsync(storage, kvp.Value, kvp.Key, cancellationToken));
100+
var monitors = await Task.WhenAll(tasks);
101+
return Results.Ok(new InitDto(
102+
new PageConfigurationDto(options.PageTitle, options.PageDescription),
103+
monitors
104+
));
105+
});
106+
96107
app.MapGet("/api/monitors/{monitorName?}", async (IStorage storage, string? monitorName, CancellationToken cancellationToken) =>
97108
{
98109
if (monitorName is null)

src/cs/Deucalion.Api/DeucalionJsonContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ namespace Deucalion.Api;
1515
[JsonSerializable(typeof(MonitorDto))]
1616
[JsonSerializable(typeof(MonitorDto[]))]
1717
[JsonSerializable(typeof(PageConfigurationDto))]
18+
[JsonSerializable(typeof(InitDto))]
1819
internal partial class DeucalionJsonContext : JsonSerializerContext;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Deucalion.Api.Models;
2+
3+
internal record InitDto(
4+
PageConfigurationDto Configuration,
5+
MonitorDto[] Monitors
6+
);

src/cs/Deucalion.Service/Program.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Deucalion.Api;
22
using Deucalion.Application.Configuration;
33
using Deucalion.Service;
4+
using Microsoft.AspNetCore.Server.Kestrel.Core;
45

56
try
67
{
@@ -16,6 +17,15 @@
1617

1718
builder.Services.AddWindowsService();
1819

20+
// Enable HTTP/2 (multiplexed asset loading, HPACK header compression)
21+
builder.WebHost.ConfigureKestrel(options =>
22+
{
23+
options.ConfigureEndpointDefaults(listenOptions =>
24+
{
25+
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
26+
});
27+
});
28+
1929
builder.ConfigureApplicationBuilder()
2030
.Build()
2131
.ConfigureApplication()

src/cs/Deucalion.Service/WebApplicationExtensions.cs

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,82 @@
1-
using System.Web;
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using System.Web;
24
using Deucalion.Api.Options;
5+
using Microsoft.AspNetCore.StaticFiles;
6+
using Microsoft.Net.Http.Headers;
37

48
namespace Deucalion.Service;
59

610
internal static class WebApplicationExtensions
711
{
812
/// <summary>
9-
/// Use file server with response cache for all static files in '/assets'.
13+
/// Serve static files with pre-compressed (Brotli/Gzip) support and immutable caching for '/assets'.
1014
/// </summary>
1115
internal static WebApplication UseCachedFileServer(this WebApplication app)
1216
{
17+
var contentTypeProvider = new FileExtensionContentTypeProvider();
18+
var webRootPath = app.Environment.WebRootPath;
19+
20+
// Middleware: serve pre-compressed .br/.gz sidecar files for /assets/
21+
app.Use(async (context, next) =>
22+
{
23+
var path = context.Request.Path.Value;
24+
if (path?.StartsWith("/assets/") == true)
25+
{
26+
var acceptEncoding = context.Request.Headers.AcceptEncoding.ToString();
27+
var physicalPath = Path.Combine(webRootPath, path.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
28+
29+
string? compressedPath = null;
30+
string? encoding = null;
31+
32+
if (acceptEncoding.Contains("br"))
33+
{
34+
var brPath = physicalPath + ".br";
35+
if (File.Exists(brPath))
36+
{
37+
compressedPath = brPath;
38+
encoding = "br";
39+
}
40+
}
41+
42+
if (compressedPath is null && acceptEncoding.Contains("gzip"))
43+
{
44+
var gzPath = physicalPath + ".gz";
45+
if (File.Exists(gzPath))
46+
{
47+
compressedPath = gzPath;
48+
encoding = "gzip";
49+
}
50+
}
51+
52+
if (compressedPath is not null)
53+
{
54+
if (contentTypeProvider.TryGetContentType(path, out var contentType))
55+
{
56+
context.Response.ContentType = contentType;
57+
}
58+
59+
context.Response.Headers.ContentEncoding = encoding;
60+
context.Response.Headers.Vary = "Accept-Encoding";
61+
context.Response.ContentLength = new FileInfo(compressedPath).Length;
62+
63+
SetImmutableCacheHeaders(context.Response);
64+
65+
await context.Response.SendFileAsync(compressedPath);
66+
return;
67+
}
68+
}
69+
70+
await next();
71+
});
72+
73+
// Fallback: serve uncompressed files (response compression middleware handles on-the-fly compression)
1374
var fso = new FileServerOptions();
1475
fso.StaticFileOptions.OnPrepareResponse = (context) =>
1576
{
16-
//
1777
if (context.Context.Request.Path.StartsWithSegments("/assets"))
1878
{
19-
var headers = context.Context.Response.GetTypedHeaders();
20-
headers.CacheControl = new Microsoft.Net.Http.Headers.CacheControlHeaderValue
21-
{
22-
Public = true,
23-
MaxAge = TimeSpan.FromDays(7),
24-
SharedMaxAge = TimeSpan.FromHours(12)
25-
};
79+
SetImmutableCacheHeaders(context.Context.Response);
2680
}
2781
};
2882

@@ -34,6 +88,7 @@ internal static WebApplication UseCachedFileServer(this WebApplication app)
3488
/// <summary>
3589
/// Serve 'index.html' replacing SEO elements with values from app configuration.
3690
/// The processed result is cached at startup since PageTitle and PageDescription don't change at runtime.
91+
/// Supports conditional requests via ETag for efficient revalidation.
3792
/// </summary>
3893
internal static WebApplication UseIndexPage(this WebApplication app)
3994
{
@@ -42,11 +97,27 @@ internal static WebApplication UseIndexPage(this WebApplication app)
4297

4398
if (cachedContent is not null)
4499
{
100+
// Pre-compute ETag based on content hash
101+
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(cachedContent));
102+
var etag = $"\"{Convert.ToHexString(hashBytes[..8])}\"";
103+
45104
app.Use(async (context, next) =>
46105
{
47106
if (context.Request.Path == "/")
48107
{
108+
// Conditional GET: return 304 if client has current version
109+
var ifNoneMatch = context.Request.Headers.IfNoneMatch.ToString();
110+
if (ifNoneMatch == "*" || ifNoneMatch.Contains(etag))
111+
{
112+
context.Response.StatusCode = StatusCodes.Status304NotModified;
113+
context.Response.Headers.CacheControl = "no-cache";
114+
context.Response.Headers.ETag = etag;
115+
return;
116+
}
117+
49118
context.Response.ContentType = "text/html";
119+
context.Response.Headers.CacheControl = "no-cache";
120+
context.Response.Headers.ETag = etag;
50121
await context.Response.WriteAsync(cachedContent);
51122
return;
52123
}
@@ -58,6 +129,17 @@ internal static WebApplication UseIndexPage(this WebApplication app)
58129
return app;
59130
}
60131

132+
private static void SetImmutableCacheHeaders(HttpResponse response)
133+
{
134+
var headers = response.GetTypedHeaders();
135+
headers.CacheControl = new CacheControlHeaderValue
136+
{
137+
Public = true,
138+
MaxAge = TimeSpan.FromDays(365),
139+
Extensions = { new NameValueHeaderValue("immutable") }
140+
};
141+
}
142+
61143
private static string? BuildIndexContent(WebApplication app)
62144
{
63145
var indexFile = app.Environment.WebRootFileProvider.GetFileInfo("/index.html").PhysicalPath;

src/ts/deucalion-ui/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
<head>
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/assets/deucalion-icon.svg" />
6-
<link rel="preload" href="/assets/deucalion-icon.svg" as="image">
6+
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
7+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
78
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
89
<!-- $DEUCALION__PAGETITLE -->
910
<!-- $DEUCALION__PAGEDESCRIPTION -->

src/ts/deucalion-ui/package-lock.json

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ts/deucalion-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"typescript": "~5.9.3",
4343
"typescript-eslint": "^8.53.1",
4444
"vite": "^8.0.2",
45+
"vite-plugin-compression": "^0.5.1",
4546
"vitest": "^4.1.1"
4647
}
4748
}

src/ts/deucalion-ui/src/components/app.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Overview, MonitorList } from "./main";
22

3-
import { logger } from "../services";
3+
import { preloadInit, logger } from "../services";
44

5-
import { preloadConfiguration, useConfiguration } from "../contexts/ConfigurationContext";
6-
import { preloadMonitors, useMonitors } from "../contexts/MonitorsContext";
5+
import { useConfiguration } from "../contexts/ConfigurationContext";
6+
import { useMonitors } from "../contexts/MonitorsContext";
77
import { useMonitorHubContext } from "../contexts/MonitorHubContext";
88

9-
preloadConfiguration();
10-
preloadMonitors();
9+
preloadInit();
1110

1211
// Log version information to console.
1312
const buildInfo = { version: import.meta.env.VITE_BUILD_VERSION, build: import.meta.env.VITE_INFORMATIONAL_VERSION };

src/ts/deucalion-ui/src/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export const API_INIT_URL = "/api/init";
12
export const API_CONFIGURATION_URL = "/api/configuration";
23
export const API_MONITORS_URL = "/api/monitors";
34
export const API_EVENTS_URL = "/api/monitors/events";

0 commit comments

Comments
 (0)