feat(gateway): gzip injected HTML and cache when no SENSITIVE vars#49
Conversation
There was a problem hiding this comment.
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.
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.
c95cbd2 to
407a8fa
Compare
…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.
There was a problem hiding this comment.
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.
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.)
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-Encodingso 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-Encodingbefore stripping, gzip the final body when both:compressMinBytes(1 KB; below that gzip overhead exceeds savings), andq=0rejections and the*wildcard).Always sets
Vary: Accept-Encodingon 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'sAccept-Encoding.EnableCache()is the only way to turn it on!cfg.HotReload && len(vars.Sensitive) == 0Set-Cookieupstream headerUpdateScriptTagclears the cacheServer wiring (
gateway/internal/server/server.go)That's the entire delta in server.go.
Tests
gateway/internal/inject/inject_perf_test.go— 12 new cases, all passing:GzipEncodingWhenAccepted,NoGzipWhenClientDoesNotAccept,NoGzipForSmallBodies,GzipQZeroRejectionCacheDisabledByDefault,CacheHitSkipsUpstream,CacheHitRespectsAcceptEncoding,UpdateScriptTagInvalidatesCacheCacheSkipsSetCookieResponses,CacheSkipsNon200VaryHeaderPresentAcceptsGzip(table-driven coverage ofq=0, wildcards, and the empty-header case)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.htmlin embedded mode with public-only env. Results:rep.inject.html path=/dashboard/ original_size=20243 injected_size=20828rep.inject.cache_hit path=/dashboard/rep.inject.cache_hit path=/dashboard/Test plan
go test ./...— all 9 packages passgo vet ./...— cleanContent-Encoding: gzipandVary: Accept-Encodingappear on HTML responses when client accepts gzipAccept-EncodingOut of scope