HTTP/2 From Scratch: Part 3
This post is part of the HTTP from Scratch series.
In the last two posts, we established a raw TCP connection, navigated the TLS handshake with ALPN to select “h2”, and built a parser that can read the 9-byte frames of an HTTP/2 connection. We have a synchronized, acknowledged connection. Now it’s time to do what we came for: request a web page.
This is where HTTP/2 departs dramatically from its predecessor. There is no GET / HTTP/1.1. Instead, we enter the world of compressed headers, pseudo-headers, and stateful tables. This is the world of HPACK: Header Compression for HTTP/2.
What is HPACK?
In HTTP/1.1, headers are human-readable text. This is great for debugging but inefficient. The same headers (like User-Agent) are sent with every single request, wasting bandwidth. HPACK (RFC 7541) solves this by using several compression strategies. Instead of sending full header names and values, it sends compact, indexed representations.
At its core, HPACK uses two tables to translate between full headers and small integer indices:
- Static Table: A predefined, read-only table containing 61 of the most common headers. For example,
{':method', 'GET'}is entry #2. Every HTTP/2 client and server knows this table. - Dynamic Table: A small, temporary table that is specific to a single connection. In a full HPACK implementation, if you send a header that’s not in the static table (like a custom
x-request-id), it can be added to the dynamic table. On the next request, you can just send its index instead of the full header again. For this part, we will focus solely on the Static Table.
Our journey into HPACK will start with the basics. We’ll implement a decoder that understands the static table and indexed headers. We’ll leave the dynamic table handling for the next article.
Decoding Indexed Headers
The simplest form of header compression is the Indexed Header Field. When a header to be sent is present in one of the tables, it can be represented by a single integer.
An indexed header byte starts with a 1. The remaining 7 bits are the start of a variable-length integer representing the index in the tables.
Let’s look at our hpack.go file. The Decode method reads the payload from a HEADERS frame. If it sees a byte starting with 1, it knows it’s an indexed header.
func (h *HPACKDecoder) Decode(payload []byte) error {
fmt.Printf("Decoding %d bytes\n", len(payload))
for len(payload) > 0 {
b := payload[0]
if b&128 == 128 { // Indexed Header Field (starts with 1)
index, n := decodeInt(payload, 7)
if n < 0 {
return fmt.Errorf("failed to decode integer")
}
payload = payload[n:]
header, ok := h.Header(index)
if !ok {
return fmt.Errorf("invalid header index: %d", index)
}
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else {
// Other header types (like literals) are not implemented yet.
// We'll tackle this in the next part.
return fmt.Errorf("not implemented: literal header field")
}
}
return nil
}
The decodeInt function handles HPACK’s specific integer encoding. It parses the first byte manually to account for the n-bit prefix. If the integer overflows that first byte, it uses the standard library’s binary.Uvarint to efficiently parse the remaining continuation bytes. This hybrid approach correctly handles the HPACK format while leveraging Go’s optimized, built-in varint decoder.
With this, our decoder can parse headers that are in the static table. The hpack.go file contains the full static table definition.
go/hpack.go (click to expand)View on GitHub
package main
import (
"encoding/binary"
"fmt"
)
// RFC 7541: HPACK: Header Compression for HTTP/2
// https://datatracker.ietf.org/doc/html/rfc7541
const (
// HPACK Static Table Indices (Masked with 0x80 for Indexed Header Fields)
// See RFC 7541 Appendix A
HpackMethodGet uint8 = 0x82 // Index 2: :method: GET
HpackPathRoot uint8 = 0x84 // Index 4: :path: /
HpackSchemeHttps uint8 = 0x87 // Index 7: :scheme: https
// 0x40 is the mask for Literal Header Field with Incremental Indexing
HpackAuthority uint8 = 0x40 | 1 // Index 1: :authority
)
// HeaderField represents a header field with a name and value.
type HeaderField struct {
Name, Value string
}
// StaticTable is the predefined, unchangeable table of header fields, as defined in RFC 7541 Appendix A.
var StaticTable = []HeaderField{
{Name: ":authority", Value: ""},
{Name: ":method", Value: "GET"},
{Name: ":method", Value: "POST"},
{Name: ":path", Value: "/"},
{Name: ":path", Value: "/index.html"},
{Name: ":scheme", Value: "http"},
{Name: ":scheme", Value: "https"},
{Name: ":status", Value: "200"},
{Name: ":status", Value: "204"},
{Name: ":status", Value: "206"},
{Name: ":status", Value: "304"},
{Name: ":status", Value: "400"},
{Name: ":status", Value: "404"},
{Name: ":status", Value: "500"},
{Name: "accept-charset", Value: ""},
{Name: "accept-encoding", Value: "gzip, deflate"},
{Name: "accept-language", Value: ""},
{Name: "accept-ranges", Value: ""},
{Name: "accept", Value: ""},
{Name: "access-control-allow-origin", Value: ""},
{Name: "age", Value: ""},
{Name: "allow", Value: ""},
{Name: "authorization", Value: ""},
{Name: "cache-control", Value: ""},
{Name: "content-disposition", Value: ""},
{Name: "content-encoding", Value: ""},
{Name: "content-language", Value: ""},
{Name: "content-length", Value: ""},
{Name: "content-location", Value: ""},
{Name: "content-range", Value: ""},
{Name: "content-type", Value: ""},
{Name: "cookie", Value: ""},
{Name: "date", Value: ""},
{Name: "etag", Value: ""},
{Name: "expect", Value: ""},
{Name: "expires", Value: ""},
{Name: "from", Value: ""},
{Name: "host", Value: ""},
{Name: "if-match", Value: ""},
{Name: "if-modified-since", Value: ""},
{Name: "if-none-match", Value: ""},
{Name: "if-range", Value: ""},
{Name: "if-unmodified-since", Value: ""},
{Name: "last-modified", Value: ""},
{Name: "link", Value: ""},
{Name: "location", Value: ""},
{Name: "max-forwards", Value: ""},
{Name: "proxy-authenticate", Value: ""},
{Name: "proxy-authorization", Value: ""},
{Name: "range", Value: ""},
{Name: "referer", Value: ""},
{Name: "retry-after", Value: ""},
{Name: "server", Value: ""},
{Name: "set-cookie", Value: ""},
{Name: "strict-transport-security", Value: ""},
{Name: "transfer-encoding", Value: ""},
{Name: "user-agent", Value: ""},
{Name: "vary", Value: ""},
{Name: "via", Value: ""},
{Name: "www-authenticate", Value: ""},
}
var (
staticTableMap = make(map[HeaderField]int)
)
func init() {
for i, hf := range StaticTable {
staticTableMap[hf] = i + 1
}
}
type HPACKDecoder struct{}
func NewHPACKDecoder() *HPACKDecoder {
return &HPACKDecoder{}
}
func (h *HPACKDecoder) Header(i int) (HeaderField, bool) {
if i <= 0 {
return HeaderField{}, false
}
staticIndex := i - 1
if staticIndex < len(StaticTable) {
return StaticTable[staticIndex], true
}
return HeaderField{}, false
}
func (h *HPACKDecoder) Decode(payload []byte) error {
fmt.Printf("Decoding %d bytes\n", len(payload))
for len(payload) > 0 {
b := payload[0]
if b&128 == 128 { // Indexed Header Field
index, n := decodeInt(payload, 7)
if n < 0 {
return fmt.Errorf("failed to decode integer")
}
payload = payload[n:]
header, ok := h.Header(index)
if !ok {
return fmt.Errorf("invalid header index: %d", index)
}
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else {
// Other header field types (literal, etc.) not implemented yet
return fmt.Errorf("not implemented: literal header field")
}
}
return nil
}
// decodeInt decodes a variable-length integer from a byte slice.
// It returns the decoded integer and the number of bytes consumed.
// See RFC 7541 section 5.1 for details.
func decodeInt(payload []byte, n int) (int, int) {
if len(payload) == 0 {
return 0, -1
}
mask := (1 << n) - 1
i := int(payload[0]) & mask
if i < mask {
return i, 1
}
// The value overflows the first byte. The rest of the integer is a
// standard varint.
val, bytesRead := binary.Uvarint(payload[1:])
if bytesRead <= 0 {
return 0, -1 // Malformed varint
}
return i + int(val), 1 + bytesRead
}
Manually Encoding Our First Request
Our current client doesn’t have an HPACK encoder. To send our first request, we’re going to manually craft the byte payload for our HEADERS frame. This is a great way to understand how encoding works.
We want to send the following headers for a GET / request:
:method: GET:path: /:scheme: https:authority: kmcd.dev
Looking at the static table in RFC 7541, Appendix A:
{':method', 'GET'}is at index 2.{':path', '/'}is at index 4.{':scheme', 'https'}is at index 7.
The :authority header doesn’t have a perfect match for both name and value. However, its name is at index 1. This means we have to send it as a Literal Header Field. This type of header representation has a prefix indicating how it should be handled. For now, we will use “Literal Header Field with Incremental Indexing” (prefix 0100), which tells the server to use this header and add it to the dynamic table. We will do this manually for now, but in the future we will want to create code to manage this for us.
Our client.go assembles this payload:
// content/posts/2026/http2-from-scratch-part-3/go/client.go
// ...
authority := "kmcd.dev"
requestPayload := []byte{
0x82, // Index 2: :method: GET
0x84, // Index 4: :path: /
0x87, // Index 7: :scheme: https
0x41, // Index 1 for :authority, with literal value
byte(len(authority)), // Length of "kmcd.dev"
}
requestPayload = append(requestPayload, []byte(authority)...)
// ...
Let’s break down the bytes:
0x82:10000010. Starts with1, so it’s an indexed header. The integer value is 2. This is:method: GET.0x84:10000100. Indexed header, index 4. This is:path: /.0x87:10000111. Indexed header, index 7. This is:scheme: https.0x41:01000001. Starts with01, so it’s a “Literal Header Field with Incremental Indexing”. The remaining 6 bits are the index for the name, which is 1 (:authority). The value will follow as a literal string.byte(len(authority)): The length of the value “kmcd.dev”, which is 8.[]byte(authority): The string “kmcd.dev” itself.
We have now manually encoded a HEADERS payload!
The Client’s Structure
Here is a high-level overview of our client’s logic so far:
- Connect & Handshake: Establish a TCP connection and perform the TLS handshake, using ALPN to negotiate “h2”.
- Send Preface: Send the magic
PRI * ...connection preface. - Exchange Settings: Send our empty
SETTINGSframe, wait for the server’sSETTINGSframe, and then send anACK. - Send Request: Construct and send the
HEADERSframe forGET /with our manually encoded HPACK payload. We set theEND_STREAMflag to indicate this is our entire request. - Read Response: Loop and read frames from the server.
- If it’s a
HEADERSframe, use ourHPACKDecoderto parse and print the response headers. - If it’s a
DATAframe, print the content. - If we see a frame with the
END_STREAMflag, the response is complete, and we exit.
- If it’s a
This gives us a working end-to-end client that makes a real HTTP/2 request and prints the response.
Putting It All Together: The Final Client
Here is the full client.go script.
go/client.go (click to expand)View on GitHub
package main
import (
"crypto/tls"
"encoding/binary"
"fmt"
"log"
)
const (
// Protocol constants
Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
Server = "kmcd.dev:443"
// Frame Types (RFC 9113 Section 6)
FrameData uint8 = 0x0
FrameHeaders uint8 = 0x1
FramePriority uint8 = 0x2
FrameRstStream uint8 = 0x3
FrameSettings uint8 = 0x4
FramePushPromise uint8 = 0x5
FramePing uint8 = 0x6
FrameGoAway uint8 = 0x7
FrameWindowUpdate uint8 = 0x8
FrameContinuation uint8 = 0x9
// Common Flags
FlagAck uint8 = 0x01 // For SETTINGS/PING
FlagEndStream uint8 = 0x01 // For DATA/HEADERS
FlagEndHeaders uint8 = 0x04 // For HEADERS/PUSH_PROMISE/CONTINUATION
)
func main() {
// 1. Setup TLS with ALPN
config := &tls.Config{
NextProtos: []string{"h2"},
}
conn, err := tls.Dial("tcp", Server, config)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
state := conn.ConnectionState()
if state.NegotiatedProtocol != "h2" {
log.Fatalf("Server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
}
fmt.Printf("Connected to %s using %s\n", Server, state.NegotiatedProtocol)
// 2. Send Connection Preface
if _, err = conn.Write([]byte(Preface)); err != nil {
log.Fatalf("Failed to send preface: %v", err)
}
fmt.Println("Preface sent.")
// 3. Initial Handshake Loop
hpackDec := NewHPACKDecoder()
// Send our initial empty settings
mySettings := []byte{0, 0, 0, FrameSettings, 0, 0, 0, 0, 0}
conn.Write(mySettings)
serverSettingsAcked := false
mySettingsAcked := false
for !serverSettingsAcked || !mySettingsAcked {
frame, err := ReadFrame(conn)
if err != nil {
log.Fatalf("Handshake read error: %v", err)
}
fmt.Printf("<<< [Handshake] Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameSettings:
if frame.Header.Flags&FlagAck != 0 {
mySettingsAcked = true
fmt.Println("<<< Server ACK'd our settings")
} else {
ack := []byte{0, 0, 0, FrameSettings, FlagAck, 0, 0, 0, 0}
conn.Write(ack)
serverSettingsAcked = true
fmt.Println(">>> Sent SETTINGS ACK")
}
case FrameWindowUpdate:
fmt.Println("<<< Server provided flow control window")
case FrameGoAway:
log.Fatalf("Server sent GOAWAY during handshake")
}
}
// 4. Send the Request (GET /)
authority := "kmcd.dev"
requestPayload := []byte{
HpackMethodGet,
HpackPathRoot,
HpackSchemeHttps,
HpackAuthority,
byte(len(authority)), // The length prefix for the string literal
}
requestPayload = append(requestPayload, []byte(authority)...)
header := make([]byte, 9)
payloadLen := len(requestPayload)
header[0] = byte(payloadLen >> 16)
header[1] = byte(payloadLen >> 8)
header[2] = byte(payloadLen)
header[3] = FrameHeaders
header[4] = FlagEndStream | FlagEndHeaders
binary.BigEndian.PutUint32(header[5:9], 1)
if _, err = conn.Write(append(header, requestPayload...)); err != nil {
log.Fatalf("Failed to send request: %v", err)
}
fmt.Println(">>> Sent HEADERS (Stream 1)")
// 5. Main Processing Loop
for {
frame, err := ReadFrame(conn)
if err != nil {
log.Printf("Connection closed: %v", err)
break
}
fmt.Printf("<<< Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameData:
fmt.Printf(" [DATA] %s\n", string(frame.Payload))
case FrameHeaders:
fmt.Println(" [HEADERS] Decoding...")
if err := hpackDec.Decode(frame.Payload); err != nil {
log.Printf("HPACK Error: %v", err)
}
case FrameGoAway:
lastStream := binary.BigEndian.Uint32(frame.Payload[0:4]) & 0x7FFFFFFF
errCode := binary.BigEndian.Uint32(frame.Payload[4:8])
fmt.Printf("!!! GOAWAY: Last Stream %d, Error Code %d\n", lastStream, errCode)
return
case FrameWindowUpdate:
fmt.Println(" [WINDOW_UPDATE]")
}
// Use the global constants for the exit condition
isEndFrame := frame.Header.Type == FrameData || frame.Header.Type == FrameHeaders
if isEndFrame && (frame.Header.Flags&FlagEndStream != 0) {
fmt.Println("Stream finished. Exiting.")
break
}
}
}
Running this client produces a full HTTP/2 interaction. We send our request and get back headers and data from the server, all parsed by our own code. Here’s what it looks like when we run it (body truncated to reduce noise):
$ go run .
Connected to kmcd.dev:443 using h2
Preface sent.
<<< [Handshake] Frame Type=4, Flags=0, Stream=0
>>> Sent SETTINGS ACK
<<< [Handshake] Frame Type=8, Flags=0, Stream=0
<<< Server provided flow control window
<<< [Handshake] Frame Type=4, Flags=1, Stream=0
<<< Server ACK'd our settings
>>> Sent HEADERS (Stream 1)
<<< Frame Type=1, Flags=4, Stream=1
[HEADERS] Decoding...
Decoding 705 bytes
[Header] :status: 200
2026/01/31 14:36:00 HPACK Error: not implemented: literal header field
<<< Frame Type=0, Flags=0, Stream=1
[DATA] <!doctype html><html lang=en>[...snipped...]</html>
<<< Frame Type=0, Flags=1, Stream=1
[DATA]
I want you to notice a few things here. We successfully make it through the initial handshake. The server sends us headers and we decode only a single header, the :status pseudo-header that tells us that the response is a 200 but the next header doesn’t exist in the status table and is sent as a string literal and since our code doesn’t yet handle string literals we have to stop processing the headers at this point. This will be improved later. Finally, I want you to notice that we have successfully received the DATA frame. We actually receive two of them: one that contains the data and another that says that we have received all of the data for the request. This is a significant improvement. We are very close to a fully functioning client!
What’s Next?
We’ve made a huge leap. We can now make requests and parse simple responses. However, as you just saw, our HPACK decoder is incomplete. In this part, we’ve focused on decoding Indexed Header Fields that refer exclusively to the Static Table. This means our client will successfully decode common headers like :method: GET or :path: / but will completely fail on any string literal or any reference to the Dynamic Table.
In the next and final part covering HTTP/2, we will complete our HPACK implementation and adapt our client to use the http.Request and http.Response types.
See all of the code mentioned in this article here:go/client.go (click to expand)View on GitHub
package main
import (
"crypto/tls"
"encoding/binary"
"fmt"
"log"
)
const (
// Protocol constants
Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
Server = "kmcd.dev:443"
// Frame Types (RFC 9113 Section 6)
FrameData uint8 = 0x0
FrameHeaders uint8 = 0x1
FramePriority uint8 = 0x2
FrameRstStream uint8 = 0x3
FrameSettings uint8 = 0x4
FramePushPromise uint8 = 0x5
FramePing uint8 = 0x6
FrameGoAway uint8 = 0x7
FrameWindowUpdate uint8 = 0x8
FrameContinuation uint8 = 0x9
// Common Flags
FlagAck uint8 = 0x01 // For SETTINGS/PING
FlagEndStream uint8 = 0x01 // For DATA/HEADERS
FlagEndHeaders uint8 = 0x04 // For HEADERS/PUSH_PROMISE/CONTINUATION
)
func main() {
// 1. Setup TLS with ALPN
config := &tls.Config{
NextProtos: []string{"h2"},
}
conn, err := tls.Dial("tcp", Server, config)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
state := conn.ConnectionState()
if state.NegotiatedProtocol != "h2" {
log.Fatalf("Server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
}
fmt.Printf("Connected to %s using %s\n", Server, state.NegotiatedProtocol)
// 2. Send Connection Preface
if _, err = conn.Write([]byte(Preface)); err != nil {
log.Fatalf("Failed to send preface: %v", err)
}
fmt.Println("Preface sent.")
// 3. Initial Handshake Loop
hpackDec := NewHPACKDecoder()
// Send our initial empty settings
mySettings := []byte{0, 0, 0, FrameSettings, 0, 0, 0, 0, 0}
conn.Write(mySettings)
serverSettingsAcked := false
mySettingsAcked := false
for !serverSettingsAcked || !mySettingsAcked {
frame, err := ReadFrame(conn)
if err != nil {
log.Fatalf("Handshake read error: %v", err)
}
fmt.Printf("<<< [Handshake] Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameSettings:
if frame.Header.Flags&FlagAck != 0 {
mySettingsAcked = true
fmt.Println("<<< Server ACK'd our settings")
} else {
ack := []byte{0, 0, 0, FrameSettings, FlagAck, 0, 0, 0, 0}
conn.Write(ack)
serverSettingsAcked = true
fmt.Println(">>> Sent SETTINGS ACK")
}
case FrameWindowUpdate:
fmt.Println("<<< Server provided flow control window")
case FrameGoAway:
log.Fatalf("Server sent GOAWAY during handshake")
}
}
// 4. Send the Request (GET /)
authority := "kmcd.dev"
requestPayload := []byte{
HpackMethodGet,
HpackPathRoot,
HpackSchemeHttps,
HpackAuthority,
byte(len(authority)), // The length prefix for the string literal
}
requestPayload = append(requestPayload, []byte(authority)...)
header := make([]byte, 9)
payloadLen := len(requestPayload)
header[0] = byte(payloadLen >> 16)
header[1] = byte(payloadLen >> 8)
header[2] = byte(payloadLen)
header[3] = FrameHeaders
header[4] = FlagEndStream | FlagEndHeaders
binary.BigEndian.PutUint32(header[5:9], 1)
if _, err = conn.Write(append(header, requestPayload...)); err != nil {
log.Fatalf("Failed to send request: %v", err)
}
fmt.Println(">>> Sent HEADERS (Stream 1)")
// 5. Main Processing Loop
for {
frame, err := ReadFrame(conn)
if err != nil {
log.Printf("Connection closed: %v", err)
break
}
fmt.Printf("<<< Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameData:
fmt.Printf(" [DATA] %s\n", string(frame.Payload))
case FrameHeaders:
fmt.Println(" [HEADERS] Decoding...")
if err := hpackDec.Decode(frame.Payload); err != nil {
log.Printf("HPACK Error: %v", err)
}
case FrameGoAway:
lastStream := binary.BigEndian.Uint32(frame.Payload[0:4]) & 0x7FFFFFFF
errCode := binary.BigEndian.Uint32(frame.Payload[4:8])
fmt.Printf("!!! GOAWAY: Last Stream %d, Error Code %d\n", lastStream, errCode)
return
case FrameWindowUpdate:
fmt.Println(" [WINDOW_UPDATE]")
}
// Use the global constants for the exit condition
isEndFrame := frame.Header.Type == FrameData || frame.Header.Type == FrameHeaders
if isEndFrame && (frame.Header.Flags&FlagEndStream != 0) {
fmt.Println("Stream finished. Exiting.")
break
}
}
}
go/hpack.go (click to expand)View on GitHub
package main
import (
"encoding/binary"
"fmt"
)
// RFC 7541: HPACK: Header Compression for HTTP/2
// https://datatracker.ietf.org/doc/html/rfc7541
const (
// HPACK Static Table Indices (Masked with 0x80 for Indexed Header Fields)
// See RFC 7541 Appendix A
HpackMethodGet uint8 = 0x82 // Index 2: :method: GET
HpackPathRoot uint8 = 0x84 // Index 4: :path: /
HpackSchemeHttps uint8 = 0x87 // Index 7: :scheme: https
// 0x40 is the mask for Literal Header Field with Incremental Indexing
HpackAuthority uint8 = 0x40 | 1 // Index 1: :authority
)
// HeaderField represents a header field with a name and value.
type HeaderField struct {
Name, Value string
}
// StaticTable is the predefined, unchangeable table of header fields, as defined in RFC 7541 Appendix A.
var StaticTable = []HeaderField{
{Name: ":authority", Value: ""},
{Name: ":method", Value: "GET"},
{Name: ":method", Value: "POST"},
{Name: ":path", Value: "/"},
{Name: ":path", Value: "/index.html"},
{Name: ":scheme", Value: "http"},
{Name: ":scheme", Value: "https"},
{Name: ":status", Value: "200"},
{Name: ":status", Value: "204"},
{Name: ":status", Value: "206"},
{Name: ":status", Value: "304"},
{Name: ":status", Value: "400"},
{Name: ":status", Value: "404"},
{Name: ":status", Value: "500"},
{Name: "accept-charset", Value: ""},
{Name: "accept-encoding", Value: "gzip, deflate"},
{Name: "accept-language", Value: ""},
{Name: "accept-ranges", Value: ""},
{Name: "accept", Value: ""},
{Name: "access-control-allow-origin", Value: ""},
{Name: "age", Value: ""},
{Name: "allow", Value: ""},
{Name: "authorization", Value: ""},
{Name: "cache-control", Value: ""},
{Name: "content-disposition", Value: ""},
{Name: "content-encoding", Value: ""},
{Name: "content-language", Value: ""},
{Name: "content-length", Value: ""},
{Name: "content-location", Value: ""},
{Name: "content-range", Value: ""},
{Name: "content-type", Value: ""},
{Name: "cookie", Value: ""},
{Name: "date", Value: ""},
{Name: "etag", Value: ""},
{Name: "expect", Value: ""},
{Name: "expires", Value: ""},
{Name: "from", Value: ""},
{Name: "host", Value: ""},
{Name: "if-match", Value: ""},
{Name: "if-modified-since", Value: ""},
{Name: "if-none-match", Value: ""},
{Name: "if-range", Value: ""},
{Name: "if-unmodified-since", Value: ""},
{Name: "last-modified", Value: ""},
{Name: "link", Value: ""},
{Name: "location", Value: ""},
{Name: "max-forwards", Value: ""},
{Name: "proxy-authenticate", Value: ""},
{Name: "proxy-authorization", Value: ""},
{Name: "range", Value: ""},
{Name: "referer", Value: ""},
{Name: "retry-after", Value: ""},
{Name: "server", Value: ""},
{Name: "set-cookie", Value: ""},
{Name: "strict-transport-security", Value: ""},
{Name: "transfer-encoding", Value: ""},
{Name: "user-agent", Value: ""},
{Name: "vary", Value: ""},
{Name: "via", Value: ""},
{Name: "www-authenticate", Value: ""},
}
var (
staticTableMap = make(map[HeaderField]int)
)
func init() {
for i, hf := range StaticTable {
staticTableMap[hf] = i + 1
}
}
type HPACKDecoder struct{}
func NewHPACKDecoder() *HPACKDecoder {
return &HPACKDecoder{}
}
func (h *HPACKDecoder) Header(i int) (HeaderField, bool) {
if i <= 0 {
return HeaderField{}, false
}
staticIndex := i - 1
if staticIndex < len(StaticTable) {
return StaticTable[staticIndex], true
}
return HeaderField{}, false
}
func (h *HPACKDecoder) Decode(payload []byte) error {
fmt.Printf("Decoding %d bytes\n", len(payload))
for len(payload) > 0 {
b := payload[0]
if b&128 == 128 { // Indexed Header Field
index, n := decodeInt(payload, 7)
if n < 0 {
return fmt.Errorf("failed to decode integer")
}
payload = payload[n:]
header, ok := h.Header(index)
if !ok {
return fmt.Errorf("invalid header index: %d", index)
}
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else {
// Other header field types (literal, etc.) not implemented yet
return fmt.Errorf("not implemented: literal header field")
}
}
return nil
}
// decodeInt decodes a variable-length integer from a byte slice.
// It returns the decoded integer and the number of bytes consumed.
// See RFC 7541 section 5.1 for details.
func decodeInt(payload []byte, n int) (int, int) {
if len(payload) == 0 {
return 0, -1
}
mask := (1 << n) - 1
i := int(payload[0]) & mask
if i < mask {
return i, 1
}
// The value overflows the first byte. The rest of the integer is a
// standard varint.
val, bytesRead := binary.Uvarint(payload[1:])
if bytesRead <= 0 {
return 0, -1 // Malformed varint
}
return i + int(val), 1 + bytesRead
}
go/parser.go (click to expand)View on GitHub
package main
import (
"encoding/binary"
"fmt"
"io"
)
// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
Length uint32
Type uint8
Flags uint8
StreamID uint32
}
// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
Header FrameHeader
Payload []byte
}
// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
// 1. Read the 9-byte header
headerBuf := make([]byte, 9)
_, err := io.ReadFull(r, headerBuf)
if err != nil {
return Frame{}, fmt.Errorf("reading header: %w", err)
}
// 2. Parse the header fields using bit-shifting
header := FrameHeader{
Length: uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
Type: headerBuf[3],
Flags: headerBuf[4],
StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
}
// 3. Read the payload based on the Length field
payload := make([]byte, header.Length)
if header.Length > 0 {
_, err = io.ReadFull(r, payload)
if err != nil {
return Frame{}, fmt.Errorf("reading payload: %w", err)
}
}
return Frame{Header: header, Payload: payload}, nil
}
