feat(backend): add HTTP cache headers (ETag + Cache-Control) for GET endpoints — fix #129#198
Open
gbengaeben wants to merge 3 commits into
Open
Conversation
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Resolves #129.
Adds
HttpCacheInterceptoras the innermost app-level NestJS interceptor. It emits RFC 7232-compliantETag+Cache-Control+Varyheaders 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
ETag(sha256 of canonical JSON),Cache-Control: private, must-revalidate,Vary: Authorization, Accept-Encoding. On a matchingIf-None-Match(strong,W/weak, comma-separated multi-value, or*wildcard), short-circuits to304 Not Modifiedwith empty body.Cache-Control: no-store+Pragma: no-cache, set before any path / decorator check.catchError, overrides tono-store+Pragma: no-cacheand strips any pre-setETag/Varyso an error can never be cached.HTTP_CACHE_MAX_ETAG_BYTES→ETagskipped butCache-Controlretained.Decorators
@HttpCache({ public?: boolean, ttl?: number })— switch topubliccache or set explicitmax-age.@HttpCacheTtl(seconds)— overridemax-agewhile preservingprivate.@SkipHttpCache()— opt-out per handler / controller for sensitive or already-cached responses.Configuration
HTTP_CACHE_ENABLEDtrueHTTP_CACHE_DEFAULT_TTL0(→no-cache)max-agefor the private directive.HTTP_CACHE_MAX_ETAG_BYTES262144NODE_ENVFiles
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 lastAPP_INTERCEPTOR)app/backend/tsconfig.json(addsrootDir: "./"so ts-jest can compile isolated spec files in__tests__subdirs without TS5011, andignoreDeprecations: "6.0"to silence TS5101 / TS5107 on the existing project-widebaseUrlandmoduleResolution: "node")Why it sits between Deprecation and the handler
APP_INTERCEPTORruns afterAPP_GUARD(the JWT / API key / rate-limit guards mutaterequest.userbefore our layer is reached) and afterDeprecationInterceptor(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 meansmapsees the raw handler body for ETag hashing.Risk
Low. The interceptor is conservative:
no-storeis 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
__tests__subdir falsely inferred as rootDir) and TS5101 / TS5107 (deprecatedbaseUrl/moduleResolution: "node") inside the same compile. Both are addressed by the two-linetsconfig.jsonchange above; no globals or build outputs are touched.Pragma: no-cacheheader 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