Skip to content

feat(gateway): gzip injected HTML and cache when no SENSITIVE vars#49

Merged
olamide226 merged 3 commits into
mainfrom
perf/gateway-gzip-cache
Apr 29, 2026
Merged

feat(gateway): gzip injected HTML and cache when no SENSITIVE vars#49
olamide226 merged 3 commits into
mainfrom
perf/gateway-gzip-cache

Conversation

@olamide226
Copy link
Copy Markdown
Contributor

@olamide226 olamide226 commented Apr 29, 2026

Summary

Two transport-layer optimisations for the inject middleware. Both apply across embedded and proxy modes; spec is unchanged.

REP-RFC-0001 §4.3 line 203 already permits caching when no REP_SENSITIVE_* variables are present. Gzip is wire-level transport encoding — the post-decode bytes are byte-identical to the injected bytes, so line 202's "MUST NOT modify any other part of the HTML response" still holds.

What changed

1. Re-compress after injection (gateway/internal/inject/inject.go)

The middleware previously stripped Accept-Encoding so the upstream would return identity (necessary for the </head> byte search) and then shipped the injected body uncompressed to the client. For gateways sitting in front of a static-site upstream, HTML was transferring at ~3–4× the size the upstream would have served natively.

Now: save the client's Accept-Encoding before stripping, gzip the final body when both:

  • body is ≥ compressMinBytes (1 KB; below that gzip overhead exceeds savings), and
  • client accepts gzip (parser honours q=0 rejections and the * wildcard).

Always sets Vary: Accept-Encoding on HTML responses so downstream caches don't serve the wrong encoding to a subsequent client.

2. In-memory cache of injected output

New Middleware.EnableCache() opts in to a per-path cache storing the processed identity body plus a pre-computed gzipped variant. Cache hits skip the upstream call entirely and serve the matching variant based on the client's Accept-Encoding.

Behaviour Why
Disabled by default Backwards-compat; existing callers see identical behaviour
EnableCache() is the only way to turn it on Forces explicit, audit-friendly opt-in
Server enables only when !cfg.HotReload && len(vars.Sensitive) == 0 Honours §4.3 line 203
Skipped on non-GET, non-200, or any Set-Cookie upstream header Don't cache per-user response data
Bounded at 1000 entries (drops new additions when full) Generous for static-site workloads; protects against a runaway URL space
UpdateScriptTag clears the cache Script changed → entries stale

Server wiring (gateway/internal/server/server.go)

s.injector = inject.New(upstream, scriptTag, logger)

if !cfg.HotReload && len(vars.Sensitive) == 0 {
    s.injector.EnableCache()
    logger.Info("rep.inject.cache_enabled", ...)
}

That's the entire delta in server.go.

Tests

gateway/internal/inject/inject_perf_test.go — 12 new cases, all passing:

  • Compression: GzipEncodingWhenAccepted, NoGzipWhenClientDoesNotAccept, NoGzipForSmallBodies, GzipQZeroRejection
  • Cache behaviour: CacheDisabledByDefault, CacheHitSkipsUpstream, CacheHitRespectsAcceptEncoding, UpdateScriptTagInvalidatesCache
  • Cache safety: CacheSkipsSetCookieResponses, CacheSkipsNon200
  • Headers: VaryHeaderPresent
  • Parser: AcceptsGzip (table-driven coverage of q=0, wildcards, and the empty-header case)
go test ./...
ok  ./internal/config         0.309s
ok  ./internal/crypto         1.054s
ok  ./internal/guardrails     0.500s
ok  ./internal/health         0.778s
ok  ./internal/hotreload      1.320s
ok  ./internal/inject         1.934s
ok  ./internal/manifest       1.579s
ok  ./internal/server         1.774s
ok  ./pkg/payload             2.184s

All existing tests still pass — the only behavioural change for callers that don't opt into caching is that HTML responses now ship gzipped + with Vary: Accept-Encoding.

End-to-end smoke test

Built the binary, served a real ~20 KB SPA index.html in embedded mode with public-only env. Results:

$ curl -sD- -o /dev/null -H "Accept-Encoding: gzip" http://localhost:3737/dashboard/
HTTP/1.1 200 OK
Content-Encoding: gzip
Content-Length: 4186
Content-Type: text/html; charset=utf-8
Vary: Accept-Encoding

$ curl -sD- -o /dev/null http://localhost:3737/dashboard/
HTTP/1.1 200 OK
Content-Length: 20828
Content-Type: text/html; charset=utf-8
Vary: Accept-Encoding
  • Identity body 20,828 bytes → gzipped 4,186 bytes (~80% reduction).
  • Decompressed gzip response is byte-identical to identity response.
  • Cache log on three repeat hits to the same path:
    • request 1 → rep.inject.html path=/dashboard/ original_size=20243 injected_size=20828
    • request 2 → rep.inject.cache_hit path=/dashboard/
    • request 3 → rep.inject.cache_hit path=/dashboard/

