esi

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 21 Imported by: 0

README

ESI (Edge Side Includes)

Package github.com/indragunawan/titip/esi provides an Edge Side Includes (ESI 1.0) processor for Go.

Features

  • Buffer Pooling: Uses sync.Pool for buffer reuse during document splicing.
  • Concurrent Fetching: Fetches fragment targets concurrently up to a configured limit.
  • SSRF Protection: Blocks private, loopback, and link-local IP ranges by default for outbound HTTP includes.
  • In-Process Fetching: Resolves local paths via http.Handler before falling back to outbound HTTP.
  • Recursion Limits & Cycle Detection: Prevents infinite loops and limits nesting depth.
  • Prometheus Metrics: Exports fragment counts and latency distributions.

Supported Directives

Directive Syntax Description
Include <esi:include src="..." /> Fetches and inlines fragment content.
Include with Fallback <esi:include src="..." alt="..." /> Fetches alt URL if src fails.
Paired Include <esi:include src="...">Fallback Content</esi:include> Renders enclosed content if src and alt fail.
Continue on Error <esi:include src="..." onerror="continue" /> Suppresses errors and omits fragment if fetch fails.
Remove <esi:remove>...</esi:remove> Strips enclosed content when ESI processing is active.
Comment <esi:comment text="..." /> Stripped from output.
Comment Wrapper <!--esi ... --> Unescapes enclosed content during ESI processing.
Tag Attributes (<esi:include>)
Attribute Required Supported Formats Interaction with Global Config
src Yes Relative path (/api/user) or absolute URL (https://...) Primary fragment target. Handled in-process when matched by WithInternalFetcher, otherwise outbound HTTP.
alt No Relative path or absolute URL Secondary fallback target attempted if src returns an error or non-2xx status.
timeout No 500ms, 2s, 0.5 (seconds), 500 (ms) Total SLA budget for the include slot (src + alt). Bounded by WithMaxTimeout and the parent branch's remaining Tree Budget.
max-depth No Integer (e.g. 2) Maximum recursion depth for nested includes within this fragment. Bounded by WithMaxDepth.
onerror No "continue" When "continue", suppresses fetch failure and renders paired fallback content or empty string. If omitted, unhandled errors render WithIncludeErrorMarker.
Tag Attributes (<esi:comment>)
Attribute Required Description
text No Descriptive comment text. The entire tag is stripped from the rendered output.

Quickstart

Standalone Document Processing
package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/indragunawan/titip/esi"
)

func main() {
    proc := esi.NewProcessor()

    html := []byte(`
        <html>
            <body>
                <header><esi:include src="https://example.com/header" /></header>
                <main>Main Content</main>
                <!--esi <footer>Rendered by ESI</footer> -->
            </body>
        </html>
    `)

    req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com/", nil)

    fragments := esi.Scan(html)
    if len(fragments) == 0 {
        fmt.Println(string(html))
        return
    }

    result, err := proc.ProcessFragments(context.Background(), req, html, fragments)
    if err != nil {
        panic(err)
    }
    defer result.Release()

    fmt.Println(string(result.Body()))
}
In-Process Subrequests (HandlerFetcher)

Resolve local paths directly through an http.Handler instead of outbound HTTP:

mux := http.NewServeMux()
mux.HandleFunc("/fragments/user", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`<span>Logged in as Alice</span>`))
})

proc := esi.NewProcessor(
    esi.WithInternalFetcher(esi.HandlerFetcher(mux)),
)

If the handler returns 404, the processor falls back to outbound HTTP.

Configuration Options

Option Default Description
WithInternalFetcher(fn) nil In-process subrequest handler. Use esi.HandlerFetcher(router) to adapt an http.Handler.
WithHeaderRequired(bool) false When true, documents are only processed if Surrogate-Control contains ESI/1.0.
WithMaxDepth(uint32) 3 Maximum nesting depth for recursive includes.
WithMaxTimeout(time.Duration) 30s Timeout per fragment request.
WithMaxConcurrentRequests(int) 8 Maximum concurrent fragment requests per document.
WithMaxResponseSize(int64) 10MB Maximum fragment response size in bytes.
WithAllowPrivateIPs(bool) false When true, allows requests to private, loopback, and link-local IP addresses.
WithAllowedHosts(...string) [] Allowed hostnames for outbound requests (empty allows any public host).
WithAllowPrivateIPsForAllowedHosts(bool) false Allows private IPs for explicitly allowed hosts.
WithDisableForwardCookies(bool) false Prevents forwarding Set-Cookie headers from fragment responses.
WithIncludeErrorMarker(string) "" HTML placeholder rendered when an include fails without fallback.
WithPreserveETag(bool) false Preserves downstream ETag (weakened) and Last-Modified headers.
WithMetrics(reg) nil Prometheus registerer for fragment metrics.
WithLogger(logger) slog.Default() Logger instance (*slog.Logger).
WithHTTPClient(client) SSRF-safe client Custom *http.Client for outbound requests.

