# Part 2 — Why I Rebuilt mitm-core from the Ground Up

## Introduction

In the previous article, I shared how a simple curiosity about HTTPS evolved into **mitm-core** and the challenges I encountered while building its first architecture.

The original design achieved its goal. It successfully intercepted HTTPS traffic, supported plugins, and provided a modular, event-driven pipeline. More importantly, it helped me understand what actually happens beneath every secure web request.

But as the project evolved, so did its requirements.

Features that once felt easy to add started interacting with each other in unexpected ways. State became harder to manage, lifecycles became increasingly complex, and solving one problem often introduced another somewhere else.

At that point, I had two choices.

I could continue patching the existing architecture, or I could step back, rethink the design, and build a foundation that would support the project's future instead of limiting it.

I chose the second option.

This article isn't about new features or performance improvements. It's about the architectural decisions behind the redesign—what changed, why it changed, and how those decisions shaped the next generation of **mitm-core**.

## Design Goals

The redesign wasn't about making the proxy faster or adding more features.

It was about building an architecture that could evolve without becoming increasingly difficult to maintain.

Before writing a single line of code, I defined a few principles that every component of the new architecture should follow.

### Replace Context-Based Design with Scope-Based Design

The original architecture stored almost everything inside a single ProxyContext.

As the project grew, this became increasingly difficult to reason about because different pieces of data had completely different lifetimes.

The redesign introduced scopes instead of a single shared context.

*   **Session Scope** — Data that lives for the lifetime of a client connection.
    
*   **Request Scope** — Data created for a single request-response cycle.
    
*   **Lifecycle Scope** — Temporary state that exists only while a specific pipeline phase is executing.
    

By separating state according to its lifetime, each component owns only the data it is responsible for.

This separation also lays a much stronger foundation for handling protocols that support multiple requests over a single connection (request multiplexing) in the future.

### Make Resource Cleanup a First-Class Citizen

One of the biggest sources of bugs in the original architecture was resource management.

Sockets, streams, and requests needed deterministic cleanup.

Instead of leaving cleanup to individual handlers, the new architecture centralizes it.

Every component follows the same cleanup strategy, ensuring streams are destroyed or gracefully closed once they're no longer needed.

This dramatically reduces socket leaks, dangling resources, and connection lifecycle issues.

### Make the Event System Fully Type-Safe

Events are one of the primary extension points of the framework.

The new event manager is completely statically typed, allowing the compiler to verify event names and payloads before the code ever runs.

This removes an entire class of runtime mistakes while providing much better IDE support for plugin developers.

### **Introduce a Real Processing Pipeline**

The original execution flow gradually became a collection of handlers.

The new design formalizes it into a **Chain of Responsibility** with explicit **Phase Orchestration**.

Each phase has a well-defined responsibility, executes in a predictable order, and remains independent from the others.

This makes the request lifecycle significantly easier to understand and extend.

### **Centralize Request State**

Instead of every component maintaining its own temporary state, request-specific information is stored in a centralized state store.

This gives every phase controlled access to shared request data without tightly coupling different parts of the system.

### **Optimize Expensive Operations**

Some operations—such as generating leaf certificates—are computationally expensive.

The redesigned architecture moves certificate generation into worker threads and introduces a layered caching strategy that combines in-memory caching with persistent filesystem storage.

The goal is simple: generate certificates only when necessary and reuse them whenever possible.

### **Build Reusable Infrastructure**

Rather than solving isolated problems, the redesign introduces reusable infrastructure that can support future capabilities.

This includes components such as:

*   A centralized response cache.
    
*   A configurable rule manager.
    
*   A dedicated plugin manager.
    
*   Shared state management.
    
*   A transport layer responsible for moving data through the proxy.
    

Each of these systems exists independently of the request pipeline, making the overall architecture more modular and easier to extend.

These goals became the blueprint for the new architecture. Every major design decision that follows can be traced back to one or more of these principles.

## The Biggest Architectural Change

The redesign wasn't about introducing new components.

It was about changing how I thought about the proxy itself.

In the original architecture, the **connection** was the center of everything. Most of the state, lifecycle, and processing revolved around a single context object that was passed through the pipeline.

The more features I added, the more responsibilities that object accumulated.

During the redesign, I asked myself a simple question:

> **What is the actual unit of work inside a proxy?**

The answer wasn't "a connection."

A single connection contains multiple kinds of state, each with a completely different lifetime and responsibility.

Some data exists as long as the client stays connected.

Some exists only for a single request.

