add http/httphi (#171)

* add http/httphi

* begin adding httphi tests

* claude found neat bugs

* add low level Handle function and more tests

* more tests, run go generate

* add Hijacker-like functionality

* improve locking and acquisition of Exchanges in reconfiguring

* several bugfixes, add internal.IntLen, round up http-linux example with new router API

* small nit

* add benchmarks

* add query handling

* remove ForEach pattern, allocates in TinyGo

* massive documentation push and code reordering in files

* Router.Handle returns error after being torn down

* run go fix

* rework Mux interface to receive a string request path

* add MethodFrom

* minor doc nit

* fail on incomplete staging

* add raw buffer access

* add streaming API distinct from Exchange

* begin adding multipart form logic

* finish rounding up multipart form parsing

* remove status type

* first Multipart approach

* begin adding readMultiPart

* add Exchange.ReadMultiparts reimagining of clanker slop

* ai insists with backoffs

* simplify clanker slop

* apply go fix

* add a pattern argument to Mux

* explicit header key/value alloc and add ExchangeConfig

* fix tests after excplicit header alloc change

* fix examples

* run go fix

* expose rawsock as experimental package (will use for external benchmarks)

* remove backoff from form parsing

* @MDr164 suggestions get potential fixes

* apply go fix

* add examples

* add README.md

* fix rawsock tinygo implementation

* apply @MDr164 various fixes

* update documentation on ContentLength methods and fix bug in Form reset on empty body

* fix tests

* add fuzz tests

* run go fix

* io.ErrNoProgress on parsing form spin

* run go fix

* remove backoff assumption from Router

* httphi.Handle rejects unsupported protocols

* go format router.go

* add kvbuffer

* rewrite Cookie with KVBuffer

* mid refactor of KVBuffer into Header

* work on KVBuffer exhausted semantics

* add Go's ServeMux Request.PathValue access semantics to Exchange, Mux and MuxSlice

* add PathValue example

* document all the things; improve req Query semantics; add Form.EnableBufferGrowth

* unexport kvBuffer

* add Exchange.PathValueAppend

* use stdlib in example instead of rawsock

* remove rawsock from http example

* add darwin arch rawsock

* fix example

* rename Router.TeardownGoroutines to Shutdown matching http.Server.Shutdown

* rename types and identifiers

* @MDr164 Content-Type and Transfer-Encoding bug catches
This commit is contained in:
Pat Whittingslow
2026-07-29 20:14:46 -03:00
committed by Patricio Whittingslow
parent a3f2742abf
commit 5c54030f19
47 changed files with 7309 additions and 804 deletions
+75 -36
View File
@@ -10,20 +10,17 @@ func TestTryParse_IncrementalRequest(t *testing.T) {
// Full HTTP request split across multiple ReadFromBytes calls.
full := "GET /index.html HTTP/1.1\r\nHost: example.com\r\nContent-Type: text/html\r\n\r\nbody here"
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
// Feed data in small chunks to exercise incremental parsing.
chunks := splitInto(full, 10)
var done bool
var doneIdx int
for i, chunk := range chunks {
n, err := hdr.ReadFromBytes([]byte(chunk))
err := hdr.ReadFromBytes([]byte(chunk))
if err != nil {
t.Fatalf("ReadFromBytes: %v", err)
}
if n != len(chunk) {
t.Fatalf("expected %d bytes read, got %d", len(chunk), n)
}
var needMore bool
needMore, err = hdr.TryParse(false)
@@ -52,19 +49,16 @@ func TestTryParse_IncrementalRequest(t *testing.T) {
if string(hdr.Method()) != "GET" {
t.Errorf("method = %q; want GET", hdr.Method())
}
if string(hdr.RequestURI()) != "/index.html" {
t.Errorf("URI = %q; want /index.html", hdr.RequestURI())
if string(hdr.RequestTarget()) != "/index.html" {
t.Errorf("URI = %q; want /index.html", hdr.RequestTarget())
}
// Verify headers via ForEach.
headers := make(map[string]string)
err := hdr.ForEach(func(key, value []byte) error {
hdr.ForEach(func(key, value []byte) bool {
headers[string(key)] = string(value)
return nil
return true
})
if err != nil {
t.Fatal(err)
}
if headers["Host"] != "example.com" {
t.Errorf("Host = %q; want example.com", headers["Host"])
}
@@ -85,7 +79,7 @@ func TestTryParse_IncrementalRequest(t *testing.T) {
func TestTryParse_IncrementalResponse(t *testing.T) {
full := "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nServer: lneto\r\n\r\nhello"
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
chunks := splitInto(full, 8)
var done bool
@@ -137,7 +131,7 @@ func TestReadFromLimited(t *testing.T) {
r := strings.NewReader(data)
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
// Read in one shot.
n, err := hdr.ReadFromLimited(r, 256)
@@ -163,7 +157,7 @@ func TestReadFromLimited(t *testing.T) {
func TestReadFromLimited_MaxBytes(t *testing.T) {
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
// Zero maxBytesToRead should error.
_, err := hdr.ReadFromLimited(strings.NewReader("data"), 0)
@@ -174,9 +168,9 @@ func TestReadFromLimited_MaxBytes(t *testing.T) {
func TestReadFromBytes_Empty(t *testing.T) {
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
_, err := hdr.ReadFromBytes(nil)
err := hdr.ReadFromBytes(nil)
if err == nil {
t.Fatal("expected error for empty bytes")
}
@@ -184,7 +178,7 @@ func TestReadFromBytes_Empty(t *testing.T) {
func TestBufferFreeAndCapacity(t *testing.T) {
var hdr Header
hdr.Reset(make([]byte, 0, 100))
hdr.Reset(make([]byte, 0, 100), numHeaderCapacity)
if hdr.BufferCapacity() != 100 {
t.Errorf("capacity = %d; want 100", hdr.BufferCapacity())
@@ -202,15 +196,14 @@ func TestBufferFreeAndCapacity(t *testing.T) {
func TestEnableBufferGrowth(t *testing.T) {
var hdr Header
buf := make([]byte, 0, 64)
hdr.Reset(buf)
hdr.EnableBufferGrowth(false)
hdr.Reset(buf, numHeaderCapacity)
hdr.ConfigBufferGrowth(false)
// With growth disabled, reading more than capacity should fail.
big := make([]byte, 128)
for i := range big {
big[i] = 'A'
}
_, err := hdr.ReadFromBytes(big)
err := hdr.ReadFromBytes(big)
if err == nil {
t.Fatal("expected error when buffer growth disabled and data exceeds capacity")
}
@@ -229,11 +222,11 @@ func TestHeader_Add(t *testing.T) {
// ForEach should find both.
var values []string
hdr.ForEach(func(key, value []byte) error {
hdr.ForEach(func(key, value []byte) bool {
if string(key) == "X-Custom" {
values = append(values, string(value))
}
return nil
return true
})
if len(values) != 2 {
t.Fatalf("expected 2 X-Custom headers, got %d", len(values))
@@ -332,6 +325,14 @@ func TestCookie_ParseBytes(t *testing.T) {
if string(c.Get("Path")) != "/" {
t.Errorf("Path = %q; want /", c.Get("Path"))
}
// The first pair sits at buffer offset 0, which a presence check keyed on
// the offset rather than the length reads as absent.
if string(c.Get("session")) != "abc123" {
t.Errorf("Get(session) = %q; want abc123", c.Get("session"))
}
if !c.HasKeyOrSingleValue("session") {
t.Error("expected first pair to be present by key")
}
if !c.HasKeyOrSingleValue("Secure") {
t.Error("expected Secure flag")
}
@@ -357,13 +358,10 @@ func TestCookie_ForEach(t *testing.T) {
c.ParseBytes([]byte("a=1; b=2; c=3"))
var keys []string
err := c.ForEach(func(key, value []byte) error {
c.ForEach(func(key, value []byte) bool {
keys = append(keys, string(key))
return nil
return true
})
if err != nil {
t.Fatal(err)
}
if len(keys) != 3 {
t.Fatalf("expected 3 cookie entries, got %d", len(keys))
}
@@ -389,7 +387,7 @@ func TestHeader_MultilineValue(t *testing.T) {
func TestHeader_ResponseRoundTrip(t *testing.T) {
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
hdr.SetProtocol("HTTP/1.1")
hdr.SetStatus("404", "Not Found")
hdr.Add("Content-Type", "text/plain")
@@ -429,10 +427,10 @@ func TestHeader_ResponseRoundTrip(t *testing.T) {
func TestHeader_RequestRoundTrip(t *testing.T) {
var hdr Header
hdr.Reset(make([]byte, 0, 256))
hdr.Reset(make([]byte, 0, 256), numHeaderCapacity)
hdr.SetProtocol("HTTP/1.1")
hdr.SetMethod("POST")
hdr.SetRequestURI("/api/data")
hdr.SetRequestTarget("/api/data")
hdr.Add("Host", "example.com")
hdr.Add("Content-Type", "application/json")
@@ -454,8 +452,8 @@ func TestHeader_RequestRoundTrip(t *testing.T) {
if string(hdr2.Method()) != "POST" {
t.Errorf("re-parsed method = %q; want POST", hdr2.Method())
}
if string(hdr2.RequestURI()) != "/api/data" {
t.Errorf("re-parsed URI = %q; want /api/data", hdr2.RequestURI())
if string(hdr2.RequestTarget()) != "/api/data" {
t.Errorf("re-parsed URI = %q; want /api/data", hdr2.RequestTarget())
}
if string(hdr2.Get("Host")) != "example.com" {
t.Errorf("re-parsed Host = %q; want example.com", hdr2.Get("Host"))
@@ -504,8 +502,8 @@ func TestParseRequest_NoProtocol(t *testing.T) {
if string(hdr.Method()) != "GET" {
t.Errorf("method = %q; want GET", hdr.Method())
}
if string(hdr.RequestURI()) != "/simple" {
t.Errorf("URI = %q; want /simple", hdr.RequestURI())
if string(hdr.RequestTarget()) != "/simple" {
t.Errorf("URI = %q; want /simple", hdr.RequestTarget())
}
if hdr.Protocol() != nil {
t.Errorf("protocol should be nil for version-less request, got %q", hdr.Protocol())
@@ -539,3 +537,44 @@ func splitInto(s string, n int) []string {
}
return chunks
}
// Connection is a case-insensitive list of case-insensitive tokens, RFC 9110
// 7.6.1, and its field name folds like any other, RFC 9110 5.1. Missing a close
// token keeps serving a peer that asked to hang up; missing keep-alive hangs up
// on an HTTP/1.0 peer that asked to stay.
func TestConnectionCloseFolded(t *testing.T) {
for _, test := range []struct {
proto string
field string
wantClose bool
}{
{proto: "HTTP/1.1", field: "Connection: close", wantClose: true},
{proto: "HTTP/1.1", field: "connection: close", wantClose: true},
{proto: "HTTP/1.1", field: "CONNECTION: close", wantClose: true},
{proto: "HTTP/1.1", field: "Connection: Close", wantClose: true},
{proto: "HTTP/1.1", field: "Connection: CLOSE", wantClose: true},
{proto: "HTTP/1.1", field: "Connection: keep-alive, close", wantClose: true},
{proto: "HTTP/1.1", field: "Connection: close, keep-alive", wantClose: true},
{proto: "HTTP/1.1", field: "Connection: TE, Close", wantClose: true},
// A token that merely contains "close" is not the close token.
{proto: "HTTP/1.1", field: "Connection: closed", wantClose: false},
{proto: "HTTP/1.1", field: "Connection: keep-alive", wantClose: false},
// HTTP/1.0 closes unless the peer asks to keep the connection.
{proto: "HTTP/1.0", field: "Connection: keep-alive", wantClose: false},
{proto: "HTTP/1.0", field: "connection: keep-alive", wantClose: false},
{proto: "HTTP/1.0", field: "Connection: Keep-Alive", wantClose: false},
{proto: "HTTP/1.0", field: "Connection: TE, keep-alive", wantClose: false},
{proto: "HTTP/1.0", field: "Host: h", wantClose: true},
} {
t.Run(test.proto+" "+test.field, func(t *testing.T) {
var hdr Header
full := "GET / " + test.proto + "\r\nHost: h\r\n" + test.field + "\r\n\r\n"
if err := hdr.ParseBytes(false, []byte(full)); err != nil {
t.Fatal(err)
}
if got := hdr.ConnectionClose(); got != test.wantClose {
t.Errorf("want ConnectionClose=%v, got %v", test.wantClose, got)
}
})
}
}