diff --git a/.claude/agents/spec-refactorer.md b/.claude/agents/spec-refactorer.md
deleted file mode 100644
index d7114e86b..000000000
--- a/.claude/agents/spec-refactorer.md
+++ /dev/null
@@ -1,234 +0,0 @@
----
-name: spec-refactorer
-description: |
- Refactors test files in TurboHTTP.Tests and TurboHTTP.StreamTests:
- - Removes [Trait("RFC", ...)] from non-Protocol folders (only Protocol tests need RFC traceability)
- - Validates RFC trait section references against the Obsidian vault (notes/RFC/)
- - Removes /// XML doc comments outside method bodies (class-level and method-level docs)
- - Validates Spec naming conventions (BDD names, sealed classes, no DisplayName, etc.)
- Trigger phrases: "refactor specs", "clean up specs", "spec refactor", "spec cleanup".
-tools:
- - Read
- - Edit
- - Glob
- - Grep
- - Bash
- - mcp__obsidian__search_notes
- - mcp__obsidian__read_note
- - mcp__obsidian__list_directory
----
-
-You are the Spec refactoring agent for the TurboHTTP project.
-You clean up test files in component-based folders by removing unnecessary RFC traits,
-validating RFC section references against the Obsidian vault, removing XML doc comments
-outside method bodies, and validating naming conventions.
-
-## Folder Classification
-
-RFC `[Trait]` attributes are only meaningful for tests that exercise Protocol-layer code.
-
-| Category | Folders | RFC traits |
-|----------|---------|-----------|
-| **Protocol** | `Http10/`, `Http11/`, `Http2/`, `Http3/`, `Caching/`, `Cookies/`, `AltSvc/`, `Semantics/` | **Keep** |
-| **Non-Protocol** | `Transport/`, `Security/`, `Diagnostics/`, `Hosting/`, `Streams/`, `Client/`, `Internal/` | **Remove** |
-
-Both `TurboHTTP.Tests/` and `TurboHTTP.StreamTests/` follow this classification.
-
-## What to Do
-
-### 1. `[Trait("RFC", ...)]` in non-Protocol folders — REMOVE
-
-Remove the entire `[Trait("RFC", "...")]` line (including trailing newline) from any test
-file located in a non-Protocol folder. Do NOT remove traits from Protocol folders.
-
-### 2. RFC Trait Section Validation — VERIFY against Obsidian vault
-
-For every `[Trait("RFC", "RFC{number}-{section}")]` that remains (Protocol folders),
-validate that the referenced section actually exists in the Obsidian vault.
-
-#### Vault structure
-
-RFC notes live under `notes/RFC/RFC{number}/`:
-```
-notes/RFC/RFC9114/
-├── RFC9114.md (index with section table)
-└── sections/
- ├── 13_7_1_frame_layout.md (§7.1 — frontmatter: rfc_section: "7.1")
- ├── 14_7_2_frame_definitions.md (§7.2 — contains ### 7.2.1 ... ### 7.2.7 headings)
- └── ...
-```
-
-#### How to validate a trait reference
-
-Given `[Trait("RFC", "RFC9204-2.1")]`:
-
-1. **Extract** RFC number (`9204`) and section (`2.1`)
-2. **Glob** `notes/RFC/RFC9204/sections/*.md`
-3. **Match level 1–2 sections** (e.g., `2`, `2.1`): find a section file whose frontmatter
- `rfc_section` matches the trait section. Each section file has:
- ```yaml
- rfc_section: "2.1"
- ```
-4. **Match level 3+ sections** (e.g., `4.2.1`, `5.2.2.3`): the parent section file covers the
- major.minor (e.g., `4.2`). Read that file and check for a `###` heading that starts with the
- full sub-section number:
- ```markdown
- ### 4.2.1 Calculating Freshness Lifetime
- ```
-5. **Report mismatches**:
- - `RFC_NOT_FOUND` — no `notes/RFC/RFC{number}/` directory exists
- - `SECTION_NOT_FOUND` — no section file with matching `rfc_section` frontmatter
- - `SUBSECTION_NOT_FOUND` — parent section file exists but no heading for the sub-section
-
-#### Validation output
-
-```
-=== RFC TRAIT VALIDATION ===
-File Line Trait Status
-src/TurboHTTP.Tests/Http3/FooSpec.cs 14 RFC9114-7.1 ✅ valid
-src/TurboHTTP.Tests/Http3/FooSpec.cs 28 RFC9114-7.2.1 ✅ valid
-src/TurboHTTP.Tests/Http3/FooSpec.cs 42 RFC9114-99.1 ❌ SECTION_NOT_FOUND
-src/TurboHTTP.Tests/Caching/BarSpec.cs 10 RFC9999-1 ❌ RFC_NOT_FOUND
-```
-
-#### Caching during scan
-
-Build a lookup cache to avoid redundant Obsidian reads:
-- Cache 1: `RFC{number}` → list of section files (glob once per RFC)
-- Cache 2: `RFC{number}-{major.minor}` → frontmatter `rfc_section` values (read once per file)
-- Cache 3: `RFC{number}-{major.minor}` → set of `###` heading section numbers (read once per file)
-
-### 3. `///` XML doc comments outside method bodies — REMOVE
-
-Remove ALL `///` comment lines that appear **outside** method bodies. This includes:
-
-- **Class-level XML docs** — `/// `, `/// `, `/// `, etc.
-- **Method-level XML docs** — `/// RFC 9114 §7 — Empty DATA frame` above `[Fact]`
-
-**Preserve** any `//` or `///` comments that are **inside** method bodies (brace depth >= 2).
-
-#### How to detect inside vs outside
-
-Track brace depth as you scan line by line:
-- Depth 0 = file/namespace level
-- Depth 1 = class level (between class `{` and `}`)
-- Depth 2+ = inside a method, property, or nested block
-
-A `///` comment at depth 0 or 1 is **outside** → remove it.
-A `///` comment at depth 2+ is **inside** → keep it.
-
-**Important:** Ignore braces inside string literals and comments when counting depth.
-
-### 4. Clean up blank lines
-
-After removing comments, collapse consecutive blank lines into at most one blank line.
-
-## Naming Convention Validation (report only)
-
-While scanning files, also check these conventions and **report** violations (do not auto-fix):
-
-| Rule | Check |
-|------|-------|
-| R1 | File name ends in `Spec.cs`, no numeric prefix |
-| R2 | Class is `sealed`, ends in `Spec` |
-| R3 | BDD method names: `Subject_should_behavior()` or `Subject_should_behavior_when_condition()` |
-| R4 | `[Fact]` has no `DisplayName` |
-| R5 | `[Theory]` has no `DisplayName` |
-| R6 | RFC Trait format (if present in Protocol folder): `RFC\d{4}(-[\d.]+)?` |
-| R7 | All tests have `Timeout`|
-
-## Workflow
-
-### Phase 1 — Discover
-
-Glob for all `*.cs` files under component-based folders in both test projects:
-
-```
-src/TurboHTTP.Tests/{Http10,Http11,Http2,Http3,Semantics,Caching,Cookies,AltSvc,Transport,Security,Diagnostics,Hosting,Streams,Client,Internal}/**/*.cs
-src/TurboHTTP.StreamTests/{Http10,Http11,Http2,Http3,Semantics,Caching,Cookies,Streams,Transport}/**/*.cs
-```
-
-Categorize each file as Protocol or non-Protocol based on its folder.
-
-### Phase 2 — Analyze
-
-For each file:
-1. Read the full file content
-2. Track brace depth to identify `///` comments outside method bodies
-3. If non-Protocol folder: find `[Trait("RFC", ...)]` lines → mark for removal
-4. If Protocol folder: collect all `[Trait("RFC", ...)]` values → validate in Phase 3
-5. Check naming conventions (Rules R1–R7)
-6. Record all findings with file path and line numbers
-
-### Phase 3 — Validate RFC References
-
-For each unique RFC number found in Phase 2:
-1. Glob `notes/RFC/RFC{number}/sections/*.md` to get available section files
-2. Read frontmatter of each section file to build a `rfc_section` → file mapping
-3. For sub-section traits (e.g., `RFC9111-4.2.1`), read the parent section file and
- extract `###` heading numbers
-
-For each trait reference, look it up in the cache:
-- Section `X` or `X.Y` → match against frontmatter `rfc_section` values
-- Section `X.Y.Z` or deeper → match against `###` headings in the `X.Y` parent file
-
-Report all mismatches as `SECTION_NOT_FOUND` or `RFC_NOT_FOUND`.
-
-### Phase 4 — Dry-Run Report
-
-Output a grouped report:
-
-```
-=== RFC TRAITS TO REMOVE (non-Protocol folders) ===
-File Line Current
-src/TurboHTTP.Tests/Transport/FooSpec.cs 14 [Trait("RFC", "RFC9112-6")]
-
-=== RFC TRAIT VALIDATION (Protocol folders) ===
-File Line Trait Status
-src/TurboHTTP.Tests/Http3/BarSpec.cs 14 RFC9114-7.1 ✅
-src/TurboHTTP.Tests/Http3/BarSpec.cs 42 RFC9114-99.1 ❌ SECTION_NOT_FOUND
-
-=== XML DOC COMMENTS TO REMOVE ===
-File Lines Preview
-src/TurboHTTP.Tests/Caching/BarSpec.cs 5-13 /// RFC 9111 §4.4 ...
-
-=== NAMING VIOLATIONS (report only) ===
-File Line Rule Detail
-src/TurboHTTP.Tests/Http2/BazSpec.cs 45 R3 Method uses PascalCase after subject
-```
-
-Then ask the user for confirmation before proceeding to Phase 5.
-
-### Phase 5 — Apply Changes
-
-For each file with findings:
-1. Read the file
-2. Build the list of line ranges to remove (trait lines + doc comment blocks)
-3. Use Edit to remove those lines
-4. Collapse consecutive blank lines
-
-Process files in batches using parallel Edit calls where possible.
-
-**RFC validation mismatches are reported but NOT auto-fixed** — the user decides whether to
-correct the section reference or remove the trait.
-
-### Phase 6 — Summary
-
-```
-Files scanned : N
-Files modified : N
-RFC traits removed : N (non-Protocol)
-RFC traits validated : N (Protocol)
- ✅ valid : N
- ❌ invalid : N
-Doc comments removed : N lines across M files
-Naming violations : N (reported, not fixed)
-```
-
-## Safety
-
-- Always show the dry-run report before applying changes
-- Never remove comments inside method bodies
-- Never modify method signatures, attributes (except Trait removal), or code
-- RFC validation mismatches are reported, not auto-fixed
-- After all edits, suggest running `dotnet build` to verify compilation
diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 958327455..000000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "hooks": {
- "PostToolUse": [
- {
- "matcher": "Write|Edit|MultiEdit",
- "hooks": [
- {
- "type": "command",
- "command": "slopwatch analyze -d $(git rev-parse --show-toplevel) --hook",
- "timeout": 60000
- }
- ]
- }
- ]
- }
-}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1461366d7..07ffe741a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,18 +1,8 @@
name: Build & Test
on:
- push:
- branches: ["main"]
- paths-ignore:
- - "docs/**"
- - "notes/**"
- - "*.md"
- - ".github/workflows/docs.yml"
- - ".github/workflows/commitlint.yml"
- - ".github/workflows/codeql.yml"
- - ".github/workflows/release.yml"
pull_request:
- branches: ["main"]
+ branches: ["main", "release-next"]
paths-ignore:
- "docs/**"
- "notes/**"
@@ -40,13 +30,17 @@ permissions:
jobs:
build-and-test:
runs-on: ubuntu-latest
- if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore(main): release')"
+ strategy:
+ fail-fast: false
+ matrix:
+ backend: [kestrel, docker]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
+ submodules: true
- name: Install libmsquic (QUIC/HTTP3 support)
run: |
@@ -72,8 +66,10 @@ jobs:
--no-restore
${{ env.PROJECT_PATH }}
- - name: Run tests
+ - name: Run tests (backend=${{ matrix.backend }})
working-directory: "./src"
+ env:
+ TURBOHTTP_TEST_BACKEND: ${{ matrix.backend }}
run: >
dotnet test
--configuration Release
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 6a06b70d6..18b039ce1 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -52,6 +52,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
+ with:
+ submodules: true
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index f79bbff04..402581d7d 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -2,7 +2,7 @@ name: PR & Commit Lint
on:
pull_request:
- branches: ["main"]
+ branches: ["main", "release-next"]
types: [opened, edited, synchronize]
permissions:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 99441465d..c16c1680d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,7 @@ name: Release & Publish
on:
push:
- branches: [ "main" ]
+ branches: [ "main", "release-next" ]
env:
GLOBAL_JSON_PATH: "./src/global.json"
@@ -28,6 +28,7 @@ jobs:
id: release
uses: googleapis/release-please-action@v5
with:
+ target-branch: ${{ github.ref_name }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
@@ -42,6 +43,7 @@ jobs:
with:
fetch-depth: 1
lfs: 'true'
+ submodules: true
- name: Install .NET
uses: actions/setup-dotnet@v5
@@ -75,7 +77,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: nuget-packages
- path: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg
+ path: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg
nuget-publish:
runs-on: ubuntu-latest
@@ -95,7 +97,7 @@ jobs:
- name: Push to NuGet.org
run: >
dotnet nuget push
- ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg
+ ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg
--source https://api.nuget.org/v3/index.json
--api-key ${{ secrets.NUGET_SECRET }}
--skip-duplicate
@@ -104,12 +106,12 @@ jobs:
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
- files: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/*.nupkg
+ files: ${{ env.PACKAGE_OUTPUT_DIRECTORY }}/TurboHTTP.*.nupkg
docs-build:
runs-on: ubuntu-latest
needs: release-please
- if: needs.release-please.outputs.release_created == 'true'
+ if: needs.release-please.outputs.release_created == 'true' && github.ref == 'refs/heads/main'
defaults:
run:
working-directory: docs
@@ -120,6 +122,7 @@ jobs:
with:
fetch-depth: 0
lfs: 'true'
+ submodules: true
- name: Setup Node.js
uses: actions/setup-node@v6
diff --git a/.gitignore b/.gitignore
index f6e488803..1da1ab496 100644
--- a/.gitignore
+++ b/.gitignore
@@ -373,4 +373,5 @@ TurboHTTP/.obsidian/
.maggus/logs/
.maggus/worktrees/
-*.ps1
\ No newline at end of file
+*.ps1
+TURBOHTTP_GAP_ANALYSIS.md
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..d1edd6db0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "lib/servus.akka"]
+ path = lib/servus.akka
+ url = https://github.com/Bavaria-Black/servus.akka.git
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 895bf0e35..a76776929 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.0.0"
+ ".": "3.0.0-alpha.2"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8274e57a..45fc42b63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,203 @@
# Changelog
+## [3.0.0-alpha.2](https://github.com/Leberkas-org/TurboHTTP/compare/v3.0.0-alpha.1...v3.0.0-alpha.2) (2026-06-11)
+
+
+### Features
+
+* **client:** default timeout for channel path + CancelPendingRequests drain ([fd4bf5e](https://github.com/Leberkas-org/TurboHTTP/commit/fd4bf5e4b1b57029e6907bc8537c61fc0cf4a505))
+* **client:** expose pipe buffer tuning via TurboClientOptions ([9773bab](https://github.com/Leberkas-org/TurboHTTP/commit/9773bab0a19d58905d3ef209ef3b9045c9c9641d))
+* **client:** propagate effective CancellationToken onto request options ([9602238](https://github.com/Leberkas-org/TurboHTTP/commit/9602238d85911e8a846e4f1cf4d5092a7dfd1d2e))
+* **h10:** per-request cancellation with disconnect ([6b51616](https://github.com/Leberkas-org/TurboHTTP/commit/6b516168f3403b7eaf8a506b895065cad1a81e93))
+* **h11:** per-request cancellation with pipelining awareness ([3b01383](https://github.com/Leberkas-org/TurboHTTP/commit/3b01383d2ef45ec000054a66ee72e35c8dd97aed))
+* **h2:** emit RST_STREAM on per-request cancellation ([323097d](https://github.com/Leberkas-org/TurboHTTP/commit/323097d26ec2c21fd40c53ad6c9beb08793d2e3a))
+* **h3:** emit STOP_SENDING on per-request cancellation ([0cbe4d9](https://github.com/Leberkas-org/TurboHTTP/commit/0cbe4d9a19476c4daf461c7a0988aa66df0b45d2))
+* pipe transport, body redesign, server simplification ([5281774](https://github.com/Leberkas-org/TurboHTTP/commit/528177468b6dc7d9cf0dcbe514af70641f37190b))
+* **protocol:** add CancellationToken infrastructure for per-request cancel ([e74d9d2](https://github.com/Leberkas-org/TurboHTTP/commit/e74d9d2bea936d953c929ddcea761e5967302036))
+* **server:** Add transport buffer options ([d2cc47f](https://github.com/Leberkas-org/TurboHTTP/commit/d2cc47f3b5a2eb2a3b7e008ed5cfc5314f0d2baa))
+* **server:** expose TransportBufferOptions with protocol-optimized defaults ([3857fc9](https://github.com/Leberkas-org/TurboHTTP/commit/3857fc9cc3883e7be455979d29c7a694923451cf))
+* **stage:** register per-request CancellationToken callbacks in connection stage ([bcb808e](https://github.com/Leberkas-org/TurboHTTP/commit/bcb808e6d807a88be870adcdadc390a29349849c))
+
+
+### Bug Fixes
+
+* **bench:** add 30s timeout guard to all benchmark iterations ([82b1cb7](https://github.com/Leberkas-org/TurboHTTP/commit/82b1cb71ee96daf4937d9f916bb2860283772123))
+* **bench:** add 30s timeout to all benchmark clients ([48fe358](https://github.com/Leberkas-org/TurboHTTP/commit/48fe358330a8d3761c4686c07b7231fb82af95a5))
+* **bench:** add CancellationToken timeout to all warmup and SendAsync calls ([b9cd7e0](https://github.com/Leberkas-org/TurboHTTP/commit/b9cd7e034aaecaa125bc96865f07bb0874995770))
+* **bench:** add IterationCleanup drain for streaming benchmarks ([a7be4f4](https://github.com/Leberkas-org/TurboHTTP/commit/a7be4f4ec3e9f225f524da05d4e439badf462991))
+* **bench:** align H3 client MaxConcurrentStreams with Kestrel default ([564e753](https://github.com/Leberkas-org/TurboHTTP/commit/564e753ac04e3366723406a07728849468239b95))
+* **bench:** drain stale responses at start of each streaming iteration ([be01236](https://github.com/Leberkas-org/TurboHTTP/commit/be012368484f05b7be77a32e099cc43fff5dcfd7))
+* **bench:** drop CL=4096 from streaming benchmarks ([564968a](https://github.com/Leberkas-org/TurboHTTP/commit/564968a16dae2ded49a253ff99c43dc448409f6d))
+* **benchmarks:** protocol-aware fan-out limits and scaled timeouts ([575375e](https://github.com/Leberkas-org/TurboHTTP/commit/575375ee8b08f863ed46e5290dc5beb2ce05c5cc))
+* **bench:** prevent benchmark reports from overwriting previous runs ([fa5d3bd](https://github.com/Leberkas-org/TurboHTTP/commit/fa5d3bdaf01c0a57db9811f873359a7430f6b41f))
+* **bench:** prevent streaming benchmark deadlocks ([a249088](https://github.com/Leberkas-org/TurboHTTP/commit/a249088e8a601f72e6900abdccf74ea83a612c6b))
+* **bench:** raise QUIC stream limit and harden streaming benchmarks ([9a54267](https://github.com/Leberkas-org/TurboHTTP/commit/9a54267077668f468a03ad24ea69c7d4bb6cefe9))
+* **bench:** restore CL=4096 for streaming benchmarks ([a02761a](https://github.com/Leberkas-org/TurboHTTP/commit/a02761ad6d235ccf369b86c5b4aa70fe42b53363))
+* **bench:** switch heavy benchmarks to /upload route + throttle streaming writer ([daab86a](https://github.com/Leberkas-org/TurboHTTP/commit/daab86a41a5cb62d4f810e4424546c9657dfdef8))
+* **body:** H10 truncated body error propagation, H11 chunked boundary deadlock ([561ff28](https://github.com/Leberkas-org/TurboHTTP/commit/561ff286be80a54637977d7419483f70967fc83e))
+* **body:** QueuedBodyReader.ReadAsync now respects CancellationToken ([fb5c55a](https://github.com/Leberkas-org/TurboHTTP/commit/fb5c55a48e2d1583f4d3328ee48b010fa100c740))
+* **body:** resolve pending ReadAsync on Reset to prevent InvalidOperationException ([bc21107](https://github.com/Leberkas-org/TurboHTTP/commit/bc21107a5c2be2e00cd293dbba99faf10eb7616b))
+* **ci:** Disable parallel test modules ([a1d783f](https://github.com/Leberkas-org/TurboHTTP/commit/a1d783ff137bbef145dde2d2dbbef7245e374bf4))
+* **ci:** run two test modules in parallel ([0ed7e7f](https://github.com/Leberkas-org/TurboHTTP/commit/0ed7e7f2b11ea3e17a6ffe97108138c23f12451a))
+* client flush backpressure, QUIC pipe options, test fixes ([bf3effd](https://github.com/Leberkas-org/TurboHTTP/commit/bf3effd10f1f7b5e6d4986ebb0cf7ec49d3c4fd2))
+* **e2e:** stabilize E2E integration tests ([cbc2252](https://github.com/Leberkas-org/TurboHTTP/commit/cbc22522d97a8f4a3089acb41b9be412571be0f4))
+* **e2e:** use ctx.RequestAborted instead of TestContext CancellationToken ([e6c6f56](https://github.com/Leberkas-org/TurboHTTP/commit/e6c6f560be57920460f372dae9f5aec15bf0acc5))
+* **h10/server:** dispatch streaming request bodies before full receipt ([4ed9c42](https://github.com/Leberkas-org/TurboHTTP/commit/4ed9c424126d91b38d5045919452984dd590388d))
+* **h2/server:** partial send in DrainOutboundBuffer when flow control window < chunk ([2f57852](https://github.com/Leberkas-org/TurboHTTP/commit/2f57852b8dadfd16202ce3a724aa6635e712e671))
+* **h2:** track stream-level send window in FlowController.OnDataSent ([f31784e](https://github.com/Leberkas-org/TurboHTTP/commit/f31784ed7de48eb515b2eb838bca1495629aba34))
+* **http2/3:** correct body read pending state ([f8d2485](https://github.com/Leberkas-org/TurboHTTP/commit/f8d248538f839ed6246e8e7904017ba2e341199c))
+* **http2:** make QueuedBodyReader thread-safe and fail truncated response bodies ([ba89a9c](https://github.com/Leberkas-org/TurboHTTP/commit/ba89a9c508bc72b6ff6ce5a2754c37a932e03492))
+* **http:** fix http version comparison and null checks ([91fdab1](https://github.com/Leberkas-org/TurboHTTP/commit/91fdab1787a5793bc2c1cd7d9c2f2144c65c8ee2))
+* **http:** Improve flow control and stream draining ([6ec29cb](https://github.com/Leberkas-org/TurboHTTP/commit/6ec29cb5d5bffed046d2c021990c84e819f5cfe4))
+* **quic:** update submodule — drain pending acquires on release/establish ([1defdbb](https://github.com/Leberkas-org/TurboHTTP/commit/1defdbbfb2a732402513a4faef14d7da2fd34ea2))
+* **quic:** update submodule — server stream accept loop exception handling ([63a1319](https://github.com/Leberkas-org/TurboHTTP/commit/63a1319b38f1a45449f5a364b70fff5362d0d866))
+* **quic:** update submodule with QUIC accept loop resilience ([7cee693](https://github.com/Leberkas-org/TurboHTTP/commit/7cee6937709ab21b9068e51953a99a0dcbb040a5))
+* Remove unused OpenTelemetry package ([93a9f26](https://github.com/Leberkas-org/TurboHTTP/commit/93a9f26af3f75dafbcb194d2d93e8eebdb2f51ef))
+* **server:** always call TryPullResponse from OnNetworkPull ([e18b2e3](https://github.com/Leberkas-org/TurboHTTP/commit/e18b2e312793d36be962e85ebdccbe3a6237ed18))
+* **server:** split buffered body into MAX_FRAME_SIZE-compliant DATA frames ([a64f68b](https://github.com/Leberkas-org/TurboHTTP/commit/a64f68bf788f59b89457bf4399fc057d7802f5f6))
+* **tcp:** update submodule with concurrent PipeWriter access fix ([ef32fea](https://github.com/Leberkas-org/TurboHTTP/commit/ef32fea181f593dfd1f901ef352b5fba5e0e9e59))
+* **test:** Disable parallel test collections ([0dae2a3](https://github.com/Leberkas-org/TurboHTTP/commit/0dae2a3e7837e005a2384c6fe19bca53a90362fa))
+* **test:** make integration test infrastructure parallel-safe ([9dce6a6](https://github.com/Leberkas-org/TurboHTTP/commit/9dce6a6be975cb850b4e056194757785f0208d3d))
+* **test:** raise client timeout in LargePayloadSpec for CI contention ([65dd73f](https://github.com/Leberkas-org/TurboHTTP/commit/65dd73f853c9a23cde702cf545a3a0b6698331c7))
+
+
+### Performance
+
+* **client:** remove .Async() boundary from EndpointDispatchStage ([f4a1bb4](https://github.com/Leberkas-org/TurboHTTP/commit/f4a1bb4d9e2e5792b4dee441784fe442fa61daa4))
+* **h3:** cache QPACK encode buffer across Encode() calls ([1ff7130](https://github.com/Leberkas-org/TurboHTTP/commit/1ff7130818e824abe8230e56e1ccbc20c04afacb))
+* **h3:** pool FrameDecoder and rent StreamState from pool in server ([49b3032](https://github.com/Leberkas-org/TurboHTTP/commit/49b303219df20dee3155ab3880a1b183f8b96b1e))
+* **h3:** reduce QUIC pipe MinimumSegmentSize from 16KB to 4KB ([35e45fa](https://github.com/Leberkas-org/TurboHTTP/commit/35e45fa134839650083576e10c36a8a78ca892fb))
+* pass sizeHint to GetMemory() + sync body read bypass for H10 server ([4b65fb3](https://github.com/Leberkas-org/TurboHTTP/commit/4b65fb3c864eaae98885b49d709ee33d7198400a))
+* pool TransportData wrappers + convert PipeTo messages to readonly record structs ([f27b895](https://github.com/Leberkas-org/TurboHTTP/commit/f27b895b00839e3a8064f6fe9b9b803491540f82))
+* reduce QueuedBodyReader default capacity from 64 to 8 ([1988ddb](https://github.com/Leberkas-org/TurboHTTP/commit/1988ddb89b1b7f7ec583a0d404664bd21832d501))
+* right-size body drain buffers using content-length ([5e54fe3](https://github.com/Leberkas-org/TurboHTTP/commit/5e54fe3c6ff1d648790cba7e221c97e999820049))
+* **server:** buffered body fast path for all protocol SMs ([8823dec](https://github.com/Leberkas-org/TurboHTTP/commit/8823dec035f3174ad2fdb264ba431c6565d3cace))
+* **server:** dual-mode ResponsePipeWriter with lazy Pipe upgrade ([721c992](https://github.com/Leberkas-org/TurboHTTP/commit/721c9928ae9935cc24c321d4d11028aa0bb20e39))
+* **server:** eliminate SetOnStarting closure allocation ([0ea3214](https://github.com/Leberkas-org/TurboHTTP/commit/0ea3214607db8a258a4497ac94d29c1975c3154b))
+* **server:** fix QUIC stream leak, reduce allocations, improve H2 throughput ([03a6d9c](https://github.com/Leberkas-org/TurboHTTP/commit/03a6d9c9b8f5e0481c787c832ffe4c4a3f3df402))
+* **server:** pool ArrayBufferWriter<byte> for response body buffering ([3db8272](https://github.com/Leberkas-org/TurboHTTP/commit/3db8272f2b3192579edfa53faacbe3aef979022e))
+* **server:** recycle FeatureCollection after response body consumption ([6a06a89](https://github.com/Leberkas-org/TurboHTTP/commit/6a06a89e6fec7f3a527cf0cac3c3c54edd50b69b))
+* **server:** synchronous body read bypass for pre-buffered responses ([ea52a12](https://github.com/Leberkas-org/TurboHTTP/commit/ea52a129638688772fe0991a5ea8c0fed1021c8c))
+* **tcp:** update submodule with server transport alignment ([a16fd7f](https://github.com/Leberkas-org/TurboHTTP/commit/a16fd7f777737c1561ac1f676297e67290efc1ed))
+* **tcp:** update submodule with write coalescing ([e1b512f](https://github.com/Leberkas-org/TurboHTTP/commit/e1b512fe79d069c97e70014c73dabfa35cac5adc))
+
+
+### Documentation
+
+* **notes:** document H2 response truncation race with repro steps ([0db412f](https://github.com/Leberkas-org/TurboHTTP/commit/0db412f39a1003004cda8f642eb6e196bed7d1c1))
+
+
+### Refactoring
+
+* **bench:** remove Binkraken benchmarks entirely ([fe255b2](https://github.com/Leberkas-org/TurboHTTP/commit/fe255b2c2a7f71998e66a4efa3cb6632124555ff))
+* Remove unused MemoryPool reference ([e56b38d](https://github.com/Leberkas-org/TurboHTTP/commit/e56b38df69493f202352508a3bbe7ecf3bb0acc5))
+
+## [3.0.0-alpha.1](https://github.com/Leberkas-org/TurboHTTP/compare/v3.0.0-alpha...v3.0.0-alpha.1) (2026-06-02)
+
+
+### Features
+
+* **client:** Add WithFirstPartyContext and WithTimeout ([4debf0f](https://github.com/Leberkas-org/TurboHTTP/commit/4debf0f06f34348036f7e0c00a1e30ae2ab41002))
+* Consolidate timer names with constants ([2c5623c](https://github.com/Leberkas-org/TurboHTTP/commit/2c5623c2fba2f94f61f775bbac094e1e8e226073))
+* **h3:** connection-error teardown on the server (stop swallow, close, RST) ([32ec3f9](https://github.com/Leberkas-org/TurboHTTP/commit/32ec3f956e084b89edf19db5443de0a5bbb8a911))
+* **http2:** adaptive receive-window growth in FlowController (client-gated) ([028c49e](https://github.com/Leberkas-org/TurboHTTP/commit/028c49e9f2fa41e7e8038f0de6aab93a474cfd81))
+* **http2:** adaptive window-scaling client options + projection ([8537293](https://github.com/Leberkas-org/TurboHTTP/commit/853729352f64d4fcd4326bdd2e655a0ea3f99f81))
+* **http2:** add RttEstimator for PING-based min-RTT measurement ([0995bb1](https://github.com/Leberkas-org/TurboHTTP/commit/0995bb1f51d0678593919a3e3786bd123152e244))
+* **http2:** add WindowScaler BDP growth formula ([6a6413d](https://github.com/Leberkas-org/TurboHTTP/commit/6a6413d0621954eec51220bd51f7878fc19d7dbe))
+* **http2:** enable adaptive window scaling ([fd722ad](https://github.com/Leberkas-org/TurboHTTP/commit/fd722ad04fa915a85086f6d276f7a772e9c4dfc4))
+* **http2:** Improve HTTP/2 protocol robustness and RFC compliance ([b67bc5d](https://github.com/Leberkas-org/TurboHTTP/commit/b67bc5d6fc63b708986caa04d94d715b226d89be))
+* **http2:** Improve interim response and trailer handling ([a64314c](https://github.com/Leberkas-org/TurboHTTP/commit/a64314cb333722b00ce7a66ba3d1a538c46781f8))
+* **http2:** project client http2 options to encoder ([2762854](https://github.com/Leberkas-org/TurboHTTP/commit/2762854d3bcb75bfb98caf37312f89fa90c895b3))
+* **http2:** raise per-stream receive window to 1 MB + E2E flow control tests ([ce844b5](https://github.com/Leberkas-org/TurboHTTP/commit/ce844b5c1b674dc093e6250ee9b93dc0a5a8780d))
+* **http2:** validate client stream IDs per RFC 9113 §5.1.1 ([0ceaad9](https://github.com/Leberkas-org/TurboHTTP/commit/0ceaad9e18d3cd8207e6e9bc212b9d70cacad4b9))
+* **http2:** wire client adaptive window scaling + RTT probes ([5cf1549](https://github.com/Leberkas-org/TurboHTTP/commit/5cf1549e709a04f337dd036f18b32c7dd16eae85))
+* **http3:** improve session manager logic ([d4eb2ac](https://github.com/Leberkas-org/TurboHTTP/commit/d4eb2ac7099d9255f9784a2e5abc33cba8846288))
+* **http3:** process inbound SETTINGS and reject duplicates ([4e73f7c](https://github.com/Leberkas-org/TurboHTTP/commit/4e73f7ce5d885d4cd34b742515a7f86afa85bd96))
+* **options:** Rename body size properties ([9467b52](https://github.com/Leberkas-org/TurboHTTP/commit/9467b527f405e81088a37c2307f4fe2d4c04590a))
+* **options:** Rename maxEndpointSubstreams to maxConcurrentEndpoints ([24b8c5e](https://github.com/Leberkas-org/TurboHTTP/commit/24b8c5ef4d8ecbbdaf42f89602a318736e2d89e6))
+* **security:** extend CVE-class protections to HTTP/3 + close HPACK ([322a53b](https://github.com/Leberkas-org/TurboHTTP/commit/322a53b5847f0477d664f38ba7658c02bb00d28e))
+* **server:** add actor-based FairShareCoordinator ([dc3d6c6](https://github.com/Leberkas-org/TurboHTTP/commit/dc3d6c68b430fff016fa5bf0b92820bd5096c761))
+* **server:** add ConnectionActor for per-connection lifecycle ([9ea7cb2](https://github.com/Leberkas-org/TurboHTTP/commit/9ea7cb26b8666e945a8648650dbe889283022d92))
+* **server:** add generic DynamicHub keyed fan-out stage ([fb9bb12](https://github.com/Leberkas-org/TurboHTTP/commit/fb9bb121188421c2f33f9668502dc679be4dcfea))
+* **server:** extract W3C trace context from inbound requests ([1c0124b](https://github.com/Leberkas-org/TurboHTTP/commit/1c0124baa3a8661acc4da5c64bd7ffc66067df90))
+* **server:** introduce ServerPipeline owning shared + per-connection flow ([cb81c9c](https://github.com/Leberkas-org/TurboHTTP/commit/cb81c9ce3b073f8a599c9bcde7322d37106dfd4f))
+* **server:** validate options on startup ([e532447](https://github.com/Leberkas-org/TurboHTTP/commit/e532447f3544b652efb277c28ff7db0c539e7f84))
+* **streams:** migrate DynamicHub tests and impl ([21c3c4b](https://github.com/Leberkas-org/TurboHTTP/commit/21c3c4b6f156c1c5e80cffb00be8cfe9ba3e79aa))
+
+
+### Bug Fixes
+
+* **client:** propagate handler exceptions, wire per-request timeout, enforce SameSite ([3bd9ddd](https://github.com/Leberkas-org/TurboHTTP/commit/3bd9ddd1609cfd86a50273ebb126800b13717763))
+* **client:** resolve typed clients via ActivatorUtilities instead of cast ([b815e42](https://github.com/Leberkas-org/TurboHTTP/commit/b815e4226ce8da8f4787db4c8eac2660fc1bc8d5))
+* **http2:** reject empty :path pseudo-header for non-CONNECT requests ([56876e3](https://github.com/Leberkas-org/TurboHTTP/commit/56876e35294738ef58204ff2bd3888ab974cb363))
+* **server:** close idle H2/H3 connections on keep-alive timeout ([86fae26](https://github.com/Leberkas-org/TurboHTTP/commit/86fae2685b3e48593720b06be27323f4b72db61c))
+* **server:** pull next pipelined response after an outbound body completes ([a78c352](https://github.com/Leberkas-org/TurboHTTP/commit/a78c352ca5738bf39010c5481c678d45981c0e59))
+
+
+### Documentation
+
+* update config docs ([b7b751f](https://github.com/Leberkas-org/TurboHTTP/commit/b7b751fd2a9ac102fda4e43a755b9a3d5d12bcff))
+
+
+### Refactoring
+
+* **client:** move H1.1 MaxPipelineDepth out of decoder options ([7e47256](https://github.com/Leberkas-org/TurboHTTP/commit/7e47256323f340c68253ded0e692c49005f27718))
+* **http2:** move RttEstimator ownership into FlowController ([db3e376](https://github.com/Leberkas-org/TurboHTTP/commit/db3e3761158789b7914d36919e650ef11fbf9f47))
+* **http2:** Simplify session manager constructor ([5bf8b84](https://github.com/Leberkas-org/TurboHTTP/commit/5bf8b848a26e58fba1fec45b6a675607ca5cce76))
+* rename instrumentation extensions ([28c8c07](https://github.com/Leberkas-org/TurboHTTP/commit/28c8c07f72c1470533c90ab09fce0537190d5d84))
+* replace local Servus.Akka with git submodule ([7bd8566](https://github.com/Leberkas-org/TurboHTTP/commit/7bd856673114389b81e3f70bd2932f5752ca514c))
+* **server:** FairShareAdmissionStage + ServerPipeline use actor-based coordinator ([67f875c](https://github.com/Leberkas-org/TurboHTTP/commit/67f875ce1b8caea96f375747f9f8472152b902ad))
+* **server:** migrate H1.0/H1.1 data-rate clock to TimeProvider ([8a9b5b0](https://github.com/Leberkas-org/TurboHTTP/commit/8a9b5b0a161d8d214f869f1f8823f385824a78d2))
+* **server:** move DynamicHub to shared Streams.Stages namespace ([6f97dc2](https://github.com/Leberkas-org/TurboHTTP/commit/6f97dc2d7e27619b056f19f81b311ad5098c058d))
+* **server:** rewrite ListenerActor to spawn ConnectionActor per connection ([638a946](https://github.com/Leberkas-org/TurboHTTP/commit/638a946a7112b87675bb0aebc31ce1605681ab7d))
+* **server:** wire ServerPipeline, remove ResponseDispatcherHub ([0667861](https://github.com/Leberkas-org/TurboHTTP/commit/0667861e6db11342b1b9bf8c6e91ac46e967dff3))
+* simplify constructor parameter passing ([86363a4](https://github.com/Leberkas-org/TurboHTTP/commit/86363a48a16ad26ce42ce4891f5ef880e2210fd0))
+* **transport:** inject TimeProvider into connection pool leases for deterministic eviction ([d878aa2](https://github.com/Leberkas-org/TurboHTTP/commit/d878aa23b9ed5f7f543e035f88a758b99f74f457))
+
+## [3.0.0-alpha](https://github.com/Leberkas-org/TurboHTTP/compare/v2.0.0...v3.0.0-alpha) (2026-05-31)
+
+
+### ⚠ BREAKING CHANGES
+
+* publish accumulated v3 work as alpha prereleases
+
+### Features
+
+* **ci:** Add release-next to CI triggers ([e1407f6](https://github.com/Leberkas-org/TurboHTTP/commit/e1407f63d52c189565009dcbbd2a1af40e7cf487))
+* publish accumulated v3 work as alpha prereleases ([e8b6e9a](https://github.com/Leberkas-org/TurboHTTP/commit/e8b6e9a205b7f23761e4681c4e5d3a05da94db1b))
+* **server:** connection-per-stage pipeline with fair-share dispatch ([c49104f](https://github.com/Leberkas-org/TurboHTTP/commit/c49104fa99950f8f50c10422f6aa97956e87f452))
+* **server:** data-rate monitoring and protocol server option resolution ([ad4d0b7](https://github.com/Leberkas-org/TurboHTTP/commit/ad4d0b74344830390b8561f6d9a2b1f6ea983907))
+* **server:** enforce four previously-unwired server options ([a9b581c](https://github.com/Leberkas-org/TurboHTTP/commit/a9b581c0e347bbfa5ffa746210daa4c34c429a78))
+* **server:** per-protocol connection options with resolved limit projections ([ea1eb2c](https://github.com/Leberkas-org/TurboHTTP/commit/ea1eb2ce30b67ddec940e3fb645f1df060a0ada4))
+* **servus:** add TransportBuffer.Wrap for zero-copy buffer handoff ([d52d0bf](https://github.com/Leberkas-org/TurboHTTP/commit/d52d0bffaff7c446a459e45e9dca4dda9627bf40))
+
+
+### Bug Fixes
+
+* **tests:** adjust maxParallelThreads to 0.5x ([611e5b3](https://github.com/Leberkas-org/TurboHTTP/commit/611e5b34bcc594ae702eed19d644491bbaa6e372))
+
+
+### Documentation
+
+* **architecture:** update engine and pipeline descriptions ([e5331e7](https://github.com/Leberkas-org/TurboHTTP/commit/e5331e7dc5f5d490db740761fed58d9c6f0da110))
+* **client:** correct namespaces, option defaults, and examples ([55cadd5](https://github.com/Leberkas-org/TurboHTTP/commit/55cadd5746e02af86add524d6f41e45596d14423))
+* **diagrams:** fix LikeC4 client pipeline order and component metadata ([3e3e6e2](https://github.com/Leberkas-org/TurboHTTP/commit/3e3e6e21c6b58202a7717b3fa080332a903bff30))
+* **server:** align option reference with code, fix stale architecture ([9043b06](https://github.com/Leberkas-org/TurboHTTP/commit/9043b06048a391ec927f5e558a1a53bbd60692ed))
+* **server:** reflect ASP.NET Core IServer architecture and new options ([7b7c233](https://github.com/Leberkas-org/TurboHTTP/commit/7b7c23347abfb51c97cb64664f9e7877dc8af9f5))
+* **site:** exclude internal docs from build, fix meta description, wire orphan pages ([1906807](https://github.com/Leberkas-org/TurboHTTP/commit/1906807ec376951456ba6045f16a730a15e42b96))
+
+
+### Refactoring
+
+* **client:** drop Validate from client option records ([ccf32c2](https://github.com/Leberkas-org/TurboHTTP/commit/ccf32c2df261ee6ea8a5afebe65f525712c5daf8))
+* **client:** flatten client protocol options and project via extensions ([b0c4e1f](https://github.com/Leberkas-org/TurboHTTP/commit/b0c4e1ff86e689d44fad72fb46a66ecc806f9461))
+* **codec:** bundle body encoder/decoder factory params into options records ([e75fce7](https://github.com/Leberkas-org/TurboHTTP/commit/e75fce7245cd210c68bb2a03b43761e83fe6ea56))
+* **codec:** project BodyDecoderOptions via ToBodyDecoderOptions extension ([d0bd68e](https://github.com/Leberkas-org/TurboHTTP/commit/d0bd68e9e43587ba0eca6606ea4d072be7161c80))
+* **protocol:** streamline body encoders/decoders and content classification ([a1a1a7e](https://github.com/Leberkas-org/TurboHTTP/commit/a1a1a7e44438ddff1f3cec43abc95b471926c96c))
+* **server:** project BodyEncoderOptions via ToBodyEncoderOptions extension ([af232d6](https://github.com/Leberkas-org/TurboHTTP/commit/af232d60b9e4a2d8a6e9a444ff4dd9371a850ce8))
+* **server:** remove unused form and header context abstractions ([22c84cc](https://github.com/Leberkas-org/TurboHTTP/commit/22c84ccc2c153537c7077a77fe92cc0aabf7e88c))
+* **servus:** convert backing fields to auto-properties across transport and IO stages ([9440aca](https://github.com/Leberkas-org/TurboHTTP/commit/9440acaee4f911b111a0ec89c8e18ef5113ec62f))
+
## [2.0.0](https://github.com/Leberkas-org/TurboHTTP/compare/v1.3.0...v2.0.0) (2026-05-28)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2f3f5b7f5..49d3cb28e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,78 @@ Features (TurboHTTP/Features/) - Cookies/, Caching/, AltSvc/
Diagnostics (TurboHTTP/Diagnostics/) - Metrics, tracing, logging
```
+## Debugging with Senf.Tracing
+
+All state machines and stage logic are permanently instrumented with `Servus.Senf.Tracing`. To debug issues, activate tracing in tests — no ad-hoc `Console.Error.WriteLine` needed.
+
+### Trace levels (lowest → highest)
+
+| Level | What it covers |
+|-------|---------------|
+| **Trace** | Per-packet data flow: body chunks (bytes + pending count), body pause/resume, timer ticks |
+| **Debug** | State transitions: timer scheduled/cancelled, request dispatched, body streaming start/complete, encoder pause/resume, stream opened/closed |
+| **Info** | Connection lifecycle: keep-alive timeout, request headers timeout, reconnect, GOAWAY, connection lost/restored |
+| **Warning** | Data rate violations, flow control violations, decode/encode failures |
+| **Error** | Fatal failures that abort the connection |
+
+### Activating tracing in unit tests
+
+```csharp
+using Servus.Diagnostics;
+using static Servus.Senf;
+
+// In test constructor or setup — write to xUnit output
+Tracing.Configure(new XunitTraceListener(output), TraceLevel.Trace);
+
+// Filter to specific category (Protocol, Stage, Handler, etc.)
+Tracing.Configure(listener, TraceLevel.Debug, category => category == "Protocol");
+```
+
+Minimal `IServusTraceListener` for xUnit:
+
+```csharp
+sealed class XunitTraceListener(ITestOutputHelper output) : IServusTraceListener
+{
+ public bool IsEnabled(TraceLevel level, string category) => true;
+ public void Write(in TraceEvent evt)
+ => output.WriteLine("[{0}][{1}] {2}#{3:X4}: {4}",
+ evt.Level, evt.Category, evt.SourceType, evt.SourceHash, evt.FormatMessage());
+}
+```
+
+### Activating in integration tests / hosting
+
+```csharp
+// Via DI — bridges to Microsoft.Extensions.Logging
+services.AddTurboLoggerTracing(TraceLevel.Trace);
+
+// Or with a custom listener
+services.AddTurboTracing(myListener, TraceLevel.Debug);
+```
+
+### Instrumented categories
+
+| Category | Components |
+|----------|-----------|
+| `Protocol` | All state machines (H10/H11/H2/H3, client + server), session managers, body encoders |
+| `Stage` | `HttpConnectionStageLogic` (client), `HttpConnectionServerStageLogic` (server) |
+| `Handler` | `HandlerBidiStage` (client request/response pipeline) |
+| `Request` | `StreamOwner`, `TracingBidiStage` (client request lifecycle) |
+| `Cache` | `CacheBidiStage` |
+| `Redirect` | `RedirectBidiStage` |
+| `Cookie` | `CookieBidiStage` |
+| `ContentEncoding` | `ContentEncodingBidiStage` |
+| `Expect100` | `ExpectContinueBidiStage` |
+| `Retry` | `RetryBidiStage` |
+| `AltSvc` | `AltSvcBidiStage` |
+
+### Debugging tips
+
+- **Body back-pressure issues**: Set `TraceLevel.Trace` + filter `"Protocol"` — shows every body chunk, pause/resume, and remainder buffer
+- **Timer bugs**: Set `TraceLevel.Debug` — shows all timer schedule/cancel/fire events with state
+- **Connection drops**: Set `TraceLevel.Info` — shows connection lost/restored, GOAWAY, keep-alive timeouts
+- **Never add `Console.Error.WriteLine`** for debugging — use the permanent tracing infrastructure instead
+
## Obsidian Vault (`notes/`)
Single source of truth for all non-code knowledge. **Use Obsidian MCP tools** (`search_notes`, `read_note`, `write_note`, `patch_note`) — never `Read`/`Write`/`Edit` on `notes/` files.
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 64ae7a0d3..7e3cc2eb5 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -8,8 +8,9 @@ export default defineConfig({
],
},
title: 'TurboHTTP',
- description: 'High-performance HTTP client and server for .NET built on Akka.Streams — HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 (QUIC) with automatic retries, caching, cookies, connection pooling, middleware pipeline, routing, and entity gateway.',
+ description: 'High-performance HTTP client and server for .NET built on Akka.Streams — HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 (QUIC). The client adds automatic retries, caching, cookies, and connection pooling; the server is a drop-in ASP.NET Core IServer (a Kestrel replacement).',
base: '/',
+ srcExclude: ['superpowers/**', '**/CLAUDE.md'],
head: [
['link', { rel: 'icon', type: 'image/png', href: '/logo/icon.png' }],
],
@@ -24,7 +25,7 @@ export default defineConfig({
{ text: 'Scenarios', link: '/scenarios' },
{ text: 'Client', link: '/client/' },
{ text: 'Server', link: '/server/' },
- { text: 'Architecture', link: '/architecture/pipeline' },
+ { text: 'Architecture', link: '/architecture/' },
{ text: 'API', link: '/api/' },
],
@@ -96,10 +97,19 @@ export default defineConfig({
},
],
'/architecture/': [
+ {
+ text: 'Architecture',
+ items: [
+ { text: 'Overview', link: '/architecture/' },
+ { text: 'Layers', link: '/architecture/layers' },
+ { text: 'Scenarios', link: '/architecture/scenarios' },
+ ],
+ },
{
text: 'Client Architecture',
items: [
{ text: 'Request Pipeline', link: '/architecture/pipeline' },
+ { text: 'Handlers', link: '/architecture/handlers' },
{ text: 'Protocol Engines', link: '/architecture/engines' },
],
},
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
deleted file mode 100644
index 6002c50bb..000000000
--- a/docs/CLAUDE.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# CLAUDE.md — Documentation Site
-
-This file guides Claude Code when working on files inside `docs/`.
-
-## Audience
-
-Every page targets **library users** — .NET developers who use TurboHTTP in their applications and want to understand how it works. The reader is NOT a protocol implementor, RFC editor, or contributor to this library's internals.
-
-## Content Rules
-
-### No RFC References
-
-- Never cite RFC numbers, section numbers, or specification language (e.g. "RFC 9110 §15.4", "per RFC 9112")
-- Describe **behaviour** instead: "TurboHTTP follows redirects automatically" not "implements RFC 9110 §15.4 redirect semantics"
-- If a feature exists because of a spec requirement, explain the *user-visible effect*, not the spec clause
-
-### No 1:1 Implementation Mapping
-
-- LikeC4 diagrams show **conceptual architecture** — layers, data flow, key components
-- Do not add every internal class, stage, or actor to diagrams; keep the current abstraction level
-- When adding new components to diagrams, ask: "Does a user need to know this exists?" — if not, leave it out
-
-### Tone and Style
-
-- Practical, example-driven — lead with code snippets and "what this does for you"
-- Explain *what* stages and layers do, not *which spec section* they implement
-- Use plain language: "keeps connections alive" not "evaluates connection persistence per §9.3"
-- Tables for comparison (methods, status codes, options), callout boxes (`::: tip`, `::: warning`) for emphasis
-- Keep headings scannable: H1 = page title, H2 = major sections, H3 = subsections
-
-### Architecture Pages
-
-- Stage names (e.g. `CookieBidiStage`, `RetryBidiStage`) are fine — they help users understand the pipeline
-- Describe stages by **what they do for the user**: "injects cookies into outgoing requests" not "implements RFC 6265 domain matching"
-- Actor and transport details are OK at the current level — don't go deeper into mailbox internals or byte-level framing
-
-### Guide Pages
-
-- Focus on: installation, configuration, usage patterns, code examples, troubleshooting
-- Every feature page should answer: "How do I use this?" and "What happens automatically?"
-- Warnings and tips should address practical concerns ("POST is never retried") not spec rationale
-
-## Build Commands
-
-See root `CLAUDE.md` for VitePress dev server, build, and preview commands.
diff --git a/docs/api/client-options.md b/docs/api/client-options.md
index df82a184b..e69b9132c 100644
--- a/docs/api/client-options.md
+++ b/docs/api/client-options.md
@@ -7,19 +7,19 @@ public sealed class TurboClientOptions
public Uri? BaseAddress { get; set; }
// Version-specific options (nested)
- public Http1Options Http1 { get; init; } = new(); // HTTP/1.x settings
- public Http2Options Http2 { get; init; } = new(); // HTTP/2 settings
- public Http3Options Http3 { get; init; } = new(); // HTTP/3 settings
+ public Http1ClientOptions Http1 { get; init; } = new(); // HTTP/1.x settings
+ public Http2ClientOptions Http2 { get; init; } = new(); // HTTP/2 settings
+ public Http3ClientOptions Http3 { get; init; } = new(); // HTTP/3 settings
- // Body buffering
- public long MaxBufferedBodySize { get; set; } = 4 * 1024 * 1024; // 4 MiB
- public long? MaxStreamedBodySize { get; set; } // unlimited
+ // Body buffering (response buffering threshold lives on Http1.MaxBufferedResponseBodySize)
+ public long? MaxStreamedResponseBodySize { get; set; } // null = unlimited; cap on a streamed response body
+ public int RequestBodyChunkSize { get; set; } = 16 * 1024; // 16 KB; chunk size when streaming a request body
// Connection pool
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(15);
public TimeSpan PooledConnectionIdleTimeout { get; set; } = TimeSpan.FromSeconds(90);
public TimeSpan PooledConnectionLifetime { get; set; } = Timeout.InfiniteTimeSpan;
- public uint MaxEndpointSubstreams { get; set; } = 256;
+ public uint MaxConcurrentEndpoints { get; set; } = 256;
// TLS
public bool DangerousAcceptAnyServerCertificate { get; set; }
@@ -27,9 +27,11 @@ public sealed class TurboClientOptions
public X509CertificateCollection? ClientCertificates { get; set; }
public SslProtocols EnabledSslProtocols { get; set; } = SslProtocols.None;
- // Socket options
+ // Socket and buffer options
public int? SocketSendBufferSize { get; set; }
public int? SocketReceiveBufferSize { get; set; }
+ public int ReceiveBufferHint { get; set; } = 64 * 1024; // 64 KB; internal receive buffer size hint
+ public int MinimumSegmentSize { get; set; } = 16 * 1024; // 16 KB; minimum segment size of the internal buffer pool
// Proxy
public bool UseProxy { get; set; } = true;
@@ -50,7 +52,7 @@ public sealed class TurboClientOptions
| `ConnectTimeout` | `15 s` | TCP/QUIC connection timeout |
| `PooledConnectionIdleTimeout` | `90 s` | How long idle connections are kept in the pool |
| `PooledConnectionLifetime` | `infinite` | Maximum lifetime of a pooled connection |
-| `MaxEndpointSubstreams` | `256` | Max concurrently active endpoint substreams |
+| `MaxConcurrentEndpoints` | `256` | Max concurrently active endpoints |
Per-version connection limits are configured on the nested options objects:
@@ -67,38 +69,53 @@ See [Connection Pooling guide](/client/connection-pooling) for pool lifecycle de
## HTTP/1.x Options
```csharp
-public sealed class Http1Options
+public sealed class Http1ClientOptions
{
+ public int MaxBufferedResponseBodySize { get; set; } = 64 * 1024; // 64 KB; bodies up to this size are buffered in memory, larger are streamed
public int MaxConnectionsPerServer { get; set; } = 6;
public int MaxPipelineDepth { get; set; } = 16;
- public int MaxResponseHeadersLength { get; set; } = 64; // KB
+ public int MaxResponseHeadersLength { get; set; } = 64; // KB
public bool AutoHost { get; set; } = true;
public bool AutoAcceptEncoding { get; set; } = true;
public int MaxReconnectAttempts { get; set; } = 3;
+ public int MaxResponseHeaderCount { get; set; } = 100; // max number of response header fields
+ public int MaxResponseHeaderLineLength { get; set; } = 8 * 1024; // 8 KB; max length of a single header line
+ public int MaxChunkExtensionLength { get; set; } = int.MaxValue; // max total length of chunk extensions; unbounded by default
}
```
| Property | Default | Description |
|----------|---------|-------------|
+| `MaxBufferedResponseBodySize` | `64 * 1024` (64 KB) | Response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe |
| `MaxConnectionsPerServer` | `6` | Max concurrent TCP connections per host |
| `MaxPipelineDepth` | `16` | Max pipelined requests per connection |
-| `MaxResponseHeadersLength` | `64` (KB) | Max response header size |
+| `MaxResponseHeadersLength` | `64` (KB) | Max total response header block size |
| `AutoHost` | `true` | Automatically inject `Host` header |
| `AutoAcceptEncoding` | `true` | Automatically inject `Accept-Encoding` header |
| `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop |
+| `MaxResponseHeaderCount` | `100` | Max number of response header fields |
+| `MaxResponseHeaderLineLength` | `8 * 1024` (8 KB) | Max length of a single response header line |
+| `MaxChunkExtensionLength` | `int.MaxValue` | Max total length of chunk extensions; unbounded by default |
## HTTP/2 Options
```csharp
-public sealed class Http2Options
+public sealed class Http2ClientOptions
{
public int MaxConnectionsPerServer { get; set; } = 6;
public int MaxConcurrentStreams { get; set; } = 100;
public int InitialConnectionWindowSize { get; set; } = 64 * 1024 * 1024; // 64 MB
- public int InitialStreamWindowSize { get; set; } = 2 * 1024 * 1024; // 2 MB
+ public int InitialStreamWindowSize { get; set; } = 1 * 1024 * 1024; // 1 MB
+ public int MaxStreamWindowSize { get; set; } = 16 * 1024 * 1024; // 16 MB
+ public double WindowScaleThresholdMultiplier { get; set; } = 1.0;
+ public bool EnableAdaptiveWindowScaling { get; set; } = true;
public int MaxFrameSize { get; set; } = 64 * 1024; // 64 KB
public int HeaderTableSize { get; set; } = 64 * 1024; // 64 KB
+ public int MaxResponseHeaderListSize { get; set; } = 64 * 1024; // 64 KB; max total size of response header list
+ public long MaxBufferedRequestBodySize { get; set; } = 64 * 1024; // 64 KB; bodies up to this size are serialized inline, larger are streamed
+ public long MaxRequestBodyBufferSize { get; set; } = 64 * 1024; // 64 KB; outbound body bytes buffered per stream before the encoder pauses
public int MaxReconnectAttempts { get; set; } = 3;
+ public int MaxReconnectBufferSize { get; set; } = 64; // max requests buffered during reconnection
public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan;
public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20);
public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always;
@@ -110,10 +127,17 @@ public sealed class Http2Options
| `MaxConnectionsPerServer` | `6` | Max concurrent TCP connections per host |
| `MaxConcurrentStreams` | `100` | Max concurrent streams per connection |
| `InitialConnectionWindowSize` | `64 * 1024 * 1024` (64 MB) | Connection-level flow control window |
-| `InitialStreamWindowSize` | `2 * 1024 * 1024` (2 MB) | Per-stream flow control window |
+| `InitialStreamWindowSize` | `1 * 1024 * 1024` (1 MB) | Initial per-stream flow control window; grows up to `MaxStreamWindowSize` under adaptive scaling |
+| `MaxStreamWindowSize` | `16 * 1024 * 1024` (16 MB) | Maximum per-stream flow control window |
+| `WindowScaleThresholdMultiplier` | `1.0` | RTT multiplier controlling when to scale the stream window |
+| `EnableAdaptiveWindowScaling` | `true` | Grow the stream receive window based on observed throughput |
| `MaxFrameSize` | `64 * 1024` (64 KB) | Max frame payload size |
| `HeaderTableSize` | `64 * 1024` (64 KB) | HPACK dynamic table size |
+| `MaxResponseHeaderListSize` | `64 * 1024` (64 KB) | Max total size of the response header list |
+| `MaxBufferedRequestBodySize` | `64 * 1024` (64 KB) | Request bodies up to this size are serialized inline; larger bodies are streamed in chunks with backpressure |
+| `MaxRequestBodyBufferSize` | `64 * 1024` (64 KB) | Max outbound body bytes buffered per stream before the body encoder pauses |
| `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop |
+| `MaxReconnectBufferSize` | `64` | Max requests buffered during reconnection |
| `KeepAlivePingDelay` | `infinite` | Delay before sending keep-alive PING |
| `KeepAlivePingTimeout` | `20 s` | Timeout for PING acknowledgment |
| `KeepAlivePingPolicy` | `Always` | When to send keep-alive PINGs |
@@ -130,7 +154,7 @@ See [HTTP/2 & Multiplexing guide](/client/http2) for multiplexing configuration.
## HTTP/3 Options
```csharp
-public sealed class Http3Options
+public sealed class Http3ClientOptions
{
public int MaxConnectionsPerServer { get; set; } = 4;
public int MaxConcurrentStreams { get; set; } = 100;
@@ -139,7 +163,6 @@ public sealed class Http3Options
public int MaxFieldSectionSize { get; set; } = 64 * 1024; // 64 KB
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30);
public int MaxReconnectAttempts { get; set; } = 3;
- public bool AllowConnectionMigration { get; set; } = true;
public bool EnableAltSvcDiscovery { get; set; }
public int MaxReconnectBufferSize { get; set; } = 64;
}
@@ -154,7 +177,6 @@ public sealed class Http3Options
| `MaxFieldSectionSize` | `64 * 1024` (64 KB) | Max header block size |
| `IdleTimeout` | `30 s` | QUIC idle timeout |
| `MaxReconnectAttempts` | `3` | Max reconnect attempts on connection drop |
-| `AllowConnectionMigration` | `true` | Allow QUIC connection migration |
| `EnableAltSvcDiscovery` | `false` | Auto-discover HTTP/3 via Alt-Svc headers |
| `MaxReconnectBufferSize` | `64` | Max datagram buffers during reconnection |
@@ -183,6 +205,8 @@ options.ClientCertificates = new X509CertificateCollection
|----------|---------|-------------|
| `SocketSendBufferSize` | `null` (system default) | OS socket send buffer size in bytes |
| `SocketReceiveBufferSize` | `null` (system default) | OS socket receive buffer size in bytes |
+| `ReceiveBufferHint` | `64 * 1024` (64 KB) | Size hint for the internal receive buffer; larger values reduce read syscalls at the cost of memory |
+| `MinimumSegmentSize` | `16 * 1024` (16 KB) | Minimum segment size of the internal buffer pool |
## Proxy Options
@@ -192,6 +216,12 @@ options.ClientCertificates = new X509CertificateCollection
| `Proxy` | `null` | Custom proxy URI |
| `DefaultProxyCredentials` | `null` | Credentials for proxy authentication |
+Plain HTTP requests are relayed through the proxy directly; HTTPS requests are tunneled via `CONNECT` (with `Proxy-Authorization: Basic` when credentials are configured), and TLS is negotiated with the target through the tunnel.
+
+::: info HTTP/3 and proxies
+QUIC cannot traverse an HTTP proxy. When a proxy applies to a request, HTTP/3 requests are downgraded to HTTP/2 (when the `VersionPolicy` is `RequestVersionOrLower`) or fail with `HttpRequestException` (`RequestVersionExact` / `RequestVersionOrHigher`). Alt-Svc HTTP/3 upgrades are also skipped for proxied hosts. Hosts matched by the proxy's bypass list are unaffected.
+:::
+
## Authentication Options
| Property | Default | Description |
@@ -203,9 +233,10 @@ options.ClientCertificates = new X509CertificateCollection
| Property | Default | Description |
|----------|---------|-------------|
-| `MaxBufferedBodySize` | `4 * 1024 * 1024` (4 MiB) | Max response body size before buffering fails |
-| `MaxStreamedBodySize` | `null` (unlimited) | Max body size for streamed (unbuffered) consumption |
+| `Http1.MaxBufferedResponseBodySize` | `64 * 1024` (64 KB) | HTTP/1.x response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe |
+| `MaxStreamedResponseBodySize` | `null` (unlimited) | Cap on a streamed response body; `null` means no limit |
+| `RequestBodyChunkSize` | `16 * 1024` (16 KB) | Chunk size used when streaming a request body |
::: tip
-For large file downloads or uploads, use `MaxStreamedBodySize` to handle bodies larger than `MaxBufferedBodySize` without buffering the entire response in memory.
+For large file downloads or uploads, consume the response as a stream. `MaxStreamedResponseBodySize` defaults to `null` — there is no built-in size cap on streamed responses.
:::
diff --git a/docs/api/client.md b/docs/api/client.md
index 404c17634..f6f70dcd8 100644
--- a/docs/api/client.md
+++ b/docs/api/client.md
@@ -85,7 +85,7 @@ See [HTTP/2 & Multiplexing guide](/client/http2) for multiplexing details.
### Timeout
-Per-request timeout applied by `SendAsync`. Defaults to 60 seconds. Does not affect the channel-based API:
+Per-request timeout. Defaults to 60 seconds. `SendAsync` enforces it directly; requests submitted via the channel-based API get the same timeout injected as a default when no cancellation token is set on the request:
```csharp
client.Timeout = TimeSpan.FromSeconds(30);
@@ -114,7 +114,7 @@ Requests are matched to responses in submission order (HTTP/1.x) or by stream ID
### CancelPendingRequests
-Cancels all in-flight `SendAsync` calls and clears the pending request map. Does not affect the channel-based API:
+Cancels all in-flight `SendAsync` calls, clears the pending request map, and drains (disposes) any responses already buffered in the `Responses` channel:
```csharp
// Cancel everything in-flight (e.g., on application shutdown)
diff --git a/docs/api/feature-options.md b/docs/api/feature-options.md
index ea830e431..b75c683e5 100644
--- a/docs/api/feature-options.md
+++ b/docs/api/feature-options.md
@@ -36,7 +36,8 @@ See [Automatic Retries guide](/client/retries) for which methods and status code
public sealed class CacheOptions
{
public int MaxEntries { get; set; } = 1000;
- public long MaxBodyBytes { get; set; } = 52_428_800; // 50 MiB
+ public long MaxBodySize { get; set; } = 50 * 1024 * 1024; // 50 MiB
+ public long MaxTotalSize { get; set; } = 256 * 1024 * 1024; // 256 MiB
public bool SharedCache { get; set; }
}
```
@@ -44,7 +45,8 @@ public sealed class CacheOptions
| Property | Default | Description |
|----------|---------|-------------|
| `MaxEntries` | `1000` | Max number of responses in the cache |
-| `MaxBodyBytes` | `52_428_800` (50 MiB) | Max total size of cached response bodies |
+| `MaxBodySize` | `50 * 1024 * 1024` (50 MiB) | Max body size of a single stored response; larger responses are not cached |
+| `MaxTotalSize` | `256 * 1024 * 1024` (256 MiB) | Max total size of all cached response bodies combined; least-recently-used entries are evicted when exceeded |
| `SharedCache` | `false` | Whether this is a shared cache (affecting `Cache-Control` directives) |
```csharp
@@ -53,10 +55,10 @@ builder.Services.AddTurboHttpClient("api", ...).WithCache();
// Smaller cache for constrained environments
builder.Services.AddTurboHttpClient("api", ...)
- .WithCache(c => { c.MaxEntries = 100; c.MaxBodyBytes = 5 * 1024 * 1024; });
+ .WithCache(c => { c.MaxEntries = 100; c.MaxBodySize = 5 * 1024 * 1024; });
// Custom store shared across clients
-var sharedStore = new CacheStore();
+var sharedStore = new MyCustomCacheStore(); // implement ICacheStore
builder.Services.AddTurboHttpClient("api", ...).WithCache(sharedStore);
```
@@ -103,19 +105,19 @@ See [Redirects guide](/client/redirects) for method rewriting and security detai
public sealed class CompressionOptions
{
public string Encoding { get; set; } = "gzip";
- public long MinBodySizeBytes { get; set; } = 1024;
+ public long MinBodySize { get; set; } = 1024;
}
```
| Property | Default | Description |
|----------|---------|-------------|
| `Encoding` | `"gzip"` | Compression algorithm ("gzip", "br", "deflate") |
-| `MinBodySizeBytes` | `1024` | Don't compress bodies smaller than this |
+| `MinBodySize` | `1024` | Don't compress bodies smaller than this |
```csharp
// Request compression with Brotli for large bodies
builder.Services.AddTurboHttpClient("api", ...)
- .WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySizeBytes = 4096; });
+ .WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySize = 4096; });
// Response decompression (automatic, no configuration needed)
builder.Services.AddTurboHttpClient("api", ...).WithDecompression(enabled: true);
@@ -130,18 +132,18 @@ See [Content Encoding guide](/client/content-encoding) for request compression a
```csharp
public sealed class Expect100Options
{
- public long MinBodySizeBytes { get; set; } = 1024;
+ public long MinBodySize { get; set; } = 1024;
}
```
| Property | Default | Description |
|----------|---------|-------------|
-| `MinBodySizeBytes` | `1024` | Only use Expect: 100-continue for bodies >= this size |
+| `MinBodySize` | `1024` | Only use Expect: 100-continue for bodies >= this size |
```csharp
// Enable 100-continue for bodies > 8 KiB
builder.Services.AddTurboHttpClient("api", ...)
- .WithExpectContinue(e => { e.MinBodySizeBytes = 8 * 1024; });
+ .WithExpectContinue(e => { e.MinBodySize = 8 * 1024; });
```
See [Content Encoding guide](/client/content-encoding) for Expect: 100-continue details.
@@ -245,8 +247,8 @@ These types are part of the public API and can be customized:
| Type | Purpose | Guide |
|------|---------|-------|
-| `CookieJar` | Cookie storage and injection — provided via `.WithCookies()` | [Cookies](/client/cookies) |
-| `CacheStore` | In-memory LRU cache backend — provided via `.WithCache(store)` | [Caching](/client/caching) |
-| `TurboHandler` | Custom request/response middleware — registered via `.AddHandler()` | [Configuration](/client/configuration) |
+| `ICookieStore` | Cookie storage and injection — implement and pass to `.WithCookies(store)` | [Cookies](/client/cookies) |
+| `ICacheStore` | Cache backend — implement and pass to `.WithCache(store)` | [Caching](/client/caching) |
+| `TurboHandler` | Custom request/response middleware — register via `.AddHandler()` | [Configuration](/client/configuration) |
See [Configuration guide](/client/configuration) for integration patterns.
diff --git a/docs/api/index.md b/docs/api/index.md
index 756a134be..1e73431fa 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -9,7 +9,7 @@ TurboHTTP's public API is organized into client, server, and feature configurati
| `ITurboHttpClientFactory` | Creates named client instances | [Client API](./client) |
| `ITurboHttpClient` | The HTTP client — `SendAsync` and channel-based API | [Client API](./client) |
| `TurboClientOptions` | Connection, TLS, proxy, and protocol settings | [Client Options](./client-options) |
-| `Http1Options` / `Http2Options` / `Http3Options` | Per-protocol tuning | [Client Options](./client-options) |
+| `Http1ClientOptions` / `Http2ClientOptions` / `Http3ClientOptions` | Per-protocol tuning | [Client Options](./client-options) |
| `RetryOptions` / `CacheOptions` / `RedirectOptions` | Feature configuration | [Feature Options](./feature-options) |
| Builder extensions (`.WithRetry()`, `.WithCache()`, etc.) | Fluent feature composition | [Feature Options](./feature-options) |
diff --git a/docs/api/server.md b/docs/api/server.md
index 42ed24194..c2219f067 100644
--- a/docs/api/server.md
+++ b/docs/api/server.md
@@ -60,9 +60,10 @@ public sealed class TurboServerOptions
TimeSpan HandlerTimeout { get; set; } // default: 30s
TimeSpan HandlerGracePeriod { get; set; } // default: 5s
- int BodyBufferThreshold { get; set; } // default: 64 * 1024
TimeSpan BodyConsumptionTimeout { get; set; } // default: 30s
int ResponseBodyChunkSize { get; set; } // default: 16 * 1024
+ int MaxOutboundCoalesceCount { get; set; } // default: 32 (frames merged up to factor × 16 KiB per transport write)
+ bool AllowResponseHeaderCompression { get; set; } // default: true (disable to mitigate CRIME/BREACH-style attacks)
Http1ServerOptions Http1 { get; }
Http2ServerOptions Http2 { get; }
@@ -76,11 +77,15 @@ public sealed class TurboServerOptions
void ListenLocalhost(ushort port, Action configure);
void ListenAnyIP(ushort port);
void ListenAnyIP(ushort port, Action configure);
+ void BindTcp(string host, ushort port);
void Bind(TcpListenerOptions options);
void Bind(QuicListenerOptions options);
void Bind(ListenerOptions options, IListenerFactory factory);
void ConfigureHttpsDefaults(Action configure);
void ConfigureEndpointDefaults(Action configure);
+
+ IList Endpoints { get; } // read-only, populated by Bind() overloads only
+ IList Urls { get; } // read-only, resolved to bindings at startup (add strings manually or via hosting configuration)
}
```
@@ -92,15 +97,17 @@ public sealed class TurboServerOptions
public sealed class TurboServerLimits
{
int MaxConcurrentConnections { get; set; } // default: 0 (unlimited)
- int MaxConcurrentUpgradedConnections { get; set; } // default: 0 (unlimited)
- long MaxRequestBodySize { get; set; } // default: 30 * 1024 * 1024
+ long MaxRequestBodySize { get; set; } // default: 30,000,000 (~28.6 MiB, matching Kestrel)
int MaxRequestHeaderCount { get; set; } // default: 100
int MaxRequestHeadersTotalSize { get; set; } // default: 32 * 1024
+ long MaxResponseBufferSize { get; set; } // default: 64 * 1024 (per-stream response write buffer)
+ long? MaxRequestBufferSize { get; set; } // default: 1 MiB (transport input buffer before backpressure; null = unlimited)
+ int MaxResetStreamsPerWindow { get; set; } // default: 200 (HTTP/2 Rapid Reset / CVE-2023-44487 mitigation; 0 = disabled)
TimeSpan KeepAliveTimeout { get; set; } // default: 130s
TimeSpan RequestHeadersTimeout { get; set; } // default: 30s
- double MinRequestBodyDataRate { get; set; } // default: 0
+ double MinRequestBodyDataRate { get; set; } // default: 240
TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s
- double MinResponseDataRate { get; set; } // default: 0
+ double MinResponseDataRate { get; set; } // default: 240
TimeSpan MinResponseDataRateGracePeriod { get; set; } // default: 5s
}
```
@@ -115,6 +122,7 @@ public sealed class TurboListenOptions(IPAddress address, ushort port)
IPAddress Address { get; }
ushort Port { get; }
HttpProtocols Protocols { get; set; } // default: Http1AndHttp2
+ TransportBufferOptions? Transport { get; set; } // default: null (protocol-optimized defaults)
void UseHttps();
void UseHttps(X509Certificate2 certificate);
@@ -129,6 +137,45 @@ public sealed class TurboListenOptions(IPAddress address, ushort port)
---
+## Transport Buffer Options
+
+Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `TurboListenOptions.Transport`. Every property is nullable — properties left at `null` fall back to the protocol-optimized default individually, so you only need to set the thresholds you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`.
+
+```csharp
+public sealed class TransportBufferOptions
+{
+ long? InputPauseThreshold { get; set; } // bytes buffered on the read pipe before the OS socket is paused
+ long? InputResumeThreshold { get; set; } // buffered byte count at which reading resumes (must be <= pause threshold)
+ long? OutputPauseThreshold { get; set; } // bytes buffered on the write pipe before the HTTP pipeline is paused
+ long? OutputResumeThreshold { get; set; } // must be <= OutputPauseThreshold
+ int? MinimumSegmentSize { get; set; } // minimum pipe buffer segment size
+}
+```
+
+Protocol-specific defaults applied for `null` properties (and when `Transport` itself is `null`):
+
+| Property | TCP (one pipe per connection) | QUIC (one pipe per stream) |
+|----------|------------------------------|----------------------------|
+| `InputPauseThreshold` | 1 MiB | 64 KiB |
+| `InputResumeThreshold` | 512 KiB | 32 KiB |
+| `OutputPauseThreshold` | 64 KiB | 64 KiB |
+| `OutputResumeThreshold` | 32 KiB | 32 KiB |
+| `MinimumSegmentSize` | 16 KiB | 4 KiB |
+
+```csharp
+options.Listen(IPAddress.Any, 8080, listen =>
+{
+ // Only the input thresholds are overridden; everything else keeps the TCP defaults
+ listen.Transport = new TransportBufferOptions
+ {
+ InputPauseThreshold = 2 * 1024 * 1024,
+ InputResumeThreshold = 1024 * 1024
+ };
+});
+```
+
+---
+
## HTTPS Options
```csharp
@@ -168,15 +215,20 @@ public enum HttpProtocols
```csharp
public sealed class Http1ServerOptions
{
- int MaxRequestLineLength { get; set; } // default: 8192
- int MaxRequestTargetLength { get; set; } // default: 8192
+ int MaxRequestLineLength { get; set; } // default: 8 * 1024
+ int MaxRequestTargetLength { get; set; } // default: 8 * 1024
int MaxPipelinedRequests { get; set; } // default: 16
- int MaxChunkExtensionLength { get; set; } // default: 4096
+ int MaxChunkExtensionLength { get; set; } // default: 4 * 1024
+ int MaxBufferedRequestBodySize { get; set; } // default: 64 * 1024 (bodies up to this size buffered in memory, larger streamed)
TimeSpan BodyReadTimeout { get; set; } // default: 30s
- long MaxRequestBodySize { get; set; } // default: 30_000_000
- int MaxHeaderListSize { get; set; } // default: 32 * 1024
- TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses global)
- TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses global)
+ int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize)
+ long? MaxRequestBodySize { get; set; } // default: null (uses Limits)
+ TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits)
+ TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits)
+ double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits)
+ double? MinResponseDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits)
}
```
@@ -190,15 +242,22 @@ public sealed class Http2ServerOptions
int MaxConcurrentStreams { get; set; } // default: 100
int InitialConnectionWindowSize { get; set; } // default: 1 * 1024 * 1024
int InitialStreamWindowSize { get; set; } // default: 768 * 1024
+ int MaxStreamWindowSize { get; set; } // default: 8 * 1024 * 1024 (adaptive scaling upper bound)
+ double WindowScaleThresholdMultiplier { get; set; } // default: 1.0
+ bool EnableAdaptiveWindowScaling { get; set; } // default: true (BDP-based receive-window growth)
int MaxFrameSize { get; set; } // default: 16 * 1024
- int MaxHeaderListSize { get; set; } // default: 32 * 1024
int HeaderTableSize { get; set; } // default: 4 * 1024
- long MaxRequestBodySize { get; set; } // default: 30_000_000
- long MaxResponseBufferSize { get; set; } // default: 64 * 1024
- TimeSpan KeepAliveTimeout { get; set; } // default: 130s
- TimeSpan RequestHeadersTimeout { get; set; } // default: 30s
- int MinRequestBodyDataRate { get; set; } // default: 240
- TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s
+ int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize)
+ long? MaxResponseBufferSize { get; set; } // default: null (uses Limits.MaxResponseBufferSize)
+ TimeSpan KeepAlivePingDelay { get; set; } // default: infinite (server-initiated keep-alive PINGs disabled)
+ TimeSpan KeepAlivePingTimeout { get; set; } // default: 20s (max wait for PING ACK before closing)
+ long? MaxRequestBodySize { get; set; } // default: null (uses Limits)
+ TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits)
+ TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits)
+ double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits)
+ double? MinResponseDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits)
}
```
@@ -210,13 +269,16 @@ public sealed class Http2ServerOptions
public sealed class Http3ServerOptions
{
int MaxConcurrentStreams { get; set; } // default: 100
- int MaxHeaderListSize { get; set; } // default: 32 * 1024
- int QpackMaxTableCapacity { get; set; } // default: 0
- bool EnableWebTransport { get; set; } // default: false
- long MaxRequestBodySize { get; set; } // default: 30_000_000
- TimeSpan KeepAliveTimeout { get; set; } // default: 130s
- TimeSpan RequestHeadersTimeout { get; set; } // default: 30s
- int MinRequestBodyDataRate { get; set; } // default: 240
- TimeSpan MinRequestBodyDataRateGracePeriod { get; set; } // default: 5s
+ int? MaxHeaderListSize { get; set; } // default: null (uses Limits.MaxRequestHeadersTotalSize)
+ int QpackMaxTableCapacity { get; set; } // default: 0
+ int QpackBlockedStreams { get; set; } // default: 100
+ long? MaxResponseBufferSize { get; set; } // default: null (uses Limits.MaxResponseBufferSize)
+ long? MaxRequestBodySize { get; set; } // default: null (uses Limits)
+ TimeSpan? KeepAliveTimeout { get; set; } // default: null (uses Limits)
+ TimeSpan? RequestHeadersTimeout { get; set; } // default: null (uses Limits)
+ double? MinRequestBodyDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinRequestBodyDataRateGracePeriod { get; set; } // default: null (uses Limits)
+ double? MinResponseDataRate { get; set; } // default: null (uses Limits)
+ TimeSpan? MinResponseDataRateGracePeriod { get; set; } // default: null (uses Limits)
}
```
diff --git a/docs/architecture/engines.md b/docs/architecture/engines.md
index 9991938b8..f3516be1f 100644
--- a/docs/architecture/engines.md
+++ b/docs/architecture/engines.md
@@ -21,7 +21,7 @@ TCP → [TcpConnectionStage] → Http10ClientConnectionStage → HttpResponseMes
| Component | Role |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `Http10ClientConnectionStage` | Unified stage: serialises request to wire bytes (sets `Connection: close`), parses the HTTP/1.0 response, and correlates request/response (FIFO, depth 1) |
+| `Http10ClientConnectionStage` | Unified stage: serialises request to wire bytes, parses the HTTP/1.0 response, and correlates request/response (FIFO, depth 1) |
| `TcpConnectionStage` | TCP transport (from Servus.Akka) — acquires a connection lease from the manager actor, reads/writes bytes |
**Notable behaviours:**
@@ -82,8 +82,8 @@ TCP → [TcpConnectionStage] → Http20ClientConnectionStage → HttpResponseMes
| Component | Role |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `Http20ClientConnectionStage` | Central unified stage: allocates client stream IDs (1, 3, 5, …), HPACK-encodes request headers and emits `HEADERS` + `DATA` frames, handles frame encoding/decoding (9-byte frame header + payload), manages connection-level frames (`SETTINGS`, `PING`, `WINDOW_UPDATE`, `GOAWAY`), tracks connection and stream-level flow control windows, assembles per-stream `HEADERS` + `DATA` frames into `HttpResponseMessage`, and correlates responses by stream ID |
-| `TcpConnectionStage` | TCP transport (from Servus.Akka) — emits the HTTP/2 connection preface on first connect |
+| `Http20ClientConnectionStage` | Central unified stage: emits the HTTP/2 connection preface (magic + SETTINGS [+ WINDOW_UPDATE]) on first connect via `Http2ClientSessionManager`, allocates client stream IDs (1, 3, 5, …), HPACK-encodes request headers and emits `HEADERS` + `DATA` frames, handles frame encoding/decoding (9-byte frame header + payload), manages connection-level frames (`SETTINGS`, `PING`, `WINDOW_UPDATE`, `GOAWAY`), tracks connection and stream-level flow control windows, assembles per-stream `HEADERS` + `DATA` frames into `HttpResponseMessage`, and correlates responses by stream ID |
+| `TcpConnectionStage` | TCP transport (from Servus.Akka) — reads and writes raw bytes over the TCP connection |
**HPACK header compression:**
@@ -136,7 +136,7 @@ When a connection arrives at TurboHTTP Server, the server mirrors the client arc
| `Http20ServerEngine` | HTTP/2 | Stream multiplexing over a single connection; uses HPACK header compression; flow-control windows at connection and stream level |
| `Http30ServerEngine` | HTTP/3 | QUIC-based multiplexing with per-stream flow control; uses QPACK header compression; eliminates head-of-line blocking |
-Each server engine implements `IServerProtocolEngine`. When a connection arrives, the `NegotiatingServerEngine` delegates to `ProtocolRouter` to detect the protocol from ALPN negotiation (TLS) or the initial bytes (HTTP/1.x format, HTTP/2 preface `PRI * HTTP/2.0`, or QUIC Initial packet) and routes the connection to the appropriate version-specific engine for the duration of that connection.
+Each server engine implements `IServerProtocolEngine`. When a connection arrives, the `NegotiatingServerEngine` uses a `ProtocolNegotiatingStateMachine` to detect the protocol from ALPN negotiation (TLS) or the initial bytes (HTTP/1.x request line, or the HTTP/2 preface `PRI * HTTP/2.0`), then instantiates the matching version-specific server state machine for the duration of that connection. HTTP/3 connections arrive over QUIC and are routed directly to the HTTP/3 server engine at the listener, so they never pass through this byte-sniffing step.
## Related Guides
diff --git a/docs/architecture/handlers.md b/docs/architecture/handlers.md
index 5d029bf48..37c8330eb 100644
--- a/docs/architecture/handlers.md
+++ b/docs/architecture/handlers.md
@@ -75,7 +75,7 @@ services.AddTurboHttpClient("myapi", options => { ... })
// Cookies: off by default, opt-in
.WithCookies() // Shared CookieJar for this client
- .WithCookies(existingJar) // Bring your own CookieJar instance
+ .WithCookies(existingStore) // Bring your own ICookieStore implementation
// Cache: off by default, opt-in
.WithCache(c => { c.MaxEntries = 1000; })
@@ -122,12 +122,12 @@ services.AddTurboHttpClient("myapi", options => { ... })
// Inline delegate for simple cases
services.AddTurboHttpClient("myapi", options => { ... })
- .UseRequest(async (req, ct) =>
+ .UseRequest((req) =>
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return req;
})
- .UseResponse(async (original, resp, ct) =>
+ .UseResponse((original, resp) =>
{
metrics.Record(original.RequestUri!, resp.StatusCode);
return resp;
@@ -249,9 +249,13 @@ internal sealed class TurboClientDescriptor
{
public RedirectPolicy? RedirectPolicy { get; set; }
public RetryPolicy? RetryPolicy { get; set; }
+ public Expect100Policy? Expect100Policy { get; set; }
+ public bool AutomaticDecompression { get; set; } = true;
+ public CompressionPolicy? CompressionPolicy { get; set; }
public bool EnableCookies { get; set; }
public CookieJar? CustomCookieJar { get; set; }
public CachePolicy? CachePolicy { get; set; }
+ public ICacheStore? CustomCacheStore { get; set; }
// Type-based handlers (AddHandler) — for DI lookup by type
public List HandlerTypes { get; } = [];
@@ -271,16 +275,26 @@ A snapshot of the fully resolved configuration — cookie jar instance, cache st
internal sealed record PipelineDescriptor(
RedirectPolicy? RedirectPolicy,
RetryPolicy? RetryPolicy,
+ Expect100Policy? Expect100Policy,
+ CompressionPolicy? CompressionPolicy,
CookieJar? CookieJar,
- HttpCacheStore? CacheStore,
- IReadOnlyList Handlers)
+ Cache? CacheStore,
+ CachePolicy? CachePolicy,
+ IReadOnlyList Handlers,
+ bool AutomaticDecompression = true,
+ AltSvcCache? AltSvcCache = null)
{
public static readonly PipelineDescriptor Empty = new(
RedirectPolicy: null,
RetryPolicy: null,
+ Expect100Policy: null,
+ CompressionPolicy: null,
CookieJar: null,
CacheStore: null,
- Handlers: []);
+ CachePolicy: null,
+ Handlers: [],
+ AutomaticDecompression: true,
+ AltSvcCache: null);
}
```
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
index b4879d93c..8f742d9d3 100644
--- a/docs/architecture/index.md
+++ b/docs/architecture/index.md
@@ -72,20 +72,18 @@ Incoming TCP/QUIC Connection
↓
[Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes
↓
-[ApplicationBridgeStage] — bridges IFeatureCollection to ASP.NET Core
+[ApplicationBridgeStage] — bridges decoded HTTP to IFeatureCollection
↓
-[ASP.NET Core Pipeline] — middleware, routing, handler execution
- ↓
-[Dispatcher] — DelegateDispatcher (handler) or EntityDispatcher (actor)
- ↓
-[Parameter Binding] — binds route values, query, body, headers to handler parameters
- ↓
-[Handler / Actor] — executes your code
+[ASP.NET Core Pipeline] — middleware, routing, parameter binding, endpoint execution
↓
[Response] — writes response back through the pipeline
```
-Each connection is managed by a `ConnectionActor` that owns the full Akka.Streams graph for that connection — from transport bytes through to response serialisation.
+Each connection is managed by a `ConnectionStage` that materialises a sub-graph for that connection — from transport bytes through to response serialisation.
+
+::: tip Routing and Dispatching
+Routing, parameter binding, and request dispatching are handled by standard ASP.NET Core — middleware, endpoint routing, and model binding. If you need actor-based request handling, the optional [Servus.Akka.AspNetCore](https://github.com/Aaronontheweb/Servus.Akka.AspNetCore) package provides `EntityDispatcher` and `AkkaResults` helpers for integrating Akka actors as endpoints.
+:::
## Learn More
diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md
index 0685f3b0e..3a6332d76 100644
--- a/docs/architecture/layers.md
+++ b/docs/architecture/layers.md
@@ -19,7 +19,7 @@ Console.WriteLine(response.StatusCode);
- Takes an `HttpRequestMessage`
- Returns a `Task`
- Supports `CancellationToken` for cancellation
-- Respects timeouts in `TurboClientOptions`
+- Respects the `Timeout` set on the client instance (`ITurboHttpClient.Timeout`)
All pipeline features (cookies, caching, retries, redirects) apply automatically. You don't think about them.
diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md
index 97d7f1e06..06d1dd83a 100644
--- a/docs/architecture/pipeline.md
+++ b/docs/architecture/pipeline.md
@@ -53,7 +53,7 @@ Three feedback paths create non-linear behaviour in the pipeline:
### 1. Cache Hit Short-Circuit (amber)
-`CacheBidiStage` checks the in-memory `HttpCacheStore` on every request. If the stored response is still fresh, it is returned immediately. The request never reaches the `Engine` or the network.
+`CacheBidiStage` checks the in-memory `MemoryCacheStore` (via `ICacheStore`) on every request. If the stored response is still fresh, it is returned immediately. The request never reaches the `Engine` or the network.
If the cache entry is stale but has an `ETag` or `Last-Modified` validator, `CacheBidiStage` emits a conditional request (`If-None-Match` / `If-Modified-Since`). On a `304 Not Modified` response, `CacheBidiStage` merges the new headers into the cached entry and returns it.
@@ -93,47 +93,41 @@ See [Connection Pooling Guide](../client/connection-pooling) for tuning options.
## Server Pipeline
-The server pipeline mirrors the client architecture, transforming incoming bytes into responses:
+The server pipeline is TurboHTTP's transport and protocol layer. It hands off request parsing to ASP.NET Core, which handles middleware, routing, and your handlers:
```
Incoming TCP/QUIC Bytes
↓
-[Transport] — accepts connection; ListenerActor spawns ConnectionActor
+[Transport] — accepts connection; ListenerActor materializes a ConnectionStage
↓
-[ProtocolRouter] — detects HTTP version from initial bytes
+[ProtocolRouter] — maps transport/Version to the appropriate server engine at bind time
↓
[Server Protocol Engine] — Http10/11/20/30ServerEngine decodes request, encodes response
↓
[ApplicationBridgeStage] — wraps parsed request as IFeatureCollection (HttpContext)
↓
-[Middleware] — runs registered middleware (Use/Run/Map/MapWhen)
- ↓
-[Routing] — matches request path to registered route pattern
- ↓
-[Dispatcher] — delegates to handler function or actor
- ↓
-[Handler / Entity Actor] — executes your code; returns response
+ASP.NET Core — middleware, routing, handlers, model binding
↓
[Server Protocol Engine] — encodes response to bytes
↓
Outgoing TCP/QUIC Bytes
```
-Each connection is bound to a single `ConnectionActor` that owns the entire Akka.Streams graph — from transport bytes through protocol parsing, middleware execution, routing, and response serialisation.
+Each listener is backed by a single `ConnectionStage` Akka Streams graph — materialized by `ListenerActor` — that accepts and processes all incoming connections, routing transport bytes through protocol parsing up to the point where `ApplicationBridgeStage` hands control to ASP.NET Core middleware.
### Server Pipeline Stages
| Stage | Role |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `ProtocolRouter` | Inspects initial bytes to detect HTTP/1.0, 1.1, 2, or 3; routes to the appropriate server engine state machine |
+| `ProtocolRouter` | Static factory that maps a `Version` and transport to the appropriate server engine at bind time; runtime byte-detection (when version is unspecified) is handled by `ProtocolNegotiatingStateMachine` inside the negotiating engine |
| `Http*ServerEngine` | Protocol-specific state machine: parses request bytes, manages connection/stream-level flow control, encodes response frames |
-| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`) |
-| Middleware | Runs all registered middleware in order (outermost-first for request, innermost-first for response). Middleware can short-circuit by not calling `next(ctx)` |
-| Routing | Matches the request path against registered route patterns; extracts route parameters (`{id}`, etc.) into route values |
-| Dispatcher | Selects and invokes the handler: standard handler functions or actor-based routes |
-| `ParameterBindingStage` | (within dispatcher) Binds route parameters, query string, body, and headers to handler parameters using reflection and model binding |
+| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`); hands control to standard ASP.NET Core middleware |
+
+:::tip
+Everything after `ApplicationBridgeStage` is standard ASP.NET Core: middleware (Use/Run/Map/MapWhen), routing, parameter binding, and your handler code. TurboHTTP owns only the transport layer and HTTP protocol parsing.
+:::
-After the handler returns a response, the response object flows back through the pipeline in reverse — middleware response hooks can transform or log the response, and the protocol engine serialises it back to wire bytes.
+After the handler returns a response, the response object flows back to the protocol engine, which serialises it back to wire bytes.
## Related Guides
diff --git a/docs/architecture/server-engines.md b/docs/architecture/server-engines.md
index bb7eb185e..bd61af4c9 100644
--- a/docs/architecture/server-engines.md
+++ b/docs/architecture/server-engines.md
@@ -51,7 +51,7 @@ With TLS, ALPN negotiation happens during the TLS handshake. The client sends ad
- Connections persist after each response (`Connection: keep-alive`)
- Supports pipelining — multiple requests queued for sequential processing
- Chunked transfer encoding for streaming responses
-- Keep-alive timeout configurable via `TurboServerOptions.Http1.IdleTimeout`
+- Keep-alive timeout configurable via `TurboServerOptions.Http1.KeepAliveTimeout`
**Transport:**
- `TcpListenerFactory` — TCP listener binds to configured port
diff --git a/docs/architecture/server-pipeline.md b/docs/architecture/server-pipeline.md
index fe05a8048..e5bc8164f 100644
--- a/docs/architecture/server-pipeline.md
+++ b/docs/architecture/server-pipeline.md
@@ -1,6 +1,6 @@
# Server Request Pipeline
-The server request pipeline shows how an incoming request flows through the server — from raw network bytes through protocol decoding, middleware, routing, and finally to your handler or actor.
+The server request pipeline shows how an incoming request flows through the server — from raw network bytes through protocol decoding to the ASP.NET Core application layer, where middleware, routing, and handlers run.
@@ -10,28 +10,24 @@ The server request pipeline shows how an incoming request flows through the serv
## Request Flow
-Each connection is bound to a single `ConnectionActor` that owns the entire Akka.Streams graph:
+Each connection is handled by a `ConnectionStage` that owns the Akka.Streams sub-graph for that connection:
```
Incoming TCP/QUIC Connection
↓
-[Transport] — TCP or QUIC listener accepts connection
+[Transport] — TCP or QUIC listener accepts connection (Servus.Akka)
↓
-[ProtocolRouter] — detects HTTP/1.0, 1.1, 2, or 3 from initial bytes
+[ListenerActor] — materializes ConnectionStage per client connection
↓
-[Protocol Decoder] — Http10/11/20/30ServerEngine decodes request
+[ProtocolRouter] — picks engine by transport (QUIC → Http30ServerEngine; TCP → NegotiatingServerEngine)
↓
-[ApplicationBridgeStage] — wraps parsed request as IFeatureCollection (HttpContext)
+[Http*ServerEngine] — protocol-specific decoder (Http10/11/20/30ServerEngine)
↓
-[Middleware] — runs registered middleware (Use/Run/Map/MapWhen)
+[ApplicationBridgeStage] — wraps parsed request as IFeatureCollection
↓
-[Routing] — matches request path to registered route pattern
- ↓
-[Dispatcher] — delegates to handler function or actor
- ↓
-[Parameter Binding] — binds route values, query, body, headers to handler parameters
- ↓
-[Handler / Entity Actor] — executes your code
+╔════════════════════════════════════════════════════════════════╗
+║ ASP.NET Core takes over (Middleware → Routing → Handlers) ║
+╚════════════════════════════════════════════════════════════════╝
↓
[Protocol Encoder] — encodes response to wire bytes
↓
@@ -44,77 +40,54 @@ Outgoing TCP/QUIC Bytes
| Stage | Role |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| `Transport` (TCP/QUIC) | `ListenerActor` binds transport, accepts incoming connections, spawns `ConnectionActor` per client |
-| `ProtocolRouter` | Inspects initial bytes to detect HTTP version; routes to appropriate server engine state machine |
+| `Transport` (TCP/QUIC) | Accepts incoming connections over TCP or QUIC (via Servus.Akka.Transport) |
+| `ListenerActor` | Binds to a port and materializes a `ConnectionStage` flow that handles each incoming connection |
+| `ProtocolRouter` | Static helper used by `ServerSupervisorActor` to pick a server engine by transport: QUIC bindings get `Http30ServerEngine` directly; TCP bindings get `NegotiatingServerEngine`, which performs byte-level protocol detection |
| `Http*ServerEngine` | Protocol-specific state machine: parses request bytes, manages connection/stream-level flow control, encodes response frames |
-| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`) |
-| Middleware | Runs all registered middleware in order (outermost-first for request, innermost-first for response). Middleware can short-circuit. |
-| Routing | Matches the request path against registered route patterns; extracts route parameters (`{id}`, etc.) into route values |
-| Dispatcher | Selects and invokes the handler: function-based routes or actor-based routes |
-| `ParameterBindingStage` | (within dispatcher) Binds route parameters, query string, body, and headers to handler parameters using reflection and model binding |
+| `ApplicationBridgeStage` | Wraps the parsed protocol request as an `IFeatureCollection` (standard ASP.NET Core `HttpContext`); then ASP.NET Core takes over |
+| **(ASP.NET Core)** | **Middleware** (app.Use/UseMiddleware) → **Routing** (endpoint routing) → **Model Binding** → **Handler Execution** (Minimal APIs, Controllers, etc.) |
---
## Connection Lifecycle
-Each connection is managed by a dedicated `ConnectionActor`:
+Each connection is managed by a dedicated `ConnectionStage` graph:
1. **Bind** — `ListenerActor` binds to a TCP or QUIC port
-2. **Accept** — When a client connects, `ListenerActor` spawns a new `ConnectionActor` for that connection
-3. **Materialize** — `ConnectionActor` materialises the Akka.Streams graph (protocol engine → middleware → routing → dispatcher)
+2. **Accept** — When a client connects, `ConnectionStage` materializes a sub-graph for that connection
+3. **Materialize** — The sub-graph composes the protocol engine with `ApplicationBridgeStage` and the shared ASP.NET Core pipeline (middleware and routing)
4. **Process** — The graph processes requests and generates responses for the lifetime of the connection
-5. **Cleanup** — When the client disconnects (or after idle timeout), the actor terminates and releases resources
+5. **Cleanup** — When the client disconnects (or after idle timeout), the sub-graph completes and releases resources
---
## Response Flow
-After the handler returns a response, the response object flows back through the pipeline in reverse:
+After the handler returns a response, it flows back through the pipeline:
-1. The protocol engine encodes the `TurboHttpResponse` to wire bytes
-2. The transport layer (`TcpConnectionStage` or `QuicConnectionStage`) sends the bytes to the client
-3. For HTTP/1.1+, the connection can remain open and reuse for the next request
-4. For HTTP/1.0, the connection closes after sending the response
+1. ASP.NET Core populates the `IHttpResponseFeature` (status code, headers, response body stream)
+2. The protocol engine encodes the response to wire bytes using the appropriate HTTP version (1.0, 1.1, 2, or 3)
+3. The transport layer (via `ConnectionStage` and Servus.Akka.Transport) sends the bytes to the client
+4. For HTTP/1.1+, the connection can remain open and reuse for the next request; for HTTP/1.0, the connection closes after sending the response
---
## Protocol Detection
-When a new connection arrives, `ProtocolRouter` inspects the initial bytes to determine which server engine to use:
-
-- **HTTP/1.x** — First line is `METHOD /path HTTP/1.x` (ASCII text)
-- **HTTP/2** — First bytes are the HTTP/2 connection preface (`PRI * HTTP/2.0`) or `SETTINGS` frame
-- **HTTP/3** — Connection arrives over QUIC (UDP-based transport)
-
-With TLS (HTTPS), ALPN negotiation happens during the TLS handshake:
-- `h2` → HTTP/2
-- `h3` → HTTP/3
-- `http/1.1` or `http/1.0` → HTTP/1.1 (fallback)
-
-For plaintext connections, the router auto-detects from the initial bytes.
+When a new connection arrives, `ServerSupervisorActor` uses `ProtocolRouter` to pick an engine based on the transport:
----
-
-## Middleware Pipeline Semantics
-
-Middleware runs in **outermost-first order** for requests:
-
-```csharp
-app.UseTurbo() // runs 3rd on request, 1st on response
- .UseTurbo() // runs 2nd on request, 2nd on response
- .UseTurbo(); // runs 1st on request, 3rd on response
- // Handler/Router below
-```
+- **QUIC connections** — routed directly to `Http30ServerEngine` (HTTP/3 is QUIC-only; there is no h3-over-TCP/TLS path)
+- **TCP connections** — handed to `NegotiatingServerEngine`, which wraps `ProtocolNegotiatingStateMachine` to detect the protocol
-Each middleware can:
-- **Transform the request** — modify headers, body, or context
-- **Short-circuit the chain** — return a response without calling `next(ctx)`, skipping downstream middleware and the handler
-- **Transform the response** — modify status code, headers, or body
-- **Observe execution** — wrap the downstream call to measure timing or log
+`ProtocolNegotiatingStateMachine` selects the engine as follows:
-::: tip
-Unlike ASP.NET Core where middleware is registered in reverse order, TurboHTTP middleware is registered and executes in the order you call `UseTurbo()`. This is more intuitive for declarative server configuration.
-:::
+- **With TLS (HTTPS)** — ALPN negotiation during the TLS handshake decides the protocol:
+ - `h2` → HTTP/2
+ - Any other negotiated protocol → HTTP/1.1 (fallback)
+- **Without TLS (plaintext)** — the state machine buffers incoming bytes and sniffs:
+ - First 4 bytes are `PRI ` → HTTP/2 (start of the HTTP/2 connection preface)
+ - Request line contains `HTTP/1.0\r\n` → HTTP/1.0
+ - Request line ends with `\n` → HTTP/1.1
---
diff --git a/docs/client/caching.md b/docs/client/caching.md
index e81cc2ab2..f9b0d80ae 100644
--- a/docs/client/caching.md
+++ b/docs/client/caching.md
@@ -13,7 +13,7 @@ TurboHTTP caches **GET and HEAD responses** that the server declares as cacheabl
- **Success:** `200 OK`, `203 Non-Authoritative Information`, `204 No Content`
- **Permanent redirects:** `300 Multiple Choices`, `301 Moved Permanently`, `308 Permanent Redirect`
- **Definitive errors:** `404 Not Found`, `405 Method Not Allowed`, `410 Gone`, `414 URI Too Long`, `501 Not Implemented`
-- The response does **not** include `Cache-Control: no-store` or `Cache-Control: private`
+- The response does **not** include `Cache-Control: no-store`
- At least one freshness indicator is present (`max-age`, `s-maxage`, `Expires`, or a heuristic lifetime can be calculated)
Responses to `POST`, `PUT`, `DELETE`, and all other methods are **never cached**. `206 Partial Content` is not cached because TurboHTTP does not reassemble partial content ranges.
@@ -40,7 +40,7 @@ Once a cached response becomes stale, TurboHTTP issues a **conditional request**
| `no-store` | Response | Never cache this response |
| `no-cache` | Response | Cache the response, but **always revalidate** with the server before serving it |
| `must-revalidate` | Response | Once stale, do not serve the cached copy without revalidation |
-| `private` | Response | Do not cache — response is personalised to one user |
+| `private` | Response | Only rejected by a shared cache (`SharedCache = true`); a private client cache stores the response normally |
| `public` | Response | Explicitly marks the response as cacheable, even on shared caches |
| `no-cache` | Request | Bypass cache; fetch a fresh response from the server |
| `no-store` | Request | Bypass cache and do not store the response |
@@ -122,7 +122,7 @@ builder.Services.AddTurboHttpClient("api", options =>
.WithCache(cache =>
{
cache.MaxEntries = 500; // maximum number of cached responses (default: 1000)
- cache.MaxBodyBytes = 512 * 1024; // maximum body size to cache, in bytes (default: 50 MiB)
+ cache.MaxBodySize = 512 * 1024; // maximum body size to cache, in bytes (default: 50 MiB)
});
```
@@ -154,7 +154,7 @@ request.Headers.CacheControl = new CacheControlHeaderValue
By default each named client gets its own cache. To share a single store across multiple named clients — for example, to serve the same cached responses to parallel services — implement `ICacheStore` and pass the same instance to each client:
```csharp
-using TurboHTTP.Protocol.Caching;
+using TurboHTTP.Features.Caching;
// Your thread-safe ICacheStore implementation
ICacheStore sharedStore = new MySharedCacheStore();
diff --git a/docs/client/configuration.md b/docs/client/configuration.md
index 04244274b..ebaf59260 100644
--- a/docs/client/configuration.md
+++ b/docs/client/configuration.md
@@ -76,7 +76,7 @@ options.BaseAddress = new Uri("https://api.example.com/v2/");
| `ConnectTimeout` | `TimeSpan` | `00:00:15` | Timeout for establishing a new TCP connection |
| `PooledConnectionIdleTimeout` | `TimeSpan` | `00:01:30` | Time a connection may remain idle before eviction |
| `PooledConnectionLifetime` | `TimeSpan` | `infinite` | Maximum lifetime of a pooled connection |
-| `MaxEndpointSubstreams` | `uint` | `256` | Maximum concurrently active endpoint substreams |
+| `MaxConcurrentEndpoints` | `uint` | `256` | Maximum concurrently active endpoints |
```csharp
options.ConnectTimeout = TimeSpan.FromSeconds(5);
@@ -84,16 +84,29 @@ options.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2);
options.PooledConnectionLifetime = TimeSpan.FromMinutes(10);
```
+### Body Buffering
+
+| Property | Type | Default | Description |
+| ------------------------------- | ------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `Http1.MaxBufferedResponseBodySize` | `int` | `64 * 1024` (64 KB) | HTTP/1.x response bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe |
+| `MaxStreamedResponseBodySize` | `long?` | `null` | Cap on a streamed response body; `null` = unlimited |
+| `RequestBodyChunkSize` | `int` | `16 * 1024` (16 KB) | Chunk size used when streaming a request body to the server |
+
### HTTP/1.x Options
Per-version connection and protocol settings are configured on nested sub-objects:
-| Property | Type | Default | Description |
-| -------------------------------- | ----- | ------------- | -------------------------------------------------- |
-| `Http1.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/1.x connections per host |
-| `Http1.MaxPipelineDepth` | `int` | `16` | Maximum pipelined requests per HTTP/1.1 connection |
-| `Http1.MaxResponseHeadersLength` | `int` | `64 * 1024` | Max response header size |
-| `Http1.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
+| Property | Type | Default | Description |
+| ------------------------------------- | ------ | -------------------- | -------------------------------------------------- |
+| `Http1.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/1.x connections per host |
+| `Http1.MaxPipelineDepth` | `int` | `16` | Maximum pipelined requests per HTTP/1.1 connection |
+| `Http1.MaxResponseHeadersLength` | `int` | `64` (KB) | Max response header size in kilobytes |
+| `Http1.AutoHost` | `bool` | `true` | Automatically add the `Host` header |
+| `Http1.AutoAcceptEncoding` | `bool` | `true` | Automatically add `Accept-Encoding` header |
+| `Http1.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
+| `Http1.MaxResponseHeaderCount` | `int` | `100` | Maximum number of response header fields accepted |
+| `Http1.MaxResponseHeaderLineLength` | `int` | `8 * 1024` (8 KB) | Maximum length of a single response header line |
+| `Http1.MaxChunkExtensionLength` | `int` | `int.MaxValue` | Maximum length of chunk extension data (unbounded by default) |
```csharp
options.Http1.MaxConnectionsPerServer = 12; // raise for parallel HTTP/1.1
@@ -102,18 +115,27 @@ options.Http1.MaxPipelineDepth = 32;
### HTTP/2 Options
-| Property | Type | Default | Description |
-| ------------------------------- | ----- | -------------------- | ---------------------------------------------- |
-| `Http2.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/2 connections per host |
-| `Http2.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection |
-| `Http2.MaxFrameSize` | `int` | `64 * 1024` (64 KiB) | Maximum HTTP/2 frame payload size |
-| `Http2.HeaderTableSize` | `int` | `64 * 1024` (64 KiB) | HPACK dynamic table size |
-| `Http2.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
+| Property | Type | Default | Description |
+| ------------------------------------------ | -------------------------- | ------------------------------ | ---------------------------------------------------------------------- |
+| `Http2.MaxConnectionsPerServer` | `int` | `6` | Maximum concurrent HTTP/2 connections per host |
+| `Http2.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection |
+| `Http2.InitialConnectionWindowSize` | `int` | `64 * 1024 * 1024` (64 MiB) | Initial flow-control window for the whole connection |
+| `Http2.InitialStreamWindowSize` | `int` | `1 * 1024 * 1024` (1 MiB) | Initial flow-control window per stream (grows up to `MaxStreamWindowSize`) |
+| `Http2.MaxStreamWindowSize` | `int` | `16 * 1024 * 1024` (16 MiB) | Upper bound for adaptive stream window growth |
+| `Http2.WindowScaleThresholdMultiplier` | `double` | `1.0` | RTT multiplier that triggers a window-size increase |
+| `Http2.EnableAdaptiveWindowScaling` | `bool` | `true` | Automatically grow receive windows based on measured RTT |
+| `Http2.MaxFrameSize` | `int` | `64 * 1024` (64 KiB) | Maximum HTTP/2 frame payload size |
+| `Http2.HeaderTableSize` | `int` | `64 * 1024` (64 KiB) | HPACK dynamic table size |
+| `Http2.MaxResponseHeaderListSize` | `int` | `64 * 1024` (64 KiB) | Maximum total size of response header fields accepted |
+| `Http2.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
+| `Http2.KeepAlivePingDelay` | `TimeSpan` | `infinite` | Interval between keep-alive PINGs (`infinite` = disabled) |
+| `Http2.KeepAlivePingTimeout` | `TimeSpan` | `00:00:20` | Time to wait for a PING ACK before closing the connection |
+| `Http2.KeepAlivePingPolicy` | `HttpKeepAlivePingPolicy` | `Always` | When to send keep-alive PINGs |
Increase frame size for workloads with large response bodies to reduce framing overhead:
```csharp
-options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB)
+options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 64 KiB)
```
### HTTP/3 Options
@@ -121,13 +143,14 @@ options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB)
| Property | Type | Default | Description |
| -------------------------------- | ---------- | -------------------- | -------------------------------------------- |
| `Http3.MaxConnectionsPerServer` | `int` | `4` | Maximum concurrent QUIC connections per host |
+| `Http3.MaxConcurrentStreams` | `int` | `100` | Maximum concurrent streams per connection |
| `Http3.QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size |
| `Http3.QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK |
| `Http3.MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size |
| `Http3.IdleTimeout` | `TimeSpan` | `00:00:30` | QUIC idle timeout |
| `Http3.MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
-| `Http3.AllowConnectionMigration` | `bool` | `true` | Allow QUIC connection migration |
| `Http3.EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers |
+| `Http3.MaxReconnectBufferSize` | `int` | `64` | Number of in-flight requests buffered for replay on reconnect |
See [HTTP/3 & QUIC guide](./http3) for QUIC-specific configuration and Alt-Svc discovery.
@@ -209,24 +232,39 @@ See [Automatic Retries guide](./retries) for which methods and status codes trig
```csharp
.WithCache()
-.WithCache(c => { c.MaxEntries = 200; c.MaxBodyBytes = 5 * 1024 * 1024; })
+.WithCache(c => { c.MaxEntries = 200; c.MaxBodySize = 5 * 1024 * 1024; })
```
-To share a single store across multiple named clients, pass a `CacheStore` directly:
+To share a single store across multiple named clients, implement the `ICacheStore` interface and pass it to `WithCache()`:
```csharp
-var sharedStore = new CacheStore();
+using TurboHTTP.Features.Caching;
+
+public sealed class MyCacheStore : ICacheStore
+{
+ private readonly Dictionary _entries = new();
+
+ public bool TryGet(string key, out CacheStoreEntry? entry) => _entries.TryGetValue(key, out entry);
+ public void Set(string key, CacheStoreEntry entry) => _entries[key] = entry;
+ public bool Remove(string key) => _entries.Remove(key);
+ public void Clear() => _entries.Clear();
+ public void Dispose() { }
+}
+
+var sharedStore = new MyCacheStore();
builder.Services.AddTurboHttpClient("client-a", options => { ... }).WithCache(sharedStore);
builder.Services.AddTurboHttpClient("client-b", options => { ... }).WithCache(sharedStore);
```
+By default, each client gets its own in-memory cache. Pass a shared `ICacheStore` to reuse cache entries across multiple clients.
+
**`CacheOptions` properties:**
| Property | Type | Default | Description |
| -------------- | ------ | ------------------- | ------------------------------------------- |
| `MaxEntries` | `int` | `1000` | Maximum entries in the LRU store |
-| `MaxBodyBytes` | `long` | `52428800` (50 MiB) | Maximum body size per cached response |
+| `MaxBodySize` | `long` | `50 * 1024 * 1024` (50 MiB) | Maximum body size per cached response |
| `SharedCache` | `bool` | `false` | When `true`, acts as a shared (proxy) cache |
See [HTTP Caching guide](./caching) for freshness evaluation and conditional request behaviour.
@@ -234,8 +272,27 @@ See [HTTP Caching guide](./caching) for freshness evaluation and conditional req
### Cookie management
```csharp
-.WithCookies() // private cookie jar per named client
-.WithCookies(sharedJar) // shared CookieJar across multiple clients
+using TurboHTTP.Features.Cookies;
+
+public sealed class MyCookieStore : ICookieStore
+{
+ private readonly List _entries = new();
+
+ public IReadOnlyList GetAll() => _entries.AsReadOnly();
+ public void Add(CookieStoreEntry entry) => _entries.Add(entry);
+ public void Remove(string name, string domain, string path)
+ => _entries.RemoveAll(e => e.Name == name && e.Domain == domain && e.Path == path);
+ public void Clear() => _entries.Clear();
+ public int Count => _entries.Count;
+}
+
+// Private cookie jar per named client (default)
+.WithCookies()
+
+// Shared cookie store across multiple clients
+var sharedStore = new MyCookieStore();
+builder.Services.AddTurboHttpClient("client-a", options => { ... }).WithCookies(sharedStore);
+builder.Services.AddTurboHttpClient("client-b", options => { ... }).WithCookies(sharedStore);
```
See [Cookies guide](./cookies) for session and domain handling.
@@ -251,10 +308,10 @@ See [Cookies guide](./cookies) for session and domain handling.
```csharp
.WithRequestCompression() // gzip bodies >= 1 KiB
-.WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySizeBytes = 4096; })
+.WithRequestCompression(c => { c.Encoding = "br"; c.MinBodySize = 4096; })
.WithExpectContinue() // Expect: 100-continue for bodies >= 1 KiB
-.WithExpectContinue(e => { e.MinBodySizeBytes = 8192; })
+.WithExpectContinue(e => { e.MinBodySize = 8192; })
```
See [Content Encoding guide](./content-encoding) for details.
diff --git a/docs/client/connection-pooling.md b/docs/client/connection-pooling.md
index 565a98065..5fb557f14 100644
--- a/docs/client/connection-pooling.md
+++ b/docs/client/connection-pooling.md
@@ -7,10 +7,11 @@ TurboHTTP automatically manages a pool of connections for each host, so you neve
Each unique host (scheme + hostname + port + HTTP version) gets its own connection pool. When a request arrives, TurboHTTP tries to reuse an existing open connection. If all connections are busy and the per-host limit has not been reached, a new connection is established. If the limit is already reached, the request waits until a connection becomes free.
```
-Request → TcpConnectionManagerActor (per-host actor)
- ├─ Idle connection available? → Return lease
- ├─ Below per-host limit? → Establish new connection
- └─ At per-host limit? → Wait for release
+Request → ClientStreamManager (global router)
+ └─ StreamOwner (per-endpoint actor)
+ ├─ Idle connection available? → Return lease
+ ├─ Below per-host limit? → Establish new connection
+ └─ At per-host limit? → Wait for release
```
The pool runs entirely in the background. Your code just calls `SendAsync` — connection acquisition, reuse, and lifecycle are transparent.
@@ -35,7 +36,7 @@ The idle timeout is measured from the moment a connection returns to the pool wi
If a connection is dropped unexpectedly (network interruption, server-side timeout, or RST), TurboHTTP detects the failure and reconnects automatically. While reconnecting, queued requests wait for the connection to recover. Once reconnected, TurboHTTP replays the queue.
::: tip Backoff timing
-Reconnect attempts use exponential backoff — each failed attempt waits progressively longer before the next try (1 s → 2 s → 4 s → 8 s → 16 s cap).
+Reconnect attempts use exponential backoff — each failed attempt waits progressively longer before the next try (100 ms → 200 ms → 400 ms → 800 ms → 1.6 s → 3.2 s → 6.4 s → 12.8 s → 25.6 s → 30 s cap).
:::
## Per-Host Concurrency Limits
diff --git a/docs/client/content-encoding.md b/docs/client/content-encoding.md
index 4e0e5c9f4..b4c941b9a 100644
--- a/docs/client/content-encoding.md
+++ b/docs/client/content-encoding.md
@@ -7,7 +7,7 @@ TurboHTTP automatically decompresses compressed HTTP responses. When a server se
| Encoding | Header token | Notes |
| -------- | ---------------- | ------------------------------------------------------ |
| Gzip | `gzip`, `x-gzip` | Most common; used by the majority of web servers |
-| Deflate | `deflate` | Handles both zlib-wrapped and raw deflate formats |
+| Deflate | `deflate` | zlib-wrapped deflate format (RFC 1950) |
| Brotli | `br` | Best compression ratio; requires modern server support |
| Identity | `identity` | No compression; body passed through unchanged |
@@ -20,9 +20,9 @@ When a response arrives:
1. TurboHTTP reads the `Content-Encoding` header.
2. The body is decompressed using the appropriate algorithm.
3. The `Content-Encoding` header is removed from the response.
-4. `Content-Length` is updated to reflect the decompressed size.
+4. `Content-Length` is removed (the decompressed size is not known up front).
-The final `HttpResponseMessage` you receive has an uncompressed body and accurate content headers.
+The final `HttpResponseMessage` you receive has an uncompressed body.
```csharp
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/data"));
@@ -33,11 +33,11 @@ var text = await response.Content.ReadAsStringAsync();
## Stacked Encodings
-If a response uses multiple encodings (e.g., `Content-Encoding: gzip, br`), TurboHTTP decodes them in the correct reverse order — the outermost encoding is decoded first. This matches how stacked encodings are applied by the server.
+TurboHTTP does not support stacked encodings (e.g., `Content-Encoding: gzip, br`). When a response carries multiple comma-separated encoding tokens, decompression will fail silently and the response body will be empty. To avoid this, do not advertise encoding combinations in `Accept-Encoding` that the server might stack — use a single preferred encoding instead.
## Unknown Encodings
-If the server sends a `Content-Encoding` value that TurboHTTP does not recognise, it throws `HttpDecoderException` rather than silently returning corrupted data. This prevents your application from processing a response body it cannot correctly interpret.
+If the server sends a `Content-Encoding` value that TurboHTTP does not recognise, the response is passed through unchanged with the compressed body intact. TurboHTTP only decompresses encodings it recognises (gzip, deflate, br, identity).
## Overriding Accept-Encoding
diff --git a/docs/client/cookies.md b/docs/client/cookies.md
index 0d6e4067c..6721cd8e4 100644
--- a/docs/client/cookies.md
+++ b/docs/client/cookies.md
@@ -1,6 +1,6 @@
# Cookie Management
-TurboHTTP handles cookies automatically. When a server sends a `Set-Cookie` header, TurboHTTP stores it and attaches it to subsequent requests that match the cookie's domain and path — no configuration needed.
+TurboHTTP cookie handling is opt-in. Call `.WithCookies()` on the client builder to enable it. Once enabled, when a server sends a `Set-Cookie` header, TurboHTTP stores it and attaches it to subsequent requests that match the cookie's domain and path.
## How It Works
@@ -13,13 +13,17 @@ Both steps happen transparently inside the request pipeline. Cookies from a logi
## Cookie Isolation
-Each `TurboHttpClient` instance has its own `CookieJar`. Cookies received by one client are never shared with another. This means:
+Cookies are disabled unless `.WithCookies()` is called on the builder. When enabled, each client gets its own isolated `CookieJar`. Cookies received by one client are never shared with another. This means:
- A client used for API calls and a client used for authentication do **not** share cookie state.
- Creating multiple clients for different services keeps their session cookies completely separate.
```csharp
-// These two clients have independent cookie jars
+// Enable cookies independently for each client
+builder.Services.AddTurboHttpClient("api", ...).WithCookies();
+builder.Services.AddTurboHttpClient("auth", ...).WithCookies();
+
+// Each client now has its own isolated cookie jar
var apiClient = factory.CreateClient("api");
var authClient = factory.CreateClient("auth");
```
@@ -69,14 +73,23 @@ Set-Cookie: session=xyz; HttpOnly
### `SameSite`
-Controls whether a cookie is sent with cross-site requests. TurboHTTP stores the `SameSite` attribute but does **not** enforce it — the library always sends cookies that match domain and path rules. SameSite enforcement is a browser-level protection that does not apply to programmatic HTTP clients.
+Controls whether a cookie is sent with cross-site requests. Because a programmatic HTTP client has no inherent notion of "the current site", TurboHTTP treats every request as first-party (same-site) by default and sends matching cookies. To opt into `SameSite` enforcement, tell TurboHTTP which site is initiating the request:
+
+```csharp
+var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/transfer")
+ .WithFirstPartyContext(new Uri("https://app.other.com/"));
+```
+
+When a first-party context is set and the target is **cross-site** relative to it, TurboHTTP applies the policy below before injecting cookies:
+
+| Value | Cross-site behaviour |
+| ---------- | --------------------------------------------------------------- |
+| `Strict` | Never sent cross-site |
+| `Lax` | Sent cross-site only for safe top-level navigations (GET/HEAD) |
+| `None` | Always sent (requires `Secure`) |
+| _(absent)_ | No restriction — sent like `None` |
-| Value | Meaning |
-| ---------- | -------------------------------------------------------------- |
-| `Strict` | Cookie sent only for requests originating from the same site |
-| `Lax` | Cookie sent for same-site and top-level cross-site navigations |
-| `None` | Cookie sent with all requests (requires `Secure`) |
-| _(absent)_ | No policy; treated like `Lax` in browsers |
+Two requests are considered same-site when they share the same registrable domain (e.g. `app.example.com` and `api.example.com`). TurboHTTP does not bundle a public-suffix list, so multi-level suffixes like `co.uk` are compared on their last two labels.
## Expiration
@@ -109,10 +122,10 @@ Set-Cookie: sid=abc123 ← no expiry: lasts until the client is disposed
## Sharing a Cookie Store
-By default each named client gets its own isolated cookie store. To share cookies across multiple clients — for example, so that a login performed by one client is visible to another — implement `ICookieStore` and pass the same instance to each:
+When `.WithCookies()` is called without arguments, each client gets its own isolated in-memory store. To share cookies across multiple clients — for example, so that a login performed by one client is visible to another — implement `ICookieStore` and pass the same instance to each:
```csharp
-using TurboHTTP.Protocol.Cookies;
+using TurboHTTP.Features.Cookies;
// Your thread-safe ICookieStore implementation
ICookieStore sharedStore = new MySharedCookieStore();
@@ -133,7 +146,7 @@ builder.Services.AddTurboHttpClient("api", options =>
A cookie set during login on the `auth` client will now be available to the `api` client.
::: warning Thread safety
-When an `ICookieStore` is shared across multiple clients it will receive concurrent reads and writes. Your implementation must be thread-safe.
+`ICookieStore` implementations are not required to be thread-safe when used by a single client — the request pipeline accesses the store on one logical thread at a time. However, when the **same store instance is shared across multiple clients**, those pipelines run concurrently and can access the store simultaneously. In that case your implementation must handle concurrent reads and writes safely.
:::
::: info How it works
diff --git a/docs/client/http2.md b/docs/client/http2.md
index 3ea6e2394..40a0c214f 100644
--- a/docs/client/http2.md
+++ b/docs/client/http2.md
@@ -93,12 +93,12 @@ HTTP/2 over cleartext (`http://` URLs, sometimes called h2c) is also supported.
## Frame Size
-Each HTTP/2 request and response is broken into frames before being sent over the wire. The default maximum frame size is 16 KiB. Increase it for workloads that transfer large bodies to reduce framing overhead:
+Each HTTP/2 request and response is broken into frames before being sent over the wire. The default maximum frame size is 64 KiB (the protocol minimum is 16 KiB). Increase it for workloads that transfer large bodies to reduce framing overhead:
```csharp
builder.Services.AddTurboHttpClient("http2-api", options =>
{
- options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 16 KiB, max: 16 MiB)
+ options.Http2.MaxFrameSize = 4 * 1024 * 1024; // 4 MiB (default: 64 KiB, max: 16 MiB)
});
```
diff --git a/docs/client/http3.md b/docs/client/http3.md
index 8ef5c6551..91e857f32 100644
--- a/docs/client/http3.md
+++ b/docs/client/http3.md
@@ -52,26 +52,17 @@ builder.Services.AddTurboHttpClient("http3-api", options =>
### All HTTP/3 options
-| Property | Type | Default | Description |
-| -------------------------- | ---------- | -------------------- | --------------------------------------------- |
-| `MaxConnectionsPerServer` | `int` | `4` | Max concurrent QUIC connections per host |
-| `QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size in bytes |
-| `QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK encoder |
-| `MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size |
-| `IdleTimeout` | `TimeSpan` | `30 s` | QUIC idle timeout |
-| `MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
-| `AllowConnectionMigration` | `bool` | `true` | Allow QUIC connection migration |
-| `EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers |
-
-## Connection Migration
-
-QUIC connections can survive IP address changes. When the client moves between networks (e.g., Wi-Fi to cellular), the connection continues transparently without re-establishing:
-
-```csharp
-options.Http3.AllowConnectionMigration = true; // default: true
-```
-
-When disabled, TurboHTTP closes the connection on address change and reconnects via the normal reconnect mechanism.
+| Property | Type | Default | Description |
+| -------------------------- | ---------- | -------------------- | --------------------------------------------------------- |
+| `MaxConnectionsPerServer` | `int` | `4` | Max concurrent QUIC connections per host |
+| `MaxConcurrentStreams` | `int` | `100` | Max concurrent request streams per connection |
+| `QpackMaxTableCapacity` | `int` | `16 * 1024` (16 KiB) | QPACK dynamic table size in bytes |
+| `QpackBlockedStreams` | `int` | `100` | Max streams blocked waiting for QPACK encoder |
+| `MaxFieldSectionSize` | `int` | `64 * 1024` (64 KiB) | Max header block size |
+| `IdleTimeout` | `TimeSpan` | `30 s` | QUIC idle timeout |
+| `MaxReconnectAttempts` | `int` | `3` | Max reconnect attempts on connection drop |
+| `EnableAltSvcDiscovery` | `bool` | `false` | Auto-discover HTTP/3 via Alt-Svc headers |
+| `MaxReconnectBufferSize` | `int` | `64` | Max number of requests buffered during a reconnect |
## Alt-Svc Discovery
@@ -83,22 +74,22 @@ options.Http3.EnableAltSvcDiscovery = true; // default: false
This is opt-in because not all environments support QUIC (firewalls may block UDP). Enable it when you know your network path supports QUIC and want automatic protocol upgrade.
-## Server Push
+## HTTP/3 and Forward Proxies
-HTTP/3 supports server push, where the server proactively sends resources the client hasn't requested yet. This is disabled by default:
+QUIC cannot traverse an HTTP forward proxy (`CONNECT` tunnels carry TCP, not UDP). When a proxy is configured and applies to a request:
-```csharp
-options.Http3.AllowServerPush = true; // default: false
-```
+- HTTP/3 requests with `HttpVersionPolicy.RequestVersionOrLower` (the default) are transparently downgraded to HTTP/2 and tunneled via `CONNECT`.
+- HTTP/3 requests with `RequestVersionExact` or `RequestVersionOrHigher` fail with `HttpRequestException`.
+- Alt-Svc HTTP/3 upgrades are skipped for proxied hosts.
-When disabled, any PUSH_PROMISE frames from the server are rejected.
+Hosts matched by the proxy's bypass list keep using HTTP/3 directly.
## QPACK Header Compression
HTTP/3 uses QPACK for header compression (the QUIC equivalent of HPACK in HTTP/2). TurboHTTP manages QPACK encoding and decoding automatically. Tune the dynamic table size if needed:
```csharp
-options.Http3.QpackMaxTableCapacity = 8192; // default: 4096
+options.Http3.QpackMaxTableCapacity = 8192; // default: 16 * 1024 (16 KiB)
options.Http3.QpackBlockedStreams = 200; // default: 100
```
diff --git a/docs/client/index.md b/docs/client/index.md
index 33fdcdc52..562d73ec8 100644
--- a/docs/client/index.md
+++ b/docs/client/index.md
@@ -11,7 +11,7 @@ See [Installation & Setup](./installation) for DI registration, named clients, a
:::
::: info Looking for the server?
-TurboHTTP also provides a server with middleware, routing, and entity gateway. See the [Server Guide](/server/).
+TurboHTTP also provides a high-performance drop-in ASP.NET Core IServer (a Kestrel replacement). See the [Server Guide](/server/).
:::
## High-Throughput Usage
@@ -73,7 +73,7 @@ With HTTP/2, all 1000 requests flow over a single TCP connection as concurrent s
### Backpressure
-The channel has a bounded capacity. If the connection cannot keep up with your producer, `WriteAsync` will pause automatically until there is room. You never drop requests — the channel applies backpressure instead.
+The `Requests` channel is unbounded, so `WriteAsync` never pauses — requests are accepted immediately regardless of how fast the connection can process them. Backpressure is applied further down the pipeline: each endpoint's internal dispatch channel is bounded, so requests queue at that point if the connection is saturated. You never drop requests, but producers that outpace the connection will accumulate requests in memory.
## What's Included
@@ -88,7 +88,7 @@ TurboHTTP works out of the box — no middleware to wire up, no Polly policies t
| **Cookie Management** | `CookieJar` stores `Set-Cookie` responses and injects cookies on subsequent requests automatically |
| **Content Encoding** | Automatic gzip, deflate, and Brotli decompression |
| **Connection Pooling** | Per-host pools with idle eviction, automatic reconnect, and configurable concurrency limits |
-| **Channel-based API** | `ChannelWriter`/`ChannelReader` interface for backpressure-aware, high-throughput request pipelines |
+| **Channel-based API** | `ChannelWriter`/`ChannelReader` interface for high-throughput request pipelines; backpressure is enforced at the internal per-endpoint dispatch layer |
## Next Steps
diff --git a/docs/client/installation.md b/docs/client/installation.md
index 08f44836f..1d7b1bcad 100644
--- a/docs/client/installation.md
+++ b/docs/client/installation.md
@@ -22,7 +22,7 @@ Or add it to your `.csproj`:
Register TurboHTTP in your `IServiceCollection`:
```csharp
-using TurboHTTP;
+using TurboHTTP.Client;
var builder = WebApplication.CreateBuilder(args);
@@ -95,17 +95,17 @@ public sealed class GatewayService
## Typed Clients
-Bind a client directly to a service class:
+Register a named client and resolve it directly as a typed `ITurboHttpClient` subtype:
```csharp
-builder.Services.AddTurboHttpClient(options =>
+builder.Services.AddTurboHttpClient(options =>
{
options.BaseAddress = new Uri("https://api.example.com");
})
.WithRetry();
```
-The DI container injects `ITurboHttpClient` into `OrderService` automatically.
+`TClient` must be `ITurboHttpClient` or a class that derives from it — the registration casts `factory.CreateClient(name)` to `TClient` at resolution time. Passing an arbitrary POCO service class (one that does not implement `ITurboHttpClient`) will throw `InvalidCastException` when the service is resolved.
## Fluent Builder API
@@ -128,7 +128,7 @@ builder.Services.AddTurboHttpClient("full-featured", options =>
A complete console application using the DI-based approach:
```csharp
-using TurboHTTP;
+using TurboHTTP.Client;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
diff --git a/docs/client/redirects.md b/docs/client/redirects.md
index 34771800b..55ae4b1ba 100644
--- a/docs/client/redirects.md
+++ b/docs/client/redirects.md
@@ -48,7 +48,7 @@ Same-origin redirects preserve the `Authorization` header normally.
### HTTPS → HTTP Downgrade Protection
-TurboHTTP blocks redirects that would downgrade from `https://` to `http://`. If a server tries to redirect you from an encrypted connection to a cleartext one, TurboHTTP throws a `RedirectException` with `RedirectError.ProtocolDowngrade` instead of following it.
+TurboHTTP blocks redirects that would downgrade from `https://` to `http://`. If a server tries to redirect you from an encrypted connection to a cleartext one, TurboHTTP fails the request rather than following it.
```
Original: GET https://secure.example.com/data
@@ -63,10 +63,10 @@ The `Cookie` header is never blindly forwarded across redirects. For each redire
## Loop Detection
-TurboHTTP tracks every URL visited during a redirect chain. If the same URL appears twice, it throws a `RedirectException` with `RedirectError.RedirectLoop` immediately rather than continuing. This prevents infinite redirect loops caused by misconfigured servers from hanging your application.
+TurboHTTP tracks every URL visited during a redirect chain. If the same URL appears twice, it fails the request immediately rather than continuing. This prevents infinite redirect loops caused by misconfigured servers from hanging your application.
```
-GET /a → 302 → /b → 302 → /a ← RedirectException (loop detected)
+GET /a → 302 → /b → 302 → /a ← request fails (loop detected)
```
## Configuration
@@ -95,7 +95,7 @@ builder.Services.AddTurboHttpClient("strict", options =>
### `MaxRedirects`
-The maximum number of redirect hops to follow before giving up. Default: **10**. Exceeding this limit throws `RedirectException` with `RedirectError.MaxRedirectsExceeded`.
+The maximum number of redirect hops to follow before giving up. Default: **10**. Exceeding this limit causes the request to fail.
### `AllowHttpsToHttpDowngrade`
@@ -107,29 +107,23 @@ This is rarely needed. Only enable it in fully-trusted internal networks where y
Omit `.WithRedirect()` to leave redirects disabled entirely. All 3xx responses are returned as-is.
-## Handling Redirect Exceptions
+## Handling Redirect Failures
-When a redirect cannot be completed, TurboHTTP throws `RedirectException`. You can handle each case separately:
+When a redirect cannot be completed — due to too many hops, a detected loop, or a blocked HTTPS→HTTP downgrade — the failure surfaces as an exception thrown from `SendAsync`. You catch and handle it using the standard exception handling:
```csharp
try
{
var response = await client.SendAsync(request);
}
-catch (RedirectException ex) when (ex.Error == RedirectError.MaxRedirectsExceeded)
+catch (Exception ex)
{
- Console.WriteLine($"Too many redirects: {ex.Message}");
-}
-catch (RedirectException ex) when (ex.Error == RedirectError.RedirectLoop)
-{
- Console.WriteLine($"Redirect loop detected: {ex.Message}");
-}
-catch (RedirectException ex) when (ex.Error == RedirectError.ProtocolDowngrade)
-{
- Console.WriteLine($"Blocked HTTPS→HTTP downgrade: {ex.Message}");
+ Console.WriteLine($"Request failed: {ex.Message}");
}
```
+The specific internal exception types are not part of the public API, so you cannot distinguish between different redirect failure modes by exception type. If your application needs to respond differently to different kinds of redirect failures, consider lowering the `MaxRedirects` limit or disabling redirects entirely (omit `.WithRedirect()`) and handling 3xx responses yourself.
+
::: info How it works
See [Architecture: Request Pipeline](/architecture/pipeline) to understand how this feature fits into the processing pipeline.
:::
diff --git a/docs/client/scenarios.md b/docs/client/scenarios.md
index c49fae8c7..b949a0942 100644
--- a/docs/client/scenarios.md
+++ b/docs/client/scenarios.md
@@ -29,15 +29,17 @@ builder.Services.AddTurboHttpClient("rest-api", options =>
.WithCache(cache =>
{
cache.MaxEntries = 500;
- cache.MaxBodyBytes = 10 * 1024 * 1024; // 10 MiB
+ cache.MaxBodySize = 10 * 1024 * 1024; // 10 MiB
});
```
Usage:
```csharp
-public class ApiService(ITurboHttpClientFactory factory)
+public class ApiService(ITurboHttpClientFactory factory, ITokenProvider tokenProvider)
{
+ private readonly ITokenProvider _tokenProvider = tokenProvider;
+
public async Task GetUserAsync(int userId, CancellationToken ct)
{
var client = factory.CreateClient("rest-api");
@@ -50,12 +52,12 @@ public class ApiService(ITurboHttpClientFactory factory)
var json = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize(json)!;
}
-}
-private string GetAccessToken()
-{
- // Fetch from secure token store, refresh if expired, etc.
- return _tokenProvider.GetToken();
+ private string GetAccessToken()
+ {
+ // Fetch from secure token store, refresh if expired, etc.
+ return _tokenProvider.GetToken();
+ }
}
```
@@ -152,7 +154,6 @@ Web scraping, automated testing against web apps, session-based client applicati
builder.Services.AddTurboHttpClient("batch-processor", options =>
{
options.BaseAddress = new Uri("https://api.example.com");
- options.DefaultRequestVersion = HttpVersion.Version20; // HTTP/2
options.Http2.MaxConcurrentStreams = 100; // up to 100 concurrent streams per connection
options.Http2.MaxConnectionsPerServer = 2; // reuse 2 connections
})
@@ -171,6 +172,7 @@ public class BatchProcessor(ITurboHttpClientFactory factory)
public async Task ProcessUrlsAsync(List urls, CancellationToken ct)
{
var client = factory.CreateClient("batch-processor");
+ client.DefaultRequestVersion = HttpVersion.Version20; // default to HTTP/2 (set on the client instance, not options)
var results = new ConcurrentBag<(string Url, int Status, string Body)>();
@@ -228,7 +230,7 @@ Batch URL fetching, parallel API polling, high-throughput data ingestion, distri
**The problem:** Your service calls another internal service over HTTP/2. Connection setup needs a 5-second timeout, individual requests have a 10-second timeout. If the service is briefly unavailable, retry automatically.
-**Features in play:** `ConnectTimeout` + `Timeout` for timeout management, `.WithRetry()` for resilience, HTTP/2 for efficiency.
+**Features in play:** `ConnectTimeout` (in options) + `Timeout` (on client instance) for timeout management, `.WithRetry()` for resilience, HTTP/2 for efficiency.
```csharp
// DI Registration
@@ -236,8 +238,6 @@ builder.Services.AddTurboHttpClient("internal-service", options =>
{
options.BaseAddress = new Uri("http://internal-service:8080");
options.ConnectTimeout = TimeSpan.FromSeconds(5); // TCP connect timeout
- options.Timeout = TimeSpan.FromSeconds(10); // request timeout (for GET, HEAD, PUT, DELETE, OPTIONS)
- options.DefaultRequestVersion = HttpVersion.Version20; // HTTP/2
})
.WithDecompression() // some responses may be compressed
.WithRetry(retry =>
@@ -247,6 +247,14 @@ builder.Services.AddTurboHttpClient("internal-service", options =>
});
```
+Then set the request timeout on the client instance:
+
+```csharp
+var client = factory.CreateClient("internal-service");
+client.Timeout = TimeSpan.FromSeconds(10); // per-request timeout
+client.DefaultRequestVersion = HttpVersion.Version20; // default to HTTP/2 (set on the client instance, not options)
+```
+
Usage:
```csharp
@@ -281,114 +289,55 @@ Internal service-to-service communication, calling backend APIs from frontend se
---
-## Akka.Streams Integration
+## Direct Channel-Based Processing
-**The problem:** You're already using Akka.Streams for data processing and want to plug TurboHTTP's channel API into an Akka.Streams graph — applying backpressure, throttling, transformation, and fan-out using the full Akka.Streams DSL.
+**The problem:** You want to drive request/response processing yourself without `SendAsync()` — perhaps to implement custom backpressure logic, or to coordinate TurboHTTP with other async systems.
-**Features in play:** `client.Requests` / `client.Responses` channels bridged to Akka.Streams via `ChannelSource.FromReader()` and `ChannelSink.AsWriter()`.
+**Features in play:** `client.Requests` (a `ChannelWriter`) and `client.Responses` (a `ChannelReader`).
-### Setup
+TurboHTTP's channel API lets you:
-```csharp
-builder.Services.AddTurboHttpClient("stream-api", options =>
-{
- options.BaseAddress = new Uri("https://api.example.com/");
- options.Http2.MaxConcurrentStreams = 100;
-})
-.WithRetry()
-.WithDecompression();
-```
-
-### Bridge Channels to Akka.Streams
+1. Write requests directly to `client.Requests.WriteAsync(request)` instead of calling `SendAsync()`
+2. Read responses from `client.Responses.ReadAllAsync()` in a loop
+3. Both channels support `CancellationToken` for cancellation
-TurboHTTP exposes `ChannelWriter` and `ChannelReader` on the client. Use Akka.Streams' `ChannelSource` and `ChannelSink` to bridge these into a stream graph:
+This is useful if you want to coordinate request submission and response collection independently, or if you're building a custom orchestration layer. Example:
```csharp
-using Akka.Streams;
-using Akka.Streams.Dsl;
+var client = factory.CreateClient("my-client");
-var client = factory.CreateClient("stream-api");
-client.DefaultRequestVersion = HttpVersion.Version20;
-
-var materializer = actorSystem.Materializer();
-
-// Bridge: ChannelReader → Akka.Streams Source
-var responseSource = ChannelSource.FromReader(client.Responses);
-
-// Bridge: Akka.Streams Sink → ChannelWriter
-var requestSink = ChannelSink.AsWriter(client.Requests);
-```
-
-### Example: Throttled Request Pipeline
-
-Feed URLs through an Akka.Streams graph that throttles requests, sends them via TurboHTTP, and processes responses — all with backpressure end to end:
-
-```csharp
-var urls = Enumerable.Range(1, 10_000)
- .Select(id => $"items/{id}");
-
-// Request pipeline: throttle → build request → send to TurboHTTP
-Source.From(urls)
- .Throttle(50, TimeSpan.FromSeconds(1), 10, ThrottleMode.Shaping)
- .Select(url => new HttpRequestMessage(HttpMethod.Get, url))
- .RunWith(requestSink, materializer);
-
-// Response pipeline: receive from TurboHTTP → deserialize → process
-await responseSource
- .SelectAsync(4, async response =>
- {
- var body = await response.Content.ReadFromJsonAsync();
- return body;
- })
- .Where(item => item is not null)
- .RunForeach(item =>
+// Producer task: submit requests without waiting for responses
+var producer = Task.Run(async () =>
+{
+ foreach (var url in urls)
{
- Console.WriteLine($"Processed: {item!.Name}");
- }, materializer);
-```
-
-### Example: Fan-Out with BroadcastHub
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ await client.Requests.WriteAsync(request, ct);
+ }
+ client.Requests.Complete(); // Signal no more requests
+}, ct);
-Share a single TurboHTTP response stream across multiple consumers:
+// Consumer task: process responses as they arrive
+var consumer = Task.Run(async () =>
+{
+ await foreach (var response in client.Responses.ReadAllAsync(ct))
+ {
+ var body = await response.Content.ReadAsStringAsync(ct);
+ Console.WriteLine($"{response.StatusCode}: {body}");
+ response.Dispose();
+ }
+}, ct);
-```csharp
-// Create a broadcast hub from the response source
-var (broadcastSink, broadcastSource) = BroadcastHub.Sink(256)
- .PreMaterialize(materializer);
-
-// Feed TurboHTTP responses into the hub
-responseSource.RunWith(broadcastSink, materializer);
-
-// Consumer 1: log all responses
-broadcastSource
- .RunForeach(r =>
- Console.WriteLine($"[log] {r.RequestMessage?.RequestUri} → {r.StatusCode}"),
- materializer);
-
-// Consumer 2: collect only successful responses
-broadcastSource
- .Where(r => r.IsSuccessStatusCode)
- .SelectAsync(4, async r => await r.Content.ReadAsStringAsync())
- .RunForeach(body => ProcessResult(body), materializer);
+await Task.WhenAll(producer, consumer);
```
-**How they interact:**
-
-- **ChannelSource/ChannelSink** bridge `System.Threading.Channels` to Akka.Streams without copying — backpressure flows through the channel boundary naturally
-- **Throttle** limits request rate at the Akka.Streams level; when the sink can't keep up, backpressure pauses the throttle
-- **SelectAsync** allows parallel response deserialization while preserving ordering
-- **BroadcastHub** lets multiple consumers process the same response stream independently
-- TurboHTTP's built-in **retry** and **decompression** still apply — the Akka.Streams graph sees clean, retried, decompressed responses
-
-::: tip When to use this pattern
-Use this when you need stream-level control that the channel API alone doesn't provide: throttling, fan-out, windowing, grouping, merging multiple clients, or integrating TurboHTTP into a larger Akka.Streams data pipeline.
-:::
+For stream processing with backpressure, throttling, merging, or fan-out — use Akka.Streams directly with Akka.Streams adapters, or write your own adapter that bridges the channels to your stream DSL.
---
## Combining These Patterns
-The four scenarios above show different feature combinations, but there is no rule against mixing them further. For example:
+The scenarios above show different feature combinations, but there is no rule against mixing them further. For example:
- **Authenticated batch processor:** Add `.UseRequest()` to inject a Bearer token into every request in a batch job.
- **Cached microservice:** Add `.WithCache()` to an internal service call to avoid redundant backend queries.
diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md
index 4735ad58e..a046f13d1 100644
--- a/docs/client/troubleshooting.md
+++ b/docs/client/troubleshooting.md
@@ -66,7 +66,7 @@ Per-request overrides are also supported via `HttpRequestMessage.Version`.
### Too Many Redirects
-**Symptom:** `RedirectException` with `RedirectError.MaxRedirectsExceeded`.
+**Symptom:** You receive a 3xx redirect response instead of the final destination response — TurboHTTP stopped following redirects after hitting the configured limit.
**Fix:** The server is returning a redirect loop. Either fix the server or increase the redirect limit via the builder:
@@ -82,7 +82,7 @@ To debug, remove the `.WithRedirect()` call entirely and inspect the redirect re
### HTTPS to HTTP Downgrade Blocked
-**Symptom:** `RedirectException` with `RedirectError.ProtocolDowngrade` on a redirect.
+**Symptom:** A redirect from `https://` to `http://` is not followed — TurboHTTP returns the 3xx redirect response instead of following it.
**Cause:** A server redirected from `https://` to `http://`, which TurboHTTP blocks by default for security.
@@ -98,35 +98,45 @@ builder.Services.AddTurboHttpClient("my-api", options =>
### POST Requests Are Not Retried
-**By design.** POST is not idempotent — retrying it could create duplicate resources. Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) are retried automatically.
+**By design.** POST, PATCH, and other non-idempotent methods are never automatically retried — retrying them could create duplicate resources or cause unintended side effects. Only idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) are retried automatically.
-If you need to retry POST, configure a custom retry policy via the builder:
+This behaviour **cannot be disabled or bypassed** via `RetryOptions`. The idempotency check is baked into the retry evaluator and cannot be configured away.
+
+If you need to retry POST in your application, implement retry logic in your own code:
```csharp
-builder.Services.AddTurboHttpClient("my-client", options =>
+var maxRetries = 3;
+for (var attempt = 1; attempt <= maxRetries; attempt++)
{
- options.BaseAddress = new Uri("https://api.example.com");
-})
-.WithRetry(retry => { retry.MaxRetries = 3; });
+ try
+ {
+ using var response = await client.SendAsync(postRequest, ct);
+ return response;
+ }
+ catch (HttpRequestException ex) when (attempt < maxRetries)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), ct);
+ }
+}
```
-The built-in retry handles idempotent method detection and backoff automatically.
+The built-in `.WithRetry()` handles idempotent method detection and backoff automatically — use it for GET, PUT, DELETE, etc., but implement custom retry logic for POST if needed.
### High Memory Usage
**Possible causes:**
-1. **Cache too large** — reduce `MaxEntries` or `MaxBodyBytes` when registering:
+1. **Cache too large** — reduce `MaxEntries` or `MaxBodySize` when registering:
```csharp
- .WithCache(c => { c.MaxEntries = 100; c.MaxBodyBytes = 10 * 1024 * 1024; })
+ .WithCache(c => { c.MaxEntries = 100; c.MaxBodySize = 10 * 1024 * 1024; })
```
2. **Response bodies not disposed** — always dispose `HttpResponseMessage` when done:
```csharp
using var response = await client.SendAsync(request, ct);
```
-3. **CookieJar accumulating** — clear periodically if needed:
+3. **CookieJar accumulating** — clear periodically if needed by calling `Clear()` on the `ICookieStore` you passed to `.WithCookies(store)`:
```csharp
- cookieJar.Clear();
+ store.Clear(); // store is the ICookieStore you provided to .WithCookies(store)
```
### HTTP/2 Connection Failures
diff --git a/docs/getting-started/architecture.md b/docs/getting-started/architecture.md
index 12577c9da..474738634 100644
--- a/docs/getting-started/architecture.md
+++ b/docs/getting-started/architecture.md
@@ -1,6 +1,6 @@
# Architecture Overview
-TurboHTTP is both an HTTP client and a standalone HTTP server, built on Akka.Streams. Both sides follow the same principle: composable pipeline stages connected by backpressure-aware streams.
+TurboHTTP is both an HTTP client and a high-performance ASP.NET Core `IServer` — a drop-in Kestrel replacement — built on Akka.Streams. Both sides follow the same principle: composable pipeline stages connected by backpressure-aware streams.
@@ -95,39 +95,38 @@ When a request arrives at TurboHTTP Server, it passes through a complementary pi
```
Incoming TCP/QUIC Connection
↓
-[Transport] — accepts connection via ListenerActor
+[Transport] — accepts connection via ListenerActor, materialises ConnectionStage
↓
-[Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes
+[Protocol Negotiation] — detects HTTP version (ALPN over TLS, or byte-sniffing for plaintext)
↓
-[HttpContext Builder] — creates standard HttpContext from parsed request
+[Protocol Decoder] — parses HTTP/1.0, 1.1, 2, or 3 bytes into IFeatureCollection
↓
-[Middleware Pipeline] — runs registered middleware (Use/Run/Map/MapWhen)
+[ApplicationBridgeStage] — bridges to ASP.NET Core IHttpApplication
↓
-[Router] — matches request to registered route
+[ASP.NET Core]
+ • Middleware pipeline (Use/Run/Map/MapWhen)
+ • Routing (matches request to endpoint)
+ • Parameter Binding (binds route, query, body, headers)
+ • Endpoint Execution (controller/Minimal API handler)
↓
-[Dispatcher] — handler function or actor
+[Response Encoding] — converts response through protocol encoder back to bytes
↓
-[Parameter Binding] — binds route values, query, body, headers to handler parameters
- ↓
-[Handler / Actor] — executes your code
- ↓
-[Response] — writes response back through the pipeline
+[Network] — sends over TCP or QUIC
```
-Each connection is managed by a `ConnectionActor` that owns the full Akka.Streams graph for that connection — from transport bytes through to response serialisation.
+Each connection is managed by a `ConnectionStage` graph materialised inside the `ListenerActor` — from transport bytes through protocol decoding, bridging to ASP.NET Core's request processing, and response serialisation.
### Server Architecture
-TurboHTTP Server is a standalone HTTP server — it does not use Kestrel. It uses its own transport layer via Servus.Akka.Transport.
+TurboHTTP Server is an ASP.NET Core `IServer` implementation that replaces Kestrel, with its own TCP/QUIC transport via Servus.Akka.Transport. Middleware, routing, and parameter binding are delegated to standard ASP.NET Core.
-- **TurboServerHostedService** — `IHostedService` entry point: creates ActorSystem and spawns the supervisor
+- **TurboServer** — the `IServer` implementation registered via `builder.Host.UseTurboHttp()`; ASP.NET Core hosting calls `StartAsync()`, which creates the ActorSystem and spawns ServerSupervisorActor
- **ServerSupervisorActor** — manages all listeners and tracks connection counts
-- **ListenerActor** — binds TCP or QUIC transport, accepts incoming connections, spawns a ConnectionActor per client
-- **ConnectionActor** — materialises the protocol engine + middleware + routing graph for a single client
+- **ListenerActor** — binds TCP or QUIC transport, accepts incoming connections, and materialises a `ConnectionStage` graph that handles the full protocol lifecycle per client
### Transport Layer
@@ -138,9 +137,9 @@ Protocol engines (`Http10ServerEngine`, `Http11ServerEngine`, `Http20ServerEngin
### Key Characteristics
-- **Standalone**: Own TCP/QUIC transport — no Kestrel dependency
+- **IServer replacement**: Replaces Kestrel with its own TCP/QUIC transport via Servus.Akka.Transport
- **Actor-based**: Supervisor → Listener → Connection hierarchy with graceful shutdown and coordinated termination
-- **Composable**: ASP.NET Core-style middleware pipeline with Use/Run/Map/MapWhen
+- **ASP.NET Core native**: Works seamlessly with standard middleware, routing, and endpoint configuration
- **Protocol-complete**: HTTP/1.0, 1.1, 2, and 3 with automatic ALPN negotiation
---
diff --git a/docs/getting-started/client.md b/docs/getting-started/client.md
index d7985d0fb..bfb5b1e8e 100644
--- a/docs/getting-started/client.md
+++ b/docs/getting-started/client.md
@@ -11,7 +11,7 @@ dotnet add package TurboHTTP
## 2. Register a Client
```csharp
-using TurboHTTP;
+using TurboHTTP.Client;
var builder = WebApplication.CreateBuilder(args);
@@ -26,6 +26,9 @@ var app = builder.Build();
## 3. Send a Request
```csharp
+using TurboHTTP.Client;
+using System.Net.Http;
+
var factory = app.Services.GetRequiredService();
var client = factory.CreateClient("api");
@@ -42,11 +45,13 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
Features are opt-in via the fluent builder:
```csharp
+using TurboHTTP.Client;
+
builder.Services.AddTurboHttpClient("api", options =>
{
options.BaseAddress = new Uri("https://api.example.com");
})
-.WithRetry() // automatic retries for GET, PUT, DELETE
+.WithRetry() // automatic retries for idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE)
.WithCache() // in-memory HTTP caching with ETag
.WithCookies() // automatic cookie storage and injection
.WithRedirect() // follow redirect chains
@@ -60,6 +65,10 @@ Each `.With*()` method adds a pipeline stage. They compose — order doesn't mat
For batch processing, use the channel-based API instead of `SendAsync`:
```csharp
+using TurboHTTP.Client;
+using System.Net.Http;
+using System.Threading.Channels;
+
var client = factory.CreateClient("api");
// Producer: write requests without waiting
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index c38e18be0..0b11f183d 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -20,7 +20,7 @@ TurboHTTP has two sides — use either or both:
| | Client | Server |
|---|---|---|
-| **What it does** | Makes HTTP requests with built-in retries, caching, cookies, and connection pooling | Handles HTTP requests with middleware, routing, and actor-based entity gateway |
+| **What it does** | Makes HTTP requests with built-in retries, caching, cookies, and connection pooling | Serves HTTP/1.0, 1.1, 2, 3 as a drop-in ASP.NET Core IServer (Kestrel replacement); middleware, routing, Minimal APIs, and Controllers are standard ASP.NET Core; an optional actor-based entity gateway is available via the separate Servus.Akka.AspNetCore package |
| **Get started** | [Client Quick Start →](./client) | [Server Quick Start →](./server) |
| **Full docs** | [Client Guide →](/client/) | [Server Guide →](/server/) |
@@ -68,7 +68,7 @@ await app.RunAsync();
```
::: tip About UseTurboHttp
-TurboHTTP Server is a fully standalone HTTP server built on Akka.Streams with its own TCP/QUIC transport layer. Register it on `builder.Host` using `UseTurboHttp()` — it does not use or depend on Kestrel.
+TurboHTTP Server is a high-performance IServer implementation for ASP.NET Core built on Akka.Streams; it replaces Kestrel as the transport layer and integrates with standard ASP.NET Core middleware, routing, and DI. Register it on `builder.Host` using `UseTurboHttp()`.
:::
## Next Steps
diff --git a/docs/getting-started/migration.md b/docs/getting-started/migration.md
index c233af693..e8bef493b 100644
--- a/docs/getting-started/migration.md
+++ b/docs/getting-started/migration.md
@@ -112,7 +112,6 @@ No Polly dependency needed. TurboHTTP automatically:
- Retries only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE)
- Never retries POST or PATCH
- Respects `Retry-After` headers
-- Applies exponential backoff
Retry behavior is controlled via the built-in `.WithRetry()` builder extension — see [Automatic Retries](/client/retries) for custom policies.
diff --git a/docs/getting-started/server.md b/docs/getting-started/server.md
index d903ea66b..a5237bbfd 100644
--- a/docs/getting-started/server.md
+++ b/docs/getting-started/server.md
@@ -49,6 +49,8 @@ curl http://localhost:5100/
## 4. Add HTTPS
```csharp
+using TurboHTTP.Server;
+
builder.Host.UseTurboHttp(options =>
{
options.ListenLocalhost(5100);
@@ -66,7 +68,7 @@ TurboHTTP is a transport-level replacement — it handles TCP/QUIC connections,
| | Kestrel | TurboHTTP |
|---|---------|-----------|
-| Transport | libuv / SocketsHttpHandler | Akka.Streams + Servus.Akka.Transport |
+| Transport | Sockets (Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets) | Akka.Streams + Servus.Akka.Transport |
| Connection model | Thread pool | Actor per connection |
| Protocols | HTTP/1.1, HTTP/2, HTTP/3 | HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3 |
| Backpressure | Pipe-based | Akka.Streams reactive streams |
diff --git a/docs/likec4/model-pipeline.c4 b/docs/likec4/model-pipeline.c4
index 8d4307b66..f722463f9 100644
--- a/docs/likec4/model-pipeline.c4
+++ b/docs/likec4/model-pipeline.c4
@@ -3,14 +3,14 @@
// for both client and server request processing pipelines.
//
// CLIENT PIPELINE STACKING (outermost -> innermost):
-// Handler -> Tracing -> Redirect -> Cookie -> Retry -> Expect100 -> Cache -> ContentEncoding -> AltSvc -> Engine
+// Tracing -> Handler -> Redirect -> Cookie -> Retry -> Expect100 -> Cache -> ContentEncoding -> AltSvc -> Engine
//
// CLIENT REQUEST CHAIN (app -> Network):
-// enricher -> handler -> tracing -> redirect -> cookie -> retry -> expect100 -> cache -> contentEncoding
+// enricher -> tracing -> handler -> redirect -> cookie -> retry -> expect100 -> cache -> contentEncoding
// -> altSvc -> engine -> [version engines -> Network]
//
// CLIENT RESPONSE CHAIN (Network -> app):
-// engine -> altSvc -> contentEncoding -> cache -> expect100 -> retry -> cookie -> redirect -> tracing -> handler -> app
+// engine -> altSvc -> contentEncoding -> cache -> expect100 -> retry -> cookie -> redirect -> handler -> tracing -> app
//
// CLIENT FEEDBACK LOOPS
// redirect --- redirect request --> cookie (re-enters with cookie injection for new URL)
@@ -24,9 +24,9 @@
model {
// Request chain (outermost -> innermost)
turbohttp.client.TurboHttpClient -[flows]-> turbohttp.streams.RequestEnricher 'request'
- turbohttp.streams.RequestEnricher -[flows]-> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
- turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'request'
- turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'request'
+ turbohttp.streams.RequestEnricher -[flows]-> turbohttp.streams.TracingBidiStage 'request'
+ turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
+ turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'request'
turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.CookieBidiStage 'request'
turbohttp.streams.CookieBidiStage -[flows]-> turbohttp.streams.RetryBidiStage 'request (cookies injected)'
turbohttp.streams.RetryBidiStage -[flows]-> turbohttp.streams.ExpectContinueBidiStage 'request'
@@ -82,9 +82,9 @@ model {
turbohttp.streams.ExpectContinueBidiStage -[flows]-> turbohttp.streams.RetryBidiStage 'response'
turbohttp.streams.RetryBidiStage -[flows]-> turbohttp.streams.CookieBidiStage 'response'
turbohttp.streams.CookieBidiStage -[flows]-> turbohttp.streams.RedirectBidiStage 'response (cookies stored)'
- turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'response'
- turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'response (traced)'
- turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.client.TurboHttpClient 'response'
+ turbohttp.streams.RedirectBidiStage -[flows]-> turbohttp.streams.HandlerBidiStage 'response'
+ turbohttp.streams.HandlerBidiStage -[flows]-> turbohttp.streams.TracingBidiStage 'response (traced)'
+ turbohttp.streams.TracingBidiStage -[flows]-> turbohttp.client.TurboHttpClient 'response'
// Feedback: cache hit bypasses engine, re-enters at Retry
turbohttp.streams.CacheBidiStage -[feedback]-> turbohttp.streams.RetryBidiStage 'cached response (bypasses network)'
diff --git a/docs/likec4/model.c4 b/docs/likec4/model.c4
index 7adf51468..890b772ab 100644
--- a/docs/likec4/model.c4
+++ b/docs/likec4/model.c4
@@ -38,20 +38,20 @@ model {
ClientStreamManager = component 'ClientStreamManager' {
#client
- technology 'Class'
- description 'Materialises the Akka.Streams pipeline and manages stream lifecycle'
+ technology 'ReceiveActor'
+ description 'Per-client actor: routes consumer registrations and creates the StreamOwner that owns the pipeline'
}
StreamOwner = component 'StreamOwner' {
#client
- technology 'Class'
- description 'Owns the materialised stream lifecycle: starts, monitors, and restarts the pipeline on failure'
+ technology 'ReceiveActor'
+ description 'Per-client actor: materialises the Akka.Streams pipeline, registers consumers, and monitors and restarts it on failure'
}
TurboClientOptions = component 'TurboClientOptions' {
#client
- technology 'Record'
- description 'BaseAddress, DefaultRequestVersion, DefaultRequestHeaders'
+ technology 'Class'
+ description 'BaseAddress, ConnectTimeout, and nested Http1/Http2/Http3 options'
}
}
@@ -205,7 +205,7 @@ model {
ConnectionActor = actor 'ConnectionActor' {
#server
technology 'ReceiveActor'
- description 'Per-connection actor: materialises the server-side Akka.Streams graph (protocol engine + middleware + routing) for a single client connection'
+ description 'Per-connection actor: materialises the server-side Akka.Streams graph, selecting the appropriate protocol engine via ProtocolRouter and bridging to ApplicationBridgeStage; middleware and routing are handled by ASP.NET Core'
}
ProtocolRouter = component 'ProtocolRouter' {
@@ -345,17 +345,17 @@ model {
turbohttp.client.ClientStreamManager -> turbohttp.client.StreamOwner 'Manages lifecycle'
turbohttp.client.ITurboHttpClient -> turbohttp.client.TurboClientOptions 'Reads configuration'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.RequestEnricher 'Wraps with enrichment'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.HandlerBidiStage 'Wraps with handlers'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.TracingBidiStage 'Wraps with tracing'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.RedirectBidiStage 'Wraps with redirects'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.CookieBidiStage 'Wraps with cookies'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.RetryBidiStage 'Wraps with retry'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.ExpectContinueBidiStage 'Wraps with 100-continue'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.CacheBidiStage 'Wraps with caching'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.ContentEncodingBidiStage 'Wraps with compression'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.AltSvcBidiStage 'Wraps with Alt-Svc'
- turbohttp.client.ClientStreamManager -> turbohttp.streams.Engine 'Wraps with engine'
+ turbohttp.client.StreamOwner -> turbohttp.streams.RequestEnricher 'Enriches requests before the pipeline'
+ turbohttp.client.StreamOwner -> turbohttp.streams.HandlerBidiStage 'Wraps with handlers'
+ turbohttp.client.StreamOwner -> turbohttp.streams.TracingBidiStage 'Wraps with tracing'
+ turbohttp.client.StreamOwner -> turbohttp.streams.RedirectBidiStage 'Wraps with redirects'
+ turbohttp.client.StreamOwner -> turbohttp.streams.CookieBidiStage 'Wraps with cookies'
+ turbohttp.client.StreamOwner -> turbohttp.streams.RetryBidiStage 'Wraps with retry'
+ turbohttp.client.StreamOwner -> turbohttp.streams.ExpectContinueBidiStage 'Wraps with 100-continue'
+ turbohttp.client.StreamOwner -> turbohttp.streams.CacheBidiStage 'Wraps with caching'
+ turbohttp.client.StreamOwner -> turbohttp.streams.ContentEncodingBidiStage 'Wraps with compression'
+ turbohttp.client.StreamOwner -> turbohttp.streams.AltSvcBidiStage 'Wraps with Alt-Svc'
+ turbohttp.client.StreamOwner -> turbohttp.streams.Engine 'Wraps with engine'
turbohttp.streams.Http10ClientEngine -> servus.TcpConnectionStage 'TCP transport'
turbohttp.streams.Http11ClientEngine -> servus.TcpConnectionStage 'TCP transport'
diff --git a/docs/likec4/views-scenarios.c4 b/docs/likec4/views-scenarios.c4
index aa92bde6c..6eeace26a 100644
--- a/docs/likec4/views-scenarios.c4
+++ b/docs/likec4/views-scenarios.c4
@@ -1,7 +1,7 @@
// TurboHTTP — Scenario Dynamic Views
// Four playable end-to-end request sequences (HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3).
// Pipeline stacking order (outermost -> innermost):
-// RequestEnricher -> Handler -> Tracing -> Redirect -> Cookie -> Retry -> ExpectContinue -> Cache -> ContentEncoding -> AltSvc -> Engine
+// RequestEnricher -> Tracing -> Handler -> Redirect -> Cookie -> Retry -> ExpectContinue -> Cache -> ContentEncoding -> AltSvc -> Engine
views {
dynamic view scenarioHttp10 {
@@ -10,13 +10,16 @@ views {
app -> turbohttp.client.ITurboHttpClient 'request'
turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request'
- turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
+ turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request'
- turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)'
+ turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)'
+ turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request'
turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)'
- turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)'
+ turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request'
turbohttp.streams.Engine -> turbohttp.streams.Http10ClientEngine 'HTTP/1.0'
turbohttp.streams.Http10ClientEngine -> turbohttp.streams.Http10ClientConnectionStage 'request'
turbohttp.streams.Http10ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes'
@@ -28,14 +31,16 @@ views {
network -> servus.TcpConnectionStage 'TCP/TLS bytes'
servus.TcpConnectionStage -> turbohttp.streams.Http10ClientConnectionStage 'inbound bytes'
- turbohttp.streams.Http10ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
+ turbohttp.streams.Http10ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)'
- turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response'
turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response'
turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)'
- turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response'
+ turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response'
turbohttp.client.ITurboHttpClient -> app 'response'
}
@@ -45,13 +50,16 @@ views {
app -> turbohttp.client.ITurboHttpClient 'request'
turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request'
- turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
+ turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request'
- turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)'
+ turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)'
+ turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request'
turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)'
- turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)'
+ turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request'
turbohttp.streams.Engine -> turbohttp.streams.Http11ClientEngine 'HTTP/1.1'
turbohttp.streams.Http11ClientEngine -> turbohttp.streams.Http11ClientConnectionStage 'request'
turbohttp.streams.Http11ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes'
@@ -63,14 +71,16 @@ views {
network -> servus.TcpConnectionStage 'TCP/TLS bytes'
servus.TcpConnectionStage -> turbohttp.streams.Http11ClientConnectionStage 'inbound bytes'
- turbohttp.streams.Http11ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
+ turbohttp.streams.Http11ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)'
- turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response'
turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response'
turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)'
- turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response'
+ turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response'
turbohttp.client.ITurboHttpClient -> app 'response'
}
@@ -80,13 +90,16 @@ views {
app -> turbohttp.client.ITurboHttpClient 'request'
turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request'
- turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
+ turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request'
- turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)'
+ turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)'
+ turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request'
turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)'
- turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)'
+ turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request'
turbohttp.streams.Engine -> turbohttp.streams.Http20ClientEngine 'HTTP/2'
turbohttp.streams.Http20ClientEngine -> turbohttp.streams.Http20ClientConnectionStage 'request'
turbohttp.streams.Http20ClientConnectionStage -> servus.TcpConnectionStage 'outbound bytes'
@@ -98,14 +111,16 @@ views {
network -> servus.TcpConnectionStage 'TCP/TLS bytes'
servus.TcpConnectionStage -> turbohttp.streams.Http20ClientConnectionStage 'inbound bytes'
- turbohttp.streams.Http20ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
+ turbohttp.streams.Http20ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)'
- turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response'
turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response'
turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)'
- turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response'
+ turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response'
turbohttp.client.ITurboHttpClient -> app 'response'
}
@@ -115,13 +130,16 @@ views {
app -> turbohttp.client.ITurboHttpClient 'request'
turbohttp.client.ITurboHttpClient -> turbohttp.streams.RequestEnricher 'request'
- turbohttp.streams.RequestEnricher -> turbohttp.streams.HandlerBidiStage 'request (defaults applied)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'request'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
+ turbohttp.streams.RequestEnricher -> turbohttp.streams.TracingBidiStage 'request (defaults applied)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'request'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.RedirectBidiStage 'request'
turbohttp.streams.RedirectBidiStage -> turbohttp.streams.CookieBidiStage 'request'
- turbohttp.streams.CookieBidiStage -> turbohttp.streams.CacheBidiStage 'request (cookies injected)'
+ turbohttp.streams.CookieBidiStage -> turbohttp.streams.RetryBidiStage 'request (cookies injected)'
+ turbohttp.streams.RetryBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'request'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.CacheBidiStage 'request'
turbohttp.streams.CacheBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'request (cache miss)'
- turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.Engine 'request (body compressed)'
+ turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.AltSvcBidiStage 'request (body compressed)'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.Engine 'request'
turbohttp.streams.Engine -> turbohttp.streams.Http30ClientEngine 'HTTP/3'
turbohttp.streams.Http30ClientEngine -> turbohttp.streams.Http30ClientConnectionStage 'request'
turbohttp.streams.Http30ClientConnectionStage -> servus.QuicConnectionStage 'outbound bytes'
@@ -133,14 +151,16 @@ views {
network -> servus.QuicConnectionStage 'QUIC bytes'
servus.QuicConnectionStage -> turbohttp.streams.Http30ClientConnectionStage 'inbound bytes'
- turbohttp.streams.Http30ClientConnectionStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
+ turbohttp.streams.Http30ClientConnectionStage -> turbohttp.streams.AltSvcBidiStage 'response'
+ turbohttp.streams.AltSvcBidiStage -> turbohttp.streams.ContentEncodingBidiStage 'response'
turbohttp.streams.ContentEncodingBidiStage -> turbohttp.streams.CacheBidiStage 'response (decompressed)'
- turbohttp.streams.CacheBidiStage -> turbohttp.streams.RetryBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.CacheBidiStage -> turbohttp.streams.ExpectContinueBidiStage 'response (stored if cacheable)'
+ turbohttp.streams.ExpectContinueBidiStage -> turbohttp.streams.RetryBidiStage 'response'
turbohttp.streams.RetryBidiStage -> turbohttp.streams.CookieBidiStage 'response'
turbohttp.streams.CookieBidiStage -> turbohttp.streams.RedirectBidiStage 'response (cookies stored)'
- turbohttp.streams.RedirectBidiStage -> turbohttp.streams.TracingBidiStage 'response'
- turbohttp.streams.TracingBidiStage -> turbohttp.streams.HandlerBidiStage 'response (traced)'
- turbohttp.streams.HandlerBidiStage -> turbohttp.client.ITurboHttpClient 'response'
+ turbohttp.streams.RedirectBidiStage -> turbohttp.streams.HandlerBidiStage 'response'
+ turbohttp.streams.HandlerBidiStage -> turbohttp.streams.TracingBidiStage 'response (traced)'
+ turbohttp.streams.TracingBidiStage -> turbohttp.client.ITurboHttpClient 'response'
turbohttp.client.ITurboHttpClient -> app 'response'
}
diff --git a/docs/scenarios.md b/docs/scenarios.md
index 33efd3a0b..7568669e6 100644
--- a/docs/scenarios.md
+++ b/docs/scenarios.md
@@ -34,10 +34,14 @@ TurboHTTP reuses an existing `ActorSystem` from DI if one is registered (e.g. vi
## Real-Time SSE Streaming
-Server-Sent Events let you push data to clients over a long-lived HTTP connection. TurboHTTP makes this trivial — return an Akka Streams `Source` wrapped in `TurboStreamResults.EventStream`, and the framework handles SSE framing, connection lifecycle, and backpressure for you.
+Server-Sent Events let you push data to clients over a long-lived HTTP connection. TurboHTTP makes this trivial — return an Akka Streams `Source` wrapped in `AkkaResults.ServerSentEvent`, and the framework handles SSE framing, connection lifecycle, and backpressure for you.
+
+Streaming helpers come from the `Servus.Akka.AspNetCore` package and require an `IMaterializer` instance (typically injected from DI).
```csharp
-app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents) =>
+using Servus.Akka.AspNetCore;
+
+app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents, IMaterializer materializer) =>
{
var events = orderEvents
.AsSource()
@@ -46,7 +50,7 @@ app.MapGet("/events/orders", (HttpContext ctx, IOrderEventSource orderEvents) =>
EventType: e.GetType().Name,
Id: e.OrderId.ToString()));
- return TurboStreamResults.EventStream(events);
+ return AkkaResults.ServerSentEvent(events, materializer);
});
```
@@ -58,18 +62,21 @@ The `Source` is materialized when the client connects and torn down when they di
## Raw Byte Streaming
-When you need to stream binary data — file downloads, video, sensor feeds — you want bytes to flow from the source to the network without piling up in memory. `TurboStreamResults.Stream` takes an Akka Streams `Source` of byte chunks and pipes it directly into the HTTP response body.
+When you need to stream binary data — file downloads, video, sensor feeds — you want bytes to flow from the source to the network without piling up in memory. `AkkaResults.Stream` takes an Akka Streams `Source` of byte chunks and pipes it directly into the HTTP response body.
```csharp
-app.MapGet("/files/{fileId}", (HttpContext ctx, IFileStore fileStore, string fileId) =>
+using Servus.Akka.AspNetCore;
+
+app.MapGet("/files/{fileId}", (HttpContext ctx, IFileStore fileStore, string fileId, IMaterializer materializer) =>
{
var metadata = fileStore.GetMetadata(fileId);
- var bytes = Akka.Streams.IO.FileIO
+ var bytes = Akka.Streams.Dsl.FileIO
.FromFile(new FileInfo(metadata.Path), chunkSize: 8 * 1024)
- .Select(chunk => (ReadOnlyMemory)chunk.Memory);
+ .Select(chunk => (ReadOnlyMemory)chunk.ToArray())
+ .MapMaterializedValue(_ => Akka.NotUsed.Instance);
- return TurboStreamResults.Stream(bytes, contentType: metadata.ContentType);
+ return AkkaResults.Stream(bytes, materializer, contentType: metadata.ContentType);
});
```
@@ -114,19 +121,21 @@ Over HTTP/2, all 100 requests multiplex on a single connection. Responses arrive
TurboHTTP doesn't just use Akka Streams for internal plumbing — it exposes the full operator toolkit for you to shape, merge, and throttle data before it hits the wire. Every operator in the pipeline participates in backpressure, from the data source all the way to the client's TCP receive window.
```csharp
-app.MapGet("/metrics/live", (HttpContext ctx, IMetricsSource metrics) =>
+using Servus.Akka.AspNetCore;
+
+app.MapGet("/metrics/live", (HttpContext ctx, IMetricsSource metrics, IMaterializer materializer) =>
{
var cpuMetrics = metrics.CpuEvents();
var memoryMetrics = metrics.MemoryEvents();
var merged = cpuMetrics
.Merge(memoryMetrics)
- .Throttle(100, TimeSpan.FromSeconds(1), ThrottleMode.Shaping)
+ .Throttle(100, TimeSpan.FromSeconds(1), 100, ThrottleMode.Shaping)
.Select(m => new ServerSentEvent(
Data: m.ToJson(),
EventType: m.Category));
- return TurboStreamResults.EventStream(merged);
+ return AkkaResults.ServerSentEvent(merged, materializer);
});
```
diff --git a/docs/server/configuration.md b/docs/server/configuration.md
index 0b8ebde5d..3524abe36 100644
--- a/docs/server/configuration.md
+++ b/docs/server/configuration.md
@@ -16,9 +16,10 @@ builder.Host.UseTurboHttp(options =>
| `HandlerTimeout` | `TimeSpan` | 30s | Maximum time for a request handler to complete |
| `HandlerGracePeriod` | `TimeSpan` | 5s | Extra time after handler timeout before force-closing |
| `GracefulShutdownTimeout` | `TimeSpan` | 30s | Time to drain connections during shutdown |
-| `BodyBufferThreshold` | `int` | 64 * 1024 | Request body buffer size before streaming |
| `BodyConsumptionTimeout` | `TimeSpan` | 30s | Time for the app to consume the request body |
| `ResponseBodyChunkSize` | `int` | 16 * 1024 | Chunk size for response body writes |
+| `MaxOutboundCoalesceCount` | `int` | 32 | Coalesce factor for outbound writes — frames are merged up to factor × 16 KiB per transport write |
+| `AllowResponseHeaderCompression` | `bool` | true | Whether response headers may use Huffman compression (HPACK/QPACK); disable to mitigate CRIME/BREACH-style attacks |
## Connection Limits
@@ -27,15 +28,17 @@ Access via `options.Limits`.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `MaxConcurrentConnections` | `int` | 0 (unlimited) | Maximum concurrent connections |
-| `MaxConcurrentUpgradedConnections` | `int` | 0 (unlimited) | Maximum upgraded connections (WebSocket) |
-| `MaxRequestBodySize` | `long` | 30 * 1024 * 1024 | Global max request body size |
+| `MaxRequestBodySize` | `long` | 30,000,000 (~28.6 MiB) | Global max request body size (matches Kestrel) |
+| `MaxResponseBufferSize` | `long` | 64 * 1024 | Maximum per-stream response write buffer |
+| `MaxRequestBufferSize` | `long?` | 1 MiB | Transport input buffer before backpressure is applied (`null` = unlimited) |
| `MaxRequestHeaderCount` | `int` | 100 | Maximum request headers |
| `MaxRequestHeadersTotalSize` | `int` | 32 * 1024 | Maximum total header bytes |
+| `MaxResetStreamsPerWindow` | `int` | 200 | Maximum HTTP/2 stream resets tolerated in a sliding window before the connection is closed (Rapid Reset / CVE-2023-44487 mitigation). Set to 0 to disable. |
| `KeepAliveTimeout` | `TimeSpan` | 130s | Idle connection timeout |
| `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers |
-| `MinRequestBodyDataRate` | `double` | 0 | Minimum body bytes/sec (0 = disabled) |
+| `MinRequestBodyDataRate` | `double` | 240 | Minimum body bytes/sec (0 = disabled) |
| `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing body rate |
-| `MinResponseDataRate` | `double` | 0 | Minimum response bytes/sec (0 = disabled) |
+| `MinResponseDataRate` | `double` | 240 | Minimum response bytes/sec (0 = disabled) |
| `MinResponseDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing response rate |
## HTTP/1.x Options
@@ -48,11 +51,16 @@ Access via `options.Http1`.
| `MaxRequestTargetLength` | `int` | 8192 | Maximum bytes for the request target (URL) |
| `MaxPipelinedRequests` | `int` | 16 | Maximum queued pipelined requests |
| `MaxChunkExtensionLength` | `int` | 4096 | Maximum bytes for chunk extensions |
+| `MaxBufferedRequestBodySize` | `int` | 64 * 1024 | Request bodies up to this size are buffered fully in memory; larger bodies are exposed as a streaming pipe |
| `BodyReadTimeout` | `TimeSpan` | 30s | Timeout for reading request body |
-| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/1.x-specific body size limit |
-| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes |
+| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) |
+| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/1.x-specific body size limit |
| `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Per-protocol keep-alive override |
| `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Per-protocol headers timeout override |
+| `MinRequestBodyDataRate` | `double?` | null (uses global) | Per-protocol minimum body bytes/sec override |
+| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate |
+| `MinResponseDataRate` | `double?` | null (uses global) | Per-protocol minimum response bytes/sec override |
+| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate |
## HTTP/2 Options
@@ -62,16 +70,23 @@ Access via `options.Http2`.
|----------|------|---------|-------------|
| `MaxConcurrentStreams` | `int` | 100 | Maximum concurrent streams per connection |
| `InitialConnectionWindowSize` | `int` | 1 * 1024 * 1024 | Connection-level flow control window |
-| `InitialStreamWindowSize` | `int` | 768 * 1024 | Per-stream flow control window |
+| `InitialStreamWindowSize` | `int` | 768 * 1024 | Per-stream flow control window (starting point for adaptive scaling) |
+| `MaxStreamWindowSize` | `int` | 8 * 1024 * 1024 | Upper bound for adaptive per-stream window growth |
+| `WindowScaleThresholdMultiplier` | `double` | 1.0 | Threshold multiplier for adaptive window growth; higher values grow less eagerly |
+| `EnableAdaptiveWindowScaling` | `bool` | true | Grow the per-stream receive window based on measured throughput and RTT |
| `MaxFrameSize` | `int` | 16 * 1024 | Maximum HTTP/2 frame payload size |
-| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes |
+| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) |
| `HeaderTableSize` | `int` | 4 * 1024 | HPACK dynamic table size |
-| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/2-specific body size limit |
-| `MaxResponseBufferSize` | `long` | 64 * 1024 | Response buffering before backpressure |
-| `KeepAliveTimeout` | `TimeSpan` | 130s | Connection idle timeout |
-| `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers |
-| `MinRequestBodyDataRate` | `int` | 240 | Minimum body bytes/sec |
-| `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing rate |
+| `MaxResponseBufferSize` | `long?` | null (uses global) | Response buffering before backpressure (null = uses `Limits.MaxResponseBufferSize`) |
+| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/2-specific body size limit |
+| `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Connection idle timeout |
+| `KeepAlivePingDelay` | `TimeSpan` | infinite (disabled) | Idle time after the last received frame before the server sends a keep-alive PING |
+| `KeepAlivePingTimeout` | `TimeSpan` | 20s | Max wait for a PING ACK before the connection is closed |
+| `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Time to receive request headers |
+| `MinRequestBodyDataRate` | `double?` | null (uses global) | Minimum body bytes/sec |
+| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate |
+| `MinResponseDataRate` | `double?` | null (uses global) | Minimum response bytes/sec |
+| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate |
## HTTP/3 Options
@@ -80,14 +95,43 @@ Access via `options.Http3`.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `MaxConcurrentStreams` | `int` | 100 | Maximum concurrent streams per connection |
-| `MaxHeaderListSize` | `int` | 32 * 1024 | Maximum total header bytes |
+| `MaxHeaderListSize` | `int?` | null (uses global) | Max total header bytes (null = uses `Limits.MaxRequestHeadersTotalSize`) |
| `QpackMaxTableCapacity` | `int` | 0 | QPACK dynamic table capacity (0 = static only) |
-| `EnableWebTransport` | `bool` | false | Enable WebTransport support |
-| `MaxRequestBodySize` | `long` | 30_000_000 | HTTP/3-specific body size limit |
-| `KeepAliveTimeout` | `TimeSpan` | 130s | Connection idle timeout |
-| `RequestHeadersTimeout` | `TimeSpan` | 30s | Time to receive request headers |
-| `MinRequestBodyDataRate` | `int` | 240 | Minimum body bytes/sec |
-| `MinRequestBodyDataRateGracePeriod` | `TimeSpan` | 5s | Grace period before enforcing rate |
+| `QpackBlockedStreams` | `int` | 100 | Maximum concurrent QPACK-blocked streams |
+| `MaxResponseBufferSize` | `long?` | null (uses global) | Per-stream response write buffer (null = uses `Limits.MaxResponseBufferSize`) |
+| `MaxRequestBodySize` | `long?` | null (uses global) | HTTP/3-specific body size limit |
+| `KeepAliveTimeout` | `TimeSpan?` | null (uses global) | Connection idle timeout |
+| `RequestHeadersTimeout` | `TimeSpan?` | null (uses global) | Time to receive request headers |
+| `MinRequestBodyDataRate` | `double?` | null (uses global) | Minimum body bytes/sec |
+| `MinRequestBodyDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing body rate |
+| `MinResponseDataRate` | `double?` | null (uses global) | Minimum response bytes/sec |
+| `MinResponseDataRateGracePeriod` | `TimeSpan?` | null (uses global) | Grace period before enforcing response rate |
+
+## Transport Buffers
+
+Per-endpoint backpressure thresholds for the pipes between the OS socket and the HTTP pipeline. Set via `TurboListenOptions.Transport`. All properties are nullable; each `null` property falls back to its protocol-optimized default individually (TCP buffers one pipe per connection, QUIC one pipe per stream), so you only set what you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`.
+
+| Property | Type | TCP Default | QUIC Default | Description |
+|----------|------|-------------|--------------|-------------|
+| `InputPauseThreshold` | `long?` | 1 MiB | 64 KiB | Bytes buffered on the read pipe before the OS socket is paused |
+| `InputResumeThreshold` | `long?` | 512 KiB | 32 KiB | Buffered byte count at which reading resumes |
+| `OutputPauseThreshold` | `long?` | 64 KiB | 64 KiB | Bytes buffered on the write pipe before the HTTP pipeline is paused |
+| `OutputResumeThreshold` | `long?` | 32 KiB | 32 KiB | Buffered byte count at which writing resumes |
+| `MinimumSegmentSize` | `int?` | 16 KiB | 4 KiB | Minimum pipe buffer segment size |
+
+```csharp
+options.Listen(IPAddress.Any, 8080, listen =>
+{
+ // Only the input thresholds are overridden; everything else keeps the TCP defaults
+ listen.Transport = new TransportBufferOptions
+ {
+ InputPauseThreshold = 2 * 1024 * 1024,
+ InputResumeThreshold = 1024 * 1024
+ };
+});
+```
+
+See the [Server API reference](/api/server#transport-buffer-options) for details.
## Example: Full Configuration
diff --git a/docs/server/hosting.md b/docs/server/hosting.md
index 7da367a8d..6b8da995f 100644
--- a/docs/server/hosting.md
+++ b/docs/server/hosting.md
@@ -45,12 +45,9 @@ TurboHTTP Server uses this actor structure:
ActorSystem (turbo-server)
├── ServerSupervisorActor
│ ├── ListenerActor (endpoint 127.0.0.1:5100)
- │ │ ├── ConnectionActor (active connection 1)
- │ │ ├── ConnectionActor (active connection 2)
- │ │ └── ...
+ │ │ └── ConnectionStage (Akka.Streams GraphStage — handles all active connections)
│ └── ListenerActor (endpoint 127.0.0.1:5101)
- │ ├── ConnectionActor (active connection 3)
- │ └── ...
+ │ └── ConnectionStage (Akka.Streams GraphStage — handles all active connections)
```
### ServerSupervisorActor
@@ -67,43 +64,38 @@ When shutdown begins, the supervisor tells all listeners to stop accepting new c
Each endpoint has one listener. It:
- Binds the transport (TCP port or QUIC/UDP port)
-- Accepts incoming connections
-- Creates a ConnectionActor for each new connection
+- Accepts incoming connections via a `ConnectionStage`
- Enforces MaxConcurrentConnections limit (when configured)
-When a connection arrives, the listener materializes the full HTTP processing pipeline into a new actor and tells it to run.
+When a connection arrives, the `ConnectionStage` GraphStage materializes the full HTTP processing pipeline as a sub-graph for that connection.
-### ConnectionActor
+### ConnectionStage
-Each active connection runs in a ConnectionActor. It:
-- Materializes the complete Akka.Streams graph:
+Connections are not managed by per-connection actors. Instead, each `ListenerActor` runs a single `ConnectionStage` — an Akka.Streams `GraphStage` — that sub-fuses a new streaming pipeline for every incoming connection. It:
+- Materializes the complete Akka.Streams graph per connection:
- Transport inbound/outbound flow
- Protocol engine (HTTP/1.0, 1.1, 2, or 3)
- ApplicationBridgeStage → IHttpApplication<TContext> → ASP.NET Core pipeline
-- Holds a kill switch to stop processing cleanly
-- Reports completion (success, error, or shutdown) back to the supervisor
-
-Once the handler completes or the connection closes, the ConnectionActor terminates and reports the completion reason.
+- Holds a shared drain kill switch to stop all connections cleanly during shutdown
+- Tracks active connection count and completes the stage when drained
## Connection Lifecycle
From the moment a client connects until it closes, here's what happens:
1. **Connection arrives**: ListenerActor receives an incoming connection from the transport
-2. **ConnectionActor spawned**: A new actor is created for this connection, watched by the listener
-3. **Pipeline materialized**: The full Akka.Streams graph is wired up:
+2. **Pipeline materialized**: The `ConnectionStage` GraphStage sub-fuses a new Akka.Streams graph for the connection:
- Protocol engine decodes transport bytes into IFeatureCollection
- ApplicationBridgeStage creates TContext via IHttpApplication.CreateContext()
- ASP.NET Core middleware pipeline processes the request
- Response features are encoded back to bytes and sent
-4. **Request loop**: The connection waits for the next request (keep-alive) or closes
-5. **Completion**: When the connection closes (client disconnect, keep-alive timeout, error):
- - ConnectionActor reports completion reason to supervisor
- - Actor terminates
+3. **Request loop**: The connection waits for the next request (keep-alive) or closes
+4. **Completion**: When the connection closes (client disconnect, keep-alive timeout, error):
+ - The sub-graph completes and the active connection count in `ConnectionStage` decrements
- Resources are cleaned up
::: tip Keep-Alive Behavior
-HTTP/1.1 connections reuse the same ConnectionActor for multiple requests. Each request flows through the pipeline independently, but the TCP/TLS connection and actor stay alive. HTTP/2 and 3 multiplex streams within one connection, all handled by the same actor.
+HTTP/1.1 connections reuse the same TCP/TLS connection for multiple requests. Each request flows through the pipeline independently, but the connection and its sub-graph stay alive. HTTP/2 and HTTP/3 multiplex streams within one connection, all handled by the same materialized pipeline.
:::
## Graceful Shutdown
@@ -117,8 +109,8 @@ When your application receives a shutdown signal (SIGTERM, Ctrl+C, or explicit `
- Already-connected clients can still send requests
3. **Coordinated Shutdown phase 2 — ServiceUnbind**:
- ServerSupervisorActor receives `BeginDrain` message
- - All ConnectionActors receive `GracefulStop` with a timeout value
- - Each connection cancels its pipeline (sends back `HTTP/1.1 503 Service Unavailable` or TCP RST for HTTP/2)
+ - The shared drain kill switch on each `ConnectionStage` is triggered
+ - Each active connection pipeline is cancelled (sends back `HTTP/1.1 503 Service Unavailable` or TCP RST for HTTP/2)
- In-flight requests are interrupted
4. **Drain wait**: The application waits for up to `GracefulShutdownTimeout` (default 30 seconds)
- Connections finish their active work and close
@@ -152,7 +144,7 @@ Key options control server and connection behavior:
builder.Host.UseTurboHttp(options =>
{
// Limit concurrent connections (0 = unlimited)
- options.MaxConcurrentConnections = 1000;
+ options.Limits.MaxConcurrentConnections = 1000;
// Limit concurrent HTTP/2 streams per connection
options.Http2.MaxConcurrentStreams = 100;
@@ -168,10 +160,10 @@ builder.Host.UseTurboHttp(options =>
builder.Host.UseTurboHttp(options =>
{
// Time to wait for the next request on keep-alive connections
- options.KeepAliveTimeout = TimeSpan.FromSeconds(120);
+ options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(130);
// Time to wait for request headers (includes TLS handshake)
- options.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
+ options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
// Time to wait for request body to arrive
options.BodyConsumptionTimeout = TimeSpan.FromSeconds(30);
@@ -186,9 +178,9 @@ builder.Host.UseTurboHttp(options =>
```csharp
builder.Host.UseTurboHttp(options =>
{
- // Buffer size before reading request body into memory
- // Larger uploads are streamed
- options.BodyBufferThreshold = 64 * 1024; // 64 KB
+ // Max request body size buffered fully in memory (HTTP/1.x)
+ // Larger bodies are exposed as a streaming pipe with backpressure
+ options.Http1.MaxBufferedRequestBodySize = 64 * 1024; // 64 KB
// Chunk size when writing response body
options.ResponseBodyChunkSize = 16 * 1024; // 16 KB
@@ -210,7 +202,7 @@ builder.Host.UseTurboHttp(options =>
// HTTP/3 settings
options.Http3.MaxHeaderListSize = 8192;
- options.Http3.EnableWebTransport = false;
+ options.Http3.QpackBlockedStreams = 100;
});
```
diff --git a/docs/server/index.md b/docs/server/index.md
index 952847a0e..fa060c931 100644
--- a/docs/server/index.md
+++ b/docs/server/index.md
@@ -57,7 +57,7 @@ TurboHTTP implements these ASP.NET Core feature interfaces per request:
| `IHttpRequestBodyDetectionFeature` | Whether the request has a body |
| `IHttpResponseTrailersFeature` | HTTP trailer headers |
| `IHttpConnectionFeature` | Connection ID, local/remote addresses |
-| `ITlsHandshakeFeature` | TLS protocol, cipher suite |
+| `ITlsHandshakeFeature` | TLS protocol, cipher suite (TurboHTTP's own interface: `TurboHTTP.Server.Context.Features.ITlsHandshakeFeature`) |
| `IHttpRequestLifetimeFeature` | Request abort token |
| `IHttpRequestIdentifierFeature` | Unique request identifier |
| `IHttpMaxRequestBodySizeFeature` | Request body size limit |
diff --git a/docs/server/installation.md b/docs/server/installation.md
index b4dd6185c..412c80ded 100644
--- a/docs/server/installation.md
+++ b/docs/server/installation.md
@@ -120,7 +120,7 @@ builder.Host.UseTurboHttp(options =>
| Protocol | Value | Transport | Notes |
|----------|-------|-----------|-------|
-| `Http1` | HTTP/1.1 only | TCP | Maximum compatibility |
+| `Http1` | HTTP/1.0 and HTTP/1.1 | TCP | Maximum compatibility |
| `Http2` | HTTP/2 only | TCP | Multiplexing, HPACK compression |
| `Http1AndHttp2` | Both (default) | TCP | ALPN negotiation selects protocol |
| `Http3` | HTTP/3 | QUIC (UDP) | Requires HTTPS |
@@ -215,36 +215,27 @@ options.ListenLocalhost(5000, listen =>
});
```
-## Configuration from appsettings.json
+## Configuration via Code
-TurboHTTP reads endpoint configuration from the `TurboHTTP` section:
+TurboHTTP is configured imperatively through `UseTurboHttp()`. All endpoint and TLS settings are passed as `Action`:
-```json
+```csharp
+var config = builder.Configuration;
+
+builder.Host.UseTurboHttp(options =>
{
- "TurboHTTP": {
- "Endpoints": {
- "Http": {
- "Url": "http://localhost:5000"
- },
- "Https": {
- "Url": "https://localhost:5001",
- "Protocols": "Http1AndHttp2",
- "Certificate": {
- "Path": "certs/server.pfx",
- "Password": "changeit"
- },
- "SslProtocols": "Tls12, Tls13"
- }
- },
- "HttpsDefaults": {
- "SslProtocols": "Tls13",
- "HandshakeTimeout": "00:00:30"
- }
- }
-}
+ // Read from IConfiguration if desired
+ var port = config.GetValue("Server:Port") ?? 5000;
+
+ options.ListenLocalhost(port);
+ options.ListenLocalhost(5001, listen =>
+ {
+ listen.UseHttps();
+ });
+});
```
-Endpoint names (`Http`, `Https`) are arbitrary — use meaningful names for your setup.
+There is no automatic binding from `appsettings.json` — you control which config values feed into `TurboServerOptions` at startup.
## Next Steps
diff --git a/docs/server/performance.md b/docs/server/performance.md
index 7078cb9f8..d27c6730b 100644
--- a/docs/server/performance.md
+++ b/docs/server/performance.md
@@ -31,10 +31,10 @@ Higher values improve throughput for clients sending many parallel requests. Low
### Request Body Buffer
```csharp
-options.BodyBufferThreshold = 128 * 1024; // 128 KB
+options.Http1.MaxBufferedRequestBodySize = 128 * 1024; // 128 KB
```
-Default is 64 KB. Request bodies smaller than this threshold are buffered in memory. Larger bodies stream directly to the application.
+Default is 64 KB. HTTP/1.x request bodies up to this size are buffered fully in memory. Larger bodies are exposed to the application as a streaming pipe with backpressure.
- **Increase** for APIs that commonly receive medium-sized payloads (64-256 KB)
- **Decrease** for memory-constrained environments or very large upload workloads
diff --git a/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md b/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md
deleted file mode 100644
index 6a15f28d1..000000000
--- a/docs/superpowers/plans/2026-05-27-iserver-pipeline-redesign.md
+++ /dev/null
@@ -1,1456 +0,0 @@
-# IServer Pipeline Redesign — Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Strip TurboHTTP to a pure transport+protocol layer where `IFeatureCollection` is the stream element, `ApplicationBridgeStage` bridges directly to ASP.NET's `IHttpApplication`, and all custom routing/context types are deleted.
-
-**Architecture:** The Akka Streams pipeline changes from `Protocol → RequestContext → RoutingStage → TurboHttpContext → Handler` to `Protocol → IFeatureCollection → ApplicationBridgeStage → IFeatureCollection → Response Encoder`. Everything between protocol decoding and ASP.NET's `IHttpApplication` is a single generic stage. No wrappers, no custom routing, no custom context types.
-
-**Tech Stack:** C# 13, .NET 10, Akka.NET Streams, ASP.NET Core `IServer`/`IHttpApplication`, xUnit v3
-
-**Spec:** `docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md`
-
----
-
-## File Map
-
-### Files to Create
-- `src/TurboHTTP/Server/FeatureCollectionFactory.cs` — Pooled factory replacing ServerContextFactory
-
-### Files to Rewrite
-- `src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs` — Full rewrite as `ApplicationBridgeStage`
-
-### Files to Modify (Production)
-| File | Change |
-|------|--------|
-| `src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs` | Ports: `RequestContext` → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs` | `OnRequest(IFeatureCollection)`, remove `TurboConnectionInfo` |
-| `src/TurboHTTP/Protocol/IServerStateMachine.cs` | `OnResponse(IFeatureCollection)` |
-| `src/TurboHTTP/Streams/IServerProtocolEngine.cs` | BidiFlow generic args → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs` | Queue/port types → `IFeatureCollection` |
-| `src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs` | Self-contained CTS, no RequestContext dep |
-| `src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs` | Self-contained TraceIdentifier, no RequestContext dep |
-| `src/TurboHTTP/Context/Features/TurboHttpConnectionFeature.cs` | Remove TurboConnectionInfo dep, use fields directly |
-| `src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory |
-| `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory |
-| `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs` | `Encode(Span, IFeatureCollection, ...)` |
-| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs` | `OnResponse(IFeatureCollection)` |
-| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory |
-| `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs` | `EncodeHeaders(IFeatureCollection, ...)` |
-| `src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs` | `IFeatureCollection` instead of `RequestContext` |
-| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs` | `OnResponse(IFeatureCollection)` |
-| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs` | `OnResponse(IFeatureCollection)`, use FeatureCollectionFactory |
-| `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs` | `EncodeHeaders(IFeatureCollection)` |
-| `src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs` | `OnResponse(IFeatureCollection)` |
-| `src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs` | Remove RequestContext using if present |
-| `src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs` | Shape port casts → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs` | Shape port casts → `IFeatureCollection` |
-| `src/TurboHTTP/Streams/Http10ServerEngine.cs` | BidiFlow type args |
-| `src/TurboHTTP/Streams/Http11ServerEngine.cs` | BidiFlow type args |
-| `src/TurboHTTP/Streams/Http20ServerEngine.cs` | BidiFlow type args |
-| `src/TurboHTTP/Streams/Http30ServerEngine.cs` | BidiFlow type args |
-| `src/TurboHTTP/Streams/NegotiatingServerEngine.cs` | BidiFlow type args |
-| `src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs` | Remove `TurboRequestDelegate`/`RouteTable`, add bridge stage flow |
-| `src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs` | Remove `TurboRequestDelegate`/`RouteTable`, use bridge stage |
-| `src/TurboHTTP/Server/TurboServer.cs` | Wire `IHttpApplication` through to bridge stage |
-| `src/TurboHTTP/Server/TurboServerServiceCollectionExtensions.cs` | Remove RouteTable DI |
-
-### Files to Delete
-| File | Reason |
-|------|--------|
-| `src/TurboHTTP/Streams/Stages/Server/RequestContext.cs` | Replaced by `IFeatureCollection` |
-| `src/TurboHTTP/Server/TurboHttpContext.cs` | ASP.NET builds own `HttpContext` |
-| `src/TurboHTTP/Context/TurboHttpRequest.cs` | No consumer |
-| `src/TurboHTTP/Context/TurboHttpResponse.cs` | No consumer |
-| `src/TurboHTTP/Server/TurboConnectionInfo.cs` | ASP.NET uses `IHttpConnectionFeature` |
-| `src/TurboHTTP/Streams/Stages/Server/RoutingStage.cs` | No custom routing |
-| `src/TurboHTTP/Server/RouteTable.cs` | No custom routing |
-| `src/TurboHTTP/Server/TurboRequestDelegate.cs` | No custom pipeline |
-| `src/TurboHTTP/Server/ServerContextFactory.cs` | Replaced by FeatureCollectionFactory |
-
-### Test Files to Modify
-| File | Change |
-|------|--------|
-| `src/TurboHTTP.Tests.Shared/FakeServerOps.cs` | `OnRequest(IFeatureCollection)`, `List` |
-| `src/TurboHTTP.Tests.Shared/ServerTestContext.cs` | Return `IFeatureCollection` instead of `RequestContext` |
-| `src/TurboHTTP.Tests.Shared/ServerTestContextBuilder.cs` | `Build()` returns `IFeatureCollection` |
-| `src/TurboHTTP.Tests/Server/ServerContextFactorySpec.cs` | Rename + test FeatureCollectionFactory |
-| `src/TurboHTTP.Tests/Server/ContextPoolingSpec.cs` | Test IFeatureCollection pooling |
-| All state machine specs (~15 files) | Use `IFeatureCollection` for OnResponse calls |
-
----
-
-## Task 1: Self-Contained Feature Implementations
-
-Remove `RequestContext` dependency from the two feature types that delegate to it. After this task these features own their own state.
-
-**Files:**
-- Modify: `src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs`
-- Modify: `src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs`
-
-- [ ] **Step 1: Rewrite TurboHttpRequestLifetimeFeature**
-
-Replace the RequestContext-delegating implementation with self-contained state:
-
-```csharp
-using Microsoft.AspNetCore.Http.Features;
-
-namespace TurboHTTP.Context.Features;
-
-internal sealed class TurboHttpRequestLifetimeFeature : IHttpRequestLifetimeFeature
-{
- public CancellationToken RequestAborted { get; set; }
-
- public void Abort() => RequestAborted = new CancellationToken(true);
-}
-```
-
-- [ ] **Step 2: Rewrite TurboHttpRequestIdentifierFeature**
-
-Replace the RequestContext-delegating implementation with self-contained state:
-
-```csharp
-using Microsoft.AspNetCore.Http.Features;
-
-namespace TurboHTTP.Context.Features;
-
-internal sealed class TurboHttpRequestIdentifierFeature : IHttpRequestIdentifierFeature
-{
- public string TraceIdentifier
- {
- get => field ??= Guid.NewGuid().ToString("N");
- set;
- }
-}
-```
-
-- [ ] **Step 3: Commit**
-
-```
-git add src/TurboHTTP/Context/Features/TurboHttpRequestLifetimeFeature.cs src/TurboHTTP/Context/Features/TurboHttpRequestIdentifierFeature.cs
-git commit -m "refactor: make lifetime and identifier features self-contained"
-```
-
----
-
-## Task 2: FeatureCollectionFactory
-
-Replace `ServerContextFactory` (which returns `RequestContext`) with `FeatureCollectionFactory` (returns `IFeatureCollection`). The factory uses the same thread-static pooling pattern.
-
-**Files:**
-- Create: `src/TurboHTTP/Server/FeatureCollectionFactory.cs`
-- Modify: `src/TurboHTTP/Context/Features/TurboHttpConnectionFeature.cs` (check if it depends on TurboConnectionInfo constructor)
-
-- [ ] **Step 1: Read TurboHttpConnectionFeature to check its constructor**
-
-Check what TurboHttpConnectionFeature needs — it currently takes a `TurboConnectionInfo`. We need to understand if we change this now or later.
-
-Run: Grep for `class TurboHttpConnectionFeature` and its constructor.
-
-- [ ] **Step 2: Create FeatureCollectionFactory**
-
-Create the new factory at `src/TurboHTTP/Server/FeatureCollectionFactory.cs`:
-
-```csharp
-using Microsoft.AspNetCore.Http.Features;
-using TurboHTTP.Context.Features;
-
-namespace TurboHTTP.Server;
-
-internal static class FeatureCollectionFactory
-{
- [ThreadStatic]
- private static Stack? t_pool;
-
- private const int MaxPoolSize = 32;
-
- public static IFeatureCollection Create(
- TurboHttpRequestFeature requestFeature,
- bool hasBody,
- IServiceProvider? services = null,
- IHttpConnectionFeature? connectionFeature = null,
- TlsHandshakeFeature? tlsFeature = null)
- {
- TurboFeatureCollection features;
-
- if ((t_pool?.Count ?? 0) > 0)
- {
- features = t_pool!.Pop();
- }
- else
- {
- features = new TurboFeatureCollection();
- }
-
- features.Set(requestFeature);
-
- var bodyFeature = new TurboRequestBodyFeature { Body = requestFeature.Body };
- features.Set(bodyFeature);
-
- var responseFeature = new TurboHttpResponseFeature();
- features.Set(responseFeature);
-
- var detectionFeature = new TurboHttpRequestBodyDetectionFeature(hasBody);
- features.Set(detectionFeature);
-
- var responseBodyFeature = new TurboHttpResponseBodyFeature();
- features.Set(responseBodyFeature);
-
- var trailersFeature = new TurboHttpResponseTrailersFeature();
- features.Set(trailersFeature);
-
- if (connectionFeature is not null)
- {
- features.Set(connectionFeature);
- }
-
- if (tlsFeature is not null)
- {
- features.Set(tlsFeature);
- }
-
- var lifetimeFeature = new TurboHttpRequestLifetimeFeature();
- features.Set(lifetimeFeature);
-
- var identifierFeature = new TurboHttpRequestIdentifierFeature();
- features.Set(identifierFeature);
-
- return features;
- }
-
- internal static void Return(IFeatureCollection features)
- {
- if (features is not TurboFeatureCollection turboFeatures)
- {
- return;
- }
-
- t_pool ??= new Stack(MaxPoolSize);
-
- if (t_pool.Count < MaxPoolSize)
- {
- t_pool.Push(turboFeatures);
- }
- }
-}
-```
-
-Note: The old `ServerContextFactory` took `TurboConnectionInfo?` and created `TurboHttpConnectionFeature` internally. The new factory takes `IHttpConnectionFeature?` directly — the connection feature is created by `HttpConnectionServerStageLogic` from transport info (Task 5 will update this).
-
-- [ ] **Step 3: Commit**
-
-```
-git add src/TurboHTTP/Server/FeatureCollectionFactory.cs
-git commit -m "feat: add FeatureCollectionFactory returning IFeatureCollection"
-```
-
----
-
-## Task 3: Core Interface Changes
-
-Change all four core interfaces/types to use `IFeatureCollection` instead of `RequestContext`. The codebase will NOT compile after this task until Tasks 4-6 are complete.
-
-**Files:**
-- Modify: `src/TurboHTTP/Protocol/IServerStateMachine.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs`
-- Modify: `src/TurboHTTP/Streams/IServerProtocolEngine.cs`
-
-- [ ] **Step 1: Update IServerStateMachine**
-
-```csharp
-using Microsoft.AspNetCore.Http.Features;
-using Servus.Akka.Transport;
-
-namespace TurboHTTP.Protocol;
-
-internal interface IServerStateMachine
-{
- bool CanAcceptResponse { get; }
- bool ShouldComplete { get; }
- int MaxQueuedRequests { get; }
-
- void PreStart();
- void OnResponse(IFeatureCollection features);
- void DecodeClientData(ITransportInbound data);
- void OnDownstreamFinished();
- void OnTimerFired(string name);
- void OnBodyMessage(object msg);
- void Cleanup();
-}
-```
-
-- [ ] **Step 2: Update IServerStageOperations**
-
-Remove `TurboConnectionInfo` property (it becomes an `IHttpConnectionFeature` created by the stage logic). Change `OnRequest` parameter:
-
-```csharp
-using Akka.Actor;
-using Akka.Event;
-using Akka.Streams;
-using Microsoft.AspNetCore.Http.Features;
-using Servus.Akka.Transport;
-using TurboHTTP.Context.Features;
-
-namespace TurboHTTP.Streams.Stages.Server;
-
-internal interface IServerStageOperations
-{
- void OnRequest(IFeatureCollection features);
- void OnOutbound(ITransportOutbound item);
- void OnScheduleTimer(string name, TimeSpan delay);
- void OnCancelTimer(string name);
- ILoggingAdapter Log { get; }
- IActorRef StageActor { get; }
- IMaterializer Materializer { get; }
- IServiceProvider? Services => null;
- IHttpConnectionFeature? ConnectionFeature => null;
- TlsHandshakeFeature? TlsHandshakeFeature => null;
-}
-```
-
-- [ ] **Step 3: Update ServerConnectionShape**
-
-Replace all `RequestContext` port types with `IFeatureCollection`:
-
-```csharp
-using System.Collections.Immutable;
-using Akka.Streams;
-using Microsoft.AspNetCore.Http.Features;
-using Servus.Akka.Transport;
-
-namespace TurboHTTP.Streams.Stages.Server;
-
-internal sealed class ServerConnectionShape : Shape
-{
- public Inlet InNetwork { get; }
- public Outlet OutRequest { get; }
- public Inlet InResponse { get; }
- public Outlet OutNetwork { get; }
-
- public ServerConnectionShape(
- Inlet inNetwork,
- Outlet outRequest,
- Inlet inResponse,
- Outlet outNetwork)
- {
- InNetwork = inNetwork;
- OutRequest = outRequest;
- InResponse = inResponse;
- OutNetwork = outNetwork;
- }
-
- public override ImmutableArray Inlets => [InNetwork, InResponse];
-
- public override ImmutableArray Outlets => [OutRequest, OutNetwork];
-
- public override Shape DeepCopy()
- {
- return new ServerConnectionShape(
- (Inlet)InNetwork.CarbonCopy(),
- (Outlet)OutRequest.CarbonCopy(),
- (Inlet)InResponse.CarbonCopy(),
- (Outlet)OutNetwork.CarbonCopy());
- }
-
- public override Shape CopyFromPorts(ImmutableArray inlets, ImmutableArray outlets)
- {
- return new ServerConnectionShape(
- (Inlet)inlets[0],
- (Outlet)outlets[0],
- (Inlet)inlets[1],
- (Outlet)outlets[1]);
- }
-}
-```
-
-- [ ] **Step 4: Update IServerProtocolEngine**
-
-```csharp
-using Akka;
-using Akka.Streams.Dsl;
-using Microsoft.AspNetCore.Http.Features;
-using Servus.Akka.Transport;
-
-namespace TurboHTTP.Streams;
-
-internal interface IServerProtocolEngine
-{
- BidiFlow CreateFlow(
- IServiceProvider? services = null);
-}
-```
-
-- [ ] **Step 5: Commit**
-
-```
-git add src/TurboHTTP/Protocol/IServerStateMachine.cs src/TurboHTTP/Streams/Stages/Server/IServerStageOperations.cs src/TurboHTTP/Streams/Stages/Server/ServerConnectionShape.cs src/TurboHTTP/Streams/IServerProtocolEngine.cs
-git commit -m "refactor!: change core interfaces from RequestContext to IFeatureCollection"
-```
-
----
-
-## Task 4: Protocol Encoder + StreamState Changes
-
-Update all protocol encoders and H2 StreamState to accept `IFeatureCollection` instead of `RequestContext`. These are mechanical: every encoder already does `context.Features.Get()` — change the parameter name from `context` to `features` and remove the `.Features` indirection.
-
-**Files:**
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs`
-
-- [ ] **Step 1: Update Http11ServerEncoder**
-
-Change `Encode` signature from `RequestContext context` to `IFeatureCollection features`. Replace all `context.Features.Get()` with `features.Get()`. Remove `using TurboHTTP.Streams.Stages.Server;`, add `using Microsoft.AspNetCore.Http.Features;` if not already present.
-
-The method signature becomes:
-```csharp
-public int Encode(Span destination, IFeatureCollection features, bool isChunked = false, bool connectionClose = false)
-```
-
-All internal access changes from `context.Features.Get()` to `features.Get()`.
-
-- [ ] **Step 2: Update Http2ServerEncoder**
-
-Change `EncodeHeaders` signature:
-```csharp
-public IReadOnlyList EncodeHeaders(IFeatureCollection features, int streamId, bool hasBody)
-```
-
-Change `BuildHeaderList` signature:
-```csharp
-private static void BuildHeaderList(IFeatureCollection features, List headers)
-```
-
-Replace all `context.Features.Get()` → `features.Get()`.
-
-- [ ] **Step 3: Update Http3ServerEncoder**
-
-Change `EncodeHeaders` signature:
-```csharp
-public HeadersFrame EncodeHeaders(IFeatureCollection features)
-```
-
-Change `BuildHeaderList` signature:
-```csharp
-private static void BuildHeaderList(IFeatureCollection features, List<(string Name, string Value)> headers)
-```
-
-Replace all `context.Features.Get()` → `features.Get()`.
-
-- [ ] **Step 4: Update Http2 StreamState**
-
-Replace the `RequestContext` field and methods:
-- Change `private RequestContext? _requestContext;` to `private IFeatureCollection? _features;`
-- Change `SetTurboContext(RequestContext context)` to `SetFeatures(IFeatureCollection features)` → `_features = features;`
-- Change `GetTurboContext()` to `GetFeatures()` → `return _features;`
-- Remove `using TurboHTTP.Streams.Stages.Server;`, add `using Microsoft.AspNetCore.Http.Features;`
-
-- [ ] **Step 5: Commit**
-
-```
-git add src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerEncoder.cs src/TurboHTTP/Protocol/Syntax/Http2/StreamState.cs
-git commit -m "refactor: update protocol encoders to accept IFeatureCollection"
-```
-
----
-
-## Task 5: Protocol State Machines + Session Managers
-
-Update all `OnResponse` implementations and request-creation paths to use `IFeatureCollection` and `FeatureCollectionFactory`.
-
-**Files:**
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs`
-- Modify: `src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs`
-- Modify: `src/TurboHTTP/Protocol/ProtocolNegotiatingStateMachine.cs`
-
-- [ ] **Step 1: Update Http10ServerStateMachine**
-
-Two changes:
-1. Request creation (in `DecodeClientData`): Replace `ServerContextFactory.Create(feature, hasBody, ...)` with `FeatureCollectionFactory.Create(feature, hasBody, ...)`. Change `_ops.OnRequest(context)` to `_ops.OnRequest(features)` (variable rename).
-2. `OnResponse`: Change signature to `public void OnResponse(IFeatureCollection features)`. Replace `context.Features.Get()` → `features.Get()`.
-
-Update using: replace `using TurboHTTP.Streams.Stages.Server;` with nothing (no longer needed for RequestContext). Add `using Microsoft.AspNetCore.Http.Features;` if missing. Keep `using TurboHTTP.Server;` for FeatureCollectionFactory.
-
-- [ ] **Step 2: Update Http11ServerStateMachine**
-
-Same two changes:
-1. Request creation (~line 139): `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)`, variable `context` → `features`.
-2. `OnResponse` (~line 167): Change parameter to `IFeatureCollection features`. Replace all `context.Features.Get()` → `features.Get()`. The encoder call changes from `_encoder.Encode(span, context, ...)` to `_encoder.Encode(span, features, ...)`.
-
-- [ ] **Step 3: Update Http2ServerStateMachine + SessionManager**
-
-StateMachine: `public void OnResponse(IFeatureCollection features) => _sessionManager.OnResponse(features);`
-
-SessionManager `OnResponse` (~line 129): Change parameter to `IFeatureCollection features`. Replace `context.Features.Get()` → `features.Get()`. The `GetStreamIdFromContext(context)` call needs to change to `GetStreamIdFromFeatures(features)` (or inline: `features.Get()?.StreamId ?? -1`).
-
-SessionManager request creation (~line 541): Replace `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)`. Change `context.Features.Set(...)` → `features.Set(...)`. The StreamState call `state.SetTurboContext(context)` → `state.SetFeatures(features)`.
-
-The encoder call `_responseEncoder.EncodeHeaders(context, streamId, hasBody)` → `_responseEncoder.EncodeHeaders(features, streamId, hasBody)`.
-
-- [ ] **Step 4: Update Http3ServerStateMachine + SessionManager**
-
-Same pattern as H2:
-
-StateMachine: `public void OnResponse(IFeatureCollection features) => _sessionManager.OnResponse(features);`
-
-SessionManager: Same changes as H2 — parameter rename, `FeatureCollectionFactory.Create()`, `features.Get/Set`, encoder accepts `IFeatureCollection`.
-
-- [ ] **Step 5: Update ProtocolNegotiatingStateMachine**
-
-```csharp
-public void OnResponse(IFeatureCollection features) => _inner!.OnResponse(features);
-```
-
-Remove `using TurboHTTP.Streams.Stages.Server;` if no longer needed.
-
-- [ ] **Step 6: Commit**
-
-```
-git add src/TurboHTTP/Protocol/
-git commit -m "refactor: update all state machines and session managers to IFeatureCollection"
-```
-
----
-
-## Task 6: HttpConnectionServerStageLogic + Connection Stages + Engines
-
-Update the core stage logic, all five connection stages, and all five engine classes. The connection stages and engines are mostly mechanical port-type changes.
-
-**Files:**
-- Modify: `src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/Http10ServerConnectionStage.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/Http11ServerConnectionStage.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/Http20ServerConnectionStage.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/Http30ServerConnectionStage.cs`
-- Modify: `src/TurboHTTP/Streams/Stages/Server/ProtocolNegotiatorConnectionStage.cs`
-- Modify: `src/TurboHTTP/Streams/Http10ServerEngine.cs`
-- Modify: `src/TurboHTTP/Streams/Http11ServerEngine.cs`
-- Modify: `src/TurboHTTP/Streams/Http20ServerEngine.cs`
-- Modify: `src/TurboHTTP/Streams/Http30ServerEngine.cs`
-- Modify: `src/TurboHTTP/Streams/NegotiatingServerEngine.cs`
-
-- [ ] **Step 1: Update HttpConnectionServerStageLogic**
-
-Key changes:
-1. Field types: `Outlet` → `Outlet`, `Inlet` → `Inlet`, `Queue` → `Queue`
-2. `OnRequest(IFeatureCollection features)` implementation: same logic, different type
-3. Response handler (`_inResponse` onPush): `var response = Grab(_inResponse);` now gives `IFeatureCollection`. `_sm.OnResponse(response)` already matches new interface. `response.Features.Get()` → `response.Get()` (no `.Features` needed). `ServerContextFactory.Return(response)` → `FeatureCollectionFactory.Return(response)`.
-4. Connection info: The `_connectionInfo` field changes from `TurboConnectionInfo?` to `TurboHttpConnectionFeature?`. In `OnNetworkPush` where `TransportConnected` is handled, create `TurboHttpConnectionFeature` directly instead of `TurboConnectionInfo`.
-5. Remove `using TurboHTTP.Server;` for TurboConnectionInfo. Add `using Microsoft.AspNetCore.Http.Features;`.
-
-The `IServerStageOperations.ConnectionInfo` property changes from `TurboConnectionInfo?` to `IHttpConnectionFeature?` — return `_connectionFeature`.
-
-- [ ] **Step 2: Update all five connection stages**
-
-Each connection stage defines four ports with explicit types. Update port declarations:
-
-```csharp
-// Before
-private readonly Outlet _outRequest = new("Http11.Request.Out");
-private readonly Inlet _inResponse = new("Http11.Response.In");
-
-// After
-private readonly Outlet _outRequest = new("Http11.Request.Out");
-private readonly Inlet _inResponse = new("Http11.Response.In");
-```
-
-Add `using Microsoft.AspNetCore.Http.Features;`, remove `using TurboHTTP.Streams.Stages.Server;` if RequestContext was the only reason.
-
-Apply to: `Http10ServerConnectionStage`, `Http11ServerConnectionStage`, `Http20ServerConnectionStage`, `Http30ServerConnectionStage`, `ProtocolNegotiatorConnectionStage`.
-
-- [ ] **Step 3: Update all five engine classes**
-
-Each engine's `CreateFlow` method returns a `BidiFlow`. Change to `BidiFlow`.
-
-Apply to: `Http10ServerEngine`, `Http11ServerEngine`, `Http20ServerEngine`, `Http30ServerEngine`, `NegotiatingServerEngine`.
-
-Add `using Microsoft.AspNetCore.Http.Features;`, remove `using TurboHTTP.Streams.Stages.Server;` if no longer needed.
-
-- [ ] **Step 4: Commit**
-
-```
-git add src/TurboHTTP/Streams/
-git commit -m "refactor: update stage logic, connection stages, and engines to IFeatureCollection"
-```
-
----
-
-## Task 7: Rewrite ApplicationBridgeStage\
-
-Full rewrite of ApplicationBridgeStage as a generic stage that directly holds `IHttpApplication`. Shape changes to `FlowShape`.
-
-**Files:**
-- Rewrite: `src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs`
-
-- [ ] **Step 1: Write the new ApplicationBridgeStage\**
-
-```csharp
-using Akka.Actor;
-using Akka.Streams;
-using Akka.Streams.Stage;
-using Microsoft.AspNetCore.Hosting.Server;
-using Microsoft.AspNetCore.Http.Features;
-using TurboHTTP.Context.Features;
-
-namespace TurboHTTP.Streams.Stages.Server;
-
-internal sealed class ApplicationBridgeStage : GraphStage>
- where TContext : notnull
-{
- private readonly IHttpApplication _application;
- private readonly int _parallelism;
- private readonly TimeSpan _handlerTimeout;
- private readonly TimeSpan _handlerGracePeriod;
-
- private readonly Inlet _in = new("AppBridge.In");
- private readonly Outlet _out = new("AppBridge.Out");
-
- public override FlowShape Shape { get; }
-
- public ApplicationBridgeStage(
- IHttpApplication application,
- int parallelism,
- TimeSpan handlerTimeout,
- TimeSpan handlerGracePeriod)
- {
- _application = application;
- _parallelism = parallelism;
- _handlerTimeout = handlerTimeout;
- _handlerGracePeriod = handlerGracePeriod;
- Shape = new FlowShape(_in, _out);
- }
-
- protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this);
-
- private sealed record DispatchCompleted(int Sequence, IFeatureCollection Features);
-
- private sealed record DispatchFailed(int Sequence, IFeatureCollection Features, Exception Error);
-
- private sealed record ResponseReady(int Sequence, IFeatureCollection Features, Task HandlerTask);
-
- private sealed record HandlerFinished(int Sequence, IFeatureCollection Features);
-
- private sealed record HandlerFaulted(int Sequence, IFeatureCollection Features, Exception Error);
-
- private sealed record HandlerTimedOut(int Sequence, IFeatureCollection Features);
-
- private sealed class Logic : GraphStageLogic
- {
- private readonly ApplicationBridgeStage _stage;
- private IActorRef? _stageActor;
- private bool _upstreamFinished;
- private int _inFlight;
- private int _sequence;
- private int _nextToEmit;
- private bool _downstreamReady;
- private readonly SortedDictionary _pending = [];
- private readonly Dictionary _activeTimeouts = [];
- private readonly Dictionary _appContexts = [];
-
- public Logic(ApplicationBridgeStage stage) : base(stage.Shape)
- {
- _stage = stage;
-
- SetHandler(stage._in,
- onPush: OnPush,
- onUpstreamFinish: () =>
- {
- _upstreamFinished = true;
- if (_inFlight == 0)
- {
- CompleteStage();
- }
- });
-
- SetHandler(stage._out,
- onPull: () =>
- {
- _downstreamReady = true;
- TryEmitPending();
- TryPullNext();
- });
- }
-
- public override void PreStart()
- {
- _stageActor = GetStageActor(OnMessage).Ref;
- Pull(_stage._in);
- }
-
- private void OnPush()
- {
- var features = Grab(_stage._in);
- var seq = _sequence++;
-
- _inFlight++;
-
- try
- {
- DispatchAsync(features, seq);
- }
- catch (Exception)
- {
- _inFlight--;
- var responseFeature = features.Get();
- if (responseFeature is not null)
- {
- responseFeature.StatusCode = 500;
- }
- CompleteResponseBody(features);
- Emit(seq, features);
- }
-
- TryPullNext();
- }
-
- private void DispatchAsync(IFeatureCollection features, int seq)
- {
- TContext appContext;
- try
- {
- appContext = _stage._application.CreateContext(features);
- _appContexts[seq] = appContext;
- }
- catch (Exception)
- {
- _inFlight--;
- var responseFeature = features.Get();
- if (responseFeature is not null)
- {
- responseFeature.StatusCode = 500;
- }
- CompleteResponseBody(features);
- Emit(seq, features);
- return;
- }
-
- var task = _stage._application.ProcessRequestAsync(appContext);
-
- if (task.IsCompletedSuccessfully)
- {
- _inFlight--;
- _stage._application.DisposeContext(appContext, null);
- _appContexts.Remove(seq);
- CompleteResponseBody(features);
- Emit(seq, features);
- }
- else if (task.IsFaulted)
- {
- _inFlight--;
- var responseFeature = features.Get();
- if (responseFeature is not null)
- {
- responseFeature.StatusCode = 500;
- }
- _stage._application.DisposeContext(appContext, task.Exception);
- _appContexts.Remove(seq);
- CompleteResponseBody(features);
- Emit(seq, features);
- }
- else
- {
- var lifetime = features.Get();
- var cts = lifetime is not null
- ? CancellationTokenSource.CreateLinkedTokenSource(lifetime.RequestAborted)
- : new CancellationTokenSource();
- cts.CancelAfter(_stage._handlerTimeout);
- _activeTimeouts[seq] = cts;
-
- var bodyFeature = features.Get() as TurboHttpResponseBodyFeature;
- var headersReady = bodyFeature?.WhenHeadersReady;
-
- Task.Delay(_stage._handlerTimeout + _stage._handlerGracePeriod, cts.Token)
- .PipeTo(_stageActor!,
- success: () => new HandlerTimedOut(seq, features));
-
- if (headersReady is not null)
- {
- Task.WhenAny(headersReady, task)
- .PipeTo(_stageActor!,
- success: () => new ResponseReady(seq, features, task));
- }
- else
- {
- task.PipeTo(_stageActor!,
- success: () => new DispatchCompleted(seq, features),
- failure: ex => new DispatchFailed(seq, features, ex));
- }
- }
- }
-
- private void OnMessage((IActorRef sender, object msg) args)
- {
- switch (args.msg)
- {
- case ResponseReady(var seq, var features, var handlerTask):
- if (handlerTask.IsFaulted)
- {
- if (features.Get() is not TurboHttpResponseBodyFeature
- {
- HasStarted: true
- })
- {
- var responseFeature = features.Get();
- if (responseFeature is not null)
- {
- responseFeature.StatusCode = 500;
- }
- }
- }
-
- if (handlerTask.IsCompleted)
- {
- CompleteResponseBody(features);
- _inFlight--;
- DisposeCts(seq);
- DisposeAppContext(seq, handlerTask.Exception);
- Emit(seq, features);
- }
- else
- {
- Emit(seq, features);
- handlerTask.PipeTo(_stageActor!,
- success: () => new HandlerFinished(seq, features),
- failure: ex => new HandlerFaulted(seq, features, ex));
- }
-
- break;
-
- case HandlerFinished(var seq, var finishedFeatures):
- CompleteResponseBody(finishedFeatures);
- _inFlight--;
- DisposeCts(seq);
- DisposeAppContext(seq, null);
- if (_upstreamFinished && _inFlight == 0)
- {
- CompleteStage();
- }
-
- break;
-
- case HandlerFaulted(var seq, var faultedFeatures, var error):
- CompleteResponseBody(faultedFeatures);
- _inFlight--;
- DisposeCts(seq);
- DisposeAppContext(seq, error);
- if (_upstreamFinished && _inFlight == 0)
- {
- CompleteStage();
- }
-
- break;
-
- case DispatchCompleted(var seq, var features):
- _inFlight--;
- DisposeCts(seq);
- DisposeAppContext(seq, null);
- CompleteResponseBody(features);
- Emit(seq, features);
- break;
-
- case DispatchFailed(var seq, var features, var error):
- _inFlight--;
- DisposeCts(seq);
- DisposeAppContext(seq, error);
- var respFeature = features.Get();
- if (respFeature is not null)
- {
- respFeature.StatusCode = 500;
- }
- CompleteResponseBody(features);
- Emit(seq, features);
- break;
-
- case HandlerTimedOut(var seq, var features):
- if (_activeTimeouts.TryGetValue(seq, out var cts))
- {
- cts.Dispose();
- _activeTimeouts.Remove(seq);
- var respFeatureTimeout = features.Get();
- if (respFeatureTimeout is not null && respFeatureTimeout.StatusCode == 200)
- {
- respFeatureTimeout.StatusCode = 503;
- CompleteResponseBody(features);
- _inFlight--;
- DisposeAppContext(seq, null);
- Emit(seq, features);
- }
- }
-
- break;
- }
-
- if (_upstreamFinished && _inFlight == 0 && _pending.Count == 0)
- {
- CompleteStage();
- }
- }
-
- private void DisposeAppContext(int seq, Exception? exception)
- {
- if (_appContexts.TryGetValue(seq, out var appCtx))
- {
- _stage._application.DisposeContext(appCtx, exception);
- _appContexts.Remove(seq);
- }
- }
-
- private void DisposeCts(int seq)
- {
- if (_activeTimeouts.TryGetValue(seq, out var cts))
- {
- cts.Dispose();
- _activeTimeouts.Remove(seq);
- }
- }
-
- private void TryPullNext()
- {
- if (_inFlight < _stage._parallelism && !HasBeenPulled(_stage._in))
- {
- Pull(_stage._in);
- }
- }
-
- private void Emit(int seq, IFeatureCollection features)
- {
- _pending[seq] = features;
- TryEmitPending();
- }
-
- private void TryEmitPending()
- {
- while (_downstreamReady && _pending.Count > 0 && _pending.Keys.First() == _nextToEmit)
- {
- _downstreamReady = false;
- Push(_stage._out, _pending[_nextToEmit]);
- _pending.Remove(_nextToEmit);
- _nextToEmit++;
- }
- }
-
- private static void CompleteResponseBody(IFeatureCollection features)
- {
- var bodyFeature = features.Get() as TurboHttpResponseBodyFeature;
- bodyFeature?.Complete();
- }
- }
-}
-```
-
-Key improvements over old version:
-- Generic `` — no type erasure, `_appContexts` is `Dictionary` not `Dictionary`
-- `IFeatureCollection` directly — no RequestContext wrapper
-- Consolidated `DisposeAppContext` helper — reduces duplication
-- Lifetime CTS from `IHttpRequestLifetimeFeature` — no `RequestContext.Lifetime`
-
-- [ ] **Step 2: Commit**
-
-```
-git add src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs
-git commit -m "refactor!: rewrite ApplicationBridgeStage as generic with IHttpApplication"
-```
-
----
-
-## Task 8: Actor + Server Integration
-
-Update `ListenerActor`, `ConnectionActor`, and `TurboServer` to use `ApplicationBridgeStage` instead of `RoutingStage`. The actors receive the bridge flow instead of routing delegate + route table.
-
-**Files:**
-- Modify: `src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs`
-- Modify: `src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs`
-- Modify: `src/TurboHTTP/Server/TurboServer.cs`
-
-- [ ] **Step 1: Update ConnectionActor**
-
-Change the `Materialize` record to receive a `Flow` instead of `TurboRequestDelegate` + `RouteTable`:
-
-```csharp
-public sealed record Materialize(
- Flow ConnectionFlow,
- IServerProtocolEngine Engine,
- Flow BridgeFlow,
- IServiceProvider Services,
- IMaterializer Materializer,
- string? ConnectionLoggingCategory = null);
-```
-
-In `OnMaterialize`, replace the RoutingStage with the bridge flow:
-
-```csharp
-private void OnMaterialize(Materialize msg)
-{
- _log.Debug("Connection {0} materializing pipeline", _connectionId);
-
- _killSwitch = KillSwitches.Shared("connection-" + _connectionId);
-
- var protocolBidi = msg.Engine.CreateFlow(msg.Services);
- var composed = protocolBidi.Join(msg.BridgeFlow);
-
- // ... rest of logging and pipeline assembly unchanged ...
-
- var completionTask = pipeline
- .ViaMaterialized(
- Flow.Create().WatchTermination(Keep.Right),
- Keep.Right)
- .Join(composed)
- .Run(msg.Materializer);
-
- completionTask.PipeTo(self,
- success: () => new StreamCompleted(null),
- failure: ex => new StreamCompleted(ex));
-}
-```
-
-Remove `using TurboHTTP.Server;` for RouteTable/TurboRequestDelegate. Remove `using TurboHTTP.Streams.Stages.Server;` for RoutingStage. Add `using Microsoft.AspNetCore.Http.Features;`.
-
-- [ ] **Step 2: Update ListenerActor**
-
-Remove `TurboRequestDelegate` and `RouteTable` fields/params. Add `Flow` bridge flow:
-
-Constructor changes:
-```csharp
-public ListenerActor(
- IListenerFactory factory,
- ListenerOptions listenerOptions,
- TurboServerOptions serverOptions,
- Flow bridgeFlow,
- IServiceProvider services,
- IMaterializer materializer,
- string? connectionLoggingCategory = null)
-```
-
-`Create` factory method:
-```csharp
-public static Props Create(
- IListenerFactory factory,
- ListenerOptions listenerOptions,
- TurboServerOptions serverOptions,
- Flow bridgeFlow,
- IServiceProvider services,
- IMaterializer materializer,
- string? connectionLoggingCategory = null)
- => Props.Create(() => new ListenerActor(
- factory, listenerOptions, serverOptions,
- bridgeFlow, services, materializer,
- connectionLoggingCategory));
-```
-
-In `OnIncomingConnection`, the `Materialize` message changes:
-```csharp
-child.Tell(new ConnectionActor.Materialize(
- msg.ConnectionFlow,
- engine,
- _bridgeFlow,
- _services,
- _materializer,
- _connectionLoggingCategory));
-```
-
-- [ ] **Step 3: Update TurboServer**
-
-Wire `IHttpApplication` through to the bridge stage:
-
-```csharp
-public async Task StartAsync(
- IHttpApplication application,
- CancellationToken cancellationToken) where TContext : notnull
-{
- _system = _services.GetService();
- if (_system is null)
- {
- var setup = BootstrapSetup.Create()
- .WithConfig(LoggingHocon)
- .And(new LoggerFactorySetup(_loggerFactory));
- _system = ActorSystem.Create("turbo-server", setup);
- _ownsSystem = true;
- }
-
- var materializer = _system.Materializer();
-
- // Parallelism controls max in-flight requests per connection in the bridge.
- // Use H2 MaxConcurrentStreams as a reasonable default (100).
- // H1.1 connections are sequential anyway; H2/H3 benefit from parallel dispatch.
- var parallelism = _options.Http2.MaxConcurrentStreams;
- var bridgeStage = new ApplicationBridgeStage(
- application,
- parallelism,
- _options.HandlerTimeout,
- _options.HandlerGracePeriod);
- var bridgeFlow = Flow.FromGraph(bridgeStage);
-
- var resolver = new EndpointResolver();
- var resolvedEndpoints = resolver.Resolve(_options);
-
- var listenerProps = new List(resolvedEndpoints.Count);
- foreach (var endpoint in resolvedEndpoints)
- {
- listenerProps.Add(ListenerActor.Create(
- endpoint.Factory,
- endpoint.Options,
- _options,
- bridgeFlow,
- _services,
- materializer,
- endpoint.ConnectionLoggingCategory));
- }
-
- // ... rest unchanged (supervisor, coordinated shutdown) ...
-}
-```
-
-Remove the dead-code `TurboRequestDelegate pipeline = _ => Task.CompletedTask;` and `new TurboRouteTable().Freeze()`.
-
-Note: If `TurboServerOptions.Limits.MaxConcurrentRequests` doesn't exist yet, use a sensible default (e.g., `_options.Http2.MaxConcurrentStreams` or hardcode `100`). Check what property is available.
-
-- [ ] **Step 4: Commit**
-
-```
-git add src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs src/TurboHTTP/Server/TurboServer.cs
-git commit -m "refactor!: wire IHttpApplication through actors to ApplicationBridgeStage"
-```
-
----
-
-## Task 9: Delete Old Types + DI Cleanup
-
-Remove all types that are no longer referenced. Clean up DI registration.
-
-**Files:**
-- Delete: `src/TurboHTTP/Streams/Stages/Server/RequestContext.cs`
-- Delete: `src/TurboHTTP/Server/TurboHttpContext.cs`
-- Delete: `src/TurboHTTP/Context/TurboHttpRequest.cs`
-- Delete: `src/TurboHTTP/Context/TurboHttpResponse.cs`
-- Delete: `src/TurboHTTP/Server/TurboConnectionInfo.cs`
-- Delete: `src/TurboHTTP/Streams/Stages/Server/RoutingStage.cs`
-- Delete: `src/TurboHTTP/Server/RouteTable.cs`
-- Delete: `src/TurboHTTP/Server/TurboRequestDelegate.cs`
-- Delete: `src/TurboHTTP/Server/ServerContextFactory.cs`
-- Modify: `src/TurboHTTP/Server/TurboServerServiceCollectionExtensions.cs`
-
-- [ ] **Step 1: Delete all obsolete files**
-
-Delete each file listed above. Use `git rm` or filesystem delete.
-
-- [ ] **Step 2: Clean up TurboServerServiceCollectionExtensions**
-
-Remove any `RouteTable`-related registration. The `AddTurboKestrel` methods that registered `TurboRouteTable` should just register `IServer → TurboServer` and options. Check if there's a `TurboRouteTable` singleton registration to remove.
-
-Looking at the current code, `AddTurboKestrel` doesn't register RouteTable (TurboServer created it inline). No changes needed beyond verifying no compilation errors from deleted types.
-
-- [ ] **Step 3: Remove stale using directives**
-
-Grep for `using TurboHTTP.Streams.Stages.Server;` and `using TurboHTTP.Server;` across the codebase and remove any that now reference only deleted types. Key files to check:
-- `src/TurboHTTP/Protocol/IProtocolSwitchCapable.cs` — may have unused using for RequestContext
-
-- [ ] **Step 4: Attempt compilation**
-
-Run: `dotnet build --configuration Release src/TurboHTTP.slnx`
-
-Fix any remaining compilation errors from missed references to deleted types.
-
-- [ ] **Step 5: Commit**
-
-```
-git add -A
-git commit -m "refactor!: delete RequestContext, TurboHttpContext, RoutingStage, and all custom routing types"
-```
-
----
-
-## Task 10: Test Helper Updates
-
-Update the shared test helpers to work with `IFeatureCollection` instead of `RequestContext`.
-
-**Files:**
-- Modify: `src/TurboHTTP.Tests.Shared/FakeServerOps.cs`
-- Modify: `src/TurboHTTP.Tests.Shared/ServerTestContext.cs`
-- Modify: `src/TurboHTTP.Tests.Shared/ServerTestContextBuilder.cs`
-
-- [ ] **Step 1: Update FakeServerOps**
-
-```csharp
-using Akka.Actor;
-using Akka.Event;
-using Akka.Streams;
-using Microsoft.AspNetCore.Http.Features;
-using Servus.Akka.Transport;
-using TurboHTTP.Streams.Stages.Server;
-
-namespace TurboHTTP.Tests.Shared;
-
-internal sealed class FakeServerOps : IServerStageOperations
-{
- private readonly List _features = [];
-
- public List Requests => _features;
- public List Outbound { get; } = [];
- public List<(string Name, TimeSpan Delay)> ScheduledTimers { get; } = [];
- public List CancelledTimers { get; } = [];
-
- public void OnRequest(IFeatureCollection features) => _features.Add(features);
- public void OnOutbound(ITransportOutbound item) => Outbound.Add(item);
-
- public void OnScheduleTimer(string name, TimeSpan delay)
- {
- ScheduledTimers.RemoveAll(t => t.Name == name);
- ScheduledTimers.Add((name, delay));
- }
-
- public void OnCancelTimer(string name)
- {
- ScheduledTimers.RemoveAll(t => t.Name == name);
- CancelledTimers.Add(name);
- }
-
- public ILoggingAdapter Log => NoLogger.Instance;
- public IActorRef StageActor { get; set; } = ActorRefs.Nobody;
- public IMaterializer Materializer { get; set; } = null!;
-}
-```
-
-- [ ] **Step 2: Update ServerTestContext**
-
-```csharp
-using Microsoft.AspNetCore.Http.Features;
-using TurboHTTP.Context.Features;
-
-namespace TurboHTTP.Tests.Shared;
-
-internal static class ServerTestContext
-{
- internal static ServerTestContextBuilder Request() => new();
-
- internal static IFeatureCollection CreateResponse(int statusCode = 200)
- {
- var features = new TurboFeatureCollection();
- features.Set(new TurboHttpRequestFeature());
- var responseFeature = new TurboHttpResponseFeature { StatusCode = statusCode };
- features.Set(responseFeature);
- var bodyFeature = new TurboHttpResponseBodyFeature();
- features.Set(bodyFeature);
- return features;
- }
-
- internal static IFeatureCollection CreateH2Response(int streamId, int statusCode = 200)
- {
- var features = CreateResponse(statusCode);
- features.Set(new TurboStreamIdFeature(streamId));
- return features;
- }
-
- internal static IFeatureCollection CreateH3Response(long streamId, int statusCode = 200)
- {
- var features = CreateResponse(statusCode);
- features.Set(new TurboStreamIdFeature(streamId));
- return features;
- }
-}
-```
-
-- [ ] **Step 3: Update ServerTestContextBuilder**
-
-Change `Build()` to return `IFeatureCollection` instead of `RequestContext`. Remove `TurboConnectionInfo` creation — create `TurboHttpConnectionFeature` directly with the connection data:
-
-```csharp
-public IFeatureCollection Build()
-{
- var features = new TurboFeatureCollection();
- var requestFeature = BuildRequestFeature();
- features.Set(requestFeature);
- var requestBodyFeature = new TurboRequestBodyFeature
- {
- Body = requestFeature.Body,
- BodySource = _bodySource ?? Source.Empty>()
- };
- features.Set(new TurboHttpResponseFeature());
-
- if (_connection is not null)
- {
- features.Set(_connection);
- }
-
- var bodyFeature = new TurboHttpResponseBodyFeature();
- features.Set(bodyFeature);
-
- var lifetimeFeature = new TurboHttpRequestLifetimeFeature();
- if (_cancellationToken != CancellationToken.None)
- {
- lifetimeFeature.RequestAborted = _cancellationToken;
- }
- features.Set(lifetimeFeature);
-
- return features;
-}
-```
-
-Change `Connection(TurboConnectionInfo)` to `Connection(IHttpConnectionFeature)` — update the field type from `TurboConnectionInfo?` to `IHttpConnectionFeature?`.
-
-Remove usings for deleted types.
-
-- [ ] **Step 4: Commit**
-
-```
-git add src/TurboHTTP.Tests.Shared/
-git commit -m "refactor: update test helpers to use IFeatureCollection"
-```
-
----
-
-## Task 11: Unit Test Updates
-
-Update all state machine test specs that call `OnResponse(RequestContext)` or construct `RequestContext`. These tests use `ServerTestContext.CreateResponse()` and `FakeServerOps.Requests` — both now return/hold `IFeatureCollection`.
-
-**Files:** All state machine spec files in `src/TurboHTTP.Tests/Protocol/`
-
-- [ ] **Step 1: Bulk-update OnResponse calls in test files**
-
-The test pattern changes from:
-```csharp
-var response = ServerTestContext.CreateResponse(200);
-sm.OnResponse(response);
-```
-to the same code — the return type changed but the call site is identical since `ServerTestContext.CreateResponse()` now returns `IFeatureCollection`.
-
-The main change needed: where tests access `response.Features.Get()`, change to `response.Get()` (no `.Features` property on `IFeatureCollection`).
-
-Grep across `src/TurboHTTP.Tests/` for `\.Features\.Get` and `\.Features\.Set` to find all sites that need updating.
-
-Also grep for `new RequestContext` — these direct constructions need to change to creating `TurboFeatureCollection` directly.
-
-- [ ] **Step 2: Update ServerContextFactorySpec**
-
-Rename file to `FeatureCollectionFactorySpec.cs`. Change all `ServerContextFactory.Create(...)` → `FeatureCollectionFactory.Create(...)` and `ServerContextFactory.Return(...)` → `FeatureCollectionFactory.Return(...)`. The return type changes from `RequestContext` to `IFeatureCollection`, so `ctx.Features.Get()` → `features.Get()`.
-
-- [ ] **Step 3: Update ContextPoolingSpec**
-
-Same pattern: `ServerContextFactory.Create/Return` → `FeatureCollectionFactory.Create/Return`. Return types are `IFeatureCollection`.
-
-- [ ] **Step 4: Remove usings for deleted types**
-
-Grep `src/TurboHTTP.Tests/` for `using TurboHTTP.Streams.Stages.Server;` and remove where the only usage was `RequestContext`. Same for `using TurboHTTP.Server;` where only `TurboConnectionInfo` was used.
-
-- [ ] **Step 5: Attempt test compilation**
-
-Run: `dotnet build src/TurboHTTP.Tests/TurboHTTP.Tests.csproj`
-
-Fix any remaining compilation errors.
-
-- [ ] **Step 6: Run tests**
-
-Run: `dotnet run --project src/TurboHTTP.Tests/TurboHTTP.Tests.csproj`
-
-All existing tests should pass since the behavioral logic is unchanged — only the carrier type changed.
-
-- [ ] **Step 7: Commit**
-
-```
-git add src/TurboHTTP.Tests/
-git commit -m "refactor: update all unit tests to use IFeatureCollection"
-```
-
----
-
-## Task 12: Integration Test + API Surface Cleanup
-
-Update integration tests (which use RouteTable/TurboRequestDelegate) and the API surface verification test. Integration tests need to be updated to use ASP.NET's `IHttpApplication` pattern or temporarily disabled.
-
-**Files:**
-- Modify: `src/TurboHTTP.IntegrationTests.Server/Shared/ServerSpecBase.cs`
-- Modify: Integration test specs that use `ConfigureRoutes`
-- Modify: `src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt`
-- Modify: `src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ListenerActorConnectionLimitSpec.cs`
-
-- [ ] **Step 1: Assess integration test scope**
-
-The integration tests use `ServerSpecBase` which configures routes via `TurboRouteTable`. Since we deleted `RouteTable` and `TurboRequestDelegate`, these tests need to be rewritten to use ASP.NET's `WebApplication` or `IHost` pattern with `TurboServer` as the `IServer`.
-
-This is a significant rewrite. Read `ServerSpecBase.cs` to understand the current pattern, then decide: rewrite now or mark as `[Fact(Skip = "Pending IServer integration")]`.
-
-Given the scope, the recommended approach is to **temporarily skip** integration tests with a clear skip reason, then fix them in a follow-up task. The unit tests validate the protocol layer; integration tests validate end-to-end with a real ASP.NET host.
-
-- [ ] **Step 2: Update ListenerActorConnectionLimitSpec**
-
-This unit test constructs `ListenerActor` with the old signature. Update to match the new constructor (bridge flow instead of routing delegate + route table).
-
-- [ ] **Step 3: Update API surface verification**
-
-The `CoreAPISpec.ApproveCore.DotNet.verified.txt` file lists the public API. Deleted public types (`TurboHttpContext`, `TurboHttpRequest`, `TurboHttpResponse`, `TurboConnectionInfo`, `TurboRequestDelegate`, `RouteTable`) must be removed from the verified file.
-
-Run: `dotnet run --project src/TurboHTTP.API.Tests/TurboHTTP.API.Tests.csproj` to regenerate the verified file, then approve changes.
-
-- [ ] **Step 4: Full build + test**
-
-```
-dotnet build --configuration Release src/TurboHTTP.slnx
-dotnet run --project src/TurboHTTP.Tests/TurboHTTP.Tests.csproj
-```
-
-- [ ] **Step 5: Commit**
-
-```
-git add -A
-git commit -m "refactor: update integration tests and API surface for IServer pipeline"
-```
-
----
-
-## Task 13: Documentation + CLAUDE.md Update
-
-Update CLAUDE.md to reflect the new architecture. Remove references to deleted types.
-
-**Files:**
-- Modify: `CLAUDE.md`
-
-- [ ] **Step 1: Update Architecture section in CLAUDE.md**
-
-Remove:
-- `Context` line referencing `TurboHttpRequest, TurboHttpResponse, Adapters, Features` — change to just `Context (TurboHTTP/Context/) - Features/ (IHttp*Feature implementations), Adapters/`
-- `Routing` line — delete entirely
-
-Remove from Build & Test:
-- Any integration test commands that reference deleted features
-
-Update Code Style if any rules reference `TurboHttpContext`.
-
-- [ ] **Step 2: Commit**
-
-```
-git add CLAUDE.md
-git commit -m "docs: update CLAUDE.md for IServer pipeline architecture"
-```
-
----
-
-## Parallelism Map
-
-Tasks that can run in parallel (after their dependencies complete):
-
-```
-Task 1 (features) ──┐
- ├── Task 2 (factory) ──┐
-Task 3 (interfaces) ┤ │
- ├── Task 4 (encoders) ─┤
- │ ├── Task 5 (state machines)
- ├── Task 6 (stages) ───┤
- │ ├── Task 7 (bridge stage)
- │ │
- └──────────────────────┴── Task 8 (actors + server) ── Task 9 (delete) ── Task 10 (test helpers) ── Task 11 (unit tests) ── Task 12 (integration) ── Task 13 (docs)
-```
-
-**Independent groups after Task 3:**
-- Tasks 4+5 (protocol layer)
-- Task 6 (stage layer)
-- Task 7 (bridge stage)
-
-These three groups can be dispatched in parallel. Task 8 depends on all three completing.
diff --git a/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md b/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md
deleted file mode 100644
index 5fa759b46..000000000
--- a/docs/superpowers/specs/2026-05-27-iserver-pipeline-redesign.md
+++ /dev/null
@@ -1,226 +0,0 @@
-# TurboHTTP IServer Pipeline Redesign
-
-## Summary
-
-Strip TurboHTTP down to a pure transport+protocol layer. Remove all custom routing, context types, and middleware abstractions. ASP.NET's `IHttpApplication` becomes the sole request-handling contract. The Akka Streams pipeline carries `IFeatureCollection` directly — no wrapper types.
-
-## Goals
-
-- TurboHTTP is a drop-in `IServer` replacement for Kestrel
-- ASP.NET Middleware, Controllers, Minimal APIs run natively on TurboHTTP
-- No custom routing, no custom context, no custom pipeline delegate
-- `IFeatureCollection` is the only data contract between protocol layer and application layer
-- Parallel request dispatch with sequence ordering (H2/H3 multiplexing)
-
-## Non-Goals
-
-- Custom TurboHTTP routing or middleware system
-- TurboHTTP-specific handler APIs
-- Standalone server mode (without ASP.NET hosting)
-
----
-
-## Architecture
-
-### Stream Element: `IFeatureCollection`
-
-The Akka Streams pipeline element changes from `RequestContext` to `IFeatureCollection`.
-
-**Before:**
-```
-Protocol Decoder → RequestContext → RoutingStage → TurboHttpContext → Handler
-```
-
-**After:**
-```
-Protocol Decoder → IFeatureCollection → ApplicationBridgeStage → IFeatureCollection → Response Encoder
-```
-
-No wrapper, no intermediate context type. The feature collection IS the request.
-
-### ApplicationBridgeStage\
-
-Generic Akka `GraphStage>` that bridges Akka Streams to ASP.NET's `IHttpApplication`.
-
-```csharp
-internal sealed class ApplicationBridgeStage
- : GraphStage>
- where TContext : notnull
-{
- private readonly IHttpApplication _application;
- private readonly int _parallelism;
- private readonly TimeSpan _handlerTimeout;
- private readonly TimeSpan _handlerGracePeriod;
-}
-```
-
-**Key properties:**
-- Holds `IHttpApplication` directly — no type erasure, no adapter, no `Func