Some only exists while a particular pipeline phase is executing.

Trying to represent all of these with a single context object inevitably mixed responsibilities together.

The new architecture separates the proxy into independent scopes.

*   **Session Scope** manages everything related to the lifetime of a client connection.
    
*   **Request Scope** owns everything required for a single request-response cycle.
    
*   **Lifecycle Scope** stores temporary state that exists only while a pipeline phase is executing.
    

Each scope has a clear owner, a clear lifetime, and a well-defined responsibility.

Instead of one object trying to represent everything, each layer now manages only the state it truly owns.

This change affected almost every part of the framework.

The pipeline became simpler.

Plugins became easier to reason about.

Cleanup became deterministic.

State propagation became explicit instead of implicit.

Most importantly, the architecture now reflects how the proxy actually behaves rather than forcing every piece of data into the same abstraction.

The complete architecture now looks like this:

![](https://cdn.hashnode.com/uploads/covers/69cdf6922d4d5bd0f080b8d3/fa3dc7b0-3237-4537-a5dc-54c31382eeb6.png align="center")

After introducing scope-based state management, the remaining pieces naturally fell into place. The transport layer became centralized, the pipeline evolved into a proper phase orchestrator, resource management became predictable, and every subsystem gained a much clearer responsibility.

This wasn't just a refactor.

It was a shift from building **features around an architecture** to building **an architecture that naturally supports new features**.

## Separating State by Lifetime

The core idea behind the redesign was surprisingly simple:

> **Not all data lives for the same amount of time, so it shouldn't live in the same object.**

In the original architecture, almost every piece of information eventually found its way into a single context object. Connection details, request metadata, temporary pipeline state, routing information, and plugin data all coexisted in the same place.

This worked initially, but over time the boundaries between different responsibilities became unclear.

The redesign separates state according to its natural lifetime.

### Session Context

A **Session Context** is created when a client establishes a connection with the proxy.

It contains information that remains valid throughout the lifetime of that connection, regardless of how many requests are processed.

Examples include:

*   Connection identifiers
    
*   Underlying socket
    
*   Negotiated protocol
    
*   Connection-level errors
    
*   Custom TLS certificates
    

Once the client disconnects, the session context is destroyed.

### Request Context

Every incoming request creates its own **Request Context**.

Unlike the session context, this object exists only for the lifetime of a single request-response transaction.

It contains everything related to that request, including:

*   Client request and response objects
    
*   Upstream request and response objects
    
*   Headers
    
*   Request and response bodies
    
*   Routing information
    
*   Status information
    

Once the response has been sent back to the client, the request context is no longer needed.

This separation means multiple requests can safely exist within the same client session without interfering with one another.

### Request Lifecycle

Some information doesn't belong to either the connection or the request.

Instead, it only exists while the proxy is actively processing a request through the pipeline.

For this, I introduced the **Request Lifecycle**.

Rather than storing operational state inside the request itself, the lifecycle tracks information such as:

*   Current execution state
    
*   Phase transitions
    
*   Request hijacking
    
*   Timing metrics
    
*   Performance measurements
    

This keeps execution state separate from business data while giving the pipeline complete control over how a request progresses.

### Why This Matters

Separating state by lifetime solved several problems at once.

Responsibilities became much clearer because each context owned only the data relevant to its lifetime.

Cleanup also became far more predictable. When a request finishes, its request context and lifecycle can be discarded without affecting the surrounding session. Likewise, when a connection closes, the entire session context can be released safely.

Perhaps the biggest benefit, however, is that the architecture now mirrors how the proxy actually operates. Instead of forcing every piece of information into a single object, each layer has a clear owner, a clear purpose, and a well-defined lifecycle.

This separation became the foundation upon which the rest of the architecture was built.

## Centralized Transport Layer

One of the biggest responsibilities of a proxy is moving data between two endpoints.

In the original architecture, each handler was responsible for creating or managing its own network connections. For example, the request handler initiated the upstream request, the handshake handler dealt with decrypted streams, and the response handler handled sending data back to the client.

While this worked, it also meant that transport logic was scattered throughout the pipeline.

The redesign introduces a dedicated **Transport Layer** whose sole responsibility is moving data between the client and the upstream server.

Instead of handlers creating sockets or managing streams directly, they simply delegate those responsibilities to the transport layer.

The transport layer is divided into three distinct responsibilities:

![](https://cdn.hashnode.com/uploads/covers/69cdf6922d4d5bd0f080b8d3/a45a96c1-a92b-4dc2-abe8-f26dcc213e31.png align="center")

*   **Inbound** — Receives and prepares incoming client traffic after the handshake phase.
    
*   **Upstream Initiator** — Establishes and manages communication with the destination server when instructed by the request handler.
    
*   **Outbound** — Returns the processed upstream response back to the client.
    

This separation keeps networking concerns isolated from request processing.

Handlers now focus on **what** should happen, while the transport layer is responsible for **how** data moves through the proxy.

The result is a cleaner architecture with well-defined responsibilities, easier testing, and a single place to evolve transport-related behavior without affecting the rest of the pipeline.

## Pipeline Evolution

One of the more subtle problems in the original architecture was pipeline ownership.

Although the proxy had clearly defined phases, there wasn't a single component responsible for orchestrating them.

Instead, each handler decided what should happen next by invoking the pipeline again. In other words, handlers weren't just processing their own phase—they were also responsible for deciding how execution continued.

This made the control flow harder to follow because the pipeline was effectively orchestrating itself.

The redesign establishes a much stricter boundary.

The **Middleware** becomes the single entry point into the execution pipeline.

![](https://cdn.hashnode.com/uploads/covers/69cdf6922d4d5bd0f080b8d3/07af2e30-c87b-4dcc-9e20-2789867e954e.png align="center")

Once [`Pipeline.run`](http://Pipeline.run)`()` begins, it becomes the sole owner of execution.

Each handler is responsible for only one thing:

*   Execute its own phase.
    
*   Update the lifecycle if another phase should follow.
    
*   Return control to the pipeline.
    

The pipeline then decides what happens next.

Instead of handlers recursively invoking the next stage, the pipeline continuously evaluates the current lifecycle state and orchestrates phase execution until the request completes or is intentionally terminated.

This seemingly small change has several advantages:

*   **Handlers no longer control execution flow.**
    
*   **Pipeline orchestration exists in one place.**
    
*   **Phase transitions become predictable and easy to debug.**
    
*   **Aborting or hijacking a request becomes a first-class operation rather than a special case.**
    
*   **Error handling is centralized instead of duplicated across handlers.**
    

By separating **execution** from **orchestration**, handlers became significantly smaller and easier to reason about, while the pipeline evolved into a true execution engine rather than just a collection of sequential function calls.

## Resource Management & Cleanup

One lesson I learned while building the first version of the proxy was that processing requests is only half the job.

The other half is cleaning everything up correctly.

A proxy sits between two endpoints and constantly manages sockets, streams, requests, and responses. If even one of these resources isn't released properly, problems begin to accumulate over time—dangling streams, half-open connections, memory growth, and difficult-to-debug networking issues.

In the original architecture, cleanup was largely the responsibility of individual handlers. Each handler had to remember which resources it created and when they should be released. This worked for simple cases, but it also meant cleanup logic became scattered throughout the codebase.

The redesign centralizes resource management into a dedicated cleanup utility.

Rather than each component implementing its own shutdown logic, handlers simply hand over the resources they own, and the cleanup layer safely closes or destroys them when their lifecycle ends.

```typescript
ProxyUtils.cleanUp([
  clientSocket,
  upstreamSocket,
  requestStream,
  responseStream,
]);
```

This approach provides several advantages:

*   A single, consistent strategy for releasing resources.
    
*   Less duplicated cleanup code across handlers.
    
*   Deterministic destruction of streams and sockets.
    
*   Reduced risk of dangling connections and resource leaks.
    
*   Simpler handlers that focus on request processing rather than lifecycle management.
    

Most importantly, cleanup is now treated as part of the architecture instead of an implementation detail.

Every resource has a well-defined owner, and every owner has a clear responsibility for releasing it when its work is complete. That makes the entire request lifecycle more predictable and the proxy far more stable during long-running workloads.

## **Centralized State Store**

As the proxy grew, different components needed a way to share operational state during a request's lifetime.

Rather than scattering temporary flags across multiple objects, the redesign introduces a **centralized state store** bound to each request lifecycle.

It provides a single location for tracking execution state, such as cache hits, completion status, and error conditions, while also allowing plugins and middleware to attach custom request-specific data.

The state store combines **strongly typed built-in state** with **flexible custom entries**, offering compile-time safety for core framework properties without restricting extensibility.

This gives every phase of the pipeline a consistent way to exchange information without tightly coupling components or polluting the request and session contexts.

## **Strongly Typed Event System**

Events are one of the primary extension points in **mitm-core**, allowing plugins and internal components to react to different stages of the proxy lifecycle.

In the original architecture, the event system relied on loosely typed event names and payloads. While functional, it left room for runtime mistakes such as misspelled event names or incorrect payload structures.

The redesigned event manager is fully **statically typed**. Every event has a well-defined payload, allowing TypeScript to validate both event names and their associated data at compile time.

Beyond improving developer experience through better autocompletion and type inference, this approach eliminates an entire class of runtime errors and makes plugin development significantly safer.

As the framework continues to grow, the event system remains both extensible and reliable without sacrificing type safety.

## **Performance Optimizations**

As the architecture matured, performance became just as important as correctness. Rather than optimizing prematurely, I focused on the parts of the proxy that were genuinely expensive or frequently repeated.

### Worker-thread certificate generation

Generating leaf certificates is one of the most computationally expensive operations in a MITM proxy. Performing this work on the main event loop would introduce unnecessary latency and reduce the proxy's ability to handle concurrent requests.

To avoid blocking the event loop, certificate generation is delegated to a pool of worker threads using **Piscina**. This allows expensive cryptographic operations to run in parallel while the main thread remains responsive.

### Multi-layer certificate cache (LRU + filesystem)

Even with worker threads, generating a certificate for every connection would be wasteful.

To eliminate redundant work, the proxy uses a two-level caching strategy:

*   **In-memory LRU cache** for frequently accessed certificates.
    
*   **Filesystem cache** for long-term persistence across proxy restarts.
    

This layered approach significantly reduces certificate generation overhead while maintaining fast lookup times for commonly requested domains.

### Response cache

The redesigned architecture also introduces a dedicated response cache.

Rather than always forwarding identical requests to the upstream server, cacheable responses can be reused when appropriate, reducing network traffic and improving response times.

By treating caching as a dedicated subsystem instead of embedding it within request handlers, the proxy keeps its execution pipeline clean while making cache behavior easier to configure and extend.

Together, these optimizations reduce unnecessary computation, minimize repeated work, and keep the proxy responsive under sustained workloads without complicating the overall architecture.

## **Built for Extensibility**

A proxy framework shouldn't require modifying its core every time new behavior is needed. One of the primary goals of the redesign was to make **mitm-core** extensible through dedicated infrastructure rather than hardcoded logic.

Two key components enable this: the **Rule Manager** and the **Plugin Manager**.

### Rule Manager

Many proxy features depend on configurable rules—whether it's bypassing specific domains, blocking requests, or applying custom routing behavior.

The redesigned **Rule Manager** provides a generic framework for defining and managing these rule sets. Each rule engine owns its own parser and storage while sharing the same underlying infrastructure.

Rule files are automatically created if they don't exist, watched for changes, and reloaded using a debounced mechanism whenever they're modified. This allows configuration changes to take effect without restarting the proxy.

Because parsing logic is abstracted behind a simple interface, different rule formats can be implemented without changing the framework itself.

### Plugin Manager

The **Plugin Manager** provides the primary extension mechanism for the framework.

Plugins subscribe to well-defined lifecycle events and receive strongly typed payloads corresponding to each stage of proxy execution. Rather than modifying the framework's internal components, developers can extend behavior by implementing small, focused plugins that react to specific events.

This event-driven model keeps the core architecture isolated while allowing new functionality—such as logging, monitoring, authentication, request inspection, or custom routing—to be added independently.

Together, the Rule Manager and Plugin Manager make **mitm-core** easier to customize without increasing coupling inside the framework. New behavior can be introduced by composing reusable components instead of modifying existing ones, keeping the architecture both flexible and maintainable as it grows.

## Conclusion

Redesigning **mitm-core** wasn't about adding more features—it was about building a stronger foundation.

By separating state according to its lifetime, centralizing transport and resource management, formalizing pipeline orchestration, and introducing reusable infrastructure, the framework became significantly easier to understand, extend, and maintain.

Every architectural decision in this redesign was driven by the same principle: **make the system reflect how a proxy actually works**. The result is an architecture that is not only cleaner internally but also better prepared for future capabilities.

* * *

## What's Next

While this redesign establishes a solid foundation, there is still plenty of work ahead.

The next major milestone is expanding the proxy's protocol support, beginning with:

*   **HTTP/2 interception**, enabling inspection and modification of modern multiplexed HTTP traffic.
    
*   **WebSocket interception**, allowing real-time bidirectional communication to be observed and manipulated through the same extensible pipeline.
    

With the core architecture now in place, these capabilities can be introduced as natural extensions of the framework rather than requiring another fundamental redesign.
