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:

  1. 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.
  2. 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 with 1, 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 with 01, 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:

  1. Connect & Handshake: Establish a TCP connection and perform the TLS handshake, using ALPN to negotiate “h2”.
  2. Send Preface: Send the magic PRI * ... connection preface.
  3. Exchange Settings: Send our empty SETTINGS frame, wait for the server’s SETTINGS frame, and then send an ACK.
  4. Send Request: Construct and send the HEADERS frame for GET / with our manually encoded HPACK payload. We set the END_STREAM flag to indicate this is our entire request.
  5. Read Response: Loop and read frames from the server.
    • If it’s a HEADERS frame, use our HPACKDecoder to parse and print the response headers.
    • If it’s a DATA frame, print the content.
    • If we see a frame with the END_STREAM flag, the response is complete, and we exit.

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
}