Skip to content

Commit 7671d1d

Browse files
committed
⏺ Migrate to DotNetStreamReference, add round-trip e2e tests, and harden compression pipeline
- Replace BlazorDownloadFile with DotNetStreamReference + JS downloadFileFromStream - Add leaveOpen: true to all compression streams to eliminate ToArray() memory copy - Add SemaphoreSlim(4) to bound concurrent compress/decompress memory usage - Add 250 MB file size warning in the UI before processing - Fix Safari download bug: defer revokeObjectURL by 60 s - Add round-trip e2e tests for all 5 formats (caught real Brotli decompression bug) - Add compression level e2e test (SmallestSize ≤ Fastest, round-trips correctly) - Fix e2e test runner: build once, kill stale server cross-platform, start with --no-build
1 parent 0979a69 commit 7671d1d

8 files changed

Lines changed: 204 additions & 50 deletions

File tree

App.razor

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
@using System.IO
22
@using System.IO.Compression
3-
@inject BlazorDownloadFile.IBlazorDownloadFileService BlazorDownloadFileService
3+
@inject IJSRuntime JS
44
@inject NavigationManager NavManager
55

66
<ul class="nav nav-pills mb-4 pb-1 border-bottom" role="navigation" aria-label="Compression format">
@@ -92,6 +92,10 @@
9292
{
9393
<li class="list-group-item d-flex align-items-center gap-2 py-2">
9494
<span class="text-truncate flex-grow-1 small fw-medium">@file.OriginalName</span>
95+
@if (file.Status == "To Process" && file.OriginalSize > WarnFileSizeBytes)
96+
{
97+
<small class="text-warning text-nowrap">⚠ >250 MB</small>
98+
}
9599
@if(file.Status == "Compressing" || file.Status == "Decompressing")
96100
{
97101
<div class="spinner-grow text-primary spinner-grow-sm flex-shrink-0" role="status">
@@ -126,6 +130,8 @@
126130

127131
private CompressionFormat _format = CompressionFormat.GZip;
128132
private int _dragCount;
133+
private const long WarnFileSizeBytes = 250L * 1024 * 1024;
134+
private static readonly SemaphoreSlim _semaphore = new(4, 4);
129135

130136
private static string FormatSlug(CompressionFormat fmt) => fmt switch
131137
{
@@ -186,17 +192,17 @@
186192
{
187193
if (_format == CompressionFormat.Brotli)
188194
{
189-
var s = new BrotliSharpLib.BrotliStream(output, CompressionMode.Compress);
195+
var s = new BrotliSharpLib.BrotliStream(output, CompressionMode.Compress, true);
190196
s.SetQuality(ToBrotliQuality(level));
191197
return s;
192198
}
193199
if (_format == CompressionFormat.ZStd)
194200
return new ZstdSharp.CompressionStream(output, ToZstdLevel(level));
195201
return _format switch
196202
{
197-
CompressionFormat.Deflate => new DeflateStream(output, level),
198-
CompressionFormat.ZLib => new ZLibStream(output, level),
199-
_ => new GZipStream(output, level)
203+
CompressionFormat.Deflate => new DeflateStream(output, level, leaveOpen: true),
204+
CompressionFormat.ZLib => new ZLibStream(output, level, leaveOpen: true),
205+
_ => new GZipStream(output, level, leaveOpen: true)
200206
};
201207
}
202208

@@ -252,6 +258,7 @@
252258
{
253259
BrowserFile = f,
254260
OriginalName = f.Name,
261+
OriginalSize = f.Size,
255262
Status = "To Process"
256263
}));
257264
}
@@ -263,31 +270,36 @@
263270
{
264271
var task = Task.Run(async () =>
265272
{
266-
file.Status = "Compressing";
267-
file.NewName = $"{file.OriginalName}{Extension}";
268-
StateHasChanged();
269-
273+
await _semaphore.WaitAsync();
270274
try
271275
{
272-
var browserFile = file.BrowserFile;
273-
file.OriginalSize = browserFile.Size;
274-
using var inputStream = browserFile.OpenReadStream(long.MaxValue);
275-
using var outputStream = new MemoryStream();
276-
using (var compressionStream = CreateCompressStream(outputStream, compressionLevel))
276+
file.Status = "Compressing";
277+
file.NewName = $"{file.OriginalName}{Extension}";
278+
StateHasChanged();
279+
280+
try
277281
{
278-
await inputStream.CopyToAsync(compressionStream);
282+
var browserFile = file.BrowserFile;
283+
using var inputStream = browserFile.OpenReadStream(long.MaxValue);
284+
using var outputStream = new MemoryStream();
285+
using (var compressionStream = CreateCompressStream(outputStream, compressionLevel))
286+
{
287+
await inputStream.CopyToAsync(compressionStream);
288+
}
289+
file.OutputSize = outputStream.Length;
290+
outputStream.Position = 0;
291+
using var streamRef = new DotNetStreamReference(outputStream, leaveOpen: true);
292+
await JS.InvokeVoidAsync("downloadFileFromStream", file.NewName, streamRef);
293+
294+
file.Status = "Finished";
295+
}
296+
catch(Exception ex){
297+
file.Status = $"Error: {ex.Message}";
279298
}
280-
var outputBytes = outputStream.ToArray();
281-
file.OutputSize = outputBytes.Length;
282-
await BlazorDownloadFileService.DownloadFile(file.NewName, outputBytes, "application/octet-stream");
283299

284-
file.Status = "Finished";
285-
}
286-
catch(Exception ex){
287-
file.Status = $"Error: {ex.Message}";
300+
StateHasChanged();
288301
}
289-
290-
StateHasChanged();
302+
finally { _semaphore.Release(); }
291303
});
292304
tasks.Add(task);
293305
}
@@ -302,30 +314,50 @@
302314
{
303315
var task = Task.Run(async () =>
304316
{
305-
file.Status = "Decompressing";
306-
file.NewName = file.OriginalName.Replace(Extension, "");
307-
StateHasChanged();
308-
317+
await _semaphore.WaitAsync();
309318
try
310319
{
311-
var browserFile = file.BrowserFile;
312-
file.OriginalSize = browserFile.Size;
313-
using var inputStream = browserFile.OpenReadStream(long.MaxValue);
314-
using var outputStream = new MemoryStream();
315-
using (var decompressionStream = CreateDecompressStream(inputStream))
320+
file.Status = "Decompressing";
321+
file.NewName = file.OriginalName.Replace(Extension, "");
322+
StateHasChanged();
323+
324+
try
316325
{
317-
await decompressionStream.CopyToAsync(outputStream);
318-
}
319-
var outputBytes = outputStream.ToArray();
320-
file.OutputSize = outputBytes.Length;
321-
await BlazorDownloadFileService.DownloadFile(file.NewName, outputBytes, "application/octet-stream");
326+
var browserFile = file.BrowserFile;
327+
using var inputStream = browserFile.OpenReadStream(long.MaxValue);
328+
using var outputStream = new MemoryStream();
329+
// BrotliSharpLib.BrotliStream.ReadAsync calls synchronous Read internally, which
330+
// fails on IBrowserFile streams (async-only). Pre-buffer the compressed input into
331+
// a MemoryStream first. This adds a copy of the compressed data in memory, but
332+
// compressed files are typically small relative to their decompressed output.
333+
if (_format == CompressionFormat.Brotli)
334+
{
335+
using var bufferedInput = new MemoryStream();
336+
await inputStream.CopyToAsync(bufferedInput);
337+
bufferedInput.Position = 0;
338+
using var s = CreateDecompressStream(bufferedInput);
339+
s.CopyTo(outputStream);
340+
}
341+
else
342+
{
343+
using (var decompressionStream = CreateDecompressStream(inputStream))
344+
{
345+
await decompressionStream.CopyToAsync(outputStream);
346+
}
347+
}
348+
file.OutputSize = outputStream.Length;
349+
outputStream.Position = 0;
350+
using var streamRef = new DotNetStreamReference(outputStream, leaveOpen: true);
351+
await JS.InvokeVoidAsync("downloadFileFromStream", file.NewName, streamRef);
322352

323-
file.Status = "Finished";
324-
}
325-
catch(Exception ex){
326-
file.Status = $"Error: {ex.Message}";
353+
file.Status = "Finished";
354+
}
355+
catch(Exception ex){
356+
file.Status = $"Error: {ex.Message}";
357+
}
358+
StateHasChanged();
327359
}
328-
StateHasChanged();
360+
finally { _semaphore.Release(); }
329361
});
330362
tasks.Add(task);
331363
}

