diff -pruN 0.23.12-1/.editorconfig 0.55.0-1/.editorconfig
--- 0.23.12-1/.editorconfig	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.editorconfig	2025-10-23 12:07:18.000000000 +0000
@@ -3,3 +3,7 @@ root = true
 [*]
 indent_style = tab
 indent_size = 2
+
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
diff -pruN 0.23.12-1/.github/copilot-instructions.md 0.55.0-1/.github/copilot-instructions.md
--- 0.23.12-1/.github/copilot-instructions.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.github/copilot-instructions.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,20 @@
+# GitHub Copilot Instructions
+
+## Use Agent Context
+
+When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards.
+
+If the file does not exist, you will need to install it, by running the following command:
+
+```bash
+$ bundle install
+$ bundle exec bake agent:context:install
+```
+
+This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions.
+
+## Ignoring Files
+
+The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled.
+
+While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request.
diff -pruN 0.23.12-1/.github/workflows/coverage.yaml 0.55.0-1/.github/workflows/coverage.yaml
--- 0.23.12-1/.github/workflows/coverage.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.github/workflows/coverage.yaml	1970-01-01 00:00:00.000000000 +0000
@@ -1,57 +0,0 @@
-name: Coverage
-
-on: [push, pull_request]
-
-permissions:
-  contents: read
-
-env:
-  CONSOLE_OUTPUT: XTerm
-  COVERAGE: PartialSummary
-
-jobs:
-  test:
-    name: ${{matrix.ruby}} on ${{matrix.os}}
-    runs-on: ${{matrix.os}}-latest
-    
-    strategy:
-      matrix:
-        os:
-          - ubuntu
-          - macos
-        
-        ruby:
-          - "3.1"
-    
-    steps:
-    - uses: actions/checkout@v3
-    - uses: ruby/setup-ruby@v1
-      with:
-        ruby-version: ${{matrix.ruby}}
-        bundler-cache: true
-    
-    - name: Run tests
-      timeout-minutes: 5
-      run: bundle exec rspec
-
-    - uses: actions/upload-artifact@v2
-      with:
-        name: coverage-${{matrix.os}}-${{matrix.ruby}}
-        path: .covered.db
-  
-  validate:
-    needs: test
-    runs-on: ubuntu-latest
-    
-    steps:
-    - uses: actions/checkout@v3
-    - uses: ruby/setup-ruby@v1
-      with:
-        ruby-version: "3.1"
-        bundler-cache: true
-    
-    - uses: actions/download-artifact@v3
-    
-    - name: Validate coverage
-      timeout-minutes: 5
-      run: bundle exec bake covered:validate --paths */.covered.db \;
diff -pruN 0.23.12-1/.github/workflows/documentation-coverage.yaml 0.55.0-1/.github/workflows/documentation-coverage.yaml
--- 0.23.12-1/.github/workflows/documentation-coverage.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.github/workflows/documentation-coverage.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,24 @@
+name: Documentation Coverage
+
+on: [push, pull_request]
+
+permissions:
+  contents: read
+
+env:
+  COVERAGE: PartialSummary
+
+jobs:
+  validate:
+    runs-on: ubuntu-latest
+    
+    steps:
+    - uses: actions/checkout@v4
+    - uses: ruby/setup-ruby@v1
+      with:
+        ruby-version: ruby
+        bundler-cache: true
+    
+    - name: Validate coverage
+      timeout-minutes: 5
+      run: bundle exec bake decode:index:coverage lib
diff -pruN 0.23.12-1/.github/workflows/documentation.yaml 0.55.0-1/.github/workflows/documentation.yaml
--- 0.23.12-1/.github/workflows/documentation.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.github/workflows/documentation.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -1,41 +1,57 @@
 name: Documentation
 
-permissions:
-  contents: write
-
 on:
   push:
     branches:
       - main
 
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages:
 permissions:
-  contents: write
+  contents: read
+  pages: write
+  id-token: write
+
+# Allow one concurrent deployment:
+concurrency:
+  group: "pages"
+  cancel-in-progress: true
 
 env:
-  CONSOLE_OUTPUT: XTerm
   BUNDLE_WITH: maintenance
 
 jobs:
-  deploy:
+  generate:
     runs-on: ubuntu-latest
     
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - uses: ruby/setup-ruby@v1
       with:
-        ruby-version: "3.1"
+        ruby-version: ruby
         bundler-cache: true
     
     - name: Installing packages
       run: sudo apt-get install wget
     
-    - name: Prepare GitHub Pages
-      run: bundle exec bake github:pages:prepare --directory docs
-    
     - name: Generate documentation
       timeout-minutes: 5
       run: bundle exec bake utopia:project:static --force no
     
-    - name: Deploy GitHub Pages
-      run: bundle exec bake github:pages:commit --directory docs
+    - name: Upload documentation artifact
+      uses: actions/upload-pages-artifact@v3
+      with:
+        path: docs
+  
+  deploy:
+    runs-on: ubuntu-latest
+    
+    environment:
+      name: github-pages
+      url: ${{steps.deployment.outputs.page_url}}
+    
+    needs: generate
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v4
diff -pruN 0.23.12-1/.github/workflows/rubocop.yaml 0.55.0-1/.github/workflows/rubocop.yaml
--- 0.23.12-1/.github/workflows/rubocop.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.github/workflows/rubocop.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,21 @@
+name: RuboCop
+
+on: [push, pull_request]
+
+permissions:
+  contents: read
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    
+    steps:
+    - uses: actions/checkout@v4
+    - uses: ruby/setup-ruby@v1
+      with:
+        ruby-version: ruby
+        bundler-cache: true
+    
+    - name: Run RuboCop
+      timeout-minutes: 10
+      run: bundle exec rubocop
diff -pruN 0.23.12-1/.github/workflows/test-coverage.yaml 0.55.0-1/.github/workflows/test-coverage.yaml
--- 0.23.12-1/.github/workflows/test-coverage.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.github/workflows/test-coverage.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,58 @@
+name: Test Coverage
+
+on: [push, pull_request]
+
+permissions:
+  contents: read
+
+env:
+  COVERAGE: PartialSummary
+
+jobs:
+  test:
+    name: ${{matrix.ruby}} on ${{matrix.os}}
+    runs-on: ${{matrix.os}}-latest
+    
+    strategy:
+      matrix:
+        os:
+          - ubuntu
+          - macos
+        
+        ruby:
+          - ruby
+    
+    steps:
+    - uses: actions/checkout@v4
+    - uses: ruby/setup-ruby@v1
+      with:
+        ruby-version: ${{matrix.ruby}}
+        bundler-cache: true
+    
+    - name: Run tests
+      timeout-minutes: 5
+      run: bundle exec bake test
+    
+    - uses: actions/upload-artifact@v4
+      with:
+        include-hidden-files: true
+        if-no-files-found: error
+        name: coverage-${{matrix.os}}-${{matrix.ruby}}
+        path: .covered.db
+  
+  validate:
+    needs: test
+    runs-on: ubuntu-latest
+    
+    steps:
+    - uses: actions/checkout@v4
+    - uses: ruby/setup-ruby@v1
+      with:
+        ruby-version: ruby
+        bundler-cache: true
+    
+    - uses: actions/download-artifact@v4
+    
+    - name: Validate coverage
+      timeout-minutes: 5
+      run: bundle exec bake covered:validate --paths */.covered.db \;
diff -pruN 0.23.12-1/.github/workflows/test-external.yaml 0.55.0-1/.github/workflows/test-external.yaml
--- 0.23.12-1/.github/workflows/test-external.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.github/workflows/test-external.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -5,9 +5,6 @@ on: [push, pull_request]
 permissions:
   contents: read
 
-env:
-  CONSOLE_OUTPUT: XTerm
-
 jobs:
   test:
     name: ${{matrix.ruby}} on ${{matrix.os}}
@@ -20,11 +17,12 @@ jobs:
           - macos
         
         ruby:
-          - "3.0"
-          - "3.1"
+          - "3.2"
+          - "3.3"
+          - "3.4"
     
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - uses: ruby/setup-ruby@v1
       with:
         ruby-version: ${{matrix.ruby}}
diff -pruN 0.23.12-1/.github/workflows/test.yaml 0.55.0-1/.github/workflows/test.yaml
--- 0.23.12-1/.github/workflows/test.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.github/workflows/test.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -5,9 +5,6 @@ on: [push, pull_request]
 permissions:
   contents: read
 
-env:
-  CONSOLE_OUTPUT: XTerm
-
 jobs:
   test:
     name: ${{matrix.ruby}} on ${{matrix.os}}
@@ -21,9 +18,9 @@ jobs:
           - macos
         
         ruby:
-          - "2.7"
-          - "3.0"
-          - "3.1"
+          - "3.2"
+          - "3.3"
+          - "3.4"
         
         experimental: [false]
         
@@ -39,7 +36,7 @@ jobs:
             experimental: true
     
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - uses: ruby/setup-ruby@v1
       with:
         ruby-version: ${{matrix.ruby}}
diff -pruN 0.23.12-1/.gitignore 0.55.0-1/.gitignore
--- 0.23.12-1/.gitignore	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.gitignore	2025-10-23 12:07:18.000000000 +0000
@@ -1,14 +1,7 @@
-/.bundle/
-/.yardoc
-/_yardoc/
-/coverage/
-/doc/
-/pkg/
-/spec/reports/
-/tmp/
-/external/
-
-# rspec failure tracking
-.rspec_status
+/agents.md
+/.context
+/.bundle
+/pkg
 /gems.locked
-.covered.db
+/.covered.db
+/external
diff -pruN 0.23.12-1/.mailmap 0.55.0-1/.mailmap
--- 0.23.12-1/.mailmap	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.mailmap	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,2 @@
+Dan Olson <olson_dan@yahoo.com>
+Thomas Morgan <tm@iprog.com>
diff -pruN 0.23.12-1/.rspec 0.55.0-1/.rspec
--- 0.23.12-1/.rspec	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/.rspec	1970-01-01 00:00:00.000000000 +0000
@@ -1,3 +0,0 @@
---format documentation
---warnings
---require spec_helper
\ No newline at end of file
diff -pruN 0.23.12-1/.rubocop.yml 0.55.0-1/.rubocop.yml
--- 0.23.12-1/.rubocop.yml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/.rubocop.yml	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,71 @@
+plugins:
+  - rubocop-socketry
+
+AllCops:
+  DisabledByDefault: true
+
+Layout/ConsistentBlankLineIndentation:
+  Enabled: true
+
+Layout/IndentationStyle:
+  Enabled: true
+  EnforcedStyle: tabs
+
+Layout/InitialIndentation:
+  Enabled: true
+
+Layout/IndentationWidth:
+  Enabled: true
+  Width: 1
+
+Layout/IndentationConsistency:
+  Enabled: true
+  EnforcedStyle: normal
+
+Layout/BlockAlignment:
+  Enabled: true
+
+Layout/EndAlignment:
+  Enabled: true
+  EnforcedStyleAlignWith: start_of_line
+
+Layout/BeginEndAlignment:
+  Enabled: true
+  EnforcedStyleAlignWith: start_of_line
+
+Layout/ElseAlignment:
+  Enabled: true
+
+Layout/DefEndAlignment:
+  Enabled: true
+
+Layout/CaseIndentation:
+  Enabled: true
+
+Layout/CommentIndentation:
+  Enabled: true
+
+Layout/EmptyLinesAroundClassBody:
+  Enabled: true
+
+Layout/EmptyLinesAroundModuleBody:
+  Enabled: true
+
+Layout/EmptyLineAfterMagicComment:
+   Enabled: true
+
+Layout/SpaceInsideBlockBraces:
+  Enabled: true
+  EnforcedStyle: no_space
+  SpaceBeforeBlockParameters: false
+
+Layout/SpaceAroundBlockParameters:
+  Enabled: true
+  EnforcedStyleInsidePipes: no_space
+
+Style/FrozenStringLiteralComment:
+  Enabled: true
+
+Style/StringLiterals:
+  Enabled: true
+  EnforcedStyle: double_quotes
diff -pruN 0.23.12-1/bake.rb 0.55.0-1/bake.rb
--- 0.23.12-1/bake.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/bake.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,37 +1,12 @@
 # frozen_string_literal: true
 
-def external
-	require 'bundler'
-	
-	Bundler.with_unbundled_env do
-		clone_and_test("protocol-http1")
-		clone_and_test("protocol-http2")
-		clone_and_test("async-websocket")
-		clone_and_test("async-http")
-		clone_and_test("async-rest")
-		clone_and_test("falcon")
-	end
-end
-
-private
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
 
-def clone_and_test(name)
-	path = "external/#{name}"
-	
-	unless File.exist?(path)
-		system("git", "clone", "https://git@github.com/socketry/#{name}", path)
-	end
-	
-	gemfile = [
-		File.join(path, "gems.rb"),
-		File.join(path, "Gemfile"),
-	].find{|path| File.exist?(path)}
-	
-	system("git", "checkout", "-f", File.basename(gemfile), chdir: path)
-	
-	File.open(gemfile, "a") do |file|
-		file.puts('', 'gem "protocol-http", path: "../../"')
-	end
-	
-	system("bundle install && bundle exec rspec", chdir: path)
+# Update the project documentation with the new version number.
+#
+# @parameter version [String] The new version number.
+def after_gem_release_version_increment(version)
+	context["releases:update"].call(version)
+	context["utopia:project:update"].call
 end
diff -pruN 0.23.12-1/benchmark/string.rb 0.55.0-1/benchmark/string.rb
--- 0.23.12-1/benchmark/string.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/benchmark/string.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,3 +1,7 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2022-2025, by Samuel Williams.
 
 def generator
 	100000.times do |i|
@@ -22,7 +26,7 @@ def consumer_with_clear
 	return nil
 end
 
-require 'benchmark'
+require "benchmark"
 
 Benchmark.bm do |x|
 	x.report("consumer_with_clear") do
diff -pruN 0.23.12-1/config/external.yaml 0.55.0-1/config/external.yaml
--- 0.23.12-1/config/external.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/config/external.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -1,12 +1,18 @@
 protocol-http1:
   url: https://github.com/socketry/protocol-http1.git
-  command: bundle exec rspec
+  command: bundle exec sus
 protocol-http2:
   url: https://github.com/socketry/protocol-http2.git
-  command: bundle exec rspec
+  command: bundle exec sus
+protocol-rack:
+  url: https://github.com/socketry/protocol-rack.git
+  command: bundle exec sus
 async-http:
   url: https://github.com/socketry/async-http.git
-  command: bundle exec rspec
+  command: bundle exec sus
+async-http-cache:
+  url: https://github.com/socketry/async-http-cache.git
+  command: bundle exec sus
 protocol-websocket:
   url: https://github.com/socketry/protocol-websocket.git
   command: bundle exec sus
@@ -15,4 +21,7 @@ async-websocket:
   command: bundle exec sus
 falcon:
   url: https://github.com/socketry/falcon.git
-  command: bundle exec rspec
+  command: bundle exec sus
+async-rest:
+  url: https://github.com/socketry/async-rest.git
+  command: bundle exec sus
diff -pruN 0.23.12-1/config/sus.rb 0.55.0-1/config/sus.rb
--- 0.23.12-1/config/sus.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/config/sus.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "covered/sus"
+include Covered::Sus
diff -pruN 0.23.12-1/context/design-overview.md 0.55.0-1/context/design-overview.md
--- 0.23.12-1/context/design-overview.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/design-overview.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,209 @@
+# Design Overview
+
+This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers.
+
+## Request/Response Model
+
+The main model we support is the request/response model. A client sends a request to a server which return response. The protocol is responsible for serializing the request and response objects.
+
+```mermaid
+sequenceDiagram
+	participant CA as Application
+	participant Client
+	participant Server
+	participant SA as Application
+	CA->>+Client: Request
+	Client->>+Server: Request
+	Server->>+SA: Request
+	SA->>+Server: Response
+	Server->>+Client: Response
+	Client->>+CA: Response
+```
+
+We provide an interface for request and response objects. This provides performance, predictability and robustness. This model has proven itself over several years, handling a variety of different use cases.
+
+~~~ ruby
+class Request
+	attr :method
+	attr :target
+	attr :headers
+	attr :body
+end
+
+class Response
+	attr :status
+	attr :headers
+	attr :body
+end
+~~~
+
+One other advantage is that it's symmetrical between clients and servers with a clear mapping, i.e. the protocol is responsible for transiting requests from the client to the server, and responses from the server back to the client. This helps us separate and define request/response interfaces independently from protocol implementation.
+
+### Client Design
+
+A request/response model implies that you create a request and receive a response back. This maps to a normal function call where the request is the argument and the response is the returned value.
+
+~~~ ruby
+request = Request.new("GET", url)
+response = client.call(request)
+
+response.headers
+response.read
+~~~
+
+## Stream Model
+
+An alternative model is the stream model. This model is more suitable for WebSockets and other persistent bi-directional channels.
+
+```mermaid
+sequenceDiagram
+	participant CA as Application
+	participant Client
+	participant Server
+	participant SA as Application
+	CA->>+Client: Stream
+	Client->>+Server: Stream
+	Server->>+SA: Stream
+```
+
+The interfaces for streaming can be implemented a bit differently, since a response is not returned but rather assigned to the stream, and the streaming occurs in the same execution context as the client or server handling the request.
+
+~~~ ruby
+class Stream
+	# Request details.
+	attr :method
+	attr :target
+	attr :headers
+	
+	attr :response
+	
+	# Write the response and start streaming the output body.
+	def respond(status, headers)
+		response.status = status
+		response.headers = headers
+	end
+	
+	# Request body.
+	attr_accessor :input
+	
+	# Response body.
+	attr_accessor :output
+	
+	# Write to the response body.
+	def write(...)
+		@output.write(...)
+	end
+	
+	# Read from the request body.
+	def read
+		@input.read
+	end
+end
+
+class Response
+	def initialize(method, target)
+		@input = Body::Writable.new
+		@output = Body::Writable.new
+	end
+	
+	attr_accessor :status
+	attr_accessor :headers
+	
+	# Prepare a stream for making a request.
+	def request(method, target, headers)
+		# Create a request stream suitable for writing into the buffered response:
+		Stream.new(method, target, headers, self, @input, @output)
+	end
+	
+	# Write to the request body.
+	def write(...)
+		@input.write(...)
+	end
+	
+	# Read from the response body.
+	def read
+		@output.read
+	end
+end
+~~~
+
+### Client Design
+
+A stream model implies that you create a stream which contains both the request and response bodies. This maps to a normal function call where the argument is the stream and the returned value is ignored.
+
+~~~ ruby
+response = Response.new
+stream = response.request("GET", url)
+
+client.call(stream)
+
+response.headers
+response.read
+~~~
+
+## Differences
+
+The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in:
+
+~~~ ruby
+def call(request)
+	body = Body::Writable.new
+	
+	Fiber.schedule do
+		while chunk = request.input.read
+			body.write(chunk.reverse)
+		end
+	end
+	
+	return Response[200, headers, body]
+end
+
+input = Body::Writable.new
+response = call(... body ...)
+
+input.write("Hello World")
+input.close
+response.read -> "dlroW olleH"
+~~~
+
+The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information.
+
+~~~ruby
+def call(stream)
+	stream.respond(200, headers)
+	
+	Fiber.schedule do
+		while chunk = stream.read
+			stream.write(chunk.reverse)
+		end
+	end
+end
+
+input = Body::Writable.new
+response = Response.new(...input...)
+call(response.stream)
+
+input.write("Hello World")
+input.close
+response.read -> "dlroW olleH"
+~~~
+
+The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server.
+
+## Interim Response Handling
+
+Interim responses are responses that are sent before the final response. They are used for things like `103 Early Hints` and `100 Continue`. These responses are sent before the final response, and are used to signal to the client that the server is still processing the request.
+
+```ruby
+body = Body::Writable.new
+
+interim_response_callback = proc do |status, headers|
+	if status == 100
+		# Continue sending the request body.
+		body.write("Hello World")
+		body.close
+	end
+end
+
+response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback)
+```
diff -pruN 0.23.12-1/context/getting-started.md 0.55.0-1/context/getting-started.md
--- 0.23.12-1/context/getting-started.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/getting-started.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,130 @@
+# Getting Started
+
+This guide explains how to use `protocol-http` for building abstract HTTP interfaces.
+
+## Installation
+
+Add the gem to your project:
+
+~~~ bash
+$ bundle add protocol-http
+~~~
+
+## Core Concepts
+
+`protocol-http` has several core concepts:
+
+  - A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state.
+  - A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state.
+  - A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications.
+  - A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc).
+  - A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming.
+
+## Integration
+
+This gem does not provide any specific client or server implementation, rather it's used by several other gems.
+
+  - [Protocol::HTTP1](https://github.com/socketry/protocol-http1) & [Protocol::HTTP2](https://github.com/socketry/protocol-http2) which provide client and server implementations.
+  - [Async::HTTP](https://github.com/socketry/async-http) which provides connection pooling and concurrency.
+
+## Usage
+
+### Request
+
+{ruby Protocol::HTTP::Request} represents an HTTP request which can be used both server and client-side.
+
+``` ruby
+require 'protocol/http/request'
+
+# Short form (recommended):
+request = Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}]
+
+# Long form:
+headers = Protocol::HTTP::Headers[["accept", "text/html"]]
+request = Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", headers)
+
+# Access request properties
+request.method           # => "GET"
+request.path             # => "/index.html"
+request.headers          # => Protocol::HTTP::Headers instance
+```
+
+### Response
+
+{ruby Protocol::HTTP::Response} represents an HTTP response which can be used both server and client-side.
+
+``` ruby
+require 'protocol/http/response'
+
+# Short form (recommended):
+response = Protocol::HTTP::Response[200, {"content-type" => "text/html"}, "Hello, World!"]
+
+# Long form:
+headers = Protocol::HTTP::Headers["content-type" => "text/html"]
+body = Protocol::HTTP::Body::Buffered.wrap("Hello, World!")
+response = Protocol::HTTP::Response.new("HTTP/1.1", 200, headers, body)
+
+# Access response properties
+response.status          # => 200
+response.headers         # => Protocol::HTTP::Headers instance
+response.body            # => Body instance
+
+# Status checking methods
+response.success?        # => true (200-299)
+response.ok?             # => true (200)
+response.redirection?    # => false (300-399)
+response.failure?        # => false (400-599)
+```
+
+### Headers
+
+{ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values and implements case-normalising keys.
+
+#### Basic Usage
+
+``` ruby
+require 'protocol/http/headers'
+
+headers = Protocol::HTTP::Headers.new
+
+# Assignment by title-case key:
+headers['Content-Type'] = "image/jpeg"
+
+# Lookup by lower-case (normalized) key:
+headers['content-type']
+# => "image/jpeg"
+```
+
+#### Semantic Processing
+
+Many headers receive special semantic processing, automatically splitting comma-separated values and providing structured access:
+
+``` ruby
+# Accept header with quality values:
+headers['Accept'] = 'text/html, application/json;q=0.8, */*;q=0.1'
+accept = headers['accept']
+# => ["text/html", "application/json;q=0.8", "*/*;q=0.1"]
+
+# Access parsed media ranges with quality factors:
+accept.media_ranges.each do |range|
+	puts "#{range.type}/#{range.subtype} (q=#{range.quality_factor})"
+end
+# text/html (q=1.0)
+# application/json (q=0.8)
+# */* (q=0.1)
+
+# Accept-Encoding automatically splits values:
+headers['Accept-Encoding'] = 'gzip, deflate, br;q=0.9'
+headers['accept-encoding']
+# => ["gzip", "deflate", "br;q=0.9"]
+
+# Cache-Control splits directives:
+headers['Cache-Control'] = 'max-age=3600, no-cache, must-revalidate'
+headers['cache-control']
+# => ["max-age=3600", "no-cache", "must-revalidate"]
+
+# Vary header normalizes field names to lowercase:
+headers['Vary'] = 'Accept-Encoding, User-Agent'
+headers['vary']
+# => ["accept-encoding", "user-agent"]
+```
diff -pruN 0.23.12-1/context/headers.md 0.55.0-1/context/headers.md
--- 0.23.12-1/context/headers.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/headers.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,94 @@
+# Headers
+
+This guide explains how to work with HTTP headers using `protocol-http`.
+
+## Core Concepts
+
+`protocol-http` provides several core concepts for working with HTTP headers:
+
+- A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features.
+- Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting.
+- Trailer security validation to prevent HTTP request smuggling attacks.
+
+## Usage
+
+The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:
+
+```ruby
+require 'protocol/http'
+
+headers = Protocol::HTTP::Headers.new
+headers.add('content-type', 'text/html')
+headers.add('set-cookie', 'session=abc123')
+
+# Access headers
+content_type = headers['content-type'] # => "text/html"
+
+# Check if header exists
+headers.include?('content-type') # => true
+```
+
+### Header Policies
+
+Different header types have different behaviors for merging, validation, and trailer handling:
+
+```ruby
+# Some headers can be specified multiple times
+headers.add('set-cookie', 'first=value1')
+headers.add('set-cookie', 'second=value2')
+
+# Others are singletons and will raise errors if duplicated
+headers.add('content-length', '100')
+# headers.add('content-length', '200') # Would raise DuplicateHeaderError
+```
+
+### Structured Headers
+
+Some headers have specialized classes for parsing and formatting:
+
+```ruby
+# Accept header with media ranges
+accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
+media_ranges = accept.media_ranges
+
+# Authorization header
+auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
+# => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
+```
+
+### Trailer Security
+
+HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers:
+
+```ruby
+# Working with trailers
+headers = Protocol::HTTP::Headers.new([
+  ['content-type', 'text/html'],
+  ['content-length', '1000']
+])
+
+# Start trailer section
+headers.trailer!
+
+# These will be allowed (safe metadata)
+headers.add('etag', '"12345"')
+headers.add('date', Time.now.httpdate)
+
+# These will be silently ignored for security
+headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk
+headers.add('connection', 'close') # Ignored - hop-by-hop header
+```
+
+The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:
+
+**Allowed headers** (return `true` for `trailer?`):
+- `date` - Response generation timestamps.
+- `digest` - Content integrity verification.
+- `etag` - Cache validation tags.
+- `server-timing` - Performance metrics.
+
+**Forbidden headers** (return `false` for `trailer?`):
+- `authorization` - Prevents credential leakage.
+- `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior.
+- `cookie`, `set-cookie` - State information needed during initial processing.
+- `accept` - Content negotiation must occur before response generation.
diff -pruN 0.23.12-1/context/hypertext-references.md 0.55.0-1/context/hypertext-references.md
--- 0.23.12-1/context/hypertext-references.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/hypertext-references.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,140 @@
+# Hypertext References
+
+This guide explains how to use `Protocol::HTTP::Reference` for constructing and manipulating hypertext references (URLs with parameters).
+
+## Overview
+
+{ruby Protocol::HTTP::Reference} is used to construct "hypertext references" which consist of a path and URL-encoded parameters. References provide a rich API for URL construction, path manipulation, and parameter handling.
+
+## Basic Construction
+
+``` ruby
+require 'protocol/http/reference'
+
+# Simple reference with parameters:
+reference = Protocol::HTTP::Reference.new("/search", nil, nil, {q: 'kittens', limit: 10})
+reference.to_s
+# => "/search?q=kittens&limit=10"
+
+# Parse existing URLs:
+reference = Protocol::HTTP::Reference.parse("/api/users?page=2&sort=name#results")
+reference.path       # => "/api/users"
+reference.query      # => "page=2&sort=name"
+reference.fragment   # => "results"
+
+# To get parameters as a hash, decode the query string:
+parameters = Protocol::HTTP::URL.decode(reference.query)
+parameters           # => {"page" => "2", "sort" => "name"}
+```
+
+## Path Manipulation
+
+References support sophisticated path manipulation including relative path resolution:
+
+``` ruby
+base = Protocol::HTTP::Reference.new("/api/v1/users")
+
+# Append paths:
+user_detail = base.with(path: "123")
+user_detail.to_s  # => "/api/v1/users/123"
+
+# Relative path navigation:
+parent = user_detail.with(path: "../groups", pop: true)
+parent.to_s  # => "/api/v1/groups"
+
+# Absolute path replacement:
+root = user_detail.with(path: "/status")
+root.to_s  # => "/status"
+```
+
+## Advanced Parameter Handling
+
+``` ruby
+# Complex parameter structures:
+reference = Protocol::HTTP::Reference.new("/search", nil, nil, {
+	filters: {
+		category: "books", 
+		price: {min: 10, max: 50}
+	},
+	tags: ["fiction", "mystery"]
+})
+
+reference.to_s
+# => "/search?filters[category]=books&filters[price][min]=10&filters[price][max]=50&tags[]=fiction&tags[]=mystery"
+
+# Parameter merging:
+base = Protocol::HTTP::Reference.new("/api", nil, nil, {version: "v1", format: "json"})
+extended = base.with(parameters: {detailed: true}, merge: true)
+extended.to_s
+# => "/api?version=v1&format=json&detailed=true"
+
+# Parameter replacement (using merge: false):
+replaced = base.with(parameters: {format: "xml"}, merge: false)
+replaced.to_s
+# => "/api?format=xml"
+```
+
+## Merge Behavior and Query Strings
+
+The `merge` parameter controls both parameter handling and query string behavior:
+
+``` ruby
+# Create a reference with both query string and parameters:
+ref = Protocol::HTTP::Reference.new("/api", "existing=query", nil, {version: "v1"})
+ref.to_s
+# => "/api?existing=query&version=v1"
+
+# merge: true (default) - keeps existing query string:
+merged = ref.with(parameters: {new: "argument"}, merge: true)
+merged.to_s
+# => "/api?existing=query&version=v1&new=argument"
+
+# merge: false with new parameters - clears query string:
+replaced = ref.with(parameters: {new: "argument"}, merge: false)
+replaced.to_s
+# => "/api?new=argument"
+
+# merge: false without new parameters - keeps everything:
+unchanged = ref.with(path: "v2", merge: false)
+unchanged.to_s
+# => "/api/v2?existing=query&version=v1"
+```
+
+## URL Encoding and Special Characters
+
+References handle URL encoding automatically:
+
+``` ruby
+# Spaces and special characters:
+reference = Protocol::HTTP::Reference.new("/search", nil, nil, {
+	q: "hello world",
+	filter: "price > $10"
+})
+reference.to_s
+# => "/search?q=hello%20world&filter=price%20%3E%20%2410"
+
+# Unicode support:
+unicode_ref = Protocol::HTTP::Reference.new("/files", nil, nil, {
+	name: "résumé.pdf",
+	emoji: "😀"
+})
+unicode_ref.to_s
+# => "/files?name=r%C3%A9sum%C3%A9.pdf&emoji=%F0%9F%98%80"
+```
+
+## Reference Merging
+
+References can be merged following RFC2396 URI resolution rules:
+
+``` ruby
+base = Protocol::HTTP::Reference.new("/docs/guide/")
+relative = Protocol::HTTP::Reference.new("../api/reference.html")
+
+merged = base + relative
+merged.to_s  # => "/docs/api/reference.html"
+
+# Absolute references override completely
+absolute = Protocol::HTTP::Reference.new("/completely/different/path")
+result = base + absolute
+result.to_s  # => "/completely/different/path"
+```
diff -pruN 0.23.12-1/context/index.yaml 0.55.0-1/context/index.yaml
--- 0.23.12-1/context/index.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/index.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,31 @@
+# Automatically generated context index for Utopia::Project guides.
+# Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
+---
+description: Provides abstractions to handle HTTP protocols.
+metadata:
+  documentation_uri: https://socketry.github.io/protocol-http/
+  source_code_uri: https://github.com/socketry/protocol-http.git
+files:
+- path: getting-started.md
+  title: Getting Started
+  description: This guide explains how to use `protocol-http` for building abstract
+    HTTP interfaces.
+- path: message-body.md
+  title: Message Body
+  description: This guide explains how to work with HTTP request and response message
+    bodies using `Protocol::HTTP::Body` classes.
+- path: headers.md
+  title: Headers
+  description: This guide explains how to work with HTTP headers using `protocol-http`.
+- path: middleware.md
+  title: Middleware
+  description: This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
+- path: streaming.md
+  title: Streaming
+  description: This guide gives an overview of how to implement streaming requests
+    and responses.
+- path: design-overview.md
+  title: Design Overview
+  description: This guide explains the high level design of `protocol-http` in the
+    context of wider design patterns that can be used to implement HTTP clients and
+    servers.
diff -pruN 0.23.12-1/context/message-body.md 0.55.0-1/context/message-body.md
--- 0.23.12-1/context/message-body.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/message-body.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,330 @@
+# Message Body
+
+This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes.
+
+## Overview
+
+HTTP message bodies represent the actual (often stateful) data content of requests and responses. `Protocol::HTTP` provides a rich set of body classes for different use cases, from simple string content to streaming data and file serving.
+
+All body classes inherit from {ruby Protocol::HTTP::Body::Readable}, which provides a consistent interface for reading data in chunks. Bodies can be:
+- **Buffered**: All content stored in memory.
+- **Streaming**: Content generated or read on-demand.
+- **File-based**: Content read directly from files.
+- **Transforming**: Content modified as it flows through e.g. compression, encryption.
+
+## Core Body Interface
+
+Every body implements the `Readable` interface:
+
+``` ruby
+# Read the next chunk of data:
+chunk = body.read
+# => "Hello" or nil when finished
+
+# Check if body has data available without blocking:
+body.ready?  # => true/false
+
+# Check if body is empty:
+body.empty?  # => true/false
+
+# Close the body and release resources:
+body.close
+
+# Iterate through all chunks: 
+body.each do |chunk|
+	puts chunk
+end
+
+# Read entire body into a string:
+content = body.join
+```
+
+## Buffered Bodies
+
+Use {ruby Protocol::HTTP::Body::Buffered} for content that's fully loaded in memory:
+
+``` ruby
+# Create from string:
+body = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])
+
+# Create from array of strings:
+chunks = ["First chunk", "Second chunk", "Third chunk"]
+body = Protocol::HTTP::Body::Buffered.new(chunks)
+
+# Wrap various types automatically:
+body = Protocol::HTTP::Body::Buffered.wrap("Simple string")
+body = Protocol::HTTP::Body::Buffered.wrap(["Array", "of", "chunks"])
+
+# Access properties:
+body.length      # => 13 (total size in bytes)
+body.empty?      # => false
+body.ready?      # => true (always ready)
+
+# Reading:
+first_chunk = body.read    # => "Hello"
+second_chunk = body.read   # => " "
+third_chunk = body.read    # => "World"
+fourth_chunk = body.read   # => nil (finished)
+
+# Rewind to beginning:
+body.rewind
+body.read  # => "Hello" (back to start)
+```
+
+### Buffered Body Features
+
+``` ruby
+# Check if rewindable:
+body.rewindable?  # => true for buffered bodies
+
+# Get all content as single string:
+content = body.join  # => "Hello World"
+
+# Convert to array of chunks:
+chunks = body.to_a   # => ["Hello", " ", "World"]
+
+# Write additional chunks:
+body.write("!")
+body.join  # => "Hello World!"
+
+# Clear all content:
+body.clear
+body.empty?  # => true
+```
+
+## File Bodies
+
+Use {ruby Protocol::HTTP::Body::File} for serving files efficiently:
+
+``` ruby
+require 'protocol/http/body/file'
+
+# Open a file:
+body = Protocol::HTTP::Body::File.open("/path/to/file.txt")
+
+# Create from existing File object:
+file = File.open("/path/to/image.jpg", "rb")
+body = Protocol::HTTP::Body::File.new(file)
+
+# Serve partial content (ranges):
+range = 100...200  # bytes 100-199
+body = Protocol::HTTP::Body::File.new(file, range)
+
+# Properties:
+body.length      # => file size or range size
+body.empty?      # => false (unless zero-length file)
+body.ready?      # => false (may block when reading)
+
+# File bodies read in chunks automatically:
+body.each do |chunk|
+	# Process each chunk (typically 64KB)
+	puts "Read #{chunk.bytesize} bytes"
+end
+```
+
+### File Body Range Requests
+
+``` ruby
+# Serve specific byte ranges (useful for HTTP range requests):
+file = File.open("large_video.mp4", "rb")
+
+# First 1MB:
+partial_body = Protocol::HTTP::Body::File.new(file, 0...1_048_576)
+
+# Custom block size for reading:
+body = Protocol::HTTP::Body::File.new(file, block_size: 8192)  # 8KB chunks
+```
+
+## Writable Bodies
+
+Use {ruby Protocol::HTTP::Body::Writable} for dynamic content generation:
+
+``` ruby
+require 'protocol/http/body/writable'
+
+# Create a writable body:
+body = Protocol::HTTP::Body::Writable.new
+
+# Write data in another thread/fiber:
+Thread.new do
+	body.write("First chunk\n")
+	sleep 0.1
+	body.write("Second chunk\n")
+	body.write("Final chunk\n")
+	body.close_write  # Signal no more data
+end
+
+# Read from main thread:
+body.each do |chunk|
+	puts "Received: #{chunk}"
+end
+# Output:
+# Received: First chunk
+# Received: Second chunk  
+# Received: Final chunk
+```
+
+### Writable Body with Backpressure
+
+``` ruby
+# Use SizedQueue to limit buffering:
+queue = Thread::SizedQueue.new(10)  # Buffer up to 10 chunks
+body = Protocol::HTTP::Body::Writable.new(queue: queue)
+
+# Writing will block if queue is full:
+body.write("chunk 1")
+# ... write up to 10 chunks before blocking
+```
+
+## Streaming Bodies
+
+Use {ruby Protocol::HTTP::Body::Streamable} for computed content:
+
+``` ruby
+require 'protocol/http/body/streamable'
+
+# Generate content dynamically:
+body = Protocol::HTTP::Body::Streamable.new do |output|
+	10.times do |i|
+		output.write("Line #{i}\n")
+		# Could include delays, computation, database queries, etc.
+	end
+end
+
+# Content is generated as it's read:
+body.each do |chunk|
+	puts "Got: #{chunk}"
+end
+```
+
+## Stream Bodies (IO Wrapper)
+
+Use {ruby Protocol::HTTP::Body::Stream} to wrap IO-like objects:
+
+``` ruby
+require 'protocol/http/body/stream'
+
+# Wrap an IO object:
+io = StringIO.new("Hello\nWorld\nFrom\nStream")
+body = Protocol::HTTP::Body::Stream.new(io)
+
+# Read line by line:
+line1 = body.gets    # => "Hello\n"
+line2 = body.gets    # => "World\n"
+
+# Read specific amounts:
+data = body.read(5)  # => "From\n"
+
+# Read remaining data:
+rest = body.read     # => "Stream"
+```
+
+## Body Transformations
+
+### Compression Bodies
+
+``` ruby
+require 'protocol/http/body/deflate'
+require 'protocol/http/body/inflate'
+
+# Compress a body:
+original = Protocol::HTTP::Body::Buffered.new(["Hello World"])
+compressed = Protocol::HTTP::Body::Deflate.new(original)
+
+# Decompress a body:
+decompressed = Protocol::HTTP::Body::Inflate.new(compressed)
+content = decompressed.join  # => "Hello World"
+```
+
+### Wrapper Bodies
+
+Create custom body transformations:
+
+``` ruby
+require 'protocol/http/body/wrapper'
+
+class UppercaseBody < Protocol::HTTP::Body::Wrapper
+	def read
+		if chunk = super
+			chunk.upcase
+		end
+	end
+end
+
+# Use the wrapper:
+original = Protocol::HTTP::Body::Buffered.wrap("hello world")
+uppercase = UppercaseBody.new(original)
+content = uppercase.join  # => "HELLO WORLD"
+```
+
+## Life-cycle
+
+### Initialization
+
+Bodies are typically initialized with the data they need to process. For example:
+
+``` ruby
+body = Protocol::HTTP::Body::Buffered.wrap("Hello World")
+```
+
+### Reading
+
+Once initialized, bodies can be read in chunks:
+
+``` ruby
+body.each do |chunk|
+	puts "Read #{chunk.bytesize} bytes"
+end
+```
+
+### Closing
+
+It's important to close bodies when done to release resources:
+
+``` ruby
+begin
+	# ... read from the body ...
+rescue => error
+	# Ignore.
+ensure
+	# The body should always be closed:
+	body.close(error)
+end
+```
+
+## Advanced Usage
+
+### Rewindable Bodies
+
+Make any body rewindable by buffering:
+
+``` ruby
+require 'protocol/http/body/rewindable'
+
+# Wrap a non-rewindable body:
+file_body = Protocol::HTTP::Body::File.open("data.txt")
+rewindable = Protocol::HTTP::Body::Rewindable.new(file_body)
+
+# Read some data:
+first_chunk = rewindable.read
+
+# Rewind and read again:
+rewindable.rewind
+same_chunk = rewindable.read  # Same as first_chunk
+```
+
+### Head Bodies (Response without content)
+
+For HEAD requests that need content-length but no body:
+
+``` ruby
+require 'protocol/http/body/head'
+
+# Create head body from another body:
+original = Protocol::HTTP::Body::File.open("large_file.zip")
+head_body = Protocol::HTTP::Body::Head.for(original)
+
+head_body.length  # => size of original file
+head_body.read    # => nil (no actual content)
+head_body.empty?  # => true
+```
diff -pruN 0.23.12-1/context/middleware.md 0.55.0-1/context/middleware.md
--- 0.23.12-1/context/middleware.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/middleware.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,195 @@
+# Middleware
+
+This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
+
+## Overview
+
+The middleware interface provides a convenient wrapper for implementing HTTP middleware components that can process requests and responses. Middleware enables you to build composable HTTP applications by chaining multiple processing layers.
+
+A middleware instance generally needs to respond to two methods:
+- `call(request)` -> `response`.
+- `close()` (called when shutting down).
+
+## Basic Middleware Interface
+
+You can implement middleware without using the `Middleware` class by implementing the interface directly:
+
+``` ruby
+class SimpleMiddleware
+	def initialize(delegate)
+		@delegate = delegate
+	end
+	
+	def call(request)
+		# Process request here
+		response = @delegate.call(request)
+		# Process response here
+		return response
+	end
+	
+	def close
+		@delegate&.close
+	end
+end
+```
+
+## Using the Middleware Class
+
+The `Protocol::HTTP::Middleware` class provides a convenient base for building middleware:
+
+``` ruby
+require 'protocol/http/middleware'
+
+class LoggingMiddleware < Protocol::HTTP::Middleware
+	def call(request)
+		puts "Processing: #{request.method} #{request.path}"
+		
+		response = super  # Calls @delegate.call(request)
+		
+		puts "Response: #{response.status}"
+		return response
+	end
+end
+
+# Use with a delegate:
+app = LoggingMiddleware.new(Protocol::HTTP::Middleware::HelloWorld)
+```
+
+## Building Middleware Stacks
+
+Use `Protocol::HTTP::Middleware.build` to construct middleware stacks:
+
+``` ruby
+require 'protocol/http/middleware'
+
+app = Protocol::HTTP::Middleware.build do
+	use LoggingMiddleware
+	use CompressionMiddleware
+	run Protocol::HTTP::Middleware::HelloWorld
+end
+
+# Handle a request:
+request = Protocol::HTTP::Request["GET", "/"]
+response = app.call(request)
+```
+
+The builder works by:
+- `use` adds middleware to the stack
+- `run` specifies the final application (defaults to `NotFound`)
+- Middleware is chained in reverse order (last `use` wraps first)
+
+## Block-Based Middleware
+
+Convert a block into middleware using `Middleware.for`:
+
+``` ruby
+middleware = Protocol::HTTP::Middleware.for do |request|
+	if request.path == '/health'
+		Protocol::HTTP::Response[200, {}, ["OK"]]
+	else
+		# This would normally delegate, but this example doesn't have a delegate
+		Protocol::HTTP::Response[404]
+	end
+end
+
+request = Protocol::HTTP::Request["GET", "/health"]
+response = middleware.call(request)
+# => Response with status 200
+```
+
+## Built-in Middleware
+
+### HelloWorld
+
+Always returns "Hello World!" response:
+
+``` ruby
+app = Protocol::HTTP::Middleware::HelloWorld
+response = app.call(request)
+# => 200 "Hello World!"
+```
+
+### NotFound
+
+Always returns 404 response:
+
+``` ruby
+app = Protocol::HTTP::Middleware::NotFound  
+response = app.call(request)
+# => 404 Not Found
+```
+
+### Okay
+
+Always returns 200 response with no body:
+
+``` ruby
+app = Protocol::HTTP::Middleware::Okay
+response = app.call(request)
+# => 200 OK
+```
+
+## Real-World Middleware Examples
+
+### Authentication Middleware
+
+``` ruby
+class AuthenticationMiddleware < Protocol::HTTP::Middleware
+	def initialize(delegate, api_key: nil)
+		super(delegate)
+		@api_key = api_key
+	end
+	
+	def call(request)
+		auth_header = request.headers['authorization']
+		
+		unless auth_header == "Bearer #{@api_key}"
+			return Protocol::HTTP::Response[401, {}, ["Unauthorized"]]
+		end
+		
+		super
+	end
+end
+
+# Usage:
+app = Protocol::HTTP::Middleware.build do
+	use AuthenticationMiddleware, api_key: "secret123"
+	run MyApplication
+end
+```
+
+### Content Type Middleware
+
+``` ruby
+class ContentTypeMiddleware < Protocol::HTTP::Middleware
+	def call(request)
+		response = super
+		
+		# Add content-type header if not present
+		unless response.headers.include?('content-type')
+			response.headers['content-type'] = 'text/plain'
+		end
+		
+		response
+	end
+end
+```
+
+## Testing Middleware
+
+``` ruby
+describe MyMiddleware do
+	let(:app) {MyMiddleware.new(Protocol::HTTP::Middleware::Okay)}
+	
+	it "processes requests correctly" do
+		request = Protocol::HTTP::Request["GET", "/test"]
+		response = app.call(request)
+		
+		expect(response.status).to be == 200
+	end
+	
+	it "closes properly" do
+		expect { app.close }.not.to raise_exception
+	end
+end
+```
diff -pruN 0.23.12-1/context/streaming.md 0.55.0-1/context/streaming.md
--- 0.23.12-1/context/streaming.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/streaming.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,132 @@
+# Streaming
+
+This guide gives an overview of how to implement streaming requests and responses.
+
+## Independent Uni-directional Streaming
+
+The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface.
+
+```ruby
+#!/usr/bin/env ruby
+
+require 'async'
+require 'async/http/client'
+require 'async/http/server'
+require 'async/http/endpoint'
+
+require 'protocol/http/body/stream'
+require 'protocol/http/body/writable'
+
+endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				stream.write(chunk)
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		stream.write("Hello, ")
+		stream.write("World!")
+		stream.close_write
+		
+		while chunk = stream.readpartial(1024)
+			puts chunk
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
+```
+
+This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction.
+
+## Bi-directional Streaming
+
+While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible.
+
+```ruby
+#!/usr/bin/env ruby
+
+require 'async'
+require 'async/http/client'
+require 'async/http/server'
+require 'async/http/endpoint'
+
+require 'protocol/http/body/stream'
+require 'protocol/http/body/writable'
+
+endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		streamable = Protocol::HTTP::Body::Streamable.
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				stream.write(chunk)
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		stream.write("Hello, ")
+		stream.write("World!")
+		stream.close_write
+		
+		while chunk = stream.readpartial(1024)
+			puts chunk
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
+```
diff -pruN 0.23.12-1/context/url-parsing.md 0.55.0-1/context/url-parsing.md
--- 0.23.12-1/context/url-parsing.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/context/url-parsing.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,130 @@
+# URL Parsing
+
+This guide explains how to use `Protocol::HTTP::URL` for parsing and manipulating URL components, particularly query strings and parameters.
+
+## Overview
+
+{ruby Protocol::HTTP::URL} provides utilities for parsing and manipulating URL components, particularly query strings and parameters. It offers robust encoding/decoding capabilities for complex parameter structures.
+
+While basic query parameter encoding follows the `application/x-www-form-urlencoded` standard, there is no universal standard for serializing complex nested structures (arrays, nested objects) in URLs. Different frameworks use varying conventions for these cases, and this implementation follows common patterns where possible.
+
+## Basic Query Parameter Parsing
+
+``` ruby
+require 'protocol/http/url'
+
+# Parse query parameters from a URL:
+reference = Protocol::HTTP::Reference.parse("/search?q=ruby&category=programming&page=2")
+parameters = Protocol::HTTP::URL.decode(reference.query)
+# => {"q" => "ruby", "category" => "programming", "page" => "2"}
+
+# Symbolize keys for easier access:
+parameters = Protocol::HTTP::URL.decode(reference.query, symbolize_keys: true)
+# => {:q => "ruby", :category => "programming", :page => "2"}
+```
+
+## Complex Parameter Structures
+
+The URL module handles nested parameters, arrays, and complex data structures:
+
+``` ruby
+# Array parameters:
+query = "tags[]=ruby&tags[]=programming&tags[]=web"
+parameters = Protocol::HTTP::URL.decode(query)
+# => {"tags" => ["ruby", "programming", "web"]}
+
+# Nested hash parameters:
+query = "user[name]=John&user[email]=john@example.com&user[preferences][theme]=dark"
+parameters = Protocol::HTTP::URL.decode(query)
+# => {"user" => {"name" => "John", "email" => "john@example.com", "preferences" => {"theme" => "dark"}}}
+
+# Mixed structures:
+query = "filters[categories][]=books&filters[categories][]=movies&filters[price][min]=10&filters[price][max]=100"
+parameters = Protocol::HTTP::URL.decode(query)
+# => {"filters" => {"categories" => ["books", "movies"], "price" => {"min" => "10", "max" => "100"}}}
+```
+
+## Encoding Parameters to Query Strings
+
+``` ruby
+# Simple parameters:
+parameters = {"search" => "protocol-http", "limit" => "20"}
+query = Protocol::HTTP::URL.encode(parameters)
+# => "search=protocol-http&limit=20"
+
+# Array parameters:
+parameters = {"tags" => ["ruby", "http", "protocol"]}
+query = Protocol::HTTP::URL.encode(parameters)
+# => "tags[]=ruby&tags[]=http&tags[]=protocol"
+
+# Nested parameters:
+parameters = {
+	user: {
+		profile: {
+			name: "Alice",
+			settings: {
+				notifications: true,
+				theme: "light"
+			}
+		}
+	}
+}
+query = Protocol::HTTP::URL.encode(parameters)
+# => "user[profile][name]=Alice&user[profile][settings][notifications]=true&user[profile][settings][theme]=light"
+```
+
+## URL Escaping and Unescaping
+
+``` ruby
+# Escape special characters:
+Protocol::HTTP::URL.escape("hello world!")
+# => "hello%20world%21"
+
+# Escape path components (preserves path separators):
+Protocol::HTTP::URL.escape_path("/path/with spaces/file.html")
+# => "/path/with%20spaces/file.html"
+
+# Unescape percent-encoded strings:
+Protocol::HTTP::URL.unescape("hello%20world%21")
+# => "hello world!"
+
+# Handle Unicode characters:
+Protocol::HTTP::URL.escape("café")
+# => "caf%C3%A9"
+
+Protocol::HTTP::URL.unescape("caf%C3%A9")
+# => "café"
+```
+
+## Scanning and Processing Query Strings
+
+For custom processing, you can scan query strings directly:
+
+``` ruby
+query = "name=John&age=30&active=true"
+
+Protocol::HTTP::URL.scan(query) do |key, value|
+	puts "#{key}: #{value}"
+end
+# Output:
+# name: John
+# age: 30
+# active: true
+```
+
+## Security and Limits
+
+The URL module includes built-in protection against deeply nested parameter attacks:
+
+``` ruby
+# This will raise an error to prevent excessive nesting:
+begin
+	Protocol::HTTP::URL.decode("a[b][c][d][e][f][g][h][i]=value")
+rescue ArgumentError => error
+	puts error.message
+	# => "Key length exceeded limit!"
+end
+
+# You can adjust the maximum nesting level:
+Protocol::HTTP::URL.decode("a[b][c]=value", 5)  # Allow up to 5 levels of nesting
+```
diff -pruN 0.23.12-1/debian/changelog 0.55.0-1/debian/changelog
--- 0.23.12-1/debian/changelog	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/changelog	2025-11-18 18:57:49.000000000 +0000
@@ -1,3 +1,14 @@
+ruby-protocol-http (0.55.0-1) unstable; urgency=medium
+
+  * Team upload.
+  * Use GitHub in the watch file for tests.
+  * New upstream release.
+  * Drop {XS,XB}-Ruby-Versions from control.
+  * Remove patches no longer applicable with tests dir.
+  * Temporarily remove calling of test suite due to upstream changes.
+
+ -- Simon Quigley <tsimonq2@debian.org>  Tue, 18 Nov 2025 12:57:49 -0600
+
 ruby-protocol-http (0.23.12-1) unstable; urgency=medium
 
   * New upstream release
@@ -18,7 +29,7 @@ ruby-protocol-http (0.22.5-1) unstable;
       for exec tests.
     - Set Standards-Version: 4.6.0
   * debian/patches
-    - Refresh patches 
+    - Refresh patches
     - Add 0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch
     - Add specify-rspec-memory.patch
 
diff -pruN 0.23.12-1/debian/control 0.55.0-1/debian/control
--- 0.23.12-1/debian/control	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/control	2025-11-18 18:40:18.000000000 +0000
@@ -4,7 +4,7 @@ Priority: optional
 Maintainer: Debian Ruby Team <pkg-ruby-extras-maintainers@lists.alioth.debian.org>
 Uploaders: Hideki Yamane <henrich@debian.org>
 Build-Depends: debhelper-compat (= 13),
-               ruby-rspec (>= 3.0), ruby-rspec (<< 4.0),
+               ruby-rspec, ruby-rspec (<< 4.0),
                ruby-async,
                ruby-async-io,
                ruby-rspec-memory,
@@ -14,12 +14,10 @@ Vcs-Git: https://salsa.debian.org/ruby-t
 Vcs-Browser: https://salsa.debian.org/ruby-team/ruby-protocol-http
 Homepage: https://github.com/socketry/protocol-http
 Testsuite: autopkgtest-pkg-ruby
-XS-Ruby-Versions: all
 Rules-Requires-Root: no
 
 Package: ruby-protocol-http
 Architecture: all
-XB-Ruby-Versions: ${ruby:Versions}
 Depends: ${misc:Depends},
          ${ruby:Depends},
          ${shlibs:Depends}
diff -pruN 0.23.12-1/debian/gbp.conf 0.55.0-1/debian/gbp.conf
--- 0.23.12-1/debian/gbp.conf	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/debian/gbp.conf	2025-11-18 18:39:37.000000000 +0000
@@ -0,0 +1,8 @@
+[DEFAULT]
+debian-branch = debian/latest
+pristine-tar = True
+upstream-tag = v%(version)s
+debian-tag = debian/%(version)s
+
+[buildpackage]
+upstream-tree = tag
diff -pruN 0.23.12-1/debian/patches/0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch 0.55.0-1/debian/patches/0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch
--- 0.23.12-1/debian/patches/0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/patches/0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch	1970-01-01 00:00:00.000000000 +0000
@@ -1,34 +0,0 @@
-From: Hideki Yamane <henrich@debian.org>
-Date: Tue, 23 Jun 2020 20:11:33 +0900
-Subject: Drop unnecessary dependency to exec autopkgtest and git usage
-
----
- protocol-http.gemspec | 7 +------
- spec/spec_helper.rb   | 1 -
- 2 files changed, 1 insertion(+), 7 deletions(-)
-
-Index: ruby-protocol-http/protocol-http.gemspec
-===================================================================
---- ruby-protocol-http.orig/protocol-http.gemspec
-+++ ruby-protocol-http/protocol-http.gemspec
-@@ -19,7 +19,5 @@ Gem::Specification.new do |spec|
- 	
- 	spec.required_ruby_version = ">= 2.5"
- 	
--	spec.add_development_dependency "bundler"
--	spec.add_development_dependency "covered"
- 	spec.add_development_dependency "rspec"
- end
-Index: ruby-protocol-http/spec/spec_helper.rb
-===================================================================
---- ruby-protocol-http.orig/spec/spec_helper.rb
-+++ ruby-protocol-http/spec/spec_helper.rb
-@@ -20,8 +20,6 @@
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- # THE SOFTWARE.
- 
--require 'async/rspec'
--require 'covered/rspec'
- 
- RSpec.shared_context 'docstring as description' do
- 	let(:description) {self.class.metadata.fetch(:description_args).first}
diff -pruN 0.23.12-1/debian/patches/0002-avoid-specifying-pem.patch 0.55.0-1/debian/patches/0002-avoid-specifying-pem.patch
--- 0.23.12-1/debian/patches/0002-avoid-specifying-pem.patch	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/patches/0002-avoid-specifying-pem.patch	2025-11-18 18:42:36.000000000 +0000
@@ -1,12 +1,11 @@
-Index: ruby-protocol-http/protocol-http.gemspec
-===================================================================
---- ruby-protocol-http.orig/protocol-http.gemspec
-+++ ruby-protocol-http/protocol-http.gemspec
-@@ -11,7 +11,6 @@ Gem::Specification.new do |spec|
+--- a/protocol-http.gemspec
++++ b/protocol-http.gemspec
+@@ -11,8 +11,6 @@ Gem::Specification.new do |spec|
  	spec.license = "MIT"
  	
- 	spec.cert_chain  = ['release.cert']
--	spec.signing_key = File.expand_path('~/.gem/release.pem')
- 	
+ 	spec.cert_chain  = ["release.cert"]
+-	spec.signing_key = File.expand_path("~/.gem/release.pem")
+-	
  	spec.homepage = "https://github.com/socketry/protocol-http"
  	
+ 	spec.metadata = {
diff -pruN 0.23.12-1/debian/patches/0003-specify-rspec-memory.patch 0.55.0-1/debian/patches/0003-specify-rspec-memory.patch
--- 0.23.12-1/debian/patches/0003-specify-rspec-memory.patch	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/patches/0003-specify-rspec-memory.patch	1970-01-01 00:00:00.000000000 +0000
@@ -1,12 +0,0 @@
-Index: ruby-protocol-http/spec/protocol/http/body/buffered_spec.rb
-===================================================================
---- ruby-protocol-http.orig/spec/protocol/http/body/buffered_spec.rb
-+++ ruby-protocol-http/spec/protocol/http/body/buffered_spec.rb
-@@ -21,6 +21,7 @@
- # THE SOFTWARE.
- 
- require 'protocol/http/body/buffered'
-+require 'rspec/memory'
- 
- RSpec.describe Protocol::HTTP::Body::Buffered do
- 	include_context RSpec::Memory
diff -pruN 0.23.12-1/debian/patches/series 0.55.0-1/debian/patches/series
--- 0.23.12-1/debian/patches/series	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/patches/series	2025-11-18 18:41:28.000000000 +0000
@@ -1,3 +1 @@
-0001-Drop-unnecessary-dependency-to-exec-autopkgtest.patch
 0002-avoid-specifying-pem.patch
-0003-specify-rspec-memory.patch
diff -pruN 0.23.12-1/debian/ruby-tests.rake 0.55.0-1/debian/ruby-tests.rake
--- 0.23.12-1/debian/ruby-tests.rake	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/ruby-tests.rake	1970-01-01 00:00:00.000000000 +0000
@@ -1,5 +0,0 @@
-require 'gem2deb/rake/spectask'
-
-Gem2Deb::Rake::RSpecTask.new do |spec|
-  spec.pattern = './spec/**/*_spec.rb'
-end
diff -pruN 0.23.12-1/debian/salsa-ci.yml 0.55.0-1/debian/salsa-ci.yml
--- 0.23.12-1/debian/salsa-ci.yml	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/debian/salsa-ci.yml	2025-11-18 18:39:37.000000000 +0000
@@ -0,0 +1,6 @@
+---
+include:
+  - https://salsa.debian.org/ruby-team/meta/raw/master/salsa-ci.yml
+
+variables:
+  SALSA_CI_DISABLE_BLHC: 1
diff -pruN 0.23.12-1/debian/watch 0.55.0-1/debian/watch
--- 0.23.12-1/debian/watch	2022-09-17 14:32:14.000000000 +0000
+++ 0.55.0-1/debian/watch	2025-11-18 18:39:37.000000000 +0000
@@ -1,2 +1,2 @@
 version=4
-https://gemwatch.debian.net/protocol-http .*/protocol-http-(.*).tar.gz
+https://github.com/socketry/protocol-http/tags .*/v?(\d.*)@ARCHIVE_EXT@
diff -pruN 0.23.12-1/design.md 0.55.0-1/design.md
--- 0.23.12-1/design.md	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/design.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,167 +0,0 @@
-# Middleware Design
-
-`Body::Writable` is a queue of String chunks.
-
-## Request Response Model
-
-~~~ruby
-class Request
-	attr :verb
-	attr :target
-	attr :body
-end
-
-class Response
-	attr :status
-	attr :headers
-	attr :body
-end
-
-def call(request)
-	return response
-end
-
-def call(request)
-	return @app.call(request)
-end
-~~~
-
-## Stream Model
-
-~~~ruby
-class Stream
-	attr :verb
-	attr :target
-	
-	def respond(status, headers) = ...
-	
-	attr_accessor :input
-	attr_accessor :output
-end
-
-class Response
-	def initialize(verb, target)
-		@input = Body::Writable.new
-		@output = Body::Writable.new
-	end
-	
-	def request(verb, target)
-		# Create a request stream suitable for writing into the buffered response:
-		Stream.new(verb, target, @input, @output)
-	end
-	
-	def write(...)
-		@input.write(...)
-	end
-	
-	def read
-		@output.read
-	end
-end
-
-def call(stream)
-	# nothing. maybe error
-end
-
-def call(stream)
-	@app.call(stream)
-end
-~~~
-
-# Client Design
-
-## Request Response Model
-
-~~~ruby
-request = Request.new("GET", url)
-response = call(request)
-
-response.headers
-response.read
-~~~
-
-## Stream Model
-
-~~~ruby
-response = Response.new
-call(response.request("GET", url))
-
-response.headers
-response.read
-~~~
-
-## Differences
-
-The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in:
-
-~~~ruby
-def call(request)
-	body = Body::Writable.new
-	
-	Fiber.schedule do
-		while chunk = request.input.read
-			body.write(chunk.reverse)
-		end
-	end
-	
-	return Response[200, [], body]
-end
-
-input = Body::Writable.new
-response = call(... body ...)
-
-input.write("Hello World")
-input.close
-response.read -> "dlroW olleH"
-~~~
-
-The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information.
-
-~~~ruby
-def call(stream)
-	Fiber.schedule do
-		while chunk = stream.read
-			stream.write(chunk.reverse)
-		end
-	end
-end
-
-input = Body::Writable.new
-response = Response.new(...input...)
-call(response.stream)
-
-input.write("Hello World")
-input.close
-response.read -> "dlroW olleH"
-~~~
-
-The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server.
-
-## Connection Upgrade
-
-### HTTP/1
-
-```
-GET /path/to/websocket HTTP/1.1
-connection: upgrade
-upgrade: websocket
-```
-
-Request.new(GET, ..., protocol = websocket)
--> Response.new(101, ..., protocol = websocket)
-
-```
-101 Switching Protocols
-upgrade: websocket
-```
-
-### HTTP/2
-
-```
-:method CONNECT
-:path /path/to/websocket
-:protocol websocket
-```
-
-Request.new(CONNECT, ..., protocol = websocket)
--> Response.new(200, ..., protocol = websocket)
diff -pruN 0.23.12-1/examples/streaming/bidirectional.rb 0.55.0-1/examples/streaming/bidirectional.rb
--- 0.23.12-1/examples/streaming/bidirectional.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/bidirectional.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,71 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "async"
+require "async/http/client"
+require "async/http/server"
+require "async/http/endpoint"
+
+require "protocol/http/body/streamable"
+require "protocol/http/body/writable"
+require "protocol/http/body/stream"
+
+endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Streamable.response(request) do |stream|
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				$stderr.puts "Server chunk: #{chunk.inspect}"
+				stream.write(chunk)
+				$stderr.puts "Server waiting for next chunk..."
+			end
+			$stderr.puts "Server done reading request."
+		rescue EOFError
+			$stderr.puts "Server EOF."
+			# Ignore EOF errors.
+		ensure
+			$stderr.puts "Server closing stream."
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	streamable = Protocol::HTTP::Body::Streamable.request do |stream|
+		stream.write("Hello, ")
+		stream.write("World!")
+		
+		$stderr.puts "Client closing write..."
+		stream.close_write
+		
+		$stderr.puts "Client reading response..."
+		
+		while chunk = stream.readpartial(1024)
+			$stderr.puts "Client chunk: #{chunk.inspect}"
+			puts chunk
+		end
+		$stderr.puts "Client done reading response."
+	rescue EOFError
+		$stderr.puts "Client EOF."
+		# Ignore EOF errors.
+	ensure
+		$stderr.puts "Client closing stream: #{$!}"
+		stream.close
+	end
+	
+	$stderr.puts "Client sending request..."
+	response = client.get("/", body: streamable)
+	$stderr.puts "Client received response and streaming it..."
+	streamable.stream(response.body)
+ensure
+	server_task.stop
+end
diff -pruN 0.23.12-1/examples/streaming/bidirectional2.rb 0.55.0-1/examples/streaming/bidirectional2.rb
--- 0.23.12-1/examples/streaming/bidirectional2.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/bidirectional2.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,66 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "async"
+require "async/http/client"
+require "async/http/server"
+require "async/http/endpoint"
+
+require "protocol/http/body/streamable"
+require "protocol/http/body/writable"
+require "protocol/http/body/stream"
+
+endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Streamable.response(request) do |stream|
+			$stderr.puts "Server writing chunks..."
+			stream.write("Hello, ")
+			stream.write("World!")
+			
+			$stderr.puts "Server reading chunks..."
+			while chunk = stream.readpartial(1024)
+				puts chunk
+			end
+		rescue EOFError
+			$stderr.puts "Server EOF."
+			# Ignore EOF errors.
+		ensure
+			$stderr.puts "Server closing stream."
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	streamable = Protocol::HTTP::Body::Streamable.request do |stream|
+		# Simple echo client:
+		while chunk = stream.readpartial(1024)
+			$stderr.puts "Client chunk: #{chunk.inspect}"
+			stream.write(chunk)
+			$stderr.puts "Client waiting for next chunk..."
+		end
+	rescue EOFError
+		$stderr.puts "Client EOF."
+		# Ignore EOF errors.
+	ensure
+		$stderr.puts "Client closing stream."
+		stream.close
+	end
+	
+	$stderr.puts "Client sending request..."
+	response = client.get("/", body: streamable)
+	$stderr.puts "Client received response and streaming it..."
+	streamable.stream(response.body)
+	$stderr.puts "Client done streaming response."
+ensure
+	server_task.stop
+end
diff -pruN 0.23.12-1/examples/streaming/gems.locked 0.55.0-1/examples/streaming/gems.locked
--- 0.23.12-1/examples/streaming/gems.locked	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/gems.locked	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,76 @@
+PATH
+  remote: ../../../async-http
+  specs:
+    async-http (0.75.0)
+      async (>= 2.10.2)
+      async-pool (~> 0.7)
+      io-endpoint (~> 0.11)
+      io-stream (~> 0.4)
+      protocol-http (~> 0.33)
+      protocol-http1 (~> 0.20)
+      protocol-http2 (~> 0.18)
+      traces (>= 0.10)
+
+PATH
+  remote: ../..
+  specs:
+    protocol-http (0.33.0)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    async (2.17.0)
+      console (~> 1.26)
+      fiber-annotation
+      io-event (~> 1.6, >= 1.6.5)
+    async-pool (0.8.1)
+      async (>= 1.25)
+      metrics
+      traces
+    console (1.27.0)
+      fiber-annotation
+      fiber-local (~> 1.1)
+      json
+    debug (1.9.2)
+      irb (~> 1.10)
+      reline (>= 0.3.8)
+    fiber-annotation (0.2.0)
+    fiber-local (1.1.0)
+      fiber-storage
+    fiber-storage (1.0.0)
+    io-console (0.7.2)
+    io-endpoint (0.13.1)
+    io-event (1.6.5)
+    io-stream (0.4.0)
+    irb (1.14.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (2.7.2)
+    metrics (0.10.2)
+    protocol-hpack (1.5.0)
+    protocol-http1 (0.22.0)
+      protocol-http (~> 0.22)
+    protocol-http2 (0.18.0)
+      protocol-hpack (~> 1.4)
+      protocol-http (~> 0.18)
+    psych (5.1.2)
+      stringio
+    rdoc (6.7.0)
+      psych (>= 4.0.0)
+    reline (0.5.10)
+      io-console (~> 0.5)
+    stringio (3.1.1)
+    traces (0.13.1)
+
+PLATFORMS
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  async
+  async-http!
+  debug
+  protocol-http!
+
+BUNDLED WITH
+   2.5.16
diff -pruN 0.23.12-1/examples/streaming/gems.rb 0.55.0-1/examples/streaming/gems.rb
--- 0.23.12-1/examples/streaming/gems.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/gems.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+source "https://rubygems.org"
+
+gem "async"
+gem "async-http", path: "../../../async-http"
+gem "protocol-http", path: "../../"
+
+gem "debug"
diff -pruN 0.23.12-1/examples/streaming/simple.rb 0.55.0-1/examples/streaming/simple.rb
--- 0.23.12-1/examples/streaming/simple.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/simple.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,56 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "async"
+require "async/http/client"
+require "async/http/server"
+require "async/http/endpoint"
+
+require "protocol/http/body/streamable"
+require "protocol/http/body/writable"
+require "protocol/http/body/stream"
+
+endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Streamable.response(request) do |stream|
+			$stderr.puts "Server sending text..."
+			stream.write("Hello from server!")
+		rescue EOFError
+			$stderr.puts "Server EOF."
+			# Ignore EOF errors.
+		ensure
+			$stderr.puts "Server closing stream."
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	streamable = Protocol::HTTP::Body::Streamable.request do |stream|
+		while chunk = stream.readpartial(1024)
+			$stderr.puts "Client chunk: #{chunk.inspect}"
+		end
+	rescue EOFError
+		$stderr.puts "Client EOF."
+		# Ignore EOF errors.
+	ensure
+		$stderr.puts "Client closing stream."
+		stream.close
+	end
+	
+	$stderr.puts "Client sending request..."
+	response = client.get("/", body: streamable)
+	$stderr.puts "Client received response and streaming it..."
+	streamable.stream(response.body)
+ensure
+	server_task.stop
+end
diff -pruN 0.23.12-1/examples/streaming/unidirectional.rb 0.55.0-1/examples/streaming/unidirectional.rb
--- 0.23.12-1/examples/streaming/unidirectional.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/unidirectional.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,60 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "async"
+require "async/http/client"
+require "async/http/server"
+require "async/http/endpoint"
+
+require "protocol/http/body/stream"
+require "protocol/http/body/writable"
+
+endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				stream.write(chunk)
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		stream.write("Hello, ")
+		stream.write("World!")
+		stream.close_write
+		
+		while chunk = stream.readpartial(1024)
+			puts chunk
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
diff -pruN 0.23.12-1/examples/streaming/unidirectional2.rb 0.55.0-1/examples/streaming/unidirectional2.rb
--- 0.23.12-1/examples/streaming/unidirectional2.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/examples/streaming/unidirectional2.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,65 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "async"
+require "async/http/client"
+require "async/http/server"
+require "async/http/endpoint"
+
+require "protocol/http/body/stream"
+require "protocol/http/body/writable"
+
+def make_server(endpoint)
+	Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			stream.write("Hello, ")
+			stream.write("World!")
+			
+			stream.close_write
+			
+			# Simple echo server:
+			$stderr.puts "Server reading chunks..."
+			while chunk = stream.readpartial(1024)
+				puts chunk
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+end
+
+Async do |task|
+	endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
+	
+	server_task = task.async{make_server(endpoint).run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		$stderr.puts "Client echoing chunks..."
+		while chunk = stream.readpartial(1024)
+			stream.write(chunk)
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
diff -pruN 0.23.12-1/fixtures/protocol/http/body/a_readable_body.rb 0.55.0-1/fixtures/protocol/http/body/a_readable_body.rb
--- 0.23.12-1/fixtures/protocol/http/body/a_readable_body.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/fixtures/protocol/http/body/a_readable_body.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+module Protocol
+	module HTTP
+		module Body
+			AReadableBody = Sus::Shared("a readable body") do
+				with "#read" do
+					it "after closing, returns nil" do
+						body.close
+						
+						expect(body.read).to be_nil
+					end
+				end
+				
+				with "empty?" do
+					it "returns true after closing" do
+						body.close
+						
+						expect(body).to be(:empty?)
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/fixtures/protocol/http/body/a_writable_body.rb 0.55.0-1/fixtures/protocol/http/body/a_writable_body.rb
--- 0.23.12-1/fixtures/protocol/http/body/a_writable_body.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/fixtures/protocol/http/body/a_writable_body.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+module Protocol
+	module HTTP
+		module Body
+			AWritableBody = Sus::Shared("a readable body") do
+				with "#read" do
+					it "after closing the write end, returns all chunks" do
+						body.write("Hello ")
+						body.write("World!")
+						body.close_write
+						
+						expect(body.read).to be == "Hello "
+						expect(body.read).to be == "World!"
+						expect(body.read).to be_nil
+					end
+				end
+				
+				with "empty?" do
+					it "returns false before writing" do
+						expect(body).not.to be(:empty?)
+					end
+					
+					it "returns true after all chunks are consumed" do
+						body.write("Hello")
+						body.close_write
+						
+						expect(body).not.to be(:empty?)
+						expect(body.read).to be == "Hello"
+						expect(body.read).to be_nil
+						
+						expect(body).to be(:empty?)
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/gems.rb 0.55.0-1/gems.rb
--- 0.23.12-1/gems.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/gems.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,21 +1,35 @@
 # frozen_string_literal: true
 
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
+
 source "https://rubygems.org"
 
 # Specify your gem's dependencies in protocol-http.gemspec
 gemspec
 
+# gem "async-http", path: "../async-http"
+
 group :maintenance, optional: true do
 	gem "bake-modernize"
 	gem "bake-gem"
+	gem "bake-releases"
+	
+	gem "agent-context"
 	
 	gem "utopia-project", "~> 0.18"
 end
 
 group :test do
+	gem "covered"
+	gem "sus"
+	gem "decode"
+	
+	gem "rubocop"
+	gem "rubocop-socketry"
+	
+	gem "sus-fixtures-async"
+	
 	gem "bake-test"
 	gem "bake-test-external"
-	
-	gem 'async-io'
-	gem 'async-rspec'
 end
diff -pruN 0.23.12-1/guides/design-overview/README.md 0.55.0-1/guides/design-overview/README.md
--- 0.23.12-1/guides/design-overview/README.md	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/guides/design-overview/README.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,191 +0,0 @@
-# Design Overview
-
-The interfaces provided by {ruby Protocol::HTTP} underpin all downstream implementations. Therefore, we provide some justification for the design choices.
-
-## Request/Response Model
-
-The main model we support is the request/response model. A client sends a request to a server which return response. The protocol is responsible for serializing the request and response objects.
-
-```mermaid
-sequenceDiagram
-	participant CA as Application
-	participant Client
-	participant Server
-	participant SA as Application
-	CA->>+Client: Request
-	Client->>+Server: Request
-	Server->>+SA: Request
-	SA->>+Server: Response
-	Server->>+Client: Response
-	Client->>+CA: Response
-```
-
-We provide an interface for request and response objects. This provides performance, predictability and robustness. This model has proven itself over several years, handling a variety of different use cases.
-
-~~~ ruby
-class Request
-	attr :verb
-	attr :target
-	attr :headers
-	attr :body
-end
-
-class Response
-	attr :status
-	attr :headers
-	attr :body
-end
-~~~
-
-One other advantage is that it's symmetrical between clients and servers with a clear mapping, i.e. the protocol is responsible for transiting requests from the client to the server, and responses from the server back to the client. This helps us separate and define request/response interfaces independently from protocol implementation.
-
-### Client Design
-
-A request/response model implies that you create a request and receive a response back. This maps to a normal function call where the request is the argument and the response is the returned value.
-
-~~~ ruby
-request = Request.new("GET", url)
-response = client.call(request)
-
-response.headers
-response.read
-~~~
-
-## Stream Model
-
-An alternative model is the stream model. This model is more suitable for WebSockets and other persistent bi-directional channels.
-
-```mermaid
-sequenceDiagram
-	participant CA as Application
-	participant Client
-	participant Server
-	participant SA as Application
-	CA->>+Client: Stream
-	Client->>+Server: Stream
-	Server->>+SA: Stream
-```
-
-The interfaces for streaming can be implemented a bit differently, since a response is not returned but rather assigned to the stream, and the streaming occurs in the same execution context as the client or server handling the request.
-
-~~~ ruby
-class Stream
-	# Request details.
-	attr :verb
-	attr :target
-	attr :headers
-	
-	attr :response
-	
-	# Write the response and start streaming the output body.
-	def respond(status, headers)
-		response.status = status
-		response.headers = headers
-	end
-	
-	# Request body.
-	attr_accessor :input
-	
-	# Response body.
-	attr_accessor :output
-	
-	# Write to the response body.
-	def write(...)
-		@output.write(...)
-	end
-	
-	# Read from the request body.
-	def read
-		@input.read
-	end
-end
-
-class Response
-	def initialize(verb, target)
-		@input = Body::Writable.new
-		@output = Body::Writable.new
-	end
-	
-	attr_accessor :status
-	attr_accessor :headers
-	
-	# Prepare a stream for making a request.
-	def request(verb, target, headers)
-		# Create a request stream suitable for writing into the buffered response:
-		Stream.new(verb, target, headers, self, @input, @output)
-	end
-	
-	# Write to the request body.
-	def write(...)
-		@input.write(...)
-	end
-	
-	# Read from the response body.
-	def read
-		@output.read
-	end
-end
-~~~
-
-### Client Design
-
-A stream model implies that you create a stream which contains both the request and response bodies. This maps to a normal function call where the argument is the stream and the returned value is ignored.
-
-~~~ ruby
-response = Response.new
-stream = response.request("GET", url)
-
-client.call(stream)
-
-response.headers
-response.read
-~~~
-
-## Differences
-
-The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in:
-
-~~~ ruby
-def call(request)
-	body = Body::Writable.new
-	
-	Fiber.schedule do
-		while chunk = request.input.read
-			body.write(chunk.reverse)
-		end
-	end
-	
-	return Response[200, headers, body]
-end
-
-input = Body::Writable.new
-response = call(... body ...)
-
-input.write("Hello World")
-input.close
-response.read -> "dlroW olleH"
-~~~
-
-The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information.
-
-~~~ruby
-def call(stream)
-	stream.respond(200, headers)
-	
-	Fiber.schedule do
-		while chunk = stream.read
-			stream.write(chunk.reverse)
-		end
-	end
-end
-
-input = Body::Writable.new
-response = Response.new(...input...)
-call(response.stream)
-
-input.write("Hello World")
-input.close
-response.read -> "dlroW olleH"
-~~~
-
-The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server.
diff -pruN 0.23.12-1/guides/design-overview/readme.md 0.55.0-1/guides/design-overview/readme.md
--- 0.23.12-1/guides/design-overview/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/design-overview/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,209 @@
+# Design Overview
+
+This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers.
+
+## Request/Response Model
+
+The main model we support is the request/response model. A client sends a request to a server which return response. The protocol is responsible for serializing the request and response objects.
+
+```mermaid
+sequenceDiagram
+	participant CA as Application
+	participant Client
+	participant Server
+	participant SA as Application
+	CA->>+Client: Request
+	Client->>+Server: Request
+	Server->>+SA: Request
+	SA->>+Server: Response
+	Server->>+Client: Response
+	Client->>+CA: Response
+```
+
+We provide an interface for request and response objects. This provides performance, predictability and robustness. This model has proven itself over several years, handling a variety of different use cases.
+
+~~~ ruby
+class Request
+	attr :method
+	attr :target
+	attr :headers
+	attr :body
+end
+
+class Response
+	attr :status
+	attr :headers
+	attr :body
+end
+~~~
+
+One other advantage is that it's symmetrical between clients and servers with a clear mapping, i.e. the protocol is responsible for transiting requests from the client to the server, and responses from the server back to the client. This helps us separate and define request/response interfaces independently from protocol implementation.
+
+### Client Design
+
+A request/response model implies that you create a request and receive a response back. This maps to a normal function call where the request is the argument and the response is the returned value.
+
+~~~ ruby
+request = Request.new("GET", url)
+response = client.call(request)
+
+response.headers
+response.read
+~~~
+
+## Stream Model
+
+An alternative model is the stream model. This model is more suitable for WebSockets and other persistent bi-directional channels.
+
+```mermaid
+sequenceDiagram
+	participant CA as Application
+	participant Client
+	participant Server
+	participant SA as Application
+	CA->>+Client: Stream
+	Client->>+Server: Stream
+	Server->>+SA: Stream
+```
+
+The interfaces for streaming can be implemented a bit differently, since a response is not returned but rather assigned to the stream, and the streaming occurs in the same execution context as the client or server handling the request.
+
+~~~ ruby
+class Stream
+	# Request details.
+	attr :method
+	attr :target
+	attr :headers
+	
+	attr :response
+	
+	# Write the response and start streaming the output body.
+	def respond(status, headers)
+		response.status = status
+		response.headers = headers
+	end
+	
+	# Request body.
+	attr_accessor :input
+	
+	# Response body.
+	attr_accessor :output
+	
+	# Write to the response body.
+	def write(...)
+		@output.write(...)
+	end
+	
+	# Read from the request body.
+	def read
+		@input.read
+	end
+end
+
+class Response
+	def initialize(method, target)
+		@input = Body::Writable.new
+		@output = Body::Writable.new
+	end
+	
+	attr_accessor :status
+	attr_accessor :headers
+	
+	# Prepare a stream for making a request.
+	def request(method, target, headers)
+		# Create a request stream suitable for writing into the buffered response:
+		Stream.new(method, target, headers, self, @input, @output)
+	end
+	
+	# Write to the request body.
+	def write(...)
+		@input.write(...)
+	end
+	
+	# Read from the response body.
+	def read
+		@output.read
+	end
+end
+~~~
+
+### Client Design
+
+A stream model implies that you create a stream which contains both the request and response bodies. This maps to a normal function call where the argument is the stream and the returned value is ignored.
+
+~~~ ruby
+response = Response.new
+stream = response.request("GET", url)
+
+client.call(stream)
+
+response.headers
+response.read
+~~~
+
+## Differences
+
+The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in:
+
+~~~ ruby
+def call(request)
+	body = Body::Writable.new
+	
+	Fiber.schedule do
+		while chunk = request.input.read
+			body.write(chunk.reverse)
+		end
+	end
+	
+	return Response[200, headers, body]
+end
+
+input = Body::Writable.new
+response = call(... body ...)
+
+input.write("Hello World")
+input.close
+response.read -> "dlroW olleH"
+~~~
+
+The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information.
+
+~~~ruby
+def call(stream)
+	stream.respond(200, headers)
+	
+	Fiber.schedule do
+		while chunk = stream.read
+			stream.write(chunk.reverse)
+		end
+	end
+end
+
+input = Body::Writable.new
+response = Response.new(...input...)
+call(response.stream)
+
+input.write("Hello World")
+input.close
+response.read -> "dlroW olleH"
+~~~
+
+The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server.
+
+## Interim Response Handling
+
+Interim responses are responses that are sent before the final response. They are used for things like `103 Early Hints` and `100 Continue`. These responses are sent before the final response, and are used to signal to the client that the server is still processing the request.
+
+```ruby
+body = Body::Writable.new
+
+interim_response_callback = proc do |status, headers|
+	if status == 100
+		# Continue sending the request body.
+		body.write("Hello World")
+		body.close
+	end
+end
+
+response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback)
+```
diff -pruN 0.23.12-1/guides/getting-started/README.md 0.55.0-1/guides/getting-started/README.md
--- 0.23.12-1/guides/getting-started/README.md	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/guides/getting-started/README.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,73 +0,0 @@
-# Getting Started
-
-This guide explains how to use `protocol-http` for building abstract HTTP interfaces.
-
-## Installation
-
-Add the gem to your project:
-
-~~~ bash
-$ bundle add protocol-http
-~~~
-
-## Core Concepts
-
-`protocol-http` has several core concepts:
-
-- A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state.
-- A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state.
-- A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications.
-- A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc).
-- A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming.
-
-## Integration
-
-This gem does not provide any specific client or server implementation, rather it's used by several other gems.
-
-- [Protocol::HTTP1] & [Protocol::HTTP2] which provide client and server implementations.
-- [Async::HTTP] which provides connection pooling and concurrency.
-
-## Usage
-
-### Headers
-
-{ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values implements case-normalising keys.
-
-``` ruby
-require 'protocol/http/headers'
-
-headers = Protocol::HTTP::Headers.new
-
-headers['Content-Type'] = "image/jpeg"
-
-headers['content-type']
-# => "image/jpeg"
-```
-
-### Reference
-
-{ruby Protocol::HTTP::Reference} is used to construct "hypertext references" which consist of a path and URL-encoded key/value pairs.
-
-``` ruby
-require 'protocol/http/reference'
-
-reference = Protocol::HTTP::Reference.new("/search", q: 'kittens')
-
-reference.to_s
-# => "/search?q=kittens"
-```
-
-### URL
-
-{ruby Protocol::HTTP::URL} is used to parse incoming URLs to extract the query string and other relevant details.
-
-``` ruby
-require 'protocol/http/url'
-
-reference = Protocol::HTTP::Reference.parse("/search?q=kittens")
-
-parameters = Protocol::HTTP::URL.decode(reference.query_string)
-# => {"q"=>"kittens"}
-```
-
-This implemenation may be merged with {ruby Protocol::HTTP::Reference} or removed in the future.
diff -pruN 0.23.12-1/guides/getting-started/readme.md 0.55.0-1/guides/getting-started/readme.md
--- 0.23.12-1/guides/getting-started/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/getting-started/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,130 @@
+# Getting Started
+
+This guide explains how to use `protocol-http` for building abstract HTTP interfaces.
+
+## Installation
+
+Add the gem to your project:
+
+~~~ bash
+$ bundle add protocol-http
+~~~
+
+## Core Concepts
+
+`protocol-http` has several core concepts:
+
+  - A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state.
+  - A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state.
+  - A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications.
+  - A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc).
+  - A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming.
+
+## Integration
+
+This gem does not provide any specific client or server implementation, rather it's used by several other gems.
+
+  - [Protocol::HTTP1](https://github.com/socketry/protocol-http1) & [Protocol::HTTP2](https://github.com/socketry/protocol-http2) which provide client and server implementations.
+  - [Async::HTTP](https://github.com/socketry/async-http) which provides connection pooling and concurrency.
+
+## Usage
+
+### Request
+
+{ruby Protocol::HTTP::Request} represents an HTTP request which can be used both server and client-side.
+
+``` ruby
+require 'protocol/http/request'
+
+# Short form (recommended):
+request = Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}]
+
+# Long form:
+headers = Protocol::HTTP::Headers[["accept", "text/html"]]
+request = Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", headers)
+
+# Access request properties
+request.method           # => "GET"
+request.path             # => "/index.html"
+request.headers          # => Protocol::HTTP::Headers instance
+```
+
+### Response
+
+{ruby Protocol::HTTP::Response} represents an HTTP response which can be used both server and client-side.
+
+``` ruby
+require 'protocol/http/response'
+
+# Short form (recommended):
+response = Protocol::HTTP::Response[200, {"content-type" => "text/html"}, "Hello, World!"]
+
+# Long form:
+headers = Protocol::HTTP::Headers["content-type" => "text/html"]
+body = Protocol::HTTP::Body::Buffered.wrap("Hello, World!")
+response = Protocol::HTTP::Response.new("HTTP/1.1", 200, headers, body)
+
+# Access response properties
+response.status          # => 200
+response.headers         # => Protocol::HTTP::Headers instance
+response.body            # => Body instance
+
+# Status checking methods
+response.success?        # => true (200-299)
+response.ok?             # => true (200)
+response.redirection?    # => false (300-399)
+response.failure?        # => false (400-599)
+```
+
+### Headers
+
+{ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values and implements case-normalising keys.
+
+#### Basic Usage
+
+``` ruby
+require 'protocol/http/headers'
+
+headers = Protocol::HTTP::Headers.new
+
+# Assignment by title-case key:
+headers['Content-Type'] = "image/jpeg"
+
+# Lookup by lower-case (normalized) key:
+headers['content-type']
+# => "image/jpeg"
+```
+
+#### Semantic Processing
+
+Many headers receive special semantic processing, automatically splitting comma-separated values and providing structured access:
+
+``` ruby
+# Accept header with quality values:
+headers['Accept'] = 'text/html, application/json;q=0.8, */*;q=0.1'
+accept = headers['accept']
+# => ["text/html", "application/json;q=0.8", "*/*;q=0.1"]
+
+# Access parsed media ranges with quality factors:
+accept.media_ranges.each do |range|
+	puts "#{range.type}/#{range.subtype} (q=#{range.quality_factor})"
+end
+# text/html (q=1.0)
+# application/json (q=0.8)
+# */* (q=0.1)
+
+# Accept-Encoding automatically splits values:
+headers['Accept-Encoding'] = 'gzip, deflate, br;q=0.9'
+headers['accept-encoding']
+# => ["gzip", "deflate", "br;q=0.9"]
+
+# Cache-Control splits directives:
+headers['Cache-Control'] = 'max-age=3600, no-cache, must-revalidate'
+headers['cache-control']
+# => ["max-age=3600", "no-cache", "must-revalidate"]
+
+# Vary header normalizes field names to lowercase:
+headers['Vary'] = 'Accept-Encoding, User-Agent'
+headers['vary']
+# => ["accept-encoding", "user-agent"]
+```
diff -pruN 0.23.12-1/guides/headers/readme.md 0.55.0-1/guides/headers/readme.md
--- 0.23.12-1/guides/headers/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/headers/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,94 @@
+# Headers
+
+This guide explains how to work with HTTP headers using `protocol-http`.
+
+## Core Concepts
+
+`protocol-http` provides several core concepts for working with HTTP headers:
+
+- A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features.
+- Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting.
+- Trailer security validation to prevent HTTP request smuggling attacks.
+
+## Usage
+
+The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:
+
+```ruby
+require 'protocol/http'
+
+headers = Protocol::HTTP::Headers.new
+headers.add('content-type', 'text/html')
+headers.add('set-cookie', 'session=abc123')
+
+# Access headers
+content_type = headers['content-type'] # => "text/html"
+
+# Check if header exists
+headers.include?('content-type') # => true
+```
+
+### Header Policies
+
+Different header types have different behaviors for merging, validation, and trailer handling:
+
+```ruby
+# Some headers can be specified multiple times
+headers.add('set-cookie', 'first=value1')
+headers.add('set-cookie', 'second=value2')
+
+# Others are singletons and will raise errors if duplicated
+headers.add('content-length', '100')
+# headers.add('content-length', '200') # Would raise DuplicateHeaderError
+```
+
+### Structured Headers
+
+Some headers have specialized classes for parsing and formatting:
+
+```ruby
+# Accept header with media ranges
+accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
+media_ranges = accept.media_ranges
+
+# Authorization header
+auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
+# => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
+```
+
+### Trailer Security
+
+HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers:
+
+```ruby
+# Working with trailers
+headers = Protocol::HTTP::Headers.new([
+  ['content-type', 'text/html'],
+  ['content-length', '1000']
+])
+
+# Start trailer section
+headers.trailer!
+
+# These will be allowed (safe metadata)
+headers.add('etag', '"12345"')
+headers.add('date', Time.now.httpdate)
+
+# These will be silently ignored for security
+headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk
+headers.add('connection', 'close') # Ignored - hop-by-hop header
+```
+
+The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:
+
+**Allowed headers** (return `true` for `trailer?`):
+- `date` - Response generation timestamps.
+- `digest` - Content integrity verification.
+- `etag` - Cache validation tags.
+- `server-timing` - Performance metrics.
+
+**Forbidden headers** (return `false` for `trailer?`):
+- `authorization` - Prevents credential leakage.
+- `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior.
+- `cookie`, `set-cookie` - State information needed during initial processing.
+- `accept` - Content negotiation must occur before response generation.
diff -pruN 0.23.12-1/guides/links.yaml 0.55.0-1/guides/links.yaml
--- 0.23.12-1/guides/links.yaml	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/guides/links.yaml	2025-10-23 12:07:18.000000000 +0000
@@ -1,4 +1,12 @@
 getting-started:
   order: 1
-design-overview:
+message-body:
   order: 2
+headers:
+  order: 3
+middleware:
+  order: 4
+streaming:
+  order: 7
+design-overview:
+  order: 10
diff -pruN 0.23.12-1/guides/message-body/readme.md 0.55.0-1/guides/message-body/readme.md
--- 0.23.12-1/guides/message-body/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/message-body/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,330 @@
+# Message Body
+
+This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes.
+
+## Overview
+
+HTTP message bodies represent the actual (often stateful) data content of requests and responses. `Protocol::HTTP` provides a rich set of body classes for different use cases, from simple string content to streaming data and file serving.
+
+All body classes inherit from {ruby Protocol::HTTP::Body::Readable}, which provides a consistent interface for reading data in chunks. Bodies can be:
+- **Buffered**: All content stored in memory.
+- **Streaming**: Content generated or read on-demand.
+- **File-based**: Content read directly from files.
+- **Transforming**: Content modified as it flows through e.g. compression, encryption.
+
+## Core Body Interface
+
+Every body implements the `Readable` interface:
+
+``` ruby
+# Read the next chunk of data:
+chunk = body.read
+# => "Hello" or nil when finished
+
+# Check if body has data available without blocking:
+body.ready?  # => true/false
+
+# Check if body is empty:
+body.empty?  # => true/false
+
+# Close the body and release resources:
+body.close
+
+# Iterate through all chunks: 
+body.each do |chunk|
+	puts chunk
+end
+
+# Read entire body into a string:
+content = body.join
+```
+
+## Buffered Bodies
+
+Use {ruby Protocol::HTTP::Body::Buffered} for content that's fully loaded in memory:
+
+``` ruby
+# Create from string:
+body = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])
+
+# Create from array of strings:
+chunks = ["First chunk", "Second chunk", "Third chunk"]
+body = Protocol::HTTP::Body::Buffered.new(chunks)
+
+# Wrap various types automatically:
+body = Protocol::HTTP::Body::Buffered.wrap("Simple string")
+body = Protocol::HTTP::Body::Buffered.wrap(["Array", "of", "chunks"])
+
+# Access properties:
+body.length      # => 13 (total size in bytes)
+body.empty?      # => false
+body.ready?      # => true (always ready)
+
+# Reading:
+first_chunk = body.read    # => "Hello"
+second_chunk = body.read   # => " "
+third_chunk = body.read    # => "World"
+fourth_chunk = body.read   # => nil (finished)
+
+# Rewind to beginning:
+body.rewind
+body.read  # => "Hello" (back to start)
+```
+
+### Buffered Body Features
+
+``` ruby
+# Check if rewindable:
+body.rewindable?  # => true for buffered bodies
+
+# Get all content as single string:
+content = body.join  # => "Hello World"
+
+# Convert to array of chunks:
+chunks = body.to_a   # => ["Hello", " ", "World"]
+
+# Write additional chunks:
+body.write("!")
+body.join  # => "Hello World!"
+
+# Clear all content:
+body.clear
+body.empty?  # => true
+```
+
+## File Bodies
+
+Use {ruby Protocol::HTTP::Body::File} for serving files efficiently:
+
+``` ruby
+require 'protocol/http/body/file'
+
+# Open a file:
+body = Protocol::HTTP::Body::File.open("/path/to/file.txt")
+
+# Create from existing File object:
+file = File.open("/path/to/image.jpg", "rb")
+body = Protocol::HTTP::Body::File.new(file)
+
+# Serve partial content (ranges):
+range = 100...200  # bytes 100-199
+body = Protocol::HTTP::Body::File.new(file, range)
+
+# Properties:
+body.length      # => file size or range size
+body.empty?      # => false (unless zero-length file)
+body.ready?      # => false (may block when reading)
+
+# File bodies read in chunks automatically:
+body.each do |chunk|
+	# Process each chunk (typically 64KB)
+	puts "Read #{chunk.bytesize} bytes"
+end
+```
+
+### File Body Range Requests
+
+``` ruby
+# Serve specific byte ranges (useful for HTTP range requests):
+file = File.open("large_video.mp4", "rb")
+
+# First 1MB:
+partial_body = Protocol::HTTP::Body::File.new(file, 0...1_048_576)
+
+# Custom block size for reading:
+body = Protocol::HTTP::Body::File.new(file, block_size: 8192)  # 8KB chunks
+```
+
+## Writable Bodies
+
+Use {ruby Protocol::HTTP::Body::Writable} for dynamic content generation:
+
+``` ruby
+require 'protocol/http/body/writable'
+
+# Create a writable body:
+body = Protocol::HTTP::Body::Writable.new
+
+# Write data in another thread/fiber:
+Thread.new do
+	body.write("First chunk\n")
+	sleep 0.1
+	body.write("Second chunk\n")
+	body.write("Final chunk\n")
+	body.close_write  # Signal no more data
+end
+
+# Read from main thread:
+body.each do |chunk|
+	puts "Received: #{chunk}"
+end
+# Output:
+# Received: First chunk
+# Received: Second chunk  
+# Received: Final chunk
+```
+
+### Writable Body with Backpressure
+
+``` ruby
+# Use SizedQueue to limit buffering:
+queue = Thread::SizedQueue.new(10)  # Buffer up to 10 chunks
+body = Protocol::HTTP::Body::Writable.new(queue: queue)
+
+# Writing will block if queue is full:
+body.write("chunk 1")
+# ... write up to 10 chunks before blocking
+```
+
+## Streaming Bodies
+
+Use {ruby Protocol::HTTP::Body::Streamable} for computed content:
+
+``` ruby
+require 'protocol/http/body/streamable'
+
+# Generate content dynamically:
+body = Protocol::HTTP::Body::Streamable.new do |output|
+	10.times do |i|
+		output.write("Line #{i}\n")
+		# Could include delays, computation, database queries, etc.
+	end
+end
+
+# Content is generated as it's read:
+body.each do |chunk|
+	puts "Got: #{chunk}"
+end
+```
+
+## Stream Bodies (IO Wrapper)
+
+Use {ruby Protocol::HTTP::Body::Stream} to wrap IO-like objects:
+
+``` ruby
+require 'protocol/http/body/stream'
+
+# Wrap an IO object:
+io = StringIO.new("Hello\nWorld\nFrom\nStream")
+body = Protocol::HTTP::Body::Stream.new(io)
+
+# Read line by line:
+line1 = body.gets    # => "Hello\n"
+line2 = body.gets    # => "World\n"
+
+# Read specific amounts:
+data = body.read(5)  # => "From\n"
+
+# Read remaining data:
+rest = body.read     # => "Stream"
+```
+
+## Body Transformations
+
+### Compression Bodies
+
+``` ruby
+require 'protocol/http/body/deflate'
+require 'protocol/http/body/inflate'
+
+# Compress a body:
+original = Protocol::HTTP::Body::Buffered.new(["Hello World"])
+compressed = Protocol::HTTP::Body::Deflate.new(original)
+
+# Decompress a body:
+decompressed = Protocol::HTTP::Body::Inflate.new(compressed)
+content = decompressed.join  # => "Hello World"
+```
+
+### Wrapper Bodies
+
+Create custom body transformations:
+
+``` ruby
+require 'protocol/http/body/wrapper'
+
+class UppercaseBody < Protocol::HTTP::Body::Wrapper
+	def read
+		if chunk = super
+			chunk.upcase
+		end
+	end
+end
+
+# Use the wrapper:
+original = Protocol::HTTP::Body::Buffered.wrap("hello world")
+uppercase = UppercaseBody.new(original)
+content = uppercase.join  # => "HELLO WORLD"
+```
+
+## Life-cycle
+
+### Initialization
+
+Bodies are typically initialized with the data they need to process. For example:
+
+``` ruby
+body = Protocol::HTTP::Body::Buffered.wrap("Hello World")
+```
+
+### Reading
+
+Once initialized, bodies can be read in chunks:
+
+``` ruby
+body.each do |chunk|
+	puts "Read #{chunk.bytesize} bytes"
+end
+```
+
+### Closing
+
+It's important to close bodies when done to release resources:
+
+``` ruby
+begin
+	# ... read from the body ...
+rescue => error
+	# Ignore.
+ensure
+	# The body should always be closed:
+	body.close(error)
+end
+```
+
+## Advanced Usage
+
+### Rewindable Bodies
+
+Make any body rewindable by buffering:
+
+``` ruby
+require 'protocol/http/body/rewindable'
+
+# Wrap a non-rewindable body:
+file_body = Protocol::HTTP::Body::File.open("data.txt")
+rewindable = Protocol::HTTP::Body::Rewindable.new(file_body)
+
+# Read some data:
+first_chunk = rewindable.read
+
+# Rewind and read again:
+rewindable.rewind
+same_chunk = rewindable.read  # Same as first_chunk
+```
+
+### Head Bodies (Response without content)
+
+For HEAD requests that need content-length but no body:
+
+``` ruby
+require 'protocol/http/body/head'
+
+# Create head body from another body:
+original = Protocol::HTTP::Body::File.open("large_file.zip")
+head_body = Protocol::HTTP::Body::Head.for(original)
+
+head_body.length  # => size of original file
+head_body.read    # => nil (no actual content)
+head_body.empty?  # => true
+```
diff -pruN 0.23.12-1/guides/middleware/readme.md 0.55.0-1/guides/middleware/readme.md
--- 0.23.12-1/guides/middleware/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/middleware/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,195 @@
+# Middleware
+
+This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
+
+## Overview
+
+The middleware interface provides a convenient wrapper for implementing HTTP middleware components that can process requests and responses. Middleware enables you to build composable HTTP applications by chaining multiple processing layers.
+
+A middleware instance generally needs to respond to two methods:
+- `call(request)` -> `response`.
+- `close()` (called when shutting down).
+
+## Basic Middleware Interface
+
+You can implement middleware without using the `Middleware` class by implementing the interface directly:
+
+``` ruby
+class SimpleMiddleware
+	def initialize(delegate)
+		@delegate = delegate
+	end
+	
+	def call(request)
+		# Process request here
+		response = @delegate.call(request)
+		# Process response here
+		return response
+	end
+	
+	def close
+		@delegate&.close
+	end
+end
+```
+
+## Using the Middleware Class
+
+The `Protocol::HTTP::Middleware` class provides a convenient base for building middleware:
+
+``` ruby
+require 'protocol/http/middleware'
+
+class LoggingMiddleware < Protocol::HTTP::Middleware
+	def call(request)
+		puts "Processing: #{request.method} #{request.path}"
+		
+		response = super  # Calls @delegate.call(request)
+		
+		puts "Response: #{response.status}"
+		return response
+	end
+end
+
+# Use with a delegate:
+app = LoggingMiddleware.new(Protocol::HTTP::Middleware::HelloWorld)
+```
+
+## Building Middleware Stacks
+
+Use `Protocol::HTTP::Middleware.build` to construct middleware stacks:
+
+``` ruby
+require 'protocol/http/middleware'
+
+app = Protocol::HTTP::Middleware.build do
+	use LoggingMiddleware
+	use CompressionMiddleware
+	run Protocol::HTTP::Middleware::HelloWorld
+end
+
+# Handle a request:
+request = Protocol::HTTP::Request["GET", "/"]
+response = app.call(request)
+```
+
+The builder works by:
+- `use` adds middleware to the stack
+- `run` specifies the final application (defaults to `NotFound`)
+- Middleware is chained in reverse order (last `use` wraps first)
+
+## Block-Based Middleware
+
+Convert a block into middleware using `Middleware.for`:
+
+``` ruby
+middleware = Protocol::HTTP::Middleware.for do |request|
+	if request.path == '/health'
+		Protocol::HTTP::Response[200, {}, ["OK"]]
+	else
+		# This would normally delegate, but this example doesn't have a delegate
+		Protocol::HTTP::Response[404]
+	end
+end
+
+request = Protocol::HTTP::Request["GET", "/health"]
+response = middleware.call(request)
+# => Response with status 200
+```
+
+## Built-in Middleware
+
+### HelloWorld
+
+Always returns "Hello World!" response:
+
+``` ruby
+app = Protocol::HTTP::Middleware::HelloWorld
+response = app.call(request)
+# => 200 "Hello World!"
+```
+
+### NotFound
+
+Always returns 404 response:
+
+``` ruby
+app = Protocol::HTTP::Middleware::NotFound  
+response = app.call(request)
+# => 404 Not Found
+```
+
+### Okay
+
+Always returns 200 response with no body:
+
+``` ruby
+app = Protocol::HTTP::Middleware::Okay
+response = app.call(request)
+# => 200 OK
+```
+
+## Real-World Middleware Examples
+
+### Authentication Middleware
+
+``` ruby
+class AuthenticationMiddleware < Protocol::HTTP::Middleware
+	def initialize(delegate, api_key: nil)
+		super(delegate)
+		@api_key = api_key
+	end
+	
+	def call(request)
+		auth_header = request.headers['authorization']
+		
+		unless auth_header == "Bearer #{@api_key}"
+			return Protocol::HTTP::Response[401, {}, ["Unauthorized"]]
+		end
+		
+		super
+	end
+end
+
+# Usage:
+app = Protocol::HTTP::Middleware.build do
+	use AuthenticationMiddleware, api_key: "secret123"
+	run MyApplication
+end
+```
+
+### Content Type Middleware
+
+``` ruby
+class ContentTypeMiddleware < Protocol::HTTP::Middleware
+	def call(request)
+		response = super
+		
+		# Add content-type header if not present
+		unless response.headers.include?('content-type')
+			response.headers['content-type'] = 'text/plain'
+		end
+		
+		response
+	end
+end
+```
+
+## Testing Middleware
+
+``` ruby
+describe MyMiddleware do
+	let(:app) {MyMiddleware.new(Protocol::HTTP::Middleware::Okay)}
+	
+	it "processes requests correctly" do
+		request = Protocol::HTTP::Request["GET", "/test"]
+		response = app.call(request)
+		
+		expect(response.status).to be == 200
+	end
+	
+	it "closes properly" do
+		expect { app.close }.not.to raise_exception
+	end
+end
+```
diff -pruN 0.23.12-1/guides/streaming/readme.md 0.55.0-1/guides/streaming/readme.md
--- 0.23.12-1/guides/streaming/readme.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/guides/streaming/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,132 @@
+# Streaming
+
+This guide gives an overview of how to implement streaming requests and responses.
+
+## Independent Uni-directional Streaming
+
+The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface.
+
+```ruby
+#!/usr/bin/env ruby
+
+require 'async'
+require 'async/http/client'
+require 'async/http/server'
+require 'async/http/endpoint'
+
+require 'protocol/http/body/stream'
+require 'protocol/http/body/writable'
+
+endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				stream.write(chunk)
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		stream.write("Hello, ")
+		stream.write("World!")
+		stream.close_write
+		
+		while chunk = stream.readpartial(1024)
+			puts chunk
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
+```
+
+This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction.
+
+## Bi-directional Streaming
+
+While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible.
+
+```ruby
+#!/usr/bin/env ruby
+
+require 'async'
+require 'async/http/client'
+require 'async/http/server'
+require 'async/http/endpoint'
+
+require 'protocol/http/body/stream'
+require 'protocol/http/body/writable'
+
+endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
+
+Async do
+	server = Async::HTTP::Server.for(endpoint) do |request|
+		streamable = Protocol::HTTP::Body::Streamable.
+		output = Protocol::HTTP::Body::Writable.new
+		stream = Protocol::HTTP::Body::Stream.new(request.body, output)
+		
+		Async do
+			# Simple echo server:
+			while chunk = stream.readpartial(1024)
+				stream.write(chunk)
+			end
+		rescue EOFError
+			# Ignore EOF errors.
+		ensure
+			stream.close
+		end
+		
+		Protocol::HTTP::Response[200, {}, output]
+	end
+	
+	server_task = Async{server.run}
+	
+	client = Async::HTTP::Client.new(endpoint)
+	
+	input = Protocol::HTTP::Body::Writable.new
+	response = client.get("/", body: input)
+	
+	begin
+		stream = Protocol::HTTP::Body::Stream.new(response.body, input)
+		
+		stream.write("Hello, ")
+		stream.write("World!")
+		stream.close_write
+		
+		while chunk = stream.readpartial(1024)
+			puts chunk
+		end
+	rescue EOFError
+		# Ignore EOF errors.
+	ensure
+		stream.close
+	end
+ensure
+	server_task.stop
+end
+```
diff -pruN 0.23.12-1/lib/protocol/http/accept_encoding.rb 0.55.0-1/lib/protocol/http/accept_encoding.rb
--- 0.23.12-1/lib/protocol/http/accept_encoding.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/accept_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,63 +1,77 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'middleware'
+require_relative "middleware"
 
-require_relative 'body/buffered'
-require_relative 'body/inflate'
+require_relative "body/buffered"
+require_relative "body/inflate"
 
 module Protocol
 	module HTTP
-		# Set a valid accept-encoding header and decode the response.
+		# A middleware that sets the accept-encoding header and decodes the response according to the content-encoding header.
 		class AcceptEncoding < Middleware
-			ACCEPT_ENCODING = 'accept-encoding'.freeze
-			CONTENT_ENCODING = 'content-encoding'.freeze
+			# The header used to request encodings.
+			ACCEPT_ENCODING = "accept-encoding".freeze
 			
+			# The header used to specify encodings.
+			CONTENT_ENCODING = "content-encoding".freeze
+			
+			# The default wrappers to use for decoding content.
 			DEFAULT_WRAPPERS = {
-				'gzip' => Body::Inflate.method(:for),
-				'identity' => ->(body){body},
+				"gzip" => Body::Inflate.method(:for),
+				"identity" => ->(body) {body}, # Identity means no encoding
+				
+				# There is no point including this:
+				# 'identity' => ->(body){body},
 			}
 			
-			def initialize(app, wrappers = DEFAULT_WRAPPERS)
-				super(app)
+			# Initialize the middleware with the given delegate and wrappers.
+			#
+			# @parameter delegate [Protocol::HTTP::Middleware] The delegate middleware.
+			# @parameter wrappers [Hash] A hash of encoding names to wrapper functions.
+			def initialize(delegate, wrappers = DEFAULT_WRAPPERS)
+				super(delegate)
 				
-				@accept_encoding = wrappers.keys.join(', ')
+				@accept_encoding = wrappers.keys.join(", ")
 				@wrappers = wrappers
 			end
 			
+			# Set the accept-encoding header and decode the response body.
+			#
+			# @parameter request [Protocol::HTTP::Request] The request to modify.
+			# @returns [Protocol::HTTP::Response] The response.
 			def call(request)
 				request.headers[ACCEPT_ENCODING] = @accept_encoding
 				
 				response = super
 				
-				if body = response.body and !body.empty? and content_encoding = response.headers.delete(CONTENT_ENCODING)
-					# We want to unwrap all encodings
-					content_encoding.reverse_each do |name|
-						if wrapper = @wrappers[name]
-							body = wrapper.call(body)
+				if body = response.body and !body.empty?
+					if content_encoding = response.headers[CONTENT_ENCODING]
+						# Process encodings in reverse order and remove them when they are decoded:
+						while name = content_encoding.last
+							# Look up wrapper with case-insensitive matching:
+							wrapper = @wrappers[name.downcase]
+							
+							if wrapper
+								body = wrapper.call(body)
+								# Remove the encoding we just processed:
+								content_encoding.pop
+							else
+								# Unknown encoding - stop processing here:
+								break
+							end
+						end
+						
+						# Update the response body:
+						response.body = body
+						
+						# Remove the content-encoding header if we decoded all encodings:
+						if content_encoding.empty?
+							response.headers.delete(CONTENT_ENCODING)
 						end
 					end
-					
-					response.body = body
 				end
 				
 				return response
diff -pruN 0.23.12-1/lib/protocol/http/body/buffered.rb 0.55.0-1/lib/protocol/http/body/buffered.rb
--- 0.23.12-1/lib/protocol/http/body/buffered.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/buffered.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,47 +1,40 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2020, by Bryan Powell.
+# Copyright, 2025, by William T. Nelson.
 
-require_relative 'readable'
+require_relative "readable"
 
 module Protocol
 	module HTTP
 		module Body
-			# A body which buffers all it's contents.
+			# A body which buffers all its contents.
 			class Buffered < Readable
-				# Wraps an array into a buffered body.
-				# @return [Readable, nil] the wrapped body or nil if nil was given.
-				def self.wrap(body)
-					if body.is_a?(Readable)
-						return body
-					elsif body.is_a?(Array)
-						return self.new(body)
-					elsif body.is_a?(String)
-						return self.new([body])
-					elsif body
-						return self.for(body)
+				# Tries to wrap an object in a {Buffered} instance.
+				#
+				# For compatibility, also accepts anything that behaves like an `Array(String)`.
+				#
+				# @parameter body [String | Array(String) | Readable | nil] the body to wrap.
+				# @returns [Readable | nil] the wrapped body or nil if nil was given.
+				def self.wrap(object)
+					if object.is_a?(Readable)
+						return object
+					elsif object.is_a?(Array)
+						return self.new(object)
+					elsif object.is_a?(String)
+						return self.new([object])
+					elsif object
+						return self.read(object)
 					end
 				end
 				
-				def self.for(body)
+				# Read the entire body into a buffered representation.
+				#
+				# @parameter body [Readable] the body to read.
+				# @returns [Buffered] the buffered body.
+				def self.read(body)
 					chunks = []
 					
 					body.each do |chunk|
@@ -51,6 +44,10 @@ module Protocol
 					self.new(chunks)
 				end
 				
+				# Initialize the buffered body with some chunks.
+				#
+				# @parameter chunks [Array(String)] the chunks to buffer.
+				# @parameter length [Integer] the length of the body, if known.
 				def initialize(chunks = [], length = nil)
 					@chunks = chunks
 					@length = length
@@ -58,26 +55,61 @@ module Protocol
 					@index = 0
 				end
 				
+				# @attribute [Array(String)] chunks the buffered chunks.
 				attr :chunks
 				
+				# A rewindable body wraps some other body. Convert it to a buffered body. The buffered body will share the same chunks as the rewindable body.
+				#
+				# @returns [Buffered] the buffered body.
+				def buffered
+					self.class.new(@chunks)
+				end
+				
+				# Finish the body, this is a no-op.
+				#
+				# @returns [Buffered] self.
 				def finish
 					self
 				end
 				
+				# Ensure that future reads return `nil`, but allow for rewinding.
+				#
+				# @parameter error [Exception | Nil] the error that caused the body to be closed, if any.
+				def close(error = nil)
+					@index = @chunks.length
+					
+					return nil
+				end
+				
+				# Clear the buffered chunks.
+				def clear
+					@chunks = []
+					@length = 0
+					@index = 0
+				end
+				
+				# The length of the body. Will compute and cache the length of the body, if it was not provided.
 				def length
 					@length ||= @chunks.inject(0) {|sum, chunk| sum + chunk.bytesize}
 				end
 				
+				# @returns [Boolean] if the body is empty.
 				def empty?
 					@index >= @chunks.length
 				end
 				
-				# A buffered response is always ready.
+				# Whether the body is ready to be read.
+				# @returns [Boolean] a buffered response is always ready.
 				def ready?
 					true
 				end
 				
+				# Read the next chunk from the buffered body.
+				#
+				# @returns [String | Nil] the next chunk or nil if there are no more chunks.
 				def read
+					return nil unless @chunks
+					
 					if chunk = @chunks[@index]
 						@index += 1
 						
@@ -85,16 +117,47 @@ module Protocol
 					end
 				end
 				
+				# Discard the body. Invokes {#close}.
+				def discard
+					# It's safe to call close here because there is no underlying stream to close:
+					self.close
+				end
+				
+				# Write a chunk to the buffered body.
 				def write(chunk)
 					@chunks << chunk
 				end
 				
+				# Close the body for writing. This is a no-op.
+				def close_write(error)
+					# Nothing to do.
+				end
+				
+				# Whether the body can be rewound.
+				#
+				# @returns [Boolean] if the body has chunks.
+				def rewindable?
+					@chunks != nil
+				end
+				
+				# Rewind the body to the beginning, causing a subsequent read to return the first chunk.
 				def rewind
+					return false unless @chunks
+					
 					@index = 0
+					
+					return true
 				end
 				
+				# Inspect the buffered body.
+				#
+				# @returns [String] a string representation of the buffered body.
 				def inspect
-					"\#<#{self.class} #{@chunks.size} chunks, #{self.length} bytes>"
+					if @chunks and @chunks.size > 0
+						"#<#{self.class} #{@index}/#{@chunks.size} chunks, #{self.length} bytes>"
+					else
+						"#<#{self.class} empty>"
+					end
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/body/completable.rb 0.55.0-1/lib/protocol/http/body/completable.rb
--- 0.23.12-1/lib/protocol/http/body/completable.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/completable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,32 +1,19 @@
 # frozen_string_literal: true
 
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'wrapper'
+require_relative "wrapper"
 
 module Protocol
 	module HTTP
 		module Body
 			# Invokes a callback once the body has completed, either successfully or due to an error.
 			class Completable < Wrapper
+				# Wrap a message body with a callback. If the body is empty, the callback is invoked immediately.
+				#
+				# @parameter message [Request | Response] the message body.
+				# @parameter block [Proc] the callback to invoke when the body is closed.
 				def self.wrap(message, &block)
 					if body = message&.body and !body.empty?
 						message.body = self.new(message.body, block)
@@ -35,32 +22,53 @@ module Protocol
 					end
 				end
 				
+				# Initialize the completable body with a callback.
+				#
+				# @parameter body [Readable] the body to wrap.
+				# @parameter callback [Proc] the callback to invoke when the body is closed.
 				def initialize(body, callback)
 					super(body)
 					
 					@callback = callback
 				end
 				
-				def finish
-					if @body
-						result = super
-						
-						@callback.call
-						
-						@body = nil
-						
-						return result
-					end
+				# @returns [Boolean] completable bodies are not rewindable.
+				def rewindable?
+					false
+				end
+				
+				# Rewind the body, is not supported.
+				def rewind
+					false
 				end
 				
+				# Close the body and invoke the callback. If an error is given, it is passed to the callback.
+				#
+				# The calback is only invoked once, and before `super` is invoked.
 				def close(error = nil)
-					if @body
-						super
-						
+					if @callback
 						@callback.call(error)
-						
-						@body = nil
+						@callback = nil
 					end
+					
+					super
+				end
+				
+				# Convert the body to a hash suitable for serialization.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					super.merge(
+						callback: @callback&.to_s
+					)
+				end
+				
+				# Inspect the completable body.
+				#
+				# @returns [String] a string representation of the completable body.
+				def inspect
+					callback_status = @callback ? "callback pending" : "callback completed"
+					return "#{super} | #<#{self.class} #{callback_status}>"
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/body/deflate.rb 0.55.0-1/lib/protocol/http/body/deflate.rb
--- 0.23.12-1/lib/protocol/http/body/deflate.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/deflate.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,53 +1,36 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'wrapper'
+require_relative "wrapper"
 
-require 'zlib'
+require "zlib"
 
 module Protocol
 	module HTTP
 		module Body
+			# A body which compresses or decompresses the contents using the DEFLATE or GZIP algorithm.
 			class ZStream < Wrapper
+				# The default compression level.
 				DEFAULT_LEVEL = 7
 				
+				# The DEFLATE window size.
 				DEFLATE = -Zlib::MAX_WBITS
+				
+				# The GZIP window size.
 				GZIP =  Zlib::MAX_WBITS | 16
 				
+				# The supported encodings.
 				ENCODINGS = {
-					'deflate' => DEFLATE,
-					'gzip' => GZIP,
+					"deflate" => DEFLATE,
+					"gzip" => GZIP,
 				}
 				
-				def self.encoding_name(window_size)
-					if window_size <= -8
-						return 'deflate'
-					elsif window_size >= 16
-						return 'gzip'
-					else
-						return 'compress'
-					end
-				end
-				
+				# Initialize the body with the given stream.
+				#
+				# @parameter body [Readable] the body to wrap.
+				# @parameter stream [Zlib::Deflate | Zlib::Inflate] the stream to use for compression or decompression.
 				def initialize(body, stream)
 					super(body)
 					
@@ -57,20 +40,33 @@ module Protocol
 					@output_length = 0
 				end
 				
+				# Close the stream.
+				#
+				# @parameter error [Exception | Nil] the error that caused the stream to be closed.
 				def close(error = nil)
-					@stream.close unless @stream.closed?
+					if stream = @stream
+						@stream = nil
+						stream.close unless stream.closed?
+					end
 					
 					super
 				end
 				
+				# The length of the output, if known. Generally, this is not known due to the nature of compression.
 				def length
 					# We don't know the length of the output until after it's been compressed.
 					nil
 				end
 				
+				# @attribute [Integer] input_length the total number of bytes read from the input.
 				attr :input_length
+				
+				# @attribute [Integer] output_length the total number of bytes written to the output.
 				attr :output_length
 				
+				# The compression ratio, according to the input and output lengths.
+				#
+				# @returns [Float] the compression ratio, e.g. 0.5 for 50% compression.
 				def ratio
 					if @input_length != 0
 						@output_length.to_f / @input_length.to_f
@@ -79,22 +75,40 @@ module Protocol
 					end
 				end
 				
+				# Convert the body to a hash suitable for serialization.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					super.merge(
+						input_length: @input_length,
+						output_length: @output_length,
+						compression_ratio: (ratio * 100).round(2)
+					)
+				end
+				
+				# Inspect the body, including the compression ratio.
+				#
+				# @returns [String] a string representation of the body.
 				def inspect
-					"#{super} | \#<#{self.class} #{(ratio*100).round(2)}%>"
+					"#{super} | #<#{self.class} #{(ratio*100).round(2)}%>"
 				end
 			end
 			
+			# A body which compresses the contents using the DEFLATE or GZIP algorithm.
 			class Deflate < ZStream
+				# Create a new body which compresses the given body using the GZIP algorithm by default.
+				#
+				# @parameter body [Readable] the body to wrap.
+				# @parameter window_size [Integer] the window size to use for compression.
+				# @parameter level [Integer] the compression level to use.
+				# @returns [Deflate] the wrapped body.
 				def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL)
 					self.new(body, Zlib::Deflate.new(level, window_size))
 				end
 				
-				def stream?
-					# We might want to revisit this design choice.
-					# We could wrap the streaming body in a Deflate stream, but that would require an extra stream wrapper which we don't have right now. See also `Digestable#stream?`.
-					false
-				end
-				
+				# Read a chunk from the underlying body and compress it. If the body is finished, the stream is flushed and finished, and the remaining data is returned.
+				#
+				# @returns [String | Nil] the compressed chunk or `nil` if the stream is closed.
 				def read
 					return if @stream.finished?
 					
@@ -118,4 +132,4 @@ module Protocol
 			end
 		end
 	end
-end
\ No newline at end of file
+end
diff -pruN 0.23.12-1/lib/protocol/http/body/digestable.rb 0.55.0-1/lib/protocol/http/body/digestable.rb
--- 0.23.12-1/lib/protocol/http/body/digestable.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/digestable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,40 +1,33 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
 
-require_relative 'wrapper'
+require_relative "wrapper"
 
-require 'digest/sha2'
+require "digest/sha2"
 
 module Protocol
 	module HTTP
 		module Body
 			# Invokes a callback once the body has finished reading.
 			class Digestable < Wrapper
+				# Wrap a message body with a callback. If the body is empty, the callback is not invoked, as there is no data to digest.
+				#
+				# @parameter message [Request | Response] the message body.
+				# @parameter digest [Digest] the digest to use.
+				# @parameter block [Proc] the callback to invoke when the body is closed.
 				def self.wrap(message, digest = Digest::SHA256.new, &block)
 					if body = message&.body and !body.empty?
 						message.body = self.new(message.body, digest, block)
 					end
 				end
 				
+				# Initialize the digestable body with a callback.
+				#
+				# @parameter body [Readable] the body to wrap.
+				# @parameter digest [Digest] the digest to use.
+				# @parameter callback [Block] The callback is invoked when the digest is complete.
 				def initialize(body, digest = Digest::SHA256.new, callback = nil)
 					super(body)
 					
@@ -42,18 +35,24 @@ module Protocol
 					@callback = callback
 				end
 				
-				def digest
-					@digest
-				end
-				
-				def etag
-					@digest.hexdigest.dump
-				end
+				# @attribute [Digest] digest the digest object.
+				attr :digest
 				
-				def stream?
-					false
+				# Generate an appropriate ETag for the digest, assuming it is complete. If you call this method before the body is fully read, the ETag will be incorrect.
+				#
+				# @parameter weak [Boolean] If true, the ETag is marked as weak.
+				# @returns [String] the ETag.
+				def etag(weak: false)
+					if weak
+						"W/\"#{digest.hexdigest}\""
+					else
+						"\"#{digest.hexdigest}\""
+					end
 				end
 				
+				# Read the body and update the digest. When the body is fully read, the callback is invoked with `self` as the argument.
+				#
+				# @returns [String | Nil] the next chunk of data, or nil if the body is fully read.
 				def read
 					if chunk = super
 						@digest.update(chunk)
@@ -65,6 +64,16 @@ module Protocol
 						return nil
 					end
 				end
+				
+				# Convert the body to a hash suitable for serialization.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					super.merge(
+						digest_class: @digest.class.name,
+						callback: @callback&.to_s
+					)
+				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/body/file.rb 0.55.0-1/lib/protocol/http/body/file.rb
--- 0.23.12-1/lib/protocol/http/body/file.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/file.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,41 +1,37 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'readable'
-require 'async/io/stream'
+require_relative "readable"
 
 module Protocol
 	module HTTP
 		module Body
+			# A body which reads from a file.
 			class File < Readable
-				BLOCK_SIZE = Async::IO::BLOCK_SIZE
+				# The default block size.
+				BLOCK_SIZE = 64*1024
+				
+				# The default mode for opening files.
 				MODE = ::File::RDONLY | ::File::BINARY
 				
+				# Open a file at the given path.
+				#
+				# @parameter path [String] the path to the file.
 				def self.open(path, *arguments, **options)
 					self.new(::File.open(path, MODE), *arguments, **options)
 				end
 				
+				# Initialize the file body with the given file.
+				#
+				# @parameter file [::File] the file to read from.
+				# @parameter range [Range] the range of bytes to read from the file.
+				# @parameter size [Integer] the size of the file, if known.
+				# @parameter block_size [Integer] the block size to use when reading from the file.
 				def initialize(file, range = nil, size: file.size, block_size: BLOCK_SIZE)
 					@file = file
+					@range = range
 					
 					@block_size = block_size
 					
@@ -44,11 +40,15 @@ module Protocol
 						@offset = range.min
 						@length = @remaining = range.size
 					else
+						@file.seek(0)
 						@offset = 0
 						@length = @remaining = size
 					end
 				end
 				
+				# Close the file.
+				#
+				# @parameter error [Exception | Nil] the error that caused the file to be closed.
 				def close(error = nil)
 					@file.close
 					@remaining = 0
@@ -56,27 +56,46 @@ module Protocol
 					super
 				end
 				
+				# @attribute [::File] file the file to read from.
 				attr :file
 				
+				# @attribute [Integer] the offset to read from.
 				attr :offset
+				
+				# @attribute [Integer] the number of bytes to read.
 				attr :length
 				
+				# @returns [Boolean] whether more data should be read.
 				def empty?
 					@remaining == 0
 				end
 				
+				# @returns [Boolean] whether the body is ready to be read, always true for files.
 				def ready?
 					true
 				end
 				
+				# Returns a copy of the body, by duplicating the file descriptor, including the same range if specified.
+				#
+				# @returns [File] the duplicated body.
+				def buffered
+					self.class.new(@file.dup, @range, block_size: @block_size)
+				end
+				
+				# Rewind the file to the beginning of the range.
 				def rewind
 					@file.seek(@offset)
+					@remaining = @length
 				end
 				
-				def stream?
-					false
+				# @returns [Boolean] whether the body is rewindable, generally always true for seekable files.
+				def rewindable?
+					true
 				end
 				
+				# Read the next chunk of data from the file.
+				#
+				# @returns [String | Nil] the next chunk of data, or nil if the file is fully read.
 				def read
 					if @remaining > 0
 						amount = [@remaining, @block_size].min
@@ -89,6 +108,19 @@ module Protocol
 					end
 				end
 				
+				# def stream?
+				# 	true
+				# end
+				
+				# def call(stream)
+				# 	IO.copy_stream(@file, stream, @remaining)
+				# ensure
+				# 	stream.close
+				# end
+				
+				# Read all the remaining data from the file and return it as a single string.
+				#
+				# @returns [String] the remaining data.
 				def join
 					return "" if @remaining == 0
 					
@@ -99,8 +131,15 @@ module Protocol
 					return buffer
 				end
 				
+				# Inspect the file body.
+				#
+				# @returns [String] a string representation of the file body.
 				def inspect
-					"\#<#{self.class} file=#{@file.inspect} offset=#{@offset} remaining=#{@remaining}>"
+					if @offset > 0
+						"#<#{self.class} #{@file.inspect} +#{@offset}, #{@remaining} bytes remaining>"
+					else
+						"#<#{self.class} #{@file.inspect}, #{@remaining} bytes remaining>"
+					end
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/body/head.rb 0.55.0-1/lib/protocol/http/body/head.rb
--- 0.23.12-1/lib/protocol/http/body/head.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/head.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,55 +1,66 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+# Copyright, 2025, by William T. Nelson.
 
-require_relative 'readable'
+require_relative "readable"
 
 module Protocol
 	module HTTP
 		module Body
+			# Represents a body suitable for HEAD requests, in other words, a body that is empty and has a known length.
 			class Head < Readable
-				def self.for(body)
-					head = self.new(body.length)
+				# Create a head body for the given body, capturing its length and then closing it.
+				#
+				# If a body is provided, the length is determined from the body, and the body is closed.
+				# If no body is provided, and the content length is provided, a head body is created with that length.
+				# This is useful for creating a head body when you only know the content length but not the actual body, which may happen in adapters for HTTP applications where the application may not provide a body for HEAD requests, but the content length is known.
+				#
+				# @parameter body [Readable | Nil] the body to create a head for.
+				# @parameter length [Integer | Nil] the content length of the body, if known.
+				# @returns [Head | Nil] the head body, or nil if the body is nil.
+				def self.for(body, length = nil)
+					if body
+						head = self.new(body.length)
+						body.close
+						return head
+					elsif length
+						return self.new(length)
+					end
 					
-					body.close
-					
-					return head
+					return nil
 				end
 				
+				# Initialize the head body with the given length.
+				#
+				# @parameter length [Integer] the length of the body.
 				def initialize(length)
 					@length = length
 				end
 				
+				# @returns [Boolean] the body is empty.
 				def empty?
 					true
 				end
 				
+				# @returns [Boolean] the body is ready.
 				def ready?
 					true
 				end
 				
+				# @returns [Integer] the length of the body, if known.
 				def length
 					@length
 				end
+				
+				# Inspect the head body.
+				#
+				# @returns [String] a string representation of the head body.
+				def inspect
+					"#<#{self.class} #{@length} bytes (empty)>"
+				end
 			end
 		end
 	end
-end
\ No newline at end of file
+end
diff -pruN 0.23.12-1/lib/protocol/http/body/inflate.rb 0.55.0-1/lib/protocol/http/body/inflate.rb
--- 0.23.12-1/lib/protocol/http/body/inflate.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/inflate.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,65 +1,65 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require 'zlib'
+require "zlib"
 
-require_relative 'deflate'
+require_relative "deflate"
 
 module Protocol
 	module HTTP
 		module Body
+			# A body which decompresses the contents using the DEFLATE or GZIP algorithm.
 			class Inflate < ZStream
-				def self.for(body, encoding = GZIP)
-					self.new(body, Zlib::Inflate.new(encoding))
-				end
-				
-				def stream?
-					false
+				# Create a new body which decompresses the given body using the GZIP algorithm by default.
+				#
+				# @parameter body [Readable] the body to wrap.
+				# @parameter window_size [Integer] the window size to use for decompression.
+				def self.for(body, window_size = GZIP)
+					self.new(body, Zlib::Inflate.new(window_size))
 				end
 				
+				# Read from the underlying stream and inflate it.
+				#
+				# @returns [String | Nil] the inflated data, or nil if the stream is finished.
 				def read
-					return if @stream.finished?
-					
-					# The stream might have been closed while waiting for the chunk to come in.
-					if chunk = super
-						@input_length += chunk.bytesize
+					if stream = @stream
+						# Read from the underlying stream and inflate it:
+						while chunk = super
+							@input_length += chunk.bytesize
+							
+							# It's possible this triggers the stream to finish.
+							chunk = stream.inflate(chunk)
+							
+							break unless chunk&.empty?
+						end
 						
-						# It's possible this triggers the stream to finish.
-						chunk = @stream.inflate(chunk)
+						if chunk
+							@output_length += chunk.bytesize
+						elsif !stream.closed?
+							chunk = stream.finish
+							@output_length += chunk.bytesize
+						end
 						
-						@output_length += chunk.bytesize
-					elsif !@stream.closed?
-						chunk = @stream.finish
+						# If the stream is finished, we need to close it and potentially return nil:
+						if stream.finished?
+							@stream = nil
+							stream.close
+							
+							while super
+								# There is data left in the stream, so we need to keep reading until it's all consumed.
+							end
+							
+							if chunk.empty?
+								return nil
+							end
+						end
 						
-						@output_length += chunk.bytesize
-					end
-					
-					if chunk.empty? and @stream.finished?
-						return nil
+						return chunk
 					end
-					
-					return chunk
 				end
 			end
 		end
 	end
-end
\ No newline at end of file
+end
diff -pruN 0.23.12-1/lib/protocol/http/body/readable.rb 0.55.0-1/lib/protocol/http/body/readable.rb
--- 0.23.12-1/lib/protocol/http/body/readable.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/readable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,108 +1,190 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'async/io/buffer'
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
+# Copyright, 2023, by Bruno Sutic.
 
 module Protocol
 	module HTTP
 		module Body
-			# A generic base class for wrapping body instances. Typically you'd override `#read`.
-			# The implementation assumes a sequential unbuffered stream of data.
-			# 	def each -> yield(String | nil)
-			# 	def read -> String | nil
-			# 	def join -> String
-			
-			# 	def finish -> buffer the stream and close it.
-			# 	def close(error = nil) -> close the stream immediately.
-			# end
+			# Represents a readable input streams.
+			#
+			# There are two major modes of operation:
+			#
+			# 1. Reading chunks using {read} (or {each}/{join}), until the body is empty, or
+			# 2. Streaming chunks using {call}, which writes chunks to a provided output stream.
+			#
+			# In both cases, reading can fail, for example if the body represents a streaming upload, and the connection is lost. In this case, {read} will raise some kind of error, or the stream will be closed with an error.
+			#
+			# At any point, you can use {close} to close the stream and release any resources, or {discard} to read all remaining data without processing it which may allow the underlying connection to be reused (but can be slower).
 			class Readable
-				# The consumer can call stop to signal that the stream output has terminated.
+				# Close the stream immediately. After invoking this method, the stream should be considered closed, and all internal resources should be released.
+				#
+				# If an error occured while handling the output, it can be passed as an argument. This may be propagated to the client, for example the client may be informed that the stream was not fully read correctly.
+				#
+				# Invoking {read} after {close} will return `nil`.
+				#
+				# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
 				def close(error = nil)
 				end
 				
-				# Will read return any data?
+				# Optimistically determine whether read (may) return any data.
+				#
+				# - If this returns true, then calling read will definitely return nil.
+				# - If this returns false, then calling read may return nil.
+				#
+				# @return [Boolean] Whether the stream is empty.
 				def empty?
 					false
 				end
 				
-				# Whether calling read will block.
-				# We prefer pessimistic implementation, and thus default to `false`.
-				# @return [Boolean]
+				# Whether calling read will return a chunk of data without blocking. We prefer pessimistic implementation, and thus default to `false`.
+				#
+				# @return [Boolean] Whether the stream is ready (read will not block).
 				def ready?
 					false
 				end
 				
+				# Whether the stream can be rewound using {rewind}.
+				#
+				# @return [Boolean] Whether the stream is rewindable.
+				def rewindable?
+					false
+				end
+				
+				# Rewind the stream to the beginning.
+				#
+				# @returns [Boolean] Whether the stream was successfully rewound.
+				def rewind
+					false
+				end
+				
+				# Return a buffered representation of this body.
+				#
+				# This method must return a buffered body if `#rewindable?`.
+				#
+				# @returns [Buffered | Nil] The buffered body.
+				def buffered
+					nil
+				end
+				
+				# The total length of the body, if known.
+				#
+				# @returns [Integer | Nil] The total length of the body, or `nil` if the length is unknown.
 				def length
 					nil
 				end
 				
 				# Read the next available chunk.
+				#
+				# @returns [String | Nil] The chunk of data, or `nil` if the stream has finished.
+				# @raises [StandardError] If an error occurs while reading.
 				def read
 					nil
 				end
 				
-				# Should the internal mechanism prefer to use {call}?
-				# @returns [Boolean]
+				# Enumerate all chunks until finished, then invoke {close}.
+				#
+				# Closes the stream when finished or if an error occurs.
+				#
+				# @yields {|chunk| ...} The block to call with each chunk of data.
+				# 	@parameter chunk [String | Nil] The chunk of data, or `nil` if the stream has finished.
+				def each
+					return to_enum unless block_given?
+					
+					begin
+						while chunk = self.read
+							yield chunk
+						end
+					rescue => error
+						raise
+					ensure
+						self.close(error)
+					end
+				end
+				
+				# Read all remaining chunks into a single binary string using `#each`.
+				#
+				# @returns [String | Nil] The binary string containing all chunks of data, or `nil` if the stream has finished (or did not contain any data).
+				def join
+					buffer = String.new.force_encoding(Encoding::BINARY)
+					
+					self.each do |chunk|
+						buffer << chunk
+					end
+					
+					if buffer.empty?
+						return nil
+					else
+						return buffer
+					end
+				end
+				
+				# Whether to prefer streaming the body using {call} rather than reading it using {read} or {each}.
+				#
+				# @returns [Boolean] Whether the body should be streamed.
 				def stream?
 					false
 				end
 				
+				# Invoke the body with the given stream.
+				#
+				# The default implementation simply writes each chunk to the stream. If the body is not ready, it will be flushed after each chunk. Closes the stream when finished or if an error occurs.
+				#
 				# Write the body to the given stream.
+				#
+				# @parameter stream [IO | Object] An `IO`-like object that responds to `#read`, `#write` and `#flush`.
+				# @returns [Boolean] Whether the ownership of the stream was transferred.
 				def call(stream)
-					while chunk = self.read
+					self.each do |chunk|
 						stream.write(chunk)
+						
+						# Flush the stream unless we are immediately expecting more data:
+						unless self.ready?
+							stream.flush
+						end
 					end
 				ensure
+					# TODO Should this invoke close_write(error) instead?
 					stream.close
 				end
 				
 				# Read all remaining chunks into a buffered body and close the underlying input.
+				#
+				# @returns [Buffered] The buffered body.
 				def finish
 					# Internally, this invokes `self.each` which then invokes `self.close`.
-					Buffered.for(self)
+					Buffered.read(self)
 				end
 				
-				# Enumerate all chunks until finished, then invoke `#close`.
-				def each
+				# Discard the body as efficiently as possible.
+				#
+				# The default implementation simply reads all chunks until the body is empty.
+				#
+				# Useful for discarding the body when it is not needed, but preserving the underlying connection.
+				def discard
 					while chunk = self.read
-						yield chunk
 					end
-				ensure
-					self.close($!)
 				end
 				
-				# Read all remaining chunks into a single binary string using `#each`.
-				def join
-					buffer = String.new.force_encoding(Encoding::BINARY)
-					
-					self.each do |chunk|
-						buffer << chunk
-					end
-					
-					if buffer.empty?
-						return nil
-					else
-						return buffer
-					end
+				# Convert the body to a hash suitable for serialization. This won't include the contents of the body, but will include metadata such as the length, streamability, and readiness, etc.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					{
+						class: self.class.name,
+						length: self.length,
+						stream: self.stream?,
+						ready: self.ready?,
+						empty: self.empty?
+					}
+				end
+				
+				# Convert the body to JSON.
+				#
+				# @returns [String] The body as JSON.
+				def to_json(...)
+					as_json.to_json(...)
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/body/reader.rb 0.55.0-1/lib/protocol/http/body/reader.rb
--- 0.23.12-1/lib/protocol/http/body/reader.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/reader.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,32 +1,19 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
+# Copyright, 2022, by Dan Olson.
 
 module Protocol
 	module HTTP
 		module Body
 			# General operations for interacting with a request or response body.
+			#
+			# This module is included in both {Request} and {Response}.
 			module Reader
 				# Read chunks from the body.
-				# @yield [String] read chunks from the body.
+				#
+				# @yields {|chunk| ...} chunks from the body.
 				def each(&block)
 					if @body
 						@body.each(&block)
@@ -35,7 +22,8 @@ module Protocol
 				end
 				
 				# Reads the entire request/response body.
-				# @return [String] the entire body as a string.
+				#
+				# @returns [String] the entire body as a string.
 				def read
 					if @body
 						buffer = @body.join
@@ -46,7 +34,8 @@ module Protocol
 				end
 				
 				# Gracefully finish reading the body. This will buffer the remainder of the body.
-				# @return [Buffered] buffers the entire body.
+				#
+				# @returns [Buffered] buffers the entire body.
 				def finish
 					if @body
 						body = @body.finish
@@ -56,10 +45,36 @@ module Protocol
 					end
 				end
 				
+				# Discard the body as efficiently as possible.
+				def discard
+					if body = @body
+						@body = nil
+						body.discard
+					end
+					
+					return nil
+				end
+				
+				# Buffer the entire request/response body.
+				#
+				# @returns [Reader] itself.
+				def buffered!
+					if @body
+						@body = @body.finish
+					end
+					
+					# TODO Should this return @body instead? It seems more useful.
+					return self
+				end
+				
 				# Write the body of the response to the given file path.
-				def save(path, mode = ::File::WRONLY|::File::CREAT, *args)
+				#
+				# @parameter path [String] the path to write the body to.
+				# @parameter mode [Integer] the mode to open the file with.
+				# @parameter options [Hash] additional options to pass to `File.open`.
+				def save(path, mode = ::File::WRONLY|::File::CREAT|::File::TRUNC, **options)
 					if @body
-						::File.open(path, mode, *args) do |file|
+						::File.open(path, mode, **options) do |file|
 							self.each do |chunk|
 								file.write(chunk)
 							end
@@ -68,6 +83,8 @@ module Protocol
 				end
 				
 				# Close the connection as quickly as possible. Discards body. May close the underlying connection if necessary to terminate the stream.
+				#
+				# @parameter error [Exception | Nil] the error that caused the stream to be closed, if any.
 				def close(error = nil)
 					if @body
 						@body.close(error)
@@ -76,6 +93,8 @@ module Protocol
 				end
 				
 				# Whether there is a body?
+				#
+				# @returns [Boolean] whether there is a body.
 				def body?
 					@body and !@body.empty?
 				end
diff -pruN 0.23.12-1/lib/protocol/http/body/rewindable.rb 0.55.0-1/lib/protocol/http/body/rewindable.rb
--- 0.23.12-1/lib/protocol/http/body/rewindable.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/rewindable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,33 +1,35 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2025, by William T. Nelson.
 
-require_relative 'wrapper'
-require_relative 'buffered'
+require_relative "wrapper"
+require_relative "buffered"
 
 module Protocol
 	module HTTP
 		module Body
-			# A body which buffers all it's contents as it is `#read`.
+			# A body which buffers all its contents as it is read.
+			#
+			# As the body is buffered in memory, you may want to ensure your server has sufficient (virtual) memory available to buffer the entire body.
 			class Rewindable < Wrapper
+				# Wrap the given message body in a rewindable body, if it is not already rewindable.
+				#
+				# @parameter message [Request | Response] the message to wrap.
+				def self.wrap(message)
+					if body = message.body
+						if body.rewindable?
+							body
+						else
+							message.body = self.new(body)
+						end
+					end
+				end
+				
+				# Initialize the body with the given body.
+				#
+				# @parameter body [Readable] the body to wrap.
 				def initialize(body)
 					super(body)
 					
@@ -35,23 +37,26 @@ module Protocol
 					@index = 0
 				end
 				
+				# @returns [Boolean] Whether the body is empty.
 				def empty?
 					(@index >= @chunks.size) && super
 				end
 				
+				# @returns [Boolean] Whether the body is ready to be read.
 				def ready?
 					(@index < @chunks.size) || super
 				end
 				
-				# A rewindable body wraps some other body. Convert it to a buffered body 
+				# A rewindable body wraps some other body. Convert it to a buffered body. The buffered body will share the same chunks as the rewindable body.
+				#
+				# @returns [Buffered] the buffered body. 
 				def buffered
 					Buffered.new(@chunks)
 				end
 				
-				def stream?
-					false
-				end
-				
+				# Read the next available chunk. This may return a buffered chunk if the stream has been rewound, or a chunk from the underlying stream, if available.
+				#
+				# @returns [String | Nil] The chunk of data, or `nil` if the stream has finished.
 				def read
 					if @index < @chunks.size
 						chunk = @chunks[@index]
@@ -67,12 +72,31 @@ module Protocol
 					return chunk
 				end
 				
+				# Rewind the stream to the beginning.
 				def rewind
 					@index = 0
 				end
 				
+				# @returns [Boolean] Whether the stream is rewindable, which it is.
+				def rewindable?
+					true
+				end
+				
+				# Convert the body to a hash suitable for serialization.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					super.merge(
+						index: @index,
+						chunks: @chunks.size
+					)
+				end
+				
+				# Inspect the rewindable body.
+				#
+				# @returns [String] a string representation of the body.
 				def inspect
-					"\#<#{self.class} #{@index}/#{@chunks.size} chunks read>"
+					"#{super} | #<#{self.class} #{@index}/#{@chunks.size} chunks read>"
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/body/stream.rb 0.55.0-1/lib/protocol/http/body/stream.rb
--- 0.23.12-1/lib/protocol/http/body/stream.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/stream.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,33 +1,25 @@
 # frozen_string_literal: true
-#
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
 
-require_relative 'buffered'
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2023, by Genki Takiuchi.
+# Copyright, 2025, by William T. Nelson.
+
+require_relative "buffered"
 
 module Protocol
 	module HTTP
 		module Body
 			# The input stream is an IO-like object which contains the raw HTTP POST data. When applicable, its external encoding must be “ASCII-8BIT” and it must be opened in binary mode, for Ruby 1.9 compatibility. The input stream must respond to gets, each, read and rewind.
 			class Stream
-				def initialize(input, output = Buffered.new)
+				# The default line separator, used by {gets}.
+				NEWLINE = "\n"
+				
+				# Initialize the stream with the given input and output.
+				#
+				# @parameter input [Readable] The input stream.
+				# @parameter output [Writable] The output stream.
+				def initialize(input = nil, output = Buffered.new)
 					@input = input
 					@output = output
 					
@@ -35,26 +27,35 @@ module Protocol
 					
 					# Will hold remaining data in `#read`.
 					@buffer = nil
+					
 					@closed = false
+					@closed_read = false
 				end
 				
+				# @attribute [Readable] The input stream.
 				attr :input
+				
+				# @attribute [Writable] The output stream.
 				attr :output
 				
 				# This provides a read-only interface for data, which is surprisingly tricky to implement correctly.
 				module Reader
-					# rack.hijack_io must respond to:
-					# read, write, read_nonblock, write_nonblock, flush, close, close_read, close_write, closed?
-					
-					# read behaves like IO#read. Its signature is read([length, [buffer]]). If given, length must be a non-negative Integer (>= 0) or nil, and buffer must be a String and may not be nil. If length is given and not nil, then this method reads at most length bytes from the input stream. If length is not given or nil, then this method reads all data until EOF. When EOF is reached, this method returns nil if length is given and not nil, or “” if length is not given or is nil. If buffer is given, then the read data will be placed into buffer instead of a newly created String object.
-					# @param length [Integer] the amount of data to read
-					# @param buffer [String] the buffer which will receive the data
-					# @return a buffer containing the data
+					# Read data from the underlying stream.
+					#
+					# If given a non-negative length, it will read at most that many bytes from the stream. If the stream is at EOF, it will return nil.
+					#
+					# If the length is not given, it will read all data until EOF, or return an empty string if the stream is already at EOF.
+					#
+					# If buffer is given, then the read data will be placed into buffer instead of a newly created String object.
+					#
+					# @parameter length [Integer] the amount of data to read
+					# @parameter buffer [String] the buffer which will receive the data
+					# @returns [String] a buffer containing the data
 					def read(length = nil, buffer = nil)
-						return '' if length == 0
+						return "" if length == 0
+						
+						buffer ||= String.new.force_encoding(Encoding::BINARY)
 						
-						buffer ||= Async::IO::Buffer.new
-
 						# Take any previously buffered data and replace it into the given buffer.
 						if @buffer
 							buffer.replace(@buffer)
@@ -70,7 +71,7 @@ module Protocol
 							
 							# This ensures the subsequent `slice!` works correctly.
 							buffer.force_encoding(Encoding::BINARY)
-
+							
 							# This will be at least one copy:
 							@buffer = buffer.byteslice(length, buffer.bytesize)
 							
@@ -91,20 +92,39 @@ module Protocol
 						end
 					end
 					
-					# Read at most `length` bytes from the stream. Will avoid reading from the underlying stream if possible.
-					def read_partial(length = nil)
+					# Read some bytes from the stream.
+					#
+					# If the length is given, at most length bytes will be read. Otherwise, one chunk of data from the underlying stream will be read.
+					#
+					# Will avoid reading from the underlying stream if there is buffered data available.
+					#
+					# @parameter length [Integer] The maximum number of bytes to read.
+					def read_partial(length = nil, buffer = nil)
 						if @buffer
-							buffer = @buffer
+							if buffer
+								buffer.replace(@buffer)
+							else
+								buffer = @buffer
+							end
 							@buffer = nil
 						else
-							buffer = read_next
+							if chunk = read_next
+								if buffer
+									buffer.replace(chunk)
+								else
+									buffer = chunk
+								end
+							else
+								buffer&.clear
+								buffer = nil
+							end
 						end
 						
 						if buffer and length
 							if buffer.bytesize > length
 								# This ensures the subsequent `slice!` works correctly.
 								buffer.force_encoding(Encoding::BINARY)
-
+								
 								@buffer = buffer.byteslice(length, buffer.bytesize)
 								buffer.slice!(length, buffer.bytesize)
 							end
@@ -113,7 +133,35 @@ module Protocol
 						return buffer
 					end
 					
-					def read_nonblock(length, buffer = nil)
+					# Similar to {read_partial} but raises an `EOFError` if the stream is at EOF.
+					#
+					# @parameter length [Integer] The maximum number of bytes to read.
+					# @parameter buffer [String] The buffer to read into.
+					def readpartial(length, buffer = nil)
+						read_partial(length, buffer) or raise EOFError, "End of file reached!"
+					end
+					
+					# Iterate over each chunk of data from the input stream.
+					#
+					# @yields {|chunk| ...} Each chunk of data.
+					def each(&block)
+						return to_enum unless block_given?
+						
+						if @buffer
+							yield @buffer
+							@buffer = nil
+						end
+						
+						while chunk = read_next
+							yield chunk
+						end
+					end
+					
+					# Read data from the stream without blocking if possible.
+					#
+					# @parameter length [Integer] The maximum number of bytes to read.
+					# @parameter buffer [String | Nil] The buffer to read into.
+					def read_nonblock(length, buffer = nil, exception: nil)
 						@buffer ||= read_next
 						chunk = nil
 						
@@ -138,10 +186,115 @@ module Protocol
 						
 						return buffer
 					end
+					
+					# Read data from the stream until encountering pattern.
+					#
+					# @parameter pattern [String] The pattern to match.
+					# @parameter offset [Integer] The offset to start searching from.
+					# @parameter chomp [Boolean] Whether to remove the pattern from the returned data.
+					# @returns [String] The contents of the stream up until the pattern, which is consumed but not returned.
+					def read_until(pattern, offset = 0, chomp: false)
+						# We don't want to split on the pattern, so we subtract the size of the pattern.
+						split_offset = pattern.bytesize - 1
+						
+						@buffer ||= read_next
+						return nil if @buffer.nil?
+						
+						until index = @buffer.index(pattern, offset)
+							offset = @buffer.bytesize - split_offset
+							
+							offset = 0 if offset < 0
+							
+							if chunk = read_next
+								@buffer << chunk
+							else
+								return nil
+							end
+						end
+						
+						@buffer.freeze
+						matched = @buffer.byteslice(0, index+(chomp ? 0 : pattern.bytesize))
+						@buffer = @buffer.byteslice(index+pattern.bytesize, @buffer.bytesize)
+						
+						return matched
+					end
+					
+					# Read a single line from the stream.
+					#
+					# @parameter separator [String] The line separator, defaults to `\n`.
+					# @parameter limit [Integer] The maximum number of bytes to read.
+					# @parameter *options [Hash] Additional options, passed to {read_until}.
+					def gets(separator = NEWLINE, limit = nil, chomp: false)
+						# If the separator is an integer, it is actually the limit:
+						if separator.is_a?(Integer)
+							limit = separator
+							separator = NEWLINE
+						end
+						
+						# If no separator is given, this is the same as a read operation:
+						if separator.nil?
+							# I tried using `read(limit)` here but it will block until the limit is reached, which is not usually desirable behaviour.
+							return read_partial(limit)
+						end
+						
+						# We don't want to split on the separator, so we subtract the size of the separator:
+						split_offset = separator.bytesize - 1
+						
+						@buffer ||= read_next
+						return nil if @buffer.nil?
+						
+						offset = 0
+						until index = @buffer.index(separator, offset)
+							offset = @buffer.bytesize - split_offset
+							offset = 0 if offset < 0
+							
+							# If we have gone past the limit, we are done:
+							if limit and offset >= limit
+								@buffer.freeze
+								matched = @buffer.byteslice(0, limit)
+								@buffer = @buffer.byteslice(limit, @buffer.bytesize)
+								return matched
+							end
+							
+							# Read more data:
+							if chunk = read_next
+								@buffer << chunk
+							else
+								# No more data could be read, return the remaining data:
+								buffer = @buffer
+								@buffer = nil
+								
+								# Return nil for empty buffers, otherwise return the content:
+								if buffer && !buffer.empty?
+									return buffer
+								else
+									return nil
+								end
+							end
+						end
+						
+						# Freeze the buffer, as this enables us to use byteslice without generating a hidden copy:
+						@buffer.freeze
+						
+						if limit and index > limit
+							line = @buffer.byteslice(0, limit)
+							@buffer = @buffer.byteslice(limit, @buffer.bytesize)
+						else
+							line = @buffer.byteslice(0, index+(chomp ? 0 : separator.bytesize))
+							@buffer = @buffer.byteslice(index+separator.bytesize, @buffer.bytesize)
+						end
+						
+						return line
+					end
 				end
 				
 				include Reader
 				
+				# Write data to the underlying stream.
+				#
+				# @parameter buffer [String] The data to write.
+				# @raises [IOError] If the stream is not writable.
+				# @returns [Integer] The number of bytes written.
 				def write(buffer)
 					if @output
 						@output.write(buffer)
@@ -151,55 +304,119 @@ module Protocol
 					end
 				end
 				
-				def write_nonblock(buffer)
+				# Write data to the stream using {write}.
+				#
+				# Provided for compatibility with IO-like objects.
+				#
+				# @parameter buffer [String] The data to write.
+				# @parameter exception [Boolean] Whether to raise an exception if the write would block, currently ignored.
+				# @returns [Integer] The number of bytes written.
+				def write_nonblock(buffer, exception: nil)
+					write(buffer)
+				end
+				
+				# Write data to the stream using {write}.
+				def << buffer
 					write(buffer)
 				end
 				
-				def <<(buffer)
+				# Write lines to the stream.
+				#
+				# The current implementation buffers the lines and writes them in a single operation.
+				#
+				# @parameter arguments [Array(String)] The lines to write.
+				# @parameter separator [String] The line separator, defaults to `\n`.
+				def puts(*arguments, separator: NEWLINE)
+					buffer = ::String.new
+					
+					arguments.each do |argument|
+						buffer << argument << separator
+					end
+					
 					write(buffer)
 				end
 				
+				# Flush the output stream.
+				#
+				# This is currently a no-op.
 				def flush
 				end
 				
-				def close_read
-					@input&.close
-					@input = nil
+				# Close the input body.
+				#
+				# If, while processing the data that was read from this stream, an error is encountered, it should be passed to this method.
+				#
+				# @parameter error [Exception | Nil] The error that was encountered, if any.
+				def close_read(error = nil)
+					if input = @input
+						@input = nil
+						@closed_read = true
+						@buffer = nil
+						
+						input.close(error)
+					end
 				end
 				
-				# close must never be called on the input stream. huh?
-				def close_write
-					@output&.close
-					@output = nil
+				# Close the output body.
+				#
+				# If, while generating the data that is written to this stream, an error is encountered, it should be passed to this method.
+				#
+				# @parameter error [Exception | Nil] The error that was encountered, if any.
+				def close_write(error = nil)
+					if output = @output
+						@output = nil
+						
+						output.close_write(error)
+					end
 				end
 				
 				# Close the input and output bodies.
+				#
+				# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
 				def close(error = nil)
-					self.close_read
-					self.close_write
-
+					self.close_read(error)
+					self.close_write(error)
+					
 					return nil
 				ensure
 					@closed = true
 				end
 				
-				# Whether the stream has been closed.
+				# @returns [Boolean] Whether the stream has been closed.
 				def closed?
 					@closed
 				end
 				
-				# Whether there are any output chunks remaining?
+				# Inspect the stream.
+				#
+				# @returns [String] a string representation of the stream.
+				def inspect
+					buffer_info = @buffer ? "#{@buffer.bytesize} bytes buffered" : "no buffer"
+					
+					status = []
+					status << "closed" if @closed
+					status << "read-closed" if @closed_read
+					
+					status_info = status.empty? ? "open" : status.join(", ")
+					
+					return "#<#{self.class} #{buffer_info}, #{status_info}>"
+				end
+				
+				# @returns [Boolean] Whether there are any output chunks remaining.
 				def empty?
 					@output.empty?
 				end
 				
 				private
 				
+				# Read the next chunk of data from the input stream.
+				#
+				# @returns [String] The next chunk of data.
+				# @raises [IOError] If the input stream was explicitly closed.
 				def read_next
 					if @input
 						return @input.read
-					else
-						@input = nil
+					elsif @closed_read
 						raise IOError, "Stream is not readable, input has been closed!"
 					end
 				end
diff -pruN 0.23.12-1/lib/protocol/http/body/streamable.rb 0.55.0-1/lib/protocol/http/body/streamable.rb
--- 0.23.12-1/lib/protocol/http/body/streamable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/streamable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,207 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require_relative "readable"
+require_relative "writable"
+
+require_relative "stream"
+
+module Protocol
+	module HTTP
+		module Body
+			# A body that invokes a block that can read and write to a stream.
+			#
+			# In some cases, it's advantageous to directly read and write to the underlying stream if possible. For example, HTTP/1 upgrade requests, WebSockets, and similar. To handle that case, response bodies can implement {stream?} and return `true`. When {stream?} returns true, the body **should** be consumed by calling `call(stream)`. Server implementations may choose to always invoke `call(stream)` if it's efficient to do so. Bodies that don't support it will fall back to using {each}.
+			#
+			# When invoking `call(stream)`, the stream can be read from and written to, and closed. However, the stream is only guaranteed to be open for the duration of the `call(stream)` call. Once the method returns, the stream **should** be closed by the server.
+			module Streamable
+				# Generate a new streaming request body using the given block to generate the body.
+				#
+				# @parameter block [Proc] The block that generates the body.
+				# @returns [RequestBody] The streaming request body.
+				def self.request(&block)
+					RequestBody.new(block)
+				end
+				
+				# Generate a new streaming response body using the given block to generate the body.
+				#
+				# @parameter request [Request] The request.
+				# @parameter block [Proc] The block that generates the body.
+				# @returns [ResponseBody] The streaming response body.
+				def self.response(request, &block)
+					ResponseBody.new(block, request.body)
+				end
+				
+				# A output stream that can be written to by a block.
+				class Output
+					# Schedule the block to be executed in a fiber.
+					#
+					# @parameter input [Readable] The input stream.
+					# @parameter block [Proc] The block that generates the output.
+					# @returns [Output] The output stream.
+					def self.schedule(input, block)
+						self.new(input, block).tap(&:schedule)
+					end
+					
+					# Initialize the output stream with the given input and block.
+					#
+					# @parameter input [Readable] The input stream.
+					# @parameter block [Proc] The block that generates the output.
+					def initialize(input, block)
+						@output = Writable.new
+						@stream = Stream.new(input, @output)
+						@block = block
+					end
+					
+					# Schedule the block to be executed in a fiber.
+					#
+					# @returns [Fiber] The fiber.
+					def schedule
+						@fiber ||= Fiber.schedule do
+							@block.call(@stream)
+						end
+					end
+					
+					# Read from the output stream (may block).
+					def read
+						@output.read
+					end
+					
+					# Close the output stream.
+					#
+					# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
+					def close(error = nil)
+						@output.close_write(error)
+					end
+				end
+				
+				# Raised when a streaming body is consumed more than once.
+				class ConsumedError < StandardError
+				end
+				
+				# A streaming body that can be read from and written to.
+				class Body < Readable
+					# Initialize the body with the given block and input.
+					#
+					# @parameter block [Proc] The block that generates the body.
+					# @parameter input [Readable] The input stream, if known.
+					def initialize(block, input = nil)
+						@block = block
+						@input = input
+						@output = nil
+					end
+					
+					# @returns [Boolean] Whether the body can be streamed, which is true.
+					def stream?
+						true
+					end
+					
+					# Invokes the block in a fiber which yields chunks when they are available.
+					def read
+						# We are reading chunk by chunk, allocate an output stream and execute the block to generate the chunks:
+						if @output.nil?
+							if @block.nil?
+								raise ConsumedError, "Streaming body has already been consumed!"
+							end
+							
+							@output = Output.schedule(@input, @block)
+							@block = nil
+						end
+						
+						@output.read
+					end
+					
+					# Invoke the block with the given stream. The block can read and write to the stream, and must close the stream when finishing.
+					#
+					# @parameter stream [Stream] The stream to read and write to.
+					def call(stream)
+						if @block.nil?
+							raise ConsumedError, "Streaming block has already been consumed!"
+						end
+						
+						block = @block
+						
+						@input = @output = @block = nil
+						
+						# Ownership of the stream is passed into the block, in other words, the block is responsible for closing the stream.
+						block.call(stream)
+					rescue => error
+						# If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here:
+						stream.close
+						raise
+					end
+					
+					# Close the input. The streaming body will eventually read all the input.
+					#
+					# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
+					def close_input(error = nil)
+						if input = @input
+							@input = nil
+							input.close(error)
+						end
+					end
+					
+					# Close the output, the streaming body will be unable to write any more output.
+					#
+					# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
+					def close_output(error = nil)
+						if output = @output
+							@output = nil
+							output.close(error)
+						end
+					end
+					
+					# Inspect the streaming body.
+					#
+					# @returns [String] a string representation of the streaming body.
+					def inspect
+						if @block
+							"#<#{self.class} block available, not consumed>"
+						elsif @output
+							"#<#{self.class} block consumed, output active>"
+						else
+							"#<#{self.class} block consumed, output closed>"
+						end
+					end
+				end
+				
+				# A response body is used on the server side to generate the response body using a block.
+				class ResponseBody < Body
+					# Close will be invoked when all the output is written.
+					def close(error = nil)
+						self.close_output(error)
+					end
+				end
+				
+				# A request body is used on the client side to generate the request body using a block.
+				#
+				# As the response body isn't available until the request is sent, the response body must be {stream}ed into the request body.
+				class RequestBody < Body
+					# Initialize the request body with the given block.
+					#
+					# @parameter block [Proc] The block that generates the body.
+					def initialize(block)
+						super(block, Writable.new)
+					end
+					
+					# Close will be invoked when all the input is read.
+					def close(error = nil)
+						self.close_input(error)
+					end
+					
+					# Stream the response body into the block's input.
+					def stream(body)
+						body&.each do |chunk|
+							@input.write(chunk)
+						end
+					rescue => error
+					ensure
+						@input.close_write(error)
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/body/wrapper.rb 0.55.0-1/lib/protocol/http/body/wrapper.rb
--- 0.23.12-1/lib/protocol/http/body/wrapper.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/wrapper.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,84 +1,108 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
 
-require_relative 'readable'
+require_relative "readable"
 
 module Protocol
 	module HTTP
 		module Body
 			# Wrapping body instance. Typically you'd override `#read`.
 			class Wrapper < Readable
+				# Wrap the body of the given message in a new instance of this class.
+				#
+				# @parameter message [Request | Response] the message to wrap.
+				# @returns [Wrapper | Nil] the wrapped body or `nil`` if the body was `nil`.
 				def self.wrap(message)
 					if body = message.body
 						message.body = self.new(body)
 					end
 				end
 				
+				# Initialize the wrapper with the given body.
+				#
+				# @parameter body [Readable] The body to wrap.
 				def initialize(body)
 					@body = body
 				end
 				
-				# The wrapped body.
+				# @attribute [Readable] The wrapped body.
 				attr :body
 				
-				# Buffer any remaining body.
-				def finish
-					@body.finish
-				end
-				
+				# Close the body.
+				#
+				# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
 				def close(error = nil)
 					@body.close(error)
 					
-					super
+					# It's a no-op:
+					# super
 				end
 				
+				# Forwards to the wrapped body.
 				def empty?
 					@body.empty?
 				end
 				
+				# Forwards to the wrapped body.
 				def ready?
 					@body.ready?
 				end
 				
+				# Forwards to the wrapped body.
+				def buffered
+					@body.buffered
+				end
+				
+				# Forwards to the wrapped body.
+				def rewind
+					@body.rewind
+				end
+				
+				# Forwards to the wrapped body.
+				def rewindable?
+					@body.rewindable?
+				end
+				
+				# Forwards to the wrapped body.
 				def length
 					@body.length
 				end
 				
-				# Read the next available chunk.
+				# Forwards to the wrapped body.
 				def read
 					@body.read
 				end
 				
+				# Forwards to the wrapped body.
+				def discard
+					@body.discard
+				end
+				
+				# Convert the body to a hash suitable for serialization.
+				#
+				# @returns [Hash] The body as a hash.
+				def as_json(...)
+					{
+						class: self.class.name,
+						body: @body&.as_json
+					}
+				end
+				
+				# Convert the body to JSON.
+				#
+				# @returns [String] The body as JSON.
+				def to_json(...)
+					as_json.to_json(...)
+				end
+				
+				# Inspect the wrapped body. The wrapper, by default, is transparent.
+				#
+				# @returns [String] a string representation of the wrapped body.
 				def inspect
 					@body.inspect
 				end
-				
-				def stream?
-					@body.stream?
-				end
-				
-				def call(stream)
-					@body.call(stream)
-				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/body/writable.rb 0.55.0-1/lib/protocol/http/body/writable.rb
--- 0.23.12-1/lib/protocol/http/body/writable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body/writable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024-2025, by Samuel Williams.
+
+require_relative "readable"
+
+module Protocol
+	module HTTP
+		module Body
+			# A dynamic body which you can write to and read from.
+			class Writable < Readable
+				# An error indicating that the body has been closed and no further writes are allowed.
+				class Closed < StandardError
+				end
+				
+				# Initialize the writable body.
+				#
+				# @parameter length [Integer] The length of the response body if known.
+				# @parameter queue [Thread::Queue] Specify a different queue implementation, e.g. `Thread::SizedQueue` to enable back-pressure.
+				def initialize(length = nil, queue: Thread::Queue.new)
+					@length = length
+					@queue = queue
+					@count = 0
+					@error = nil
+				end
+				
+				# @attribute [Integer] The length of the response body if known.
+				attr :length
+				
+				# Stop generating output; cause the next call to write to fail with the given error. Does not prevent existing chunks from being read. In other words, this indicates both that no more data will be or should be written to the body.
+				#
+				# @parameter error [Exception] The error that caused this body to be closed, if any. Will be raised on the next call to {read}.
+				def close(error = nil)
+					@error ||= error
+					
+					@queue.clear
+					@queue.close
+					
+					super
+				end
+				
+				# Whether the body is closed. A closed body can not be written to or read from.
+				#
+				# @returns [Boolean] Whether the body is closed.
+				def closed?
+					@queue.closed?
+				end
+				
+				# @returns [Boolean] Whether the body is ready to be read from, without blocking.
+				def ready?
+					!@queue.empty? || @queue.closed?
+				end
+				
+				# Indicates whether the body is empty. This can occur if the body has been closed, or if the producer has invoked {close_write} and the reader has consumed all available chunks.
+				#
+				# @returns [Boolean] Whether the body is empty.
+				def empty?
+					@queue.empty? && @queue.closed?
+				end
+				
+				# Read the next available chunk.
+				#
+				# @returns [String | Nil] The next chunk, or `nil` if the body is finished.
+				# @raises [Exception] If the body was closed due to an error.
+				def read
+					if @error
+						raise @error
+					end
+					
+					# This operation may result in @error being set.
+					chunk = @queue.pop
+					
+					if @error
+						raise @error
+					end
+					
+					return chunk
+				end
+				
+				# Write a single chunk to the body. Signal completion by calling {close_write}.
+				#
+				# @parameter chunk [String] The chunk to write.
+				# @raises [Closed] If the body has been closed without error.
+				# @raises [Exception] If the body has been closed due to an error.
+				def write(chunk)
+					if @queue.closed?
+						raise(@error || Closed)
+					end
+					
+					@queue.push(chunk)
+					@count += 1
+				end
+				
+				# Signal that no more data will be written to the body.
+				#
+				# @parameter error [Exception] The error that caused this body to be closed, if any.
+				def close_write(error = nil)
+					@error ||= error
+					@queue.close
+				end
+				
+				# The output interface for writing chunks to the body.
+				class Output
+					# Initialize the output with the given writable body.
+					#
+					# @parameter writable [Writable] The writable body.
+					def initialize(writable)
+						@writable = writable
+						@closed = false
+					end
+					
+					# @returns [Boolean] Whether the output is closed for writing only.
+					def closed?
+						@closed || @writable.closed?
+					end
+					
+					# Write a chunk to the body.
+					def write(chunk)
+						@writable.write(chunk)
+					end
+					
+					alias << write
+					
+					# Close the output stream.
+					#
+					# If an error is given, the error will be used to close the body by invoking {close} with the error. Otherwise, only the write side of the body will be closed.
+					#
+					# @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
+					def close(error = nil)
+						@closed = true
+						
+						if error
+							@writable.close(error)
+						else
+							@writable.close_write
+						end
+					end
+				end
+				
+				# Create an output wrapper which can be used to write chunks to the body.
+				#
+				# If a block is given, and the block raises an error, the error will used to close the body by invoking {close} with the error.
+				#
+				# @yields {|output| ...} if a block is given.
+				# 	@parameter output [Output] The output wrapper.
+				# @returns [Output] The output wrapper.
+				def output
+					output = Output.new(self)
+					
+					unless block_given?
+						return output
+					end
+					
+					begin
+						yield output
+					rescue => error
+						raise error
+					ensure
+						output.close(error)
+					end
+				end
+				
+				# Inspect the body.
+				#
+				# @returns [String] A string representation of the body.
+				def inspect
+					if @error
+						"#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>"
+					else
+						"#<#{self.class} #{@count} chunks written, #{status}>"
+					end
+				end
+				
+				private
+				
+				# @returns [String] A string representation of the body's status.
+				def status
+					if @queue.empty?
+						if @queue.closed?
+							"closed"
+						else
+							"waiting"
+						end
+					else
+						if @queue.closed?
+							"closing"
+						else
+							"ready"
+						end
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/body.rb 0.55.0-1/lib/protocol/http/body.rb
--- 0.23.12-1/lib/protocol/http/body.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/body.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require_relative "body/readable"
+require_relative "body/writable"
+require_relative "body/wrapper"
+
+module Protocol
+	module HTTP
+		# @namespace
+		module Body
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/content_encoding.rb 0.55.0-1/lib/protocol/http/content_encoding.rb
--- 0.23.12-1/lib/protocol/http/content_encoding.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/content_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,69 +1,64 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'middleware'
+require_relative "middleware"
 
-require_relative 'body/buffered'
-require_relative 'body/deflate'
+require_relative "body/buffered"
+require_relative "body/deflate"
 
 module Protocol
 	module HTTP
 		# Encode a response according the the request's acceptable encodings.
 		class ContentEncoding < Middleware
+			# The default wrappers to use for encoding content.
 			DEFAULT_WRAPPERS = {
-				'gzip' => Body::Deflate.method(:for)
+				"gzip" => Body::Deflate.method(:for)
 			}
 			
+			# The default content types to apply encoding to.
 			DEFAULT_CONTENT_TYPES = %r{^(text/.*?)|(.*?/json)|(.*?/javascript)$}
 			
-			def initialize(app, content_types = DEFAULT_CONTENT_TYPES, wrappers = DEFAULT_WRAPPERS)
-				super(app)
+			# Initialize the content encoding middleware.
+			#
+			# @parameter delegate [Middleware] The next middleware in the chain.
+			# @parameter content_types [Regexp] The content types to apply encoding to.
+			# @parameter wrappers [Hash] The encoding wrappers to use.
+			def initialize(delegate, content_types = DEFAULT_CONTENT_TYPES, wrappers = DEFAULT_WRAPPERS)
+				super(delegate)
 				
 				@content_types = content_types
 				@wrappers = wrappers
 			end
 			
+			# Encode the response body according to the request's acceptable encodings.
+			#
+			# @parameter request [Request] The request.
+			# @returns [Response] The response.
 			def call(request)
 				response = super
 				
 				# Early exit if the response has already specified a content-encoding.
-				return response if response.headers['content-encoding']
+				return response if response.headers["content-encoding"]
 				
 				# This is a very tricky issue, so we avoid it entirely.
 				# https://lists.w3.org/Archives/Public/ietf-http-wg/2014JanMar/1179.html
 				return response if response.partial?
 				
+				body = response.body
+				
+				# If there is no response body, there is nothing to encode:
+				return response if body.nil? or body.empty?
+				
 				# Ensure that caches are aware we are varying the response based on the accept-encoding request header:
-				response.headers.add('vary', 'accept-encoding')
+				response.headers.add("vary", "accept-encoding")
 				
-				# TODO use http-accept and sort by priority
-				if !response.body.empty? and accept_encoding = request.headers['accept-encoding']
-					
-					if content_type = response.headers['content-type'] and @content_types =~ content_type
-						body = response.body
-						
+				if accept_encoding = request.headers["accept-encoding"]
+					if content_type = response.headers["content-type"] and @content_types =~ content_type
 						accept_encoding.each do |name|
 							if wrapper = @wrappers[name]
-								response.headers['content-encoding'] = name
+								response.headers["content-encoding"] = name
 								
 								body = wrapper.call(body)
 								
diff -pruN 0.23.12-1/lib/protocol/http/cookie.rb 0.55.0-1/lib/protocol/http/cookie.rb
--- 0.23.12-1/lib/protocol/http/cookie.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/cookie.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,63 +1,69 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2022, by Herrick Fang.
 
-require_relative 'url'
+require_relative "quoted_string"
 
 module Protocol
 	module HTTP
 		# Represents an individual cookie key-value pair.
 		class Cookie
-			def initialize(name, value, directives)
+			# Valid cookie name characters according to RFC 6265.
+			# cookie-name = token (RFC 2616 defines token)
+			VALID_COOKIE_KEY = /\A#{TOKEN}\z/.freeze
+			
+			# Valid cookie value characters according to RFC 6265.
+			# cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
+			# cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
+			# Excludes control chars, whitespace, DQUOTE, comma, semicolon, and backslash
+			VALID_COOKIE_VALUE = /\A[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/.freeze
+			
+			# Initialize the cookie with the given name, value, and directives.
+			#
+			# @parameter name [String] The name of the cookie, e.g. "session_id".
+			# @parameter value [String] The value of the cookie, e.g. "1234".
+			# @parameter directives [Hash] The directives of the cookie, e.g. `{"path" => "/"}`.
+			# @raises [ArgumentError] If the name or value contains invalid characters.
+			def initialize(name, value, directives = nil)
+				unless VALID_COOKIE_KEY.match?(name)
+					raise ArgumentError, "Invalid cookie name: #{name.inspect}"
+				end
+				
+				if value && !VALID_COOKIE_VALUE.match?(value)
+					raise ArgumentError, "Invalid cookie value: #{value.inspect}"
+				end
+				
 				@name = name
 				@value = value
 				@directives = directives
 			end
 			
-			attr :name
-			attr :value
-			attr :directives
+			# @attribute [String] The name of the cookie.
+			attr_accessor :name
 			
-			def encoded_name
-				URL.escape(@name)
-			end
+			# @attribute [String] The value of the cookie.
+			attr_accessor :value
 			
-			def encoded_value
-				URL.escape(@value)
-			end
+			# @attribute [Hash] The directives of the cookie.
+			attr_accessor :directives
 			
+			# Convert the cookie to a string.
+			#
+			# @returns [String] The string representation of the cookie.
 			def to_s
-				buffer = String.new.b
+				buffer = String.new
 				
-				buffer << encoded_name << '=' << encoded_value
+				buffer << @name << "=" << @value
 				
 				if @directives
-					@directives.collect do |key, value|
-						buffer << ';'
+					@directives.each do |key, value|
+						buffer << ";"
+						buffer << key
 						
-						case value
-						when String
-							buffer << key << '=' << value
-						when TrueClass
-							buffer << key
+						if value != true
+							buffer << "=" << value.to_s
 						end
 					end
 				end
@@ -65,22 +71,26 @@ module Protocol
 				return buffer
 			end
 			
+			# Parse a string into a cookie.
+			#
+			# @parameter string [String] The string to parse.
+			# @returns [Cookie] The parsed cookie.
 			def self.parse(string)
 				head, *directives = string.split(/\s*;\s*/)
 				
-				key, value = head.split('=', 2)
+				key, value = head.split("=", 2)
 				directives = self.parse_directives(directives)
 				
-				self.new(
-					URL.unescape(key),
-					URL.unescape(value),
-					directives,
-				)
+				self.new(key, value, directives)
 			end
 			
+			# Parse a list of strings into a hash of directives.
+			#
+			# @parameter strings [Array(String)] The list of strings to parse.
+			# @returns [Hash] The hash of directives.
 			def self.parse_directives(strings)
 				strings.collect do |string|
-					key, value = string.split('=', 2)
+					key, value = string.split("=", 2)
 					[key, value || true]
 				end.to_h
 			end
diff -pruN 0.23.12-1/lib/protocol/http/error.rb 0.55.0-1/lib/protocol/http/error.rb
--- 0.23.12-1/lib/protocol/http/error.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/error.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,29 +1,30 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
 		# A generic, HTTP protocol error.
 		class Error < StandardError
 		end
+		
+		# Represents a bad request error (as opposed to a server error).
+		# This is used to indicate that the request was malformed or invalid.
+		module BadRequest
+		end
+		
+		# Raised when a singleton (e.g. `content-length`) header is duplicated in a request or response.
+		class DuplicateHeaderError < Error
+			include BadRequest
+			
+			# @parameter key [String] The header key that was duplicated.
+			def initialize(key)
+				super("Duplicate singleton header key: #{key.inspect}")
+			end
+			
+			# @attribute [String] key The header key that was duplicated.
+			attr :key
+		end
 	end
 end
diff -pruN 0.23.12-1/lib/protocol/http/header/accept.rb 0.55.0-1/lib/protocol/http/header/accept.rb
--- 0.23.12-1/lib/protocol/http/header/accept.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/accept.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+# Copyright, 2025, by William T. Nelson.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `accept-content-type` header represents a list of content-types that the client can accept.
+			class Accept < Array
+				# Regular expression used to split values on commas, with optional surrounding whitespace, taking into account quoted strings.
+				SEPARATOR = /
+					(?:            # Start non-capturing group
+						"[^"\\]*"    # Match quoted strings (no escaping of quotes within)
+						|            # OR
+						[^,"]+       # Match non-quoted strings until a comma or quote
+					)+
+					(?=,|\z)       # Match until a comma or end of string
+				/x
+				
+				ParseError = Class.new(Error)
+				
+				MEDIA_RANGE = /\A(?<type>#{TOKEN})\/(?<subtype>#{TOKEN})(?<parameters>.*)\z/
+				
+				PARAMETER = /\s*;\s*(?<key>#{TOKEN})=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
+				
+				# A single entry in the Accept: header, which includes a mime type and associated parameters. A media range can include wild cards, but a media type is a specific type and subtype.
+				MediaRange = Struct.new(:type, :subtype, :parameters) do
+					# Create a new media range.
+					#
+					# @parameter type [String] the type of the media range.
+					# @parameter subtype [String] the subtype of the media range.
+					# @parameter parameters [Hash] the parameters associated with the media range.
+					def initialize(type, subtype = "*", parameters = {})
+						super(type, subtype, parameters)
+					end
+					
+					# Compare the media range with another media range or a string, based on the quality factor.
+					def <=> other
+						other.quality_factor <=> self.quality_factor
+					end
+					
+					private def parameters_string
+						return "" if parameters == nil or parameters.empty?
+						
+						parameters.collect do |key, value|
+							";#{key.to_s}=#{QuotedString.quote(value.to_s)}"
+						end.join
+					end
+					
+					# The string representation of the media range, including the type, subtype, and any parameters.
+					def to_s
+						"#{type}/#{subtype}#{parameters_string}"
+					end
+					
+					alias to_str to_s
+					
+					# The quality factor associated with the media range, which is used to determine the order of preference.
+					#
+					# @returns [Float] the quality factor, which defaults to 1.0 if not specified.
+					def quality_factor
+						parameters.fetch("q", 1.0).to_f
+					end
+				end
+				
+				# Parse the `accept` header value into a list of content types.
+				#
+				# @parameter value [String] the value of the header.
+				def initialize(value = nil)
+					if value
+						super(value.scan(SEPARATOR).map(&:strip))
+					end
+				end
+				
+				# Adds one or more comma-separated values to the header.
+				#
+				# The input string is split into distinct entries and appended to the array.
+				#
+				# @parameter value [String] the value or values to add, separated by commas.
+				def << value
+					self.concat(value.scan(SEPARATOR).map(&:strip))
+				end
+				
+				# Serializes the stored values into a comma-separated string.
+				#
+				# @returns [String] the serialized representation of the header values.
+				def to_s
+					join(",")
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# @returns [Boolean] `false`, as Accept headers are used for response content negotiation.
+				def self.trailer?
+					false
+				end
+				
+				# Parse the `accept` header.
+				#
+				# @returns [Array(Charset)] the list of content types and their associated parameters.
+				def media_ranges
+					self.map do |value|
+						self.parse_media_range(value)
+					end
+				end
+				
+				private
+				
+				def parse_media_range(value)
+					if match = value.match(MEDIA_RANGE)
+						type = match[:type]
+						subtype = match[:subtype]
+						parameters = {}
+						
+						match[:parameters].scan(PARAMETER) do |key, value, quoted_value|
+							if quoted_value
+								value = QuotedString.unquote(quoted_value)
+							end
+							
+							parameters[key] = value
+						end
+						
+						return MediaRange.new(type, subtype, parameters)
+					else
+						raise ParseError, "Invalid media type: #{value.inspect}"
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/accept_charset.rb 0.55.0-1/lib/protocol/http/header/accept_charset.rb
--- 0.23.12-1/lib/protocol/http/header/accept_charset.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/accept_charset.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `accept-charset` header represents a list of character sets that the client can accept.
+			class AcceptCharset < Split
+				ParseError = Class.new(Error)
+				
+				# https://tools.ietf.org/html/rfc7231#section-5.3.3
+				CHARSET = /\A(?<name>#{TOKEN})(;q=(?<q>#{QVALUE}))?\z/
+				
+				Charset = Struct.new(:name, :q) do
+					def quality_factor
+						(q || 1.0).to_f
+					end
+					
+					def <=> other
+						other.quality_factor <=> self.quality_factor
+					end
+				end
+				
+				# Parse the `accept-charset` header value into a list of character sets.
+				#
+				# @returns [Array(Charset)] the list of character sets and their associated quality factors.
+				def charsets
+					self.map do |value|
+						if match = value.match(CHARSET)
+							Charset.new(match[:name], match[:q])
+						else
+							raise ParseError.new("Could not parse character set: #{value.inspect}")
+						end
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/accept_encoding.rb 0.55.0-1/lib/protocol/http/header/accept_encoding.rb
--- 0.23.12-1/lib/protocol/http/header/accept_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/accept_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `accept-encoding` header represents a list of encodings that the client can accept.
+			class AcceptEncoding < Split
+				ParseError = Class.new(Error)
+				
+				# https://tools.ietf.org/html/rfc7231#section-5.3.1
+				QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/
+				
+				# https://tools.ietf.org/html/rfc7231#section-5.3.4
+				ENCODING = /\A(?<name>#{TOKEN})(;q=(?<q>#{QVALUE}))?\z/
+				
+				Encoding = Struct.new(:name, :q) do
+					def quality_factor
+						(q || 1.0).to_f
+					end
+					
+					def <=> other
+						other.quality_factor <=> self.quality_factor
+					end
+				end
+				
+				# Parse the `accept-encoding` header value into a list of encodings.
+				#
+				# @returns [Array(Charset)] the list of character sets and their associated quality factors.
+				def encodings
+					self.map do |value|
+						if match = value.match(ENCODING)
+							Encoding.new(match[:name], match[:q])
+						else
+							raise ParseError.new("Could not parse encoding: #{value.inspect}")
+						end
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/accept_language.rb 0.55.0-1/lib/protocol/http/header/accept_language.rb
--- 0.23.12-1/lib/protocol/http/header/accept_language.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/accept_language.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `accept-language` header represents a list of languages that the client can accept.
+			class AcceptLanguage < Split
+				ParseError = Class.new(Error)
+				
+				# https://tools.ietf.org/html/rfc3066#section-2.1
+				NAME = /\*|[A-Z]{1,8}(-[A-Z0-9]{1,8})*/i
+				
+				# https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
+				QVALUE = /0(\.[0-9]{0,6})?|1(\.[0]{0,6})?/
+				
+				# https://greenbytes.de/tech/webdav/rfc7231.html#quality.values
+				LANGUAGE = /\A(?<name>#{NAME})(\s*;\s*q=(?<q>#{QVALUE}))?\z/
+				
+				Language = Struct.new(:name, :q) do
+					def quality_factor
+						(q || 1.0).to_f
+					end
+					
+					def <=> other
+						other.quality_factor <=> self.quality_factor
+					end
+				end
+				
+				# Parse the `accept-language` header value into a list of languages.
+				#
+				# @returns [Array(Charset)] the list of character sets and their associated quality factors.
+				def languages
+					self.map do |value|
+						if match = value.match(LANGUAGE)
+							Language.new(match[:name], match[:q])
+						else
+							raise ParseError.new("Could not parse language: #{value.inspect}")
+						end
+					end
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/authorization.rb 0.55.0-1/lib/protocol/http/header/authorization.rb
--- 0.23.12-1/lib/protocol/http/header/authorization.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/authorization.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,26 +1,8 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'base64'
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2024, by Earlopain.
 
 module Protocol
 	module HTTP
@@ -30,20 +12,34 @@ module Protocol
 			# ~~~ ruby
 			# headers.add('authorization', Authorization.basic("my_username", "my_password"))
 			# ~~~
+			#
+			# TODO Support other authorization mechanisms, e.g. bearer token.
 			class Authorization < String
-				# Splits the header and 
-				# @return [Tuple(String, String)]
+				# Splits the header into the credentials.
+				#
+				# @returns [Tuple(String, String)] The username and password.
 				def credentials
 					self.split(/\s+/, 2)
 				end
 				
+				# Generate a new basic authorization header, encoding the given username and password.
+				#
+				# @parameter username [String] The username.
+				# @parameter password [String] The password.
+				# @returns [Authorization] The basic authorization header.
 				def self.basic(username, password)
-					encoded = "#{username}:#{password}"
+					strict_base64_encoded = ["#{username}:#{password}"].pack("m0")
 					
 					self.new(
-						"Basic #{Base64.strict_encode64(encoded)}"
+						"Basic #{strict_base64_encoded}"
 					)
 				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# @returns [Boolean] `false`, as authorization headers are used for request authentication.
+				def self.trailer?
+					false
+				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/header/cache_control.rb 0.55.0-1/lib/protocol/http/header/cache_control.rb
--- 0.23.12-1/lib/protocol/http/header/cache_control.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/cache_control.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,82 +1,131 @@
 # frozen_string_literal: true
 
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+# Copyright, 2023, by Thomas Morgan.
 
-require_relative 'split'
+require_relative "split"
 
 module Protocol
 	module HTTP
 		module Header
+			# Represents the `cache-control` header, which is a list of cache directives.
 			class CacheControl < Split
-				PRIVATE = 'private'
-				PUBLIC = 'public'
-				NO_CACHE = 'no-cache'
-				NO_STORE = 'no-store'
-				MAX_AGE = 'max-age'
-				
-				STATIC = 'static'
-				DYNAMIC = 'dynamic'
-				STREAMING = 'streaming'
+				# The `private` directive indicates that the response is intended for a single user and must not be stored by shared caches.
+				PRIVATE = "private"
 				
-				def initialize(value)
-					super(value.downcase)
+				# The `public` directive indicates that the response may be stored by any cache, even if it would normally be considered non-cacheable.
+				PUBLIC = "public"
+				
+				# The `no-cache` directive indicates that caches must revalidate the response with the origin server before serving it to clients.
+				NO_CACHE = "no-cache"
+				
+				# The `no-store` directive indicates that caches must not store the response under any circumstances.
+				NO_STORE = "no-store"
+				
+				# The `max-age` directive indicates the maximum amount of time, in seconds, that a response is considered fresh.
+				MAX_AGE = "max-age"
+				
+				# The `s-maxage` directive is similar to `max-age` but applies only to shared caches. If both `s-maxage` and `max-age` are present, `s-maxage` takes precedence in shared caches.
+				S_MAXAGE = "s-maxage"
+				
+				# The `static` directive is a custom directive often used to indicate that the resource is immutable or rarely changes, allowing longer caching periods.
+				STATIC = "static"
+				
+				# The `dynamic` directive is a custom directive used to indicate that the resource is generated dynamically and may change frequently, requiring shorter caching periods.
+				DYNAMIC = "dynamic"
+				
+				# The `streaming` directive is a custom directive used to indicate that the resource is intended for progressive or chunked delivery, such as live video streams.
+				STREAMING = "streaming"
+				
+				# The `must-revalidate` directive indicates that once a response becomes stale, caches must not use it to satisfy subsequent requests without revalidating it with the origin server.
+				MUST_REVALIDATE = "must-revalidate"
+				
+				# The `proxy-revalidate` directive is similar to `must-revalidate` but applies only to shared caches.
+				PROXY_REVALIDATE = "proxy-revalidate"
+				
+				# Initializes the cache control header with the given value. The value is expected to be a comma-separated string of cache directives.
+				#
+				# @parameter value [String | Nil] the raw Cache-Control header value.
+				def initialize(value = nil)
+					super(value&.downcase)
 				end
 				
+				# Adds a directive to the Cache-Control header. The value will be normalized to lowercase before being added.
+				#
+				# @parameter value [String] the directive to add.
 				def << value
 					super(value.downcase)
 				end
 				
+				# @returns [Boolean] whether the `static` directive is present.
 				def static?
 					self.include?(STATIC)
 				end
 				
+				# @returns [Boolean] whether the `dynamic` directive is present.
 				def dynamic?
 					self.include?(DYNAMIC)
 				end
 				
+				# @returns [Boolean] whether the `streaming` directive is present.
 				def streaming?
 					self.include?(STREAMING)
 				end
 				
+				# @returns [Boolean] whether the `private` directive is present.
 				def private?
 					self.include?(PRIVATE)
 				end
 				
+				# @returns [Boolean] whether the `public` directive is present.
 				def public?
 					self.include?(PUBLIC)
 				end
 				
+				# @returns [Boolean] whether the `no-cache` directive is present.
 				def no_cache?
 					self.include?(NO_CACHE)
 				end
 				
+				# @returns [Boolean] whether the `no-store` directive is present.
 				def no_store?
 					self.include?(NO_STORE)
 				end
 				
+				# @returns [Boolean] whether the `must-revalidate` directive is present.
+				def must_revalidate?
+					self.include?(MUST_REVALIDATE)
+				end
+				
+				# @returns [Boolean] whether the `proxy-revalidate` directive is present.
+				def proxy_revalidate?
+					self.include?(PROXY_REVALIDATE)
+				end
+				
+				# @returns [Integer | Nil] the value of the `max-age` directive in seconds, or `nil` if the directive is not present or invalid.
 				def max_age
-					if value = self.find{|value| value.start_with?(MAX_AGE)}
-						_, age = value.split('=', 2)
+					find_integer_value(MAX_AGE)
+				end
+				
+				# @returns [Integer | Nil] the value of the `s-maxage` directive in seconds, or `nil` if the directive is not present or invalid.
+				def s_maxage
+					find_integer_value(S_MAXAGE)
+				end
+				
+				private
+				
+				# Finds and parses an integer value from a directive.
+				#
+				# @parameter value_name [String] the directive name to search for (e.g., "max-age").
+				# @returns [Integer | Nil] the parsed integer value, or `nil` if not found or invalid.
+				def find_integer_value(value_name)
+					if value = self.find{|value| value.start_with?(value_name)}
+						_, age = value.split("=", 2)
 						
-						return Integer(age)
+						if age =~ /\A[0-9]+\z/
+							return Integer(age)
+						end
 					end
 				end
 			end
diff -pruN 0.23.12-1/lib/protocol/http/header/connection.rb 0.55.0-1/lib/protocol/http/header/connection.rb
--- 0.23.12-1/lib/protocol/http/header/connection.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/connection.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,54 +1,62 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2024, by Thomas Morgan.
 
-require_relative 'split'
+require_relative "split"
 
 module Protocol
 	module HTTP
 		module Header
+			# Represents the `connection` HTTP header, which controls options for the current connection.
+			#
+			# The `connection` header is used to specify control options such as whether the connection should be kept alive, closed, or upgraded to a different protocol.
 			class Connection < Split
-				KEEP_ALIVE = 'keep-alive'
-				CLOSE = 'close'
-				UPGRADE = 'upgrade'
+				# The `keep-alive` directive indicates that the connection should remain open for future requests or responses, avoiding the overhead of opening a new connection.
+				KEEP_ALIVE = "keep-alive"
 				
-				def initialize(value)
-					super(value.downcase)
+				# The `close` directive indicates that the connection should be closed after the current request and response are complete.
+				CLOSE = "close"
+				
+				# The `upgrade` directive indicates that the connection should be upgraded to a different protocol, as specified in the `Upgrade` header.
+				UPGRADE = "upgrade"
+				
+				# Initializes the connection header with the given value. The value is expected to be a comma-separated string of directives.
+				#
+				# @parameter value [String | Nil] the raw `connection` header value.
+				def initialize(value = nil)
+					super(value&.downcase)
 				end
 				
+				# Adds a directive to the `connection` header. The value will be normalized to lowercase before being added.
+				#
+				# @parameter value [String] the directive to add.
 				def << value
 					super(value.downcase)
 				end
 				
+				# @returns [Boolean] whether the `keep-alive` directive is present and the connection is not marked for closure with the `close` directive.
 				def keep_alive?
-					self.include?(KEEP_ALIVE)
+					self.include?(KEEP_ALIVE) && !close?
 				end
 				
+				# @returns [Boolean] whether the `close` directive is present, indicating that the connection should be closed after the current request and response.
 				def close?
 					self.include?(CLOSE)
 				end
 				
+				# @returns [Boolean] whether the `upgrade` directive is present, indicating that the connection should be upgraded to a different protocol.
 				def upgrade?
 					self.include?(UPGRADE)
 				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# Connection headers control the current connection and must not appear in trailers.
+				# @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers.
+				def self.trailer?
+					false
+				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/header/cookie.rb 0.55.0-1/lib/protocol/http/header/cookie.rb
--- 0.23.12-1/lib/protocol/http/header/cookie.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/cookie.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,33 +1,21 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'multiple'
-require_relative '../cookie'
+require_relative "multiple"
+require_relative "../cookie"
 
 module Protocol
 	module HTTP
 		module Header
-			# The Cookie HTTP request header contains stored HTTP cookies previously sent by the server with the Set-Cookie header.
+			# The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header.
+			#
+			# It is used by clients to send key-value pairs representing stored cookies back to the server.
 			class Cookie < Multiple
+				# Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects.
+				#
+				# @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects.
 				def to_h
 					cookies = self.collect do |string|
 						HTTP::Cookie.parse(string)
@@ -35,9 +23,18 @@ module Protocol
 					
 					cookies.map{|cookie| [cookie.name, cookie]}.to_h
 				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# Cookie headers should not appear in trailers as they contain state information needed early in processing.
+				# @returns [Boolean] `false`, as cookie headers are needed during initial request processing.
+				def self.trailer?
+					false
+				end
 			end
 			
-			# The Set-Cookie HTTP response header sends cookies from the server to the user agent.
+			# The `set-cookie` header sends cookies from the server to the user agent.
+			#
+			# It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header.
 			class SetCookie < Cookie
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/header/date.rb 0.55.0-1/lib/protocol/http/header/date.rb
--- 0.23.12-1/lib/protocol/http/header/date.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/date.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "time"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `date` header represents the date and time at which the message was originated.
+			#
+			# This header is typically included in HTTP responses and follows the format defined in RFC 9110.
+			class Date < String
+				# Replaces the current value of the `date` header with the specified value.
+				#
+				# @parameter value [String] the new value for the `date` header.
+				def << value
+					replace(value)
+				end
+				
+				# Converts the `date` header value to a `Time` object.
+				#
+				# @returns [Time] the parsed time object corresponding to the `date` header value.
+				def to_time
+					::Time.parse(self)
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# Date headers can safely appear in trailers as they provide metadata about response generation.
+				# @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation.
+				def self.trailer?
+					true
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/digest.rb 0.55.0-1/lib/protocol/http/header/digest.rb
--- 0.23.12-1/lib/protocol/http/header/digest.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/digest.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `digest` header provides a digest of the message body for integrity verification.
+			#
+			# This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available.
+			#
+			# ## Examples
+			#
+			# ```ruby
+			# digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=")
+			# digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8"
+			# puts digest.to_s
+			# # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8"
+			# ```
+			class Digest < Split
+				ParseError = Class.new(Error)
+				
+				# https://tools.ietf.org/html/rfc3230#section-4.3.2
+				ENTRY = /\A(?<algorithm>[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?<value>.*)\z/
+				
+				# A single digest entry in the Digest header.
+				Entry = Struct.new(:algorithm, :value) do
+					# Create a new digest entry.
+					#
+					# @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5").
+					# @parameter value [String] the base64-encoded or hex-encoded digest value.
+					def initialize(algorithm, value)
+						super(algorithm.downcase, value)
+					end
+					
+					# Convert the entry to its string representation.
+					#
+					# @returns [String] the formatted digest string.
+					def to_s
+						"#{algorithm}=#{value}"
+					end
+				end
+				
+				# Parse the `digest` header value into a list of digest entries.
+				#
+				# @returns [Array(Entry)] the list of digest entries with their algorithms and values.
+				def entries
+					self.map do |value|
+						if match = value.match(ENTRY)
+							Entry.new(match[:algorithm], match[:value])
+						else
+							raise ParseError.new("Could not parse digest value: #{value.inspect}")
+						end
+					end
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available.
+				def self.trailer?
+					true
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/etag.rb 0.55.0-1/lib/protocol/http/header/etag.rb
--- 0.23.12-1/lib/protocol/http/header/etag.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/etag.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,35 +1,36 @@
 # frozen_string_literal: true
 
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
 		module Header
+			# The `etag` header represents the entity tag for a resource.
+			#
+			# The `etag` header provides a unique identifier for a specific version of a resource, typically used for cache validation or conditional requests. It can be either a strong or weak validator as defined in RFC 9110.
 			class ETag < String
+				# Replaces the current value of the `etag` header with the specified value.
+				#
+				# @parameter value [String] the new value for the `etag` header.
 				def << value
 					replace(value)
 				end
 				
+				# Checks whether the `etag` is a weak validator.
+				#
+				# Weak validators indicate semantically equivalent content but may not be byte-for-byte identical.
+				#
+				# @returns [Boolean] whether the `etag` is weak.
 				def weak?
-					self.start_with('\W')
+					self.start_with?("W/")
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# ETag headers can safely appear in trailers as they provide cache validation metadata.
+				# @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation.
+				def self.trailer?
+					true
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/header/etags.rb 0.55.0-1/lib/protocol/http/header/etags.rb
--- 0.23.12-1/lib/protocol/http/header/etags.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/etags.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,39 +1,74 @@
 # frozen_string_literal: true
 
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+# Copyright, 2023, by Thomas Morgan.
 
-require_relative 'split'
+require_relative "split"
 
 module Protocol
 	module HTTP
 		module Header
-			# This implementation is not strictly correct according to the RFC-specified format.
+			# The `etags` header represents a list of entity tags (ETags) for resources.
+			#
+			# The `etags` header is used for conditional requests to compare the current version of a resource with previously stored versions. It supports both strong and weak validators, as well as the wildcard character (`*`) to indicate a match for any resource.
 			class ETags < Split
+				# Checks if the `etags` header contains the wildcard (`*`) character.
+				#
+				# The wildcard character matches any resource version, regardless of its actual value.
+				#
+				# @returns [Boolean] whether the wildcard is present.
 				def wildcard?
-					self.include?('*')
+					self.include?("*")
 				end
 				
+				# Checks if the specified ETag matches the `etags` header.
+				#
+				# This method returns `true` if the wildcard is present or if the exact ETag is found in the list. Note that this implementation is not strictly compliant with the RFC-specified format.
+				#
+				# @parameter etag [String] the ETag to compare against the `etags` header.
+				# @returns [Boolean] whether the specified ETag matches.
 				def match?(etag)
 					wildcard? || self.include?(etag)
 				end
+				
+				# Checks for a strong match with the specified ETag, useful with the `if-match` header.
+				#
+				# A strong match requires that the ETag in the header list matches the specified ETag and that neither is a weak validator.
+				#
+				# @parameter etag [String] the ETag to compare against the `etags` header.
+				# @returns [Boolean] whether a strong match is found.
+				def strong_match?(etag)
+					wildcard? || (!weak_tag?(etag) && self.include?(etag))
+				end
+				
+				# Checks for a weak match with the specified ETag, useful with the `if-none-match` header.
+				#
+				# A weak match allows for semantically equivalent content, including weak validators and their strong counterparts.
+				#
+				# @parameter etag [String] the ETag to compare against the `etags` header.
+				# @returns [Boolean] whether a weak match is found.
+				def weak_match?(etag)
+					wildcard? || self.include?(etag) || self.include?(opposite_tag(etag))
+				end
+				
+				private
+				
+				# Converts a weak tag to its strong counterpart or vice versa.
+				#
+				# @parameter etag [String] the ETag to convert.
+				# @returns [String] the opposite form of the provided ETag.
+				def opposite_tag(etag)
+					weak_tag?(etag) ? etag[2..-1] : "W/#{etag}"
+				end
+				
+				# Checks if the given ETag is a weak validator.
+				#
+				# @parameter tag [String] the ETag to check.
+				# @returns [Boolean] whether the tag is weak.
+				def weak_tag?(tag)
+					tag&.start_with? "W/"
+				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/header/multiple.rb 0.55.0-1/lib/protocol/http/header/multiple.rb
--- 0.23.12-1/lib/protocol/http/header/multiple.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/multiple.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,39 +1,37 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
 		module Header
-			# Header value which is split by newline charaters (e.g. cookies).
+			# Represents headers that can contain multiple distinct values separated by newline characters.
+			#
+			# This isn't a specific header but is used as a base for headers that store multiple values, such as cookies. The values are split and stored as an array internally, and serialized back to a newline-separated string when needed.
 			class Multiple < Array
+				# Initializes the multiple header with the given value. As the header key-value pair can only contain one value, the value given here is added to the internal array, and subsequent values can be added using the `<<` operator.
+				#
+				# @parameter value [String] the raw header value.
 				def initialize(value)
 					super()
 					
 					self << value
 				end
 				
+				# Serializes the stored values into a newline-separated string.
+				#
+				# @returns [String] the serialized representation of the header values.
 				def to_s
 					join("\n")
 				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# This is a base class for headers with multiple values, default is to disallow in trailers.
+				# @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default.
+				def self.trailer?
+					false
+				end
 			end
 		end
 	end
diff -pruN 0.23.12-1/lib/protocol/http/header/priority.rb 0.55.0-1/lib/protocol/http/header/priority.rb
--- 0.23.12-1/lib/protocol/http/header/priority.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/priority.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require_relative "split"
+
+module Protocol
+	module HTTP
+		module Header
+			# Represents the `priority` header, used to indicate the relative importance of an HTTP request.
+			#
+			# The `priority` header allows clients to express their preference for how resources should be prioritized by the server. It supports directives like `u=` to specify the urgency level of a request, and `i` to indicate whether a response can be delivered incrementally. The urgency levels range from 0 (highest priority) to 7 (lowest priority), while the `i` directive is a boolean flag.
+			class Priority < Split
+				# Initialize the priority header with the given value.
+				#
+				# @parameter value [String | Nil] the value of the priority header, if any. The value should be a comma-separated string of directives.
+				def initialize(value = nil)
+					super(value&.downcase)
+				end
+				
+				# Add a value to the priority header.
+				#
+				# @parameter value [String] the directive to add to the header.
+				def << value
+					super(value.downcase)
+				end
+				
+				# The default urgency level if not specified.
+				DEFAULT_URGENCY = 3
+				
+				# The urgency level, if specified using `u=`. 0 is the highest priority, and 7 is the lowest.
+				#
+				# Note that when duplicate Dictionary keys are encountered, all but the last instance are ignored.
+				#
+				# @returns [Integer | Nil] the urgency level if specified, or `nil` if not present.
+				def urgency(default = DEFAULT_URGENCY)
+					if value = self.reverse_find{|value| value.start_with?("u=")}
+						_, level = value.split("=", 2)
+						return Integer(level)
+					end
+					
+					return default
+				end
+				
+				# Checks if the response should be delivered incrementally.
+				#
+				# The `i` directive, when present, indicates that the response can be delivered incrementally as data becomes available.
+				#
+				# @returns [Boolean] whether the request should be delivered incrementally.
+				def incremental?
+					self.include?("i")
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/server_timing.rb 0.55.0-1/lib/protocol/http/header/server_timing.rb
--- 0.23.12-1/lib/protocol/http/header/server_timing.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/server_timing.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `server-timing` header communicates performance metrics about the request-response cycle to the client.
+			#
+			# This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description.
+			#
+			# ## Examples
+			#
+			# ```ruby
+			# server_timing = ServerTiming.new("db;dur=53.2")
+			# server_timing << "cache;dur=12.1;desc=\"Redis lookup\""
+			# puts server_timing.to_s
+			# # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\""
+			# ```
+			class ServerTiming < Split
+				ParseError = Class.new(Error)
+				
+				# https://www.w3.org/TR/server-timing/
+				METRIC = /\A(?<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?<parameters>.*))?\z/
+				PARAMETER = /(?<key>dur|desc)=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
+				
+				# A single metric in the Server-Timing header.
+				Metric = Struct.new(:name, :duration, :description) do
+					# Create a new server timing metric.
+					#
+					# @parameter name [String] the name of the metric.
+					# @parameter duration [Float | Nil] the duration in milliseconds.
+					# @parameter description [String | Nil] the description of the metric.
+					def initialize(name, duration = nil, description = nil)
+						super(name, duration, description)
+					end
+					
+					# Convert the metric to its string representation.
+					#
+					# @returns [String] the formatted metric string.
+					def to_s
+						result = name.dup
+						result << ";dur=#{duration}" if duration
+						result << ";desc=\"#{description}\"" if description
+						result
+					end
+				end
+				
+				# Parse the `server-timing` header value into a list of metrics.
+				#
+				# @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions.
+				def metrics
+					self.map do |value|
+						if match = value.match(METRIC)
+							name = match[:name]
+							parameters = match[:parameters] || ""
+							
+							duration = nil
+							description = nil
+							
+							parameters.scan(PARAMETER) do |key, value, quoted_value|
+								value = QuotedString.unquote(quoted_value) if quoted_value
+								
+								case key
+								when "dur"
+									duration = value.to_f
+								when "desc"
+									description = value
+								end
+							end
+							
+							Metric.new(name, duration, description)
+						else
+							raise ParseError.new("Could not parse server timing metric: #{value.inspect}")
+						end
+					end
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation.
+				def self.trailer?
+					true
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/split.rb 0.55.0-1/lib/protocol/http/header/split.rb
--- 0.23.12-1/lib/protocol/http/header/split.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/split.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,42 +1,60 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
 		module Header
-			# Header value which is split by commas.
+			# Represents headers that can contain multiple distinct values separated by commas.
+			#
+			# This isn't a specific header  class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed.
 			class Split < Array
+				# Regular expression used to split values on commas, with optional surrounding whitespace.
 				COMMA = /\s*,\s*/
 				
-				def initialize(value)
-					super(value.split(COMMA))
+				# Initializes a `Split` header with the given value. If the value is provided, it is split into distinct entries and stored as an array.
+				#
+				# @parameter value [String | Nil] the raw header value containing multiple entries separated by commas, or `nil` for an empty header.
+				def initialize(value = nil)
+					if value
+						super(value.split(COMMA))
+					else
+						super()
+					end
 				end
 				
+				# Adds one or more comma-separated values to the header.
+				#
+				# The input string is split into distinct entries and appended to the array.
+				#
+				# @parameter value [String] the value or values to add, separated by commas.
 				def << value
-					self.push(*value.split(COMMA))
+					self.concat(value.split(COMMA))
 				end
 				
+				# Serializes the stored values into a comma-separated string.
+				#
+				# @returns [String] the serialized representation of the header values.
 				def to_s
-					join(", ")
+					join(",")
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# This is a base class for comma-separated headers, default is to disallow in trailers.
+				# @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default.
+				def self.trailer?
+					false
+				end
+				
+				protected
+				
+				def reverse_find(&block)
+					reverse_each do |value|
+						return value if block.call(value)
+					end
+					
+					return nil
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/header/te.rb 0.55.0-1/lib/protocol/http/header/te.rb
--- 0.23.12-1/lib/protocol/http/header/te.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/te.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+require_relative "../quoted_string"
+require_relative "../error"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `te` header indicates the transfer encodings the client is willing to accept. AKA `accept-transfer-encoding`. How we ended up with `te` instead of `accept-transfer-encoding` is a mystery lost to time.
+			#
+			# The `te` header allows a client to indicate which transfer encodings it can handle, and in what order of preference using quality factors.
+			class TE < Split
+				ParseError = Class.new(Error)
+				
+				# Transfer encoding token pattern
+				TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/
+				
+				# Quality value pattern (0.0 to 1.0)
+				QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/
+				
+				# Pattern for parsing transfer encoding with optional quality factor
+				TRANSFER_CODING = /\A(?<name>#{TOKEN})(\s*;\s*q=(?<q>#{QVALUE}))?\z/
+				
+				# The `chunked` transfer encoding
+				CHUNKED = "chunked"
+				
+				# The `gzip` transfer encoding
+				GZIP = "gzip"
+				
+				# The `deflate` transfer encoding  
+				DEFLATE = "deflate"
+				
+				# The `compress` transfer encoding
+				COMPRESS = "compress"
+				
+				# The `identity` transfer encoding
+				IDENTITY = "identity"
+				
+				# The `trailers` pseudo-encoding indicates willingness to accept trailer fields
+				TRAILERS = "trailers"
+				
+				# A single transfer coding entry with optional quality factor
+				TransferCoding = Struct.new(:name, :q) do
+					def quality_factor
+						(q || 1.0).to_f
+					end
+					
+					def <=> other
+						other.quality_factor <=> self.quality_factor
+					end
+					
+					def to_s
+						if q && q != 1.0
+							"#{name};q=#{q}"
+						else
+							name.to_s
+						end
+					end
+				end
+				
+				# Initializes the TE header with the given value. The value is split into distinct entries and converted to lowercase for normalization.
+				#
+				# @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas.
+				def initialize(value = nil)
+					super(value&.downcase)
+				end
+				
+				# Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization.
+				#
+				# @parameter value [String] the value or values to add, separated by commas.
+				def << value
+					super(value.downcase)
+				end
+				
+				# Parse the `te` header value into a list of transfer codings with quality factors.
+				#
+				# @returns [Array(TransferCoding)] the list of transfer codings and their associated quality factors.
+				def transfer_codings
+					self.map do |value|
+						if match = value.match(TRANSFER_CODING)
+							TransferCoding.new(match[:name], match[:q])
+						else
+							raise ParseError.new("Could not parse transfer coding: #{value.inspect}")
+						end
+					end
+				end
+				
+				# @returns [Boolean] whether the `chunked` encoding is accepted.
+				def chunked?
+					self.any? {|value| value.start_with?(CHUNKED)}
+				end
+				
+				# @returns [Boolean] whether the `gzip` encoding is accepted.
+				def gzip?
+					self.any? {|value| value.start_with?(GZIP)}
+				end
+				
+				# @returns [Boolean] whether the `deflate` encoding is accepted.
+				def deflate?
+					self.any? {|value| value.start_with?(DEFLATE)}
+				end
+				
+				# @returns [Boolean] whether the `compress` encoding is accepted.
+				def compress?
+					self.any? {|value| value.start_with?(COMPRESS)}
+				end
+				
+				# @returns [Boolean] whether the `identity` encoding is accepted.
+				def identity?
+					self.any? {|value| value.start_with?(IDENTITY)}
+				end
+				
+				# @returns [Boolean] whether trailers are accepted.
+				def trailers?
+					self.any? {|value| value.start_with?(TRAILERS)}
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# TE headers negotiate transfer encodings and must not appear in trailers.
+				# @returns [Boolean] `false`, as TE headers are hop-by-hop and control message framing.
+				def self.trailer?
+					false
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/trailer.rb 0.55.0-1/lib/protocol/http/header/trailer.rb
--- 0.23.12-1/lib/protocol/http/header/trailer.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/trailer.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+
+module Protocol
+	module HTTP
+		module Header
+			# Represents headers that can contain multiple distinct values separated by commas.
+			#
+			# This isn't a specific header  class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed.
+			class Trailer < Split
+				# Whether this header is acceptable in HTTP trailers.
+				# @returns [Boolean] `false`, as Trailer headers control trailer processing and must appear before the message body.
+				def self.trailer?
+					false
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/transfer_encoding.rb 0.55.0-1/lib/protocol/http/header/transfer_encoding.rb
--- 0.23.12-1/lib/protocol/http/header/transfer_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/transfer_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "split"
+
+module Protocol
+	module HTTP
+		module Header
+			# The `transfer-encoding` header indicates the encoding transformations that have been applied to the message body.
+			#
+			# The `transfer-encoding` header is used to specify the form of encoding used to safely transfer the message body between the sender and receiver.
+			class TransferEncoding < Split
+				# The `chunked` transfer encoding allows a server to send data of unknown length by breaking it into chunks.
+				CHUNKED = "chunked"
+				
+				# The `gzip` transfer encoding compresses the message body using the gzip algorithm.
+				GZIP = "gzip"
+				
+				# The `deflate` transfer encoding compresses the message body using the deflate algorithm.
+				DEFLATE = "deflate"
+				
+				# The `compress` transfer encoding compresses the message body using the compress algorithm.
+				COMPRESS = "compress"
+				
+				# The `identity` transfer encoding indicates no transformation has been applied.
+				IDENTITY = "identity"
+				
+				# Initializes the transfer encoding header with the given value. The value is split into distinct entries and converted to lowercase for normalization.
+				#
+				# @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas.
+				def initialize(value = nil)
+					super(value&.downcase)
+				end
+				
+				# Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization.
+				#
+				# @parameter value [String] the value or values to add, separated by commas.
+				def << value
+					super(value.downcase)
+				end
+				
+				# @returns [Boolean] whether the `chunked` encoding is present.
+				def chunked?
+					self.include?(CHUNKED)
+				end
+				
+				# @returns [Boolean] whether the `gzip` encoding is present.
+				def gzip?
+					self.include?(GZIP)
+				end
+				
+				# @returns [Boolean] whether the `deflate` encoding is present.
+				def deflate?
+					self.include?(DEFLATE)
+				end
+				
+				# @returns [Boolean] whether the `compress` encoding is present.
+				def compress?
+					self.include?(COMPRESS)
+				end
+				
+				# @returns [Boolean] whether the `identity` encoding is present.
+				def identity?
+					self.include?(IDENTITY)
+				end
+				
+				# Whether this header is acceptable in HTTP trailers.
+				# Transfer-Encoding headers control message framing and must not appear in trailers.
+				# @returns [Boolean] `false`, as Transfer-Encoding headers are hop-by-hop and must precede the message body.
+				def self.trailer?
+					false
+				end
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/header/vary.rb 0.55.0-1/lib/protocol/http/header/vary.rb
--- 0.23.12-1/lib/protocol/http/header/vary.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/header/vary.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,35 +1,27 @@
 # frozen_string_literal: true
 
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
 
-require_relative 'split'
+require_relative "split"
 
 module Protocol
 	module HTTP
 		module Header
+			# Represents the `vary` header, which specifies the request headers a server considers when determining the response.
+			#
+			# The `vary` header is used in HTTP responses to indicate which request headers affect the selected response. It allows caches to differentiate stored responses based on specific request headers.
 			class Vary < Split
+				# Initializes a `Vary` header with the given value. The value is split into distinct entries and converted to lowercase for normalization.
+				#
+				# @parameter value [String] the raw header value containing request header names separated by commas.
 				def initialize(value)
 					super(value.downcase)
 				end
 				
+				# Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization.
+				#
+				# @parameter value [String] the value or values to add, separated by commas.
 				def << value
 					super(value.downcase)
 				end
diff -pruN 0.23.12-1/lib/protocol/http/headers.rb 0.55.0-1/lib/protocol/http/headers.rb
--- 0.23.12-1/lib/protocol/http/headers.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/headers.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,45 +1,48 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
 
-require_relative 'header/split'
-require_relative 'header/multiple'
-require_relative 'header/cookie'
-require_relative 'header/connection'
-require_relative 'header/cache_control'
-require_relative 'header/etag'
-require_relative 'header/etags'
-require_relative 'header/vary'
-require_relative 'header/authorization'
+require_relative "error"
+
+require_relative "header/split"
+require_relative "header/multiple"
+
+require_relative "header/cookie"
+require_relative "header/connection"
+require_relative "header/cache_control"
+require_relative "header/etag"
+require_relative "header/etags"
+require_relative "header/vary"
+require_relative "header/authorization"
+require_relative "header/date"
+require_relative "header/priority"
+require_relative "header/trailer"
+require_relative "header/server_timing"
+require_relative "header/digest"
+
+require_relative "header/accept"
+require_relative "header/accept_charset"
+require_relative "header/accept_encoding"
+require_relative "header/accept_language"
+require_relative "header/transfer_encoding"
+require_relative "header/te"
 
 module Protocol
 	module HTTP
+		# @namespace
+		module Header
+		end
+		
 		# Headers are an array of key-value pairs. Some header keys represent multiple values.
 		class Headers
 			Split = Header::Split
 			Multiple = Header::Multiple
 			
-			TRAILER = 'trailer'
+			TRAILER = "trailer"
 			
 			# Construct an instance from a headers Array or Hash. No-op if already an instance of `Headers`. If the underlying array is frozen, it will be duped.
+			#
 			# @return [Headers] an instance of headers.
 			def self.[] headers
 				if headers.nil?
@@ -63,14 +66,38 @@ module Protocol
 				return self.new(fields)
 			end
 			
-			def initialize(fields = [], indexed = nil)
+			# Initialize the headers with the specified fields.
+			#
+			# @parameter fields [Array] An array of `[key, value]` pairs.
+			# @parameter tail [Integer | Nil] The index of the trailer start in the @fields array.
+			def initialize(fields = [], tail = nil, indexed: nil, policy: POLICY)
 				@fields = fields
-				@indexed = indexed
 				
-				# Marks where trailer start in the @fields array.
-				@tail = nil
+				# Marks where trailer start in the @fields array:
+				@tail = tail
+				
+				# The cached index of headers:
+				@indexed = nil
+				
+				@policy = policy
+			end
+			
+			# @attribute [Hash] The policy for the headers.
+			attr :policy
+			
+			# Set the policy for the headers.
+			#
+			# The policy is used to determine how headers are merged and normalized. For example, if a header is specified multiple times, the policy will determine how the values are merged.
+			#
+			# @parameter policy [Hash] The policy for the headers.
+			def policy=(policy)
+				@policy = policy
+				@indexed = nil
 			end
 			
+			# Initialize a copy of the headers.
+			#
+			# @parameter other [Headers] The headers to copy.
 			def initialize_dup(other)
 				super
 				
@@ -78,13 +105,14 @@ module Protocol
 				@indexed = @indexed.dup
 			end
 			
+			# Clear all headers.
 			def clear
 				@fields.clear
-				@indexed = nil
 				@tail = nil
+				@indexed = nil
 			end
 			
-			# Flatten trailer into the headers.
+			# Flatten trailer into the headers, in-place.
 			def flatten!
 				if @tail
 					self.delete(TRAILER)
@@ -94,29 +122,35 @@ module Protocol
 				return self
 			end
 			
+			# Flatten trailer into the headers, returning a new instance of {Headers}.
 			def flatten
 				self.dup.flatten!
 			end
 			
-			# An array of `[key, value]` pairs.
+			# @attribute [Array] An array of `[key, value]` pairs.
 			attr :fields
 			
-			# @returns Whether there are any trailers.
+			# @attribute [Integer | Nil] The index where trailers begin.
+			attr :tail
+			
+			# @returns [Array] The fields of the headers.
+			def to_a
+				@fields
+			end
+			
+			# @returns [Boolean] Whether there are any trailers.
 			def trailer?
 				@tail != nil
 			end
 			
 			# Record the current headers, and prepare to add trailers.
 			#
-			# This method is typically used after headers are sent to capture any
-			# additional headers which should then be sent as trailers.
+			# This method is typically used after headers are sent to capture any additional headers which should then be sent as trailers.
 			#
-			# A sender that intends to generate one or more trailer fields in a
-			# message should generate a trailer header field in the header section of
-			# that message to indicate which fields might be present in the trailers.
+			# A sender that intends to generate one or more trailer fields in a message should generate a trailer header field in the header section of that message to indicate which fields might be present in the trailers.
 			#
 			# @parameter names [Array] The trailer header names which will be added later.
-			# @yields block {|name, value| ...} The trailer headers if any.
+			# @yields {|name, value| ...} the trailing headers if a block is given.
 			# @returns An enumerator which is suitable for iterating over trailers.
 			def trailer!(&block)
 				@tail ||= @fields.size
@@ -133,6 +167,7 @@ module Protocol
 				end
 			end
 			
+			# Freeze the headers, and ensure the indexed hash is generated.
 			def freeze
 				return if frozen?
 				
@@ -145,24 +180,35 @@ module Protocol
 				super
 			end
 			
+			# @returns [Boolean] Whether the headers are empty.
 			def empty?
 				@fields.empty?
 			end
 			
+			# Enumerate all header keys and values.
+			#
+			# @yields {|key, value| ...}
+			# 	@parameter key [String] The header key.
+			# 	@parameter value [String] The header value.
 			def each(&block)
 				@fields.each(&block)
 			end
 			
+			# @returns [Boolean] Whether the headers include the specified key.
 			def include? key
 				self[key] != nil
 			end
 			
 			alias key? include?
 			
+			# @returns [Array] All the keys of the headers.
 			def keys
 				self.to_h.keys
 			end
 			
+			# Extract the specified keys from the headers.
+			#
+			# @parameter keys [Array] The keys to extract.
 			def extract(keys)
 				deleted, @fields = @fields.partition do |field|
 					keys.include?(field.first.downcase)
@@ -179,86 +225,118 @@ module Protocol
 			
 			# Add the specified header key value pair.
 			#
-			# @param key [String] the header key.
-			# @param value [String] the header value to assign.
+			# @parameter key [String] the header key.
+			# @parameter value [String] the header value to assign.
 			def add(key, value)
-				self[key] = value
+				# The value MUST be a string, so we convert it to a string to prevent errors later on.
+				value = value.to_s
+				
+				if @indexed
+					merge_into(@indexed, key.downcase, value)
+				end
+				
+				@fields << [key, value]
 			end
 			
+			alias []= add
+			
 			# Set the specified header key to the specified value, replacing any existing header keys with the same name.
-			# @param key [String] the header key to replace.
-			# @param value [String] the header value to assign.
+			#
+			# @parameter key [String] the header key to replace.
+			# @parameter value [String] the header value to assign.
 			def set(key, value)
 				# TODO This could be a bit more efficient:
 				self.delete(key)
 				self.add(key, value)
 			end
 			
+			# Merge the headers into this instance.
 			def merge!(headers)
 				headers.each do |key, value|
-					self[key] = value
+					self.add(key, value)
 				end
 				
 				return self
 			end
 			
+			# Merge the headers into a new instance of {Headers}.
 			def merge(headers)
 				self.dup.merge!(headers)
 			end
 			
-			# Append the value to the given key. Some values can be appended multiple times, others can only be set once.
-			# @param key [String] The header key.
-			# @param value The header value.
-			def []= key, value
-				if @indexed
-					merge_into(@indexed, key.downcase, value)
-				end
-				
-				@fields << [key, value]
-			end
-			
+			# The policy for various headers, including how they are merged and normalized.
 			POLICY = {
-				# Headers which may only be specified once.
-				'content-type' => false,
-				'content-disposition' => false,
-				'content-length' => false,
-				'user-agent' => false,
-				'referer' => false,
-				'host' => false,
-				'if-modified-since' => false,
-				'if-unmodified-since' => false,
-				'from' => false,
-				'location' => false,
-				'max-forwards' => false,
+				# Headers which may only be specified once:
+				"content-disposition" => false,
+				"content-length" => false,
+				"content-type" => false,
+				"expect" => false,
+				"from" => false,
+				"host" => false,
+				"location" => false,
+				"max-forwards" => false,
+				"range" => false,
+				"referer" => false,
+				"retry-after" => false,
+				"server" => false,
+				"transfer-encoding" => Header::TransferEncoding,
+				"user-agent" => false,
+				"trailer" => Header::Trailer,
 				
 				# Custom headers:
-				'connection' => Header::Connection,
-				'cache-control' => Header::CacheControl,
-				'vary' => Header::Vary,
+				"connection" => Header::Connection,
+				"cache-control" => Header::CacheControl,
+				"te" => Header::TE,
+				"vary" => Header::Vary,
+				"priority" => Header::Priority,
 				
 				# Headers specifically for proxies:
-				'via' => Split,
-				'x-forwarded-for' => Split,
+				"via" => Split,
+				"x-forwarded-for" => Split,
 				
 				# Authorization headers:
-				'authorization' => Header::Authorization,
-				'proxy-authorization' => Header::Authorization,
+				"authorization" => Header::Authorization,
+				"proxy-authorization" => Header::Authorization,
 				
 				# Cache validations:
-				'etag' => Header::ETag,
-				'if-match' => Header::ETags,
-				'if-none-match' => Header::ETags,
+				"etag" => Header::ETag,
+				"if-match" => Header::ETags,
+				"if-none-match" => Header::ETags,
+				"if-range" => false,
 				
 				# Headers which may be specified multiple times, but which can't be concatenated:
-				'www-authenticate' => Multiple,
-				'proxy-authenticate' => Multiple,
+				"www-authenticate" => Multiple,
+				"proxy-authenticate" => Multiple,
 				
 				# Custom headers:
-				'set-cookie' => Header::SetCookie,
-				'cookie' => Header::Cookie,
+				"set-cookie" => Header::SetCookie,
+				"cookie" => Header::Cookie,
+				
+				# Date headers:
+				# These headers include a comma as part of the formatting so they can't be concatenated.
+				"date" => Header::Date,
+				"expires" => Header::Date,
+				"last-modified" => Header::Date,
+				"if-modified-since" => Header::Date,
+				"if-unmodified-since" => Header::Date,
+				
+				# Accept headers:
+				"accept" => Header::Accept,
+				"accept-charset" => Header::AcceptCharset,
+				"accept-encoding" => Header::AcceptEncoding,
+				"accept-language" => Header::AcceptLanguage,
+				
+				# Performance headers:
+				"server-timing" => Header::ServerTiming,
+				
+				# Content integrity headers:
+				"digest" => Header::Digest,
 			}.tap{|hash| hash.default = Split}
 			
-			# Delete all headers with the given key, and return the merged value.
+			# Delete all header values for the given key, and return the merged value.
+			#
+			# @parameter key [String] The header key.
+			# @returns [String | Array | Object] The merged header value.
 			def delete(key)
 				deleted, @fields = @fields.partition do |field|
 					field.first.downcase == key
@@ -270,7 +348,7 @@ module Protocol
 				
 				if @indexed
 					return @indexed.delete(key)
-				elsif policy = POLICY[key]
+				elsif policy = @policy[key]
 					(key, value), *tail = deleted
 					merged = policy.new(value)
 					
@@ -283,36 +361,74 @@ module Protocol
 				end
 			end
 			
-			protected def merge_into(hash, key, value)
-				if policy = POLICY[key]
+			# Merge the value into the hash according to the policy for the given key.
+			# 
+			# @parameter hash [Hash] The hash to merge into.
+			# @parameter key [String] The header key.
+			# @parameter value [String] The raw header value.
+			protected def merge_into(hash, key, value, trailer = @tail)
+				if policy = @policy[key]
+					# Check if we're adding to trailers and this header is allowed:
+					if trailer && !policy.trailer?
+						return false
+					end
+					
 					if current_value = hash[key]
 						current_value << value
 					else
 						hash[key] = policy.new(value)
 					end
 				else
-					# We can't merge these, we only expose the last one set.
+					# By default, headers are not allowed in trailers:
+					if trailer
+						return false
+					end
+					
+					if hash.key?(key)
+						raise DuplicateHeaderError, key
+					end
+					
 					hash[key] = value
 				end
 			end
 			
+			# Get the value of the specified header key.
+			#
+			# @parameter key [String] The header key.
+			# @returns [String | Array | Object] The header value.
 			def [] key
 				to_h[key]
 			end
 			
-			# A hash table of `{key, policy[key].map(values)}`
+			# Compute a hash table of headers, where the keys are normalized to lower case and the values are normalized according to the policy for that header.
+			#
+			# @returns [Hash] A hash table of `{key, value}` pairs.
 			def to_h
-				@indexed ||= @fields.inject({}) do |hash, (key, value)|
-					merge_into(hash, key.downcase, value)
+				unless @indexed
+					@indexed = {}
 					
-					hash
+					@fields.each_with_index do |(key, value), index|
+						trailer = (@tail && index >= @tail)
+						
+						merge_into(@indexed, key.downcase, value, trailer)
+					end
 				end
+				
+				return @indexed
 			end
 			
+			alias as_json to_h
+			
+			# Inspect the headers.
+			#
+			# @returns [String] A string representation of the headers.
 			def inspect
 				"#<#{self.class} #{@fields.inspect}>"
 			end
 			
+			# Compare this object to another object. May depend on the order of the fields.
+			#
+			# @returns [Boolean] Whether the other object is equal to this one.
 			def == other
 				case other
 				when Hash
@@ -328,22 +444,45 @@ module Protocol
 			class Merged
 				include Enumerable
 				
+				# Construct a merged list of headers.
+				#
+				# @parameter *all [Array] An array of all headers to merge.
 				def initialize(*all)
 					@all = all
 				end
 				
+				# @returns [Array] A list of all headers, in the order they were added, as `[key, value]` pairs.
+				def fields
+					each.to_a
+				end
+				
+				# @returns [Headers] A new instance of {Headers} containing all the merged headers.
+				def flatten
+					Headers.new(fields)
+				end
+				
+				# Clear the references to all headers.
 				def clear
 					@all.clear
 				end
 				
+				# Add a new set of headers to the merged list.
+				#
+				# @parameter headers [Headers | Array | Hash] A list of headers to add.
 				def << headers
 					@all << headers
 					
 					return self
 				end
 				
-				# @yields [String, String] header key (lower case string) and value (as string).
+				# Enumerate all headers in the merged list.
+				#
+				# @yields {|key, value| ...} The header key and value.
+				# 	@parameter key [String] The header key (lower case).
+				# 	@parameter value [String] The header value.
 				def each(&block)
+					return to_enum unless block_given?
+					
 					@all.each do |headers|
 						headers.each do |key, value|
 							yield key.to_s.downcase, value.to_s
diff -pruN 0.23.12-1/lib/protocol/http/methods.rb 0.55.0-1/lib/protocol/http/methods.rb
--- 0.23.12-1/lib/protocol/http/methods.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/methods.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,41 +1,60 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
-		# All supported HTTP methods
+		# Provides a convenient interface for commonly supported HTTP methods.
+		#
+		# | Method Name | Request Body | Response Body | Safe | Idempotent | Cacheable |
+		# | ----------- | ------------ | ------------- | ---- | ---------- | --------- |
+		# | GET         | Optional     | Yes           | Yes  | Yes        | Yes       |
+		# | HEAD        | Optional     | No            | Yes  | Yes        | Yes       |
+		# | POST        | Yes          | Yes           | No   | No         | Yes       |
+		# | PUT         | Yes          | Yes           | No   | Yes        | No        |
+		# | DELETE      | Optional     | Yes           | No   | Yes        | No        |
+		# | CONNECT     | Optional     | Yes           | No   | No         | No        |
+		# | OPTIONS     | Optional     | Yes           | Yes  | Yes        | No        |
+		# | TRACE       | No           | Yes           | Yes  | Yes        | No        |
+		# | PATCH       | Yes          | Yes           | No   | No         | No        |
+		#
+		# These methods are defined in this module using lower case names. They are for convenience only and you should not overload those methods.
+		#
+		# See <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods> for more details.
 		class Methods
-			GET = 'GET'
-			POST = 'POST'
-			PUT = 'PUT'
-			PATCH = 'PATCH'
-			DELETE = 'DELETE'
-			HEAD = 'HEAD'
-			OPTIONS = 'OPTIONS'
-			LINK = 'LINK'
-			UNLINK = 'UNLINK'
-			TRACE = 'TRACE'
-			CONNECT = 'CONNECT'
+			# The GET method requests a representation of the specified resource. Requests using GET should only retrieve data.
+			GET = "GET"
 			
+			# The HEAD method asks for a response identical to a GET request, but without the response body.
+			HEAD = "HEAD"
+			
+			# The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server.
+			POST = "POST"
+			
+			# The PUT method replaces all current representations of the target resource with the request payload.
+			PUT = "PUT"
+			
+			# The DELETE method deletes the specified resource.
+			DELETE = "DELETE"
+			
+			# The CONNECT method establishes a tunnel to the server identified by the target resource.
+			CONNECT = "CONNECT"
+			
+			# The OPTIONS method describes the communication options for the target resource.
+			OPTIONS = "OPTIONS"
+			
+			# The TRACE method performs a message loop-back test along the path to the target resource.
+			TRACE = "TRACE"
+			
+			# The PATCH method applies partial modifications to a resource.
+			PATCH = "PATCH"
+			
+			# Check if the given name is a valid HTTP method, according to this module.
+			#
+			# Note that this method only knows about the methods defined in this module, however there are many other methods defined in different specifications.
+			#
+			# @returns [Boolean] True if the name is a valid HTTP method.
 			def self.valid?(name)
 				const_defined?(name)
 			rescue NameError
@@ -43,17 +62,22 @@ module Protocol
 				return false
 			end
 			
+			# Enumerate all HTTP methods.
+			# @yields {|name, value| ...}
+			# 	@parameter name [Symbol] The name of the method, e.g. `:GET`.
+			# 	@parameter value [String] The value of the method, e.g. `"GET"`.
 			def self.each
+				return to_enum(:each) unless block_given?
+				
 				constants.each do |name|
-					yield name, const_get(name)
+					yield name.downcase, const_get(name)
 				end
 			end
 			
-			# Use Methods.constants to get all constants.
-			self.each do |name, value|
-				define_method(name.downcase) do |location, headers = nil, body = nil|
+			self.each do |name, method|
+				define_method(name) do |*arguments, **options|
 					self.call(
-						Request[value, location.to_s, Headers[headers], body]
+						Request[method, *arguments, **options]
 					)
 				end
 			end
diff -pruN 0.23.12-1/lib/protocol/http/middleware/builder.rb 0.55.0-1/lib/protocol/http/middleware/builder.rb
--- 0.23.12-1/lib/protocol/http/middleware/builder.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/middleware/builder.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,55 +1,59 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
 
-require_relative '../middleware'
+require_relative "../middleware"
 
 module Protocol
 	module HTTP
 		class Middleware
+			# A convenient interface for constructing middleware stacks.
 			class Builder
+				# Initialize the builder with the given default application.
+				#
+				# @parameter default_app [Object] The default application to use if no middleware is specified.
 				def initialize(default_app = NotFound)
 					@use = []
 					@app = default_app
 				end
 				
-				def use(middleware, *arguments, &block)
-					@use << proc {|app| middleware.new(app, *arguments, &block)}
+				# Use the given middleware with the given arguments and options.
+				#
+				# @parameter middleware [Class | Object] The middleware class to use.
+				# @parameter arguments [Array] The arguments to pass to the middleware constructor.
+				# @parameter options [Hash] The options to pass to the middleware constructor.
+				# @parameter block [Proc] The block to pass to the middleware constructor.
+				def use(middleware, *arguments, **options, &block)
+					@use << proc {|app| middleware.new(app, *arguments, **options, &block)}
 				end
 				
-				ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true)
-				
+				# Specify the (default) middleware application to use.
+				#
+				# @parameter app [Middleware] The application to use if no middleware is able to handle the request.
 				def run(app)
 					@app = app
 				end
 				
+				# Convert the builder to an application by chaining the middleware together.
+				#
+				# @returns [Middleware] The application.
 				def to_app
 					@use.reverse.inject(@app) {|app, use| use.call(app)}
 				end
 			end
 			
+			# Build a middleware application using the given block.
 			def self.build(&block)
 				builder = Builder.new
 				
-				builder.instance_eval(&block)
+				if block_given?
+					if block.arity == 0
+						builder.instance_exec(&block)
+					else
+						yield builder
+					end
+				end
 				
 				return builder.to_app
 			end
diff -pruN 0.23.12-1/lib/protocol/http/middleware.rb 0.55.0-1/lib/protocol/http/middleware.rb
--- 0.23.12-1/lib/protocol/http/middleware.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/middleware.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,79 +1,100 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'methods'
-require_relative 'headers'
-require_relative 'request'
-require_relative 'response'
+require_relative "methods"
+require_relative "headers"
+require_relative "request"
+require_relative "response"
 
 module Protocol
 	module HTTP
+		# The middleware interface provides a convenient wrapper for implementing HTTP middleware.
+		#
+		# A middleware instance generally needs to respond to two methods:
+		#
+		# - `call(request)` -> `response`
+		# - `close()`
+		#
+		# The call method is called for each request. The close method is called when the server is shutting down.
+		#
+		# You do not need to use the Middleware class to implement middleware. You can implement the interface directly.
 		class Middleware < Methods
 			# Convert a block to a middleware delegate.
+			#
+			# @parameter block [Proc] The block to convert to a middleware delegate.
+			# @returns [Middleware] The middleware delegate.
 			def self.for(&block)
+				# Add a close method to the block.
 				def block.close
 				end
 				
 				return self.new(block)
 			end
 			
+			# Initialize the middleware with the given delegate.
+			#
+			# @parameter delegate [Object] The delegate object. A delegate is used for passing along requests that are not handled by *this* middleware.
 			def initialize(delegate)
 				@delegate = delegate
 			end
 			
+			# @attribute [Object] The delegate object that is used for passing along requests that are not handled by *this* middleware.
 			attr :delegate
 			
+			# Close the middleware. Invokes the close method on the delegate.
 			def close
 				@delegate.close
 			end
 			
+			# Call the middleware with the given request. Invokes the call method on the delegate.
 			def call(request)
 				@delegate.call(request)
 			end
 			
+			# A simple middleware that always returns a 200 response.
 			module Okay
+				# Close the middleware - idempotent no-op.
 				def self.close
 				end
 				
+				# Call the middleware with the given request, always returning a 200 response.
+				#
+				# @parameter request [Request] The request object.
+				# @returns [Response] The response object, which always contains a 200 status code.
 				def self.call(request)
 					Response[200]
 				end
 			end
 			
+			# A simple middleware that always returns a 404 response.
 			module NotFound
+				# Close the middleware - idempotent no-op.
 				def self.close
 				end
 				
+				# Call the middleware with the given request, always returning a 404 response. This middleware is useful as a default.
+				#
+				# @parameter request [Request] The request object.
+				# @returns [Response] The response object, which always contains a 404 status code.
 				def self.call(request)
 					Response[404]
 				end
 			end
 			
+			# A simple middleware that always returns "Hello World!".
 			module HelloWorld
+				# Close the middleware - idempotent no-op.
 				def self.close
 				end
 				
+				# Call the middleware with the given request.
+				#
+				# @parameter request [Request] The request object.
+				# @returns [Response] The response object, whihc always contains "Hello World!".
 				def self.call(request)
-					Response[200, Headers['content-type' => 'text/plain'], ["Hello World!"]]
+					Response[200, Headers["content-type" => "text/plain"], ["Hello World!"]]
 				end
 			end
 		end
diff -pruN 0.23.12-1/lib/protocol/http/peer.rb 0.55.0-1/lib/protocol/http/peer.rb
--- 0.23.12-1/lib/protocol/http/peer.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/peer.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024-2025, by Samuel Williams.
+
+module Protocol
+	module HTTP
+		# Provide a well defined, cached representation of a peer (address).
+		class Peer
+			# Create a new peer object for the given IO object, using the remote address if available.
+			#
+			# @returns [Peer | Nil] The peer object, or nil if the remote address is not available.
+			def self.for(io)
+				if address = io.remote_address
+					return new(address)
+				end
+			end
+			
+			# Initialize the peer with the given address.
+			#
+			# @parameter address [Addrinfo] The remote address of the peer.
+			def initialize(address)
+				@address = address
+				
+				if address.ip?
+					@ip_address = @address.ip_address
+				end
+			end
+			
+			# @attribute [Addrinfo] The remote address of the peer.
+			attr :address
+			
+			# @attribute [String] The IP address of the peer, if available.
+			attr :ip_address
+			
+			alias remote_address address
+		end
+	end
+end
diff -pruN 0.23.12-1/lib/protocol/http/quoted_string.rb 0.55.0-1/lib/protocol/http/quoted_string.rb
--- 0.23.12-1/lib/protocol/http/quoted_string.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/quoted_string.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+module Protocol
+	module HTTP
+		# According to https://tools.ietf.org/html/rfc7231#appendix-C
+		TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i
+		
+		QUOTED_STRING = /"(?:.(?!(?<!\\)"))*.?"/
+		
+		# https://tools.ietf.org/html/rfc7231#section-5.3.1
+		QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/
+		
+		# Handling of HTTP quoted strings.
+		module QuotedString
+			# Unquote a "quoted-string" value according to <https://tools.ietf.org/html/rfc7230#section-3.2.6>. It should already match the QUOTED_STRING pattern above by the parser.
+			def self.unquote(value, normalize_whitespace = true)
+				value = value[1...-1]
+				
+				value.gsub!(/\\(.)/, '\1')
+				
+				if normalize_whitespace
+					# LWS = [CRLF] 1*( SP | HT )
+					value.gsub!(/[\r\n]+\s+/, " ")
+				end
+				
+				return value
+			end
+			
+			QUOTES_REQUIRED = /[()<>@,;:\\"\/\[\]?={} \t]/
+			
+			# Quote a string for HTTP header values if required.
+			#
+			# @raises [ArgumentError] if the value contains invalid characters like control characters or newlines.
+			def self.quote(value, force = false)
+				# Check if quoting is required:
+				if value =~ QUOTES_REQUIRED or force
+					"\"#{value.gsub(/["\\]/, '\\\\\0')}\""
+				else
+					value
+				end
+			end
+		end
+	end
+end
\ No newline at end of file
diff -pruN 0.23.12-1/lib/protocol/http/reference.rb 0.55.0-1/lib/protocol/http/reference.rb
--- 0.23.12-1/lib/protocol/http/reference.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/reference.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,204 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require_relative 'url'
-
-module Protocol
-	module HTTP
-		# A relative reference, excluding any authority. The path part of an HTTP request.
-		class Reference
-			include Comparable
-			
-			# Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
-			def self.parse(path = '/', parameters = nil)
-				base, fragment = path.split('#', 2)
-				path, query_string = base.split('?', 2)
-				
-				self.new(path, query_string, fragment, parameters)
-			end
-			
-			def initialize(path = '/', query_string = nil, fragment = nil, parameters = nil)
-				@path = path
-				@query_string = query_string
-				@fragment = fragment
-				@parameters = parameters
-			end
-			
-			# The path component, e.g. /foo/bar/index.html
-			attr_accessor :path
-			
-			# The un-parsed query string, e.g. 'x=10&y=20'
-			attr_accessor :query_string
-			
-			# A fragment, the part after the '#'
-			attr_accessor :fragment
-			
-			# User supplied parameters that will be appended to the query part.
-			attr_accessor :parameters
-			
-			def freeze
-				return self if frozen?
-				
-				@path.freeze
-				@query_string.freeze
-				@fragment.freeze
-				@parameters.freeze
-				
-				super
-			end
-			
-			def to_ary
-				[@path, @query_string, @fragment, @parameters]
-			end
-			
-			def <=> other
-				to_ary <=> other.to_ary
-			end
-			
-			def self.[] reference
-				if reference.is_a? self
-					return reference
-				else
-					return self.parse(reference)
-				end
-			end
-			
-			def parameters?
-				@parameters and !@parameters.empty?
-			end
-			
-			def query_string?
-				@query_string and !@query_string.empty?
-			end
-			
-			def fragment?
-				@fragment and !@fragment.empty?
-			end
-			
-			def append(buffer)
-				if query_string?
-					buffer << URL.escape_path(@path) << '?' << @query_string
-					buffer << '&' << URL.encode(@parameters) if parameters?
-				else
-					buffer << URL.escape_path(@path)
-					buffer << '?' << URL.encode(@parameters) if parameters?
-				end
-				
-				if fragment?
-					buffer << '#' << URL.escape(@fragment)
-				end
-				
-				return buffer
-			end
-			
-			def to_s
-				append(String.new)
-			end
-			
-			# Merges two references as specified by RFC2396, similar to `URI.join`.
-			def + other
-				other = self.class[other]
-				
-				self.class.new(
-					expand_path(self.path, other.path, true),
-					other.query_string,
-					other.fragment,
-					other.parameters,
-				)
-			end
-			
-			# Just the base path, without any query string, parameters or fragment.
-			def base
-				self.class.new(@path, nil, nil, nil)
-			end
-			
-			# @option path [String] Append the string to this reference similar to `File.join`.
-			# @option parameters [Hash] Append the parameters to this reference.
-			# @option fragment [String] Set the fragment to this value.
-			def with(path: nil, parameters: nil, fragment: @fragment)
-				if @parameters
-					if parameters
-						parameters = @parameters.merge(parameters)
-					else
-						parameters = @parameters
-					end
-				end
-				
-				if path
-					path = expand_path(@path, path, false)
-				else
-					path = @path
-				end
-				
-				self.class.new(path, @query_string, fragment, parameters)
-			end
-			
-			# The arguments to this function are legacy, prefer to use `with`.
-			def dup(path = nil, parameters = nil, merge_parameters = true)
-				if merge_parameters
-					with(path: path, parameters: parameters)
-				else
-					self.base.with(path: path, parameters: parameters)
-				end
-			end
-			
-			private
-			
-			def split(path)
-				if path.empty?
-					[path]
-				else
-					path.split('/', -1)
-				end
-			end
-			
-			# @param pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
-			def expand_path(base, relative, pop = true)
-				if relative.start_with? '/'
-					return relative
-				else
-					path = split(base)
-					
-					# RFC2396 Section 5.2:
-					# 6) a) All but the last segment of the base URI's path component is
-					# copied to the buffer.  In other words, any characters after the
-					# last (right-most) slash character, if any, are excluded.
-					path.pop if pop or path.last == ''
-					
-					parts = split(relative)
-					
-					parts.each do |part|
-						if part == '..'
-							path.pop
-						elsif part == '.'
-							# Do nothing.
-						else
-							path << part
-						end
-					end
-					
-					return path.join('/')
-				end
-			end
-		end
-	end
-end
diff -pruN 0.23.12-1/lib/protocol/http/request.rb 0.55.0-1/lib/protocol/http/request.rb
--- 0.23.12-1/lib/protocol/http/request.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/request.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,37 +1,42 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'body/buffered'
-require_relative 'body/reader'
+require_relative "body/buffered"
+require_relative "body/reader"
 
-require_relative 'headers'
-require_relative 'methods'
+require_relative "headers"
+require_relative "methods"
 
 module Protocol
 	module HTTP
+		# Represents an HTTP request which can be used both server and client-side.
+		#
+		# ~~~ ruby
+		# require 'protocol/http'
+		# 
+		# # Long form:
+		# Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", Protocol::HTTP::Headers[["accept", "text/html"]])
+		# 
+		# # Short form:
+		# Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}]
+		# ~~~
 		class Request
 			prepend Body::Reader
 			
-			def initialize(scheme = nil, authority = nil, method = nil, path = nil, version = nil, headers = Headers.new, body = nil, protocol = nil)
+			# Initialize the request.
+			#
+			# @parameter scheme [String | Nil] The request scheme, usually `"http"` or `"https"`.
+			# @parameter authority [String | Nil] The request authority, usually a hostname and port number, e.g. `"example.com:80"`.
+			# @parameter method [String | Nil] The request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc.
+			# @parameter path [String | Nil] The request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, etc.
+			# @parameter version [String | Nil] The request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`.
+			# @parameter headers [Headers] The request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc.
+			# @parameter body [Body::Readable] The request body.
+			# @parameter protocol [String | Array(String) | Nil] The request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`.
+			# @parameter interim_response [Proc] A callback which is called when an interim response is received.
+			def initialize(scheme = nil, authority = nil, method = nil, path = nil, version = nil, headers = Headers.new, body = nil, protocol = nil, interim_response = nil)
 				@scheme = scheme
 				@authority = authority
 				@method = method
@@ -40,56 +45,124 @@ module Protocol
 				@headers = headers
 				@body = body
 				@protocol = protocol
+				@interim_response = interim_response
 			end
 			
-			# The request scheme, usually one of "http" or "https".
+			# @attribute [String] the request scheme, usually `"http"` or `"https"`.
 			attr_accessor :scheme
-
-			# The request authority, usually a hostname and port number.
+			
+			# @attribute [String] the request authority, usually a hostname and port number, e.g. `"example.com:80"`.
 			attr_accessor :authority
-
-			# The request method, usually one of "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT" or "OPTIONS".
+			
+			# @attribute [String] the request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc.
 			attr_accessor :method
-
-			# The request path, usually a path and query string.
+			
+			# @attribute [String] the request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, however it can be any [valid request target](https://www.rfc-editor.org/rfc/rfc9110#target.resource).
 			attr_accessor :path
-
-			# The request version, usually "http/1.0", "http/1.1", "h2", or "h3".
+			
+			# @attribute [String] the request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`.
 			attr_accessor :version
-
-			# The request headers, contains metadata associated with the request such as the user agent, accept (content type), accept-language, etc.
+			
+			# @attribute [Headers] the request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc.
 			attr_accessor :headers
-
-			# The request body, an instance of Protocol::HTTP::Body::Readable or similar.
+			
+			# @attribute [Body::Readable] the request body. It should only be read once (it may not be idempotent).
 			attr_accessor :body
-
-			# The request protocol, usually empty, but occasionally "websocket" or "webtransport", can be either single value `String` or multi-value `Array` of `String` instances. In HTTP/1, it is used to request a connection upgrade, and in HTTP/2 it is used to indicate a specfic protocol for the stream.
+			
+			# @attribute [String | Array(String) | Nil] the request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`. In HTTP/1, it is used to request a connection upgrade, and in HTTP/2 it is used to indicate a specfic protocol for the stream.
 			attr_accessor :protocol
 			
+			# @attribute [Proc] a callback which is called when an interim response is received.
+			attr_accessor :interim_response
+			
+			# A request that is generated by a server, may choose to include the peer (address) associated with the request. It should be implemented by a sub-class.
+			#
+			# @returns [Peer | Nil] The peer (address) associated with the request.
+			def peer
+				nil
+			end
+			
 			# Send the request to the given connection.
 			def call(connection)
 				connection.call(self)
 			end
 			
+			# Send an interim response back to the origin of this request, if possible.
+			def send_interim_response(status, headers)
+				@interim_response&.call(status, headers)
+			end
+			
+			# Register a callback to be called when an interim response is received.
+			#
+			# @yields {|status, headers| ...} The callback to be called when an interim response is received.
+			# 	@parameter status [Integer] The HTTP status code, e.g. `100`, `101`, etc.
+			# 	@parameter headers [Hash] The headers, e.g. `{"link" => "</style.css>; rel=stylesheet"}`, etc.
+			def on_interim_response(&block)
+				if interim_response = @interim_response
+					@interim_response = ->(status, headers) do
+						block.call(status, headers)
+						interim_response.call(status, headers)
+					end
+				else
+					@interim_response = block
+				end
+			end
+			
+			# Whether this is a HEAD request: no body is expected in the response.
 			def head?
 				@method == Methods::HEAD
 			end
 			
+			# Whether this is a CONNECT request: typically used to establish a tunnel.
 			def connect?
 				@method == Methods::CONNECT
 			end
 			
-			def self.[](method, path, headers, body)
+			# A short-cut method which exposes the main request variables that you'd typically care about.
+			#
+			# @parameter method [String] The HTTP method, e.g. `"GET"`, `"POST"`, etc.
+			# @parameter path [String] The path, e.g. `"/index.html"`, `"/search?q=hello"`, etc.
+			# @parameter headers [Hash] The headers, e.g. `{"accept" => "text/html"}`, etc.
+			# @parameter body [String | Array(String) | Body::Readable] The body, e.g. `"Hello, World!"`, etc. See {Body::Buffered.wrap} for more information about .
+			def self.[](method, path = nil, _headers = nil, _body = nil, scheme: nil, authority: nil, headers: _headers, body: _body, protocol: nil, interim_response: nil)
+				path = path&.to_s
 				body = Body::Buffered.wrap(body)
-				headers = ::Protocol::HTTP::Headers[headers]
+				headers = Headers[headers]
 				
-				self.new(nil, nil, method, path, nil, headers, body)
+				self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response)
 			end
 			
+			# Whether the request can be replayed without side-effects.
 			def idempotent?
 				@method != Methods::POST && (@body.nil? || @body.empty?)
 			end
 			
+			# Convert the request to a hash, suitable for serialization.
+			#
+			# @returns [Hash] The request as a hash.
+			def as_json(...)
+				{
+					scheme: @scheme,
+					authority: @authority,
+					method: @method,
+					path: @path,
+					version: @version,
+					headers: @headers&.as_json,
+					body: @body&.as_json,
+					protocol: @protocol
+				}
+			end
+			
+			# Convert the request to JSON.
+			#
+			# @returns [String] The request as JSON.
+			def to_json(...)
+				as_json.to_json(...)
+			end
+			
+			# Summarize the request as a string.
+			#
+			# @returns [String] The request as a string.
 			def to_s
 				"#{@scheme}://#{@authority}: #{@method} #{@path} #{@version}"
 			end
diff -pruN 0.23.12-1/lib/protocol/http/response.rb 0.55.0-1/lib/protocol/http/response.rb
--- 0.23.12-1/lib/protocol/http/response.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/response.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,33 +1,35 @@
 # frozen_string_literal: true
 
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
 
-require_relative 'body/buffered'
-require_relative 'body/reader'
+require_relative "body/buffered"
+require_relative "body/reader"
+require_relative "headers"
 
 module Protocol
 	module HTTP
+		# Represents an HTTP response which can be used both server and client-side.
+		#
+		# ~~~ ruby
+		# require 'protocol/http'
+		# 
+		# # Long form:
+		# Protocol::HTTP::Response.new("http/1.1", 200, Protocol::HTTP::Headers[["content-type", "text/html"]], Protocol::HTTP::Body::Buffered.wrap("Hello, World!"))
+		# 
+		# # Short form:
+		# Protocol::HTTP::Response[200, {"content-type" => "text/html"}, ["Hello, World!"]]
+		# ~~~
 		class Response
 			prepend Body::Reader
 			
+			# Create a new response.
+			#
+			# @parameter version [String | Nil] The HTTP version, e.g. `"HTTP/1.1"`. If `nil`, the version may be provided by the server sending the response.
+			# @parameter status [Integer] The HTTP status code, e.g. `200`, `404`, etc.
+			# @parameter headers [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc.
+			# @parameter body [Body::Readable] The body, e.g. `"Hello, World!"`, etc.
+			# @parameter protocol [String | Array(String)] The protocol, e.g. `"websocket"`, etc.
 			def initialize(version = nil, status = 200, headers = Headers.new, body = nil, protocol = nil)
 				@version = version
 				@status = status
@@ -36,67 +38,150 @@ module Protocol
 				@protocol = protocol
 			end
 			
+			# @attribute [String | Nil] The HTTP version, usually one of `"HTTP/1.1"`, `"HTTP/2"`, etc.
 			attr_accessor :version
+			
+			# @attribute [Integer] The HTTP status code, e.g. `200`, `404`, etc.
 			attr_accessor :status
+			
+			# @attribute [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc.
 			attr_accessor :headers
+			
+			# @attribute [Body::Readable] The body, e.g. `"Hello, World!"`, etc.
 			attr_accessor :body
+			
+			# @attribute [String | Array(String) | Nil] The protocol, e.g. `"websocket"`, etc.
 			attr_accessor :protocol
 			
+			# A response that is generated by a client, may choose to include the peer (address) associated with the response. It should be implemented by a sub-class.
+			#
+			# @returns [Peer | Nil] The peer (address) associated with the response.
+			def peer
+				nil
+			end
+			
+			# Whether the response is considered a hijack: the connection has been taken over by the application and the server should not send any more data.
 			def hijack?
 				false
 			end
 			
+			# Whether the status is 100 (continue).
 			def continue?
 				@status == 100
 			end
 			
+			# Whether the status is considered informational.
+			def informational?
+				@status and @status >= 100 && @status < 200
+			end
+			
+			# Whether the status is considered final. Note that 101 is considered final.
+			def final?
+				# 101 is effectively a final status.
+				@status and @status >= 200 || @status == 101
+			end
+			
+			# Whether the status is 200 (ok).
+			def ok?
+				@status == 200
+			end
+			
+			# Whether the status is considered successful.
 			def success?
 				@status and @status >= 200 && @status < 300
 			end
 			
+			# Whether the status is 206 (partial content).
 			def partial?
 				@status == 206
 			end
 			
+			# Whether the status is considered a redirection.
 			def redirection?
 				@status and @status >= 300 && @status < 400
 			end
 			
+			# Whether the status is 304 (not modified).
 			def not_modified?
 				@status == 304
 			end
 			
+			# Whether the status is 307 (temporary redirect) and should preserve the method of the request when following the redirect.
 			def preserve_method?
 				@status == 307 || @status == 308
 			end
 			
+			# Whether the status is considered a failure.
 			def failure?
 				@status and @status >= 400 && @status < 600
 			end
 			
+			# Whether the status is 400 (bad request).
 			def bad_request?
 				@status == 400
 			end
 			
-			def server_failure?
+			# Whether the status is 500 (internal server error).
+			def internal_server_error?
 				@status == 500
 			end
 			
-			def self.[](status, headers = nil, body = nil, protocol = nil)
+			# @deprecated Use {#internal_server_error?} instead.
+			alias server_failure? internal_server_error?
+			
+			# A short-cut method which exposes the main response variables that you'd typically care about. It follows the same order as the `Rack` response tuple, but also includes the protocol.
+			#
+			# ~~~ ruby
+			# 	Response[200, {"content-type" => "text/html"}, ["Hello, World!"]]
+			# ~~~
+			#
+			# @parameter status [Integer] The HTTP status code, e.g. `200`, `404`, etc.
+			# @parameter headers [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc.
+			# @parameter body [String | Array(String) | Body::Readable] The body, e.g. `"Hello, World!"`, etc. See {Body::Buffered.wrap} for more information about .
+			def self.[](status, _headers = nil, _body = nil, headers: _headers, body: _body, protocol: nil)
 				body = Body::Buffered.wrap(body)
-				headers = ::Protocol::HTTP::Headers[headers]
+				headers = Headers[headers]
 				
 				self.new(nil, status, headers, body, protocol)
 			end
 			
+			# Create a response for the given exception.
+			#
+			# @parameter exception [Exception] The exception to generate the response for.
 			def self.for_exception(exception)
-				Response[500, Headers['content-type' => 'text/plain'], ["#{exception.class}: #{exception.message}"]]
+				Response[500, Headers["content-type" => "text/plain"], ["#{exception.class}: #{exception.message}"]]
 			end
 			
+			# Convert the response to a hash suitable for serialization.
+			#
+			# @returns [Hash] The response as a hash.
+			def as_json(...)
+				{
+					version: @version,
+					status: @status,
+					headers: @headers&.as_json,
+					body: @body&.as_json,
+					protocol: @protocol
+				}
+			end
+			
+			# Convert the response to JSON.
+			#
+			# @returns [String] The response as JSON.
+			def to_json(...)
+				as_json.to_json(...)
+			end
+			
+			# Summarise the response as a string.
+			#
+			# @returns [String] The response as a string.
 			def to_s
 				"#{@status} #{@version}"
 			end
 			
+			# Implicit conversion to an array.
+			#
+			# @returns [Array] The response as an array, e.g. `[status, headers, body]`.
 			def to_ary
 				return @status, @headers, @body
 			end
diff -pruN 0.23.12-1/lib/protocol/http/url.rb 0.55.0-1/lib/protocol/http/url.rb
--- 0.23.12-1/lib/protocol/http/url.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/url.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,129 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-module Protocol
-	module HTTP
-		module URL
-			# Escapes a string using percent encoding.
-			def self.escape(string, encoding = string.encoding)
-				string.b.gsub(/([^a-zA-Z0-9_.\-]+)/) do |m|
-					'%' + m.unpack('H2' * m.bytesize).join('%').upcase
-				end.force_encoding(encoding)
-			end
-			
-			# Unescapes a percent encoded string.
-			def self.unescape(string, encoding = string.encoding)
-				string.b.gsub(/%(\h\h)/) do |hex|
-					Integer($1, 16).chr
-				end.force_encoding(encoding)
-			end
-			
-			# According to https://tools.ietf.org/html/rfc3986#section-3.3, we escape non-pchar.
-			NON_PCHAR = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze
-			
-			# Escapes non-path characters using percent encoding.
-			def self.escape_path(path)
-				encoding = path.encoding
-				path.b.gsub(NON_PCHAR) do |m|
-					'%' + m.unpack('H2' * m.bytesize).join('%').upcase
-				end.force_encoding(encoding)
-			end
-			
-			# Encodes a hash or array into a query string.
-			def self.encode(value, prefix = nil)
-				case value
-				when Array
-					return value.map {|v|
-						self.encode(v, "#{prefix}[]")
-					}.join("&")
-				when Hash
-					return value.map {|k, v|
-						self.encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
-					}.reject(&:empty?).join('&')
-				when nil
-					return prefix
-				else
-					raise ArgumentError, "value must be a Hash" if prefix.nil?
-					
-					return "#{prefix}=#{escape(value.to_s)}"
-				end
-			end
-			
-			# Scan a string for URL-encoded key/value pairs.
-			# @yields {|key, value| ...}
-			# 	@parameter key [String] The unescaped key.
-			# 	@parameter value [String] The unescaped key.
-			def self.scan(string)
-				string.split('&') do |assignment|
-					key, value = assignment.split('=', 2)
-					
-					yield unescape(key), unescape(value)
-				end
-			end
-			
-			def self.split(name)
-				name.scan(/([^\[]+)|(?:\[(.*?)\])/).flatten!.compact!
-			end
-			
-			def self.assign(keys, value, parent)
-				top, *middle = keys
-				
-				middle.each_with_index do |key, index|
-					if key.nil? or key.empty?
-						parent = (parent[top] ||= Array.new)
-						top = parent.size
-						
-						if nested = middle[index+1] and last = parent.last
-							top -= 1 unless last.include?(nested)
-						end
-					else
-						parent = (parent[top] ||= Hash.new)
-						top = key
-					end
-				end
-				
-				parent[top] = value
-			end
-			
-			# TODO use native C extension from `Trenni::Reference`.
-			def self.decode(string, maximum = 8, symbolize_keys: false)
-				parameters = {}
-				
-				self.scan(string) do |name, value|
-					keys = self.split(name)
-					
-					if keys.size > maximum
-						raise ArgumentError, "Key length exceeded limit!"
-					end
-					
-					if symbolize_keys
-						keys.collect!{|key| key.empty? ? nil : key.to_sym}
-					end
-					
-					self.assign(keys, value, parameters)
-				end
-				
-				return parameters
-			end
-		end
-	end
-end
diff -pruN 0.23.12-1/lib/protocol/http/version.rb 0.55.0-1/lib/protocol/http/version.rb
--- 0.23.12-1/lib/protocol/http/version.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http/version.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,27 +1,10 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
 
 module Protocol
 	module HTTP
-		VERSION = "0.23.12"
+		VERSION = "0.55.0"
 	end
 end
diff -pruN 0.23.12-1/lib/protocol/http.rb 0.55.0-1/lib/protocol/http.rb
--- 0.23.12-1/lib/protocol/http.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/lib/protocol/http.rb	2025-10-23 12:07:18.000000000 +0000
@@ -1,23 +1,18 @@
 # frozen_string_literal: true
 
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
+# Released under the MIT License.
+# Copyright, 2018-2024, by Samuel Williams.
 
 require_relative "http/version"
+
+require_relative "http/headers"
+require_relative "http/request"
+require_relative "http/response"
+require_relative "http/middleware"
+
+# @namespace
+module Protocol
+	# @namespace
+	module HTTP
+	end
+end
diff -pruN 0.23.12-1/license.md 0.55.0-1/license.md
--- 0.23.12-1/license.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/license.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,32 @@
+# MIT License
+
+Copyright, 2018-2025, by Samuel Williams.  
+Copyright, 2019, by Yuta Iwama.  
+Copyright, 2020, by Olle Jonsson.  
+Copyright, 2020, by Bryan Powell.  
+Copyright, 2020-2023, by Bruno Sutic.  
+Copyright, 2022, by Herrick Fang.  
+Copyright, 2022, by Dan Olson.  
+Copyright, 2023, by Genki Takiuchi.  
+Copyright, 2023-2024, by Thomas Morgan.  
+Copyright, 2023, by Marcelo Junior.  
+Copyright, 2024, by Earlopain.  
+Copyright, 2025, by William T. Nelson.  
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff -pruN 0.23.12-1/protocol-http.gemspec 0.55.0-1/protocol-http.gemspec
--- 0.23.12-1/protocol-http.gemspec	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/protocol-http.gemspec	2025-10-23 12:07:18.000000000 +0000
@@ -7,19 +7,20 @@ Gem::Specification.new do |spec|
 	spec.version = Protocol::HTTP::VERSION
 	
 	spec.summary = "Provides abstractions to handle HTTP protocols."
-	spec.authors = ["Samuel Williams", "Bruno Sutic", "Bryan Powell", "Olle Jonsson", "Yuta Iwama"]
+	spec.authors = ["Samuel Williams", "Thomas Morgan", "Bruno Sutic", "Herrick Fang", "William T. Nelson", "Bryan Powell", "Dan Olson", "Earlopain", "Genki Takiuchi", "Marcelo Junior", "Olle Jonsson", "Yuta Iwama"]
 	spec.license = "MIT"
 	
-	spec.cert_chain  = ['release.cert']
-	spec.signing_key = File.expand_path('~/.gem/release.pem')
+	spec.cert_chain  = ["release.cert"]
+	spec.signing_key = File.expand_path("~/.gem/release.pem")
 	
 	spec.homepage = "https://github.com/socketry/protocol-http"
 	
-	spec.files = Dir.glob('{lib}/**/*', File::FNM_DOTMATCH, base: __dir__)
+	spec.metadata = {
+		"documentation_uri" => "https://socketry.github.io/protocol-http/",
+		"source_code_uri" => "https://github.com/socketry/protocol-http.git",
+	}
 	
-	spec.required_ruby_version = ">= 2.5"
+	spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__)
 	
-	spec.add_development_dependency "bundler"
-	spec.add_development_dependency "covered"
-	spec.add_development_dependency "rspec"
+	spec.required_ruby_version = ">= 3.2"
 end
diff -pruN 0.23.12-1/readme.md 0.55.0-1/readme.md
--- 0.23.12-1/readme.md	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/readme.md	2025-10-23 12:07:18.000000000 +0000
@@ -8,11 +8,90 @@ Provides abstractions for working with t
 
   - General abstractions for HTTP requests and responses.
   - Symmetrical interfaces for client and server.
-  - Light-weight middlewar model for building applications.
+  - Light-weight middleware model for building applications.
 
 ## Usage
 
-Please see the [project documentation](https://socketry.github.io/protocol-http).
+Please see the [project documentation](https://socketry.github.io/protocol-http/) for more details.
+
+  - [Getting Started](https://socketry.github.io/protocol-http/guides/getting-started/index) - This guide explains how to use `protocol-http` for building abstract HTTP interfaces.
+
+  - [Message Body](https://socketry.github.io/protocol-http/guides/message-body/index) - This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes.
+
+  - [Headers](https://socketry.github.io/protocol-http/guides/headers/index) - This guide explains how to work with HTTP headers using `protocol-http`.
+
+  - [Middleware](https://socketry.github.io/protocol-http/guides/middleware/index) - This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
+
+  - [Streaming](https://socketry.github.io/protocol-http/guides/streaming/index) - This guide gives an overview of how to implement streaming requests and responses.
+
+  - [Design Overview](https://socketry.github.io/protocol-http/guides/design-overview/index) - This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers.
+
+## Releases
+
+Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases.
+
+### v0.55.0
+
+  - **Breaking**: Move `Protocol::HTTP::Header::QuotedString` to `Protocol::HTTP::QuotedString` for better reusability.
+  - **Breaking**: Handle cookie key/value pairs using `QuotedString` as per RFC 6265.
+      - Don't use URL encoding for cookie key/value.
+  - **Breaking**: Remove `Protocol::HTTP::URL` and `Protocol::HTTP::Reference` – replaced by `Protocol::URL` gem.
+      - `Protocol::HTTP::URL` -\> `Protocol::URL::Encoding`.
+      - `Protocol::HTTP::Reference` -\> `Protocol::URL::Reference`.
+
+### v0.54.0
+
+  - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`.
+  - [Improved HTTP Trailer Security](https://socketry.github.io/protocol-http/releases/index#improved-http-trailer-security)
+
+### v0.53.0
+
+  - Improve consistency of Body `#inspect`.
+  - Improve `as_json` support for Body wrappers.
+
+### v0.52.0
+
+  - Add `Protocol::HTTP::Headers#to_a` method that returns the fields array, providing compatibility with standard Ruby array conversion pattern.
+  - Expose `tail` in `Headers.new` so that trailers can be accurately reproduced.
+  - Add agent context.
+
+### v0.51.0
+
+  - `Protocol::HTTP::Headers` now raise a `DuplicateHeaderError` when a duplicate singleton header (e.g. `content-length`) is added.
+  - `Protocol::HTTP::Headers#add` now coerces the value to a string when adding a header, ensuring consistent behaviour.
+  - `Protocol::HTTP::Body::Head.for` now accepts an optional `length` parameter, allowing it to create a head body even when the body is not provided, based on the known content length.
+
+### v0.50.0
+
+    - Drop support for Ruby v3.1.
+
+### v0.48.0
+
+  - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values.
+
+### v0.46.0
+
+  - Add support for `priority:` header.
+
+### v0.33.0
+
+  - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`.
+  - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`.
+
+### v0.31.0
+
+  - Ensure chunks are flushed if required, when streaming.
+
+## See Also
+
+  - [protocol-http1](https://github.com/socketry/protocol-http1) — HTTP/1 client/server implementation using this
+    interface.
+  - [protocol-http2](https://github.com/socketry/protocol-http2) — HTTP/2 client/server implementation using this
+    interface.
+  - [protocol-url](https://github.com/socketry/protocol-url) — URL parsing and manipulation library.
+  - [async-http](https://github.com/socketry/async-http) — Asynchronous HTTP client and server, supporting multiple HTTP
+    protocols & TLS.
+  - [async-websocket](https://github.com/socketry/async-websocket) — Asynchronous client and server WebSockets.
 
 ## Contributing
 
@@ -24,33 +103,10 @@ We welcome contributions to this project
 4.  Push to the branch (`git push origin my-new-feature`).
 5.  Create new Pull Request.
 
-## See Also
-
-  - [protocol-http1](https://github.com/socketry/protocol-http1) — HTTP/1 client/server implementation using this interface.
-  - [protocol-http2](https://github.com/socketry/protocol-http2) — HTTP/2 client/server implementation using this interface.
-  - [async-http](https://github.com/socketry/async-http) — Asynchronous HTTP client and server, supporting multiple HTTP protocols & TLS.
-  - [async-websocket](https://github.com/socketry/async-websocket) — Asynchronous client and server WebSockets.
-
-## License
+### Developer Certificate of Origin
 
-Released under the MIT license.
+In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
 
-Copyright, 2019, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams).
+### Community Guidelines
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
diff -pruN 0.23.12-1/releases.md 0.55.0-1/releases.md
--- 0.23.12-1/releases.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/releases.md	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,293 @@
+# Releases
+
+## v0.55.0
+
+  - **Breaking**: Move `Protocol::HTTP::Header::QuotedString` to `Protocol::HTTP::QuotedString` for better reusability.
+  - **Breaking**: Handle cookie key/value pairs using `QuotedString` as per RFC 6265.
+      - Don't use URL encoding for cookie key/value.
+  - **Breaking**: Remove `Protocol::HTTP::URL` and `Protocol::HTTP::Reference` – replaced by `Protocol::URL` gem.
+      - `Protocol::HTTP::URL` -\> `Protocol::URL::Encoding`.
+      - `Protocol::HTTP::Reference` -\> `Protocol::URL::Reference`.
+
+## v0.54.0
+
+  - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`.
+
+### Improved HTTP Trailer Security
+
+This release introduces significant security improvements for HTTP trailer handling, addressing potential HTTP request smuggling vulnerabilities by implementing a restrictive-by-default policy for trailer headers.
+
+  - **Security-by-default**: HTTP trailers are now validated and restricted by default to prevent HTTP request smuggling attacks.
+  - Only safe headers are permitted in trailers:
+      - `date` - Response generation timestamps (safe metadata)
+      - `digest` - Content integrity verification (safe metadata)
+      - `etag` - Cache validation tags (safe metadata)
+      - `server-timing` - Performance metrics (safe metadata)
+  - All other trailers are ignored by default.
+
+If you are using this library for gRPC, you will need to use a custom policy to allow the `grpc-status` and `grpc-message` trailers:
+
+``` ruby
+module GRPCStatus
+	def self.new(value)
+		Integer(value)
+	end
+	
+	def self.trailer?
+		true
+	end
+end
+
+module GRPCMessage
+	def self.new(value)
+		value
+	end
+	
+	def self.trailer?
+		true
+	end
+end
+
+GRPC_POLICY = Protocol::HTTP::Headers::POLICY.dup
+GRPC_POLICY['grpc-status'] = GRPCStatus
+GRPC_POLICY['grpc-message'] = GRPCMessage
+
+# Reinterpret the headers using the new policy:
+response.headers.policy = GRPC_POLICY
+response.headers['grpc-status'] # => 0
+response.headers['grpc-message'] # => "OK"
+```
+
+## v0.53.0
+
+  - Improve consistency of Body `#inspect`.
+  - Improve `as_json` support for Body wrappers.
+
+## v0.52.0
+
+  - Add `Protocol::HTTP::Headers#to_a` method that returns the fields array, providing compatibility with standard Ruby array conversion pattern.
+  - Expose `tail` in `Headers.new` so that trailers can be accurately reproduced.
+  - Add agent context.
+
+## v0.51.0
+
+  - `Protocol::HTTP::Headers` now raise a `DuplicateHeaderError` when a duplicate singleton header (e.g. `content-length`) is added.
+  - `Protocol::HTTP::Headers#add` now coerces the value to a string when adding a header, ensuring consistent behaviour.
+  - `Protocol::HTTP::Body::Head.for` now accepts an optional `length` parameter, allowing it to create a head body even when the body is not provided, based on the known content length.
+
+## v0.50.0
+
+    - Drop support for Ruby v3.1.
+
+## v0.48.0
+
+  - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values.
+
+## v0.46.0
+
+  - Add support for `priority:` header.
+
+## v0.33.0
+
+  - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`.
+  - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`.
+
+## v0.31.0
+
+  - Ensure chunks are flushed if required, when streaming.
+
+## v0.30.0
+
+### `Request[]` and `Response[]` Keyword Arguments
+
+The `Request[]` and `Response[]` methods now support keyword arguments as a convenient way to set various positional arguments.
+
+``` ruby
+# Request keyword arguments:
+client.get("/", headers: {"accept" => "text/html"}, authority: "example.com")
+
+# Response keyword arguments:
+def call(request)
+	return Response[200, headers: {"content-Type" => "text/html"}, body: "Hello, World!"]
+```
+
+### Interim Response Handling
+
+The `Request` class now exposes a `#interim_response` attribute which can be used to handle interim responses both on the client side and server side.
+
+On the client side, you can pass a callback using the `interim_response` keyword argument which will be invoked whenever an interim response is received:
+
+``` ruby
+client = ...
+response = client.get("/index", interim_response: proc{|status, headers| ...})
+```
+
+On the server side, you can send an interim response using the `#send_interim_response` method:
+
+``` ruby
+def call(request)
+	if request.headers["expect"] == "100-continue"
+		# Send an interim response:
+		request.send_interim_response(100)
+	end
+	
+	# ...
+end
+```
+
+## v0.29.0
+
+  - Introduce `rewind` and `rewindable?` methods for body rewinding capabilities.
+  - Add support for output buffer in `read_partial`/`readpartial` methods.
+  - `Reader#buffered!` now returns `self` for method chaining.
+
+## v0.28.0
+
+  - Add convenient `Reader#buffered!` method to buffer the body.
+  - Modernize gem infrastructure with RuboCop integration.
+
+## v0.27.0
+
+  - Expand stream interface to support `gets`/`puts` operations.
+  - Skip empty key/value pairs in header processing.
+  - Prefer lowercase method names for consistency.
+  - Add `as_json` support to avoid default Rails implementation.
+  - Use `@callback` to track invocation state.
+  - Drop `base64` gem dependency.
+
+## v0.26.0
+
+  - Prefer connection `close` over `keep-alive` when both are present.
+  - Add support for `#readpartial` method.
+  - Add `base64` dependency.
+
+## v0.25.0
+
+  - Introduce explicit support for informational responses (1xx status codes).
+  - Add `cache-control` support for `must-revalidate`, `proxy-revalidate`, and `s-maxage` directives.
+  - Add `#strong_match?` and `#weak_match?` methods to `ETags` header.
+  - Fix `last-modified`, `if-modified-since` and `if-unmodified-since` headers to use proper `Date` parsing.
+  - Improve date/expires header parsing.
+  - Add tests for `Stream#close_read`.
+  - Check if input is closed before raising `IOError`.
+  - Ensure saved files truncate existing file by default.
+
+## v0.24.0
+
+  - Add output stream `#<<` as alias for `#write`.
+  - Add support for `Headers#include?` and `#key?` methods.
+  - Fix URL unescape functionality.
+  - Fix cookie parsing issues.
+  - Fix superclass mismatch in `Protocol::HTTP::Middleware::Builder`.
+  - Allow trailers without explicit `trailer` header.
+  - Fix cookie handling and Ruby 2 keyword arguments.
+
+## v0.23.0
+
+  - Improve argument handling.
+  - Rename `path` parameter to `target` to better match RFCs.
+
+## v0.22.0
+
+  - Rename `trailers` to `trailer` for consistency.
+
+## v0.21.0
+
+  - Streaming interface improvements.
+  - Rename `Streamable` to `Completable`.
+
+## v0.20.0
+
+  - Improve `Authorization` header implementation.
+
+## v0.19.0
+
+  - Expose `Body#ready?` for more efficient response handling.
+
+## v0.18.0
+
+  - Add `#trailers` method which enumerates trailers without marking tail.
+  - Don't clear trailers in `#dup`, move functionality to `flatten!`.
+  - All requests and responses must have mutable headers instance.
+
+## v0.17.0
+
+  - Remove deferred headers due to complexity.
+  - Remove deprecated `Headers#slice!`.
+  - Add support for static, dynamic and streaming content to `cache-control` model.
+  - Initial support for trailers.
+  - Add support for `Response#not_modified?`.
+
+## v0.16.0
+
+  - Add support for `if-match` and `if-none-match` headers.
+  - Revert `Request#target` change for HTTP/2 compatibility.
+
+## v0.15.0
+
+  - Prefer `Request#target` over `Request#path`.
+  - Add body implementation to support HEAD requests.
+  - Add support for computing digest on buffered body.
+  - Add `Headers#set(key, value)` to replace existing values.
+  - Add support for `vary` header.
+  - Add support for `no-cache` & `no-store` cache directives.
+
+## v0.14.0
+
+  - Add `Cacheable` body for buffering and caching responses.
+  - Add support for `cache-control` header.
+
+## v0.13.0
+
+  - Add support for `connection` header.
+  - Fix handling of keyword arguments.
+
+## v0.12.0
+
+  - Improved handling of `cookie` header.
+  - Add `Headers#clear` method.
+
+## v0.11.0
+
+  - Ensure `Body#call` invokes `stream.close` when done.
+
+## v0.10.0
+
+  - Allow user to specify size for character devices.
+
+## v0.9.1
+
+  - Add support for `authorization` header.
+
+## v0.8.0
+
+  - Remove `reason` from `Response`.
+
+## v0.7.0
+
+  - Explicit path handling in `Reference#with`.
+
+## v0.6.0
+
+  - Initial version with basic HTTP protocol support.
+
+## v0.5.1
+
+  - Fix path splitting behavior when path is empty.
+  - Add `connect` method.
+  - Support protocol in `[]` constructor.
+  - Incorporate middleware functionality.
+
+## v0.4.0
+
+  - Add `Request`, `Response` and `Body` classes from `async-http`.
+  - Allow deletion of non-existent header fields.
+
+## v0.3.0
+
+  - **Initial release** of `protocol-http` gem.
+  - Initial implementation of HTTP/2 flow control.
+  - Support for connection preface and settings frames.
+  - Initial headers support.
+  - Implementation of `Connection`, `Client` & `Server` classes.
+  - HTTP/2 protocol framing and headers.
diff -pruN 0.23.12-1/spec/protocol/http/body/buffered_spec.rb 0.55.0-1/spec/protocol/http/body/buffered_spec.rb
--- 0.23.12-1/spec/protocol/http/body/buffered_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/buffered_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/buffered'
-
-RSpec.describe Protocol::HTTP::Body::Buffered do
-	include_context RSpec::Memory
-	
-	let(:body) {["Hello", "World"]}
-	subject! {described_class.wrap(body)}
-	
-	describe ".wrap" do
-		context "when body is a Body::Readable" do
-			let(:body) {Protocol::HTTP::Body::Readable.new}
-			
-			it "returns the body" do
-				expect(subject).to be == body
-			end
-		end
-		
-		context "when body is an Array" do
-			let(:body) {["Hello", "World"]}
-			
-			it "returns instance initialized with the array" do
-				expect(subject).to be_an_instance_of(described_class)
-			end
-		end
-		
-		context "when body responds to #each" do
-			let(:body) {["Hello", "World"].each}
-			
-			it "buffers the content into an array before initializing" do
-				expect(subject).to be_an_instance_of(described_class)
-				allow(body).to receive(:each).and_raise(StopIteration)
-				expect(subject.read).to be == "Hello"
-				expect(subject.read).to be == "World"
-			end
-		end
-
-		context "when body is a String" do
-			let(:body) {"Hello World"}
-
-			it "returns instance initialized with the array" do
-				expect(subject).to be_an_instance_of(described_class)
-			end
-		end
-	end
-	
-	describe "#length" do
-		it "returns sum of chunks' bytesize" do
-			expect(subject.length).to be == 10
-		end
-	end
-	
-	describe "#empty?" do
-		it "returns false when there are chunks left" do
-			expect(subject.empty?).to be == false
-			subject.read
-			expect(subject.empty?).to be == false
-		end
-		
-		it "returns true when there are no chunks left" do
-			subject.read
-			subject.read
-			expect(subject.empty?).to be == true
-		end
-		
-		it "returns false when rewinded" do
-			subject.read
-			subject.read
-			subject.rewind
-			expect(subject.empty?).to be == false
-		end
-	end
-	
-	describe '#ready?' do
-		it {is_expected.to be_ready}
-	end
-	
-	describe "#finish" do
-		it "returns self" do
-			expect(subject.finish).to be == subject
-		end
-	end
-	
-	describe "#read" do
-		it "retrieves chunks of content" do
-			expect(subject.read).to be == "Hello"
-			expect(subject.read).to be == "World"
-			expect(subject.read).to be == nil
-		end
-		
-		context "with large content" do
-			let(:content) {Array.new(5) {|i| "#{i}" * (1*1024*1024)}}
-			
-			it "allocates expected amount of memory" do
-				expect do
-					subject.read until subject.empty?
-				end.to limit_allocations(size: 0)
-			end
-		end
-	end
-	
-	describe "#rewind" do
-		it "positions the cursor to the beginning" do
-			expect(subject.read).to be == "Hello"
-			subject.rewind
-			expect(subject.read).to be == "Hello"
-		end
-	end
-
-	describe '#inspect' do
-		it "can be inspected" do
-			expect(subject.inspect).to be =~ /\d+ chunks, \d+ bytes/
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/completable_spec.rb 0.55.0-1/spec/protocol/http/body/completable_spec.rb
--- 0.23.12-1/spec/protocol/http/body/completable_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/completable_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/completable'
-
-RSpec.describe Protocol::HTTP::Body::Completable do
-	let(:source) {Protocol::HTTP::Body::Buffered.new}
-	let(:callback) {double}
-	subject {described_class.new(source, callback)}
-	
-	it "can trigger callback when finished reading" do
-		expect(callback).to receive(:call)
-		
-		expect(subject.read).to be_nil
-		
-		subject.close
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/deflate_spec.rb 0.55.0-1/spec/protocol/http/body/deflate_spec.rb
--- 0.23.12-1/spec/protocol/http/body/deflate_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/deflate_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,59 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-# Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/buffered'
-require 'protocol/http/body/deflate'
-require 'protocol/http/body/inflate'
-
-RSpec.describe Protocol::HTTP::Body::Deflate do
-	let(:body) {Protocol::HTTP::Body::Buffered.new}
-	let(:compressed_body) {Protocol::HTTP::Body::Deflate.for(body)}
-	let(:decompressed_body) {Protocol::HTTP::Body::Inflate.for(compressed_body)}
-	
-	it "should round-trip data" do
-		body.write("Hello World!")
-		body.close
-		
-		expect(decompressed_body.join).to be == "Hello World!"
-	end
-	
-	it "should read chunks" do
-		body.write("Hello ")
-		body.write("World!")
-		body.close
-		
-		expect(body.read).to be == "Hello "
-		expect(body.read).to be == "World!"
-		expect(body.read).to be == nil
-	end
-	
-	it "should round-trip chunks" do
-		body.write("Hello ")
-		body.write("World!")
-		body.close
-		
-		expect(decompressed_body.read).to be == "Hello "
-		expect(decompressed_body.read).to be == "World!"
-		expect(decompressed_body.read).to be == nil
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/digestable_spec.rb 0.55.0-1/spec/protocol/http/body/digestable_spec.rb
--- 0.23.12-1/spec/protocol/http/body/digestable_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/digestable_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/digestable'
-
-RSpec.describe Protocol::HTTP::Body::Digestable do
-	let(:source) {Protocol::HTTP::Body::Buffered.new}
-	subject {described_class.new(source)}
-	
-	describe '#digest' do
-		before do
-			source.write "Hello"
-			source.write "World"
-		end
-		
-		it "can compute digest" do
-			2.times {subject.read}
-			
-			expect(subject.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"
-		end
-		
-		it "can recompute digest" do
-			expect(subject.read).to be == "Hello"
-			expect(subject.digest).to be == "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969"
-			
-			expect(subject.read).to be == "World"
-			expect(subject.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/file_spec.rb 0.55.0-1/spec/protocol/http/body/file_spec.rb
--- 0.23.12-1/spec/protocol/http/body/file_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/file_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/file'
-
-RSpec.describe Protocol::HTTP::Body::File do
-	let(:path) {File.expand_path('file_spec.txt', __dir__)}
-	
-	context 'entire file' do
-		subject {described_class.open(path)}
-		
-		it "should read entire file" do
-			expect(subject.read).to be == "Hello World"
-		end
-		
-		it "should use binary encoding" do
-			expect(::File).to receive(:open).with(path, ::File::RDONLY | ::File::BINARY).and_call_original
-			
-			chunk = subject.read
-			
-			expect(chunk.encoding).to be == Encoding::BINARY
-		end
-		
-		describe '#ready?' do
-			it {is_expected.to be_ready}
-		end
-	end
-	
-	context 'partial file' do
-		subject {described_class.open(path, 2...4)}
-		
-		it "should read specified range" do
-			expect(subject.read).to be == "ll"
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/file_spec.txt 0.55.0-1/spec/protocol/http/body/file_spec.txt
--- 0.23.12-1/spec/protocol/http/body/file_spec.txt	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/file_spec.txt	1970-01-01 00:00:00.000000000 +0000
@@ -1 +0,0 @@
-Hello World
\ No newline at end of file
diff -pruN 0.23.12-1/spec/protocol/http/body/head_spec.rb 0.55.0-1/spec/protocol/http/body/head_spec.rb
--- 0.23.12-1/spec/protocol/http/body/head_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/head_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/head'
-
-RSpec.describe Protocol::HTTP::Body::Head do
-	context "with zero length" do
-		subject(:body) {described_class.new(0)}
-		
-		it {is_expected.to be_empty}
-		
-		describe '#join' do
-			subject {body.join}
-			
-			it {is_expected.to be_nil}
-		end
-	end
-	
-	context "with non-zero length" do
-		subject(:body) {described_class.new(1)}
-		
-		it {is_expected.to be_empty}
-		
-		describe '#read' do
-			subject {body.read}
-			it {is_expected.to be_nil}
-		end
-		
-		describe '#join' do
-			subject {body.join}
-			
-			it {is_expected.to be_nil}
-		end
-	end
-	
-	describe '.for' do
-		let(:body) {double}
-		subject {described_class.for(body)}
-		
-		it "captures length and closes existing body" do
-			expect(body).to receive(:length).and_return(1)
-			expect(body).to receive(:close)
-			
-			expect(subject).to have_attributes(length: 1)
-			
-			subject.close
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/rewindable_spec.rb 0.55.0-1/spec/protocol/http/body/rewindable_spec.rb
--- 0.23.12-1/spec/protocol/http/body/rewindable_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/rewindable_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,100 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/rewindable'
-
-RSpec.describe Protocol::HTTP::Body::Rewindable do
-	let(:source) {Protocol::HTTP::Body::Buffered.new}
-	subject {described_class.new(source)}
-	
-	it "can write and read data" do
-		3.times do |i|
-			source.write("Hello World #{i}")
-			expect(subject.read).to be == "Hello World #{i}"
-		end
-	end
-	
-	it "can write and read data multiple times" do
-		3.times do |i|
-			source.write("Hello World #{i}")
-		end
-		
-		3.times do
-			subject.rewind
-			
-			expect(subject.read).to be == "Hello World 0"
-		end
-	end
-	
-	it "can buffer data in order" do
-		3.times do |i|
-			source.write("Hello World #{i}")
-		end
-		
-		2.times do
-			subject.rewind
-			
-			3.times do |i|
-				expect(subject.read).to be == "Hello World #{i}"
-			end
-		end
-	end
-	
-	describe '#empty?' do
-		it {is_expected.to be_empty}
-		
-		context "with unread chunk" do
-			before {source.write("Hello World")}
-			it {is_expected.to_not be_empty}
-		end
-		
-		context "with read chunk" do
-			before do
-				source.write("Hello World")
-				expect(subject.read).to be == "Hello World"
-			end
-			
-			it {is_expected.to be_empty}
-		end
-		
-		context "with rewound chunk" do
-			before do
-				source.write("Hello World")
-				expect(subject.read).to be == "Hello World"
-				subject.rewind
-			end
-			
-			it {is_expected.to_not be_empty}
-		end
-		
-		context "with rewound chunk" do
-			before do
-				source.write("Hello World")
-				expect(subject.read).to be == "Hello World"
-				subject.rewind
-				expect(subject.read).to be == "Hello World"
-			end
-			
-			it {is_expected.to be_empty}
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/body/stream_spec.rb 0.55.0-1/spec/protocol/http/body/stream_spec.rb
--- 0.23.12-1/spec/protocol/http/body/stream_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/body/stream_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/body/stream'
-require 'protocol/http/body/buffered'
-
-RSpec.describe Protocol::HTTP::Body::Stream do
-	let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello", "World"])}
-	let(:output) {Protocol::HTTP::Body::Buffered.new}
-	subject {described_class.new(input, output)}
-
-	describe "#read" do
-		it "should read from the input" do
-			expect(subject.read(5)).to be == "Hello"
-		end
-
-		it "can handle zero-length read" do
-			expect(subject.read(0)).to be == ""
-		end
-
-		it "can read the entire input" do
-			expect(subject.read).to be == "HelloWorld"
-		end
-
-		it "should read from the input into the given buffer" do
-			buffer = String.new
-			expect(subject.read(5, buffer)).to be == "Hello"
-			expect(buffer).to be == "Hello"
-			expect(subject.read(5, buffer)).to be == "World"
-			expect(buffer).to be == "World"
-			expect(subject.read(5, buffer)).to be nil
-			expect(buffer).to be == ""
-		end
-		
-		it "can read partial input" do
-			expect(subject.read(2)).to be == "He"
-			expect(subject.read(2)).to be == "ll"
-			expect(subject.read(2)).to be == "oW"
-			expect(subject.read(2)).to be == "or"
-			expect(subject.read(2)).to be == "ld"
-			expect(subject.read(2)).to be == nil
-		end
-		
-		it "can read partial input into the given buffer" do
-			buffer = String.new
-			expect(subject.read(100, buffer)).to be == "HelloWorld"
-			expect(buffer).to be == "HelloWorld"
-			
-			expect(subject.read(2, buffer)).to be == nil
-			expect(buffer).to be == ""
-		end
-	end
-
-	describe "#read_nonblock" do
-		it "should read from the input" do
-			expect(subject.read_nonblock(5)).to be == "Hello"
-			expect(subject.read_nonblock(5)).to be == "World"
-			expect(subject.read_nonblock(5)).to be == nil
-		end
-
-		it "should read from the input into the given buffer" do
-			buffer = String.new
-			expect(subject.read_nonblock(5, buffer)).to be == "Hello"
-			expect(buffer).to be == "Hello"
-			expect(subject.read_nonblock(5, buffer)).to be == "World"
-			expect(buffer).to be == "World"
-			expect(subject.read_nonblock(5, buffer)).to be nil
-			expect(buffer).to be == ""
-		end
-		
-		it "can read partial input into the given buffer" do
-			buffer = String.new
-			expect(subject.read_nonblock(100, buffer)).to be == "Hello"
-			expect(buffer).to be == "Hello"
-			
-			expect(subject.read_nonblock(100, buffer)).to be == "World"
-			expect(buffer).to be == "World"
-			
-			expect(subject.read_nonblock(2, buffer)).to be == nil
-			expect(buffer).to be == ""
-		end
-	end
-
-	describe '#close_read' do
-		it "should close the input" do
-			subject.close_read
-			expect{subject.read(5)}.to raise_error(IOError)
-		end
-	end
-
-	describe "#write" do
-		it "should write to the output" do
-			expect(subject.write("Hello")).to be == 5
-			expect(subject.write("World")).to be == 5
-
-			expect(output.chunks).to be == ["Hello", "World"]
-		end
-	end
-	
-	describe '#<<' do
-		it "should write to the output" do
-			subject << "Hello"
-			subject << "World"
-			
-			expect(output.chunks).to be == ["Hello", "World"]
-		end
-	end
-	
-	describe "#write_nonblock" do
-		it "should write to the output" do
-			subject.write_nonblock("Hello")
-			subject.write_nonblock("World")
-			
-			expect(output.chunks).to be == ["Hello", "World"]
-		end
-	end
-
-	describe '#close_write' do
-		it "should close the input" do
-			subject.close_write
-			expect{subject.write("X")}.to raise_error(IOError)
-		end
-	end
-
-	describe '#flush' do
-		it "can be flushed" do	
-			# For streams, this is a no-op since buffering is handled by the output body.
-			subject.flush
-		end
-	end
-
-	describe '#close' do
-		it "can can be closed" do
-			subject.close
-			expect(subject).to be_closed
-		end
-
-		it "can be closed multiple times" do
-			subject.close
-			subject.close
-			expect(subject).to be_closed
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/content_encoding_spec.rb 0.55.0-1/spec/protocol/http/content_encoding_spec.rb
--- 0.23.12-1/spec/protocol/http/content_encoding_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/content_encoding_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,96 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/accept_encoding'
-require 'protocol/http/content_encoding'
-
-RSpec.describe Protocol::HTTP::ContentEncoding do
-	context 'with complete text/plain response' do
-		subject do
-			described_class.new(Protocol::HTTP::Middleware::HelloWorld)
-		end
-		
-		it "can request resource with compression" do
-			compressor = Protocol::HTTP::AcceptEncoding.new(subject)
-			
-			response = compressor.get("/index", {'accept-encoding' => 'gzip'})
-			expect(response).to be_success
-			
-			expect(response.headers['vary']).to include('accept-encoding')
-			
-			expect(response.body).to be_kind_of Protocol::HTTP::Body::Inflate
-			expect(response.read).to be == "Hello World!"
-		end
-		
-		it "can request resource without compression" do
-			response = subject.get("/index")
-			
-			expect(response).to be_success
-			expect(response.headers).to_not include('content-encoding')
-			expect(response.headers['vary']).to include('accept-encoding')
-			
-			expect(response.read).to be == "Hello World!"
-		end
-	end
-	
-	context 'with partial response' do
-		let(:app) do
-			app = ->(request){
-				Protocol::HTTP::Response[206, Protocol::HTTP::Headers['content-type' => 'text/plain'], ["Hello World!"]]
-			}
-		end
-			
-		subject do
-			described_class.new(app)
-		end
-		
-		it "can request resource with compression" do
-			response = subject.get("/index", {'accept-encoding' => 'gzip'})
-			expect(response).to be_success
-			
-			expect(response.headers).to_not include('content-encoding')
-			expect(response.read).to be == "Hello World!"
-		end
-	end
-	
-	context 'with existing content encoding' do
-		let(:app) do
-			app = ->(request){
-				Protocol::HTTP::Response[200, Protocol::HTTP::Headers['content-type' => 'text/plain', 'content-encoding' => 'identity'], ["Hello World!"]]
-			}
-		end
-			
-		subject do
-			described_class.new(app)
-		end
-		
-		it "does not compress response" do
-			response = subject.get("/index", {'accept-encoding' => 'gzip'})
-			
-			expect(response).to be_success
-			expect(response.headers).to include('content-encoding')
-			expect(response.headers['content-encoding']).to be == ['identity']
-			
-			expect(response.read).to be == "Hello World!"
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/header/authorization_spec.rb 0.55.0-1/spec/protocol/http/header/authorization_spec.rb
--- 0.23.12-1/spec/protocol/http/header/authorization_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/header/authorization_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/header/authorization'
-require 'protocol/http/headers'
-
-RSpec.describe Protocol::HTTP::Header::Authorization do
-	context 'with basic username/password' do
-		subject {described_class.basic("samuel", "password")}
-		
-		it "should generate correct authorization header" do
-			expect(subject).to be == "Basic c2FtdWVsOnBhc3N3b3Jk"
-		end
-		
-		describe '#credentials' do
-			it "can split credentials" do
-				expect(subject.credentials).to be == ["Basic", "c2FtdWVsOnBhc3N3b3Jk"]
-			end
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/header/cache_control_spec.rb 0.55.0-1/spec/protocol/http/header/cache_control_spec.rb
--- 0.23.12-1/spec/protocol/http/header/cache_control_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/header/cache_control_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/header/cache_control'
-
-RSpec.describe Protocol::HTTP::Header::CacheControl do
-	subject {described_class.new(description)}
-	
-	context "max-age=60, public" do
-		it {is_expected.to have_attributes(public?: true)}
-		it {is_expected.to have_attributes(private?: false)}
-		it {is_expected.to have_attributes(max_age: 60)}
-	end
-	
-	context "no-cache, no-store" do
-		it {is_expected.to have_attributes(no_cache?: true)}
-		it {is_expected.to have_attributes(no_store?: true)}
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/header/cookie_spec.rb 0.55.0-1/spec/protocol/http/header/cookie_spec.rb
--- 0.23.12-1/spec/protocol/http/header/cookie_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/header/cookie_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2021, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/header/cookie'
-
-RSpec.describe Protocol::HTTP::Header::Cookie do
-	subject {described_class.new(description)}
-	let(:cookies) {subject.to_h}
-	
-	context "session=123; secure" do
-		it "has named cookie" do
-			expect(cookies).to include('session')
-			
-			session = cookies['session']
-			expect(session).to have_attributes(name: 'session')
-			expect(session).to have_attributes(value: '123')
-			expect(session.directives).to include('secure')
-		end
-	end
-
-	context "session=123==; secure" do
-		it "has named cookie" do
-			expect(cookies).to include('session')
-
-			session = cookies['session']
-			expect(session).to have_attributes(name: 'session')
-			expect(session).to have_attributes(value: '123==')
-			expect(session.directives).to include('secure')
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/header/etags_spec.rb 0.55.0-1/spec/protocol/http/header/etags_spec.rb
--- 0.23.12-1/spec/protocol/http/header/etags_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/header/etags_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/header/etags'
-
-RSpec.describe Protocol::HTTP::Header::ETags do
-	subject {described_class.new(description)}
-	
-	context "*" do
-		it {is_expected.to be_wildcard}
-		it {is_expected.to be_match('whatever')}
-	end
-	
-	context "abcd" do
-		it {is_expected.to_not be_wildcard}
-		it {is_expected.to_not be_match('whatever')}
-		it {is_expected.to be_match('abcd')}
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/header/vary_spec.rb 0.55.0-1/spec/protocol/http/header/vary_spec.rb
--- 0.23.12-1/spec/protocol/http/header/vary_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/header/vary_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/header/cache_control'
-
-RSpec.describe Protocol::HTTP::Header::CacheControl do
-	subject {described_class.new(description)}
-	
-	context "accept-language" do
-		it {is_expected.to include('accept-language')}
-		it {is_expected.to_not include('user-agent')}
-	end
-	
-	context "Accept-Language" do
-		it {is_expected.to include('accept-language')}
-		it {is_expected.to_not include('Accept-Language')}
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/headers/connection_spec.rb 0.55.0-1/spec/protocol/http/headers/connection_spec.rb
--- 0.23.12-1/spec/protocol/http/headers/connection_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/headers/connection_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/headers'
-require 'protocol/http/cookie'
-
-RSpec.describe Protocol::HTTP::Header::Connection do
-	context "connection: close" do
-		subject {described_class.new("close")}
-		
-		it "should indiciate connection will be closed" do
-			expect(subject).to be_close
-		end
-		
-		it "should indiciate connection will not be keep-alive" do
-			expect(subject).to_not be_keep_alive
-		end
-	end
-	
-	context "connection: keep-alive" do
-		subject {described_class.new("keep-alive")}
-		
-		it "should indiciate connection will not be closed" do
-			expect(subject).to_not be_close
-		end
-		
-		it "should indiciate connection is not keep-alive" do
-			expect(subject).to be_keep_alive
-		end
-	end
-	
-	context "connection: upgrade" do
-		subject {described_class.new("upgrade")}
-		
-		it "should indiciate connection can be upgraded" do
-			expect(subject).to be_upgrade
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/headers/merged_spec.rb 0.55.0-1/spec/protocol/http/headers/merged_spec.rb
--- 0.23.12-1/spec/protocol/http/headers/merged_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/headers/merged_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/headers'
-
-RSpec.describe Protocol::HTTP::Headers::Merged do	
-	let(:fields) do
-		[
-			['Content-Type', 'text/html'],
-			['Set-Cookie', 'hello=world'],
-			['Accept', '*/*'],
-			['content-length', 10],
-		]
-	end
-	
-	subject{described_class.new(fields)}
-	let(:headers) {Protocol::HTTP::Headers.new(subject)}
-	
-	describe '#each' do
-		it 'should yield keys as lower case' do
-			subject.each do |key, value|
-				expect(key).to be == key.downcase
-			end
-		end
-		
-		it 'should yield values as strings' do
-			subject.each do |key, value|
-				expect(value).to be_kind_of String
-			end
-		end
-	end
-	
-	describe '#<<' do
-		it "can append fields" do
-			subject << [["Accept", "image/jpeg"]]
-			
-			expect(headers['accept']).to be == ['*/*', 'image/jpeg']
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/headers_spec.rb 0.55.0-1/spec/protocol/http/headers_spec.rb
--- 0.23.12-1/spec/protocol/http/headers_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/headers_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,258 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/headers'
-require 'protocol/http/cookie'
-
-RSpec.describe Protocol::HTTP::Headers do
-	let(:fields) do
-		[
-			['Content-Type', 'text/html'],
-			['Set-Cookie', 'hello=world'],
-			['Accept', '*/*'],
-			['Set-Cookie', 'foo=bar'],
-			['Connection', 'Keep-Alive']
-		]
-	end
-	
-	before(:each) do
-		fields&.each do |name, value|
-			subject.add(name, value)
-		end
-	end
-	
-	describe '#freeze' do
-		it "can't modify frozen headers" do
-			subject.freeze
-			
-			expect(subject.fields).to be == fields
-			expect(subject.fields).to be_frozen
-			expect(subject.to_h).to be_frozen
-		end
-	end
-	
-	describe '#dup' do
-		it "should not modify source object" do
-			headers = subject.dup
-			
-			headers['field'] = 'value'
-			
-			expect(subject).to_not include('field')
-		end
-	end
-	
-	describe '#empty?' do
-		it "shouldn't be empty" do
-			expect(subject).to_not be_empty
-		end
-	end
-	
-	describe '#include?' do
-		it "should include? named fields" do
-			expect(subject).to be_include('set-cookie')
-		end
-	end
-	
-	describe '#key?' do
-		it "should key? named fields" do
-			expect(subject).to be_key('set-cookie')
-		end
-	end
-	
-	describe '#fields' do
-		it 'should add fields in order' do
-			expect(subject.fields).to be == fields
-		end
-		
-		it 'can enumerate fields' do
-			subject.each.with_index do |field, index|
-				expect(field).to be == fields[index]
-			end
-		end
-	end
-	
-	describe '#to_h' do
-		it 'should generate array values for duplicate keys' do
-			expect(subject.to_h['set-cookie']).to be == ['hello=world', 'foo=bar']
-		end
-	end
-	
-	describe '#[]' do
-		it 'can lookup fields' do
-			expect(subject['content-type']).to be == 'text/html'
-		end
-	end
-	
-	describe '#[]=' do
-		it 'can add field' do
-			subject['Content-Length'] = 1
-			
-			expect(subject.fields.last).to be == ['Content-Length', 1]
-			expect(subject['content-length']).to be == 1
-		end
-		
-		it 'can add field with indexed hash' do
-			expect(subject.to_h).to_not be_empty
-			
-			subject['Content-Length'] = 1
-			expect(subject['content-length']).to be == 1
-		end
-	end
-	
-	describe '#add' do
-		it 'can add field' do
-			subject.add('Content-Length', 1)
-			
-			expect(subject.fields.last).to be == ['Content-Length', 1]
-			expect(subject['content-length']).to be == 1
-		end
-	end
-	
-	describe '#set' do
-		it 'can replace an existing field' do
-			subject.add('accept-encoding', 'gzip,deflate')
-			
-			subject.set('accept-encoding', 'gzip')
-			
-			expect(subject['accept-encoding']).to be == ['gzip']
-		end
-	end
-	
-	describe '#extract' do
-		it "can extract key's that don't exist" do
-			expect(subject.extract('foo')).to be_empty
-		end
-		
-		it 'can extract single key' do
-			expect(subject.extract('content-type')).to be == [['Content-Type', 'text/html']]
-		end
-	end
-	
-	describe '#==' do
-		it "can compare with array" do
-			expect(subject).to be == fields
-		end
-		
-		it "can compare with itself" do
-			expect(subject).to be == subject
-		end
-		
-		it "can compare with hash" do
-			expect(subject).to_not be == {}
-		end
-	end
-	
-	describe '#delete' do
-		it 'can delete case insensitive fields' do
-			expect(subject.delete('content-type')).to be == 'text/html'
-			
-			expect(subject.fields).to be == fields[1..-1]
-		end
-		
-		it 'can delete non-existant fields' do
-			expect(subject.delete('transfer-encoding')).to be_nil
-		end
-	end
-	
-	describe '#merge' do
-		it "can merge content-length" do
-			subject.merge!('content-length' => 2)
-			
-			expect(subject['content-length']).to be == 2
-		end
-	end
-	
-	describe '#trailer!' do
-		it "can add trailer" do
-			subject.add('trailer', 'etag')
-			
-			trailer = subject.trailer!
-			
-			subject.add('etag', 'abcd')
-			
-			expect(trailer.to_h).to be == {'etag' => 'abcd'}
-		end
-		
-		it "can add trailer without explicit header" do
-			trailer = subject.trailer!
-			
-			subject.add('etag', 'abcd')
-			
-			expect(trailer.to_h).to be == {'etag' => 'abcd'}
-		end
-	end
-	
-	describe '#trailer' do
-		it "can enumerate trailer" do
-			subject.add('trailer', 'etag')
-			subject.trailer!
-			subject.add('etag', 'abcd')
-			
-			expect(subject.trailer.to_h).to be == {'etag' => 'abcd'}
-		end
-	end
-	
-	describe '#flatten!' do
-		it "can flatten trailer" do
-			subject.add('trailer', 'etag')
-			trailer = subject.trailer!
-			subject.add('etag', 'abcd')
-			
-			subject.flatten!
-			
-			expect(subject).to_not include('trailer')
-			expect(subject).to include('etag')
-		end
-	end
-	
-	describe '#flatten' do
-		it "can flatten trailer" do
-			subject.add('trailer', 'etag')
-			trailer = subject.trailer!
-			subject.add('etag', 'abcd')
-			
-			copy = subject.flatten
-			
-			expect(subject).to include('trailer')
-			expect(subject).to include('etag')
-			
-			expect(copy).to_not include('trailer')
-			expect(copy).to include('etag')
-		end
-	end
-	
-	describe 'set-cookie' do
-		it "can extract parsed cookies" do
-			expect(subject['set-cookie']).to be_kind_of(Protocol::HTTP::Header::Cookie)
-		end
-	end
-	
-	describe 'connection' do
-		it "can extract connection options" do
-			expect(subject['connection']).to be_kind_of(Protocol::HTTP::Header::Connection)
-		end
-		
-		it "should normalize to lower case" do
-			expect(subject['connection']).to be == ['keep-alive']
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/methods_spec.rb 0.55.0-1/spec/protocol/http/methods_spec.rb
--- 0.23.12-1/spec/protocol/http/methods_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/methods_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/methods'
-
-RSpec.describe Protocol::HTTP::Methods do
-	it "defines several methods" do
-		expect(described_class.constants).to_not be_empty
-	end
-	
-	shared_examples_for Protocol::HTTP::Methods do |name|
-		it "defines #{name} method" do
-			expect(described_class.constants).to include(name.to_sym)
-		end
-		
-		it "has correct value" do
-			expect(described_class.const_get(name)).to be == name.to_s
-		end
-		
-		it "is a valid method" do
-			expect(described_class).to be_valid(name)
-		end
-	end
-	
-	it_behaves_like Protocol::HTTP::Methods, "GET"
-	it_behaves_like Protocol::HTTP::Methods, "POST"
-	it_behaves_like Protocol::HTTP::Methods, "PUT"
-	it_behaves_like Protocol::HTTP::Methods, "PATCH"
-	it_behaves_like Protocol::HTTP::Methods, "DELETE"
-	it_behaves_like Protocol::HTTP::Methods, "HEAD"
-	it_behaves_like Protocol::HTTP::Methods, "OPTIONS"
-	it_behaves_like Protocol::HTTP::Methods, "LINK"
-	it_behaves_like Protocol::HTTP::Methods, "UNLINK"
-	it_behaves_like Protocol::HTTP::Methods, "TRACE"
-	it_behaves_like Protocol::HTTP::Methods, "CONNECT"
-	
-	it "defines exactly 11 methods" do
-		expect(described_class.constants.length).to be == 11
-	end
-	
-	describe '.valid?' do
-		subject {described_class}
-		
-		describe "FOOBAR" do
-			it {is_expected.to_not be_valid(description)}
-		end
-		
-		describe "GETEMALL" do
-			it {is_expected.to_not be_valid(description)}
-		end
-		
-		describe "Accept:" do
-			it {is_expected.to_not be_valid(description)}
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/middleware/builder_spec.rb 0.55.0-1/spec/protocol/http/middleware/builder_spec.rb
--- 0.23.12-1/spec/protocol/http/middleware/builder_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/middleware/builder_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/middleware'
-require 'protocol/http/middleware/builder'
-
-RSpec.describe Protocol::HTTP::Middleware::Builder do
-	it "can make an app" do
-		app = Protocol::HTTP::Middleware.build do
-			run Protocol::HTTP::Middleware::HelloWorld
-		end
-		
-		expect(app).to be Protocol::HTTP::Middleware::HelloWorld
-	end
-	
-	it "defaults to not found" do
-		app = Protocol::HTTP::Middleware.build do
-		end
-		
-		expect(app).to be Protocol::HTTP::Middleware::NotFound
-	end
-	
-	it "can instantiate middleware" do
-		app = Protocol::HTTP::Middleware.build do
-			use Protocol::HTTP::Middleware
-		end
-		
-		expect(app).to be_kind_of Protocol::HTTP::Middleware
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/middleware_spec.rb 0.55.0-1/spec/protocol/http/middleware_spec.rb
--- 0.23.12-1/spec/protocol/http/middleware_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/middleware_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2021, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/middleware'
-
-RSpec.describe Protocol::HTTP::Middleware do
-	it "can invoke delegate" do
-		request = :request
-		
-		delegate = instance_double(described_class)
-		expect(delegate).to receive(:call) do |request|
-			expect(request).to be request
-		end
-		
-		middleware = described_class.new(delegate)
-		middleware.call(request)
-	end
-	
-	it "can close delegate" do
-		delegate = instance_double(described_class)
-		expect(delegate).to receive(:close)
-		
-		middleware = described_class.new(delegate)
-		middleware.close
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/reference_spec.rb 0.55.0-1/spec/protocol/http/reference_spec.rb
--- 0.23.12-1/spec/protocol/http/reference_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/reference_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,183 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/reference'
-
-RSpec.describe Protocol::HTTP::Reference do
-	describe '#+' do
-		let(:absolute) {described_class['/foo/bar']}
-		let(:relative) {described_class['foo/bar']}
-		let(:up) {described_class['../baz']}
-		
-		it 'can add a relative path' do
-			expect(subject + relative).to be == absolute
-		end
-		
-		it 'can add an absolute path' do
-			expect(subject + absolute).to be == absolute
-		end
-		
-		it 'can add an absolute path' do
-			expect(relative + absolute).to be == absolute
-		end
-		
-		it 'can remove relative parts' do
-			expect(absolute + up).to be == described_class['/baz']
-		end
-	end
-	
-	describe '#freeze' do
-		it "can freeze reference" do
-			expect(subject.freeze).to be subject
-			expect(subject).to be_frozen
-		end
-	end
-	
-	describe '#dup' do
-		let(:parameters) {{x: 10}}
-		let(:path) {"foo/bar.html"}
-		
-		it "can add parameters" do
-			copy = subject.dup(nil, parameters)
-			expect(copy.parameters).to be == parameters
-		end
-		
-		it "can update path" do
-			copy = subject.dup(path)
-			
-			expect(copy.path).to be == "/foo/bar.html"
-		end
-		
-		it "can append path components" do
-			copy = subject.dup("foo/").dup("bar/")
-			
-			expect(copy.path).to be == "/foo/bar/"
-		end
-		
-		it "can append empty path components" do
-			copy = subject.dup("")
-			
-			expect(copy.path).to be == subject.path
-		end
-		
-		it "can delete last path component" do
-			copy = subject.dup("hello").dup("")
-			
-			expect(copy.path).to be == "/hello/"
-		end
-		
-		it "can merge parameters" do
-			subject.parameters = {y: 20}
-			copy = subject.dup(nil, parameters, true)
-			expect(copy.parameters).to be == {x: 10, y: 20}
-		end
-		
-		it "can replace parameters" do
-			subject.parameters = {y: 20}
-			copy = subject.dup(nil, parameters, false)
-			expect(copy.parameters).to be == parameters
-		end
-		
-		it "can nest path with absolute base" do
-			copy = subject.with(path: "foo").with(path: "bar")
-			
-			expect(copy.path).to be == "/foo/bar"
-		end
-		
-		it "can nest path with relative base" do
-			copy = subject.with(path: "foo").with(path: "bar")
-			
-			expect(copy.path).to be == "/foo/bar"
-		end
-	end
-	
-	context 'empty query string' do
-		subject {described_class.new('/', '', nil, {})}
-		
-		it 'it should not append query string' do
-			expect(subject.to_s).to_not include('?')
-		end
-		
-		it 'can add a relative path' do
-			result = subject + described_class['foo/bar']
-			
-			expect(result.to_s).to be == '/foo/bar'
-		end
-	end
-	
-	context 'empty fragment' do
-		subject {described_class.new('/', nil, '', nil)}
-		
-		it 'it should not append query string' do
-			expect(subject.to_s).to_not include('#')
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('path with spaces/image.jpg') do
-		it "encodes whitespace" do
-			expect(subject.to_s).to be == "path%20with%20spaces/image.jpg"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('path', array: [1, 2, 3]) do
-		it "encodes array" do
-			expect(subject.to_s).to be == "path?array[]=1&array[]=2&array[]=3"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('path_with_underscores/image.jpg') do
-		it "doesn't touch underscores" do
-			expect(subject.to_s).to be == "path_with_underscores/image.jpg"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('index', 'my name' => 'Bob Dole') do
-		it "encodes query" do
-			expect(subject.to_s).to be == "index?my%20name=Bob%20Dole"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('index#All Your Base') do
-		it "encodes fragment" do
-			expect(subject.to_s).to be == "index\#All%20Your%20Base"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('I/❤️/UNICODE', face: '😀') do
-		it "encodes unicode" do
-			expect(subject.to_s).to be == "I/%E2%9D%A4%EF%B8%8F/UNICODE?face=%F0%9F%98%80"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse("foo?bar=10&baz=20", yes: 'no') do
-		it "can use existing query parameters" do
-			expect(subject.to_s).to be == "foo?bar=10&baz=20&yes=no"
-		end
-	end
-	
-	context Protocol::HTTP::Reference.parse('foo#frag') do
-		it "can use existing fragment" do
-			expect(subject.fragment).to be == "frag"
-			expect(subject.to_s).to be == 'foo#frag'
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/request_spec.rb 0.55.0-1/spec/protocol/http/request_spec.rb
--- 0.23.12-1/spec/protocol/http/request_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/request_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/request'
-
-RSpec.describe Protocol::HTTP::Request do
-	let(:headers) {Protocol::HTTP::Headers.new}
-	let(:body) {nil}
-	
-	context "simple GET request" do
-		subject {described_class.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)}
-		
-		it {is_expected.to have_attributes(
-			scheme: "http",
-			authority: "localhost",
-			method: "GET",
-			path: "/index.html",
-			version: "HTTP/1.0",
-			headers: headers,
-			body: body,
-			protocol: nil
-		)}
-		
-		it {is_expected.to_not be_head}
-		it {is_expected.to_not be_connect}
-		it {is_expected.to be_idempotent}
-		
-		it {is_expected.to have_attributes(
-			to_s: "http://localhost: GET /index.html HTTP/1.0"
-		)}
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/response_spec.rb 0.55.0-1/spec/protocol/http/response_spec.rb
--- 0.23.12-1/spec/protocol/http/response_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/response_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/request'
-
-RSpec.describe Protocol::HTTP::Response do
-	let(:headers) {Protocol::HTTP::Headers.new}
-	let(:body) {nil}
-	
-	context "GET response" do
-		subject {described_class.new("HTTP/1.0", 200, headers, body)}
-		
-		it {is_expected.to have_attributes(
-			version: "HTTP/1.0",
-			status: 200,
-			headers: headers,
-			body: body,
-			protocol: nil
-		)}
-		
-		it {is_expected.to_not be_hijack}
-		it {is_expected.to_not be_continue}
-		it {is_expected.to be_success}
-		
-		it {is_expected.to have_attributes(
-			to_ary: [200, headers, body]
-		)}
-		
-		it {is_expected.to have_attributes(
-			to_s: "200 HTTP/1.0"
-		)}
-	end
-	
-	context "unmodified cached response" do
-		subject {described_class.new("HTTP/1.1", 304, headers, body)}
-		
-		it {is_expected.to_not be_success}
-		it {is_expected.to be_redirection}
-		it {is_expected.to be_not_modified}
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http/url_spec.rb 0.55.0-1/spec/protocol/http/url_spec.rb
--- 0.23.12-1/spec/protocol/http/url_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http/url_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,83 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http/url'
-
-RSpec.shared_examples_for "valid parameters" do |parameters, query_string = nil|
-	let(:encoded) {Protocol::HTTP::URL.encode(parameters)}
-	
-	if query_string
-		it "can encode #{parameters.inspect}" do
-			expect(encoded).to be == query_string
-		end
-	end
-	
-	let(:decoded) {Protocol::HTTP::URL.decode(encoded)}
-	
-	it "can round-trip #{parameters.inspect}" do
-		expect(decoded).to be == parameters
-	end
-end
-
-RSpec.describe Protocol::HTTP::URL do
-	it_behaves_like "valid parameters", {'foo' => 'bar'}, "foo=bar"
-	it_behaves_like "valid parameters", {'foo' => ["1", "2", "3"]}, "foo[]=1&foo[]=2&foo[]=3"
-	
-	it_behaves_like "valid parameters", {'foo' => {'bar' => 'baz'}}, "foo[bar]=baz"
-	it_behaves_like "valid parameters", {'foo' => [{'bar' => 'baz'}]}, "foo[][bar]=baz"
-	
-	it_behaves_like "valid parameters", {'foo' => [{'bar' => 'baz'}, {'bar' => 'bob'}]}
-	
-	let(:encoded) {Protocol::HTTP::URL.encode(parameters)}
-	
-	context "basic parameters" do
-		let(:parameters) {{x: "10", y: "20"}}
-		let(:decoded) {Protocol::HTTP::URL.decode(encoded, symbolize_keys: true)}
-		
-		it "can symbolize keys" do
-			expect(decoded).to be == parameters
-		end
-	end
-	
-	context "nested parameters" do
-		let(:parameters) {{things: [{x: "10"}, {x: "20"}]}}
-		let(:decoded) {Protocol::HTTP::URL.decode(encoded, symbolize_keys: true)}
-		
-		it "can symbolize keys" do
-			expect(decoded).to be == parameters
-		end
-	end
-	
-	describe '.decode' do
-		it "fails on deeply nested parameters" do
-			expect do
-				Protocol::HTTP::URL.decode("a[b][c][d][e][f][g][h][i]=10")
-			end.to raise_error(/Key length exceeded/)
-		end
-	end
-
-	describe '.unescape' do
-		it "succeds with hex characters" do
-			expect(Protocol::HTTP::URL.unescape("%3A")).to be == ":"
-		end
-	end
-end
diff -pruN 0.23.12-1/spec/protocol/http_spec.rb 0.55.0-1/spec/protocol/http_spec.rb
--- 0.23.12-1/spec/protocol/http_spec.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/protocol/http_spec.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'protocol/http'
-
-RSpec.describe Protocol::HTTP do
-	it "has a version number" do
-		expect(Protocol::HTTP::VERSION).not_to be nil
-	end
-end
diff -pruN 0.23.12-1/spec/spec_helper.rb 0.55.0-1/spec/spec_helper.rb
--- 0.23.12-1/spec/spec_helper.rb	2022-08-31 04:36:25.000000000 +0000
+++ 0.55.0-1/spec/spec_helper.rb	1970-01-01 00:00:00.000000000 +0000
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright, 2019, by Samuel G. D. Williams. <http://www.codeotaku.com>
-# 
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-# 
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-# 
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-require 'async/rspec'
-require 'covered/rspec'
-
-RSpec.shared_context 'docstring as description' do
-	let(:description) {self.class.metadata.fetch(:description_args).first}
-end
-
-RSpec.configure do |config|
-	# Enable flags like --only-failures and --next-failure
-	config.example_status_persistence_file_path = ".rspec_status"
-	
-	# Disable RSpec exposing methods globally on `Module` and `main`
-	config.disable_monkey_patching!
-	
-	config.include_context 'docstring as description'
-	
-	config.expect_with :rspec do |c|
-		c.syntax = :expect
-	end
-end
diff -pruN 0.23.12-1/test/protocol/http/accept_encoding.rb 0.55.0-1/test/protocol/http/accept_encoding.rb
--- 0.23.12-1/test/protocol/http/accept_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/accept_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/accept_encoding"
+
+describe Protocol::HTTP::AcceptEncoding do
+	let(:delegate) do
+		proc do |request|
+			Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]]
+		end
+	end
+	
+	let(:middleware) {Protocol::HTTP::AcceptEncoding.new(delegate)}
+	
+	with "known encodings" do
+		it "can decode gzip responses" do
+			# Mock a response with gzip encoding
+			gzip_delegate = proc do |request|
+				Protocol::HTTP::Response[200, 
+					Protocol::HTTP::Headers[
+						"content-type" => "text/plain",
+						"content-encoding" => "gzip"
+					], 
+					["Hello World!"]
+				]
+			end
+			
+			gzip_middleware = Protocol::HTTP::AcceptEncoding.new(gzip_delegate)
+			request = Protocol::HTTP::Request["GET", "/"]
+			response = gzip_middleware.call(request)
+			
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.body).to be_a(Protocol::HTTP::Body::Inflate)
+		end
+	end
+	
+	with "unknown encodings" do
+		it "preserves unknown content-encoding headers" do
+			# Mock a response with brotli encoding (not in DEFAULT_WRAPPERS)
+			br_delegate = proc do |request|
+				Protocol::HTTP::Response[200,
+					Protocol::HTTP::Headers[
+						"content-type" => "text/plain",
+						"content-encoding" => "br"
+					], 
+					["Hello World!"]  # This would actually be brotli-encoded in reality
+				]
+			end
+			
+			br_middleware = Protocol::HTTP::AcceptEncoding.new(br_delegate)
+			request = Protocol::HTTP::Request["GET", "/"]
+			response = br_middleware.call(request)
+			
+			# The bug: this currently fails because content-encoding gets removed
+			# when the middleware encounters an unknown encoding
+			expect(response.headers).to have_keys("content-encoding")
+			expect(response.headers["content-encoding"]).to be == ["br"]
+			# The body should remain untouched since we can't decode it
+			expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate)
+		end
+		
+		it "preserves mixed known and unknown encodings" do
+			# Mock a response with multiple encodings where some are unknown
+			mixed_delegate = proc do |request|
+				Protocol::HTTP::Response[200, 
+					Protocol::HTTP::Headers[
+						"content-type" => "text/plain",
+						"content-encoding" => "gzip, br"  # gzip is known, br is unknown
+					], 
+					["Hello World!"]
+				]
+			end
+			
+			mixed_middleware = Protocol::HTTP::AcceptEncoding.new(mixed_delegate)
+			request = Protocol::HTTP::Request["GET", "/"]
+			response = mixed_middleware.call(request)
+			
+			# The bug: this currently fails because the entire content-encoding 
+			# header gets removed when ANY unknown encoding is present
+			expect(response.headers).to have_keys("content-encoding")
+			expect(response.headers["content-encoding"]).to be == ["gzip", "br"]
+			# The body should remain untouched since we can't decode the br part
+			expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate)
+		end
+		
+		it "handles case-insensitive encoding names" do
+			# Mock a response with uppercase encoding name
+			uppercase_delegate = proc do |request|
+				Protocol::HTTP::Response[200, 
+					Protocol::HTTP::Headers[
+						"content-type" => "text/plain",
+						"content-encoding" => "GZIP"
+					], 
+					["Hello World!"]
+				]
+			end
+			
+			uppercase_middleware = Protocol::HTTP::AcceptEncoding.new(uppercase_delegate)
+			request = Protocol::HTTP::Request["GET", "/"]
+			response = uppercase_middleware.call(request)
+			
+			# This might also be a bug - encoding names should be case-insensitive
+			# but the current implementation uses exact string matching
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.body).to be_a(Protocol::HTTP::Body::Inflate)
+		end
+	end
+	
+	with "issue #86 - transparent proxy scenario" do
+		it "preserves unknown content-encoding when acting as transparent proxy" do
+			# This test simulates the exact scenario described in issue #86
+			# where a transparent proxy fetches content with brotli encoding
+			# but the AcceptEncoding middleware doesn't know about brotli
+			
+			# Mock upstream server that returns brotli-encoded content
+			upstream_delegate = proc do |request|
+				# Simulate a server responding with brotli encoding
+				Protocol::HTTP::Response[200, 
+					Protocol::HTTP::Headers[
+						"content-type" => "text/html",
+						"content-encoding" => "br"  # Server chose brotli
+					], 
+					["<compressed brotli content>"]  # This would be actual brotli data
+				]
+			end
+			
+			# Proxy middleware that only knows about gzip
+			proxy_middleware = Protocol::HTTP::AcceptEncoding.new(upstream_delegate)
+			
+			# Client request that accepts both gzip and brotli
+			request = Protocol::HTTP::Request["GET", "/some/resource"]
+			response = proxy_middleware.call(request)
+			
+			# BUG: The content-encoding header should be preserved
+			# so the client knows the content is still brotli-encoded
+			expect(response.headers).to have_keys("content-encoding")
+			expect(response.headers["content-encoding"]).to be == ["br"]
+			
+			# The body should remain untouched since proxy can't decode brotli
+			expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate)
+			expect(response.read).to be == "<compressed brotli content>"
+		end
+	end
+	
+	with "empty or identity encodings" do
+		it "handles identity encoding correctly" do
+			identity_delegate = proc do |request|
+				Protocol::HTTP::Response[200,
+					Protocol::HTTP::Headers[
+						"content-type" => "text/plain",
+						"content-encoding" => "identity"
+					], 
+					["Hello World!"]
+				]
+			end
+			
+			identity_middleware = Protocol::HTTP::AcceptEncoding.new(identity_delegate)
+			request = Protocol::HTTP::Request["GET", "/"]
+			response = identity_middleware.call(request)
+			
+			# Identity encoding means no encoding, so header should be removed
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/buffered.rb 0.55.0-1/test/protocol/http/body/buffered.rb
--- 0.23.12-1/test/protocol/http/body/buffered.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/buffered.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,211 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+# Copyright, 2020-2023, by Bruno Sutic.
+
+require "protocol/http/body/buffered"
+require "protocol/http/body/a_readable_body"
+
+describe Protocol::HTTP::Body::Buffered do
+	let(:source) {["Hello", "World"]}
+	let(:body) {subject.wrap(source)}
+	
+	it_behaves_like Protocol::HTTP::Body::AReadableBody
+	
+	with ".wrap" do
+		with "an instance of Protocol::HTTP::Body::Readable as a source" do
+			let(:source) {Protocol::HTTP::Body::Readable.new}
+			
+			it "returns the body" do
+				expect(body).to be == source
+			end
+		end
+		
+		with "an instance of an Array as a source" do
+			let(:source) {["Hello", "World"]}
+			
+			it "returns instance initialized with the array" do
+				expect(body).to be_a(subject)
+			end
+		end
+		
+		with "source that responds to #each" do
+			let(:source) {["Hello", "World"].each}
+			
+			it "buffers the content into an array before initializing" do
+				expect(body).to be_a(subject)
+				expect(body.read).to be == "Hello"
+				expect(body.read).to be == "World"
+			end
+		end
+		
+		with "an instance of a String as a source" do
+			let(:source) {"Hello World"}
+			
+			it "returns instance initialized with the String" do
+				expect(body).to be_a(subject)
+				expect(body.read).to be == "Hello World"
+			end
+		end
+	end
+	
+	with "#length" do
+		it "returns sum of chunks' bytesize" do
+			expect(body.length).to be == 10
+		end
+	end
+	
+	with "#empty?" do
+		it "returns false when there are chunks left" do
+			expect(body.empty?).to be == false
+			body.read
+			expect(body.empty?).to be == false
+		end
+		
+		it "returns true when there are no chunks left" do
+			body.read
+			body.read
+			expect(body.empty?).to be == true
+		end
+		
+		it "returns false when rewinded" do
+			body.read
+			body.read
+			body.rewind
+			expect(body.empty?).to be == false
+		end
+	end
+	
+	with "#ready?" do
+		it "is ready when chunks are available" do
+			expect(body).to be(:ready?)
+		end
+	end
+	
+	with "#finish" do
+		it "returns self" do
+			expect(body.finish).to be == body
+		end
+	end
+	
+	with "#call" do
+		let(:output) {Protocol::HTTP::Body::Buffered.new}
+		let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)}
+		
+		it "can stream data" do
+			body.call(stream)
+			
+			expect(output).not.to be(:empty?)
+			expect(output.chunks).to be == source
+		end
+	end
+	
+	with "#read" do
+		it "retrieves chunks of content" do
+			expect(body.read).to be == "Hello"
+			expect(body.read).to be == "World"
+			expect(body.read).to be == nil
+		end
+		
+		# with "large content" do
+		# 	let(:content) {Array.new(5) {|i| "#{i}" * (1*1024*1024)}}
+		
+		# 	it "allocates expected amount of memory" do
+		# 		expect do
+		# 			subject.read until subject.empty?
+		# 		end.to limit_allocations(size: 0)
+		# 	end
+		# end
+	end
+	
+	with "#rewind" do
+		it "is rewindable" do
+			expect(body).to be(:rewindable?)
+		end
+		
+		it "positions the cursor to the beginning" do
+			expect(body.read).to be == "Hello"
+			body.rewind
+			expect(body.read).to be == "Hello"
+		end
+	end
+	
+	with "#buffered" do
+		let(:buffered_body) {body.buffered}
+		
+		it "returns a buffered body" do
+			expect(buffered_body).to be_a(subject)
+			expect(buffered_body.read).to be == "Hello"
+			expect(buffered_body.read).to be == "World"
+		end
+		
+		it "doesn't affect the original body" do
+			expect(buffered_body.join).to be == "HelloWorld"
+			
+			expect(buffered_body).to be(:empty?)
+			expect(body).not.to be(:empty?)
+		end
+	end
+	
+	with "#inspect" do
+		let(:body) {subject.new}
+		
+		it "generates string representation for empty body" do
+			expect(body.inspect).to be == "#<Protocol::HTTP::Body::Buffered empty>"
+		end
+	end
+	
+	with "#each" do
+		with "a block" do
+			it "iterates over chunks" do
+				result = []
+				body.each{|chunk| result << chunk}
+				expect(result).to be == source
+			end
+		end
+		
+		with "no block" do
+			it "returns an enumerator" do
+				expect(body.each).to be_a(Enumerator)
+			end
+			
+			it "can be chained with enumerator methods" do
+				result = []
+				
+				body.each.with_index do |chunk, index|
+					if index.zero?
+						result << chunk.upcase
+					else
+						result << chunk.downcase
+					end
+				end
+				
+				expect(result).to be == ["HELLO", "world"]
+			end
+		end
+	end
+	
+	with "#clear" do
+		it "clears all chunks and resets length" do
+			body.clear
+			expect(body.chunks).to be(:empty?)
+			expect(body.read).to be == nil
+			expect(body.length).to be == 0
+		end
+	end
+	
+	with "#inspect" do
+		it "can be inspected" do
+			expect(body.inspect).to be =~ /\d+ chunks, \d+ bytes/
+		end
+	end
+	
+	with "#discard" do
+		it "closes the body" do
+			expect(body).to receive(:close)
+			
+			expect(body.discard).to be == nil
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/completable.rb 0.55.0-1/test/protocol/http/body/completable.rb
--- 0.23.12-1/test/protocol/http/body/completable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/completable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/body/completable"
+require "protocol/http/body/buffered"
+require "protocol/http/request"
+
+describe Protocol::HTTP::Body::Completable do
+	let(:body) {Protocol::HTTP::Body::Buffered.new}
+	let(:callback) {Proc.new{}}
+	let(:completable) {subject.new(body, callback)}
+	
+	it "can trigger callback when finished reading" do
+		expect(callback).to receive(:call)
+		
+		expect(completable.read).to be_nil
+		completable.close
+	end
+	
+	AnImmediateCallback = Sus::Shared("an immediate callback") do
+		it "invokes block immediately" do
+			invoked = false
+			
+			wrapped = subject.wrap(message) do
+				invoked = true
+			end
+			
+			expect(invoked).to be == true
+			expect(message.body).to be_equal(body)
+		end
+	end
+	
+	ADeferredCallback = Sus::Shared("a deferred callback") do
+		it "invokes block when body is finished reading" do
+			invoked = false
+			
+			wrapped = subject.wrap(message) do
+				invoked = true
+			end
+			
+			expect(invoked).to be == false
+			expect(message.body).to be_equal(wrapped)
+			
+			wrapped.join
+			
+			expect(invoked).to be == true
+		end
+	end
+	
+	with ".wrap" do
+		let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)}
+		
+		with "empty body" do
+			it_behaves_like AnImmediateCallback
+		end
+		
+		with "nil body" do
+			let(:body) {nil}
+			
+			it_behaves_like AnImmediateCallback
+		end
+		
+		with "non-empty body" do
+			let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello World")}
+			
+			it_behaves_like ADeferredCallback
+		end
+	end
+	
+	with "#finish" do
+		it "invokes callback once" do
+			expect(callback).to receive(:call)
+			
+			2.times do
+				completable.finish
+			end
+		end
+		
+		it "doesn't break #read after finishing" do
+			completable.finish
+			expect(completable.read).to be_nil
+		end
+	end
+	
+	with "#rewindable?" do
+		it "is not rewindable" do
+			# Because completion can only happen once, we can't rewind the body.
+			expect(body).to be(:rewindable?)
+			expect(completable).not.to be(:rewindable?)
+			expect(completable.rewind).to be == false
+		end
+	end
+	
+	with "#close" do
+		let(:events) {Array.new}
+		let(:callback) {Proc.new{events << :close}}
+		
+		it "invokes callback once" do
+			completable1 = subject.new(body, proc{events << :close1})
+			completable2 = subject.new(completable1, proc{events << :close2})
+			
+			completable2.close
+			
+			expect(events).to be == [:close2, :close1]
+		end
+	end
+	
+	with "#as_json" do
+		it "includes callback information" do
+			completable = subject.new(body, proc{events << :close})
+			
+			expect(completable.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Completable",
+				callback: be =~ /Proc/
+			)
+		end
+		
+		it "shows nil when no callback" do
+			completable = subject.new(body, nil)
+			
+			expect(completable.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Completable",
+				callback: be == nil
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/deflate.rb 0.55.0-1/test/protocol/http/body/deflate.rb
--- 0.23.12-1/test/protocol/http/body/deflate.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/deflate.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,75 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/body/buffered"
+require "protocol/http/body/deflate"
+require "protocol/http/body/inflate"
+
+require "securerandom"
+
+describe Protocol::HTTP::Body::Deflate do
+	let(:body) {Protocol::HTTP::Body::Buffered.new}
+	let(:compressed_body) {Protocol::HTTP::Body::Deflate.for(body)}
+	let(:decompressed_body) {Protocol::HTTP::Body::Inflate.for(compressed_body)}
+	
+	it "should round-trip data" do
+		body.write("Hello World!")
+		
+		expect(decompressed_body.join).to be == "Hello World!"
+	end
+	
+	let(:data) {"Hello World!" * 10_000}
+	
+	it "should round-trip data" do
+		body.write(data)
+		
+		expect(decompressed_body.read).to be == data
+		expect(decompressed_body.read).to be == nil
+		
+		expect(compressed_body.ratio).to be < 1.0
+		expect(decompressed_body.ratio).to be > 1.0
+	end
+	
+	it "should round-trip chunks" do
+		10.times do
+			body.write("Hello World!")
+		end
+		
+		10.times do
+			expect(decompressed_body.read).to be == "Hello World!"
+		end
+		expect(decompressed_body.read).to be == nil
+	end
+	
+	with "#length" do
+		it "should be unknown" do
+			expect(compressed_body).to have_attributes(
+				length: be_nil,
+			)
+			
+			expect(decompressed_body).to have_attributes(
+				length: be_nil,
+			)
+		end
+	end
+	
+	with "#inspect" do
+		it "can generate string representation" do
+			expect(compressed_body.inspect).to be == "#<Protocol::HTTP::Body::Buffered empty> | #<Protocol::HTTP::Body::Deflate 100.0%>"
+		end
+	end
+	
+	with "#as_json" do
+		it "includes compression information" do
+			expect(compressed_body.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Deflate",
+				input_length: be == 0,
+				output_length: be == 0,
+				compression_ratio: be == 100.0
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/digestable.rb 0.55.0-1/test/protocol/http/body/digestable.rb
--- 0.23.12-1/test/protocol/http/body/digestable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/digestable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+
+require "protocol/http/body/digestable"
+require "protocol/http/body/buffered"
+
+describe Protocol::HTTP::Body::Digestable do
+	let(:source) {Protocol::HTTP::Body::Buffered.new}
+	let(:body) {subject.new(source)}
+	
+	with ".wrap" do
+		let(:source) {Protocol::HTTP::Body::Buffered.wrap("HelloWorld")}
+		let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)}
+		
+		it "can wrap a message" do
+			Protocol::HTTP::Body::Digestable.wrap(message) do |digestable|
+				expect(digestable).to have_attributes(
+					digest: be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4",
+				)
+			end
+			
+			expect(message.body.join).to be == "HelloWorld"
+		end
+	end
+	
+	with "#digest" do
+		def before
+			source.write "Hello"
+			source.write "World"
+			
+			super
+		end
+		
+		it "can compute digest" do
+			2.times {body.read}
+			
+			expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"
+		end
+		
+		it "can recompute digest" do
+			expect(body.read).to be == "Hello"
+			expect(body.digest).to be == "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969"
+			
+			expect(body.read).to be == "World"
+			expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"
+			
+			expect(body.etag).to be == '"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"'
+			expect(body.etag(weak: true)).to be == 'W/"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"'
+		end
+	end
+	
+	with "#as_json" do
+		it "includes digest information" do
+			expect(body.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Digestable",
+				digest_class: be == "Digest::SHA256",
+				callback: be == nil
+			)
+		end
+		
+		with "callback" do
+			let(:callback) {proc {puts "digest complete"}}
+			let(:body) {subject.new(source, Digest::SHA256.new, callback)}
+			
+			it "includes callback information" do
+				expect(body.as_json).to have_keys(
+								class: be == "Protocol::HTTP::Body::Digestable",
+								digest_class: be == "Digest::SHA256",
+								callback: be =~ /Proc/
+							)
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/file.rb 0.55.0-1/test/protocol/http/body/file.rb
--- 0.23.12-1/test/protocol/http/body/file.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/file.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/body/file"
+
+describe Protocol::HTTP::Body::File do
+	let(:path) {File.expand_path("file_spec.txt", __dir__)}
+	let(:body) {subject.open(path)}
+	
+	after do
+		@body&.close
+	end
+	
+	# with '#stream?' do
+	# 	it "should be streamable" do
+	# 		expect(body).to be(:stream?)
+	# 	end
+	# end
+	
+	with "#join" do
+		it "should read entire file" do
+			expect(body.join).to be == "Hello World"
+		end
+	end
+	
+	with "#close" do
+		it "should close file" do
+			body.close
+			
+			expect(body).to be(:empty?)
+			expect(body.file).to be(:closed?)
+		end
+	end
+	
+	with "#rewindable?" do
+		it "should be rewindable" do
+			expect(body).to be(:rewindable?)
+		end
+	end
+	
+	with "#rewind" do
+		it "should rewind file" do
+			expect(body.read).to be == "Hello World"
+			expect(body).to be(:empty?)
+			
+			body.rewind
+			
+			expect(body).not.to be(:empty?)
+			expect(body.read).to be == "Hello World"
+		end
+	end
+	
+	with "#buffered" do
+		it "should return a new instance" do
+			buffered = body.buffered
+			
+			expect(buffered).to be_a(Protocol::HTTP::Body::File)
+			expect(buffered).not.to be_equal(body)
+		ensure
+			buffered&.close
+		end
+	end
+	
+	with "#inspect" do
+		it "generates a string representation" do
+			expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?), \d+ bytes remaining/
+		end
+		
+		with "range" do
+			let(:body) {subject.new(File.open(path), 5..10)}
+			
+			it "shows offset when present" do
+				expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?) \+5, \d+ bytes remaining/
+			end
+		end
+	end
+	
+	with "entire file" do
+		it "should read entire file" do
+			expect(body.read).to be == "Hello World"
+		end
+		
+		it "should use binary encoding" do
+			expect(::File).to receive(:open).with(path, ::File::RDONLY | ::File::BINARY)
+			
+			chunk = body.read
+			
+			expect(chunk.encoding).to be == Encoding::BINARY
+		end
+		
+		with "#ready?" do
+			it "should be ready" do
+				expect(body).to be(:ready?)
+			end
+		end
+	end
+	
+	with "partial file" do
+		let(:body) {subject.open(path, 2...4)}
+		
+		it "should read specified range" do
+			expect(body.read).to be == "ll"
+		end
+	end
+	
+	with "#call" do
+		let(:output) {StringIO.new}
+		
+		it "can stream output" do
+			body.call(output)
+			
+			expect(output.string).to be == "Hello World"
+		end
+		
+		with "/dev/zero" do
+			it "can stream partial output" do
+				skip unless File.exist?("/dev/zero")
+				
+				body = subject.open("/dev/zero", 0...10)
+				
+				body.call(output)
+				
+				expect(output.string).to be == "\x00" * 10
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/file_spec.txt 0.55.0-1/test/protocol/http/body/file_spec.txt
--- 0.23.12-1/test/protocol/http/body/file_spec.txt	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/file_spec.txt	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1 @@
+Hello World
\ No newline at end of file
diff -pruN 0.23.12-1/test/protocol/http/body/head.rb 0.55.0-1/test/protocol/http/body/head.rb
--- 0.23.12-1/test/protocol/http/body/head.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/head.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/body/head"
+require "protocol/http/body/buffered"
+
+describe Protocol::HTTP::Body::Head do
+	with "zero length" do
+		let(:body) {subject.new(0)}
+		
+		it "should be ready" do
+			expect(body).to be(:ready?)
+		end
+		
+		it "should be empty" do
+			expect(body).to be(:empty?)
+		end
+		
+		with "#join" do
+			it "should be nil" do
+				expect(body.join).to be_nil
+			end
+		end
+	end
+	
+	with "non-zero length" do
+		let(:body) {subject.new(1)}
+		
+		it "should be empty" do
+			expect(body).to be(:empty?)
+		end
+		
+		with "#read" do
+			it "should be nil" do
+				expect(body.join).to be_nil
+			end
+		end
+		
+		with "#join" do
+			it "should be nil" do
+				expect(body.join).to be_nil
+			end
+		end
+	end
+	
+	with ".for" do
+		with "body" do
+			let(:source) {Protocol::HTTP::Body::Buffered.wrap("!")}
+			let(:body) {subject.for(source)}
+			
+			it "captures length and closes existing body" do
+				expect(source).to receive(:close)
+				
+				expect(body).to have_attributes(length: be == 1)
+				body.close
+			end
+		end
+		
+		with "content length" do
+			let(:body) {subject.for(nil, 42)}
+			
+			it "uses the content length if no body is provided" do
+				expect(body).to have_attributes(length: be == 42)
+				expect(body).to be(:empty?)
+				expect(body).to be(:ready?)
+			end
+		end
+	end
+	
+	with ".for with nil body" do
+		it "returns nil when body is nil" do
+			body = subject.for(nil)
+			expect(body).to be_nil
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/inflate.rb 0.55.0-1/test/protocol/http/body/inflate.rb
--- 0.23.12-1/test/protocol/http/body/inflate.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/inflate.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,35 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2024, by Samuel Williams.
+
+require "protocol/http/body/buffered"
+require "protocol/http/body/deflate"
+require "protocol/http/body/inflate"
+
+require "securerandom"
+
+describe Protocol::HTTP::Body::Inflate do
+	let(:sample) {"The quick brown fox jumps over the lazy dog."}
+	let(:chunks) {[sample] * 1024}
+	
+	let(:body) {Protocol::HTTP::Body::Buffered.new(chunks)}
+	let(:deflate_body) {Protocol::HTTP::Body::Deflate.for(body)}
+	let(:compressed_chunks) {deflate_body.join.each_char.to_a}
+	let(:compressed_body_chunks) {compressed_chunks}
+	let(:compressed_body) {Protocol::HTTP::Body::Buffered.new(compressed_body_chunks)}
+	let(:decompressed_body) {subject.for(compressed_body)}
+	
+	it "can decompress a body" do
+		expect(decompressed_body.join).to be == chunks.join
+	end
+	
+	with "incomplete input" do
+		let(:compressed_body_chunks) {compressed_chunks.first(compressed_chunks.size/2)}
+		
+		it "raises error when input is incomplete" do
+			expect{decompressed_body.join}.to raise_exception(Zlib::BufError)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/readable.rb 0.55.0-1/test/protocol/http/body/readable.rb
--- 0.23.12-1/test/protocol/http/body/readable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/readable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/body/stream"
+require "protocol/http/body/readable"
+
+describe Protocol::HTTP::Body::Readable do
+	let(:body) {subject.new}
+	
+	it "might not be empty" do
+		expect(body).not.to be(:empty?)
+	end
+	
+	it "should not be ready" do
+		expect(body).not.to be(:ready?)
+	end
+	
+	with "#buffered" do
+		it "is unable to buffer by default" do
+			expect(body.buffered).to be_nil
+		end
+	end
+	
+	with "#finish" do
+		it "should return empty buffered representation" do
+			expect(body.finish).to be(:empty?)
+		end
+	end
+	
+	with "#call" do
+		let(:output) {Protocol::HTTP::Body::Buffered.new}
+		let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)}
+		
+		it "can stream (empty) data" do
+			body.call(stream)
+			
+			expect(output).to be(:empty?)
+		end
+		
+		it "flushes the stream if it is not ready" do
+			chunks = ["Hello World"]
+			
+			mock(body) do |mock|
+				mock.replace(:read) do
+					chunks.pop
+				end
+				
+				mock.replace(:ready?) do
+					false
+				end
+			end
+			
+			expect(stream).to receive(:flush)
+			
+			body.call(stream)
+		end
+	end
+	
+	with "#join" do
+		it "should be nil" do
+			expect(body.join).to be_nil
+		end
+	end
+	
+	with "#discard" do
+		it "should read all chunks" do
+			expect(body).to receive(:read).and_return(nil)
+			expect(body.discard).to be_nil
+		end
+	end
+	
+	with "#as_json" do
+		it "generates a JSON representation" do
+			expect(body.as_json).to have_keys(
+				class: be == subject.name,
+				length: be_nil,
+				stream: be == false,
+				ready: be == false,
+				empty: be == false,
+			)
+		end
+		
+		it "generates a JSON string" do
+			expect(JSON.dump(body)).to be == body.to_json
+		end
+	end
+	
+	with "#rewindable?" do
+		it "is not rewindable" do
+			expect(body).not.to be(:rewindable?)
+			expect(body.rewind).to be == false
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/reader.rb 0.55.0-1/test/protocol/http/body/reader.rb
--- 0.23.12-1/test/protocol/http/body/reader.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/reader.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2022, by Dan Olson.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/body/reader"
+require "protocol/http/body/buffered"
+
+require "tempfile"
+
+class TestReader
+	include Protocol::HTTP::Body::Reader
+	
+	def initialize(body)
+		@body = body
+	end
+	
+	attr :body
+end
+
+describe Protocol::HTTP::Body::Reader do
+	let(:body) {Protocol::HTTP::Body::Buffered.wrap("thequickbrownfox")}
+	let(:reader) {TestReader.new(body)}
+	
+	with "#finish" do
+		it "returns a buffered representation" do
+			expect(reader.finish).to be == body
+		end
+	end
+	
+	with "#discard" do
+		it "discards the body" do
+			expect(body).to receive(:discard)
+			expect(reader.discard).to be_nil
+		end
+	end
+	
+	with "#buffered!" do
+		it "buffers the body" do
+			expect(reader.buffered!).to be_equal(reader)
+			expect(reader.body).to be == body
+		end
+	end
+	
+	with "#close" do
+		it "closes the underlying body" do
+			expect(body).to receive(:close)
+			reader.close
+			
+			expect(reader).not.to be(:body?)
+		end
+	end
+	
+	with "#save" do
+		it "saves to the provided filename" do
+			Tempfile.create do |file|
+				reader.save(file.path)
+				expect(File.read(file.path)).to be == "thequickbrownfox"
+			end
+		end
+		
+		it "saves by truncating an existing file if it exists" do
+			Tempfile.create do |file|
+				File.write(file.path, "hello" * 100)
+				reader.save(file.path)
+				expect(File.read(file.path)).to be == "thequickbrownfox"
+			end
+		end
+		
+		it "mirrors the interface of File.open" do
+			Tempfile.create do |file|
+				reader.save(file.path, "w")
+				expect(File.read(file.path)).to be == "thequickbrownfox"
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/reader_spec.txt 0.55.0-1/test/protocol/http/body/reader_spec.txt
--- 0.23.12-1/test/protocol/http/body/reader_spec.txt	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/reader_spec.txt	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1 @@
+thequickbrownfox
\ No newline at end of file
diff -pruN 0.23.12-1/test/protocol/http/body/rewindable.rb 0.55.0-1/test/protocol/http/body/rewindable.rb
--- 0.23.12-1/test/protocol/http/body/rewindable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/rewindable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/body/rewindable"
+require "protocol/http/request"
+
+describe Protocol::HTTP::Body::Rewindable do
+	let(:source) {Protocol::HTTP::Body::Buffered.new}
+	let(:body) {subject.new(source)}
+	
+	it "can write and read data" do
+		3.times do |i|
+			source.write("Hello World #{i}")
+			expect(body.read).to be == "Hello World #{i}"
+		end
+	end
+	
+	it "can write and read data multiple times" do
+		3.times do |i|
+			source.write("Hello World #{i}")
+		end
+		
+		3.times do
+			body.rewind
+			
+			expect(body).to be(:ready?)
+			expect(body.read).to be == "Hello World 0"
+		end
+	end
+	
+	it "can buffer data in order" do
+		3.times do |i|
+			source.write("Hello World #{i}")
+		end
+		
+		2.times do
+			body.rewind
+			
+			3.times do |i|
+				expect(body.read).to be == "Hello World #{i}"
+			end
+		end
+	end
+	
+	with ".wrap" do
+		with "a buffered body" do
+			let(:body) {Protocol::HTTP::Body::Buffered.new}
+			let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)}
+			
+			it "returns the body" do
+				expect(subject.wrap(message)).to be == body
+			end
+		end
+		
+		with "a non-rewindable body" do
+			let(:body) {Protocol::HTTP::Body::Readable.new}
+			let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)}
+			
+			it "returns a new rewindable body" do
+				expect(subject.wrap(message)).to be_a(Protocol::HTTP::Body::Rewindable)
+			end
+		end
+	end
+	
+	with "#buffered" do
+		it "can generate buffered representation" do
+			3.times do |i|
+				source.write("Hello World #{i}")
+			end
+			
+			expect(body.buffered).to be(:empty?)
+			
+			# Read one chunk into the internal buffer:
+			body.read
+			
+			expect(body.buffered.chunks).to be == ["Hello World 0"]
+		end
+	end
+	
+	with "#empty?" do
+		it "can read and re-read the body" do
+			source.write("Hello World")
+			expect(body).not.to be(:empty?)
+			
+			expect(body.read).to be == "Hello World"
+			expect(body).to be(:empty?)
+			
+			body.rewind
+			expect(body.read).to be == "Hello World"
+			expect(body).to be(:empty?)
+		end
+	end
+	
+	with "#rewindable?" do
+		it "is rewindable" do
+			expect(body).to be(:rewindable?)
+		end
+	end
+	
+	with "#inspect" do
+		it "can generate string representation" do
+			expect(body.inspect).to be == "#<Protocol::HTTP::Body::Buffered empty> | #<Protocol::HTTP::Body::Rewindable 0/0 chunks read>"
+		end
+	end
+	
+	with "#as_json" do
+		it "includes rewind tracking information" do
+			expect(body.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Rewindable",
+				index: be == 0,
+				chunks: be == 0
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/stream.rb 0.55.0-1/test/protocol/http/body/stream.rb
--- 0.23.12-1/test/protocol/http/body/stream.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/stream.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,335 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2022-2025, by Samuel Williams.
+
+require "protocol/http/body/stream"
+require "protocol/http/body/buffered"
+
+describe Protocol::HTTP::Body::Stream do
+	let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello", "World"])}
+	let(:output) {Protocol::HTTP::Body::Buffered.new}
+	let(:stream) {subject.new(input, output)}
+	
+	with "no input" do
+		let(:input) {nil}
+		
+		it "should be empty" do
+			expect(stream).to be(:empty?)
+		end
+		
+		it "should read nothing" do
+			expect(stream.read).to be == ""
+		end
+	end
+	
+	with "#empty?" do
+		it "should be empty" do
+			expect(stream).to be(:empty?)
+		end
+	end
+	
+	with "#read" do
+		it "should read from the input" do
+			expect(stream.read(5)).to be == "Hello"
+		end
+		
+		it "can handle zero-length read" do
+			expect(stream.read(0)).to be == ""
+		end
+		
+		it "can read the entire input" do
+			expect(stream.read).to be == "HelloWorld"
+		end
+		
+		it "should read from the input into the given buffer" do
+			buffer = String.new
+			expect(stream.read(5, buffer)).to be == "Hello"
+			expect(buffer).to be == "Hello"
+			expect(stream.read(5, buffer)).to be == "World"
+			expect(buffer).to be == "World"
+			expect(stream.read(5, buffer)).to be == nil
+			expect(buffer).to be == ""
+		end
+		
+		it "can read partial input" do
+			expect(stream.read(2)).to be == "He"
+			expect(stream.read(2)).to be == "ll"
+			expect(stream.read(2)).to be == "oW"
+			expect(stream.read(2)).to be == "or"
+			expect(stream.read(2)).to be == "ld"
+			expect(stream.read(2)).to be == nil
+		end
+		
+		it "can read partial input into the given buffer" do
+			buffer = String.new
+			expect(stream.read(100, buffer)).to be == "HelloWorld"
+			expect(buffer).to be == "HelloWorld"
+			
+			expect(stream.read(2, buffer)).to be == nil
+			expect(buffer).to be == ""
+		end
+	end
+	
+	with "#read_nonblock" do
+		it "should read from the input" do
+			expect(stream.read_nonblock(5)).to be == "Hello"
+			expect(stream.read_nonblock(5)).to be == "World"
+			expect(stream.read_nonblock(5)).to be == nil
+		end
+		
+		it "should read from the input into the given buffer" do
+			buffer = String.new
+			expect(stream.read_nonblock(5, buffer)).to be == "Hello"
+			expect(buffer).to be == "Hello"
+			expect(stream.read_nonblock(5, buffer)).to be == "World"
+			expect(buffer).to be == "World"
+			expect(stream.read_nonblock(5, buffer)).to be == nil
+			expect(buffer).to be == ""
+		end
+		
+		it "can read input into the given buffer" do
+			buffer = String.new
+			expect(stream.read_nonblock(100, buffer)).to be == "Hello"
+			expect(buffer).to be == "Hello"
+			
+			expect(stream.read_nonblock(100, buffer)).to be == "World"
+			expect(buffer).to be == "World"
+			
+			expect(stream.read_nonblock(2, buffer)).to be == nil
+			expect(buffer).to be == ""
+		end
+		
+		it "can read partial input" do
+			expect(stream.read_nonblock(2)).to be == "He"
+			expect(stream.read_nonblock(2)).to be == "ll"
+			expect(stream.read_nonblock(2)).to be == "o"
+			expect(stream.read_nonblock(2)).to be == "Wo"
+			expect(stream.read_nonblock(2)).to be == "rl"
+			expect(stream.read_nonblock(2)).to be == "d"
+			expect(stream.read_nonblock(2)).to be == nil
+		end
+	end
+	
+	with "#read_partial" do
+		it "can read partial input" do
+			expect(stream.read_partial(2)).to be == "He"
+			expect(stream.read_partial(2)).to be == "ll"
+			expect(stream.read_partial(2)).to be == "o"
+			expect(stream.read_partial(2)).to be == "Wo"
+			expect(stream.read_partial(2)).to be == "rl"
+			expect(stream.read_partial(2)).to be == "d"
+			expect(stream.read_partial(2)).to be == nil
+		end
+		
+		it "can read partial input with buffer" do
+			buffer = String.new
+			expect(stream.read_partial(2, buffer)).to be == "He"
+			expect(buffer).to be == "He"
+			expect(stream.read_partial(2, buffer)).to be == "ll"
+			expect(buffer).to be == "ll"
+			expect(stream.read_partial(2, buffer)).to be == "o"
+			expect(buffer).to be == "o"
+			expect(stream.read_partial(2, buffer)).to be == "Wo"
+			expect(buffer).to be == "Wo"
+			expect(stream.read_partial(2, buffer)).to be == "rl"
+			expect(buffer).to be == "rl"
+			expect(stream.read_partial(2, buffer)).to be == "d"
+			expect(buffer).to be == "d"
+			expect(stream.read_partial(2, buffer)).to be == nil
+			expect(buffer).to be == ""
+		end
+	end
+	
+	with "#readpartial" do
+		it "can read partial input" do
+			expect(stream.readpartial(20)).to be == "Hello"
+			expect(stream.readpartial(20)).to be == "World"
+			expect{stream.readpartial(20)}.to raise_exception(EOFError)
+		end
+		
+		it "can read partial input with buffer" do
+			buffer = String.new
+			expect(stream.readpartial(20, buffer)).to be == "Hello"
+			expect(buffer).to be == "Hello"
+			expect(stream.readpartial(20, buffer)).to be == "World"
+			expect(buffer).to be == "World"
+			expect{stream.readpartial(20, buffer)}.to raise_exception(EOFError)
+			expect(buffer).to be == ""
+		end
+	end
+	
+	with "#each" do
+		it "can iterate over input" do
+			chunks = []
+			
+			stream.each do |chunk|
+				chunks << chunk
+			end
+			
+			expect(chunks).to be == ["Hello", "World"]
+		end
+		
+		it "can iterate over input with buffer" do
+			expect(stream.read(2)).to be == "He"
+			
+			chunks = []
+			
+			stream.each do |chunk|
+				chunks << chunk
+			end
+			
+			expect(chunks).to be == ["llo", "World"]
+		end
+		
+		it "can return an enumerator" do
+			expect(stream.each.to_a).to be == ["Hello", "World"]
+		end
+	end
+	
+	with "#read_until" do
+		it "can read until a pattern is encountered" do
+			expect(stream.read_until("o")).to be == "Hello"
+			expect(stream.read_until("d")).to be == "World"
+		end
+		
+		it "can read until a pattern which isn't encountered" do
+			expect(stream.read_until("X")).to be_nil
+		end
+	end
+	
+	with "#gets" do
+		let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello\nWorld\n"])}
+		
+		it "can read lines" do
+			expect(stream.gets).to be == "Hello\n"
+			expect(stream.gets).to be == "World\n"
+			expect(stream.gets).to be == nil
+		end
+		
+		it "can read up until the limit" do
+			expect(stream.gets("X", 2)).to be == "He"
+		end
+		
+		it "can read lines with limit" do
+			expect(stream.gets(2)).to be == "He"
+			expect(stream.gets(6)).to be == "llo\n"
+			expect(stream.gets(2)).to be == "Wo"
+			expect(stream.gets(6)).to be == "rld\n"
+			expect(stream.gets(2)).to be == nil
+		end
+		
+		it "can read lines and chomp separators" do
+			expect(stream.gets(chomp: true)).to be == "Hello"
+			expect(stream.gets(chomp: true)).to be == "World"
+			expect(stream.gets(chomp: true)).to be == nil
+		end
+		
+		it "can read without separator" do
+			expect(stream.gets(nil, 4)).to be == "Hell"
+			expect(stream.gets(nil, 4)).to be == "o\nWo"
+			expect(stream.gets(nil, 4)).to be == "rld\n"
+			expect(stream.gets(nil, 4)).to be == nil
+		end
+		
+		with "several chunks" do
+			let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello ", "World\n"])}
+			
+			it "can read lines" do
+				expect(stream.gets).to be == "Hello World\n"
+				expect(stream.gets).to be == nil
+			end
+		end
+		
+		with "incomplete line at the end" do
+			let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello\nWorld"])}
+			
+			it "returns the remaining buffer when there is no more data to read" do
+				expect(stream.gets).to be == "Hello\n"
+				expect(stream.gets).to be == "World"
+				expect(stream.gets).to be == nil
+			end
+		end
+	end
+	
+	with "#close_read" do
+		it "should close the input" do
+			stream.read(1)
+			stream.close_read
+			expect{stream.read(1)}.to raise_exception(IOError)
+		end
+	end
+	
+	with "#write" do
+		it "should write to the output" do
+			expect(stream.write("Hello")).to be == 5
+			expect(stream.write("World")).to be == 5
+			
+			expect(output.chunks).to be == ["Hello", "World"]
+		end
+	end
+	
+	with "#<<" do
+		it "should write to the output" do
+			stream << "Hello"
+			stream << "World"
+			
+			expect(output.chunks).to be == ["Hello", "World"]
+		end
+	end
+	
+	with "#write_nonblock" do
+		it "should write to the output" do
+			stream.write_nonblock("Hello")
+			stream.write_nonblock("World")
+			
+			expect(output.chunks).to be == ["Hello", "World"]
+		end
+	end
+	
+	with "#puts" do
+		it "should write lines to the output" do
+			stream.puts("Hello", "World")
+			stream.puts("Goodbye")
+			
+			expect(output.chunks).to be == ["Hello\nWorld\n", "Goodbye\n"]
+		end
+	end
+	
+	with "#close_write" do
+		it "should close the input" do
+			stream.close_write
+			expect{stream.write("X")}.to raise_exception(IOError)
+		end
+	end
+	
+	with "#flush" do
+		it "can be flushed" do	
+			# For streams, this is a no-op since buffering is handled by the output body.
+			stream.flush
+		end
+	end
+	
+	with "#close" do
+		it "can can be closed" do
+			stream.close
+			expect(stream).to be(:closed?)
+		end
+		
+		it "can be closed multiple times" do
+			stream.close
+			stream.close
+			expect(stream).to be(:closed?)
+		end
+	end
+	
+	with "IO.copy_stream" do
+		let(:output) {StringIO.new}
+		
+		it "can copy input to output" do
+			::IO.copy_stream(stream, output)
+			
+			expect(output.string).to be == "HelloWorld"
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/streamable.rb 0.55.0-1/test/protocol/http/body/streamable.rb
--- 0.23.12-1/test/protocol/http/body/streamable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/streamable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,309 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024-2025, by Samuel Williams.
+
+require "protocol/http/body/streamable"
+require "sus/fixtures/async"
+
+describe Protocol::HTTP::Body::Streamable do
+	include Sus::Fixtures::Async::ReactorContext
+	
+	let(:block) do
+		proc do |stream|
+			stream.write("Hello")
+			stream.write("World")
+			stream.close
+		end
+	end
+	
+	let(:body) {subject.request(&block)}
+	
+	with ".request" do
+		it "can create a new body" do
+			body = subject.request(&block)
+			expect(body).to be_a(Protocol::HTTP::Body::Streamable::RequestBody)
+		end
+	end
+	
+	with ".response" do
+		let(:request) {Protocol::HTTP::Request.new("GET", "/")}
+		
+		it "can create a new body" do
+			body = subject.response(request, &block)
+			expect(body).to be_a(Protocol::HTTP::Body::Streamable::Body)
+		end
+	end
+	
+	with "#stream?" do
+		it "should be streamable" do
+			expect(body).to be(:stream?)
+		end
+	end
+	
+	with "#read" do
+		it "can read the body" do
+			expect(body.read).to be == "Hello"
+			expect(body.read).to be == "World"
+			expect(body.read).to be == nil
+		end
+	end
+	
+	with "#close_write" do
+		let(:block) do
+			proc do |stream|
+				stream.close_write
+			end
+		end
+		
+		it "can close the output body" do
+			expect(body.read).to be == nil
+		end
+	end
+	
+	with "#each" do
+		it "can read the body" do
+			chunks = []
+			body.each{|chunk| chunks << chunk}
+			expect(chunks).to be == ["Hello", "World"]
+		end
+	end
+	
+	with "#call" do
+		it "can read the body" do
+			stream = StringIO.new
+			body.call(stream)
+			expect(stream.string).to be == "HelloWorld"
+		end
+		
+		it "will fail if invoked twice" do
+			stream = StringIO.new
+			body.call(stream)
+			
+			expect do
+				body.call(stream)
+			end.to raise_exception(Protocol::HTTP::Body::Streamable::ConsumedError)
+		end
+		
+		it "will fail if trying to read after streaming" do
+			stream = StringIO.new
+			body.call(stream)
+			
+			expect do
+				body.read
+			end.to raise_exception(Protocol::HTTP::Body::Streamable::ConsumedError)
+		end
+	end
+	
+	with "#inspect" do
+		it "shows block available when not consumed" do
+			expect(body.inspect).to be == "#<Protocol::HTTP::Body::Streamable::RequestBody block available, not consumed>"
+		end
+		
+		it "shows output active after reading starts" do
+			# Start reading to create @output
+			body.read
+			expect(body.inspect).to be == "#<Protocol::HTTP::Body::Streamable::RequestBody block consumed, output active>"
+		end
+		
+		it "shows output closed after completion" do
+			# Consume the body, then close output to trigger final else state
+			body.read
+			body.close_output
+			expect(body.inspect).to be == "#<Protocol::HTTP::Body::Streamable::RequestBody block consumed, output closed>"
+		end
+		
+		with "a block that raises an error" do
+			let(:block) do
+				proc do |stream|
+					stream.write("Hello")
+					
+					raise "Oh no... a wild error appeared!"
+				ensure
+					stream.close
+				end
+			end
+			
+			it "closes the stream if an error occurs" do
+				stream = StringIO.new
+				
+				expect do
+					body.call(stream)
+				end.to raise_exception(RuntimeError, message: be =~ /Oh no... a wild error appeared!/)
+				
+				expect(stream.string).to be == "Hello"
+			end
+		end
+	end
+	
+	with "#close" do
+		it "can close the body" do
+			expect(body.read).to be == "Hello"
+			
+			body.close
+		end
+		
+		it "can raise an error on the block" do
+			expect(body.read).to be == "Hello"
+			body.close(RuntimeError.new("Oh no!"))
+		end
+	end
+	
+	with "nested fiber" do
+		let(:block) do
+			proc do |stream|
+				Fiber.new do
+					stream.write("Hello")
+				end.resume
+			end
+		end
+		
+		it "can read a chunk" do
+			expect(body.read).to be == "Hello"
+		end
+	end
+	
+	with "buffered input" do
+		let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])}
+		
+		let(:block) do
+			proc do |stream|
+				while chunk = stream.read_partial
+					stream.write(chunk)
+				end
+			end
+		end
+		
+		let(:response) {Protocol::HTTP::Response[200, body: input]}
+		let(:body) {subject.response(response, &block)}
+		
+		it "can read from input" do
+			expect(body.read).to be == "Hello"
+			expect(body.read).to be == " "
+			expect(body.read).to be == "World"
+		end
+		
+		it "can stream to output" do
+			output = StringIO.new
+			stream = Protocol::HTTP::Body::Stream.new(input, output)
+			
+			body.call(stream)
+			
+			expect(output.string).to be == "Hello World"
+		end
+		
+		with "#close" do
+			it "can close the body" do
+				expect(input).not.to receive(:close)
+				
+				expect(body.read).to be == "Hello"
+				body.close
+			end
+		end
+	end
+	
+	with "#stream" do
+		let(:block) do
+			proc do |stream|
+				while chunk = stream.read_partial
+					stream.write(chunk)
+				end
+			rescue => error
+			ensure
+				stream.close(error)
+			end
+		end
+		
+		it "can stream to output" do
+			input = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])
+			
+			body.stream(input)
+			
+			expect(body.read).to be == "Hello"
+			expect(body.read).to be == " "
+			expect(body.read).to be == "World"
+			
+			body.close
+		end
+		
+		it "can stream to output with an error" do
+			input = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])
+			
+			mock(input) do |mock|
+				mock.replace(:read) do
+					raise "Oh no!"
+				end
+			end
+			
+			body.stream(input)
+			
+			expect do
+				body.read
+			end.to raise_exception(RuntimeError, message: be =~ /Oh no!/)
+		end
+	end
+	
+	with "streaming in a different task" do
+		let(:block) do
+			proc do |stream|
+				while chunk = stream.read_partial
+					stream.write(chunk)
+				end
+			rescue => error
+			ensure
+				stream.close(error)
+			end
+		end
+		
+		let(:input) {Protocol::HTTP::Body::Writable.new}
+		let(:output) {Protocol::HTTP::Body::Writable.new}
+		
+		before do
+			parent = Async::Task.current
+			
+			@input_task = parent.async do
+				body.stream(input)
+			end
+			
+			@output_task = parent.async do
+				while chunk = body.read
+					output.write(chunk)
+				end
+			rescue => error
+			ensure
+				output.close_write(error)
+			end
+		end
+		
+		after do
+			@input_task&.wait
+			@output_task&.wait
+		end
+		
+		it "can stream a chunk" do
+			input.write("Hello")
+			input.close_write
+			expect(output.read).to be == "Hello"
+		end
+		
+		it "can stream multiple chunks" do
+			input.write("Hello")
+			input.write(" ")
+			input.write("World")
+			input.close_write
+			
+			expect(output.read).to be == "Hello"
+			expect(output.read).to be == " "
+			expect(output.read).to be == "World"
+		end
+		
+		it "can stream an error" do
+			input.write("Hello")
+			input.close_write(RuntimeError.new("Oh no!"))
+			
+			expect do
+				output.read
+			end.to raise_exception(RuntimeError, message: be =~ /Oh no!/)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/wrapper.rb 0.55.0-1/test/protocol/http/body/wrapper.rb
--- 0.23.12-1/test/protocol/http/body/wrapper.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/wrapper.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2024, by Samuel Williams.
+
+require "protocol/http/body/wrapper"
+require "protocol/http/body/buffered"
+require "protocol/http/request"
+
+require "json"
+require "stringio"
+
+describe Protocol::HTTP::Body::Wrapper do
+	let(:source) {Protocol::HTTP::Body::Buffered.new}
+	let(:body) {subject.new(source)}
+	
+	with "#stream?" do
+		it "should not be streamable" do
+			expect(body).not.to be(:stream?)
+		end
+	end
+	
+	it "should proxy close" do
+		expect(source).to receive(:close).and_return(nil)
+		body.close
+	end
+	
+	it "should proxy empty?" do
+		expect(source).to receive(:empty?).and_return(true)
+		expect(body.empty?).to be == true
+	end
+	
+	it "should proxy ready?" do
+		expect(source).to receive(:ready?).and_return(true)
+		expect(body.ready?).to be == true
+	end
+	
+	it "should proxy length" do
+		expect(source).to receive(:length).and_return(1)
+		expect(body.length).to be == 1
+	end
+	
+	it "should proxy read" do
+		expect(source).to receive(:read).and_return("!")
+		expect(body.read).to be == "!"
+	end
+	
+	it "should proxy inspect" do
+		expect(source).to receive(:inspect).and_return("!")
+		expect(body.inspect).to be(:include?, "!")
+	end
+	
+	with ".wrap" do
+		let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)}
+		
+		it "should wrap body" do
+			subject.wrap(message)
+			
+			expect(message.body).to be_a(Protocol::HTTP::Body::Wrapper)
+		end
+	end
+	
+	with "#buffered" do
+		it "should proxy buffered" do
+			expect(source).to receive(:buffered).and_return(true)
+			expect(body.buffered).to be == true
+		end
+	end
+	
+	with "#rewindable?" do
+		it "should proxy rewindable?" do
+			expect(source).to receive(:rewindable?).and_return(true)
+			expect(body.rewindable?).to be == true
+		end
+	end
+	
+	with "#rewind" do
+		it "should proxy rewind" do
+			expect(source).to receive(:rewind).and_return(true)
+			expect(body.rewind).to be == true
+		end
+	end
+	
+	with "#as_json" do
+		it "generates a JSON representation" do
+			expect(body.as_json).to have_keys(
+				class: be == "Protocol::HTTP::Body::Wrapper",
+				body: be == source.as_json
+			)
+		end
+		
+		it "generates a JSON string" do
+			expect(JSON.dump(body)).to be == body.to_json
+		end
+	end
+	
+	with "#each" do
+		it "should invoke close correctly" do
+			expect(body).to receive(:close)
+			
+			body.each{}
+		end
+	end
+	
+	with "#stream" do
+		let(:stream) {StringIO.new}
+		
+		it "should invoke close correctly" do
+			expect(body).to receive(:close)
+			
+			body.call(stream)
+		end
+	end
+	
+	with "#discard" do
+		it "should proxy discard" do
+			expect(source).to receive(:discard).and_return(nil)
+			expect(body.discard).to be_nil
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/body/writable.rb 0.55.0-1/test/protocol/http/body/writable.rb
--- 0.23.12-1/test/protocol/http/body/writable.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/body/writable.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024, by Samuel Williams.
+
+require "protocol/http/body/writable"
+require "protocol/http/body/deflate"
+require "protocol/http/body/a_writable_body"
+
+describe Protocol::HTTP::Body::Writable do
+	let(:body) {subject.new}
+	
+	it_behaves_like Protocol::HTTP::Body::AWritableBody
+	
+	with "#length" do
+		it "should be unspecified by default" do
+			expect(body.length).to be_nil
+		end
+	end
+	
+	with "#closed?" do
+		it "should not be closed by default" do
+			expect(body).not.to be(:closed?)
+		end
+	end
+	
+	with "#ready?" do
+		it "should be ready if chunks are available" do
+			expect(body).not.to be(:ready?)
+			
+			body.write("Hello")
+			
+			expect(body).to be(:ready?)
+		end
+		
+		it "should be ready if closed" do
+			body.close
+			
+			expect(body).to be(:ready?)
+		end
+	end
+	
+	with "#empty?" do
+		it "should be empty if closed with no pending chunks" do
+			expect(body).not.to be(:empty?)
+			
+			body.close_write
+			
+			expect(body).to be(:empty?)
+		end
+		
+		it "should become empty when pending chunks are read" do
+			body.write("Hello")
+			
+			body.close_write
+			
+			expect(body).not.to be(:empty?)
+			body.read
+			expect(body).to be(:empty?)
+		end
+		
+		it "should not be empty if chunks are available" do
+			body.write("Hello")
+			expect(body).not.to be(:empty?)
+		end
+	end
+	
+	with "#write" do
+		it "should write chunks" do
+			body.write("Hello")
+			body.write("World")
+			
+			expect(body.read).to be == "Hello"
+			expect(body.read).to be == "World"
+		end
+		
+		it "can't write to closed body" do
+			body.close
+			
+			expect do
+				body.write("Hello")
+			end.to raise_exception(Protocol::HTTP::Body::Writable::Closed)
+		end
+		
+		it "can write and read data" do
+			3.times do |i|
+				body.write("Hello World #{i}")
+				expect(body.read).to be == "Hello World #{i}"
+			end
+		end
+		
+		it "can buffer data in order" do
+			3.times do |i|
+				body.write("Hello World #{i}")
+			end
+			
+			3.times do |i|
+				expect(body.read).to be == "Hello World #{i}"
+			end
+		end
+	end
+	
+	with "#join" do
+		it "can join chunks" do
+			3.times do |i|
+				body.write("#{i}")
+			end
+			
+			body.close_write
+			
+			expect(body.join).to be == "012"
+		end
+	end
+	
+	with "#each" do
+		it "can read all data in order" do
+			3.times do |i|
+				body.write("Hello World #{i}")
+			end
+			
+			body.close_write
+			
+			3.times do |i|
+				chunk = body.read
+				expect(chunk).to be == "Hello World #{i}"
+			end
+		end
+		
+		it "can propagate failures" do
+			body.write("Beep boop") # This will cause a failure.
+			
+			expect do
+				body.each do |chunk|
+					raise RuntimeError.new("It was too big!")
+				end
+			end.to raise_exception(RuntimeError, message: be =~ /big/)
+			
+			expect do
+				body.write("Beep boop") # This will fail.
+			end.to raise_exception(RuntimeError, message: be =~ /big/)
+		end
+		
+		it "can propagate failures in nested bodies" do
+			nested = ::Protocol::HTTP::Body::Deflate.for(body)
+			
+			body.write("Beep boop") # This will cause a failure.
+			
+			expect do
+				nested.each do |chunk|
+					raise RuntimeError.new("It was too big!")
+				end
+			end.to raise_exception(RuntimeError, message: be =~ /big/)
+			
+			expect do
+				body.write("Beep boop") # This will fail.
+			end.to raise_exception(RuntimeError, message: be =~ /big/)
+		end
+		
+		it "will stop after finishing" do
+			body.write("Hello World!")
+			body.close_write
+			
+			expect(body).not.to be(:empty?)
+			
+			body.each do |chunk|
+				expect(chunk).to be == "Hello World!"
+			end
+			
+			expect(body).to be(:empty?)
+		end
+	end
+	
+	with "#output" do
+		it "can be used to write data" do
+			body.output do |output|
+				output.write("Hello World!")
+			end
+			
+			expect(body.output).to be(:closed?)
+			
+			expect(body.read).to be == "Hello World!"
+			expect(body.read).to be_nil
+		end
+		
+		it "can propagate errors" do
+			expect do
+				body.output do |output|
+					raise "Oops!"
+				end
+			end.to raise_exception(RuntimeError, message: be =~ /Oops/)
+			
+			expect(body).to be(:closed?)
+			
+			expect do
+				body.read
+			end.to raise_exception(RuntimeError, message: be =~ /Oops/)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/content_encoding.rb 0.55.0-1/test/protocol/http/content_encoding.rb
--- 0.23.12-1/test/protocol/http/content_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/content_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/accept_encoding"
+require "protocol/http/content_encoding"
+
+describe Protocol::HTTP::ContentEncoding do
+	with "complete text/plain response" do
+		let(:middleware) {subject.new(Protocol::HTTP::Middleware::HelloWorld)}
+		let(:accept_encoding) {Protocol::HTTP::AcceptEncoding.new(middleware)}
+		
+		it "can request resource without compression" do
+			response = middleware.get("/index")
+			
+			expect(response).to be(:success?)
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.headers["vary"]).to be(:include?, "accept-encoding")
+			
+			expect(response.read).to be == "Hello World!"
+		end
+		
+		it "can request a resource with the identity encoding" do
+			response = accept_encoding.get("/index", {"accept-encoding" => "identity"})
+			
+			expect(response).to be(:success?)
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.headers["vary"]).to be(:include?, "accept-encoding")
+			
+			expect(response.read).to be == "Hello World!"
+		end
+		
+		it "can request resource with compression" do
+			response = accept_encoding.get("/index", {"accept-encoding" => "gzip"})
+			expect(response).to be(:success?)
+			
+			expect(response.headers["vary"]).to be(:include?, "accept-encoding")
+			
+			expect(response.body).to be_a(Protocol::HTTP::Body::Inflate)
+			expect(response.read).to be == "Hello World!"
+		end
+	end
+	
+	with "partial response" do
+		let(:app) do
+			proc do |request|
+				Protocol::HTTP::Response[206, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]]
+			end
+		end
+		
+		let(:client) {subject.new(app)}
+		
+		it "can request resource with compression" do
+			response = client.get("/index", {"accept-encoding" => "gzip"})
+			expect(response).to be(:success?)
+			
+			expect(response.headers).not.to have_keys("content-encoding")
+			expect(response.read).to be == "Hello World!"
+		end
+	end
+	
+	with "existing content encoding" do
+		let(:app) do
+			app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain", "content-encoding" => "identity"], ["Hello World!"]]
+			}
+		end
+		
+		let(:client) {subject.new(app)}
+		
+		it "does not compress response" do
+			response = client.get("/index", {"accept-encoding" => "gzip"})
+			
+			expect(response).to be(:success?)
+			expect(response.headers).to have_keys("content-encoding")
+			expect(response.headers["content-encoding"]).to be == ["identity"]
+			
+			expect(response.read).to be == "Hello World!"
+		end
+	end
+	
+	with "nil body" do
+		let(:app) do
+			app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], nil]
+			}
+		end
+		
+		let(:client) {subject.new(app)}
+		
+		it "does not compress response" do
+			response = client.get("/index", {"accept-encoding" => "gzip"})
+			
+			expect(response).to be(:success?)
+			expect(response.headers).not.to have_keys("content-encoding")
+			
+			expect(response.read).to be == nil
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/cookie.rb 0.55.0-1/test/protocol/http/cookie.rb
--- 0.23.12-1/test/protocol/http/cookie.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/cookie.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/cookie"
+
+describe Protocol::HTTP::Cookie do
+	describe "#initialize" do
+		it "accepts valid cookie names" do
+			cookie = Protocol::HTTP::Cookie.new("session_id", "123")
+			expect(cookie.name).to be == "session_id"
+			expect(cookie.value).to be == "123"
+		end
+		
+		it "accepts valid cookie values with allowed characters" do
+			# Test cookie-octet range: !#$%&'()*+-./0-9:;<=>?@A-Z[]^_`a-z{|}~
+			cookie = Protocol::HTTP::Cookie.new("test", "abc123!#$%&'()*+-./:")
+			expect(cookie.value).to be == "abc123!#$%&'()*+-./:"
+		end
+		
+		it "rejects cookie names with invalid characters" do
+			expect do
+				Protocol::HTTP::Cookie.new("session id", "123")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/)
+		end
+		
+		it "rejects cookie names with semicolon" do
+			expect do
+				Protocol::HTTP::Cookie.new("session;id", "123")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/)
+		end
+		
+		it "rejects cookie values with control characters" do
+			expect do
+				Protocol::HTTP::Cookie.new("session", "123\n456")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/)
+		end
+		
+		it "rejects cookie values with semicolon" do
+			expect do
+				Protocol::HTTP::Cookie.new("session", "123;456")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/)
+		end
+		
+		it "rejects cookie values with comma" do
+			expect do
+				Protocol::HTTP::Cookie.new("session", "123,456")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/)
+		end
+		
+		it "rejects cookie values with backslash" do
+			expect do
+				Protocol::HTTP::Cookie.new("session", "123\\456")
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/)
+		end
+		
+		it "rejects cookie values with double quote" do
+			expect do
+				Protocol::HTTP::Cookie.new("session", '"quoted"')
+			end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/)
+		end
+		
+		it "accepts nil value" do
+			cookie = Protocol::HTTP::Cookie.new("session", nil)
+			expect(cookie.value).to be_nil
+		end
+	end
+	
+	describe "#to_s" do
+		it "returns cookie name and value" do
+			cookie = Protocol::HTTP::Cookie.new("session", "abc123")
+			expect(cookie.to_s).to be == "session=abc123"
+		end
+		
+		it "includes directives" do
+			cookie = Protocol::HTTP::Cookie.new("session", "123", {"path" => "/", "secure" => true})
+			expect(cookie.to_s).to be == "session=123;path=/;secure"
+		end
+	end
+	
+	describe ".parse" do
+		it "parses simple cookie" do
+			cookie = Protocol::HTTP::Cookie.parse("session=123")
+			expect(cookie.name).to be == "session"
+			expect(cookie.value).to be == "123"
+		end
+		
+		it "parses cookie with equals in value" do
+			cookie = Protocol::HTTP::Cookie.parse("session=123==")
+			expect(cookie.name).to be == "session"
+			expect(cookie.value).to be == "123=="
+		end
+		
+		it "parses cookie with directives" do
+			cookie = Protocol::HTTP::Cookie.parse("session=123; path=/; secure")
+			expect(cookie.name).to be == "session"
+			expect(cookie.value).to be == "123"
+			expect(cookie.directives).to be == {"path" => "/", "secure" => true}
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/accept.rb 0.55.0-1/test/protocol/http/header/accept.rb
--- 0.23.12-1/test/protocol/http/header/accept.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/accept.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/accept"
+
+describe Protocol::HTTP::Header::Accept::MediaRange do
+	it "should have default quality_factor of 1.0" do
+		media_range = subject.new("text/plain", nil)
+		expect(media_range.quality_factor).to be == 1.0
+	end
+	
+	with "#to_s" do
+		it "can convert to string" do
+			media_range = subject.new("text", "plain", {"q" => "0.5"})
+			expect(media_range.to_s).to be == "text/plain;q=0.5"
+		end
+	end
+end
+
+describe Protocol::HTTP::Header::Accept do
+	let(:header) {subject.new(description)}
+	let(:media_ranges) {header.media_ranges.sort}
+	
+	with "text/plain, text/html;q=0.5, text/xml;q=0.25" do
+		it "can parse media ranges" do
+			expect(header.length).to be == 3
+			
+			expect(media_ranges[0]).to have_attributes(
+				type: be == "text",
+				subtype: be == "plain",
+				quality_factor: be == 1.0
+			)
+			
+			expect(media_ranges[1]).to have_attributes(
+				type: be == "text",
+				subtype: be == "html",
+				quality_factor: be == 0.5
+			)
+			
+			expect(media_ranges[2]).to have_attributes(
+				type: be == "text",
+				subtype: be == "xml",
+				quality_factor: be == 0.25
+			)
+		end
+		
+		it "can convert to string" do
+			expect(header.to_s).to be == "text/plain,text/html;q=0.5,text/xml;q=0.25"
+		end
+	end
+	
+	with "foobar" do
+		it "fails to parse" do
+			expect{media_ranges}.to raise_exception(Protocol::HTTP::Header::Accept::ParseError)
+		end
+	end
+	
+	with "text/html;q=0.25, text/xml;q=0.5, text/plain" do
+		it "should order based on quality factor" do
+			expect(media_ranges.collect(&:to_s)).to be == %w{text/plain text/xml;q=0.5 text/html;q=0.25}
+		end
+	end
+	
+	with "text/html, text/plain;q=0.8, text/xml;q=0.6, application/json" do
+		it "should order based on quality factor" do
+			expect(media_ranges.collect(&:to_s)).to be == %w{text/html application/json text/plain;q=0.8 text/xml;q=0.6}
+		end
+	end
+	
+	with "*/*" do
+		it "should accept wildcard media range" do
+			expect(media_ranges[0].to_s).to be == "*/*"
+		end
+	end
+	
+	with "text/html;schema=\"example.org\";q=0.5" do
+		it "should parse parameters" do
+			expect(media_ranges[0].parameters).to have_keys(
+				"schema" => be == "example.org",
+				"q" => be == "0.5",
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/accept_charset.rb 0.55.0-1/test/protocol/http/header/accept_charset.rb
--- 0.23.12-1/test/protocol/http/header/accept_charset.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/accept_charset.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/accept_charset"
+
+describe Protocol::HTTP::Header::AcceptCharset::Charset do
+	it "should have default quality_factor of 1.0" do
+		charset = subject.new("utf-8", nil)
+		expect(charset.quality_factor).to be == 1.0
+	end
+end
+
+describe Protocol::HTTP::Header::AcceptCharset do
+	let(:header) {subject.new(description)}
+	let(:charsets) {header.charsets.sort}
+	
+	with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.25" do
+		it "can parse charsets" do
+			expect(header.length).to be == 3
+			
+			expect(charsets[0].name).to be == "utf-8"
+			expect(charsets[0].quality_factor).to be == 1.0
+			
+			expect(charsets[1].name).to be == "iso-8859-1"
+			expect(charsets[1].quality_factor).to be == 0.5
+			
+			expect(charsets[2].name).to be == "windows-1252"
+			expect(charsets[2].quality_factor).to be == 0.25
+		end
+	end
+	
+	with "windows-1252;q=0.25, iso-8859-1;q=0.5, utf-8" do
+		it "should order based on quality factor" do
+			expect(charsets.collect(&:name)).to be == %w{utf-8 iso-8859-1 windows-1252}
+		end
+	end
+	
+	with "us-ascii,iso-8859-1;q=0.8,windows-1252;q=0.6,utf-8" do
+		it "should order based on quality factor" do
+			expect(charsets.collect(&:name)).to be == %w{us-ascii utf-8 iso-8859-1 windows-1252}
+		end
+	end
+	
+	with "*;q=0" do
+		it "should accept wildcard charset" do
+			expect(charsets[0].name).to be == "*"
+			expect(charsets[0].quality_factor).to be == 0
+		end
+	end
+	
+	with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.5" do
+		it "should preserve relative order" do
+			expect(charsets[0].name).to be == "utf-8"
+			expect(charsets[1].name).to be == "iso-8859-1"
+			expect(charsets[2].name).to be == "windows-1252"
+		end
+	end
+	
+	it "should not accept invalid input" do
+		bad_values = [
+			# Invalid quality factor:
+			"utf-8;f=1",
+			
+			# Invalid parameter:
+			"us-ascii;utf-8",
+			
+			# Invalid use of separator:
+			";",
+			
+			# Empty charset (we ignore this one):
+			# ","
+		]
+		
+		bad_values.each do |value|
+			expect{subject.new(value).charsets}.to raise_exception(subject::ParseError)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/accept_encoding.rb 0.55.0-1/test/protocol/http/header/accept_encoding.rb
--- 0.23.12-1/test/protocol/http/header/accept_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/accept_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/accept_encoding"
+
+describe Protocol::HTTP::Header::AcceptEncoding::Encoding do
+	it "should have default quality_factor of 1.0" do
+		encoding = subject.new("utf-8", nil)
+		expect(encoding.quality_factor).to be == 1.0
+	end
+end
+
+describe Protocol::HTTP::Header::AcceptEncoding do
+	let(:header) {subject.new(description)}
+	let(:encodings) {header.encodings.sort}
+	
+	with "gzip, deflate;q=0.5, identity;q=0.25" do
+		it "can parse charsets" do
+			expect(header.length).to be == 3
+			
+			expect(encodings[0].name).to be == "gzip"
+			expect(encodings[0].quality_factor).to be == 1.0
+			
+			expect(encodings[1].name).to be == "deflate"
+			expect(encodings[1].quality_factor).to be == 0.5
+			
+			expect(encodings[2].name).to be == "identity"
+			expect(encodings[2].quality_factor).to be == 0.25
+		end
+	end
+	
+	with "identity;q=0.25, deflate;q=0.5, gzip" do
+		it "should order based on quality factor" do
+			expect(encodings.collect(&:name)).to be == %w{gzip deflate identity}
+		end
+	end
+	
+	with "br,deflate;q=0.8,identity;q=0.6,gzip" do
+		it "should order based on quality factor" do
+			expect(encodings.collect(&:name)).to be == %w{br gzip deflate identity}
+		end
+	end
+	
+	with "*;q=0" do
+		it "should accept wildcard encoding" do
+			expect(encodings[0].name).to be == "*"
+			expect(encodings[0].quality_factor).to be == 0
+		end
+	end
+	
+	with "br, gzip;q=0.5, deflate;q=0.5" do
+		it "should preserve relative order" do
+			expect(encodings[0].name).to be == "br"
+			expect(encodings[1].name).to be == "gzip"
+			expect(encodings[2].name).to be == "deflate"
+		end
+	end
+	
+	it "should not accept invalid input" do
+		bad_values = [
+			# Invalid quality factor:
+			"br;f=1",
+			
+			# Invalid parameter:
+			"br;gzip",
+			
+			# Invalid use of separator:
+			";",
+			
+			# Empty (we ignore this one):
+			# ","
+		]
+		
+		bad_values.each do |value|
+			expect{subject.new(value).encodings}.to raise_exception(subject::ParseError)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/accept_language.rb 0.55.0-1/test/protocol/http/header/accept_language.rb
--- 0.23.12-1/test/protocol/http/header/accept_language.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/accept_language.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/accept_language"
+
+describe Protocol::HTTP::Header::AcceptLanguage::Language do
+	it "should have default quality_factor of 1.0" do
+		language = subject.new("utf-8", nil)
+		expect(language.quality_factor).to be == 1.0
+	end
+end
+
+describe Protocol::HTTP::Header::AcceptLanguage do
+	let(:header) {subject.new(description)}
+	let(:languages) {header.languages.sort}
+	
+	with "da, en-gb;q=0.5, en;q=0.25" do
+		it "can parse languages" do
+			expect(header.length).to be == 3
+			
+			expect(languages[0].name).to be == "da"
+			expect(languages[0].quality_factor).to be == 1.0
+			
+			expect(languages[1].name).to be == "en-gb"
+			expect(languages[1].quality_factor).to be == 0.5
+			
+			expect(languages[2].name).to be == "en"
+			expect(languages[2].quality_factor).to be == 0.25
+		end
+	end
+	
+	with "en-gb;q=0.25, en;q=0.5, en-us" do
+		it "should order based on quality factor" do
+			expect(languages.collect(&:name)).to be == %w{en-us en en-gb}
+		end
+	end
+	
+	with "en-us,en-gb;q=0.8,en;q=0.6,es-419" do
+		it "should order based on quality factor" do
+			expect(languages.collect(&:name)).to be == %w{en-us es-419 en-gb en}
+		end
+	end
+	
+	with "*;q=0" do
+		it "should accept wildcard language" do
+			expect(languages[0].name).to be == "*"
+			expect(languages[0].quality_factor).to be == 0
+		end
+	end
+	
+	with "en, de;q=0.5, jp;q=0.5" do
+		it "should preserve relative order" do
+			expect(languages[0].name).to be == "en"
+			expect(languages[1].name).to be == "de"
+			expect(languages[2].name).to be == "jp"
+		end
+	end
+	
+	with "de, en-US; q=0.7, en ; q=0.3" do
+		it "should parse with optional whitespace" do
+			expect(languages[0].name).to be == "de"
+			expect(languages[1].name).to be == "en-US"
+			expect(languages[2].name).to be == "en"
+		end
+	end
+	
+	with "en;q=0.123456" do
+		it "accepts quality factors with up to 6 decimal places" do
+			expect(languages[0].name).to be == "en"
+			expect(languages[0].quality_factor).to be == 0.123456
+		end
+	end
+	
+	it "should not accept invalid input" do
+		bad_values = [
+			# Invalid quality factor:
+			"en;f=1",
+			
+			# Invalid parameter:
+			"de;fr",
+			
+			# Invalid use of separator:
+			";",
+			
+			# Empty (we ignore this one):
+			# ","
+		]
+		
+		bad_values.each do |value|
+			expect{subject.new(value).languages}.to raise_exception(subject::ParseError)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/authorization.rb 0.55.0-1/test/protocol/http/header/authorization.rb
--- 0.23.12-1/test/protocol/http/header/authorization.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/authorization.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/header/authorization"
+require "protocol/http/headers"
+
+describe Protocol::HTTP::Header::Authorization do
+	with "basic username/password" do
+		let(:header) {subject.basic("samuel", "password")}
+		
+		it "should generate correct authorization header" do
+			expect(header).to be == "Basic c2FtdWVsOnBhc3N3b3Jk"
+		end
+		
+		with "#credentials" do
+			it "can split credentials" do
+				expect(header.credentials).to be == ["Basic", "c2FtdWVsOnBhc3N3b3Jk"]
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/cache_control.rb 0.55.0-1/test/protocol/http/header/cache_control.rb
--- 0.23.12-1/test/protocol/http/header/cache_control.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/cache_control.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+# Copyright, 2023, by Thomas Morgan.
+
+require "protocol/http/header/cache_control"
+
+describe Protocol::HTTP::Header::CacheControl do
+	let(:header) {subject.new(description)}
+	
+	with "max-age=60, s-maxage=30, public" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				public?: be == true,
+				private?: be == false,
+				max_age: be == 60,
+				s_maxage: be == 30,
+			)
+		end
+	end
+	
+	with "max-age=-10, s-maxage=0x22" do
+		it "gracefully handles invalid values" do
+			expect(header).to have_attributes(
+				max_age: be == nil,
+				s_maxage: be == nil,
+			)
+		end
+	end
+	
+	with "no-cache, no-store" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				no_cache?: be == true,
+				no_store?: be == true,
+			)
+		end
+	end
+	
+	with "static" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				static?: be == true,
+			)
+		end
+	end
+	
+	with "dynamic" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				dynamic?: be == true,
+			)
+		end
+	end
+	
+	with "streaming" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				streaming?: be == true,
+			)
+		end
+	end
+	
+	with "must-revalidate" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				must_revalidate?: be == true,
+			)
+		end
+	end
+	
+	with "proxy-revalidate" do
+		it "correctly parses cache header" do
+			expect(header).to have_attributes(
+				proxy_revalidate?: be == true,
+			)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can append values" do
+			header << "max-age=60"
+			expect(header).to have_attributes(
+				max_age: be == 60,
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/connection.rb 0.55.0-1/test/protocol/http/header/connection.rb
--- 0.23.12-1/test/protocol/http/header/connection.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/connection.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
+# Copyright, 2024, by Thomas Morgan.
+
+require "protocol/http/headers"
+require "protocol/http/cookie"
+
+describe Protocol::HTTP::Header::Connection do
+	let(:header) {subject.new(description)}
+	
+	with "close" do
+		it "should indiciate connection will be closed" do
+			expect(header).to be(:close?)
+		end
+		
+		it "should indiciate connection will not be keep-alive" do
+			expect(header).not.to be(:keep_alive?)
+		end
+	end
+	
+	with "keep-alive" do
+		it "should indiciate connection will not be closed" do
+			expect(header).not.to be(:close?)
+		end
+		
+		it "should indiciate connection is not keep-alive" do
+			expect(header).to be(:keep_alive?)
+		end
+	end
+	
+	with "close, keep-alive" do
+		it "should prioritize close over keep-alive" do
+			expect(header).to be(:close?)
+			expect(header).not.to be(:keep_alive?)
+		end
+	end
+	
+	with "upgrade" do
+		it "should indiciate connection can be upgraded" do
+			expect(header).to be(:upgrade?)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can append values" do
+			header << "close"
+			expect(header).to be(:close?)
+			
+			header << "upgrade"
+			expect(header).to be(:upgrade?)
+			
+			expect(header.to_s).to be == "close,upgrade"
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/cookie.rb 0.55.0-1/test/protocol/http/header/cookie.rb
--- 0.23.12-1/test/protocol/http/header/cookie.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/cookie.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2021-2025, by Samuel Williams.
+# Copyright, 2022, by Herrick Fang.
+
+require "protocol/http/header/cookie"
+
+describe Protocol::HTTP::Header::Cookie do
+	let(:header) {subject.new(description)}
+	let(:cookies) {header.to_h}
+	
+	with "session=123; secure" do
+		it "can parse cookies" do
+			expect(cookies).to have_keys("session")
+			
+			session = cookies["session"]
+			expect(session).to have_attributes(
+				name: be == "session",
+				value: be == "123",
+			)
+			expect(session.directives).to have_keys("secure")
+		end
+	end
+	
+	with "session=123; path=/; secure" do
+		it "can parse cookies" do
+			session = cookies["session"]
+			expect(session).to have_attributes(
+				name: be == "session",
+				value: be == "123",
+				directives: be == {"path" => "/", "secure" => true},
+			)
+		end
+		
+		it "has string representation" do
+			session = cookies["session"]
+			expect(session.to_s).to be == "session=123;path=/;secure"
+		end
+	end
+	
+	with "session=abc123; secure" do
+		it "can parse cookies" do
+			expect(cookies).to have_keys("session")
+			
+			session = cookies["session"]
+			expect(session).to have_attributes(
+				name: be == "session",
+				value: be == "abc123",
+			)
+			expect(session.directives).to have_keys("secure")
+		end
+		
+		it "has string representation" do
+			session = cookies["session"]
+			expect(session.to_s).to be == "session=abc123;secure"
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/date.rb 0.55.0-1/test/protocol/http/header/date.rb
--- 0.23.12-1/test/protocol/http/header/date.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/date.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/header/date"
+
+describe Protocol::HTTP::Header::Date do
+	let(:header) {subject.new(description)}
+	
+	with "Wed, 21 Oct 2015 07:28:00 GMT" do
+		it "can parse time" do
+			time = header.to_time
+			expect(time).to be_a(::Time)
+			
+			expect(time).to have_attributes(
+				year: be == 2015,
+				month: be == 10,
+				mday: be == 21,
+				hour: be == 7,
+				min: be == 28,
+				sec: be == 0
+			)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can replace values" do
+			header << "Wed, 21 Oct 2015 07:28:00 GMT"
+			expect(header.to_time).to have_attributes(
+				year: be == 2015,
+				month: be == 10,
+				mday: be == 21
+			)
+			
+			header << "Wed, 22 Oct 2015 07:28:00 GMT"
+			expect(header.to_time).to have_attributes(
+				year: be == 2015,
+				month: be == 10,
+				mday: be == 22
+			)
+		end
+	end
+	
+	describe Protocol::HTTP::Headers do
+		let(:headers) {subject[[
+				["Date", "Wed, 21 Oct 2015 07:28:00 GMT"],
+				["Expires", "Wed, 21 Oct 2015 07:28:00 GMT"],
+				["Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"],
+				["If-Modified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"],
+				["If-Unmodified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"]
+			]]
+		}
+		
+		it "should parse date headers" do
+			# When you convert headers into a hash, the policy is applied (i.e. conversion to Date instances):
+			headers.to_h.each do |key, value|
+				expect(value).to be_a(Protocol::HTTP::Header::Date)
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/digest.rb 0.55.0-1/test/protocol/http/header/digest.rb
--- 0.23.12-1/test/protocol/http/header/digest.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/digest.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/digest"
+require "sus"
+
+describe Protocol::HTTP::Header::Digest do
+	let(:header) {subject.new(description)}
+	
+	with "empty header" do
+		let(:header) {subject.new}
+		
+		it "should be empty" do
+			expect(header.to_s).to be == ""
+		end
+		
+		it "should be an array" do
+			expect(header).to be_a(Array)
+		end
+		
+		it "should return empty entries" do
+			expect(header.entries).to be == []
+		end
+	end
+	
+	with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do
+		it "can parse a single entry" do
+			entries = header.entries
+			expect(entries.size).to be == 1
+			expect(entries.first.algorithm).to be == "sha-256"
+			expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE="
+		end
+	end
+	
+	with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" do
+		it "can parse multiple entries" do
+			entries = header.entries
+			expect(entries.size).to be == 2
+			expect(entries[0].algorithm).to be == "sha-256"
+			expect(entries[1].algorithm).to be == "md5"
+		end
+	end
+	
+	with "SHA-256=abc123" do
+		it "normalizes algorithm to lowercase" do
+			entries = header.entries
+			expect(entries.first.algorithm).to be == "sha-256"
+		end
+	end
+	
+	with "sha-256 = X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do
+		it "handles whitespace around equals sign" do
+			entries = header.entries
+			expect(entries.first.algorithm).to be == "sha-256"
+			expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE="
+		end
+	end
+	
+	with "invalid-format-no-equals" do
+		it "raises ParseError for invalid format" do
+			expect do
+				header.entries
+			end.to raise_exception(Protocol::HTTP::Header::Digest::ParseError)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can add entries from string" do
+			header << "sha-256=abc123"
+			header << "md5=def456"
+			expect(header.size).to be == 2
+			
+			entries = header.entries
+			expect(entries[0].algorithm).to be == "sha-256"
+			expect(entries[1].algorithm).to be == "md5"
+		end
+		
+		it "can add multiple entries at once" do
+			header << "sha-256=abc123, md5=def456"
+			expect(header.size).to be == 2
+			
+			entries = header.entries
+			expect(entries[0].algorithm).to be == "sha-256"
+			expect(entries[1].algorithm).to be == "md5"
+		end
+	end
+	
+	with "inherited Split behavior" do
+		let(:header) {subject.new}
+		
+		it "behaves as an array" do
+			header << "sha-256=abc123"
+			expect(header.size).to be == 1
+			expect(header.first).to be == "sha-256=abc123"
+		end
+		
+		it "can be enumerated" do
+			header << "sha-256=abc123, md5=def456"
+			values = []
+			header.each {|value| values << value}
+			expect(values).to be == ["sha-256=abc123", "md5=def456"]
+		end
+		
+		it "supports array methods" do
+			header << "sha-256=abc123, md5=def456"
+			expect(header.length).to be == 2
+			expect(header.empty?).to be == false
+		end
+	end
+	
+	with "trailer support" do
+		it "should be allowed as a trailer" do
+			expect(subject.trailer?).to be == true
+		end
+	end
+	
+	with "algorithm edge cases" do
+		it "handles hyphenated algorithms" do
+			header = subject.new("sha-256=abc123")
+			entries = header.entries
+			expect(entries.first.algorithm).to be == "sha-256"
+		end
+		
+		it "handles numeric algorithms" do
+			header = subject.new("md5=def456")
+			entries = header.entries
+			expect(entries.first.algorithm).to be == "md5"
+		end
+	end
+	
+	with "value edge cases" do
+		it "handles empty values" do
+			header = subject.new("sha-256=")
+			entries = header.entries
+			expect(entries.first.value).to be == ""
+		end
+		
+		it "handles values with special characters" do
+			header = subject.new("sha-256=abc+def/123==")
+			entries = header.entries
+			expect(entries.first.value).to be == "abc+def/123=="
+		end
+	end
+end
+
+describe Protocol::HTTP::Header::Digest::Entry do
+	it "can create entry directly" do
+		entry = subject.new("sha-256", "abc123")
+		expect(entry.algorithm).to be == "sha-256"
+		expect(entry.value).to be == "abc123"
+		expect(entry.to_s).to be == "sha-256=abc123"
+	end
+	
+	it "normalizes algorithm to lowercase" do
+		entry = subject.new("SHA-256", "abc123")
+		expect(entry.algorithm).to be == "sha-256"
+	end
+	
+	it "handles complex algorithm names" do
+		entry = subject.new("sha-384", "complex-value")
+		expect(entry.algorithm).to be == "sha-384"
+		expect(entry.to_s).to be == "sha-384=complex-value"
+	end
+	
+	it "handles base64 padding in values" do
+		entry = subject.new("md5", "abc123==")
+		expect(entry.value).to be == "abc123=="
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/etag.rb 0.55.0-1/test/protocol/http/header/etag.rb
--- 0.23.12-1/test/protocol/http/header/etag.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/etag.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/header/etag"
+
+describe Protocol::HTTP::Header::ETag do
+	let(:header) {subject.new(description)}
+	
+	with 'W/"abcd"' do
+		it "is weak" do
+			expect(header).to be(:weak?)
+		end
+	end
+	
+	with '"abcd"' do
+		it "is not weak" do
+			expect(header).not.to be(:weak?)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can replace values" do
+			header << '"abcd"'
+			expect(header).not.to be(:weak?)
+			
+			header << 'W/"abcd"'
+			expect(header).to be(:weak?)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/etags.rb 0.55.0-1/test/protocol/http/header/etags.rb
--- 0.23.12-1/test/protocol/http/header/etags.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/etags.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+# Copyright, 2023, by Thomas Morgan.
+
+require "protocol/http/header/etags"
+
+describe Protocol::HTTP::Header::ETags do
+	let(:header) {subject.new(description)}
+	
+	with "*" do
+		it "is a wildcard" do
+			expect(header).to be(:wildcard?)
+		end
+		
+		it "matches anything" do
+			expect(header).to be(:match?, '"anything"')
+		end
+	end
+	
+	with '"abcd"' do
+		it "is not a wildcard" do
+			expect(header).not.to be(:wildcard?)
+		end
+		
+		it "matches itself" do
+			expect(header).to be(:match?, '"abcd"')
+		end
+		
+		it "strongly matches only another strong etag" do
+			expect(header).to be(:strong_match?, '"abcd"')
+			expect(header).not.to be(:strong_match?, 'W/"abcd"')
+		end
+		
+		it "weakly matches both weak and strong etags" do
+			expect(header).to be(:weak_match?, '"abcd"')
+			expect(header).to be(:weak_match?, 'W/"abcd"')
+		end
+		
+		it "does not match anything else" do
+			expect(header).not.to be(:match?, '"anything else"')
+		end
+	end
+	
+	with 'W/"abcd"' do
+		it "never strongly matches" do
+			expect(header).not.to be(:strong_match?, '"abcd"')
+			expect(header).not.to be(:strong_match?, 'W/"abcd"')
+		end
+		
+		it "weakly matches both weak and strong etags" do
+			expect(header).to be(:weak_match?, '"abcd"')
+			expect(header).to be(:weak_match?, 'W/"abcd"')
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/multiple.rb 0.55.0-1/test/protocol/http/header/multiple.rb
--- 0.23.12-1/test/protocol/http/header/multiple.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/multiple.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2020-2025, by Samuel Williams.
+
+require "protocol/http/header/multiple"
+
+describe Protocol::HTTP::Header::Multiple do
+	let(:header) {subject.new(description)}
+	
+	with "first-value" do
+		it "can add several values" do
+			header << "second-value"
+			header << "third-value"
+			
+			expect(header).to be == ["first-value", "second-value", "third-value"]
+			expect(header).to have_attributes(
+				to_s: be == "first-value\nsecond-value\nthird-value"
+			)
+		end
+	end
+	
+	with ".trailer?" do
+		it "is not allowed in trailers by default" do
+			expect(subject).not.to be(:trailer?)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/priority.rb 0.55.0-1/test/protocol/http/header/priority.rb
--- 0.23.12-1/test/protocol/http/header/priority.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/priority.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024-2025, by Samuel Williams.
+
+require "protocol/http/header/priority"
+
+describe Protocol::HTTP::Header::Priority do
+	let(:header) {subject.new(description)}
+	
+	with "u=1, i" do
+		it "correctly parses priority header" do
+			expect(header).to have_attributes(
+				urgency: be == 1,
+				incremental?: be == true,
+			)
+		end
+	end
+	
+	with "u=0" do
+		it "correctly parses priority header" do
+			expect(header).to have_attributes(
+				urgency: be == 0,
+				incremental?: be == false,
+			)
+		end
+	end
+	
+	with "i" do
+		it "correctly parses incremental flag" do
+			expect(header).to have_attributes(
+				# Default urgency level is used:
+				urgency: be == 3,
+				incremental?: be == true,
+			)
+		end
+	end
+	
+	with "u=6" do
+		it "correctly parses urgency level" do
+			expect(header).to have_attributes(
+				urgency: be == 6,
+			)
+		end
+	end
+	
+	with "u=9, i" do
+		it "gracefully handles non-standard urgency levels" do
+			expect(header).to have_attributes(
+				# Non-standard value is preserved
+				urgency: be == 9,
+				incremental?: be == true,
+			)
+		end
+	end
+	
+	with "u=2, u=5" do
+		it "prioritizes the last urgency directive" do
+			expect(header).to have_attributes(
+				urgency: be == 5,
+			)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can append values" do
+			header << "u=4"
+			expect(header).to have_attributes(
+				urgency: be == 4,
+			)
+		end
+		
+		it "can append incremental flag" do
+			header << "i"
+			expect(header).to have_attributes(
+				incremental?: be == true,
+			)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/server_timing.rb 0.55.0-1/test/protocol/http/header/server_timing.rb
--- 0.23.12-1/test/protocol/http/header/server_timing.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/server_timing.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,249 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/server_timing"
+require "sus"
+
+describe Protocol::HTTP::Header::ServerTiming do
+	let(:header) {subject.new(description)}
+	
+	with "empty header" do
+		let(:header) {subject.new}
+		
+		it "should be empty" do
+			expect(header.to_s).to be == ""
+		end
+		
+		it "should be an array" do
+			expect(header).to be_a(Array)
+		end
+		
+		it "should return empty metrics" do
+			expect(header.metrics).to be == []
+		end
+	end
+	
+	with "db;dur=53.2" do
+		it "can parse metric with duration" do
+			metrics = header.metrics
+			expect(metrics.size).to be == 1
+			expect(metrics.first.name).to be == "db"
+			expect(metrics.first.duration).to be == 53.2
+			expect(metrics.first.description).to be_nil
+		end
+	end
+	
+	with 'db;dur="53.2"' do
+		it "can parse metric with quoted duration" do
+			metrics = header.metrics
+			expect(metrics.size).to be == 1
+			expect(metrics.first.name).to be == "db"
+			expect(metrics.first.duration).to be == 53.2
+			expect(metrics.first.description).to be_nil
+		end
+	end
+	
+	with 'cache;desc="Redis lookup"' do
+		it "can parse metric with description" do
+			metrics = header.metrics
+			expect(metrics.size).to be == 1
+			expect(metrics.first.name).to be == "cache"
+			expect(metrics.first.duration).to be_nil
+			expect(metrics.first.description).to be == "Redis lookup"
+		end
+	end
+	
+	with 'app;dur=12.7;desc="Application logic"' do
+		it "can parse metric with duration and description" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "app"
+			expect(metrics.first.duration).to be == 12.7
+			expect(metrics.first.description).to be == "Application logic"
+		end
+	end
+	
+	with 'app;dur="12.7";desc="Application logic"' do
+		it "can parse metric with quoted duration and quoted description" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "app"
+			expect(metrics.first.duration).to be == 12.7
+			expect(metrics.first.description).to be == "Application logic"
+		end
+	end
+	
+	with "db;dur=45.3, app;dur=12.7;desc=\"Application logic\", cache;desc=\"Cache miss\"" do
+		it "can parse multiple metrics" do
+			metrics = header.metrics
+			expect(metrics.size).to be == 3
+			
+			expect(metrics[0].name).to be == "db"
+			expect(metrics[0].duration).to be == 45.3
+			expect(metrics[0].description).to be_nil
+			
+			expect(metrics[1].name).to be == "app"
+			expect(metrics[1].duration).to be == 12.7
+			expect(metrics[1].description).to be == "Application logic"
+			
+			expect(metrics[2].name).to be == "cache"
+			expect(metrics[2].duration).to be_nil
+			expect(metrics[2].description).to be == "Cache miss"
+		end
+	end
+	
+	with "cache-hit" do
+		it "can parse metric with name only" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "cache-hit"
+			expect(metrics.first.duration).to be_nil
+			expect(metrics.first.description).to be_nil
+		end
+	end
+	
+	with "invalid;unknown=param" do
+		it "ignores unknown parameters" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "invalid"
+			expect(metrics.first.duration).to be_nil
+			expect(metrics.first.description).to be_nil
+		end
+	end
+	
+	with "invalid-metric-name!" do
+		it "raises ParseError for invalid metric name" do
+			expect do
+				header.metrics
+			end.to raise_exception(Protocol::HTTP::Header::ServerTiming::ParseError)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can add metrics from string" do
+			header << "db;dur=25.5"
+			header << "cache;dur=5.2;desc=\"Hit\""
+			expect(header.size).to be == 2
+			
+			metrics = header.metrics
+			expect(metrics[0].name).to be == "db"
+			expect(metrics[1].name).to be == "cache"
+		end
+		
+		it "can add multiple metrics at once" do
+			header << "db;dur=25.5, cache;desc=\"Hit\""
+			expect(header.size).to be == 2
+			
+			metrics = header.metrics
+			expect(metrics[0].name).to be == "db"
+			expect(metrics[1].name).to be == "cache"
+		end
+	end
+	
+	with "inherited Split behavior" do
+		let(:header) {subject.new}
+		
+		it "behaves as an array" do
+			header << "db;dur=25.5"
+			expect(header.size).to be == 1
+			expect(header.first).to be == "db;dur=25.5"
+		end
+		
+		it "can be enumerated" do
+			header << "db;dur=25.5, cache;desc=\"Hit\""
+			values = []
+			header.each {|value| values << value}
+			expect(values).to be == ["db;dur=25.5", "cache;desc=\"Hit\""]
+		end
+		
+		it "supports array methods" do
+			header << "db;dur=25.5, cache;desc=\"Hit\""
+			expect(header.length).to be == 2
+			expect(header.empty?).to be == false
+		end
+	end
+	
+	with "trailer support" do
+		it "should be allowed as a trailer" do
+			expect(subject.trailer?).to be == true
+		end
+	end
+	
+	with "cache_hit" do
+		it "can parse metric with underscore in name" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "cache_hit"
+		end
+	end
+	
+	with "test;desc=unquoted-value" do
+		it "can parse unquoted description" do
+			metrics = header.metrics
+			expect(metrics.first.description).to be == "unquoted-value"
+		end
+	end
+	
+	with 'test;desc=""' do
+		it "can parse empty quoted description" do
+			metrics = header.metrics
+			expect(metrics.first.description).to be == ""
+		end
+	end
+	
+	with "test;dur=123;desc=mixed;unknown=ignored" do
+		it "ignores unknown parameters and processes known ones" do
+			metrics = header.metrics
+			expect(metrics.first.name).to be == "test"
+			expect(metrics.first.duration).to be == 123.0
+			expect(metrics.first.description).to be == "mixed"
+		end
+	end
+	
+	with "test;dur=0" do
+		it "can parse zero duration" do
+			metrics = header.metrics
+			expect(metrics.first.duration).to be == 0.0
+		end
+	end
+	
+	with "test;dur=123.456789" do
+		it "preserves decimal precision" do
+			metrics = header.metrics
+			expect(metrics.first.duration).to be == 123.456789
+		end
+	end
+end
+
+describe Protocol::HTTP::Header::ServerTiming::Metric do
+	it "can create metric directly" do
+		metric = subject.new("test", 123.45, "Test metric")
+		expect(metric.name).to be == "test"
+		expect(metric.duration).to be == 123.45
+		expect(metric.description).to be == "Test metric"
+		expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\""
+	end
+	
+	it "can create metric with name only" do
+		metric = subject.new("cache")
+		expect(metric.name).to be == "cache"
+		expect(metric.duration).to be_nil
+		expect(metric.description).to be_nil
+		expect(metric.to_s).to be == "cache"
+	end
+	
+	it "can create metric with duration only" do
+		metric = subject.new("test", 123.45, nil)
+		expect(metric.to_s).to be == "test;dur=123.45"
+	end
+	
+	it "can create metric with description only" do
+		metric = subject.new("test", nil, "description")
+		expect(metric.to_s).to be == "test;desc=\"description\""
+	end
+	
+	it "handles nil values correctly" do
+		metric = subject.new("test", nil, nil)
+		expect(metric.to_s).to be == "test"
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/te.rb 0.55.0-1/test/protocol/http/header/te.rb
--- 0.23.12-1/test/protocol/http/header/te.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/te.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/te"
+
+describe Protocol::HTTP::Header::TE do
+	let(:header) {subject.new(description)}
+	
+	with "chunked" do
+		it "detects chunked encoding" do
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with "gzip" do
+		it "detects gzip encoding" do
+			expect(header).to be(:gzip?)
+		end
+	end
+	
+	with "deflate" do
+		it "detects deflate encoding" do
+			expect(header).to be(:deflate?)
+		end
+	end
+	
+	with "trailers" do
+		it "detects trailers acceptance" do
+			expect(header).to be(:trailers?)
+		end
+	end
+	
+	with "compress" do
+		it "detects compress encoding" do
+			expect(header).to be(:compress?)
+		end
+	end
+	
+	with "identity" do
+		it "detects identity encoding" do
+			expect(header).to be(:identity?)
+		end
+	end
+	
+	with "gzip;q=0.8, chunked;q=1.0" do
+		it "parses quality factors" do
+			codings = header.transfer_codings
+			expect(codings.length).to be == 2
+			expect(codings[0].name).to be == "gzip"
+			expect(codings[0].quality_factor).to be == 0.8
+			expect(codings[1].name).to be == "chunked"
+			expect(codings[1].quality_factor).to be == 1.0
+		end
+		
+		it "contains expected encodings" do
+			expect(header).to be(:gzip?)
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with "gzip;q=0.5, deflate;q=0.8" do
+		it "handles multiple quality factors" do
+			codings = header.transfer_codings.sort
+			expect(codings[0].name).to be == "deflate"  # higher quality first
+			expect(codings[1].name).to be == "gzip"
+		end
+	end
+	
+	with "empty header value" do
+		let(:header) {subject.new}
+		
+		it "handles empty TE header" do
+			expect(header).to be(:empty?)
+			expect(header).not.to be(:chunked?)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can add encodings" do
+			header << "gzip"
+			expect(header).to be(:gzip?)
+			
+			header << "chunked;q=0.9"
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with "error handling" do
+		it "raises ParseError for invalid transfer coding" do
+			header = subject.new("invalid@encoding")
+			expect do
+				header.transfer_codings
+			end.to raise_exception(Protocol::HTTP::Header::TE::ParseError)
+		end
+	end
+	
+	with ".trailer?" do
+		it "should be forbidden in trailers" do
+			expect(subject).not.to be(:trailer?)
+		end
+	end
+end
+
+describe Protocol::HTTP::Header::TE::TransferCoding do
+	it "handles quality factor conversion" do
+		coding = subject.new("gzip", "0.8")
+		expect(coding.quality_factor).to be == 0.8
+	end
+	
+	it "defaults quality factor to 1.0" do
+		coding = subject.new("gzip", nil)
+		expect(coding.quality_factor).to be == 1.0
+	end
+	
+	it "serializes with quality factor" do
+		coding = subject.new("gzip", "0.8")
+		expect(coding.to_s).to be == "gzip;q=0.8"
+	end
+	
+	it "serializes without quality factor when 1.0" do
+		coding = subject.new("gzip", nil)
+		expect(coding.to_s).to be == "gzip"
+	end
+	
+	it "compares by quality factor" do
+		high = subject.new("gzip", "0.9")
+		low = subject.new("deflate", "0.5")
+		expect(high <=> low).to be == -1  # high quality first
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/trailer.rb 0.55.0-1/test/protocol/http/header/trailer.rb
--- 0.23.12-1/test/protocol/http/header/trailer.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/trailer.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/trailer"
+
+describe Protocol::HTTP::Header::Trailer do
+	let(:header) {subject.new(description)}
+	
+	with "etag" do
+		it "contains etag header" do
+			expect(header).to be(:include?, "etag")
+		end
+		
+		it "has one header" do
+			expect(header.length).to be == 1
+		end
+	end
+	
+	with "etag, content-md5" do
+		it "contains multiple headers" do
+			expect(header).to be(:include?, "etag")
+			expect(header).to be(:include?, "content-md5")
+		end
+		
+		it "has correct count" do
+			expect(header.length).to be == 2
+		end
+	end
+	
+	with "etag, content-md5, expires" do
+		it "handles three headers" do
+			expect(header).to be(:include?, "etag")
+			expect(header).to be(:include?, "content-md5")
+			expect(header).to be(:include?, "expires")
+		end
+		
+		it "serializes correctly" do
+			expect(header.to_s).to be == "etag,content-md5,expires"
+		end
+	end
+	
+	with "etag , content-md5 , expires" do
+		it "strips whitespace" do
+			expect(header.length).to be == 3
+			expect(header).to be(:include?, "etag")
+			expect(header).to be(:include?, "content-md5")
+		end
+	end
+	
+	with "empty header value" do
+		let(:header) {subject.new}
+		
+		it "handles empty trailer" do
+			expect(header).to be(:empty?)
+			expect(header.to_s).to be == ""
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new("etag")}
+		
+		it "can add headers" do
+			header << "content-md5, expires"
+			expect(header.length).to be == 3
+			expect(header).to be(:include?, "expires")
+		end
+	end
+	
+	with ".trailer?" do
+		it "should be forbidden in trailers" do
+			expect(subject).not.to be(:trailer?)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/transfer_encoding.rb 0.55.0-1/test/protocol/http/header/transfer_encoding.rb
--- 0.23.12-1/test/protocol/http/header/transfer_encoding.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/transfer_encoding.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/header/transfer_encoding"
+
+describe Protocol::HTTP::Header::TransferEncoding do
+	let(:header) {subject.new(description)}
+	
+	with "chunked" do
+		it "detects chunked encoding" do
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with "gzip" do
+		it "detects gzip encoding" do
+			expect(header).to be(:gzip?)
+		end
+	end
+	
+	with "deflate" do
+		it "detects deflate encoding" do
+			expect(header).to be(:deflate?)
+		end
+	end
+	
+	with "compress" do
+		it "detects compress encoding" do
+			expect(header).to be(:compress?)
+		end
+	end
+	
+	with "identity" do
+		it "detects identity encoding" do
+			expect(header).to be(:identity?)
+		end
+	end
+	
+	with "gzip, chunked" do
+		it "handles multiple encodings" do
+			expect(header.length).to be == 2
+			expect(header).to be(:include?, "gzip")
+			expect(header).to be(:include?, "chunked")
+			expect(header).to be(:gzip?)
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with "empty header value" do
+		let(:header) {subject.new}
+		
+		it "handles empty transfer encoding" do
+			expect(header).to be(:empty?)
+			expect(header).not.to be(:chunked?)
+		end
+	end
+	
+	with "#<<" do
+		let(:header) {subject.new}
+		
+		it "can add encodings" do
+			header << "gzip"
+			expect(header).to be(:gzip?)
+			
+			header << "chunked"
+			expect(header).to be(:chunked?)
+		end
+	end
+	
+	with ".trailer?" do
+		it "should be forbidden in trailers" do
+			expect(subject).not.to be(:trailer?)
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/header/vary.rb 0.55.0-1/test/protocol/http/header/vary.rb
--- 0.23.12-1/test/protocol/http/header/vary.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/header/vary.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/header/vary"
+
+describe Protocol::HTTP::Header::Vary do
+	let(:header) {subject.new(description)}
+	
+	with "#<<" do
+		it "can append normalised header names" do
+			header << "Accept-Language"
+			expect(header).to be(:include?, "accept-language")
+		end
+	end
+	
+	with "accept-language" do
+		it "should be case insensitive" do
+			expect(header).to be(:include?, "accept-language")
+		end
+		
+		it "should not have unspecific keys" do
+			expect(header).not.to be(:include?, "user-agent")
+		end
+	end
+	
+	with "Accept-Language" do
+		it "should be case insensitive" do
+			expect(header).to be(:include?, "accept-language")
+		end
+		
+		it "uses normalised lower case keys" do
+			expect(header).not.to be(:include?, "Accept-Language")
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/headers/merged.rb 0.55.0-1/test/protocol/http/headers/merged.rb
--- 0.23.12-1/test/protocol/http/headers/merged.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/headers/merged.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
+
+require "protocol/http/headers"
+
+describe Protocol::HTTP::Headers::Merged do	
+	let(:fields) do
+		[
+			["Content-Type", "text/html"],
+			["Set-Cookie", "hello=world"],
+			["Accept", "*/*"],
+			["content-length", 10],
+		]
+	end
+	
+	let(:merged) {subject.new(fields)}
+	let(:headers) {Protocol::HTTP::Headers.new(merged)}
+	
+	with "#each" do
+		it "should yield keys as lower case" do
+			merged.each do |key, value|
+				expect(key).to be == key.downcase
+			end
+		end
+		
+		it "should yield values as strings" do
+			merged.each do |key, value|
+				expect(value).to be_a(String)
+			end
+		end
+	end
+	
+	with "#<<" do
+		it "can append fields" do
+			merged << [["Accept", "image/jpeg"]]
+			
+			expect(headers["accept"]).to be == ["*/*", "image/jpeg"]
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/headers.rb 0.55.0-1/test/protocol/http/headers.rb
--- 0.23.12-1/test/protocol/http/headers.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/headers.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,545 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
+
+require "protocol/http/headers"
+require "protocol/http/cookie"
+
+describe Protocol::HTTP::Headers do
+	let(:fields) do
+		[
+			["Content-Type", "text/html"],
+			["connection", "Keep-Alive"],
+			["Set-Cookie", "hello=world"],
+			["Accept", "*/*"],
+			["set-cookie", "foo=bar"],
+		]
+	end
+	
+	let(:headers) {subject[fields]}
+	
+	with ".new" do
+		it "can construct headers with trailers" do
+			headers = subject.new(fields, 4)
+			expect(headers).to be(:trailer?)
+			expect(headers.trailer.to_a).to be == [
+				["set-cookie", "foo=bar"],
+			]
+		end
+	end
+	
+	with ".[]" do
+		it "can be constructed from frozen array" do
+			self.fields.freeze
+			
+			expect(headers.fields).not.to be(:frozen?)
+		end
+	end
+	
+	with "#keys" do
+		it "should return keys" do
+			expect(headers.keys).to be == ["content-type", "connection", "set-cookie", "accept"]
+		end
+	end
+	
+	with "#trailer?" do
+		it "should not be a trailer" do
+			expect(headers).not.to be(:trailer?)
+			expect(headers.tail).to be_nil
+		end
+	end
+	
+	with "#merge" do
+		it "should merge headers" do
+			other = subject[[
+				# This will be appended:
+				["Set-Cookie", "goodbye=world"],
+			]]
+			
+			merged = headers.merge(other)
+			
+			expect(merged.to_h).to be == {
+				"content-type" => "text/html",
+				"set-cookie" => ["hello=world", "foo=bar", "goodbye=world"],
+				"accept" => ["*/*"],
+				"connection" => ["keep-alive"]
+			}
+		end
+		
+		it "can't merge singleton headers" do
+			other = subject[[
+				["content-type", "text/plain"],
+			]]
+			
+			# This doesn't fail as we haven't built an internal index yet:
+			merged = headers.merge(other)
+			
+			expect do
+				# Once we build the index, it will fail:
+				merged.to_h
+			end.to raise_exception(Protocol::HTTP::DuplicateHeaderError)
+		end
+	end
+	
+	with "#extract" do
+		it "can extract named fields" do
+			# Force the headers to be indexed:
+			headers.to_h
+			
+			expect(headers.extract(["content-type", "set-cookie"])).to be == [
+				["Content-Type", "text/html"],
+				["Set-Cookie", "hello=world"],
+				["set-cookie", "foo=bar"],
+			]
+		end
+	end
+	
+	with "#clear" do
+		it "should clear headers" do
+			headers.clear
+			
+			expect(headers.fields).to be(:empty?)
+		end
+	end
+	
+	with "#freeze" do
+		it "can't modify frozen headers" do
+			headers.freeze
+			
+			expect(headers.fields).to be == fields
+			expect(headers.fields).to be(:frozen?)
+			expect(headers.to_h).to be(:frozen?)
+		end
+		
+		it "returns duplicated headers if they are frozen" do
+			headers.freeze
+			
+			expect(subject[headers]).not.to be(:frozen?)
+		end
+	end
+	
+	with "#dup" do
+		it "should not modify source object" do
+			headers = self.headers.dup
+			
+			headers["field"] = "value"
+			
+			expect(self.headers).not.to be(:include?, "field")
+		end
+	end
+	
+	with "#empty?" do
+		it "shouldn't be empty" do
+			expect(headers).not.to be(:empty?)
+		end
+	end
+	
+	with "#include?" do
+		it "should include? named fields" do
+			expect(headers).to be(:include?, "set-cookie")
+		end
+	end
+	
+	with "#key?" do
+		it "should key? named fields" do
+			expect(headers).to be(:key?, "set-cookie")
+		end
+	end
+	
+	with "#fields" do
+		it "should add fields in order" do
+			expect(headers.fields).to be == fields
+		end
+		
+		it "can enumerate fields" do
+			headers.each.with_index do |field, index|
+				expect(field).to be == fields[index]
+			end
+		end
+	end
+	
+	with "#to_a" do
+		it "should return the fields array" do
+			expect(headers.to_a).to be == fields
+		end
+		
+		it "should return the same object as fields" do
+			expect(headers.to_a).to be_equal(headers.fields)
+		end
+		
+		it "should return an array" do
+			expect(headers.to_a).to be_a(Array)
+		end
+	end
+	
+	with "#to_h" do
+		it "should generate array values for duplicate keys" do
+			expect(headers.to_h["set-cookie"]).to be == ["hello=world", "foo=bar"]
+		end
+	end
+	
+	with "#inspect" do
+		it "should generate a string representation" do
+			expect(headers.inspect).to be == "#<Protocol::HTTP::Headers #{fields.inspect}>"
+		end
+	end
+	
+	with "#[]" do
+		it "can lookup fields" do
+			expect(headers["content-type"]).to be == "text/html"
+		end
+	end
+	
+	with "#[]=" do
+		it "can add field with a String value" do
+			headers["Content-Length"] = "1"
+			
+			expect(headers.fields.last).to be == ["Content-Length", "1"]
+			expect(headers["content-length"]).to be == "1"
+		end
+		
+		it "can add field with an Integer value" do
+			headers["Content-Length"] = 1
+			
+			expect(headers.fields.last).to be == ["Content-Length", "1"]
+			expect(headers["content-length"]).to be == "1"
+		end
+		
+		it "can add field with indexed hash" do
+			expect(headers.to_h).not.to be(:empty?)
+			
+			headers["Content-Length"] = "1"
+			expect(headers["content-length"]).to be == "1"
+		end
+	end
+	
+	with "#add" do
+		it "can add field" do
+			headers.add("Content-Length", 1)
+			
+			expect(headers.fields.last).to be == ["Content-Length", "1"]
+			expect(headers["content-length"]).to be == "1"
+		end
+	end
+	
+	with "#set" do
+		it "can replace an existing field" do
+			headers.add("accept-encoding", "gzip,deflate")
+			
+			headers.set("accept-encoding", "gzip")
+			
+			expect(headers["accept-encoding"]).to be == ["gzip"]
+		end
+	end
+	
+	with "#extract" do
+		it "can extract key's that don't exist" do
+			expect(headers.extract("foo")).to be(:empty?)
+		end
+		
+		it "can extract single key" do
+			expect(headers.extract("content-type")).to be == [["Content-Type", "text/html"]]
+		end
+	end
+	
+	with "#==" do
+		it "can compare with array" do
+			expect(headers).to be == fields
+		end
+		
+		it "can compare with itself" do
+			expect(headers).to be == headers
+		end
+		
+		it "can compare with hash" do
+			expect(headers).not.to be == {}
+		end
+	end
+	
+	with "#delete" do
+		it "can delete case insensitive fields" do
+			expect(headers.delete("content-type")).to be == "text/html"
+			
+			expect(headers.fields).to be == fields[1..-1]
+		end
+		
+		it "can delete non-existant fields" do
+			expect(headers.delete("transfer-encoding")).to be_nil
+		end
+	end
+	
+	with "#merge" do
+		it "can merge content-length" do
+			headers.merge!("content-length" => 2)
+			
+			expect(headers["content-length"]).to be == "2"
+		end
+	end
+	
+	with "#trailer!" do
+		it "can add trailer" do
+			headers.add("trailer", "etag")
+			count = headers.fields.size
+			
+			trailer = headers.trailer!
+			expect(headers.tail).to be == count
+			
+			headers.add("etag", "abcd")
+			
+			expect(trailer.to_h).to be == {"etag" => "abcd"}
+		end
+		
+		it "can add trailer without explicit header" do
+			trailer = headers.trailer!
+			
+			headers.add("etag", "abcd")
+			
+			expect(trailer.to_h).to be == {"etag" => "abcd"}
+		end
+		
+		with "forbidden trailers" do
+			let(:headers) {subject.new}
+			
+			forbidden_trailers = %w[
+				accept
+				accept-charset
+				accept-encoding
+				accept-language
+				
+				authorization
+				proxy-authorization
+				www-authenticate
+				proxy-authenticate
+				
+				connection
+				content-length
+				transfer-encoding
+				te
+				upgrade
+				trailer
+				
+				host
+				expect
+				range
+				
+				content-type
+				content-encoding
+				content-range
+				
+				cookie
+				set-cookie
+				
+				x-foo-bar
+			]
+			
+			forbidden_trailers.each do |key|
+				it "can't add a #{key.inspect} header in the trailer", unique: key do
+					trailer = headers.trailer!
+					headers.add(key, "example")
+					expect(headers).not.to be(:include?, key)
+				end
+			end
+		end
+		
+		with "permitted trailers" do
+			let(:headers) {subject.new}
+			
+			permitted_trailers = [
+				"date",
+				"digest",
+				"etag",
+				"server-timing",
+			]
+			
+			permitted_trailers.each do |key|
+				it "can add a #{key.inspect} header in the trailer", unique: key do
+					trailer = headers.trailer!
+					headers.add(key, "example")
+					expect(headers).to be(:include?, key)
+				end
+			end
+		end
+	end
+	
+	with "#trailer" do
+		it "can enumerate trailer" do
+			headers.add("trailer", "etag")
+			headers.trailer!
+			headers.add("etag", "abcd")
+			
+			expect(headers.trailer.to_h).to be == {"etag" => "abcd"}
+		end
+	end
+	
+	with "custom policy" do
+		let(:headers) {subject.new}
+		
+		# Create a custom header class that allows trailers
+		let(:grpc_status_class) do
+			Class.new(String) do
+				def self.trailer?
+					true
+				end
+			end
+		end
+		
+		it "can set custom policy to allow additional trailer headers" do
+			# Create custom policy that allows grpc-status as trailer
+			custom_policy = Protocol::HTTP::Headers::POLICY.dup
+			custom_policy["grpc-status"] = grpc_status_class
+			
+			# Set the custom policy
+			headers.policy = custom_policy
+			
+			# Enable trailers
+			headers.trailer!
+			
+			# Add grpc-status header (should be allowed with custom policy)
+			headers.add("grpc-status", "0")
+			
+			# Verify it appears in trailers
+			expect(headers).to be(:include?, "grpc-status")
+			
+			trailer_headers = {}
+			headers.trailer do |key, value|
+				trailer_headers[key] = value
+			end
+			
+			expect(trailer_headers["grpc-status"]).to be == "0"
+		end
+		
+		it "policy= clears indexed cache" do
+			# Add some headers first
+			headers.add("content-type", "text/html")
+			
+			# Force indexing
+			hash1 = headers.to_h
+			expect(hash1).to be(:include?, "content-type")
+			
+			# Change policy
+			new_policy = {}
+			headers.policy = new_policy
+			
+			# Add another header
+			headers.add("x-custom", "value")
+			
+			# Verify cache was cleared and rebuilt
+			hash2 = headers.to_h
+			expect(hash2).to be(:include?, "content-type")
+			expect(hash2).to be(:include?, "x-custom")
+		end
+		
+		it "can read policy attribute" do
+			original_policy = headers.policy
+			expect(original_policy).to be == Protocol::HTTP::Headers::POLICY
+			
+			# Set new policy
+			custom_policy = {"custom" => String}
+			headers.policy = custom_policy
+			
+			# Verify policy was changed
+			expect(headers.policy).to be == custom_policy
+			expect(headers.policy).not.to be == original_policy
+		end
+	end
+	
+	with "#flatten!" do
+		it "can flatten trailer" do
+			headers.add("trailer", "etag")
+			trailer = headers.trailer!
+			headers.add("etag", "abcd")
+			
+			headers.flatten!
+			
+			expect(headers).not.to have_keys("trailer")
+			expect(headers).to have_keys("etag")
+		end
+	end
+	
+	with "#flatten" do
+		it "can flatten trailer" do
+			headers.add("trailer", "etag")
+			trailer = headers.trailer!
+			headers.add("etag", "abcd")
+			
+			copy = headers.flatten
+			
+			expect(headers).to have_keys("trailer")
+			expect(headers).to have_keys("etag")
+			
+			expect(copy).not.to have_keys("trailer")
+			expect(copy).to have_keys("etag")
+		end
+	end
+	
+	with "set-cookie" do
+		it "can extract parsed cookies" do
+			expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie)
+		end
+	end
+	
+	with "connection" do
+		it "can extract connection options" do
+			expect(headers["connection"]).to be_a(Protocol::HTTP::Header::Connection)
+		end
+		
+		it "should normalize to lower case" do
+			expect(headers["connection"]).to be == ["keep-alive"]
+		end
+	end
+end
+
+describe Protocol::HTTP::Headers::Merged do
+	let(:merged) do
+		Protocol::HTTP::Headers::Merged.new(
+			Protocol::HTTP::Headers["content-type" => "text/html"],
+			Protocol::HTTP::Headers["content-encoding" => "gzip"]
+		)
+	end
+	
+	with "#flatten" do
+		let(:flattened) {merged.flatten}
+		
+		it "can combine all headers" do
+			expect(flattened).to be_a Protocol::HTTP::Headers
+			expect(flattened.fields).to be == [
+				["content-type", "text/html"],
+				["content-encoding", "gzip"]
+			]
+		end
+	end
+	
+	with "#clear" do
+		it "can clear all headers" do
+			merged.clear
+			
+			expect(merged.flatten).to be(:empty?)
+		end
+	end
+	
+	with "#each" do
+		it "can iterate over all headers" do
+			expect(merged.each.to_a).to be == [
+				["content-type", "text/html"],
+				["content-encoding", "gzip"]
+			]
+		end
+	end
+	
+	with "non-normalized case" do
+		let(:merged) do
+			Protocol::HTTP::Headers::Merged.new(
+				Protocol::HTTP::Headers["Content-Type" => "text/html"],
+				Protocol::HTTP::Headers["Content-Encoding" => "gzip"]
+			)
+		end
+		
+		it "can normalize case" do
+			expect(merged.each.to_a).to be == [
+				["content-type", "text/html"],
+				["content-encoding", "gzip"]
+			]
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/http.rb 0.55.0-1/test/protocol/http/http.rb
--- 0.23.12-1/test/protocol/http/http.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/http.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2018-2025, by Samuel Williams.
+
+require "protocol/http"
+
+describe Protocol::HTTP do
+	it "has a version number" do
+		expect(Protocol::HTTP::VERSION).to be =~ /\d+\.\d+\.\d+/
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/methods.rb 0.55.0-1/test/protocol/http/methods.rb
--- 0.23.12-1/test/protocol/http/methods.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/methods.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/methods"
+
+ValidMethod = Sus::Shared("valid method") do |name|
+	it "defines #{name} method" do
+		expect(Protocol::HTTP::Methods.constants).to be(:include?, name.to_sym)
+	end
+	
+	it "has correct value" do
+		expect(Protocol::HTTP::Methods.const_get(name)).to be == name.to_s
+	end
+	
+	it "is a valid method" do
+		expect(Protocol::HTTP::Methods).to be(:valid?, name)
+	end
+end
+
+describe Protocol::HTTP::Methods do
+	it "defines several methods" do
+		expect(subject.constants).not.to be(:empty?)
+	end
+	
+	it_behaves_like ValidMethod, "GET"
+	it_behaves_like ValidMethod, "POST"
+	it_behaves_like ValidMethod, "PUT"
+	it_behaves_like ValidMethod, "PATCH"
+	it_behaves_like ValidMethod, "DELETE"
+	it_behaves_like ValidMethod, "HEAD"
+	it_behaves_like ValidMethod, "OPTIONS"
+	it_behaves_like ValidMethod, "TRACE"
+	it_behaves_like ValidMethod, "CONNECT"
+	
+	it "defines exactly 9 methods" do
+		expect(subject.constants.length).to be == 9
+	end
+	
+	with ".valid?" do
+		with "FOOBAR" do
+			it "is not a valid method" do
+				expect(subject).not.to be(:valid?, description)
+			end
+		end
+		
+		with "GETEMALL" do
+			it "is not a valid method" do
+				expect(subject).not.to be(:valid?, description)
+			end
+		end
+		
+		with "Accept:" do
+			it "is not a valid method" do
+				expect(subject).not.to be(:valid?, description)
+			end
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/middleware/builder.rb 0.55.0-1/test/protocol/http/middleware/builder.rb
--- 0.23.12-1/test/protocol/http/middleware/builder.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/middleware/builder.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2024, by Samuel Williams.
+
+require "protocol/http/middleware"
+require "protocol/http/middleware/builder"
+
+describe Protocol::HTTP::Middleware::Builder do
+	it "can make an app" do
+		app = Protocol::HTTP::Middleware.build do
+			run Protocol::HTTP::Middleware::HelloWorld
+		end
+		
+		expect(app).to be_equal(Protocol::HTTP::Middleware::HelloWorld)
+	end
+	
+	it "defaults to not found" do
+		app = Protocol::HTTP::Middleware.build do
+		end
+		
+		expect(app).to be_equal(Protocol::HTTP::Middleware::NotFound)
+	end
+	
+	it "can instantiate middleware" do
+		app = Protocol::HTTP::Middleware.build do
+			use Protocol::HTTP::Middleware
+		end
+		
+		expect(app).to be_a(Protocol::HTTP::Middleware)
+	end
+	
+	it "provides the builder as an argument" do
+		current_self = self
+		
+		app = Protocol::HTTP::Middleware.build do |builder|
+			builder.use Protocol::HTTP::Middleware
+			
+			expect(self).to be_equal(current_self)
+		end
+		
+		expect(app).to be_a(Protocol::HTTP::Middleware)
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/middleware.rb 0.55.0-1/test/protocol/http/middleware.rb
--- 0.23.12-1/test/protocol/http/middleware.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/middleware.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2019-2025, by Samuel Williams.
+
+require "protocol/http/middleware"
+
+describe Protocol::HTTP::Middleware do
+	it "can wrap a block" do
+		middleware = subject.for do |request|
+			Protocol::HTTP::Response[200]
+		end
+		
+		request = Protocol::HTTP::Request["GET", "/"]
+		
+		response = middleware.call(request)
+		
+		expect(response).to have_attributes(
+			status: be == 200,
+		)
+	end
+	
+	it "can invoke delegate" do
+		request = :request
+		
+		delegate = subject.new(nil)
+		expect(delegate).to(receive(:call) do |request|
+			expect(request).to be_equal(request)
+		end.and_return(nil))
+		
+		middleware = subject.new(delegate)
+		middleware.call(request)
+	end
+	
+	it "can close delegate" do
+		delegate = subject.new(nil)
+		expect(delegate).to receive(:close).and_return(nil)
+		
+		middleware = subject.new(delegate)
+		middleware.close
+	end
+end
+
+describe Protocol::HTTP::Middleware::Okay do
+	let(:middleware) {subject}
+	
+	it "responds with 200" do
+		request = Protocol::HTTP::Request["GET", "/"]
+		
+		response = middleware.call(request)
+		
+		expect(response).to have_attributes(
+			status: be == 200,
+		)
+	end
+end
+
+describe Protocol::HTTP::Middleware::NotFound do
+	let(:middleware) {subject}
+	
+	it "responds with 404" do
+		request = Protocol::HTTP::Request["GET", "/"]
+		
+		response = middleware.call(request)
+		
+		expect(response).to have_attributes(
+			status: be == 404,
+		)
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/peer.rb 0.55.0-1/test/protocol/http/peer.rb
--- 0.23.12-1/test/protocol/http/peer.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/peer.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2024-2025, by Samuel Williams.
+
+require "protocol/http/peer"
+require "socket"
+
+describe Protocol::HTTP::Peer do
+	it "can be created from IO" do
+		address = Addrinfo.tcp("192.168.1.1", 80)
+		io = Socket.new(:AF_INET, :SOCK_STREAM)
+		expect(io).to receive(:remote_address).and_return(address)
+		
+		peer = Protocol::HTTP::Peer.for(io)
+		expect(peer).to have_attributes(
+			address: be_equal(address),
+		)
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/quoted_string.rb 0.55.0-1/test/protocol/http/quoted_string.rb
--- 0.23.12-1/test/protocol/http/quoted_string.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/quoted_string.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/quoted_string"
+
+describe Protocol::HTTP::QuotedString do
+	with ".unquote" do
+		it "ignores linear whitespace" do
+			quoted_string = subject.unquote(%Q{"Hello\r\n  World"})
+			
+			expect(quoted_string).to be == "Hello World"
+		end
+	end
+	
+	with ".quote" do
+		it "doesn't quote a string that has no special characters" do
+			quoted_string = subject.quote("Hello")
+			
+			expect(quoted_string).to be == "Hello"
+		end
+		
+		it "quotes a string with a space" do
+			quoted_string = subject.quote("Hello World")
+			
+			expect(quoted_string).to be == %Q{"Hello World"}
+		end
+		
+		it "quotes a string with a double quote" do
+			quoted_string = subject.quote(%Q{Hello "World"})
+			
+			expect(quoted_string).to be == %Q{"Hello \\"World\\""}
+		end
+		
+		it "quotes a string with a backslash" do
+			quoted_string = subject.quote(%Q{Hello \\World})
+			
+			expect(quoted_string).to be == %Q{"Hello \\\\World"}
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/request.rb 0.55.0-1/test/protocol/http/request.rb
--- 0.23.12-1/test/protocol/http/request.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/request.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/request"
+
+require "json"
+
+describe Protocol::HTTP::Request do
+	let(:headers) {Protocol::HTTP::Headers.new}
+	let(:body) {nil}
+	
+	with ".[]" do
+		let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")}
+		let(:headers) {Protocol::HTTP::Headers[{"accept" => "text/html"}]}
+		
+		it "creates a new request" do
+			request = subject["GET", "/index.html", headers]
+			
+			expect(request).to have_attributes(
+				scheme: be_nil,
+				authority: be_nil,
+				method: be == "GET",
+				path: be == "/index.html",
+				version: be_nil,
+				headers: be == headers,
+				body: be_nil,
+				protocol: be_nil
+			)
+		end
+		
+		it "creates a new request with keyword arguments" do
+			request = subject["GET", "/index.html", scheme: "http", authority: "localhost", headers: headers, body: body]
+			
+			expect(request).to have_attributes(
+				scheme: be == "http",
+				authority: be == "localhost",
+				method: be == "GET",
+				path: be == "/index.html",
+				version: be_nil,
+				headers: be == headers,
+				body: be == body,
+				protocol: be_nil
+			)
+		end
+		
+		it "converts header hash to headers instance" do
+			request = subject["GET", "/index.html", {"accept" => "text/html"}]
+			
+			expect(request).to have_attributes(
+				headers: be == headers,
+			)
+		end
+		
+		it "converts array body to buffered body" do
+			request = subject["GET", "/index.html", headers: headers, body: ["Hello, World!"]]
+			
+			expect(request).to have_attributes(
+				body: be_a(Protocol::HTTP::Body::Buffered)
+			)
+		end
+		
+		it "can accept no arguments" do
+			request = subject["GET"]
+			
+			expect(request).to have_attributes(
+				method: be == "GET",
+				path: be_nil,
+			)
+		end
+		
+		it "converts path to string" do
+			request = subject["GET", :index]
+			
+			expect(request).to have_attributes(
+				method: be == "GET",
+				path: be == "index",
+			)
+		end
+	end
+	
+	with "simple GET request" do
+		let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)}
+		
+		it "should have attributes" do
+			expect(request).to have_attributes(
+				scheme: be == "http",
+				authority: be == "localhost",
+				method: be == "GET",
+				path: be == "/index.html",
+				version: be == "HTTP/1.0",
+				headers: be == headers,
+				body: be == body,
+				protocol: be_nil,
+				peer: be_nil,
+			)
+		end
+		
+		with "#as_json" do
+			it "generates a JSON representation" do
+				expect(request.as_json).to be == {
+					scheme: "http",
+					authority: "localhost",
+					method: "GET",
+					path: "/index.html",
+					version: "HTTP/1.0",
+					headers: headers.as_json,
+					body: nil,
+					protocol: nil
+				}
+			end
+			
+			it "generates a JSON string" do
+				expect(JSON.dump(request)).to be == request.to_json
+			end
+		end
+		
+		it "should not be HEAD" do
+			expect(request).not.to be(:head?)
+		end
+		
+		it "should not be CONNECT" do
+			expect(request).not.to be(:connect?)
+		end
+		
+		it "should be idempotent" do
+			expect(request).to be(:idempotent?)
+		end
+		
+		it "should have a string representation" do
+			expect(request.to_s).to be == "http://localhost: GET /index.html HTTP/1.0"
+		end
+		
+		it "can apply the request to a connection" do
+			connection = proc{|request| request}
+			
+			expect(connection).to receive(:call).with(request)
+			
+			request.call(connection)
+		end
+	end
+	
+	with "interim response" do
+		let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)}
+		
+		it "should call block" do
+			request.on_interim_response do |status, headers|
+				expect(status).to be == 100
+				expect(headers).to be == {}
+			end
+			
+			request.send_interim_response(100, {})
+		end
+		
+		it "calls multiple blocks" do
+			sequence = []
+			
+			request.on_interim_response do |status, headers|
+				sequence << 1
+				
+				expect(status).to be == 100
+				expect(headers).to be == {}
+			end
+			
+			request.on_interim_response do |status, headers|
+				sequence << 2
+				
+				expect(status).to be == 100
+				expect(headers).to be == {}
+			end
+			
+			request.send_interim_response(100, {})
+			
+			expect(sequence).to be == [2, 1]
+		end
+	end
+end
diff -pruN 0.23.12-1/test/protocol/http/response.rb 0.55.0-1/test/protocol/http/response.rb
--- 0.23.12-1/test/protocol/http/response.rb	1970-01-01 00:00:00.000000000 +0000
+++ 0.55.0-1/test/protocol/http/response.rb	2025-10-23 12:07:18.000000000 +0000
@@ -0,0 +1,324 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2023-2025, by Samuel Williams.
+
+require "protocol/http/response"
+require "protocol/http/request"
+
+describe Protocol::HTTP::Response do
+	let(:headers) {Protocol::HTTP::Headers.new}
+	let(:body) {nil}
+	
+	InformationalResponse = Sus::Shared("informational response") do
+		it "should be informational" do
+			expect(response).to be(:informational?)
+			expect(response.as_json).to have_keys(status: be_within(100...200))
+		end
+		
+		it "should not be a failure" do
+			expect(response).not.to be(:failure?)
+		end
+	end
+	
+	SuccessfulResponse = Sus::Shared("successful response") do
+		it "should be successful" do
+			expect(response).to be(:success?)
+			expect(response.as_json).to have_keys(status: be_within(200...300))
+		end
+		
+		it "should be final" do
+			expect(response).to be(:final?)
+		end
+		
+		it "should not be informational" do
+			expect(response).not.to be(:informational?)
+		end
+		
+		it "should not be a failure" do
+			expect(response).not.to be(:failure?)
+		end
+	end
+	
+	RedirectionResponse = Sus::Shared("redirection response") do
+		it "should be final" do
+			expect(response).to be(:final?)
+		end
+		
+		it "should be a redirection" do
+			expect(response).to be(:redirection?)
+			expect(response.as_json).to have_keys(status: be_within(300...400))
+		end
+		
+		it "should not be informational" do
+			expect(response).not.to be(:informational?)
+		end
+		
+		it "should not be a failure" do
+			expect(response).not.to be(:failure?)
+		end
+	end
+	
+	FailureResponse = Sus::Shared("failure response") do
+		it "should not be successful" do
+			expect(response).not.to be(:success?)
+		end
+		
+		it "should be final" do
+			expect(response).to be(:final?)
+		end
+		
+		it "should not be informational" do
+			expect(response).not.to be(:informational?)
+		end
+		
+		it "should be a failure" do
+			expect(response).to be(:failure?)
+			expect(response.as_json).to have_keys(status: be_within(400...600))
+		end
+	end
+	
+	RedirectUsingOriginalMethod = Sus::Shared("redirect using original method") do
+		it "should preserve the method when following the redirect" do
+			expect(response).to be(:preserve_method?)
+		end
+	end
+	
+	RedirectUsingGetAllowed = Sus::Shared("redirect using get allowed") do
+		it "should not preserve the method when following the redirect" do
+			expect(response).not.to be(:preserve_method?)
+		end
+	end
+	
+	with "100 Continue" do
+		let(:response) {subject.new("HTTP/1.1", 100, headers)}
+		
+		it "should have attributes" do
+			expect(response).to have_attributes(
+				version: be == "HTTP/1.1",
+				status: be == 100,
+				headers: be == headers,
+				body: be == nil,
+				protocol: be == nil
+			)
+		end
+		
+		with "#as_json" do
+			it "generates a JSON representation" do
+				expect(response.as_json).to have_keys(
+					version: be == "HTTP/1.1",
+					status: be == 100,
+					headers: be == headers.as_json,
+					body: be == nil,
+					protocol: be == nil,
+				)
+			end
+			
+			it "generates a JSON string" do
+				expect(JSON.dump(response)).to be == response.to_json
+			end
+		end
+		
+		it_behaves_like InformationalResponse
+		
+		it "should be a continue" do
+			expect(response).to be(:continue?)
+		end
+		
+		it "should have a String representation" do
+			expect(response.to_s).to be == "100 HTTP/1.1"
+		end
+		
+		it "should have an Array representation" do
+			expect(response.to_ary).to be == [100, headers, nil]
+		end
+	end
+	
+	with "301 Moved Permanently" do
+		let(:response) {subject.new("HTTP/1.1", 301, headers, body)}
+		
+		it_behaves_like RedirectionResponse
+		it_behaves_like RedirectUsingGetAllowed
+	end
+	
+	with "302 Moved Permanently" do
+		let(:response) {subject.new("HTTP/1.1", 301, headers, body)}
+		
+		it_behaves_like RedirectionResponse
+		it_behaves_like RedirectUsingGetAllowed
+	end
+	
+	with "307 Temporary Redirect" do
+		let(:response) {subject.new("HTTP/1.1", 307, headers, body)}
+		
+		it_behaves_like RedirectionResponse
+		it_behaves_like RedirectUsingOriginalMethod
+	end
+	
+	with "308 Permanent Redirect" do
+		let(:response) {subject.new("HTTP/1.1", 308, headers, body)}
+		
+		it_behaves_like RedirectionResponse
+		it_behaves_like RedirectUsingOriginalMethod
+	end
+	
+	with "200 OK" do
+		let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")}
+		let(:response) {subject.new("HTTP/1.0", 200, headers, body)}
+		
+		it "should have attributes" do
+			expect(response).to have_attributes(
+				version: be == "HTTP/1.0",
+				status: be == 200,
+				headers: be == headers,
+				body: be == body,
+				protocol: be_nil,
+				peer: be_nil,
+			)
+		end
+		
+		with "#as_json" do
+			it "generates a JSON representation" do
+				expect(response.as_json).to have_keys(
+					version: be == "HTTP/1.0",
+					status: be == 200,
+					headers: be == headers.as_json,
+					body: be == body.as_json,
+					protocol: be == nil,
+				)
+			end
+			
+			it "generates a JSON string" do
+				expect(JSON.dump(response)).to be == response.to_json
+			end
+		end
+		
+		it_behaves_like SuccessfulResponse
+		
+		it "should be ok" do
+			expect(response).to be(:ok?)
+		end
+		
+		it "should not be a redirection" do
+			expect(response).not.to be(:redirection?)
+		end
+		
+		it "should not be a hijack" do
+			expect(response).not.to be(:hijack?)
+		end
+		
+		it "should not be a continue" do
+			expect(response).not.to be(:continue?)
+		end
+		
+		it "should have a String representation" do
+			expect(response.to_s).to be == "200 HTTP/1.0"
+		end
+		
+		it "should have an Array representation" do
+			expect(response.to_ary).to be == [200, headers, body]
+		end
+	end
+	
+	with "400 Bad Request" do
+		let(:response) {subject.new("HTTP/1.1", 400, headers, body)}
+		
+		it_behaves_like FailureResponse
+		
+		it "should be a bad request" do
+			expect(response).to be(:bad_request?)
+		end
+	end
+	
+	with "500 Internal Server Error" do
+		let(:response) {subject.new("HTTP/1.1", 500, headers, body)}
+		
+		it_behaves_like FailureResponse
+		
+		it "should be an internal server error" do
+			expect(response).to be(:internal_server_error?)
+		end
+	end
+	
+	with ".for_exception" do
+		let(:exception) {StandardError.new("Something went wrong")}
+		let(:response) {subject.for_exception(exception)}
+		
+		it "should have a 500 status" do
+			expect(response.status).to be == 500
+			
+			expect(response.body.read).to be =~ /Something went wrong/
+		end
+	end
+	
+	with "unmodified cached response" do
+		let(:response) {subject.new("HTTP/1.1", 304, headers, body)}
+		
+		it "should have attributes" do
+			expect(response).to have_attributes(
+				version: be == "HTTP/1.1",
+				status: be == 304,
+				headers: be == headers,
+				body: be == body,
+				protocol: be == nil
+			)
+		end
+		
+		it "should not be successful" do
+			expect(response).not.to be(:success?)
+		end
+		
+		it "should be a redirection" do
+			expect(response).to be(:redirection?)
+		end
+		
+		it "should be not modified" do
+			expect(response).to be(:not_modified?)
+		end
+	end
+	
+	with ".[]" do
+		let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")}
+		let(:headers) {Protocol::HTTP::Headers[{"accept" => "text/html"}]}
+		
+		it "creates a new response" do
+			response = subject[200, headers]
+			
+			expect(response).to have_attributes(
+				version: be_nil,
+				status: be == 200,
+				headers: be == headers,
+				body: be_nil,
+				protocol: be_nil
+			)
+		end
+		
+		it "creates a new response with keyword arguments" do
+			response = subject[200, headers: headers, body: body]
+			
+			expect(response).to have_attributes(
+				version: be_nil,
+				status: be == 200,
+				headers: be == headers,
+				body: be == body,
+				protocol: be_nil
+			)
+		end
+		
+		it "converts header hash to headers instance" do
+			response = subject[200, {"accept" => "text/html"}]
+			
+			expect(response).to have_attributes(
+				headers: be == headers,
+			)
+		end
+		
+		it "converts array body to buffered body" do
+			response = subject[200, headers: headers, body: ["Hello, World!"]]
+			
+			expect(response).to have_attributes(
+				body: be_a(Protocol::HTTP::Body::Buffered)
+			)
+		end
+	end
+end