Protocol Helpers

Helpers for upstream capability negotiation and downstream response reconciliation defined in the W3C ESI 1.0 / Edge Architecture Specification and RFC 9110:

  • proc.AddSurrogateCapability(header http.Header, deviceToken string): Advertises Surrogate-Capability: <deviceToken>="ESI/1.0" to upstream origin servers (nil-safe no-op).
  • proc.CanProcess(header http.Header) bool: Reports whether response headers meet ESI processing requirements based on WithHeaderRequired.
  • proc.ShouldPreserveETag() bool: Reports whether downstream ETag (weakened) and Last-Modified headers are preserved based on WithPreserveETag.
  • proc.ReconcileHeaders(header http.Header, result *Result): Modifies response headers in-place according to ESI 1.0 specifications (removes Surrogate-Control, adjusts ETag and Last-Modified per WithPreserveETag, updates Content-Length, and appends fragment Set-Cookie headers).

Memory Management

Call result.Release() after reading result.Body() to return the buffer to the pool:

fragments := esi.Scan(body)
if len(fragments) > 0 {
    result, err := proc.ProcessFragments(ctx, req, body, fragments)
    if err != nil {
        return err
    }
    defer result.Release()

    proc.ReconcileHeaders(w.Header(), result)
    w.Write(result.Body())
}

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrFallbackToHTTP is returned by an InternalFetcher to signal that the processor should resolve this include via outbound HTTP.
	ErrFallbackToHTTP = errors.New("esi: fallback to outbound http")
)

Functions

func Scan

func Scan(b []byte) []*proto.EsiFragment

Scan inspects the HTML byte slice for ESI tags and extracts pre-compiled fragment metadata. It returns a slice of EsiFragment descriptors if any ESI directives exist, or nil if none are found.

Types

type InternalFetcherFunc

type InternalFetcherFunc func(ctx context.Context, targetPath string, r *http.Request) ([]byte, http.Header, error)

InternalFetcherFunc defines the signature for resolving in-process ESI includes.

func HandlerFetcher

func HandlerFetcher(router http.Handler) InternalFetcherFunc

HandlerFetcher adapts any standard http.Handler into an InternalFetcherFunc for in-process subrequests.

type Option

type Option func(*config)

Option configures ESI processor parameters.

func WithAllowPrivateIPs

func WithAllowPrivateIPs(allow bool) Option

WithAllowPrivateIPs configures whether SSRF blocking permits dials to private/loopback CIDRs (default: false = blocked).

func WithAllowPrivateIPsForAllowedHosts

func WithAllowPrivateIPsForAllowedHosts(allow bool) Option

WithAllowPrivateIPsForAllowedHosts permits private IPs specifically for explicitly allowed hosts.

func WithAllowedHosts

func WithAllowedHosts(hosts ...string) Option

WithAllowedHosts restricts external HTTP includes to matching domain patterns (default: empty = all public).

func WithDisableForwardCookies

func WithDisableForwardCookies(disable bool) Option

WithDisableForwardCookies configures whether Set-Cookie headers from subrequests are forwarded to the client (default: false = forwarded).

func WithHTTPClient added in v0.2.0

func WithHTTPClient(client *http.Client) Option

WithHTTPClient configures a custom http.Client for outbound fragment fetching.

func WithHeaderRequired

func WithHeaderRequired(required bool) Option

WithHeaderRequired configures whether ESI is processed only when Surrogate-Control is present.

func WithIncludeErrorMarker

func WithIncludeErrorMarker(marker string) Option

