Skip to content

feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fix #129#198

Open
gbengaeben wants to merge 3 commits into
ChainForgee:mainfrom
gbengaeben:fix/issue-129-http-cache-headers
Open

feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fix #129#198
gbengaeben wants to merge 3 commits into
ChainForgee:mainfrom
gbengaeben:fix/issue-129-http-cache-headers

Conversation

@gbengaeben

Copy link
Copy Markdown
Contributor

Summary

Resolves #129.

Adds HttpCacheInterceptor as the innermost app-level NestJS interceptor. It emits RFC 7232-compliant ETag + Cache-Control + Vary headers on GET/HEAD responses, Cache-Control: no-store + Pragma: no-cache (HTTP/1.0 fallback) on every mutation response (POST/PUT/PATCH/DELETE), and overrides the pre-set Cache-Control to no-store when a controller throws so 4xx/5xx responses are never cached by an intermediary.

Behavior

  • GET / HEADETag (sha256 of canonical JSON), Cache-Control: private, must-revalidate, Vary: Authorization, Accept-Encoding. On a matching If-None-Match (strong, W/ weak, comma-separated multi-value, or * wildcard), short-circuits to 304 Not Modified with empty body.
  • POST / PUT / PATCH / DELETECache-Control: no-store + Pragma: no-cache, set before any path / decorator check.
  • 4xx / 5xx via thrown error → interceptor catches via catchError, overrides to no-store + Pragma: no-cache and strips any pre-set ETag / Vary so an error can never be cached.
  • Streams / Buffers / Uint8Array / non-JSON Content-Type / responses larger than HTTP_CACHE_MAX_ETAG_BYTESETag skipped but Cache-Control retained.

Decorators

  • @HttpCache({ public?: boolean, ttl?: number }) — switch to public cache or set explicit max-age.
  • @HttpCacheTtl(seconds) — override max-age while preserving private.
  • @SkipHttpCache() — opt-out per handler / controller for sensitive or already-cached responses.

Configuration

