Files
zeekayandHanzo Dev c813b6bebe point the doc and the README at the schema's new name
I renamed the file and left three references behind — wire.go's format note and
two in the README, one of them the ASCII frame diagram. A doc that names a file
which is not there is worse than no doc: the reader concludes the format is
undocumented.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-10 16:45:02 -07:00

363 lines
13 KiB
Go

// Package http implements HTTP request/response semantics over the
// ZAP transport for the fasthttp handler model. A fasthttp.RequestHandler
// is served over ZAP frames; a fasthttp.Request/Response pair is exchanged
// by the client. There is no net/http in the request path — Fiber and
// every other fasthttp stack hand their handler straight to this package.
//
// The wire format is documented in schema/http.zap (authored in the
// ZAP schema language). The bytes on the wire are github.com/zap-proto/go
// Objects — the canonical pure-stdlib ZAP runtime — packed at explicit
// byte offsets within each object's fixed payload. There is no external
// codec dependency: codec.go is the encoder and decoder.
//
// # Wire
//
// Object slots, offsets and the flags discriminator are stable. Headers moved
// from a JSON map to length-prefixed pairs — a JSON encoder inside a zero-copy
// binary protocol was buying escaping, sorting and a grammar nobody needed, and
// it forced two implementations to be kept byte-identical. The golden-wire
// tests pin the current bytes; they were regenerated once, deliberately, for
// that change. Peers must agree on the version.
//
// What's covered:
// - Request method, target, headers, body
// - Response status, reason, headers, body
// - Trailers (read and write)
//
// What's not yet covered:
// - Streaming bodies larger than one frame use the head/data/end frames
// below; a non-streamed body must fit in one frame (transport-negotiated
// max frame size).
// - WebSocket / SSE upgrade paths; those land in zap-proto/ws.
//
// # Wire layout
//
// A frame is one zap-proto/go message. The message type rides in the header
// flags (flags>>8): FrameRequest for a request, FrameResponse for a response,
// and the streaming variants below. Every text/bytes field is an 8-byte
// {relOffset,length} slot in the object's fixed section; non-empty fields'
// bytes are appended to the variable tail in field-declaration order. See
// codec.go for the encoder/decoder and the offset discipline that keeps the
// bytes byte-identical.
//
// # Framing metadata vs headers
//
// Three fields are owned by the frame, not the headers slot: Content-Length
// (the frame length-prefixes the body), Host (fasthttp's Host slot), and
// Trailer (the dedicated trailer slot). Everything else rides in the headers
// slot as length-prefixed name/value pairs. A repeated name is simply a
// repeated pair, which covers multi-value headers (RFC 9110 §5.2) without
// modelling a list.
package http
import (
"encoding/binary"
"fmt"
"net/textproto"
"github.com/valyala/fasthttp"
zap "github.com/zap-proto/go"
)
// Frame type IDs. The ZAP message header carries the type in the upper byte
// of the 16-bit flags field; the encoder tags the message with type<<8 and
// flags>>8 recovers it. A request frame and a response frame are the two
// shapes the non-streaming wire carries.
const (
FrameRequest uint16 = 0x01
FrameResponse uint16 = 0x02
// Streaming response frames. A streamed response is a FrameResponseHead
// (status + headers, no body) followed by zero or more FrameData chunks and
// a terminating FrameEnd (optional trailers). This is how server→client
// push (SSE, MCP notifications, chunked bodies) rides ZAP — the analogue of
// HTTP/2 HEADERS + DATA + END_STREAM. The non-streaming FrameResponse path
// is unchanged, so existing peers interoperate.
FrameResponseHead uint16 = 0x03
FrameData uint16 = 0x04
FrameEnd uint16 = 0x05
)
// Data/End frame layout: a single bytes slot (the chunk, or the trailer pairs).
const (
chunkBytes = 0 // bytes (8)
chunkSlotSize = 8
)
// Request frame field offsets within the object's fixed payload. Every
// text/bytes slot is 8 bytes (relOffset+length); see codec.go's offset
// discipline.
const (
reqMethod = 0 // text (8)
reqTarget = 8 // text (8)
reqProto = 16 // text (8)
reqHeaders = 24 // bytes (8) -- name/value pairs
reqBody = 32 // bytes (8)
reqTrailer = 40 // bytes (8) -- name/value pairs
reqSlotSize = 48
)
// Response frame field offsets. status@0 is a uint16 (2 bytes); the first text
// slot starts at the next 8-byte boundary.
const (
respStatus = 0 // uint16 (2)
respReason = 8 // text (8)
respProto = 16 // text (8)
respHeaders = 24 // bytes (8) -- name/value pairs
respBody = 32 // bytes (8)
respTrailer = 40 // bytes (8) -- name/value pairs
respSlotSize = 48
)
// defaultProto is the proto string written when the source carries none.
// fasthttp always reports a protocol ("HTTP/1.1" by default), so this only
// guards a hand-built zero-value header.
const defaultProto = "ZAP-HTTP/1.0"
// FrameTypeOf peeks a frame's type from its ZAP header without decoding the
// body, so a reader can dispatch (response vs streamed head vs data vs end).
func FrameTypeOf(frame []byte) (uint16, error) {
if len(frame) < zap.HeaderSize {
return 0, zap.ErrBufferTooSmall
}
if string(frame[0:4]) != zap.Magic {
return 0, zap.ErrInvalidMagic
}
return binary.LittleEndian.Uint16(frame[6:8]) >> 8, nil
}
// MarshalRequest serializes a *fasthttp.Request into a ZAP frame. The returned
// bytes are the ZAP message; the transport layer prepends the length prefix
// (see transport.go). It is the dst==nil convenience for AppendRequest — hot
// callers append into a reused buffer to stay allocation-free.
func MarshalRequest(req *fasthttp.Request) ([]byte, error) {
return AppendRequest(make([]byte, 0, 128+len(req.Body())), req)
}
// MarshalResponse serializes a *fasthttp.Response into a ZAP frame. See
// MarshalRequest; AppendResponse is the reused-buffer form.
func MarshalResponse(resp *fasthttp.Response) ([]byte, error) {
return AppendResponse(make([]byte, 0, 128+len(resp.Body())), resp)
}
// UnmarshalRequest reconstructs a request into dst from a ZAP frame. dst is
// reset first, then populated: method, request-URI, protocol, headers,
// trailers, and body. Content-Length is derived from the body length by
// SetBody; Host comes from a Host header if the frame carried one.
func UnmarshalRequest(frame []byte, dst *fasthttp.Request) error {
root, size, err := frameRoot(frame, FrameRequest)
if err != nil {
return err
}
method := readVar(frame, size, root, reqMethod)
target := readVar(frame, size, root, reqTarget)
proto := readVar(frame, size, root, reqProto)
headers := readVar(frame, size, root, reqHeaders)
body := readVar(frame, size, root, reqBody)
trailer := readVar(frame, size, root, reqTrailer)
dst.Reset()
if len(method) == 0 {
method = bMethodGET
}
dst.Header.SetMethodBytes(method)
dst.SetRequestURIBytes(target)
if len(proto) != 0 {
dst.Header.SetProtocolBytes(proto)
}
if err := decodeRequestHeaders(&dst.Header, headers); err != nil {
return fmt.Errorf("http: decode headers: %w", err)
}
if err := applyTrailers(&dst.Header, trailer); err != nil {
return fmt.Errorf("http: decode trailer: %w", err)
}
dst.SetBody(body)
return nil
}
// UnmarshalResponse reconstructs a response into dst from a ZAP frame.
func UnmarshalResponse(frame []byte, dst *fasthttp.Response) error {
root, size, err := frameRoot(frame, FrameResponse)
if err != nil {
return err
}
status := int(readU16(frame, size, root, respStatus))
reason := readVar(frame, size, root, respReason)
proto := readVar(frame, size, root, respProto)
headers := readVar(frame, size, root, respHeaders)
body := readVar(frame, size, root, respBody)
trailer := readVar(frame, size, root, respTrailer)
dst.Reset()
dst.SetStatusCode(status)
if len(reason) != 0 {
dst.Header.SetStatusMessage(reason)
}
if len(proto) != 0 {
dst.Header.SetProtocol(proto)
}
if err := decodeResponseHeaders(&dst.Header, headers); err != nil {
return fmt.Errorf("http: decode headers: %w", err)
}
if err := applyTrailers(&dst.Header, trailer); err != nil {
return fmt.Errorf("http: decode trailer: %w", err)
}
dst.SetBody(body)
return nil
}
// ---- streaming frames ----
// MarshalResponseHead serializes a response's status + headers (NO body) as the
// opening frame of a stream. The body is delivered by subsequent FrameData
// frames.
func MarshalResponseHead(resp *fasthttp.Response) ([]byte, error) {
dst := appendFrameHeader(nil, FrameResponseHead<<8)
obj := len(dst)
dst = append(dst, zeroFixed[:respSlotSize]...)
status := resp.StatusCode()
if status == 0 {
status = fasthttp.StatusOK
}
binary.LittleEndian.PutUint16(dst[obj+respStatus:], uint16(status))
reason := resp.Header.StatusMessage()
if len(reason) == 0 {
reason = unsafeBytes(fasthttp.StatusMessage(status))
}
proto := resp.Header.Protocol()
if len(proto) == 0 {
proto = bDefaultProto
}
dst = putVarBytes(dst, obj+respReason, reason)
dst = putVarBytes(dst, obj+respProto, proto)
hStart := len(dst)
dst = appendResponseHeaders(dst, &resp.Header, newTrailerSkip(&resp.Header))
dst = patchVarSlot(dst, obj+respHeaders, hStart)
// body and trailer slots stay null.
binary.LittleEndian.PutUint32(dst[12:], uint32(len(dst)))
return dst, nil
}
// UnmarshalResponseHead applies a streamed response's status + headers to dst.
// The body is left empty; the caller attaches a streaming body that reads the
// following FrameData frames.
func UnmarshalResponseHead(frame []byte, dst *fasthttp.Response) error {
root, size, err := frameRoot(frame, FrameResponseHead)
if err != nil {
return err
}
dst.Reset()
dst.SetStatusCode(int(readU16(frame, size, root, respStatus)))
if reason := readVar(frame, size, root, respReason); len(reason) != 0 {
dst.Header.SetStatusMessage(reason)
}
if proto := readVar(frame, size, root, respProto); len(proto) != 0 {
dst.Header.SetProtocol(proto)
}
return decodeResponseHeaders(&dst.Header, readVar(frame, size, root, respHeaders))
}
// MarshalData wraps one body chunk as a FrameData frame.
func MarshalData(chunk []byte) []byte {
return marshalChunk(FrameData, chunk)
}
// MarshalEnd terminates a stream, carrying optional encoded trailers.
func MarshalEnd(trailer []byte) []byte {
return marshalChunk(FrameEnd, trailer)
}
// marshalChunk builds a single-bytes-field frame (data or end).
func marshalChunk(frameType uint16, data []byte) []byte {
dst := appendFrameHeader(nil, frameType<<8)
obj := len(dst)
dst = append(dst, zeroFixed[:chunkSlotSize]...)
dst = putVarBytes(dst, obj+chunkBytes, data)
binary.LittleEndian.PutUint32(dst[12:], uint32(len(dst)))
return dst
}
// DataChunkOf extracts the chunk bytes from a FrameData frame. The returned
// slice aliases the frame buffer; copy it to retain past the frame's lifetime.
func DataChunkOf(frame []byte) ([]byte, error) {
root, size, err := frameRoot(frame, FrameData)
if err != nil {
return nil, err
}
return readVar(frame, size, root, chunkBytes), nil
}
// ---- header decode ----
// decodeRequestHeaders applies a request's headers to h. Host and
// Content-Length are frame-owned: Host is reconstructed here, Content-Length is
// set authoritatively by SetBody.
func decodeRequestHeaders(h *fasthttp.RequestHeader, raw []byte) error {
return forEachHeader(raw, func(key, val []byte) {
switch {
case bytesEqualFold(key, strHost):
h.SetHostBytes(val) // last value wins
case bytesEqualFold(key, strContentLength):
// frame-owned
default:
h.AddBytesKV(key, val)
}
})
}
func decodeResponseHeaders(h *fasthttp.ResponseHeader, raw []byte) error {
return forEachHeader(raw, func(key, val []byte) {
if bytesEqualFold(key, strContentLength) {
return // frame-owned
}
h.AddBytesKV(key, val)
})
}
// ---- trailers ----
// encodeTrailers writes declared trailers (rare) into the frame's trailer slot,
// in the same pair encoding as headers. None returns nil — a null slot.
func encodeTrailers(h trailerHeader) ([]byte, error) {
keys := h.PeekTrailerKeys()
if len(keys) == 0 {
return nil, nil
}
var pairs []hpair
for _, k := range keys {
ck := []byte(textproto.CanonicalMIMEHeaderKey(string(k)))
for _, v := range h.PeekAll(string(k)) {
pairs = append(pairs, hpair{ck, v})
}
}
return emitHeaders(nil, pairs), nil
}
// trailerHeader is the shared subset of *fasthttp.RequestHeader and
// *fasthttp.ResponseHeader used to read declared trailers.
type trailerHeader interface {
PeekTrailerKeys() [][]byte
PeekAll(key string) [][]byte
}
// applyTrailers declares each trailer name and re-adds its value(s). A name
// fasthttp forbids as a trailer (framing/routing/auth field) is carried as an
// ordinary header so no data is dropped.
func applyTrailers(h trailerSetter, raw []byte) error {
return forEachHeader(raw, func(key, val []byte) {
k := string(key)
_ = h.AddTrailer(k)
h.Add(k, string(val))
})
}
// *fasthttp.ResponseHeader used to reapply trailers.
type trailerSetter interface {
AddTrailer(trailer string) error
Add(key, value string)
}