Test plan

  • go test ./... — all 9 packages pass
  • go vet ./... — clean
  • Smoke: Content-Encoding: gzip and Vary: Accept-Encoding appear on HTML responses when client accepts gzip
  • Smoke: identity response when client doesn't send Accept-Encoding
  • Smoke: cache hits served without invoking the upstream

Out of scope

  • Streaming response (still buffers full upstream body before injection — see RFC §4.3 byte-search requirement). Could be revisited with a streaming HTML scanner later.
  • Persistent / disk cache for cold-start scenarios. Static-site workloads recover from a cold cache in one request per route, so this isn't urgent.
  • Compression of non-HTML responses. Out of scope for the inject middleware; upstream servers typically handle those already.

Copilot AI review requested due to automatic review settings April 29, 2026 16:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the gateway’s HTML injection middleware by re-compressing injected HTML responses (gzip when appropriate) and adding an optional in-memory cache of injected output for safe, static scenarios (no SENSITIVE vars + hot-reload disabled).

Changes:

  • Re-compress injected HTML responses (gzip when client accepts and body is large enough) and add Vary: Accept-Encoding.
  • Add an opt-in in-memory per-path cache for injected HTML with precomputed identity+gzip variants, plus invalidation on script tag updates.
  • Wire cache enablement in the server when !cfg.HotReload && len(vars.Sensitive)==0, and add new tests covering compression, caching, and header behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