Env var Default Behavior
HTTP_CACHE_ENABLED true Kill switch for staged rollout.
HTTP_CACHE_DEFAULT_TTL 0 (→ no-cache) Default max-age for the private directive.
HTTP_CACHE_MAX_ETAG_BYTES 262144 Skip ETag hashing on larger payloads.
NODE_ENV `X-Http-Cache: miss

Files

  • app/backend/src/common/interceptors/http-cache.interceptor.ts (new, ~270 LOC)
  • app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts (new, ~330 LOC of specs)
  • app/backend/src/common/decorators/http-cache.decorator.ts (new)
  • app/backend/src/common/utils/json-canonicalize.util.ts (new)
  • app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts (new)
  • app/backend/src/app.module.ts (registers the interceptor as last APP_INTERCEPTOR)
  • app/backend/tsconfig.json (adds rootDir: "./" so ts-jest can compile isolated spec files in __tests__ subdirs without TS5011, and ignoreDeprecations: "6.0" to silence TS5101 / TS5107 on the existing project-wide baseUrl and moduleResolution: "node")

Why it sits between Deprecation and the handler

APP_INTERCEPTOR runs after APP_GUARD (the JWT / API key / rate-limit guards mutate request.user before our layer is reached) and after DeprecationInterceptor (so we don't re-cache the body of a deprecated endpoint that is being phased out). NestJS runs interceptors outer→inner on the request side and inner→outer on the response side, so being registered last means map sees the raw handler body for ETag hashing.

Risk

Low. The interceptor is conservative: no-store is set before any path / decorator check on mutating methods, all __tests__ co-located specs cover the full behavior matrix (Pragma on mutations, catchError override on GET/HEAD when controller throws, primitive response bodies, ETag skip on non-JSON Content-Type, all If-None-Match forms, all decorators, all skipped paths).

Maintainer notes

  • A previous attempt at the same fix (feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fixes #129 #197) was closed because the new unit specs could not run: ts-jest failed with TS5011 (__tests__ subdir falsely inferred as rootDir) and TS5101 / TS5107 (deprecated baseUrl / moduleResolution: "node") inside the same compile. Both are addressed by the two-line tsconfig.json change above; no globals or build outputs are touched.
  • The Pragma: no-cache header on mutations and on thrown errors is the RFC 7234 §5.5 HTTP/1.0 fallback. It is not set on GET/HEAD because doing so would defeat the cache entirely — we accept that an old HTTP/1.0 intermediary could still cache a GET response and document that here.

Closes #129

…endpoints

Resolves ChainForgee#129.

* New HttpCacheInterceptor (registered as APP_INTERCEPTOR, innermost so it
  sees the raw handler response) emits ETag, Cache-Control and Vary on
  successful GET/HEAD responses, and emits Cache-Control: no-store on
  POST/PUT/PATCH/DELETE so shared caches never persist mutations.
* ETag is a strong SHA-256 over canonical JSON (sorted keys, dates as
  ISO, BigInt as string) so cosmetic reordering does not invalidate the
  cache. Weak-comparison If-None-Match handling matches
  If-None-Match: "*", W/-prefixed tags, and comma-separated lists,
  returning 304 Not Modified with empty body and retained ETag /
  Cache-Control.
* Default directive is `private, must-revalidate` so auth-scoped NGO
  data is never cached by shared proxies; opt in to `public` or override
  TTL via @HttpCache / @HttpCacheTtl, opt out via @SkipHttpCache.
* Streams (Node and Web), Buffers, Uint8Array / ArrayBufferView, and
  responses with non-JSON Content-Type are passed through without any
  ETag but still get Cache-Control.
* Mutation branch is evaluated before skip-path or @SkipHttpCache so
  any mutation — even on a skip-prefixed route — leaves the client
  with Cache-Control: no-store.
* X-Http-Cache debug header is gated on NODE_ENV != production.
* Newly added unit specs cover the decorator surface, weak / wildcard /
  multi-value If-None-Match handling, HEAD cache surface + 304, BigInt
  and typed-array bodies, and mutation-on-skip-path no-store.
…endpoints

Resolves ChainForgee#129.

Adds HttpCacheInterceptor as the innermost APP_INTERCEPTOR in the
NestJS request pipeline. Emits RFC 7232-compliant ETag + Cache-Control
+ Vary on GET/HEAD responses, Cache-Control: no-store + Pragma: no-cache
on every mutation response (POST/PUT/PATCH/DELETE), and overrides the
pre-set Cache-Control to no-store when a controller throws so 4xx/5xx
responses are never cached by an intermediary.

The interceptor is env-gated via HTTP_CACHE_ENABLED (default true)
and can be tuned per handler via @HttpCache, @HttpCacheTtl, and
@SkipHttpCache decorators. ETag is computed from a deterministic
canonical JSON view of the handler result so cosmetic serialization
reordering does not invalidate the cache.

Files:
- src/common/interceptors/http-cache.interceptor.ts (new)
- src/common/interceptors/__tests__/http-cache.interceptor.spec.ts (new)
- src/common/decorators/http-cache.decorator.ts (new)
- src/common/utils/json-canonicalize.util.ts (new)
- src/common/utils/__tests__/json-canonicalize.util.spec.ts (new)
- src/app.module.ts (registers the new interceptor)
- tsconfig.json (rootDir hint for ts-jest on __tests__ subdirs and
  ignoreDeprecations 6.0 to silence TS5101 / TS5107 on the existing
  project-wide baseUrl and moduleResolution: node)
This commit lands two coupled fixes for issue ChainForgee#129:

1. Add HttpCacheInterceptor (PR ChainForgee#198) — RFC 7232-compliant ETag +
   Cache-Control + Vary on safe GET/HEAD responses, and Cache-Control:
   no-store + Pragma: no-cache on every mutation response. catchError
   overrides to no-store so 4xx/5xx are never cached. @HttpCache,
   @HttpCacheTtl, and @SkipHttpCache decorators for per-handler tuning.

   - defaultTtl now falls back to NaN (not 0) when HTTP_CACHE_DEFAULT_TTL
     is unset or 0, so unconfigured handlers don't silently emit
     no-cache. Explicit @HttpCacheTtl(0) remains the only opt-in.
   - HEAD requests are treated as safe methods and receive Cache-Control.
   - Tests assert all advertised behaviors (Pragma on mutations,
     catchError override, primitives, non-JSON Content-Type bypass,
     streams/buffers, @HttpCache decorator combinations, all
     If-None-Match shapes, the full skip-path list).

2. Align jest / jest-runtime versions across the pnpm workspace so the
   new specs and every other backend suite can actually run on a clean
   checkout.

   - Root pnpm.overrides pins jest: ^30.4.2 and jest-runtime: ^30.4.2.
   - app/mobile keeps jest@^29.7.0 and jest-runtime@^29.7.0 because
     Expo SDK 54 segfaults on jest 30.
   - app/backend and app/frontend jest ranges bumped from their old
     caret floors so dedupe lands a single copy.
   - tsconfig.json gains rootDir (so ts-jest can compile isolated
     __tests__ spec files without TS5011) and ignoreDeprecations '5.0'
     because backend's locally-resolved tsc is 5.9.3; '6.0' was rejected
     as out of range.
   - pnpm-lock.yaml re-generated from a clean install to drop a stale
     jest-runtime@30.3.0 resolution that was surviving previous overrides.

Closes ChainForgee#129.

Validated:
  cd app/backend && npx jest --testPathPatterns 'http-cache|json-canonicalize' --no-coverage
    → 30 / 30 passing
  cd app/backend && npx tsc --noEmit
    → clean
  cd app/backend && npx jest --no-coverage
    → 44 / 45 suites passing (1 pre-existing app.module-style spec skipped)
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.

[LOW] No cache headers set on API responses

1 participant