BlazorDeCompressor.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="BlazorDownloadFile" Version="2.4.0.2" />
1312
<PackageReference Include="BrotliSharpLib" Version="0.3.3" />
1413
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
1514
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />

Program.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
2-
using BlazorDownloadFile;
32
using BlazorDeCompressor;
43

54
var builder = WebAssemblyHostBuilder.CreateDefault(args);
65
builder.RootComponents.Add<App>("#app");
7-
builder.Services.AddBlazorDownloadFile(ServiceLifetime.Scoped);
86
await builder.Build().RunAsync();

e2e/kill-port.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env node
2+
// Kills any process listening on the given port (cross-platform).
3+
const { execSync } = require('child_process');
4+
const port = process.argv[2];
5+
if (!port) { console.error('Usage: kill-port.js <port>'); process.exit(1); }
6+
7+
try {
8+
if (process.platform === 'win32') {
9+
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8' });
10+
const pids = [...new Set(out.trim().split('\n').map(l => l.trim().split(/\s+/).at(-1)).filter(Boolean))];
11+
for (const pid of pids) execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
12+
} else {
13+
execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: 'ignore' });
14+
}
15+
} catch {
16+
// Nothing was listening — ignore
17+
}

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "e2e",
33
"scripts": {
4-
"test": "playwright test"
4+
"test": "dotnet build .. && node kill-port.js 5001 && playwright test"
55
},
66
"devDependencies": {
77
"@playwright/test": "^1.58.2"

e2e/playwright.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ module.exports = defineConfig({
88
ignoreHTTPSErrors: true,
99
},
1010
webServer: {
11-
command: 'dotnet run --project .. --urls https://localhost:5001',
11+
command: 'dotnet run --no-build --project .. --urls https://localhost:5001',
1212
url: 'https://localhost:5001',
13-
timeout: 60000,
14-
reuseExistingServer: !process.env.CI,
13+
timeout: 10000,
14+
reuseExistingServer: false,
1515
ignoreHTTPSErrors: true,
1616
},
1717
});