gateway/internal/inject/inject.go Adds gzip recompression after injection, Vary handling, and an opt-in bounded in-memory cache for processed HTML responses.
gateway/internal/server/server.go Enables the inject cache automatically when hot-reload is off and there are no sensitive vars.
gateway/internal/inject/inject_perf_test.go Adds tests for gzip negotiation, cache behavior/safety, and header expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/internal/inject/inject.go
Comment thread gateway/internal/inject/inject.go
Comment thread gateway/internal/inject/inject.go Outdated
Comment thread gateway/internal/inject/inject_perf_test.go
Two transport-layer optimisations for the inject middleware. Spec
unchanged — REP-RFC-0001 §4.3 line 203 already permits caching when
no SENSITIVE variables are present, and gzip is silent on the wire
encoding (the post-decode bytes are byte-identical, so "MUST NOT
modify any other part of the HTML" still holds).

1. Gzip the response after injection
   ----------------------------------

   The middleware previously stripped Accept-Encoding (so the upstream
   would return identity, allowing a byte search for </head>) and
   shipped the injected body to the client uncompressed. For gateways
   sitting in front of a static-site upstream this meant HTML
   transferred at ~3-4× the size the upstream would have served
   natively.

   We now save the client's Accept-Encoding before stripping, gzip the
   final injected body when:

     - the body is at least compressMinBytes (1 KB; gzip overhead
       outweighs savings below that), AND
     - the client accepts gzip (Accept-Encoding parser honours q=0
       rejections and the * wildcard).

   `Vary: Accept-Encoding` is always set on HTML responses so caches
   don't serve the wrong encoding to a subsequent client.

2. In-memory cache of injected output
   ----------------------------------

   New EnableCache method opts the middleware into a per-path cache
   that stores the fully-processed (injected) body + a pre-computed
   gzipped variant. Cache hits skip the upstream call entirely and
   serve the matching variant based on the client's Accept-Encoding.

   - Disabled by default. The server enables it only when
     `!cfg.HotReload && len(vars.Sensitive) == 0` to honour the spec.
   - Skipped on per-response signals: non-GET, non-200, or any
     Set-Cookie header on the upstream response.
   - Bounded at 1000 entries (drops new additions when full;
     generous for static-site workloads).
   - UpdateScriptTag clears the cache (script changed -> entries
     stale).

Tests added in inject_perf_test.go (12 cases, all passing):

  - GzipEncodingWhenAccepted, NoGzipWhenClientDoesNotAccept,
    NoGzipForSmallBodies, GzipQZeroRejection
  - CacheDisabledByDefault, CacheHitSkipsUpstream,
    CacheHitRespectsAcceptEncoding, UpdateScriptTagInvalidatesCache
  - CacheSkipsSetCookieResponses, CacheSkipsNon200
  - VaryHeaderPresent, AcceptsGzip (parser unit test)

All existing gateway tests still pass (`go test ./...`).

Spec note
---------

REP-RFC-0001 §4.3 reviewed. The cache opt-in matches the existing
"MUST NOT cache when SENSITIVE present" requirement (line 203). Gzip
is wire-level transport encoding, not HTML modification — line 202's
"MUST NOT modify any other part of the HTML response" is preserved
because the encoded body decompresses byte-for-byte to the same
injected bytes.
@olamide226 olamide226 force-pushed the perf/gateway-gzip-cache branch from c95cbd2 to 407a8fa Compare April 29, 2026 16:22
…cost

Addresses review feedback on the inject middleware.

acceptsGzip wildcard precedence
-------------------------------

Per RFC 9110 §12.5.3, an explicit coding parameter takes precedence
over the `*` wildcard. The previous implementation iterated tokens
and returned true on the first match with q>0 — which incorrectly
permitted gzip for `Accept-Encoding: gzip;q=0, *;q=0.5`.

Now scan all tokens, separately track whether `gzip` was named
explicitly and whether `*` was named, and resolve precedence at the
end: explicit gzip wins, otherwise fall back to the wildcard.

addVary handles comma-separated existing values
-----------------------------------------------

`Vary` is commonly a comma-separated list (e.g. `Vary: Origin,
Accept-Encoding`). The previous implementation only compared whole
header values, so calling addVary("Accept-Encoding") on a header
with `Origin, Accept-Encoding` would append a duplicate. Now split
each existing value on `,` and check token-wise.

Skip gzip work that won't be used
---------------------------------

Pre-computing the gzipped variant on every HTML response wasted CPU
when (a) caching is disabled and (b) the current client doesn't
accept gzip. Gate the compression call on
`acceptsGzip(clientAccepts) || cacheActive()` so we only do the
work when a current or future client will benefit.

Tests
-----

- TestAcceptsGzip: two new cases for the precedence rules
  (`gzip;q=0, *;q=0.5` -> false; `*;q=0, gzip` -> true).
- TestAddVary_DoesNotDuplicate: table-driven coverage for the
  comma-separated and case-insensitive cases.

All existing tests still pass.
Copilot AI review requested due to automatic review settings April 29, 2026 16:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds transport-level optimizations to the HTML injection middleware by re-introducing gzip to clients after injection and optionally caching injected HTML responses in-memory when the gateway is configured without sensitive variables.

Changes:

  • Re-compress injected HTML with gzip when the client accepts it and the body is large enough; add Vary: Accept-Encoding.
  • Add an opt-in in-memory cache in the inject middleware to skip upstream calls for repeated GETs.
  • Wire cache enablement in the server when !cfg.HotReload && len(vars.Sensitive) == 0.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
gateway/internal/server/server.go Enables inject middleware caching under “safe” configuration conditions.
gateway/internal/inject/inject.go Implements gzip re-compression, Vary handling, and a per-path in-memory cache.
gateway/internal/inject/inject_perf_test.go Adds test coverage for gzip negotiation, cache behavior, and header handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/internal/server/server.go
Comment thread gateway/internal/inject/inject.go Outdated
Comment thread gateway/internal/inject/inject.go Outdated
Comment thread gateway/internal/inject/inject.go Outdated
Comment thread gateway/internal/inject/inject.go
Addresses a second batch of review feedback on the inject middleware.
All five comments are correctness fixes around cache eligibility,
header propagation, and HTTP semantics.

1. Cache key now includes query string
   ------------------------------------

   The cache was keyed on `r.URL.Path`, so `/page?a=1` and `/page?a=2`
   collided and could serve the wrong HTML. Switched to
   `r.URL.RequestURI()` (path + query) so distinct queries get
   distinct cache entries.

2. Skip caching for requests carrying identity headers
   ---------------------------------------------------

   In proxy mode an upstream may return per-user HTML based on the
   request's `Cookie` or `Authorization` header. Without this guard,
   the cache could replay one user's HTML to another. New
   requestIsCacheable helper rejects requests carrying either header.

3. Honour upstream cache directives
   --------------------------------

   New responseIsCacheable helper now also rejects:

     - Cache-Control: private / no-store / no-cache
     - Vary: Cookie / Authorization / *
     - any Set-Cookie header (kept from before)

   Together with (2) this means the cache will only ever store
   responses that the upstream itself considers shareable across
   clients.

4. Strip ETag and Last-Modified after injection
   --------------------------------------------

   The middleware modifies the response body, so the upstream's
   validators no longer describe the bytes being served. Keeping them
   would produce false 304s on conditional requests. Drop both from
   the outbound response header set.

5. Pass-through bodyless statuses
   ------------------------------

   Per RFC 9110 §15, statuses 1xx, 204 No Content, and 304 Not
   Modified MUST NOT carry a body. The middleware previously ran
   injection (and would have set Content-Length) for any HTML
   Content-Type regardless of status. New isBodylessStatus helper
   short-circuits to a clean pass-through for those, preserving the
   upstream headers (including ETag/Last-Modified, which still
   describe the upstream's representation in the bodyless case).

Tests added in inject_perf_test.go (14 new cases, all passing):

  - CacheKeyIncludesQueryString
  - CacheSkipsCookieRequests, CacheSkipsAuthorizationRequests
  - CacheSkipsCacheControlPrivate (table-driven: private, no-store,
    no-cache, "private, max-age=60")
  - CacheSkipsVaryByCookie
  - StripsETagAndLastModified
  - BodylessStatusPassThrough (table-driven: 100, 101, 204, 304)

`make test` clean across all 9 gateway packages. (make lint flags one
pre-existing errcheck in internal/config/envfile.go from Feb 2026 —
not introduced by this branch.)
@olamide226 olamide226 merged commit 83b5e58 into main Apr 29, 2026
9 checks passed
@olamide226 olamide226 deleted the perf/gateway-gzip-cache branch April 29, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants