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:
- HTTP middleware: Handler and NewHandler wrap an existing net/http.Handler. Headers are applied dynamically when the wrapped handler writes its status, ensuring configured headers override downstream values and "!" removals drop them.
- Manual resolution: Parse reads rules into a []Rule slice, and Resolve returns a *Resolved result that can be applied to an net/http.Header via Resolved.ApplyTo.
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 ¶
var ( // ErrSyntax indicates invalid rule syntax. ErrSyntax = rulefile.ErrSyntax // ErrUnsupported indicates an unsupported feature. ErrUnsupported = rulefile.ErrUnsupported )
Functions ¶
func 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.