header

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package header parses and applies _headers files.

The _headers file format, popularised by Netlify and Cloudflare Pages, defines HTTP response header fields that should be attached to responses matching specific request path patterns.

File Format

A _headers file consists of an unindented path pattern line followed by one or more indented lines that set or remove header fields:

/*
  X-Robots-Tag: noindex
/static/*
  Cache-Control: public, max-age=31536000, immutable
  ! X-Robots-Tag

Field names are case-insensitive and converted to canonical form with net/http.CanonicalHeaderKey.

Multi-Value and Framing Headers

When multiple rules match a request path, their header operations are applied in the order declared. If a header is set multiple times, values are joined with ", ", matching Cloudflare Pages behavior. The "Set-Cookie" header is an exception: each value is retained as an individual header line.

Hop-by-hop and response-framing headers (such as "Content-Length", "Transfer-Encoding", "Connection", "Upgrade", "Trailer", and "Keep-Alive") are rejected during parsing with ErrUnsupported.

Usage

The package supports two workflows:

Example

Example demonstrates parsing and resolving headers.

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"thde.io/rulefiles/header"
)

const file = `
/*
  X-Robots-Tag: noindex
  Referrer-Policy: strict-origin-when-cross-origin

/assets/*
  Cache-Control: public, max-age=31536000, immutable
  ! X-Robots-Tag

/docs/:slug
  Vary: Accept-Language
  X-Slug: :slug

/docs/*
  Vary: Accept-Encoding
`

func main() {
	rules, err := header.Parse(strings.NewReader(file))
	if err != nil {
		log.Fatal(err)
	}

	for _, path := range []string{"/index.html", "/assets/app.css", "/docs/backups"} {
		hdr := http.Header{}
		resolved := header.Resolve(rules, path)
		resolved.ApplyTo(hdr)

		fmt.Println(path)
		for _, name := range resolved.Fields() {
			if _, ok := hdr[name]; !ok {
				fmt.Println("  " + name + " is removed")

				continue
			}
			fmt.Println("  " + name + ": " + hdr.Get(name))
		}
	}

}
Output:
/index.html
  Referrer-Policy: strict-origin-when-cross-origin
  X-Robots-Tag: noindex
/assets/app.css
  Cache-Control: public, max-age=31536000, immutable
  Referrer-Policy: strict-origin-when-cross-origin
  X-Robots-Tag is removed
/docs/backups
  Referrer-Policy: strict-origin-when-cross-origin
  Vary: Accept-Language, Accept-Encoding
  X-Robots-Tag: noindex
  X-Slug: backups

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrSyntax indicates invalid rule syntax.
	ErrSyntax = rulefile.ErrSyntax

	// ErrUnsupported indicates an unsupported feature.
	ErrUnsupported = rulefile.ErrUnsupported
)

Functions

func Handler

func Handler(rules []Rule, next http.Handler) http.Handler

Handler returns a handler that applies the rules matching the path of a request to the response next writes.

The fields are applied when next writes its status, so a rule wins over a field next sets itself and a "!" removal drops a field next set. A request no rule matches reaches next with the response writer it was given.

Example

ExampleHandler demonstrates wrapping a handler with header rules.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	const file = `
/*
  X-Robots-Tag: noindex

/assets/*
  Cache-Control: public, max-age=31536000, immutable
  Content-Type: text/css
  ! X-Robots-Tag
`

	rules, err := header.Parse(strings.NewReader(file))
	if err != nil {
		slog.Error("reading the header rules", "error", err)

		return
	}

	// site sets fields of its own, as a file server does.
	site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Header().Set("X-Robots-Tag", "index")

		//nolint:gosec // Echo path for example output.
		_, _ = fmt.Fprint(w, "the file at "+r.URL.Path)
	})

	handler := header.Handler(rules, site)

	for _, path := range []string{"/index.html", "/assets/app.css"} {
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, http.NoBody))

		fmt.Printf("%s\n  Content-Type: %q\n  Cache-Control: %q\n  X-Robots-Tag: %q\n",
			path, rec.Header().Get("Content-Type"), rec.Header().Get("Cache-Control"), rec.Header().Get("X-Robots-Tag"))
	}

}
Output:
/index.html
  Content-Type: "text/plain; charset=utf-8"
  Cache-Control: ""
  X-Robots-Tag: "noindex"
/assets/app.css
  Content-Type: "text/css"
  Cache-Control: "public, max-age=31536000, immutable"
  X-Robots-Tag: ""

func NewHandler

func NewHandler(r io.Reader, next http.Handler, opts ...Option) (http.Handler, error)

NewHandler reads the rules of a _headers file from r and returns Handler(rules, next).

Example

ExampleNewHandler demonstrates reading rules from a file into a handler.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		//nolint:gosec // Echo path for example output.
		_, _ = fmt.Fprint(w, "the file at "+r.URL.Path)
	})

	handler, err := header.NewHandler(strings.NewReader("/assets/*\n  Cache-Control: no-store\n"), site)
	if err != nil {
		slog.Error("reading the header rules", "error", err)

		return
	}

	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/assets/app.css", http.NoBody))

	fmt.Printf("%d Cache-Control %q\n", rec.Code, rec.Header().Get("Cache-Control"))

}
Output:
200 Cache-Control "no-store"

Types

type Operation

type Operation struct {
	// Name is the canonical header name.
	Name string
	// Value is the field value, possibly containing ":name" placeholders. It is
	// empty for a removal.
	Value string
	// Remove reports whether to delete the header.
	Remove bool
}

Operation is a single header operation.

type Option

type Option func(*options)

Option configures parser behavior. Defaults align with the behavioir of Netlify.

func WithExactTrailingSlash

func WithExactTrailingSlash() Option

WithExactTrailingSlash matches the path of a request as written, as Cloudflare Pages does: a path pattern ending in "/" then only matches a request path ending in "/", and one that does not only matches a request path that does not. By default a trailing slash is ignored on both sides, as on Netlify.

A pattern ending in "*" is unaffected: its splat covers the rest of the path, a trailing slash included, and captures it.

Example

ExampleWithExactTrailingSlash demonstrates exact slash matching.

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	const file = `
/docs/
  X-Rule: directory

/docs
  X-Rule: file
`

	for _, opts := range [][]header.Option{nil, {header.WithExactTrailingSlash()}} {
		rules, err := header.Parse(strings.NewReader(file), opts...)
		if err != nil {
			log.Fatal(err)
		}

		for _, path := range []string{"/docs", "/docs/"} {
			hdr := http.Header{}
			header.Resolve(rules, path).ApplyTo(hdr)

			fmt.Printf("%s: X-Rule %q\n", path, hdr.Get("X-Rule"))
		}
	}

}
Output:
/docs: X-Rule "directory, file"
/docs/: X-Rule "directory, file"
/docs: X-Rule "file"
/docs/: X-Rule "directory"

type Resolved

type Resolved struct {
	// contains filtered or unexported fields
}

Resolved holds headers resolved for a request.

func Resolve

func Resolve(rules []Rule, path string) *Resolved

Resolve finds matching headers for path.

Example

ExampleResolve demonstrates applying headers to responses.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	rules, err := header.Parse(strings.NewReader("/assets/*\n  Cache-Control: no-store\n"))
	if err != nil {
		log.Fatal(err)
	}

	handler := func(w http.ResponseWriter, r *http.Request) {
		header.Resolve(rules, r.URL.Path).ApplyTo(w.Header())

		//nolint:gosec // example handler only
		_, _ = fmt.Fprintln(w, "the body of "+r.URL.Path)
	}

	for _, path := range []string{"/assets/app.css", "/index.html"} {
		rec := httptest.NewRecorder()
		handler(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, http.NoBody))

		fmt.Printf("%s: Cache-Control %q\n", path, rec.Header().Get("Cache-Control"))
	}

}
Output:
/assets/app.css: Cache-Control "no-store"
/index.html: Cache-Control ""
Example (Placeholders)

ExampleResolve_placeholders demonstrates path placeholder expansion.

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	const file = `
/movies/:title
  X-Movie-Name: You are watching ":title"

/downloads/*
  X-Path: :splat
`

	rules, err := header.Parse(strings.NewReader(file))
	if err != nil {
		log.Fatal(err)
	}

	for _, path := range []string{"/movies/serenity", "/downloads/2026/report.pdf"} {
		hdr := http.Header{}
		header.Resolve(rules, path).ApplyTo(hdr)

		fmt.Printf("%s\n  X-Movie-Name: %q\n  X-Path: %q\n", path, hdr.Get("X-Movie-Name"), hdr.Get("X-Path"))
	}

}
Output:
/movies/serenity
  X-Movie-Name: "You are watching \"serenity\""
  X-Path: ""
/downloads/2026/report.pdf
  X-Movie-Name: ""
  X-Path: "2026/report.pdf"

func (*Resolved) ApplyTo

func (r *Resolved) ApplyTo(hdr http.Header)

ApplyTo updates hdr with the resolved headers.

Example

ExampleResolved_ApplyTo demonstrates modifying existing headers.

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"thde.io/rulefiles/header"
)

func main() {
	rules, err := header.Parse(strings.NewReader("/assets/*\n  ! X-Robots-Tag\n  Vary: Accept-Encoding\n"))
	if err != nil {
		log.Fatal(err)
	}

	hdr := http.Header{"X-Robots-Tag": []string{"index"}, "Vary": []string{"Cookie"}}
	header.Resolve(rules, "/assets/app.css").ApplyTo(hdr)

	_, ok := hdr["X-Robots-Tag"]
	fmt.Println("X-Robots-Tag is set:", ok)
	fmt.Println("Vary:", hdr.Get("Vary"))

}
Output:
X-Robots-Tag is set: false
Vary: Accept-Encoding

func (*Resolved) Fields

func (r *Resolved) Fields() []string

Fields returns sorted touched header names.

Example

ExampleResolved_Fields demonstrates listing touched fields.

package main

import (
	"fmt"
	"log"
	"strings"

	"thde.io/rulefiles/header"
)

const file = `
/*
  X-Robots-Tag: noindex
  Referrer-Policy: strict-origin-when-cross-origin

/assets/*
  Cache-Control: public, max-age=31536000, immutable
  ! X-Robots-Tag

/docs/:slug
  Vary: Accept-Language
  X-Slug: :slug

/docs/*
  Vary: Accept-Encoding
`

func main() {
	rules, err := header.Parse(strings.NewReader(file))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(header.Resolve(rules, "/assets/app.css").Fields())

}
Output:
[Cache-Control Referrer-Policy X-Robots-Tag]

type Rule

type Rule struct {
	// Source is the raw path pattern.
	Source string
	// Operations are the header operations.
	Operations []Operation
	// contains filtered or unexported fields
}

Rule defines headers for a path pattern.

func Parse

func Parse(r io.Reader, opts ...Option) ([]Rule, error)

Parse reads the rules of a _headers file. Empty lines and comments starting with "#" are ignored. An unindented line declares the path pattern of a rule, every indented line that follows sets or removes one of its header fields:

/static/*
  Cache-Control: public, max-age=31536000, immutable
  ! X-Robots-Tag

Errors of all lines are reported together.

By default, the rules behave as they do on Netlify. Use Option arguments to configure their behaviour.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL