Compare commits

..
11 Commits
Author SHA1 Message Date
Stevan 46ff22b250 Merge pull request 'posts: make it secure' (#6) from stevanfreeborn/posts/make-it-secure into main
Deploy / Build and push Docker image (push) Successful in 16m4s
Deploy / Deploy to server (push) Successful in 41s
2026-09-11 14:23:09 +00:00
Stevan Freeborn b6843919d8 posts: add make it secure post
Pull Request / Run tests (pull_request) Successful in 17m0s
2026-09-11 08:54:57 -05:00
Stevan Freeborn eaa5f4a965 posts: fix og image path
Deploy / Build and push Docker image (push) Successful in 13m58s
Deploy / Deploy to server (push) Successful in 28s
2026-09-06 17:10:37 -05:00
Stevan 2990c82181 Merge pull request 'posts: thoughts about typing code being over' (#5) from stevanfreeborn/posts/thoughts-about-typing-code-being-over into main
Deploy / Build and push Docker image (push) Successful in 18m43s
Deploy / Deploy to server (push) Successful in 56s
2026-09-06 21:44:25 +00:00
Stevan Freeborn e5d7a2b73a posts: add thoughts about typing code being over
Pull Request / Run tests (pull_request) Successful in 4m6s
2026-09-06 16:38:59 -05:00
Stevan 0300402667 Merge pull request 'fix: correct metadata' (#4) from stevanfreeborn/fix/correct-metadata into main
Deploy / Build and push Docker image (push) Successful in 15m52s
Deploy / Deploy to server (push) Successful in 30s
2026-09-03 19:17:02 +00:00
Stevan Freeborn 81cc1e4f64 tests: fix blog host factory to use built in use kestrel call and dump composite host.
Pull Request / Run tests (pull_request) Successful in 2m3s
2026-09-03 14:13:02 -05:00
Stevan Freeborn 4a7c8b31c5 tests: add test that validates the metadata of all posts 2026-09-03 13:47:34 -05:00
Stevan Freeborn 49fe692ec7 fix: remove metadata issue 2026-09-03 13:34:28 -05:00
Stevan 0b5ebc3bb1 Merge pull request 'posts: chunked upload explainer' (#3) from stevanfreeborn/posts/dealing-with-request-size-limits-for-file-uploads into main
Deploy / Build and push Docker image (push) Successful in 19m15s
Deploy / Deploy to server (push) Successful in 30s
2026-09-03 18:10:52 +00:00
Stevan Freeborn 4af74e3c0d posts: finished draft explaining chunked upload implementation
Pull Request / Run tests (pull_request) Successful in 3m19s
2026-09-03 13:01:42 -05:00
10 changed files with 429 additions and 79 deletions

No files matched your search

@@ -0,0 +1,279 @@
```json meta
{
"title": "Building a Chunked, Resumable File Upload System in .NET and Vanilla JS",
"lead": "How to bypass reverse proxy payload limits, eliminate double-storage disk overhead, and build a resumable file uploader.",
"isPublished": true,
"publishedAt": "2026-09-03",
"openGraphImage": "posts/chunked-resumable-file-uploads-dotnet/og-image.png"
}
```
I like to build software. I enjoy finding problems and being able to solve them. I like having an idea for something I want to exist and being able to create it. I love the fact that I can get meaningful enjoyment from the act of it and also have the result be something that is useful to me or someone else. However, most of the software I build doesn't need to really scale vertically or horizontally. It probably won't have more than 10s of users let alone 100s or 1000s. This also means it won't make much, if any, money. This is actually pretty cool in a way because it presents a different set of constraints with which to engineer within, particularly when it comes to the cost to host, run, and maintain the software.
It has been pretty popular over the last decade and a half to prioritize building for scale and most commonly horizontal scale that is dynamically contracted and expanded based on demand. This is largely implemented through abstractions provided by [the big 3 cloud providers](https://www.srgresearch.com/articles/cloud-market-share-trends-big-three-together-hold-63-while-oracle-and-the-neoclouds-inch-higher). This approach makes a ton of sense in the context of businesses, especially startups, where your demand is really unknown, your profits are thin or non-existent, and you need to be able to respond at a moment's notice to viral popularity without leaving a bad taste in a new customer's mouth by having the service tip over when they are most interested in exploring it. The trade-off though in my circumstance is cost and particularly the fact that in almost all the provider clouds while there is a way to set budget alerts there is not a great way to set hard limits that prevent wild runaway costs which again for the things I am building (see above) just isn't really an acceptable risk.
This has led me to settle in on a really simple model for deploying the apps that I build that while it doesn't provide the same levels of convenience and capability it does minimize the risk to my financial well-being and still make it possible to build software and deploy it in a way that others can see and use it. What this looks like in most all cases is something like this. The underlying compute is supplied by a shared host that is either a server running on-prem in my homelab or in [Oracle's cloud](https://www.oracle.com/cloud/). I don't have any affiliation with Oracle nor do I particularly put them above any of the other providers I was just able to get a pretty generous free-tier instance stood up there a while back and have continued to use it and them going forward. The host then typically has [Docker](https://www.docker.com/) installed on it and any application I deploy is containerized and run as a container using Docker. I use [Nginx](https://nginx.org/) on the host as a reverse proxy so that I can run multiple applications on the host and route incoming traffic accordingly. This reverse proxy almost always handles TLS/SSL termination and I issue and manage SSL certificates using [Let's Encrypt](https://letsencrypt.org/) via [`certbot`](https://certbot.eff.org/). The final piece of the puzzle is routing all the public traffic through Cloudflare so that I get the benefit of [Cloudflare](https://www.cloudflare.com/)'s DDOS protection and generous free tier benefits.
For the most part this model works well, but it does have its trade offs. If I ever do need to scale something it will require a bit of work and/or money on my part depending on if I do it vertically or horizontally. That isn't though as I mentioned above really something I'm too concerned about. Probably the biggest trade off is just that there is no convenient done-for-you services that are all interoperable like in a big cloud provider. Each additional service or need you have you've got to figure out a solution for yourself. This is also exciting though because it gives me opportunities to explore, experiment, and learn on top of doing it within the constraints of minimizing how much of my resources I consume and limiting the cost of it overall.
I recently had this idea for an application that I expect to be rather storage hungry because a big part of the functionality is the ability to store and archive files, especially things like videos, images, and audio clips. This is a good example of where not leveraging cloud providers is a bit of a drawback because they all offer pretty elegant and well-designed solution for this type of storage need and some would even say it is pretty cheap looking at something like S3, but when the software you are running doesn't make any money at all any additional expense from my perspective is really too much. Plus I recently added a pretty large [network-attached storage server](https://a.co/d/036NIhWk) to my own small and growing cloud that has lots of extra room just sitting there not being used that I've already paid for in one way or another. The general thing I want for this application is to be able to upload, store, and serve really large files without really having concern for the size of that file, but I need to do it in the constraints of the hosting model I described above and using the NAS I mentioned earlier as the backing store for the files.
I did consider alternative backing stores for the files, but all presented complexities that felt like poor trade-offs. [MinIO](https://www.min.io/) is a self-host-able blob storage solution that has an S3 compatible API, but that then means I have an additional service I've got to factor in to hosting, maintaining, and deploying just for currently what is this single use case. Not to mention I'm skeptical taking a hard dependency on MinIO given what I see as a [recent pivot](https://www.linkedin.com/pulse/minios-licensing-pivot-rise-new-opensource-object-storage-goodwolf-qnibc/) towards focusing on their enterprise/paid product offering. I also thought about just storing the files as blobs in SQLite, but given I want to support large files, like potentially multi-gigabyte uploads that felt like a [poor choice](https://sqlite.org/limits.html) as well. Which again brought me back to just using the disk storage I already have lying around. While I fully understand there will be performance trade-offs to this decision I think within the constraints I've placed on myself it is the best option.
The one sticking point though I was left trying to figure out was how to handle the fact that Cloudflare's free tier only supports request up to 100 MB in size. That means if I wanted to allow the upload of arbitrarily large files to this application I'd either need to not proxy its traffic through Cloudflare, create some sort of exception for the file upload part of the app, or place a hard cap on the size of the uploads. None of which really satisfy me. And this is the fun part of building things. You've got a problem you need solving and a set of constraints you need to work within. I think I ended up with a pretty slick solution that I should hopefully be able to reuse in other apps I build and deploy within this same type of hosting model and I want to share it with you.
On its face it doesn't sound like this problem - allowing a client to upload arbitrarily large files to a server - should be that difficult to solve, but like anything the devil is in the details and edge cases. Generally though the solution to a problem like this where you can't do all the work you need to do in a single unit because of some resource constraint like time or memory is to break up the work into smaller units that fit within the resource constraint you have and work through those smaller units either sequentially or concurrently depending on the nature of the work to be done and the functionality available to you in the technology you are working with. In this context it basically means that the client needs to take responsibility for dividing files that are larger than the size that can be handled in a single request up into smaller chunks that can be sent to the destination server then the server needs to take responsibility for reassembling those chunks into the original file and persisting that original file. The tricky thing here is that the client needs to make sure that it can identify which chunk belongs to which larger parent file so the server can make sure to assemble them in the exact right order without losing or corrupting anything along the way.
> [!NOTE]
> The actual reference implementation for the solution describe below can be found [on GitHub.](https://github.com/StevanFreeborn/chunked-upload-example)
## Duplicating storage
When I first started thinking through how to assemble the chunked files on disk the most intuitive pattern I came up with looked something like this:
- Client splits a 10 GB file into 8 MB chunks.
- Server writes each incoming chunk as a temporary file on disk (i.e. 0.tmp, 1.tmp, etc.).
- Once all chunks arrive, a completion endpoint spins up a background task to create the final file.
- The background process reads every `.tmp` chunk in order.
- The background process writes the temporary file bytes to the final file.
- The background process deletes the temporary file.
This works pretty well for a 50 MB PDF or even something like a 500 MB video file, but as soon as you start talking about multi-gigabyte files, this pattern starts to show some cracks. Particularly because you are first always requiring double the amount of storage overhead. This is because during the assembly phase, you have say the 10 GB of temporary chunks and the growing 10 GB final file living on disk at roughly the same time. So for every file you upload you need to have two times that available to allow for the upload to be successful. And second this pattern puts quite a bit of load on disk input and output even if I was using something like NVMe drives, reading and copying thousands of small files sequentially into a single large file can peg disk I/O, spike CPU usage, and force the user to stare at a progress spinner for several minutes even after all their network requests have finished. You basically are incurring a load and additional wait both for the upload and the assembly processes. Not to mention you probably need to come up with a good solution to be able to communicate back from the server to the client about the progress of the assembly and handle any issues that might occur and make sure the client is notified of those issues.
So while this is an option and what I originally ended up implementing it introduces complexity and places additional strain on resources that you might not want.
## Writing offsets directly
Turns out I actually don't need separate temporary files at all. Files on modern filesystems aren't just sequential streams. They support [random access](https://okyrylchuk.dev/blog/high-performance-file-access-in-dotnet-with-randomaccess/). Meaning if you know the index and size of the chunk you are working with you can calculate the exact byte offset where that chunk belongs in the final file before you write it with some simple math like this:
```txt
offset = index * chunkSize
```
This allows me to avoid saving thousands of `.tmp` files and instead preallocate and open a single target file of the correct size then when chunks arrive just seek to that chunk's offset and write its bytes accordingly.
```csharp
using var formStream = chunk.OpenReadStream();
var buffer = new byte[chunk.Length];
await formStream.ReadExactlyAsync(buffer);
var hashBytes = SHA256.HashData(buffer);
var calculatedHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(calculatedHash), Encoding.UTF8.GetBytes(hash.ToLowerInvariant())) is false)
{
return Results.BadRequest("Chunk hash mismatch.");
}
var partFilePath = Path.Combine(fileDirectory, "upload.part");
long byteOffset = index * chunkSize;
using var handle = File.OpenHandle(
partFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite,
FileOptions.Asynchronous
);
await RandomAccess.WriteAsync(handle, buffer, byteOffset);
await File.WriteAllTextAsync(markerFilePath, string.Empty);
```
Because each chunk writes to distinct, non-overlapping slice of bytes, multiple HTTP threads can write to the exact same target file concurrently without collisions.
## Completing uploads is instant
And not only does this reduce the need for duplicate storage overhead during the upload process it also eliminates the additional upload time that is added by the final assembly process. In this pattern the completion process is near instant because I don't need to wait for the processing required to assemble the file nor do I have to deal with the complexity it adds. I don't need a background service. I don't need a queue. We don't need to copy gigabytes of data.
All of the bytes are already in their final order inside our temporary target file. Finishing the upload is just an atomic filesystem rename:
```csharp
File.Move(partFilePath, finalFilePath, overwrite: true);
```
Renaming a file on the same filesystem is just a pointer update in the filesystem's metadata table. It takes less than a millisecond, regardless of whether the file is 50 MB or 50 GB. The user uploads their last chunk, calls complete, and it's immediately done.
## Things can go wrong
I'd like to think most of the time [things will work out](https://www.youtube.com/watch?v=TKJJiF8QJRc), but when it comes to the internet lots of stuff can happen and it would suck to upload a file and only find out later down the road it got corrupted in transit somehow. This is probably particularly more of a concern when you consider the vast spectrum of quality that exists out in the wild for a particular network connection. I want to have some way to verify that the file that client uploaded is the file I assembled on the server and even better if I can know that each chunk of the file makes it across the wire without any issue so that I can handle retrying the individual pieces that failed versus the entire file at the end.
I don't think the solution to this is particularly new or revolutionary, but I just used a two-tiered verification strategy that relies on using SHA-256 hashing. The first tier involves the client computing a SHA-256 digest using the browser's Web Crypto API (`crypto.subtle.digest`) from a chunk's content just before sending the chunk to the server. The server receives the chunk and computes the same hash over the chunk's content using `SHA256.HashDataAsync` and compares the calculated digests to one another using constant-time byte comparison (`CryptographicOperations.FixedTimeEquals`). This means that if my cellular or Wi-Fi connection blips and drops a packet, only that chunk fails and retries. The second tier involves once all the chunks are uploaded, sorting the individual chunk hashes by index, joining them into a comma-separated string, and computing a composite SHA-256 hash:
```javascript
const sortedHashes = chunkHashes.join(",");
const encoder = new TextEncoder();
const uint8Array = encoder.encode(sortedHashes);
const compositeHashBuffer = await crypto.subtle.digest("SHA-256", uint8Array);
const compositeHashHex = convertToHexString(compositeHashBuffer);
```
The client then includes this composite hash when they make the completion request, the server uses the marker files it has collected on disk, runs the same composite calculation, and verifies that the composite hash it has come up with matches the one the client did as well before executing the `File.Move` operation. This guarantees that not a byte is out of place once the upload has finished.
## Starting over sucks
While things can go wrong during individual network requests clients themselves can also just unintentionally interrupt the upload process and when that happens it would be really sucky to have to start the upload process completely from scratch again particularly for large files not to mention to have to worry about having lots of orphaned partially uploaded files to manage on the server. However I also for this reference implementation didn't want to bring in an external database to just track upload sessions. So in order to support resumability I put the responsibility on the client to provide a deterministic upload session ID using the file's metadata itself. Specifically I used a SHA-256 hash of the `file.name + file.size + file.lastModified` and formatted it to a standard GUID.
```javascript
async function generateDeterministicFileId(file) {
const signature = `${file.name}-${file.size}-${file.lastModified}`;
const encoder = new TextEncoder();
const data = encoder.encode(signature);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hex = convertToHexString(hashBuffer);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
```
This allows me on the client to implement an upload flow that looks like this:
- The client generates the fileId which also doubles as the upload session id and `POST`'s to `/Files/{fileId}.
- If there is a directory on the server with that file id the server returns a `409 Conflict` response to signal an active or paused session.
- The client can then `GET /Files/{fileId}/status`. The server scans the directory for marker files and returns which chunks are already on disk.
- The client marks those chunks as already done, skips them, and uploads what is remaining.
This means if my laptop goes to sleep or my internet drops out midway through an upload, I can just select the file again. The system inspects what's already written, resumes where I left off, and finishes without re-uploading duplicate data.
## Consideration for client resources
If I try to split a 40 GB file into 8 MB chunks, you end up with 5,000 chunks. If I write some JavaScript that creates 5,000 FormData objects and fires `Promise.allSettled()`, the browser is likely going to choke on memory and throw an insufficient resources error as it exhausts socket pools. Plus calculating 5,000 cryptographic hashes upfront on the same thread as the UI will probably leave us a bit deadlocked. I ended up just implementing a worker pool pattern that allows for a reasonable amount of concurrency and delaying the hashing operation until it absolutely has to happen.
```javascript
const MAX_CONCURRENCY = 4;
let failedChunksCount = 0;
async function worker() {
while (pendingIndices.length > 0) {
const index = pendingIndices.shift();
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const chunkId = `${fileId}-${index}`;
const chunkElement = document.getElementById(chunkId);
const chunkRedX = chunkElement?.querySelector(".red-x");
const chunkCheckmark = chunkElement?.querySelector(".checkmark");
const chunkSpinner = chunkElement?.querySelector(".spinner");
const chunkStatus = chunkElement?.querySelector(".status");
try {
const hash = await calculateHash(blob);
chunkHashes[index] = hash;
const formData = new FormData();
formData.append("chunk", blob);
formData.append("index", index);
formData.append("chunkSize", CHUNK_SIZE);
formData.append("hash", hash);
const response = await fetch(`/Files/${fileId}/upload`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Chunk ${index} failed with status ${response.status}`,
);
}
chunkCheckmark?.classList.remove("hidden");
chunkSpinner?.classList.add("hidden");
if (chunkStatus) {
chunkStatus.innerText = "Succeeded";
}
} catch (err) {
console.error(`Chunk ${index} failed:`, err);
failedChunksCount++;
chunkRedX?.classList.remove("hidden");
chunkSpinner?.classList.add("hidden");
if (chunkStatus) {
chunkStatus.innerText = "Failed";
}
}
}
}
const workers = Array.from(
{
length: Math.min(MAX_CONCURRENCY, Math.max(1, pendingIndices.length)),
},
() => worker(),
);
await Promise.all(workers);
```
This keeps exactly four HTTP connections active at any time, provides JIT hashing, and doesn't destroy the browser's memory consumption.
## Round tripping the file
I thought for a workable reference implementation it would be nice to also include the ability to serve the file back to the client. This is also likely what I will need to do in the actual application I am building. .NET provides some really great built-in primitives and functionalities to do this and make the experience for the client an enjoyable one.
I added a `GET /Files/{fileId}/download` endpoint. It returns the file using `Results.File` and enables range processing:
```csharp
var provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(fileName, out var contentType))
{
contentType = "application/octet-stream";
}
return Results.File(filePath, contentType, enableRangeProcessing: true);
```
This allows clients to immediately seek forward and backward through multi-gigabyte video or audio files in a new tab without needing to download the whole file upfront.
## A bonus for .NET nerds
The pattern I've discussed above isn't one specific to a .NET backend. You could implement the same pattern in most other languages. However I love C# and I thought a fun finishing touch for this reference implementation would be to make the entire application compile as a standalone [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot) binary. It is pretty easy to do.
In `ChunkedUploadExample.csproj`, I removed `wwwroot` from external disk content and embed the HTML, JS, and CSS files directly into the compiled executable using [`ManifestEmbeddedFileProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.fileproviders.manifestembeddedfileprovider):
```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<Content Remove="wwwroot\**\*" />
<EmbeddedResource Include="wwwroot\**\*" />
</ItemGroup>
</Project>
```
And because Native AOT strips away runtime reflection, all API request/response types use source-generated JSON metadata via a `JsonSerializerContext`:
```csharp
[JsonSerializable(typeof(CreateFileRequest))]
[JsonSerializable(typeof(FileCompleteRequest))]
[JsonSerializable(typeof(SessionResponse))]
[JsonSerializable(typeof(ChunkStatusInfo))]
[JsonSerializable(typeof(SessionStatusResponse))]
[JsonSerializable(typeof(ChunkUploadResponse))]
[JsonSerializable(typeof(CompleteResponse))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
```
Running `dotnet publish ./src/ -c Release -r <runtime-identifier>` spits out a single binary. No runtime dependencies, no external `wwwroot` folder, and instant startup times.
## Conclusion
I know this sort of implementation probably seems unnecessary to a lot of people. It also still has a few drawbacks compared with alternative solutions. I'll bet more than one person is thinking "dude just use S3 with a pre-signed URL and call it a day." And while I hear you and understand you some of the most fun I get from programming and coding is building stuff for myself within my own constraints. It offers me the ability to learn and try new things. Not to mention every approach to a problem has trade-offs and a big part of engineering is deciding which ones you can or can not live with.
Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

@@ -0,0 +1,19 @@
```json meta
{
"title": "\"Make it secure\"",
"lead": "At KCDC, a speaker shared their team's security strategy for AI-generated hardware and access controls: simply prompting the model to 'make it secure.' A reflection on where we are with AI tooling and the vanishing bar for verifiable verification.",
"isPublished": true,
"publishedAt": "2026-09-11",
"openGraphImage": "posts/make-it-secure/og-image.png"
}
```
I sat in on a talk yesterday at the Kansas City Developer Conference that was mostly about how a local small business owner was able to leverage AI tooling to advance his business outcomes.
One of the things he did was incorporate hardware into his business model, including facility access and monitoring-related devices. They developed both the hardware and software using agents and off-the-shelf components.
An audience member asked if he had any concerns about the security of what they were developing and deploying. The speaker's answer left me a bit stunned. I expected at least a qualified answer, or perhaps that they had red-teamed their work as part of the development process.
Instead, the real answer was that they just prompt the LLM to “make it secure” and run several reviews of the implementation across a couple of different models. Surprisingly, there wasn’t much reaction from the audience. I guess this is where we are now.
Honestly, I don’t really have any shade to throw at the presenter. It’s his company, and if he’s comfortable with the risk, more power to him. But I suppose I imagined more people would feel the way I do: wanting something verifiable and provable when it comes to assuring the quality, performance, and security of the platforms and products they build, with or without AI.
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

@@ -0,0 +1,19 @@
```json meta
{
"title": "Thoughts On Typing Code Being Over",
"lead": "Reflecting on the impact of AI code generation on junior developers, the value of grinding through problems, and the future of software engineering careers.",
"isPublished": true,
"publishedAt": "2026-09-06",
"openGraphImage": "posts/thoughts-on-typing-code-being-over/og-image.png"
}
```
I have been ruminating on [this post](https://x.com/davidfowl/status/2095602923502010398) since first reading it. I understand it, and I can see the truth in it given how quickly you can generate code with an LLM. I won't lie: I still genuinely enjoy the experience of writing code by hand.
However, what gives me greater pause is the feeling that, to some extent, the tradeoff for an increasing reliance on LLMs is a decrease in my own knowledge and competence. So many of the people who inspire me, including David, didn't acquire their abilities by using AI. They spent two decades or more grinding away at problems. I am worried that in this new world, I will end up being robbed of those same development opportunities.
Maybe the truth is that these skills and knowledge won't matter moving forward. I don't quite see evidence of that yet; it seems like the people getting the most out of the LLM era are exactly these veteran folks. But if those skills eventually cease to matter, it raises the question: why waste any more time on this? Why not reinvest in some other field or career? That feels fatalistic, but it is also a reasonable path forward for someone like me who has at least another 19 years of mandatory work ahead.
I honestly don't know what the right answer is. I do think the conversation around this topic always needs to be bifurcated between what you do for fun and enjoyment, and what you do to put food on the table. The former is completely up to you, and all choices can be valid. The latter requires more thought and consideration, especially if you have other human beings relying on you for their economic well-being. You have to take into account the most important questions for a successful career: What am I good at? What do I want to spend my workdays doing? Does the world need that work? Will people pay me for that work?
I know it might seem silly to leap from "typing code is over" to "maybe I should be looking for a different career field." However, it feels like a valid thing to ponder, especially for someone who is only six years into this profession. We are facing a rapidly advancing technology that the most experienced people in our field are enamored with, and it appears to be reducing the need for developers at my level. At the very least, it is reducing the leverage that this knowledge and skill provide in earning a living.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+11 -45
View File
@@ -2,62 +2,28 @@ namespace Blog.Tests.EndToEnd;
public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
{
private IHost? _host;
private void EnsureServer()
public BlogHostFactory()
{
if (_host is null)
{
using var _ = CreateDefaultClient();
}
}
protected override void Dispose(bool disposing)
{
_host?.Dispose();
UseKestrel(server => server.Listen(IPAddress.Loopback, 0));
}
public string ServerAddress
{
get
{
EnsureServer();
StartServer();
return ClientOptions.BaseAddress.ToString();
}
}
protected override IHost CreateHost(IHostBuilder builder)
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
var testHost = builder
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder.ConfigureLogging(config => config.ClearProviders());
webHostBuilder.ConfigureTestServices(services =>
{
var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts");
var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory };
services.AddSingleton(Options.Create(postServiceOptions));
});
})
.Build();
builder.ConfigureWebHost(
webHostBuilder => webHostBuilder.UseKestrel(
o => o.Listen(IPAddress.Loopback, 0)
)
);
_host = builder.Build();
_host.Start();
var server = _host.Services.GetRequiredService<IServer>();
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>();
ClientOptions.BaseAddress = addresses.Addresses
.Select(x => new Uri(x))
.Last();
testHost.Start();
return testHost;
builder.ConfigureLogging(config => config.ClearProviders());
builder.ConfigureTestServices(services =>
{
var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts");
var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory };
services.AddSingleton(Options.Create(postServiceOptions));
});
}
}
+6 -6
View File
@@ -3,12 +3,12 @@ namespace Blog.Tests.EndToEnd;
[TestFixture]
public abstract class BlogTest : PageTest
{
private readonly BlogHostFactory<Program> _factory = new();
private static readonly BlogHostFactory<Program> Factory = new();
public override BrowserNewContextOptions ContextOptions()
{
var options = base.ContextOptions();
options.BaseURL = _factory.ServerAddress;
options.BaseURL = Factory.ServerAddress;
return options;
}
@@ -30,10 +30,10 @@ public abstract class BlogTest : PageTest
await Context.Tracing.StopAsync(new()
{
Path = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
)
TestContext.CurrentContext.WorkDirectory,
"playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
)
});
}
}
-28
View File
@@ -1,28 +0,0 @@
namespace Blog.Tests.EndToEnd;
public class CompositeHost(IHost testHost, IHost kestrelHost) : IHost
{
private readonly IHost _testHost = testHost;
private readonly IHost _kestrelHost = kestrelHost;
public IServiceProvider Services => _testHost.Services;
public void Dispose()
{
_testHost.Dispose();
_kestrelHost.Dispose();
GC.SuppressFinalize(this);
}
public async Task StartAsync(CancellationToken cancellationToken = default)
{
await _testHost.StartAsync(cancellationToken);
await _kestrelHost.StartAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
await _testHost.StopAsync(cancellationToken);
await _kestrelHost.StopAsync(cancellationToken);
}
}
+95
View File
@@ -0,0 +1,95 @@
using System.Text.Json;
using Markdig;
using Markdig.Syntax;
namespace Blog.Tests.Unit;
[TestFixture]
public class PostMetadataTests
{
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
};
private const string MetaFence = "meta";
[Test]
public void PostsDirectory_ShouldContainIndexFiles()
{
var postsDir = GetPostsDirectory();
var files = Directory.GetFiles(postsDir, "index.md", SearchOption.AllDirectories);
files.Should().NotBeEmpty("The wwwroot/posts directory should contain blog posts with index.md files.");
}
[TestCaseSource(nameof(GetPostIndexFiles))]
public async Task IndexMd_Metadata_CanBeProperlyParsedAndRead(string filePath)
{
var markdown = await File.ReadAllTextAsync(filePath);
var document = Markdown.Parse(markdown, MarkdownPipeline);
var metadataBlock = document
.OfType<FencedCodeBlock>()
.FirstOrDefault(block =>
block.Arguments is not null &&
block.Arguments.Contains(MetaFence)
);
metadataBlock.Should().NotBeNull($"file '{filePath}' should contain a fenced code block with '{MetaFence}' argument.");
var jsonContent = metadataBlock.Lines.ToString();
jsonContent.Should().NotBeNullOrWhiteSpace($"metadata code block in '{filePath}' should not be empty.");
Post? post = null;
Action deserializeAction = () => post = JsonSerializer.Deserialize<Post>(jsonContent, JsonSerializerOptions);
deserializeAction.Should().NotThrow($"JSON metadata in '{filePath}' should be valid JSON matching the Post record schema.");
post.Should().NotBeNull($"deserialized Post from '{filePath}' should not be null.");
post.Title.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have a title.");
post.Lead.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have a lead summary.");
post.PublishedAt.Should().NotBe(default, $"post in '{filePath}' must have a valid published date.");
post.OpenGraphImage.Should().NotBeNullOrWhiteSpace($"post in '{filePath}' must have an openGraphImage path.");
}
private static string GetPostsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, "src", "Blog", "wwwroot", "posts");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException($"Could not locate 'src/Blog/wwwroot/posts' starting from '{AppContext.BaseDirectory}'.");
}
private static IEnumerable<TestCaseData> GetPostIndexFiles()
{
var postsDir = GetPostsDirectory();
var files = Directory.GetFiles(postsDir, "index.md", SearchOption.AllDirectories);
foreach (var file in files)
{
var relativePath = Path.GetRelativePath(postsDir, file);
yield return new TestCaseData(file).SetName($"PostMetadata_{relativePath.Replace('\\', '/')}");
}
}
}