e2e/tests/app.spec.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,101 @@ test('decompress a file end-to-end', async ({ page }) => {
8383
fs.unlinkSync(gzFile);
8484
});
8585

86+
// Round-trip correctness tests — one per format.
87+
// Each test compresses a file, decompresses the output, and asserts the content is identical.
88+
// Non-gzip formats navigate to *.localhost:5001, which resolves to 127.0.0.1 on macOS and
89+
// modern Linux (systemd-resolved) automatically — no /etc/hosts entries required.
90+
const roundTripFormats = [
91+
{ name: 'gzip', ext: '.gz', url: 'https://localhost:5001' },
92+
{ name: 'brotli', ext: '.br', url: 'https://brotli.localhost:5001' },
93+
{ name: 'deflate', ext: '.deflate', url: 'https://deflate.localhost:5001' },
94+
{ name: 'zlib', ext: '.zlib', url: 'https://zlib.localhost:5001' },
95+
{ name: 'zstd', ext: '.zst', url: 'https://zstd.localhost:5001' },
96+
];
97+
98+
for (const fmt of roundTripFormats) {
99+
test(`${fmt.name} round-trip: compress then decompress preserves content`, async ({ page }) => {
100+
const content = `Hello from the ${fmt.name} round-trip test!`;
101+
const inputFile = path.join(os.tmpdir(), `roundtrip-${fmt.name}-in.txt`);
102+
const compressedFile = path.join(os.tmpdir(), `roundtrip-${fmt.name}-in.txt${fmt.ext}`);
103+
fs.writeFileSync(inputFile, content);
104+
105+
try {
106+
await page.goto(fmt.url);
107+
await page.waitForSelector('.spinner-border', { state: 'hidden', timeout: 30000 });
108+
109+
// Compress
110+
const compressDownloadPromise = page.waitForEvent('download');
111+
await page.locator('input[type="file"]').setInputFiles(inputFile);
112+
await page.getByRole('button', { name: 'Compress Files' }).click();
113+
const compressedDownload = await compressDownloadPromise;
114+
expect(compressedDownload.suggestedFilename()).toBe(`roundtrip-${fmt.name}-in.txt${fmt.ext}`);
115+
await compressedDownload.saveAs(compressedFile);
116+
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
117+
118+
// Switch to decompress mode
119+
await page.locator('label[for="radio-decompress"]').click();
120+
121+
// Decompress
122+
const decompressDownloadPromise = page.waitForEvent('download');
123+
await page.locator('input[type="file"]').setInputFiles(compressedFile);
124+
await page.getByRole('button', { name: 'Decompress Files' }).click();
125+
const decompressedDownload = await decompressDownloadPromise;
126+
expect(decompressedDownload.suggestedFilename()).toBe(`roundtrip-${fmt.name}-in.txt`);
127+
const decompressedPath = await decompressedDownload.path();
128+
expect(fs.readFileSync(decompressedPath, 'utf-8')).toBe(content);
129+
} finally {
130+
if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
131+
if (fs.existsSync(compressedFile)) fs.unlinkSync(compressedFile);
132+
}
133+
});
134+
}
135+
136+
test('SmallestSize compression produces output <= Fastest, and round-trips correctly', async ({ page }) => {
137+
const content = 'A'.repeat(100000);
138+
const inputFile = path.join(os.tmpdir(), 'level-test-input.txt');
139+
fs.writeFileSync(inputFile, content);
140+
141+
const smallestFile = path.join(os.tmpdir(), 'level-test-input.txt.gz');
142+
143+
try {
144+
// Compress at SmallestSize
145+
await page.locator('label[for="radio-smallest"]').click();
146+
let downloadPromise = page.waitForEvent('download');
147+
await page.locator('input[type="file"]').setInputFiles(inputFile);
148+
await page.getByRole('button', { name: 'Compress Files' }).click();
149+
const smallestDownload = await downloadPromise;
150+
await smallestDownload.saveAs(smallestFile);
151+
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
152+
const smallestSize = fs.statSync(smallestFile).size;
153+
154+
// Compress at Fastest (re-select file to reset list)
155+
await page.locator('label[for="radio-fastest"]').click();
156+
downloadPromise = page.waitForEvent('download');
157+
await page.locator('input[type="file"]').setInputFiles(inputFile);
158+
await page.getByRole('button', { name: 'Compress Files' }).click();
159+
const fastestDownload = await downloadPromise;
160+
const fastestPath = await fastestDownload.path();
161+
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
162+
const fastestSize = fs.statSync(fastestPath).size;
163+
164+
expect(smallestSize).toBeLessThanOrEqual(fastestSize);
165+
166+
// Decompress the SmallestSize output and verify round-trip
167+
await page.locator('label[for="radio-decompress"]').click();
168+
downloadPromise = page.waitForEvent('download');
169+
await page.locator('input[type="file"]').setInputFiles(smallestFile);
170+
await page.getByRole('button', { name: 'Decompress Files' }).click();
171+
const decompressedDownload = await downloadPromise;
172+
const decompressedPath = await decompressedDownload.path();
173+
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
174+
expect(fs.readFileSync(decompressedPath, 'utf-8')).toBe(content);
175+
} finally {
176+
if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
177+
if (fs.existsSync(smallestFile)) fs.unlinkSync(smallestFile);
178+
}
179+
});
180+
86181
// Static content (heading, title, meta, paragraph) is driven by the inline JS in index.html,
87182
// which reads window.compressionFormat. We spoof it here via addInitScript.
88183
// Blazor component behaviour (labels, file extension) uses NavigationManager and requires

wwwroot/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ <h1 class="display-5 fw-bold">Online GZIP de/compressor</h1>
249249
setHref('link[rel="manifest"]', `manifest-${format}.json`);
250250
})();
251251
</script>
252+
<script>
253+
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
254+
const arrayBuffer = await contentStreamReference.arrayBuffer();
255+
const blob = new Blob([arrayBuffer]);
256+
const url = URL.createObjectURL(blob);
257+
const anchorElement = document.createElement('a');
258+
anchorElement.href = url;
259+
anchorElement.download = fileName ?? '';
260+
anchorElement.click();
261+
anchorElement.remove();
262+
setTimeout(() => URL.revokeObjectURL(url), 60000);
263+
}
264+
</script>
252265
<script src="_framework/blazor.webassembly.js"></script>
253266
<script>navigator.serviceWorker.register('service-worker.js');</script>
254267
</body>

0 commit comments

Comments
 (0)