WithIncludeErrorMarker configures an HTML placeholder rendered on unhandled fetch errors.

func WithInternalFetcher

func WithInternalFetcher(fetcher InternalFetcherFunc) Option

WithInternalFetcher configures a custom in-process handler for resolving local fragment subrequests.

func WithLogger added in v0.2.0

func WithLogger(logger *slog.Logger) Option

WithLogger configures a custom slog.Logger for ESI processing.

func WithMaxConcurrentRequests

func WithMaxConcurrentRequests(limit int) Option

WithMaxConcurrentRequests caps concurrent fragment fetch goroutines per document (default: 8).

func WithMaxDepth

func WithMaxDepth(depth uint32) Option

WithMaxDepth configures the maximum global recursion depth for nested includes (default: 3).

func WithMaxResponseSize

func WithMaxResponseSize(size int64) Option

WithMaxResponseSize caps the maximum allowed fragment body size in bytes (default: 10MB, 0 = unlimited).

func WithMaxTimeout

func WithMaxTimeout(timeout time.Duration) Option

WithMaxTimeout configures the maximum fetch timeout for resolving an include fragment (default: 30s).

func WithMetrics added in v0.2.0

func WithMetrics(reg prometheus.Registerer) Option

WithMetrics registers ESI telemetry collectors with the provided Prometheus Registerer.

func WithPreserveETag added in v0.2.0

func WithPreserveETag(preserve bool) Option

WithPreserveETag configures whether downstream ETag (weakened) and Last-Modified are preserved on ESI documents (default: false = headers stripped, downstream 304 bypassed).

type Processor added in v0.2.0

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

Processor orchestrates ESI fragment fetching, recursion handling, error fallbacks, and document splicing.

func NewProcessor added in v0.2.0

func NewProcessor(opts ...Option) *Processor

NewProcessor constructs a new ESI Processor from the provided options.

func (*Processor) AddSurrogateCapability added in v0.2.0

func (p *Processor) AddSurrogateCapability(h http.Header, deviceToken string)

AddSurrogateCapability appends an ESI/1.0 capability token for the specified deviceToken to the request's Surrogate-Capability header (e.g. `deviceToken="ESI/1.0"`). If p is nil or h is nil, this is a no-op. If deviceToken is empty, "esi" is used.

func (*Processor) CanProcess added in v0.2.0

func (p *Processor) CanProcess(h http.Header) bool

CanProcess reports whether the response headers satisfy ESI processing criteria. When WithHeaderRequired is false (default), it returns true; otherwise it verifies that Surrogate-Control contains "ESI/1.0".

func (*Processor) ProcessFragments added in v0.2.0

func (p *Processor) ProcessFragments(
	ctx context.Context,
	parentReq *http.Request,
	parentBody []byte,
	fragments []*proto.EsiFragment,
) (*Result, error)

ProcessFragments resolves all ESI fragments in parentBody, fetches includes concurrently, splices the results, and returns an assembled Result.

func (*Processor) ReconcileHeaders added in v0.2.0

func (p *Processor) ReconcileHeaders(h http.Header, res *Result)

ReconcileHeaders updates h in-place per ESI 1.0 (§3.2) and RFC 9110 specifications: - Removes Surrogate-Control header - Weakens or removes ETag and Last-Modified according to PreserveETag - Updates Content-Length if present to match the spliced body length - Appends fragment Set-Cookie headers from res

If the original headers must be preserved, call h.Clone() before passing.

func (*Processor) ShouldPreserveETag added in v0.2.0

func (p *Processor) ShouldPreserveETag() bool

ShouldPreserveETag reports whether downstream ETag (weakened) and Last-Modified headers are preserved.

type Result added in v0.2.0

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

Result contains the assembled document and metadata resulting from ESI processing. Callers must invoke Release when finished to return the underlying buffer to the memory pool.

func (*Result) Body added in v0.2.0

func (r *Result) Body() []byte

Body returns the assembled document byte slice. The slice is valid only until Release is called.

func (*Result) Duration added in v0.2.0

func (r *Result) Duration() time.Duration

Duration returns the total wall-clock time spent resolving and splicing all ESI includes.

func (*Result) Release added in v0.2.0

func (r *Result) Release()

Release returns the internal buffer back to the memory pool.

Jump to

Keyboard shortcuts

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