PublishAsync(string name, string? compressionProperty)
+ {
+ var directory = Path.Combine(_root, name);
+ Directory.CreateDirectory(directory);
+ foreach (var path in AssetPaths)
+ {
+ var file = Path.Combine(directory, "wwwroot", path);
+ Directory.CreateDirectory(Path.GetDirectoryName(file)!);
+ var contents = path.EndsWith(".css", StringComparison.Ordinal)
+ ? string.Concat(Enumerable.Range(0, 100).Select(index => $".item-{index} {{ color: #345678; padding: {index}px; }}\n"))
+ : string.Concat(Enumerable.Range(0, 100).Select(index => $"Asset {index}: Aspire documentation and API reference.
\n"));
+ await File.WriteAllTextAsync(file, contents);
+ }
+ await File.WriteAllTextAsync(Path.Combine(directory, "Program.cs"), "var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets(); app.Run();");
+ var targets = Path.Combine(AppContext.BaseDirectory, "PublishFixture");
+ var project = new XDocument(new XElement("Project",
+ new XAttribute("Sdk", "Microsoft.NET.Sdk.Web"),
+ new XElement("PropertyGroup",
+ new XElement("TargetFramework", "net10.0"),
+ new XElement("ImplicitUsings", "enable"),
+ new XElement("EnableDefaultCompressionFormats", "false"),
+ new XElement("PublishCompressionFormats", "brotli")),
+ new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Publishing.targets"))),
+ new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Caching.targets")))));
+ await File.WriteAllTextAsync(Path.Combine(directory, "PublishFixture.csproj"), project.ToString());
+
+ var start = new ProcessStartInfo("dotnet")
+ {
+ WorkingDirectory = directory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ foreach (var argument in new[] { "publish", "--configuration", "Release", "--nologo", "--verbosity", "minimal", "--output", Path.Combine(directory, "publish") })
+ {
+ start.ArgumentList.Add(argument);
+ }
+ if (compressionProperty is not null)
+ {
+ start.ArgumentList.Add(compressionProperty);
+ }
+ var results = Environment.GetEnvironmentVariable("TEST_RESULTS_DIR");
+ if (!string.IsNullOrEmpty(results))
+ {
+ Directory.CreateDirectory(results);
+ start.ArgumentList.Add($"-bl:{Path.Combine(results, $"StaticHost.Publish.{name}.binlog")};ProjectImports=None");
+ }
+ start.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
+ var result = await RunAsync(start, $"publish fixture '{name}'");
+ Assert.True(result.ExitCode == 0, $"Publish fixture '{name}' failed with exit code {result.ExitCode}.\n{result.Log}");
+ return result.Log;
+ }
+
+ private static async Task<(int ExitCode, string Log)> RunAsync(ProcessStartInfo start, string name)
+ {
+ using var process = new Process { StartInfo = start };
+ Assert.True(process.Start(), $"Failed to start {name}.");
+ var output = process.StandardOutput.ReadToEndAsync();
+ var error = process.StandardError.ReadToEndAsync();
+ using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromMinutes(3));
+ try
+ {
+ await process.WaitForExitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException) when (timeout.IsCancellationRequested)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync();
+ throw new TimeoutException($"{name} timed out.\n{await output}\n{await error}");
+ }
+ return (process.ExitCode, $"{await output}\n{await error}");
+ }
+}
diff --git a/tests/StaticHost.Tests/StaticHost.Tests.csproj b/tests/StaticHost.Tests/StaticHost.Tests.csproj
index 648d9c0b73..40e83b3882 100644
--- a/tests/StaticHost.Tests/StaticHost.Tests.csproj
+++ b/tests/StaticHost.Tests/StaticHost.Tests.csproj
@@ -34,6 +34,15 @@
static layout it wants to assert on.
-->
+
+
+
diff --git a/tests/scripts/Measure-StaticHostPublish.ps1 b/tests/scripts/Measure-StaticHostPublish.ps1
new file mode 100644
index 0000000000..656412e813
--- /dev/null
+++ b/tests/scripts/Measure-StaticHostPublish.ps1
@@ -0,0 +1,91 @@
+param(
+ [Parameter(Mandatory)][string]$PublishDirectory,
+ [Parameter(Mandatory)][string]$FrontendDirectory,
+ [Parameter(Mandatory)][string]$DiagnosticsDirectory,
+ [Parameter(Mandatory)][string]$SummaryPath
+)
+
+$ErrorActionPreference = 'Stop'
+$webRoot = Join-Path $PublishDirectory 'wwwroot'
+$frontendRoot = (Resolve-Path -LiteralPath $FrontendDirectory).Path
+$frontendFiles = @(Get-ChildItem -LiteralPath $FrontendDirectory -Recurse -File)
+if ($frontendFiles.Count -eq 0) {
+ throw 'The frontend output is empty.'
+}
+foreach ($file in $frontendFiles) {
+ $relative = [IO.Path]::GetRelativePath($frontendRoot, $file.FullName)
+ if (-not (Test-Path -LiteralPath (Join-Path $webRoot $relative) -PathType Leaf)) {
+ throw "Frontend output is missing from the published host: $relative"
+ }
+}
+
+$assets = @(Get-ChildItem -LiteralPath $webRoot -Recurse -File)
+$compressed = @($assets | Where-Object Extension -eq '.br')
+if ($compressed.Count -eq 0) {
+ throw 'No Brotli assets were published.'
+}
+$samples = @(
+ Get-Item -LiteralPath (Join-Path $webRoot 'index.html')
+ $assets | Where-Object { $_.Extension -eq '.md' } | Select-Object -First 1
+ $assets | Where-Object { $_.Extension -eq '.html' -and $_.FullName -match '[\\/]reference[\\/]api[\\/]' } | Select-Object -First 1
+ Get-ChildItem -LiteralPath (Join-Path $webRoot '_astro') -File -Filter '*.css' | Select-Object -First 1
+)
+if ($samples.Count -ne 4) {
+ throw 'Expected published home, Markdown, API HTML, and hashed CSS samples.'
+}
+foreach ($sample in $samples) {
+ $original = [IO.File]::ReadAllBytes($sample.FullName)
+ $inputStream = [IO.File]::OpenRead("$($sample.FullName).br")
+ $decoder = [IO.Compression.BrotliStream]::new($inputStream, [IO.Compression.CompressionMode]::Decompress)
+ $restored = [IO.MemoryStream]::new()
+ try {
+ $decoder.CopyTo($restored)
+ if ([Convert]::ToBase64String($original) -cne [Convert]::ToBase64String($restored.ToArray())) {
+ throw "Brotli content differs from the original: $($sample.FullName)"
+ }
+ } finally {
+ $restored.Dispose()
+ $decoder.Dispose()
+ $inputStream.Dispose()
+ }
+}
+
+$archives = @(Get-ChildItem -LiteralPath (Join-Path $DiagnosticsDirectory 'images') -File)
+if ($archives.Count -ne 1 -or $archives[0].Length -eq 0) {
+ throw 'Expected one nonempty container archive.'
+}
+$timings = @(Get-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'publish.log') |
+ Where-Object { $_.Contains('[publish-timing]') })
+foreach ($phase in @('compression start', 'compression complete', 'static asset metadata and manifest complete')) {
+ if (-not ($timings | Where-Object { $_.Contains($phase) })) {
+ throw "Publish timing is missing: $phase"
+ }
+}
+if (-not ($timings | Where-Object { $_.Contains('brotliLevel=Optimal') })) {
+ throw 'Full-site publishing did not use Optimal Brotli.'
+}
+$metrics = [ordered]@{
+ frontendFiles = $frontendFiles.Count
+ publishedFiles = $assets.Count
+ brotliFiles = $compressed.Count
+ brotliBytes = ($compressed | Measure-Object Length -Sum).Sum
+ containerArchiveBytes = $archives[0].Length
+ roundTripSamples = $samples.Count
+ timings = $timings
+}
+$metrics | ConvertTo-Json -Depth 3 |
+ Set-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'output.json')
+@(
+ '## StaticHost publish measurements'
+ ''
+ "- Verified $($metrics.frontendFiles) frontend files in the published host."
+ "- $($metrics.brotliFiles) Brotli files; $($metrics.brotliBytes) compressed bytes."
+ "- Container archive: $($metrics.containerArchiveBytes) bytes."
+ '- Verified exact Brotli round trips for home, Markdown, API HTML, and hashed CSS.'
+ ''
+ '```text'
+ $timings
+ '```'
+ ''
+ 'Full timings, resource usage, and MSBuild binary log: `statichost-publish-performance` artifact.'
+) | Add-Content -LiteralPath $SummaryPath
From 59dc212d95cb04e04aed8f0b0f850093d483c01b Mon Sep 17 00:00:00 2001
From: David Pine <7679720+IEvangelist@users.noreply.github.com>
Date: Mon, 12 Oct 2026 22:25:28 -0500
Subject: [PATCH 2/8] Reject corrupted Brotli output in publish diagnostics
Exercise the full-site verifier against incorrect compressed content without mutating shared publish fixtures.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6
---
.../PublishingTargetsTests.cs | 25 ++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/tests/StaticHost.Tests/PublishingTargetsTests.cs b/tests/StaticHost.Tests/PublishingTargetsTests.cs
index c3e5a39fa5..f390f2619a 100644
--- a/tests/StaticHost.Tests/PublishingTargetsTests.cs
+++ b/tests/StaticHost.Tests/PublishingTargetsTests.cs
@@ -57,6 +57,14 @@ public async Task PublishMeasurementVerifiesAssetsAndRejectsIncompleteOutput()
Assert.Contains("Frontend output is missing from the published host", incomplete.Log);
}
+ [Fact]
+ public async Task PublishMeasurementRejectsIncorrectBrotliContent()
+ {
+ var result = await fixture.MeasureAsync(includeMissingFile: false, corruptBrotli: true);
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("Brotli content differs from the original", result.Log);
+ }
+
[Theory]
[InlineData("/index.html")]
[InlineData("/reference/api/sample.md")]
@@ -163,10 +171,11 @@ public Task DisposeAsync()
return Task.CompletedTask;
}
- public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile)
+ public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile, bool corruptBrotli = false)
{
- var directory = Path.Combine(_root, includeMissingFile ? "measure-incomplete" : "measure-complete");
+ var directory = Path.Combine(_root, $"measure-{Guid.NewGuid():N}");
var frontend = Path.Combine(directory, "frontend");
+ var publish = Path.Combine(directory, "publish");
var diagnostics = Path.Combine(directory, "diagnostics");
Directory.CreateDirectory(Path.Combine(diagnostics, "images"));
foreach (var path in AssetPaths)
@@ -174,11 +183,21 @@ public Task DisposeAsync()
var file = Path.Combine(frontend, path);
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path), file);
+ var published = Path.Combine(publish, "wwwroot", path);
+ Directory.CreateDirectory(Path.GetDirectoryName(published)!);
+ File.Copy(file, published);
+ File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path + ".br"), published + ".br");
}
if (includeMissingFile)
{
await File.WriteAllTextAsync(Path.Combine(frontend, "missing.html"), "missing");
}
+ if (corruptBrotli)
+ {
+ await using var output = File.Create(Path.Combine(publish, "wwwroot", "index.html.br"));
+ await using var compressor = new BrotliStream(output, CompressionLevel.Optimal);
+ await compressor.WriteAsync(Encoding.UTF8.GetBytes("incorrect content"));
+ }
await File.WriteAllTextAsync(Path.Combine(diagnostics, "images", "fixture.tar.gz"), "fixture archive");
await File.WriteAllTextAsync(Path.Combine(diagnostics, "publish.log"), DefaultOutput);
var start = new ProcessStartInfo("pwsh")
@@ -192,7 +211,7 @@ public Task DisposeAsync()
{
"-NoProfile", "-NonInteractive", "-File",
Path.Combine(AppContext.BaseDirectory, "PublishFixture", "Measure-StaticHostPublish.ps1"),
- "-PublishDirectory", DefaultDirectory,
+ "-PublishDirectory", publish,
"-FrontendDirectory", frontend,
"-DiagnosticsDirectory", diagnostics,
"-SummaryPath", Path.Combine(diagnostics, "summary.md")
From 0ae48c5481e820a9cf449b6a5228514a24b45a83 Mon Sep 17 00:00:00 2001
From: David Pine <7679720+IEvangelist@users.noreply.github.com>
Date: Tue, 13 Oct 2026 09:45:32 -0500
Subject: [PATCH 3/8] Align dashboard persistence assertions with the writable
volume example
Update the stale mount and data-directory expectations to match the corrected non-root dashboard example in both shell variants.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6
---
.../tests/unit/dashboard-image-references.vitest.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts
index a4960b8891..1de12e5ba9 100644
--- a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts
+++ b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts
@@ -81,10 +81,10 @@ describe('standalone dashboard container images', () => {
expect(commands).toHaveLength(2);
for (const command of commands) {
expect(command).toContain(
- '--mount type=volume,source=aspire-dashboard-data,target=/dashboard-data'
+ '--mount type=volume,source=aspire-dashboard-data,target=/home/app'
);
expect(command).toContain('-e ASPIRE_DASHBOARD_APPLICATION_NAME=my-app');
- expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/dashboard-data');
+ expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/home/app/dashboard-data');
expect(command).toContain('-e ASPIRE_DASHBOARD_PERSISTENCE_MODE=run');
}
});
From e0fdfee8664cf8f917a7d82057aa8a1662c39ea0 Mon Sep 17 00:00:00 2001
From: David Pine <7679720+IEvangelist@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:05:41 -0500
Subject: [PATCH 4/8] Profile compression size and browser tradeoffs on
identical site output
Add opt-in controlled SmallestSize versus Optimal publishing, image, CPU, memory, native decode, and cold/warm Chromium measurements without rendering the frontend twice.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6
---
.github/workflows/apphost-build.yml | 30 ++-
.github/workflows/ci.yml | 1 +
CONTRIBUTING.md | 24 ++
.../profile-static-host-compression.mjs | 207 ++++++++++++++++++
.../unit/compression-profile.vitest.test.ts | 19 ++
.../CompressionProfileTests.cs | 40 ++++
.../StaticHost.Tests/StaticHost.Tests.csproj | 2 +
.../CompressionProfile/CompressionInputs.cs | 41 ++++
.../CompressionProfile.csproj | 15 ++
.../performance/CompressionProfile/Program.cs | 204 +++++++++++++++++
tests/scripts/Measure-StaticHostPublish.ps1 | 7 +-
.../profile-static-host-compression.sh | 92 ++++++++
12 files changed, 677 insertions(+), 5 deletions(-)
create mode 100644 src/frontend/scripts/profile-static-host-compression.mjs
create mode 100644 src/frontend/tests/unit/compression-profile.vitest.test.ts
create mode 100644 tests/StaticHost.Tests/CompressionProfileTests.cs
create mode 100644 tests/performance/CompressionProfile/CompressionInputs.cs
create mode 100644 tests/performance/CompressionProfile/CompressionProfile.csproj
create mode 100644 tests/performance/CompressionProfile/Program.cs
create mode 100644 tests/scripts/profile-static-host-compression.sh
diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml
index 3be184a4c1..8098f92a4c 100644
--- a/.github/workflows/apphost-build.yml
+++ b/.github/workflows/apphost-build.yml
@@ -8,6 +8,11 @@ on:
required: false
default: false
type: boolean
+ profile_compression:
+ description: Compare compression, image sizes, and browser performance on identical generated content
+ required: false
+ default: false
+ type: boolean
permissions:
contents: read
@@ -119,9 +124,9 @@ jobs:
publish:
name: StaticHost publish diagnostics
- if: ${{ inputs.profile_publish }}
+ if: ${{ inputs.profile_publish || inputs.profile_compression }}
runs-on: ubuntu-latest
- timeout-minutes: 45
+ timeout-minutes: 90
env:
DOTNET_NOLOGO: 1
DOTNET_CLI_TELEMETRY_OPTOUT: 1
@@ -178,6 +183,22 @@ jobs:
-DiagnosticsDirectory "$env:RUNNER_TEMP/statichost-publish" `
-SummaryPath $env:GITHUB_STEP_SUMMARY
+ - name: Build controlled compression profiler
+ if: ${{ inputs.profile_compression }}
+ run: >
+ dotnet build tests/performance/CompressionProfile/CompressionProfile.csproj
+ --configuration Release --output "$RUNNER_TEMP/statichost-publish/profiler"
+
+ - name: Install Chromium for compression comparison
+ if: ${{ inputs.profile_compression }}
+ working-directory: src/frontend
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Compare complete compression variants and browser performance
+ if: ${{ inputs.profile_compression }}
+ shell: bash
+ run: bash tests/scripts/profile-static-host-compression.sh
+
- name: Upload publish diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -189,5 +210,10 @@ jobs:
${{ runner.temp }}/statichost-publish/*.binlog
${{ runner.temp }}/statichost-publish/*.log
${{ runner.temp }}/statichost-publish/*.txt
+ ${{ runner.temp }}/statichost-publish/comparison/**/*.json
+ ${{ runner.temp }}/statichost-publish/comparison/**/*.binlog
+ ${{ runner.temp }}/statichost-publish/comparison/**/*.log
+ ${{ runner.temp }}/statichost-publish/comparison/**/*.txt
+ ${{ runner.temp }}/statichost-publish/comparison/*.sha256
if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 111c81edd4..52ce20bb57 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -104,6 +104,7 @@ jobs:
uses: ./.github/workflows/apphost-build.yml
with:
profile_publish: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-profile') }}
+ profile_compression: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'compression-profile') }}
release-sync-tests:
runs-on: ubuntu-latest
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 854f94c086..efc61917c3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -95,6 +95,30 @@ and representative Brotli round trips, and uploads
binary log, resource measurements, frontend timings, and output sizes. It does
not upload or deploy the container image.
+Use the `compression-profile` label for a controlled `SmallestSize` versus
+`Optimal` comparison. The job renders the frontend once, snapshots every output
+file with SHA-256, and republishes each compression variant without rebuilding
+the frontend or StaticHost assembly. It clears only the generated publish and
+compression directories between variants. Both variants must retain identical
+frontend content, assembly/configuration hashes, and container base layers.
+
+The comparison records Brotli and published-output totals, sizes by directory,
+actual OCI archive sizes, compression timings, CPU time, and peak RSS. Chromium
+measures response/transfer sizes, TTFB, FCP/LCP, main-thread activity, and JS heap
+for home, a guide, and median-sized C# and TypeScript API pages. Each page gets
+three cold/warm browser-cache pairs in desktop and mobile-like scenarios.
+The mobile-like profile uses 5 Mbps downstream, 40 ms simulated latency, and
+4x CPU throttling. An isolated native decoder measurement and 1/5/20 Mbps
+body-byte transfer models are also included.
+
+These are lab measurements against Kestrel with the published SDK static-asset
+manifest. Front Door, TLS, Redis, live APIs, external requests, and physical
+device behavior are not measured. Server filesystem caches are not forcibly
+evicted. Medians and ranges are descriptive, not statistical significance
+claims. Review `comparison/comparison.json` in
+`statichost-publish-performance` before deciding whether the byte-size tradeoff
+is acceptable.
+
Measure full-site publishing in CI. Compare equivalent source commits, SDKs,
runner images, and clean intermediate directories before attributing a timing
change to compression. A full StaticHost publish also builds the frontend.
diff --git a/src/frontend/scripts/profile-static-host-compression.mjs b/src/frontend/scripts/profile-static-host-compression.mjs
new file mode 100644
index 0000000000..662b1ad8a4
--- /dev/null
+++ b/src/frontend/scripts/profile-static-host-compression.mjs
@@ -0,0 +1,207 @@
+import { createHash } from 'node:crypto';
+import { appendFile, readFile, writeFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { chromium } from '@playwright/test';
+
+/** @param {number[]} values @returns {number} */
+export function median(values) {
+ if (values.length === 0 || values.some((value) => !Number.isFinite(value))) {
+ throw new Error('Median requires nonempty finite measurements.');
+ }
+ const sorted = [...values].sort((a, b) => a - b);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
+}
+
+/** @param {number} extraBytes @param {number} megabitsPerSecond @returns {number} */
+export function transferPenaltyMs(extraBytes, megabitsPerSecond) {
+ if (!Number.isFinite(extraBytes) || !Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) {
+ throw new Error('Invalid transfer model inputs.');
+ }
+ return extraBytes * 8 / (megabitsPerSecond * 1000);
+}
+
+async function run(baseUrl, manifestPath, variant, output) {
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ const browser = await chromium.launch({ headless: true });
+ const environment = {
+ node: process.version, chromium: browser.version(), platform: process.platform,
+ architecture: process.arch, runnerImage: process.env.ImageVersion, sourceCommit: process.env.GITHUB_SHA,
+ };
+ const samples = [];
+ const serverBefore = await (await fetch(`${baseUrl}/__profile/stats`)).json();
+ try {
+ for (const scenario of ['desktop', 'mobile-like']) {
+ for (const target of manifest.pages) {
+ for (let trial = 0; trial < 3; trial++) {
+ const context = await browser.newContext({
+ viewport: scenario === 'desktop' ? { width: 1440, height: 900 } : { width: 390, height: 844 },
+ locale: 'en-US',
+ });
+ try {
+ await context.route('**/*', (route) => {
+ return new URL(route.request().url()).origin === new URL(baseUrl).origin
+ ? route.continue() : route.abort();
+ });
+ for (const cache of ['cold', 'warm']) {
+ const page = await context.newPage();
+ try {
+ const session = await context.newCDPSession(page);
+ await session.send('Network.enable');
+ if (cache === 'cold') await session.send('Network.clearBrowserCache');
+ await session.send('Performance.enable');
+ if (scenario === 'mobile-like') {
+ await session.send('Network.emulateNetworkConditions', {
+ offline: false, latency: 40, downloadThroughput: 625000, uploadThroughput: 312500,
+ });
+ await session.send('Emulation.setCPUThrottlingRate', { rate: 4 });
+ }
+ await page.addInitScript(() => {
+ globalThis.__compressionLcp = null;
+ new PerformanceObserver((list) => {
+ globalThis.__compressionLcp = list.getEntries().at(-1)?.startTime ?? null;
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
+ });
+ const response = await page.goto(`${baseUrl}/${target.path}`, { waitUntil: 'load', timeout: 60000 });
+ if (!response || response.status() !== 200 || response.headers()['content-encoding'] !== 'br') {
+ throw new Error(`Expected a successful Brotli response for ${target.path}.`);
+ }
+ const hash = createHash('sha256').update(await response.body()).digest('hex').toUpperCase();
+ if (hash !== target.sha256) throw new Error(`Browser received different content: ${target.path}.`);
+ await page.waitForTimeout(1000);
+ const timing = await page.evaluate(() => {
+ const navigation = performance.getEntriesByType('navigation')[0];
+ const resources = performance.getEntriesByType('resource').filter((entry) =>
+ new URL(entry.name).origin === globalThis.location.origin);
+ return {
+ ttfbMs: navigation.responseStart - navigation.requestStart,
+ downloadMs: navigation.responseEnd - navigation.responseStart,
+ domContentLoadedMs: navigation.domContentLoadedEventEnd,
+ loadMs: navigation.loadEventEnd,
+ fcpMs: performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null,
+ lcpMs: globalThis.__compressionLcp,
+ documentEncodedBytes: navigation.encodedBodySize,
+ documentDecodedBytes: navigation.decodedBodySize,
+ transferBytes: navigation.transferSize + resources.reduce((sum, entry) => sum + entry.transferSize, 0),
+ };
+ });
+ const performanceMetrics = Object.fromEntries((await session.send('Performance.getMetrics'))
+ .metrics.map(({ name, value }) => [name, value]));
+ samples.push({
+ variant, scenario, page: target.name, path: target.path, trial, cache, ...timing,
+ mainThreadTaskMs: performanceMetrics.TaskDuration * 1000,
+ scriptMs: performanceMetrics.ScriptDuration * 1000,
+ layoutMs: performanceMetrics.LayoutDuration * 1000,
+ jsHeapBytes: performanceMetrics.JSHeapUsedSize,
+ });
+ await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore }, null, 2));
+ } finally {
+ await page.close();
+ }
+ }
+ } finally {
+ await context.close();
+ }
+ }
+ }
+ }
+ const serverAfter = await (await fetch(`${baseUrl}/__profile/stats`)).json();
+ await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore, serverAfter }, null, 2));
+ } finally {
+ await browser.close();
+ }
+}
+
+async function compare(directory, summaryPath) {
+ const variants = await Promise.all(['SmallestSize', 'Optimal'].map(async (name) => ({
+ name,
+ output: JSON.parse(await readFile(`${directory}/${name}/files.json`, 'utf8')),
+ image: JSON.parse(await readFile(`${directory}/${name}/image.json`, 'utf8')),
+ browser: JSON.parse(await readFile(`${directory}/${name}/browser.json`, 'utf8')),
+ timings: JSON.parse(await readFile(`${directory}/${name}/output.json`, 'utf8')),
+ tasks: JSON.parse(await readFile(`${directory}/${name}/tasks.json`, 'utf8')),
+ resources: await readFile(`${directory}/${name}/resources.txt`, 'utf8'),
+ })));
+ const [baseline, candidate] = variants;
+ if (JSON.stringify(baseline.image.baseLayers) !== JSON.stringify(candidate.image.baseLayers)) {
+ throw new Error('Container base layers changed between variants.');
+ }
+ if (baseline.output.pages.some((page, index) =>
+ page.sha256 !== candidate.output.pages[index]?.sha256)) {
+ throw new Error('Representative pages changed between variants.');
+ }
+ const browser = baseline.browser.samples.filter(({ trial }) => trial === 0).map((key) => {
+ const select = (variant) => variant.browser.samples.filter((sample) =>
+ sample.page === key.page && sample.scenario === key.scenario && sample.cache === key.cache);
+ return {
+ page: key.page, scenario: key.scenario, cache: key.cache,
+ variants: variants.map((variant) => {
+ const samples = select(variant);
+ return {
+ name: variant.name, samples: samples.length,
+ metrics: Object.fromEntries([
+ 'ttfbMs', 'downloadMs', 'domContentLoadedMs', 'loadMs', 'fcpMs', 'lcpMs',
+ 'documentEncodedBytes', 'documentDecodedBytes', 'transferBytes', 'mainThreadTaskMs',
+ 'scriptMs', 'layoutMs', 'jsHeapBytes',
+ ].map((metric) => {
+ const values = samples.map((sample) => sample[metric]).filter((value) => value !== null);
+ return [metric, values.length ? {
+ median: median(values), min: Math.min(...values), max: Math.max(...values),
+ } : null];
+ })),
+ };
+ }),
+ };
+ });
+ const deltas = {
+ brotliBytes: candidate.output.brotliBytes - baseline.output.brotliBytes,
+ publishedBytes: candidate.output.totalBytes - baseline.output.totalBytes,
+ archiveBytes: candidate.image.archiveBytes - baseline.image.archiveBytes,
+ };
+ const model = baseline.output.pages.map((page, index) => ({
+ page: page.name,
+ extraBytes: candidate.output.pages[index].brotliBytes - page.brotliBytes,
+ extraTransferMs: Object.fromEntries([1, 5, 20].map((rate) =>
+ [rate, transferPenaltyMs(candidate.output.pages[index].brotliBytes - page.brotliBytes, rate)])),
+ }));
+ const report = {
+ methodology: {
+ content: 'One SHA-256-verified generated frontend, same binaries and runner, clean compression outputs per variant.',
+ order: ['SmallestSize', 'Optimal'],
+ origin: 'Isolated Kestrel using the actual published MapStaticAssets manifest; no Front Door, TLS, Redis or live APIs.',
+ browser: 'Chromium; 3 cold/warm pairs per page/scenario; outside-origin requests blocked; 1-second LCP observation after load.',
+ mobile: '390x844 viewport, 5 Mbps downstream, 40 ms simulated latency, 4x CPU throttling; not a physical device.',
+ cache: 'Browser cache is cold or warm; server filesystem cache is not forcibly evicted.',
+ statistics: 'Descriptive medians and ranges; three pairs do not establish statistical significance.',
+ transferModel: 'Body-byte delta at 1/5/20 Mbps; excludes RTT, headers and multiplexing.',
+ },
+ variants, deltas, browser, model,
+ };
+ await writeFile(`${directory}/comparison.json`, JSON.stringify(report, null, 2));
+ const lines = [
+ '## Controlled compression comparison', '',
+ '| Metric | SmallestSize | Optimal | Delta |',
+ '| --- | ---: | ---: | ---: |',
+ `| Brotli task seconds | ${baseline.tasks.BrotliCompress.toFixed(3)} | ${candidate.tasks.BrotliCompress.toFixed(3)} | ${(candidate.tasks.BrotliCompress - baseline.tasks.BrotliCompress).toFixed(3)} |`,
+ `| Brotli bytes | ${baseline.output.brotliBytes} | ${candidate.output.brotliBytes} | ${deltas.brotliBytes} |`,
+ `| Published wwwroot bytes | ${baseline.output.totalBytes} | ${candidate.output.totalBytes} | ${deltas.publishedBytes} |`,
+ `| OCI archive bytes | ${baseline.image.archiveBytes} | ${candidate.image.archiveBytes} | ${deltas.archiveBytes} |`,
+ '', 'Compression timings and GNU time CPU/RSS measurements:', '',
+ ...variants.flatMap((variant) => [`### ${variant.name}`, '```text', ...variant.timings.timings, variant.resources, '```']),
+ '', 'Browser response, transfer, FCP/LCP, main-thread, heap, server CPU/RSS, and bandwidth-model data: `comparison.json`.',
+ 'These are controlled lab measurements, not deployed Front Door or field Core Web Vitals.', '',
+ ];
+ await appendFile(summaryPath, lines.join('\n'));
+}
+
+async function main() {
+ const [command, ...args] = process.argv.slice(2);
+ if (command === 'run' && args.length === 4) await run(...args);
+ else if (command === 'compare' && args.length === 2) await compare(...args);
+ else throw new Error('Usage: profile-static-host-compression.mjs run