diff --git a/.prettierignore b/.prettierignore index ccd54197..d190f6a6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,6 @@ ilc/public ilc/dist .nyc_output .karma_output + +# Not ours to reformat: mkdocs uses 2-space YAML, prettier rewrites the whole file +mkdocs.yml diff --git a/docs/ssr_fragment_caching.md b/docs/ssr_fragment_caching.md new file mode 100644 index 00000000..1b3cbddd --- /dev/null +++ b/docs/ssr_fragment_caching.md @@ -0,0 +1,231 @@ +# SSR fragment caching + +ILC can serve SSR fragment output from an in-memory cache, so repeatedly requested static fragments are +not re-rendered on every request. Caching is **strictly opt-in**: routes and fragments behave exactly as +before unless caching is explicitly enabled for an app. + +## Enabling + +Add a `cache` section to the app's `ssr` config in the Registry: + +```json +{ + "ssr": { + "src": "http://fragment-app/render", + "timeout": 1000, + "cache": { + "enabled": true, + "ttlSeconds": 300 + } + } +} +``` + +- `enabled` — required boolean; `false` (or the absent `cache` key) keeps today's behavior. +- `ttlSeconds` — required when `enabled: true`; positive integer up to 30 days (2592000), freshness + window in seconds. ILC enforces the same contract at runtime: `enabled: true` without a valid + `ttlSeconds` is not cached. + +## What is cached and what never is + +A fragment render is cached only when **all** of the following hold: + +- `cache.enabled: true` for the app in the Registry; +- the fragment is not wrapped (`wrappedWith` / App Wrappers flow is excluded); +- the fragment does not use `forwardQuerystring` (arbitrary user query params would explode key + cardinality — the combination is refused); +- the route is not a special route (404 etc.) — its `reqUrl` is the original request URL, so every + scanned path would become a distinct cache entry; such renders are refused at runtime. For the + same reason **do not enable caching for apps rendered on wildcard routes** (`/news/*`): the path + is part of the cache key, and unbounded paths churn the LRU; +- `x-request-host` is present as a string; without it ILC cannot isolate cached output by domain, + so the request is rendered privately; +- the response status is exactly `200`; +- the response carries no `set-cookie` header (a personalization signal); +- the response does not opt out via `Cache-Control: no-store` or `Cache-Control: private`. + +Error and non-2xx responses are never cached; a response interrupted mid-stream is never cached. +Primary fragments are cacheable under the same rules (only complete `200` responses are stored, so +special 404-route handling is unaffected). Requests carrying an `ILC-overrideConfig` cookie (LDE / +develop-in-production) always bypass the cache, so developers see their live changes. + +### Fragments rendering prices + +Fragments that render prices must never be served from cache: stale pricing is a business and +compliance risk. There is deliberately no automated way to detect "this fragment renders prices", +so the guarantee is layered: + +1. Caching is opt-in per app — **do not enable it for price-bearing fragments**. + Enabling it for a fragment that always refuses is not merely useless — it is **worse than leaving + it off**. A cold request then costs two renders instead of one: a shared probe that gets refused, + followed by the private render the user actually receives, because the probe was rendered without + the user's headers and cannot be served to them. The negative entry keeps it to one render per + request afterwards, but it expires after 60s, so the double render recurs roughly once a minute + per cache key for as long as the fragment stays opted in. +2. A fragment team can protect itself regardless of Registry config by responding with + `Cache-Control: no-store` — ILC honours it even when caching is enabled. A response that turns + out non-cacheable (`no-store`/`private`/`set-cookie`) is never shared between users: every + request gets its own live render with full user headers. If an entry was already cached before + the fragment turned dynamic, the first completed refresh replaces it with a **negative entry**. + Requests arriving while that single refresh is in flight can still receive the stale response; + once the negative entry is stored, requests go straight to the fragment until cacheability is + probed again after it expires. + +## User isolation (cache key and headers) + +The contract: **a request header reaches a cacheable fragment only if its value is part of the cache +key**. Cacheable renders are performed with an anonymous header set — only `x-request-host` and +`x-request-intl` are forwarded; `cookie`, `authorization`, `accept-language`, `referer`, `user-agent`, +`x-request-uri`, all `x-forwarded-*` and `fragmentProxyHeaders` are stripped. This makes it structurally +impossible to serve output personalized for one user to another. + +The cache key is composed of: fragment `src`, app id, route (`basePath` + query-stripped `reqUrl`), +`appProps` (including experiment variants), domain (`x-request-host`) and locale/currency +(`x-request-intl`). Keys are stored and logged only as SHA-256 digests, so `ssrProps` or URL tokens +never leak into logs. + +The same rule applies to the **query string**: it is neither part of the cache key nor visible to a +cacheable render — `routerProps.reqUrl` arrives query-stripped, so arbitrary UTM/gclid traffic +shares one entry. A fragment that renders query-dependent output server-side must not have caching +enabled (or must answer `Cache-Control: no-store`). + +## Lifetime and invalidation + +Expiry is TTL-based with **stale-while-revalidate** semantics: +after `ttlSeconds`, requests are served the stale entry immediately while a single background +render refreshes it; concurrent misses for one key are deduplicated into one render. +`ttlSeconds` triggers a refresh but does not bound staleness: if the background render keeps +failing, the last good entry keeps being served indefinitely (each request retriggers a refresh +attempt) — deliberate graceful degradation during fragment outages, at the cost of unbounded +staleness while the fragment is down. A fragment stuck stale shows up as `stale` metrics +persisting past the TTL; the failing background refreshes themselves are reported through the +logger's error channel (the rejection as thrown, without a dedicated message prefix). There is no explicit purge API — pick TTLs accordingly. The cache is in-memory and per ILC instance: +it is empty after every deploy/restart, and hit ratios are per instance. The render deadline derives +from the fragment's own `ssr.timeout` (plus a small slack), never from `ttlSeconds` — a short TTL +cannot abort a slow-but-legal render. + +### Runtime architecture + +The cache is one request coordinator at the Tailor `requestFragment` seam. Cache policy does not travel +inside fragment attributes: the coordinator explicitly asks the transport for either a private render +(normal forwarded headers) or a shared render (only headers represented in the cache key). + +```mermaid +flowchart LR + Tailor --> Cache[Fragment cache coordinator] + Cache --> Plan{Request eligible?} + Plan -->|no| Private[Private fragment render] + Plan -->|yes| State{Unified cache entry} + State -->|fresh response| Replay[Replay stored response] + State -->|stale response| Replay + State -->|negative entry| Private + State -->|miss| Shared[Shared fragment render] + Shared --> Decision{Response cacheable?} + Decision -->|yes| Store[Byte-budgeted LRU response entry] + Decision -->|no| Refusal[Negative entry] + Refusal --> Private +``` + +Positive responses and temporary refusals use the same keyed lifecycle. Concurrent misses share only the +anonymous probe; when that probe is refused, each caller receives its own private render. Stale responses +are still served while one background refresh runs. A refresh that returns `private`, `no-store`, or +`set-cookie` atomically replaces the response with a negative entry — the fragment declared itself +uncacheable, deliberately. A refresh that returns a non-2xx status is treated differently: since that is +the one refusal reason likely to be a transient origin blip rather than a deliberate opt-out, it leaves an +existing cached response untouched rather than tombstoning it. A non-2xx on a cold miss (nothing cached +yet) still writes a negative entry, so a failing origin isn't hammered on every request. + +## Observability + +- New Relic metrics per fragment: `FragmentCache//hit|stale|miss|refuse|error` (`error` — + a render failed while a request was awaiting it, i.e. on a cold miss; the error is rethrown and + handled as without caching. Failed _background_ refreshes surface in logs, not in this metric). +- A refusal always carries a reason, so `refuse` is diagnosable without reading the code: + `FragmentCache//refuse/`. Likewise `error` carries `cache-internal` or `fragment` + to say whose fault it was. These are two separate taxonomies and never share a field. +- Each fragment served through the cache is annotated in the page markup with an HTML comment + `` (`HIT` / `STALE` / `MISS`), next to the standard + `` comment. A refused render is marked with its reason — + `` — which is the per-request diagnostic to reach + for in production. The hot-path decisions (`hit` / `stale` / `miss`) are logged at `debug`, since + one line per cache-enabled fragment per request would multiply log volume at full traffic; only + `refuse` and `error` are logged at `info`. + +### Refusal reasons + +The union in `server/tailor/request-fragment-cache/types/refusal.ts` is the single home of this +taxonomy; the rules stay where they are enforced, but every reason is named in one place. + +| Stage | Reason | Meaning | +| -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| request | `cache-disabled` | Fragment never opted in. **Not reported** — it is not a refusal, and reporting it would emit on nearly every render. | +| request | `ttl-invalid` | `ttlSeconds` missing, non-integer, or not positive. | +| request | `ttl-too-long` | `ttlSeconds` above the 30-day ceiling shared with the registry schema. | +| request | `wrapper-conf` | Fragment renders inside a wrapper, whose output the cache key does not cover. | +| request | `forward-querystring` | Fragment forwards the query string, which the key deliberately strips. | +| request | `special-role-route` | Special-role route (404 and friends); those renders are never shared. | +| request | `no-vary-host` | No `x-request-host` to vary on — a shared entry could leak across hosts. | +| request | `lde-request` | Local development environment request, rendered privately on purpose. | +| response | `status-not-200` | Only 200 responses are shared. | +| response | `set-cookie` | Response sets cookies — the clearest personalisation signal. | +| response | `cache-control` | A directive forbidding reuse without revalidation (RFC 9111 §5.2.2). | +| capture | `body-too-large` | Body exceeded the per-response byte cap. | +| capture | `stream-closed-early` | Stream closed without `end`; the buffered body would be truncated. | +| capture | `unsupported-encoding` | `Content-Encoding` this cache cannot replay. | +| capture | `decode-failed` | Decompression failed, including the decompressed-size guard. | +| runtime | `capture-budget-exhausted` | Every concurrent-capture slot taken; the render proceeds privately. | +| runtime | `render-deadline` | Shared probe outlived the render deadline; the render proceeds privately. | + +A negative entry remembers the reason that produced it, so refusals replayed from the tombstone +(up to 60s) report the original cause rather than a second, contextless `refuse`. Stream **errors** +are not refusals: they surface as `error` and are rethrown. + +## Operational validation + +### How many entries a deployment needs + +An entry exists per distinct cache key, and the key covers the fragment URL, the app id, the route +(`basePath` plus the query-stripped `reqUrl`), `appProps`, the l10n manifest and the vary headers. +Vary headers carry the host and `x-request-intl`, which encodes both locale and currency. So entry +count grows as: + +``` +routes x cacheable fragments per route x hosts x locales x currencies +``` + +A shared layout fragment therefore occupies one entry **per route**, not one overall: it receives +the route in `routerProps` and may legitimately render differently for each. Twenty routes with two +cacheable fragments, five locales and three currencies already needs ~600 entries against the +default `maxEntries` of 500. + +Exceeding either bound is visible rather than silent — evictions log at `warn` level with the +limits, one line per minute at most, carrying the number of keys evicted since the previous line +(a cache whose working set exceeds the budget evicts on every insert, so a line per eviction would +report one steady state thousands of times a minute). Treat those lines as the signal to re-size the +budget for the deployment, and measure entry count alongside hit ratio when validating the rollout +below. + +The automated integration suite verifies that repeated page requests render a cacheable fragment +once, but it is not a production-like load benchmark. Validation of SSR load reduction and response +time on a representative static route remains a rollout prerequisite and must be recorded with the +route, concurrency, cache hit ratio, fragment render count and latency percentiles. + +## Trade-offs and limits + +- **Per-instance memory cache** — no cross-instance sharing, cold after deploys. Chosen to avoid new + infrastructure (no Redis in the stack). The `CacheStorage` interface allows swapping the backend for + another **in-process** one; it does not make a networked backend a drop-in. `CacheStorage.getItem` is + synchronous (`common/types/CacheWrapper.ts`), so Redis or any out-of-process store would first require + making the storage contract and the whole `lookup` → `get` → `handle` chain asynchronous. That is + separate work, not a configuration change. +- **Miss path is buffered** — a cacheable fragment's body is fully buffered before it is streamed into + the page (required to store it). Enabled fragments are expected to be small, fast, static markup. +- **Body size cap** — bodies larger than 1 MiB (raw or decompressed) are never cached: buffering + aborts, the response is treated as non-cacheable (negative entry + live streamed renders), so a + misbehaving fragment degrades to "not cached" instead of exhausting the ILC heap. +- **LRU cap** — the storage holds at most 500 entries and 64 MiB of response bodies in total; evictions are + logged with a warning, rate-limited to one line per minute with a count. Negative entries have zero body weight. Watch key + cardinality: every locale, domain, route and `appProps` variant (including experiments) is a separate + entry. +- **Content encoding** — `gzip` and `deflate` responses are stored decompressed and replayed without + `content-encoding`; unsupported encodings are treated as non-cacheable and rendered privately. diff --git a/ilc/client/registry/BrowserCacheStorage.ts b/ilc/client/registry/BrowserCacheStorage.ts index 59acaa61..decb4bee 100644 --- a/ilc/client/registry/BrowserCacheStorage.ts +++ b/ilc/client/registry/BrowserCacheStorage.ts @@ -11,4 +11,8 @@ export class BrowserCacheStorage implements CacheStorage { setItem(key: string, cache: CacheResult): void { this.storage.setItem(key, JSON.stringify(cache)); } + + deleteItem(key: string): void { + this.storage.removeItem(key); + } } diff --git a/ilc/common/DefaultCacheWrapper.spec.ts b/ilc/common/DefaultCacheWrapper.spec.ts index 282f12ec..4c625e3f 100644 --- a/ilc/common/DefaultCacheWrapper.spec.ts +++ b/ilc/common/DefaultCacheWrapper.spec.ts @@ -46,6 +46,7 @@ describe('DefaultCacheWrapper', () => { storageMock = { getItem: (key) => storageMockCache[key] ?? null, setItem: (key, cache) => (storageMockCache[key] = cache), + deleteItem: (key) => delete storageMockCache[key], }; const cacheWrapper = new DefaultCacheWrapper(storageMock, loggerMock, null); wrappedFn = cacheWrapper.wrap(fn, { name: 'testCacheName' }); @@ -266,6 +267,7 @@ describe('DefaultCacheWrapper', () => { { setItem, getItem: () => null, + deleteItem: sinon.stub(), }, loggerMock, null, diff --git a/ilc/common/EvictingCacheStorage.spec.ts b/ilc/common/EvictingCacheStorage.spec.ts index d55d8fb5..6d4f1ae7 100644 --- a/ilc/common/EvictingCacheStorage.spec.ts +++ b/ilc/common/EvictingCacheStorage.spec.ts @@ -84,4 +84,38 @@ describe('EvictingCacheStorage', () => { expect(cache.getItem('c')).to.deep.equal({ data: 3, cachedAt: 1 }); expect(cache.getItem('d')).to.deep.equal({ data: 4, cachedAt: 1 }); }); + + it('should evict least-recently-used entries until the total weight is within budget', () => { + const weightedCache = new EvictingCacheStorage({ + maxSize: 10, + maxWeight: 10, + getWeight: (entry) => entry.data as number, + }); + + weightedCache.setItem('a', { data: 4, cachedAt: 1 }); + weightedCache.setItem('b', { data: 4, cachedAt: 1 }); + weightedCache.getItem('a'); + weightedCache.setItem('c', { data: 5, cachedAt: 1 }); + + expect(weightedCache.getItem('b')).to.be.null; + expect(weightedCache.getItem('a')).to.deep.equal({ data: 4, cachedAt: 1 }); + expect(weightedCache.getItem('c')).to.deep.equal({ data: 5, cachedAt: 1 }); + }); + + it('should update the total weight when an existing entry is replaced or deleted', () => { + const weightedCache = new EvictingCacheStorage({ + maxSize: 10, + maxWeight: 10, + getWeight: (entry) => entry.data as number, + }); + + weightedCache.setItem('a', { data: 8, cachedAt: 1 }); + weightedCache.setItem('a', { data: 2, cachedAt: 1 }); + weightedCache.setItem('b', { data: 8, cachedAt: 1 }); + weightedCache.deleteItem('a'); + weightedCache.setItem('c', { data: 2, cachedAt: 1 }); + + expect(weightedCache.getItem('b')).to.deep.equal({ data: 8, cachedAt: 1 }); + expect(weightedCache.getItem('c')).to.deep.equal({ data: 2, cachedAt: 1 }); + }); }); diff --git a/ilc/common/EvictingCacheStorage.ts b/ilc/common/EvictingCacheStorage.ts index 2c18b125..09a6b348 100644 --- a/ilc/common/EvictingCacheStorage.ts +++ b/ilc/common/EvictingCacheStorage.ts @@ -2,40 +2,55 @@ import { CacheResult, CacheStorage } from './types/CacheWrapper'; type EvictingCacheStorageOptions = { maxSize: number; + maxWeight?: number; + getWeight?: (cache: CacheResult) => number; onEvict?: (evictedKey: string) => void; }; export class EvictingCacheStorage implements CacheStorage { private readonly cache: Map> = new Map(); + private totalWeight = 0; constructor(private readonly options: EvictingCacheStorageOptions) {} getItem(key: string): CacheResult | null { - if (!this.cache.has(key)) { + const value = this.cache.get(key); + if (value === undefined) { return null; } // Move the accessed key to the end to mark it as recently used - const value = this.cache.get(key)!; this.cache.delete(key); this.cache.set(key, value); return value; } - setItem(key: string, cache: CacheResult): void { - // If the key already exists, delete it to update the order - if (this.cache.has(key)) { - this.cache.delete(key); + deleteItem(key: string): void { + const existing = this.cache.get(key); + if (existing !== undefined) { + this.totalWeight -= this.getWeight(existing); } + this.cache.delete(key); + } + + setItem(key: string, cache: CacheResult): void { + this.deleteItem(key); - // Add the new item to the cache this.cache.set(key, cache); + this.totalWeight += this.getWeight(cache); - // Evict the least recently used item if the cache exceeds maxSize - if (this.cache.size > this.options.maxSize) { + while (this.cache.size > this.options.maxSize || this.isOverWeightBudget()) { const oldestKey = this.cache.keys().next().value!; // Get the first key (LRU) - this.cache.delete(oldestKey); + this.deleteItem(oldestKey); this.options.onEvict?.(oldestKey); } } + + private getWeight(cache: CacheResult): number { + return this.options.getWeight?.(cache) ?? 0; + } + + private isOverWeightBudget(): boolean { + return this.options.maxWeight !== undefined && this.totalWeight > this.options.maxWeight; + } } diff --git a/ilc/common/types/CacheWrapper.ts b/ilc/common/types/CacheWrapper.ts index 7e3f7ca0..12bdb35e 100644 --- a/ilc/common/types/CacheWrapper.ts +++ b/ilc/common/types/CacheWrapper.ts @@ -12,6 +12,7 @@ export type CacheHashFn = (value: string) => string; export interface CacheStorage { getItem(key: string): CacheResult | null; setItem(key: string, cache: CacheResult): void; + deleteItem(key: string): void; } export interface CacheWrapper { diff --git a/ilc/common/utils.ts b/ilc/common/utils.ts index cd013591..68f871e5 100644 --- a/ilc/common/utils.ts +++ b/ilc/common/utils.ts @@ -32,6 +32,8 @@ export function cloneDeep(source: T): T { export const uniqueArray = (array: T[]): T[] => [...new Set(array)]; +export const nowInSec = (): number => Math.floor(Date.now() / 1000); + export const encodeHtmlEntities = (value: string): string => value.replace(//g, '>').replace(/"/g, '"'); export const decodeHtmlEntities = (value: string): string => diff --git a/ilc/package-lock.json b/ilc/package-lock.json index fd41d40b..a7475aa1 100644 --- a/ilc/package-lock.json +++ b/ilc/package-lock.json @@ -48,6 +48,7 @@ "@babel/preset-typescript": "^7.28.5", "@types/chai": "^5.2.3", "@types/config": "^3.3.5", + "@types/lodash": "^4.17.25", "@types/mocha": "^10.0.10", "@types/newrelic": "^9.14.8", "@types/node": "^22.19.2", @@ -3452,6 +3453,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", diff --git a/ilc/package.json b/ilc/package.json index 9e522da2..2fdaadd5 100644 --- a/ilc/package.json +++ b/ilc/package.json @@ -59,6 +59,7 @@ "@babel/preset-typescript": "^7.28.5", "@types/chai": "^5.2.3", "@types/config": "^3.3.5", + "@types/lodash": "^4.17.25", "@types/mocha": "^10.0.10", "@types/newrelic": "^9.14.8", "@types/node": "^22.19.2", diff --git a/ilc/server/TransitionHooksExecutor.ts b/ilc/server/TransitionHooksExecutor.ts index 4ec9243d..cbaaf64b 100644 --- a/ilc/server/TransitionHooksExecutor.ts +++ b/ilc/server/TransitionHooksExecutor.ts @@ -37,7 +37,7 @@ export class TransitionHooksExecutor { meta: route.meta, url: route.reqUrl, hostname: req.host, - route: route.route, + route: route.route as string, }, log: req.log, req: req.raw, diff --git a/ilc/server/tailor/factory.js b/ilc/server/tailor/factory.js deleted file mode 100644 index ba51c4a9..00000000 --- a/ilc/server/tailor/factory.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const newrelic = require('newrelic'); - -const Tailor = require('@namecheap/tailorx'); -const { fetchTemplate } = require('./fetch-template'); -const { filterHeaders } = require('./filter-headers'); -const errorHandlerSetup = require('./error-handler'); -const fragmentHooks = require('./fragment-hooks'); -const { ConfigsInjector } = require('./configs-injector'); -const processFragmentResponse = require('./process-fragment-response'); -const requestFragment = require('./request-fragment'); - -module.exports = function ( - registryService, - errorHandlingService, - cdnUrl, - nrCustomClientJsWrapper = null, - nrAutomaticallyInjectClientScript = true, - logger, -) { - const configsInjector = new ConfigsInjector( - newrelic, - cdnUrl, - nrCustomClientJsWrapper, - nrAutomaticallyInjectClientScript, - ); - - const tailor = new Tailor({ - fetchContext: async function (request) { - return request.router.getFragmentsContext(); - }, - fetchTemplate: fetchTemplate(configsInjector, newrelic, registryService), - requestFragment: requestFragment(filterHeaders, processFragmentResponse, logger), - processFragmentResponse, - systemScripts: '', - filterHeaders, - fragmentHooks: { - insertStart: fragmentHooks.insertStart.bind(null, logger), - insertEnd: fragmentHooks.insertEnd, - }, - botsGuardEnabled: true, - getAssetsToPreload: configsInjector.getAssetsToPreload, - filterResponseHeaders: (attributes, headers) => _.pick(headers, ['set-cookie']), - baseTemplatesCacheSize: 1, - shouldSetPrimaryFragmentAssetsToPreload: false, - }); - - errorHandlerSetup(tailor, errorHandlingService); - - return tailor; -}; diff --git a/ilc/server/tailor/factory.ts b/ilc/server/tailor/factory.ts new file mode 100644 index 00000000..83ec72df --- /dev/null +++ b/ilc/server/tailor/factory.ts @@ -0,0 +1,74 @@ +import _ from 'lodash'; +import newrelic from 'newrelic'; +import type { Logger } from 'ilc-plugins-sdk'; +import Tailor from '@namecheap/tailorx'; + +import { fetchTemplate } from './fetch-template'; +import { filterHeaders } from './filter-headers'; +import errorHandlerSetup from './error-handler'; +import * as fragmentHooks from './fragment-hooks'; +import { ConfigsInjector } from './configs-injector'; +import processFragmentResponse from './process-fragment-response'; +import requestFragmentFactory from './request-fragment'; +import { wrapRequestFragmentWithCache } from './request-fragment-cache'; +import type { PatchedHttpRequest } from '../types/PatchedHttpRequest'; +import type { Registry } from '../types/Registry'; +import type { ErrorHandler } from '../types/ErrorHandler'; + +export default function tailorFactory( + registryService: Registry, + errorHandlingService: ErrorHandler, + cdnUrl: string, + nrCustomClientJsWrapper: string | null = null, + nrAutomaticallyInjectClientScript = true, + logger: Logger, +) { + const configsInjector = new ConfigsInjector( + newrelic, + cdnUrl, + nrCustomClientJsWrapper, + nrAutomaticallyInjectClientScript, + ); + + const tailorOptions = { + fetchContext: async function (request: PatchedHttpRequest) { + return request.router!.getFragmentsContext(); + }, + fetchTemplate: fetchTemplate(configsInjector, newrelic, registryService), + requestFragment: wrapRequestFragmentWithCache( + requestFragmentFactory(filterHeaders, processFragmentResponse, logger), + { + logger, + onCacheEvent: (event, { appId, source, reason }) => { + // at most one qualifier is ever set: `source` on 'error', `reason` on 'refuse' + const qualifier = source ?? reason; + const metricName = qualifier + ? `FragmentCache/${appId}/${event}/${qualifier}` + : `FragmentCache/${appId}/${event}`; + newrelic.incrementMetric(metricName); + }, + }, + ), + processFragmentResponse, + systemScripts: '', + filterHeaders, + fragmentHooks: { + insertStart: fragmentHooks.insertStart.bind(null, logger), + insertEnd: fragmentHooks.insertEnd, + }, + botsGuardEnabled: true, + getAssetsToPreload: configsInjector.getAssetsToPreload, + filterResponseHeaders: (_attributes: unknown, headers: Record) => + _.pick(headers, ['set-cookie']), + baseTemplatesCacheSize: 1, + shouldSetPrimaryFragmentAssetsToPreload: false, + }; + + // @namecheap/tailorx's bundled .d.ts is stale (see index.js) and covers fewer options than + // the runtime reads; this assertion bridges to that outdated third-party declaration. + const tailor = new Tailor(tailorOptions as unknown as ConstructorParameters[0]); + + errorHandlerSetup(tailor, errorHandlingService); + + return tailor; +} diff --git a/ilc/server/tailor/filter-headers.spec.js b/ilc/server/tailor/filter-headers.spec.js deleted file mode 100644 index f65c6dc6..00000000 --- a/ilc/server/tailor/filter-headers.spec.js +++ /dev/null @@ -1,97 +0,0 @@ -const chai = require('chai'); - -const { filterHeaders } = require('./filter-headers'); - -describe('filter headers', () => { - it('should not return any headers due to security reasons when a fragment is public', () => { - const attributes = { - public: true, - }; - - const request = { - headers: { - 'content-type': 'text/html', - host: 'www.somewhere.com/host', - 'accept-language': 'en-US, en;q=0.5', - }, - }; - - chai.expect(filterHeaders(attributes, request)).to.be.eql({}); - }); - - it('should not return any headers when request does not have any one', () => { - const attributes = { - public: false, - }; - - const request = { - headers: {}, - }; - - chai.expect(filterHeaders(attributes, request)).to.be.eql({}); - }); - - it('should also forward headers listed in extraHeaders', () => { - const attributes = { public: false }; - const request = { - headers: { - authorization: 'Bearer 12345', - 'x-custom-header': 'custom-value', - 'x-real-ip': '1.2.3.4', - 'content-type': 'text/html', - }, - }; - - chai.expect(filterHeaders(attributes, request, ['x-custom-header', 'X-Real-IP'])).to.be.eql({ - authorization: 'Bearer 12345', - 'x-custom-header': 'custom-value', - 'x-real-ip': '1.2.3.4', - }); - }); - - it('should not forward extraHeaders to public fragments', () => { - const attributes = { public: true }; - const request = { - headers: { - 'x-custom-header': 'custom-value', - }, - }; - - chai.expect(filterHeaders(attributes, request, ['x-custom-header'])).to.be.eql({}); - }); - - it('should return only accepted and x-forwarded headers', () => { - const attributes = { - public: false, - }; - - const request = { - headers: { - authorization: 'Bearer 12345', - 'content-type': 'text/html', - host: 'www.somewhere.com/host', - 'accept-language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5', - referer: 'www.somewhere.com/referer', - 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)', - 'x-request-uri': 'www.somewhere.com/x-request-uri', - 'x-request-host': 'www.somewhere.com/x-request-host', - cookie: 'yummy_cookie=choco; tasty_cookie=strawberry', - 'x-forwarded-cookie': 'yummy_cookie=choco; tasty_cookie=apple', - 'x-cookie': 'yummy_cookie=choco; tasty_cookie=orange', - 'cookie-x-forwarded': 'yummy_cookie=choco; tasty_cookie=banana', - 'x-cookie-forwarded': 'yummy_cookie=choco; tasty_cookie=lemon', - }, - }; - - chai.expect(filterHeaders(attributes, request)).to.be.eql({ - authorization: 'Bearer 12345', - 'accept-language': request.headers['accept-language'], - referer: request.headers['referer'], - 'user-agent': request.headers['user-agent'], - 'x-request-uri': request.headers['x-request-uri'], - 'x-request-host': request.headers['x-request-host'], - cookie: request.headers['cookie'], - 'x-forwarded-cookie': request.headers['x-forwarded-cookie'], - }); - }); -}); diff --git a/ilc/server/tailor/filter-headers.spec.ts b/ilc/server/tailor/filter-headers.spec.ts new file mode 100644 index 00000000..625ed107 --- /dev/null +++ b/ilc/server/tailor/filter-headers.spec.ts @@ -0,0 +1,186 @@ +import chai from 'chai'; + +import { filterHeaders } from './filter-headers'; +import { pickSharedRenderHeaders, type FragmentRenderOptions } from './fragment-render'; + +describe('filter headers', () => { + it('should not return any headers due to security reasons when a fragment is public', () => { + const attributes = { + public: true, + }; + + const request = { + headers: { + 'content-type': 'text/html', + host: 'www.somewhere.com/host', + 'accept-language': 'en-US, en;q=0.5', + }, + }; + + chai.expect(filterHeaders(attributes, request)).to.be.eql({}); + }); + + it('should not return any headers when request does not have any one', () => { + const attributes = { + public: false, + }; + + const request = { + headers: {}, + }; + + chai.expect(filterHeaders(attributes, request)).to.be.eql({}); + }); + + it('should also forward headers listed in extraHeaders', () => { + const attributes = { public: false }; + const request = { + headers: { + authorization: 'Bearer 12345', + 'x-custom-header': 'custom-value', + 'x-real-ip': '1.2.3.4', + 'content-type': 'text/html', + }, + }; + + chai.expect(filterHeaders(attributes, request, ['x-custom-header', 'X-Real-IP'])).to.be.eql({ + authorization: 'Bearer 12345', + 'x-custom-header': 'custom-value', + 'x-real-ip': '1.2.3.4', + }); + }); + + it('should not forward extraHeaders to public fragments', () => { + const attributes = { public: true }; + const request = { + headers: { + 'x-custom-header': 'custom-value', + }, + }; + + chai.expect(filterHeaders(attributes, request, ['x-custom-header'])).to.be.eql({}); + }); + + it('should return only accepted and x-forwarded headers', () => { + const attributes = { + public: false, + }; + + const request = { + headers: { + authorization: 'Bearer 12345', + 'content-type': 'text/html', + host: 'www.somewhere.com/host', + 'accept-language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5', + referer: 'www.somewhere.com/referer', + 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)', + 'x-request-uri': 'www.somewhere.com/x-request-uri', + 'x-request-host': 'www.somewhere.com/x-request-host', + cookie: 'yummy_cookie=choco; tasty_cookie=strawberry', + 'x-forwarded-cookie': 'yummy_cookie=choco; tasty_cookie=apple', + 'x-cookie': 'yummy_cookie=choco; tasty_cookie=orange', + 'cookie-x-forwarded': 'yummy_cookie=choco; tasty_cookie=banana', + 'x-cookie-forwarded': 'yummy_cookie=choco; tasty_cookie=lemon', + }, + }; + + chai.expect(filterHeaders(attributes, request)).to.be.eql({ + authorization: 'Bearer 12345', + 'accept-language': request.headers['accept-language'], + referer: request.headers['referer'], + 'user-agent': request.headers['user-agent'], + 'x-request-uri': request.headers['x-request-uri'], + 'x-request-host': request.headers['x-request-host'], + cookie: request.headers['cookie'], + 'x-forwarded-cookie': request.headers['x-forwarded-cookie'], + }); + }); + + describe('cacheable fragments (a header is forwarded only if its value is part of the cache key)', () => { + const cacheableAttributes = { + public: false, + }; + const varyHeaders = pickSharedRenderHeaders({ + 'x-request-host': 'www.somewhere.com', + 'x-request-intl': 'en-US:en-US:USD:USD', + }); + const sharedRender: FragmentRenderOptions = { mode: 'shared', varyHeaders }; + + const userIdentifyingHeaders = { + authorization: 'Bearer 12345', + cookie: 'yummy_cookie=choco; session=abc', + 'accept-language': 'fr-CH, fr;q=0.9', + referer: 'www.somewhere.com/referer', + 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)', + 'x-request-uri': 'www.somewhere.com/x-request-uri', + }; + + it('should forward exactly the given varyHeaders on a shared render, regardless of request.headers or extraHeaders', () => { + // A shared render's headers come from the caller's already-computed varyHeaders (the + // same value used to build the cache key), not from re-deriving them here — that's + // what makes the cache key and the forwarded headers structurally unable to diverge. + // pickSharedRenderHeaders' own filtering behavior (strips x-forwarded-*, keeps only + // x-request-host/x-request-intl) is covered directly in request-fragment-cache.spec.ts. + const request = { + headers: { + ...userIdentifyingHeaders, + 'x-forwarded-for': '203.0.113.7', + }, + }; + + chai.expect(filterHeaders(cacheableAttributes, request, ['x-custom-header'], sharedRender)).to.equal( + varyHeaders, + ); + }); + + it('should keep full headers when cache is enabled but the render is not marked (e.g. wrapped-app recursion)', () => { + // wrapperConf is nulled before the 210 re-request, so deriving cacheability from + // attributes would strip user headers from a render that is never cached + const attributes = { + public: false, + cache: { enabled: true, ttlSeconds: 300 }, + wrapperConf: null, + forwardQuerystring: false, + }; + + const request = { + headers: { + ...userIdentifyingHeaders, + }, + }; + + chai.expect(filterHeaders(attributes, request)).to.be.eql(userIdentifyingHeaders); + }); + + it('should keep current behavior when the render is not cacheable', () => { + const attributes = { + public: false, + }; + + const request = { + headers: { + ...userIdentifyingHeaders, + }, + }; + + chai.expect(filterHeaders(attributes, request, undefined, { mode: 'private' })).to.be.eql( + userIdentifyingHeaders, + ); + }); + + it('should still return no headers for public fragments even with cache enabled', () => { + const attributes = { + ...cacheableAttributes, + public: true, + }; + + const request = { + headers: { + 'x-request-intl': 'en-US:en-US:USD:USD', + }, + }; + + chai.expect(filterHeaders(attributes, request, undefined, sharedRender)).to.be.eql({}); + }); + }); +}); diff --git a/ilc/server/tailor/filter-headers.ts b/ilc/server/tailor/filter-headers.ts index bc5e3f13..baaf6d7c 100644 --- a/ilc/server/tailor/filter-headers.ts +++ b/ilc/server/tailor/filter-headers.ts @@ -1,18 +1,14 @@ import type { IncomingHttpHeaders } from 'http'; - -interface FragmentAttributes { - public?: boolean | string; - [key: string]: unknown; -} +import { pickHeaders, SHARED_RENDER_HEADERS, type FragmentRenderOptions } from './fragment-render'; +import type { FragmentAttributes } from './fragment-attributes'; const ACCEPT_HEADERS: readonly string[] = [ + ...SHARED_RENDER_HEADERS, 'authorization', 'accept-language', 'referer', 'user-agent', 'x-request-uri', - 'x-request-host', - 'x-request-intl', 'cookie', ]; @@ -20,6 +16,7 @@ export function filterHeaders( attributes: FragmentAttributes, request: { headers?: IncomingHttpHeaders }, extraHeaders?: string[], + renderOptions: FragmentRenderOptions = { mode: 'private' }, ): Record { const { public: isPublic } = attributes; const { headers = {} } = request; @@ -29,17 +26,14 @@ export function filterHeaders( return {}; } + if (renderOptions.mode === 'shared') { + return renderOptions.varyHeaders; + } + const allowedHeaders = extraHeaders && extraHeaders.length > 0 ? [...ACCEPT_HEADERS, ...extraHeaders.map((h) => h.toLowerCase())] : ACCEPT_HEADERS; - return Object.keys(headers).reduce>((newHeaders, key) => { - const value = headers[key]; - if ((allowedHeaders.includes(key) || key.startsWith('x-forwarded')) && value) { - newHeaders[key] = value as string; - } - - return newHeaders; - }, {}); + return pickHeaders(headers, (key) => allowedHeaders.includes(key) || key.startsWith('x-forwarded')); } diff --git a/ilc/server/tailor/fragment-attributes.ts b/ilc/server/tailor/fragment-attributes.ts new file mode 100644 index 00000000..7a5e1501 --- /dev/null +++ b/ilc/server/tailor/fragment-attributes.ts @@ -0,0 +1,27 @@ +import type { CacheableFragmentAttributes } from './fragment-render'; + +export interface FragmentWrapperConf { + appId: string; + name?: string; + src: string; + props?: Record; + timeout?: number; + ignoreInvalidSsl?: boolean; + cache?: { + enabled?: boolean; + ttlSeconds?: number; + }; +} + +/** + * Tailor's parsed `` attributes merged with ServerRouter#getFragmentsContext; carries + * more fields than listed. No index signature: one would break assignability to RequestFragment's + * attributes param, which requires this stay a supertype of the index-signature-less CacheableFragmentAttributes. + */ +export interface FragmentAttributes extends CacheableFragmentAttributes { + public?: boolean | string; + async?: boolean; + ignoreInvalidSsl?: boolean; + spaBundleUrl?: string; + wrapperPropsOverride?: Record; +} diff --git a/ilc/server/tailor/fragment-hooks.spec.js b/ilc/server/tailor/fragment-hooks.spec.ts similarity index 79% rename from ilc/server/tailor/fragment-hooks.spec.js rename to ilc/server/tailor/fragment-hooks.spec.ts index bd47fa9f..4dd5ac53 100644 --- a/ilc/server/tailor/fragment-hooks.spec.js +++ b/ilc/server/tailor/fragment-hooks.spec.ts @@ -1,8 +1,10 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const { getFragmentAttributes } = require('../../tests/helpers'); -const { insertStart, insertEnd } = require('./fragment-hooks'); -const { PassThrough } = require('stream'); +import chai from 'chai'; +import sinon from 'sinon'; +import { PassThrough } from 'stream'; +import type { Logger } from 'ilc-plugins-sdk'; +import { getFragmentAttributes } from '../../tests/helpers'; +import { insertStart, insertEnd } from './fragment-hooks'; +import { setCacheMarker } from './request-fragment-cache'; describe('fragment-hooks', () => { describe('insertEnd', () => { @@ -18,11 +20,60 @@ describe('fragment-hooks', () => { }); describe('insertStart', () => { - const logger = { + const logger: Logger = { + fatal: () => {}, + error: () => {}, warn: () => {}, + info: () => {}, debug: () => {}, + trace: () => {}, }; + describe('fragment cache marker (AC#5: hit/miss visibility per fragment)', () => { + for (const marker of ['hit', 'stale', 'miss'] as const) { + it(`should write an html comment when the cache decorator set marker ${marker.toUpperCase()}`, () => { + const mockStream = new PassThrough(); + const fragmentAttrs = getFragmentAttributes(); + setCacheMarker(fragmentAttrs, marker); + + insertStart(logger, mockStream, fragmentAttrs, {}); + + const streamData = mockStream.read(); + chai.expect(streamData.toString()).to.be.equal( + ``, + ); + }); + } + + it('should carry the refusal reason into the markup, so a page can be diagnosed from view-source', () => { + const mockStream = new PassThrough(); + const fragmentAttrs = getFragmentAttributes(); + setCacheMarker(fragmentAttrs, 'refuse:set-cookie'); + + insertStart(logger, mockStream, fragmentAttrs, {}); + + chai.expect(mockStream.read().toString()).to.be.equal(''); + }); + + it('should write nothing when the decorator set no marker', () => { + const mockStream = new PassThrough(); + + insertStart(logger, mockStream, getFragmentAttributes(), {}); + + chai.expect(mockStream.readableLength).to.be.equal(0); + }); + + it('should ignore marker-like response headers from real fragments (status spoofing)', () => { + const mockStream = new PassThrough(); + const fragmentAttrs = getFragmentAttributes({ cache: { enabled: true, ttlSeconds: 300 } }); + const headers = { 'x-ilc-fragment-cache': 'HIT' }; + + insertStart(logger, mockStream, fragmentAttrs, headers); + + chai.expect(mockStream.readableLength).to.be.equal(0); + }); + }); + it('should write a script tag with wrapper overrides', () => { const mockStream = new PassThrough(); const fragmentAttrs = getFragmentAttributes({ @@ -101,7 +152,7 @@ describe('fragment-hooks', () => { link: '<../app.test.css>; rel="stylesheet"', }; - const resultingUrl = `${fragmentAttrs.spaBundleUrl.replace(/[^\\/]+\/[^\\/]+$/, '')}app.test.css`; + const resultingUrl = `${fragmentAttrs.spaBundleUrl!.replace(/[^\\/]+\/[^\\/]+$/, '')}app.test.css`; insertStart(logger, mockStream, fragmentAttrs, headers); @@ -153,7 +204,7 @@ describe('fragment-hooks', () => { const headers = { link: '; rel="fragment-script"; as="script"; crossorigin="anonymous"', }; - const resultingUrl = `${fragmentAttrs.spaBundleUrl.replace(/[^\\/]+$/, '')}single_spa.tst.js`; + const resultingUrl = `${fragmentAttrs.spaBundleUrl!.replace(/[^\\/]+$/, '')}single_spa.tst.js`; insertStart(logger, mockStream, fragmentAttrs, headers); @@ -244,10 +295,13 @@ describe('fragment-hooks', () => { const mockStream = new PassThrough(); const fragmentAttrs = getFragmentAttributes({ id: 'test-fragment' }); const errorSpy = sinon.spy(); - const loggerSpy = { + const loggerSpy: Logger = { + fatal: () => {}, + error: errorSpy, warn: () => {}, + info: () => {}, debug: () => {}, - error: errorSpy, + trace: () => {}, }; const headers = { link: '<>; rel="stylesheet"', diff --git a/ilc/server/tailor/fragment-hooks.js b/ilc/server/tailor/fragment-hooks.ts similarity index 61% rename from ilc/server/tailor/fragment-hooks.js rename to ilc/server/tailor/fragment-hooks.ts index 0440428e..3026890b 100644 --- a/ilc/server/tailor/fragment-hooks.js +++ b/ilc/server/tailor/fragment-hooks.ts @@ -1,11 +1,23 @@ -'use strict'; +import type { IncomingHttpHeaders } from 'http'; +import _ from 'lodash'; +import type { Logger } from 'ilc-plugins-sdk'; +import { appIdToNameAndSlot } from '../../common/utils'; +import { getCacheMarker } from './request-fragment-cache'; +import type { FragmentAttributes, FragmentWrapperConf } from './fragment-attributes'; -const _ = require('lodash'); -const parseLinkHeader = require('@namecheap/tailorx/lib/parse-link-header'); +const parseLinkHeader = require('@namecheap/tailorx/lib/parse-link-header') as ( + linkHeader: string, +) => Array<{ uri?: string; rel?: string; params: Record }>; -const { appIdToNameAndSlot } = require('../../common/utils'); +interface BundleVersionOverrides { + wrapperPropsOverride?: Record; + cssBundle?: string; + spaBundle?: string; + dependencies?: Record; + appName?: string; +} -function asyncStylesLoadTemplate(uri, id) { +function asyncStylesLoadTemplate(uri: string, id: string): string { return ( '`); } -function insertEnd(stream, attributes, headers, index) { +export function insertEnd( + _stream: NodeJS.WritableStream, + _attributes: FragmentAttributes, + _headers: IncomingHttpHeaders, + _index?: number, +): void { // disabling default TailorX behaviour } -function fixUri(fragmentAttrs, uri) { +function fixUri(fragmentAttrs: FragmentAttributes, uri: string): string { const { spaBundleUrl } = fragmentAttrs; return new URL(uri, spaBundleUrl).href; } - -module.exports = { - insertStart, - insertEnd, -}; diff --git a/ilc/server/tailor/fragment-render.ts b/ilc/server/tailor/fragment-render.ts new file mode 100644 index 00000000..e42fee00 --- /dev/null +++ b/ilc/server/tailor/fragment-render.ts @@ -0,0 +1,82 @@ +import type { IncomingHttpHeaders } from 'http'; +import type { Readable } from 'stream'; + +/** + * Vocabulary of a fragment render, shared by the transport and the cache wrapping it. Lives here, + * not in request-fragment-cache/, so deleting the cache can't break the transport's build. + */ + +declare const sharedRenderHeadersBrand: unique symbol; + +export interface FragmentCacheConfig { + enabled: boolean; + ttlSeconds?: number; +} + +export interface CacheableFragmentAttributes { + id?: string; + cache?: FragmentCacheConfig | null; + wrapperConf?: object | null; + forwardQuerystring?: boolean; + appProps?: object | null; + timeout?: number; +} + +export type FragmentResponse = Readable & { + statusCode: number; + headers: IncomingHttpHeaders; +}; + +export interface FragmentRequest { + id?: string; + headers: IncomingHttpHeaders; + host?: string; + ldeRelated?: boolean; + registryConfig: { + apps: Record; + settings?: { fragmentProxyHeaders?: string[] }; + }; + router: { + getRoute(): { + basePath?: string; + reqUrl?: string; + route?: string; + specialRole?: unknown; + }; + }; +} + +export type SharedRenderHeaders = Readonly> & { + readonly [sharedRenderHeadersBrand]: true; +}; + +// varyHeaders is required on a shared render: it's the same value used for the cache key, so the +// key and the forwarded headers can't diverge (see cached-fragment-requester.ts's requestCached()). +export type FragmentRenderOptions = { mode: 'private' } | { mode: 'shared'; varyHeaders: SharedRenderHeaders }; + +export type RequestFragment = ( + fragmentUrl: string, + attributes: CacheableFragmentAttributes, + request: FragmentRequest, + options?: FragmentRenderOptions, +) => Promise; + +/** + * The only request headers a shared render may see. The same set is what the cache key varies on, + * so a header can never reach a shared render without also being part of its identity. + */ +export const SHARED_RENDER_HEADERS: readonly string[] = ['x-request-host', 'x-request-intl']; + +export function pickHeaders(headers: IncomingHttpHeaders, isAllowed: (key: string) => boolean): Record { + return Object.keys(headers).reduce>((selected, key) => { + const value = headers[key]; + if (isAllowed(key) && value) { + selected[key] = value as string; + } + return selected; + }, {}); +} + +export function pickSharedRenderHeaders(headers: IncomingHttpHeaders = {}): SharedRenderHeaders { + return pickHeaders(headers, (key) => SHARED_RENDER_HEADERS.includes(key)) as SharedRenderHeaders; +} diff --git a/ilc/server/tailor/process-fragment-response.js b/ilc/server/tailor/process-fragment-response.js index c870ac69..3dcfbae0 100644 --- a/ilc/server/tailor/process-fragment-response.js +++ b/ilc/server/tailor/process-fragment-response.js @@ -8,7 +8,7 @@ const errors = require('./errors'); * @param {http.IncomingMessage} context.request - incoming request from browser * @param {Object} context.fragmentAttributes - fragment attributes map * @param {String} context.fragmentUrl - URL that was requested on fragment - * @param {String} context.isWrapper - Indicates if App Wrapper is requested + * @param {Boolean} [context.isWrapper] - Indicates if App Wrapper is requested */ module.exports = (response, context) => { const currRoute = context.request.router.getRoute(); diff --git a/ilc/server/tailor/request-fragment-cache.consumer.spec.ts b/ilc/server/tailor/request-fragment-cache.consumer.spec.ts new file mode 100644 index 00000000..58367e70 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache.consumer.spec.ts @@ -0,0 +1,619 @@ +import http from 'node:http'; +import zlib from 'node:zlib'; +import chai from 'chai'; +import supertest from 'supertest'; +import * as helpers from '../../tests/helpers'; +import { isCacheableRequest } from './request-fragment-cache'; +import { pickSharedRenderHeaders } from './fragment-render'; + +// server/app.js is a large untyped legacy composition root (out of this PR's conversion scope); +// its factory shape is asserted here rather than fought field-by-field. +const createApp = require('../app') as (...args: any[]) => Promise; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type FragmentHandler = (req: http.IncomingMessage, res: http.ServerResponse) => unknown; +type ReceivedRequest = { url: string; headers: http.IncomingHttpHeaders }; + +/** + * End-to-end coverage of SSR fragment caching (NPF-5188) against a REAL fragment consumer: + * a genuine HTTP server on a real socket, real gzip, real streaming and real keep-alive — + * no nock interception. This exercises paths the interception-based suite cannot reach: + * transport-level encoding negotiation, chunked bodies, slow bodies, socket reuse. + */ +describe('request-fragment-cache — real consumer', () => { + let fragmentServer: http.Server; + let fragmentOrigin: string; + let app: any; + let server: ReturnType; + /** Per-scenario handler; replaced by each test. */ + let handle: FragmentHandler; + /** Records every request the fragment actually received. */ + let received: ReceivedRequest[]; + + before(async () => { + fragmentServer = http.createServer((req, res) => { + // several scenarios make ILC abort mid-response on purpose (size cap, deadline, + // transport timeout) — the resulting socket errors are expected, not failures + res.on('error', () => {}); + req.on('error', () => {}); + received.push({ url: req.url!, headers: req.headers }); + // the non-cached primary fragment answers statically, so per-scenario + // handlers (and their render counters) observe the cached fragment alone + if (req.url!.startsWith('/primary')) { + res.end('
primary-static
'); + return; + } + handle(req, res); + }); + fragmentServer.on('error', () => {}); + await new Promise((resolve) => fragmentServer.listen(0, '127.0.0.1', () => resolve())); + const address = fragmentServer.address(); + fragmentOrigin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : ''}`; + }); + + after(async () => { + await new Promise((resolve) => fragmentServer.close(resolve)); + }); + + beforeEach(() => { + received = []; + handle = (req, res) => res.end('
default
'); + }); + + afterEach(async () => { + if (app) { + await app.close(); + app = null; + } + }); + + async function bootIlc( + { cache }: { cache?: { enabled: boolean; ttlSeconds: number } | null } = { + cache: { enabled: true, ttlSeconds: 300 }, + }, + ) { + const registryOverrides = { + getTemplate: () => ({ + data: { + content: + '\n' + + '
\n' + + '
\n' + + '', + }, + }), + apps: { + '@portal/primary': { ssr: { src: `${fragmentOrigin}/primary`, timeout: 2000 } }, + '@portal/regular': { + ssr: { src: `${fragmentOrigin}/regular`, timeout: 2000, ...(cache ? { cache } : {}) }, + }, + }, + }; + + // parse the locale prefix for real so localized routes are not redirected away + const pluginManager = { + ...helpers.getPluginManagerMock(), + getI18nParamsDetectionPlugin: () => ({ + type: 'i18nParamsDetection', + detectI18nConfig: (req: { url: string }, intl: any, i18nConfig: Record) => ({ + ...i18nConfig, + locale: intl.parseUrl(req.url).locale, + }), + }), + }; + + app = await createApp(helpers.getRegistryMock(registryOverrides), pluginManager); + await app.ready(); + app.server.listen(0); + server = supertest(app.server); + } + + const regularRequests = () => received.filter((r) => r.url.startsWith('/regular')); + const regularHits = () => regularRequests().length; + + describe('caching over a real socket', () => { + it('renders once for N page requests and replays the stored body', async () => { + await bootIlc(); + let renders = 0; + handle = (req, res) => { + renders += 1; + res.end(`
rendered-${renders}
`); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + const third = await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(1); + chai.expect(first.text).to.include('rendered-1'); + chai.expect(second.text).to.include('rendered-1'); + chai.expect(third.text).to.include('rendered-1'); + chai.expect(second.text).to.include(''); + }); + + it('replays a real gzip-compressed fragment identically', async () => { + await bootIlc(); + handle = (req, res) => { + const body = zlib.gzipSync(Buffer.from('
gzipped-from-the-wire
')); + res.writeHead(200, { 'content-encoding': 'gzip', 'content-type': 'text/html' }); + res.end(body); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(1); + chai.expect(first.text).to.include('gzipped-from-the-wire'); + chai.expect(second.text).to.include('gzipped-from-the-wire'); + }); + + it('replays a chunked (streamed) fragment body', async () => { + await bootIlc(); + handle = async (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.write('
chunk-a'); + await sleep(15); + res.write('|chunk-b'); + await sleep(15); + res.end('|chunk-c
'); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(1); + chai.expect(first.text).to.include('chunk-a|chunk-b|chunk-c'); + chai.expect(second.text).to.include('chunk-a|chunk-b|chunk-c'); + }); + }); + + describe('user isolation (AC#3)', () => { + it('never forwards identifying headers on a cacheable render and shares one body', async () => { + await bootIlc(); + handle = (req, res) => res.end('
shared-body
'); + + const first = await server + .get('/all') + .set('Cookie', 'session=user-a') + .set('Authorization', 'Bearer token-a') + .set('X-Forwarded-For', '203.0.113.7') + .set('Accept-Language', 'fr-CH') + .expect(200); + const second = await server + .get('/all') + .set('Cookie', 'session=user-b') + .set('Authorization', 'Bearer token-b') + .expect(200); + + chai.expect(regularHits()).to.equal(1); + chai.expect(first.text).to.include('shared-body'); + chai.expect(second.text).to.include('shared-body'); + + const sent = regularRequests()[0].headers; + chai.expect(sent).to.not.have.property('cookie'); + chai.expect(sent).to.not.have.property('authorization'); + chai.expect(sent).to.not.have.property('x-forwarded-for'); + chai.expect(sent).to.not.have.property('accept-language'); + chai.expect(sent).to.have.property('x-request-host'); + }); + + it('keeps locales apart', async () => { + await bootIlc(); + let renders = 0; + handle = (req, res) => { + renders += 1; + res.end(`
locale-render-${renders}
`); + }; + + const en = await server.get('/all').expect(200); + const ua = await server.get('/ua/all').expect(200); + const uaAgain = await server.get('/ua/all').expect(200); + + chai.expect(regularHits()).to.equal(2); + chai.expect(en.text).to.include('locale-render-1'); + chai.expect(ua.text).to.include('locale-render-2'); + chai.expect(uaAgain.text).to.include('locale-render-2'); + }); + + it('keeps domains apart', async () => { + await bootIlc(); + handle = (req, res) => res.end(`
host-${req.headers['x-request-host']}
`); + + await server.get('/all').set('Host', 'foo.example.org').expect(200); + await server.get('/all').set('Host', 'bar.example.org').expect(200); + await server.get('/all').set('Host', 'foo.example.org').expect(200); + + chai.expect(regularHits()).to.equal(2); + }); + + it('is blind to the query string and never leaks it to the fragment', async () => { + await bootIlc(); + handle = (req, res) => res.end('
query-blind
'); + + await server.get('/all?utm_source=facebook&gclid=abc').expect(200); + const second = await server.get('/all?utm_source=google&nonce=xyz').expect(200); + + chai.expect(regularHits()).to.equal(1); + chai.expect(second.text).to.include(''); + + const routerProps = JSON.parse( + Buffer.from( + new URL(`http://x${regularRequests()[0].url}`).searchParams.get('routerProps')!, + 'base64', + ).toString('utf8'), + ); + chai.expect(routerProps.reqUrl).to.not.include('utm_source'); + }); + }); + + describe('refusals (AC#2 / AC#4)', () => { + it('never caches nor shares a set-cookie response', async () => { + await bootIlc(); + let renders = 0; + handle = (req, res) => { + renders += 1; + res.writeHead(200, { 'set-cookie': `session=user-${renders}` }); + res.end(`
personalized-${renders}
`); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(first.text).to.not.equal(second.text); + chai.expect(second.text).to.not.include('ilc:fragment-cache HIT'); + }); + + it('honours Cache-Control: no-store from the fragment', async () => { + await bootIlc(); + let renders = 0; + handle = (req, res) => { + renders += 1; + res.writeHead(200, { 'cache-control': 'no-store' }); + res.end(`
priced-${renders}
`); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(first.text).to.not.equal(second.text); + chai.expect(second.text).to.not.include('ilc:fragment-cache HIT'); + }); + + it('does not cache a 500 response and keeps rendering live', async () => { + await bootIlc(); + handle = (req, res) => { + res.writeHead(500); + res.end('boom'); + }; + + await server.get('/all'); + await server.get('/all'); + + chai.expect(regularHits()).to.equal(2); + }); + + it('refuses to cache bodies over the 1 MiB budget and still delivers them intact', async () => { + await bootIlc(); + const payload = 'x'.repeat(2 * 1024 * 1024); // 2 MiB, twice the cap + const marker = 'END-OF-OVERSIZED-BODY'; + handle = (req, res) => res.end(`
${payload}${marker}
`); + + const first = await server.get('/all').expect(200); + const before = regularHits(); + const second = await server.get('/all').expect(200); + + // not cached: every request renders live + chai.expect(regularHits()).to.be.greaterThan(before); + // and critically: the oversized body is delivered whole, not truncated at the cap + for (const response of [first, second]) { + chai.expect(response.text).to.include(marker); + chai.expect(response.text.length).to.be.greaterThan(2 * 1024 * 1024); + } + }); + + it('caches a body just under the cap and keeps it byte-exact', async () => { + await bootIlc(); + const marker = 'UNDER-CAP-TAIL'; + const payload = 'y'.repeat(1000 * 1024 - marker.length); // ~0.98 MiB, under 1 MiB + handle = (req, res) => res.end(`${payload}${marker}`); + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(1); // cached + chai.expect(second.text).to.include(''); + // replayed body is identical to the freshly rendered one + const fragmentOf = (html: string) => html.slice(html.indexOf('yyy'), html.indexOf(marker) + marker.length); + chai.expect(fragmentOf(second.text)).to.equal(fragmentOf(first.text)); + chai.expect(fragmentOf(second.text).length).to.equal(1000 * 1024); + }); + + it('delivers an oversized gzip body intact while refusing to cache it', async () => { + await bootIlc(); + const marker = 'END-OF-GZIP-BOMB'; + const raw = Buffer.from('z'.repeat(4 * 1024 * 1024) + marker); + handle = (req, res) => { + res.writeHead(200, { 'content-encoding': 'gzip' }); + res.end(zlib.gzipSync(raw)); + }; + + const first = await server.get('/all').expect(200); + const before = regularHits(); + await server.get('/all').expect(200); + + chai.expect(regularHits()).to.be.greaterThan(before); + // TailorX unzips the live response for the browser — the payload must survive whole + chai.expect(first.text).to.include(marker); + chai.expect(first.text.length).to.be.greaterThan(4 * 1024 * 1024); + }); + + it('keeps refusing oversized renders without poisoning the cache for other keys', async function () { + this.timeout(30000); + await bootIlc(); + handle = (req, res) => res.end(`
${'q'.repeat(1536 * 1024)}
`); // 1.5 MiB, over cap + + for (let i = 0; i < 10; i++) { + await server.get('/all').expect(200); + } + const oversizedRenders = regularHits(); + // every single request rendered live — nothing was stored, nothing was replayed + chai.expect(oversizedRenders).to.be.at.least(10); + + // a different key on the same fragment still caches normally afterwards + handle = (req, res) => res.end('
small-and-cacheable
'); + const firstUa = await server.get('/ua/all').expect(200); + const secondUa = await server.get('/ua/all').expect(200); + + chai.expect(regularHits()).to.equal(oversizedRenders + 1); + chai.expect(firstUa.text).to.include('small-and-cacheable'); + chai.expect(secondUa.text).to.include(''); + }); + + it('refuses a decompression bomb without exhausting memory', async () => { + await bootIlc(); + handle = (req, res) => { + const bomb = zlib.gzipSync(Buffer.alloc(4 * 1024 * 1024, 'a')); + res.writeHead(200, { 'content-encoding': 'gzip' }); + res.end(bomb); + }; + + await server.get('/all').expect(200); + const before = regularHits(); + await server.get('/all').expect(200); + + chai.expect(regularHits()).to.be.greaterThan(before); + }); + + it('refuses an unsupported content-encoding', async () => { + await bootIlc(); + handle = (req, res) => { + res.writeHead(200, { 'content-encoding': 'br' }); + res.end('
brotli
'); + }; + + await server.get('/all'); + const before = regularHits(); + await server.get('/all'); + + chai.expect(regularHits()).to.be.greaterThan(before); + }); + }); + + describe('lifetime', () => { + it('serves stale immediately and refreshes once in the background (SWR)', async function () { + this.timeout(15000); + await bootIlc({ cache: { enabled: true, ttlSeconds: 1 } }); + let renders = 0; + handle = (req, res) => { + renders += 1; + res.end(`
swr-${renders}
`); + }; + + const first = await server.get('/all').expect(200); + chai.expect(first.text).to.include('swr-1'); + + await sleep(2200); + + const stale = await server.get('/all').expect(200); + chai.expect(stale.text).to.include('swr-1'); + chai.expect(stale.text).to.include(''); + + await sleep(200); + chai.expect(regularHits()).to.equal(2); + + const fresh = await server.get('/all').expect(200); + chai.expect(fresh.text).to.include('swr-2'); + chai.expect(fresh.text).to.include(''); + }); + + it('deduplicates concurrent misses into a single upstream render', async () => { + await bootIlc(); + handle = async (req, res) => { + await sleep(120); + res.end('
deduped
'); + }; + + const [a, b, c] = await Promise.all([server.get('/all'), server.get('/all'), server.get('/all')]); + + chai.expect(regularHits()).to.equal(1); + for (const response of [a, b, c]) { + chai.expect(response.text).to.include('deduped'); + } + }); + + it('bounds a dripping body by the render deadline and recovers afterwards', async function () { + this.timeout(20000); + await bootIlc(); + let attempt = 0; + handle = async (req, res) => { + attempt += 1; + if (attempt === 1) { + // headers immediately, body never completes — the idle timeout never fires. + // Stop as soon as ILC gives up, so the handler never outlives its test. + res.writeHead(200, { 'content-type': 'text/html' }); + let aborted = false; + res.on('close', () => { + aborted = true; + }); + while (!aborted) { + res.write('x'); + await sleep(50); + } + return; + } + res.end('
recovered
'); + }; + + await server.get('/all'); + const recovered = await server.get('/all').expect(200); + + chai.expect(recovered.text).to.include('recovered'); + }); + }); + + describe('protocol details preserved through the cache', () => { + it('replays Link headers so fragment assets keep loading on a hit', async () => { + await bootIlc(); + const link = `<${fragmentOrigin}/app.js>; rel="fragment-script"`; + handle = (req, res) => { + res.writeHead(200, { link, 'content-type': 'text/html' }); + res.end('
with-assets
'); + }; + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(1); + // the asset override script is emitted from the Link header by insertStart + chai.expect(first.text).to.include('text/spa-config-override'); + chai.expect(second.text).to.include('text/spa-config-override'); + }); + + it('does not replay hop-by-hop headers from the stored response', async () => { + await bootIlc(); + handle = (req, res) => { + res.writeHead(200, { 'content-type': 'text/html', 'x-custom-marker': 'from-fragment' }); + res.end('
headers-check
'); + }; + + await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(second.text).to.include('headers-check'); + chai.expect(second.headers).to.not.have.property('content-encoding'); + }); + }); + + describe('flows that must never be cached', () => { + it('refuses caching on a special route (404) where reqUrl is unbounded', async () => { + await bootIlc(); + handle = (req, res) => { + res.writeHead(404); + res.end('
not-found
'); + }; + + await server.get('/all'); + const before = regularHits(); + await server.get('/all'); + + chai.expect(regularHits()).to.be.greaterThan(before); + }); + + it('keeps rendering live when the fragment times out at the transport level', async function () { + this.timeout(20000); + await bootIlc(); + let attempt = 0; + handle = async (req, res) => { + attempt += 1; + if (attempt === 1) { + // exceeds ssr.timeout of 2000ms; stop early once ILC aborts so the + // handler never outlives its test + let aborted = false; + res.on('close', () => { + aborted = true; + }); + for (let i = 0; i < 60 && !aborted; i++) { + await sleep(50); + } + if (!aborted) { + res.end('
too-late
'); + } + return; + } + res.end('
after-timeout
'); + }; + + await server.get('/all'); + const recovered = await server.get('/all').expect(200); + + chai.expect(recovered.text).to.include('after-timeout'); + }); + + it('refuses caching when ttlSeconds exceeds the 30-day runtime cap', async () => { + await bootIlc({ cache: { enabled: true, ttlSeconds: 2592001 } }); + handle = (req, res) => res.end('
over-cap
'); + + await server.get('/all').expect(200); + await server.get('/all').expect(200); + await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(3); + }); + + it('refuses caching for fragments using forwardQuerystring', async () => { + await bootIlc(); + // forwardQuerystring is declared per route slot; assert through the registry-level flag + chai.expect( + isCacheableRequest( + { + id: 'app__at__slot', + cache: { enabled: true, ttlSeconds: 300 }, + forwardQuerystring: true, + }, + {}, + pickSharedRenderHeaders({ 'x-request-host': 'example.org' }), + ), + ).to.equal(false); + }); + }); + + describe('recovery', () => { + it('caches again after a refusal expires (fragment turned static)', async function () { + this.timeout(15000); + await bootIlc({ cache: { enabled: true, ttlSeconds: 1 } }); + let dynamic = true; + handle = (req, res) => { + if (dynamic) { + res.writeHead(200, { 'cache-control': 'no-store' }); + res.end('
dynamic
'); + return; + } + res.end('
static-again
'); + }; + + await server.get('/all').expect(200); + dynamic = false; + await sleep(2200); + + await server.get('/all').expect(200); + const hit = await server.get('/all').expect(200); + + chai.expect(hit.text).to.include('static-again'); + chai.expect(hit.text).to.include(''); + }); + }); + + describe('bypasses', () => { + it('leaves non-enabled fragments untouched (AC#1)', async () => { + await bootIlc({ cache: null }); + handle = (req, res) => res.end('
never-cached
'); + + await server.get('/all').expect(200); + await server.get('/all').expect(200); + await server.get('/all').expect(200); + + chai.expect(regularHits()).to.equal(3); + }); + }); +}); diff --git a/ilc/server/tailor/request-fragment-cache.integration.spec.ts b/ilc/server/tailor/request-fragment-cache.integration.spec.ts new file mode 100644 index 00000000..e85c7dc2 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache.integration.spec.ts @@ -0,0 +1,288 @@ +import chai from 'chai'; +import nock from 'nock'; +import zlib from 'zlib'; +import supertest from 'supertest'; +import * as helpers from '../../tests/helpers'; +// server/app.js is a large untyped legacy composition root (out of this PR's conversion scope); +// its factory shape is asserted here rather than fought field-by-field. +const createApp = require('../app') as (...args: any[]) => Promise; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Integration coverage for SSR fragment caching (NPF-5188): real ILC app + nock'ed fragments. + * A fresh app instance is created per test — the fragment cache lives inside the tailor factory, + * so reusing one app would leak cached entries between tests. + */ +type MockFragmentState = { + hits: { primary: number; regular: number }; + regularHeaders: Record[]; + regularUris: string[]; +}; + +describe('request-fragment-cache integration', () => { + let app: any; + let server: ReturnType; + + afterEach(async () => { + if (app) { + await app.close(); + app = null; + } + nock.cleanAll(); + }); + + async function bootApp({ cache = { enabled: true, ttlSeconds: 300 } } = {}) { + const registryOverrides = { + // the default mock template renders only the "primary" slot; the cache tests + // need the cache-enabled "regular" fragment on the page next to a non-cached one + getTemplate: () => ({ + data: { + content: + '\n' + + '
\n' + + '
\n' + + '', + }, + }), + apps: { + '@portal/regular': { + ssr: cache ? { cache } : {}, + }, + }, + }; + + // the default i18n detection mock ignores the URL locale prefix — parse it for real, + // otherwise localized requests like /ua/all get redirected back to the default locale + const pluginManager = { + ...helpers.getPluginManagerMock(), + getI18nParamsDetectionPlugin: () => ({ + type: 'i18nParamsDetection', + detectI18nConfig: (req: { url: string }, intl: any, i18nConfig: Record) => { + const { locale } = intl.parseUrl(req.url); + return { ...i18nConfig, locale }; + }, + }), + }; + + app = await createApp(helpers.getRegistryMock(registryOverrides), pluginManager); + await app.ready(); + app.server.listen(0); + server = supertest(app.server); + } + + function mockFragments({ regular }: { regular?: (state: MockFragmentState) => nock.ReplyFnResult } = {}) { + const state: MockFragmentState = { + hits: { primary: 0, regular: 0 }, + regularHeaders: [], + regularUris: [], + }; + + nock('http://apps.test') + .persist() + .get(/.?/) + .reply(function (uri): nock.ReplyFnResult { + if (uri.startsWith('/primary')) { + state.hits.primary += 1; + return [200, `
primary-content-${state.hits.primary}
`]; + } + + state.hits.regular += 1; + state.regularHeaders.push(this.req.headers); + state.regularUris.push(uri); + + if (regular) { + return regular(state); + } + + return [200, `
regular-content-${state.hits.regular}
`]; + }); + + return state; + } + + it('should render a cache-enabled fragment once for N page requests and mark HIT (Todo#3, AC#5)', async () => { + await bootApp(); + const state = mockFragments(); + + const first = await server.get('/all').expect(200); + chai.expect(first.text).to.include('regular-content-1'); + chai.expect(first.text).to.include(''); + + const second = await server.get('/all').expect(200); + const third = await server.get('/all').expect(200); + + chai.expect(state.hits.regular).to.equal(1); + chai.expect(second.text).to.include('regular-content-1'); + chai.expect(second.text).to.include(''); + chai.expect(third.text).to.include('regular-content-1'); + }); + + it('should keep rendering non-enabled fragments on every request on the same page (AC#1)', async () => { + await bootApp(); + const state = mockFragments(); + + await server.get('/all').expect(200); + await server.get('/all').expect(200); + await server.get('/all').expect(200); + + chai.expect(state.hits.primary).to.equal(3); + chai.expect(state.hits.regular).to.equal(1); + }); + + it('should serve the same cached body to different users and never forward their identity (AC#3)', async () => { + await bootApp(); + const state = mockFragments(); + + const first = await server + .get('/all') + .set('Cookie', 'session=user-a') + .set('Authorization', 'Bearer user-a-token') + .set('X-Forwarded-For', '203.0.113.7') + .expect(200); + const second = await server + .get('/all') + .set('Cookie', 'session=user-b') + .set('Authorization', 'Bearer user-b-token') + .expect(200); + + chai.expect(state.hits.regular).to.equal(1); + chai.expect(first.text).to.include('regular-content-1'); + chai.expect(second.text).to.include('regular-content-1'); + + chai.expect(state.regularHeaders[0]).to.not.have.property('cookie'); + chai.expect(state.regularHeaders[0]).to.not.have.property('authorization'); + chai.expect(state.regularHeaders[0]).to.not.have.property('x-forwarded-for'); + chai.expect(state.regularHeaders[0]).to.have.property('x-request-host'); + }); + + it('should render separately per locale and never mix localized content (AC#3)', async () => { + await bootApp(); + const state = mockFragments(); + + const defaultLocale = await server.get('/all').expect(200); + const uaLocale = await server.get('/ua/all').expect(200); + const uaLocaleAgain = await server.get('/ua/all').expect(200); + + chai.expect(state.hits.regular).to.equal(2); + chai.expect(defaultLocale.text).to.include('regular-content-1'); + chai.expect(uaLocale.text).to.include('regular-content-2'); + chai.expect(uaLocaleAgain.text).to.include('regular-content-2'); + }); + + it('should render separately per domain (AC#3)', async () => { + await bootApp(); + const state = mockFragments(); + + await server.get('/all').set('Host', 'foo.example.org').expect(200); + await server.get('/all').set('Host', 'bar.example.org').expect(200); + await server.get('/all').set('Host', 'foo.example.org').expect(200); + + chai.expect(state.hits.regular).to.equal(2); + }); + + it('should not cache error responses and keep the error path unchanged (AC#4)', async () => { + await bootApp(); + const state = mockFragments({ + regular: () => [500, 'fragment exploded'], + }); + + const first = await server.get('/all'); + const second = await server.get('/all'); + + chai.expect(state.hits.regular).to.equal(2); + chai.expect(first.text).to.not.include('ilc:fragment-cache HIT'); + chai.expect(second.text).to.not.include('ilc:fragment-cache HIT'); + }); + + it('should serve set-cookie responses to the current user but never cache them (AC#3/AC#4)', async () => { + await bootApp(); + const state = mockFragments({ + regular: (s) => [200, `
personalized-${s.hits.regular}
`, { 'Set-Cookie': 'flavor=choco' }], + }); + + // transition request: buffered probe (discarded) + live per-request render + const first = await server.get('/all').expect(200); + // tombstone is fresh: exactly one live render per request + const second = await server.get('/all').expect(200); + + chai.expect(state.hits.regular).to.equal(3); + chai.expect(first.text).to.include('personalized-2'); + chai.expect(second.text).to.include('personalized-3'); + chai.expect(second.text).to.not.include('ilc:fragment-cache HIT'); + }); + + it('should respect Cache-Control: no-store from the fragment even when caching is enabled (AC#2)', async () => { + await bootApp(); + const state = mockFragments({ + regular: (s) => [200, `
priced-${s.hits.regular}
`, { 'Cache-Control': 'no-store' }], + }); + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(state.hits.regular).to.equal(3); + chai.expect(first.text).to.include('priced-2'); + chai.expect(second.text).to.include('priced-3'); + chai.expect(second.text).to.not.include('ilc:fragment-cache HIT'); + }); + + it('should be blind to query strings: UTM traffic shares one entry and the fragment never sees the query', async () => { + await bootApp(); + const state = mockFragments(); + + const first = await server.get('/all?utm_source=facebook&gclid=abc123').expect(200); + const second = await server.get('/all?utm_source=google&nonce=xyz').expect(200); + + chai.expect(state.hits.regular).to.equal(1); + chai.expect(first.text).to.include('regular-content-1'); + chai.expect(second.text).to.include('regular-content-1'); + chai.expect(second.text).to.include(''); + + // the render input contract: query is neither in the key nor visible to the fragment + const routerPropsParam = new URL('http://apps.test' + state.regularUris[0]).searchParams.get('routerProps'); + const routerProps = JSON.parse(Buffer.from(routerPropsParam!, 'base64').toString('utf8')); + chai.expect(routerProps.reqUrl).to.not.include('utm_source'); + chai.expect(routerProps.reqUrl).to.not.include('?'); + }); + + it('should serve stale after TTL and refresh in the background (SWR)', async function () { + this.timeout(10000); + + await bootApp({ cache: { enabled: true, ttlSeconds: 1 } }); + const state = mockFragments(); + + const first = await server.get('/all').expect(200); + chai.expect(first.text).to.include('regular-content-1'); + + // cachedAt/now are floored to whole seconds, so ttl + 1s + margin guarantees staleness + await sleep(2200); + + const stale = await server.get('/all').expect(200); + chai.expect(stale.text).to.include('regular-content-1'); + chai.expect(stale.text).to.include(''); + + await sleep(100); + chai.expect(state.hits.regular).to.equal(2); + + const fresh = await server.get('/all').expect(200); + chai.expect(fresh.text).to.include('regular-content-2'); + chai.expect(fresh.text).to.include(''); + chai.expect(state.hits.regular).to.equal(2); + }); + + it('should cache gzip fragment responses and replay identical markup', async () => { + await bootApp(); + const state = mockFragments({ + regular: () => [200, zlib.gzipSync('
gzipped-regular-content
'), { 'Content-Encoding': 'gzip' }], + }); + + const first = await server.get('/all').expect(200); + const second = await server.get('/all').expect(200); + + chai.expect(state.hits.regular).to.equal(1); + chai.expect(first.text).to.include('gzipped-regular-content'); + chai.expect(second.text).to.include('gzipped-regular-content'); + chai.expect(second.text).to.include(''); + }); +}); diff --git a/ilc/server/tailor/request-fragment-cache.spec.ts b/ilc/server/tailor/request-fragment-cache.spec.ts new file mode 100644 index 00000000..d57ca043 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache.spec.ts @@ -0,0 +1,1787 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import zlib from 'zlib'; +import { Readable } from 'stream'; +import { + composeCacheKey, + isCacheableRequest, + isCacheableResponse, + wrapRequestFragmentWithCache, + getCacheMarker, +} from './request-fragment-cache'; +import { REFUSAL_REASONS } from './request-fragment-cache'; +import type { RefusalReason } from './request-fragment-cache'; +import { EvictingCacheStorage } from '../../common/EvictingCacheStorage'; +import { pickSharedRenderHeaders } from './fragment-render'; +import { createFragmentCacheStorage } from './request-fragment-cache/storage-factory'; +import type { Logger } from 'ilc-plugins-sdk'; + +const errors = require('./errors'); + +interface MockResponseOptions { + statusCode?: number; + headers?: Record; + body?: string; + gzip?: boolean; + contentEncoding?: string; + errorMidStream?: boolean; +} + +function makeFragmentResponse({ + statusCode = 200, + headers = {}, + body = 'fragment-body', + gzip = false, + contentEncoding, + errorMidStream = false, +}: MockResponseOptions = {}) { + let payload = Buffer.from(body); + if (gzip) { + payload = zlib.gzipSync(payload); + headers['content-encoding'] = contentEncoding ?? 'gzip'; + } + + const stream = new Readable({ read() {} }); + setImmediate(() => { + if (errorMidStream) { + stream.emit('error', new Error('socket hang up')); + return; + } + stream.push(payload); + stream.push(null); + }); + + return Object.assign(stream, { statusCode, headers }); +} + +function readBody(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); +} + +const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('fragment cache guarantees (through the seam)', () => { + const logger = { info: sinon.spy(), warn: sinon.spy(), error: sinon.spy(), debug: sinon.spy() }; + + const cacheableAttributes = Object.freeze({ + id: 'app__at__slot', + appProps: {}, + wrapperConf: null, + forwardQuerystring: false, + primary: false, + timeout: 3000, + cache: { enabled: true, ttlSeconds: 300 }, + }); + + const makeRequest = (reqUrl = '/page') => ({ + headers: { 'x-request-intl': 'en-US:en-US:USD:USD', 'x-request-host': 'example.org' }, + registryConfig: { apps: {} }, + router: { getRoute: () => ({ basePath: '/', reqUrl }) }, + }); + + let events: Array<{ event: string; appId: string }>; + let clock: sinon.SinonFakeTimers | null = null; + + /** + * Builds the cache through its only public seam. Capacity is injected rather than imported, + * so the memory and single-flight guarantees are exercised with kilobytes and two slots + * instead of production megabytes — same rules, milliseconds instead of seconds. + */ + const makeWrapped = ( + innerFn: (...args: any[]) => Promise, + capacity: Record = { maxConcurrentCaptures: 2, maxBodyBytes: 1024, maxTotalBodyBytes: 8192 }, + ) => + wrapRequestFragmentWithCache(innerFn as any, { + logger: logger as any, + capacity, + onCacheEvent: (event, { appId }) => events.push({ event, appId }), + }); + + const drippingResponse = () => { + const stream = new Readable({ read() {} }); + const interval = setInterval(() => stream.push('x'), 10); + return { + response: Object.assign(stream, { statusCode: 200, headers: {} }), + stop: () => clearInterval(interval), + }; + }; + + beforeEach(() => { + events = []; + }); + + afterEach(() => { + logger.info.resetHistory(); + logger.error.resetHistory(); + clock?.restore(); + clock = null; + }); + + describe('memory bounds', () => { + it('refuses a body over the size budget and still delivers it whole', async () => { + const marker = 'END-OF-OVERSIZED'; + const payload = 'x'.repeat(4096) + marker; // 4× the injected 1 KiB cap + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return makeFragmentResponse({ body: payload }); + }); + + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const rendersAfterFirst = renders; + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + // an oversized body is never stored: every request renders live afterwards + expect(renders, 'an oversized body must never be replayed from cache').to.be.greaterThan(rendersAfterFirst); + expect(await readBody(first)).to.include(marker); + expect(await readBody(second)).to.include(marker); + expect(events.map((e) => e.event)).to.include('refuse'); + }); + + it('caches a body just under the size budget', async () => { + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return makeFragmentResponse({ body: 'y'.repeat(512) }); + }); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const attributes = { ...cacheableAttributes }; + const second = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(renders).to.equal(1); + expect(getCacheMarker(attributes)).to.equal('hit'); + expect(await readBody(second)).to.have.length(512); + }); + + it('refuses to buffer beyond the concurrent-capture budget instead of growing memory', async () => { + const release: Array<() => void> = []; + let sharedRenders = 0; + let privateRenders = 0; + // only a shared render is buffered into the cache; a refused one is streamed privately + const wrapped = makeWrapped((_url: string, _attrs: any, _req: any, renderOptions?: { mode: string }) => { + if (renderOptions?.mode === 'shared') { + sharedRenders += 1; + return new Promise((resolve) => { + release.push(() => resolve(makeFragmentResponse({ body: 'held' }))); + }); + } + privateRenders += 1; + return Promise.resolve(makeFragmentResponse({ body: 'private' })); + }); + + // two slots are injected, so the third distinct key must not be buffered + const inFlight = ['a', 'b', 'c'].map((key) => + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest(`/${key}`)), + ); + await flushAsync(); + + expect(sharedRenders, 'only the budgeted number of captures may buffer').to.equal(2); + expect(privateRenders, 'the surplus request still renders, just without buffering').to.equal(1); + expect(events.filter((e) => e.event === 'refuse')).to.have.length(1); + + release.forEach((fn) => fn()); + await Promise.all(inFlight); + }); + + it('frees budget slots once captures settle', async () => { + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return makeFragmentResponse({ body: 'settled' }); + }); + + for (let i = 0; i < 5; i++) { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest(`/seq-${i}`)); + } + + expect(renders, 'sequential misses must never exhaust the budget').to.equal(5); + expect(events.some((e) => e.event === 'refuse')).to.equal(false); + }); + }); + + describe('render deadline', () => { + it('abandons a capture whose body never finishes and keeps serving later requests', async function () { + this.timeout(15000); + const dripping = drippingResponse(); + let attempt = 0; + const wrapped = makeWrapped(async () => { + attempt += 1; + return attempt === 1 ? dripping.response : makeFragmentResponse({ body: 'recovered' }); + }); + + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes, timeout: 30 }, makeRequest()).catch( + () => {}, + ); + + const recovered = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes, timeout: 30 }, + makeRequest('/other'), + ); + expect(await readBody(recovered)).to.equal('recovered'); + } finally { + dripping.stop(); + } + }); + + it('does not let abandoned captures starve a healthy key', async function () { + this.timeout(15000); + const dripping = [drippingResponse(), drippingResponse()]; + let index = 0; + const wrapped = makeWrapped(async () => { + const current = dripping[index]; + index += 1; + return current ? current.response : makeFragmentResponse({ body: 'healthy' }); + }); + + try { + // saturate both injected slots with captures that hang, then time out + for (let i = 0; i < 2; i++) { + await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes, timeout: 30 }, + makeRequest(`/hung-${i}`), + ).catch(() => {}); + } + + const healthy = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + makeRequest('/healthy'), + ); + expect(await readBody(healthy)).to.equal('healthy'); + } finally { + dripping.forEach((d) => d.stop()); + } + }); + + it('does not tombstone a stale entry when a background refresh hits its render deadline', async function () { + this.timeout(15000); + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + const dripping = drippingResponse(); + let attempt = 0; + const wrapped = makeWrapped(async () => { + attempt += 1; + if (attempt === 1) return makeFragmentResponse({ body: 'render-1' }); + // only the second attempt (the one background refresh under test) hangs; any + // further attempt must resolve normally so nothing is left dangling past this test + if (attempt === 2) return dripping.response; + return makeFragmentResponse({ body: 'render-3' }); + }); + const attributes = { ...cacheableAttributes, timeout: 30, cache: { enabled: true, ttlSeconds: 1 } }; + + try { + const first = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(first)).to.equal('render-1'); + + clock.tick(2000); // entry goes stale + + const stale = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('stale'); + expect(await readBody(stale)).to.equal('render-1'); + + // wait out the real render deadline so the background refresh (attempt 2) hits it, + // destroying the hung response and reporting stream-closed-early + await new Promise((resolve) => setTimeout(resolve, 5200)); + + // a deadline-driven abort must not have tombstoned the entry: still stale, old body intact + const afterDeadline = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('stale'); + expect(await readBody(afterDeadline)).to.equal('render-1'); + + // let the (normal, quick) third-attempt background refresh it triggered settle + // before the test ends, so nothing bleeds into the next test + await flushAsync(); + } finally { + dripping.stop(); + } + }); + + it('reclaims the capture slot when a fragment never returns a response', async function () { + this.timeout(15000); + let attempt = 0; + const wrapped = makeWrapped( + async () => { + attempt += 1; + if (attempt === 1) { + return new Promise(() => {}); + } + return makeFragmentResponse({ body: 'healthy' }); + }, + { maxConcurrentCaptures: 1, maxBodyBytes: 1024, maxTotalBodyBytes: 8192 }, + ); + + await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes, timeout: 30 }, + makeRequest('/never-responds'), + ).catch(() => {}); + + const healthyAttributes = { ...cacheableAttributes, timeout: 30 }; + const healthy = await wrapped('http://apps.test/app', healthyAttributes, makeRequest('/healthy')); + + // the only injected slot must be free again, so an unrelated key still gets captured + expect(getCacheMarker(healthyAttributes)).to.equal('miss'); + expect(await readBody(healthy)).to.equal('healthy'); + }); + + it('drops a response that arrives after the deadline instead of caching it', async function () { + this.timeout(15000); + let answerLate: (response: any) => void = () => {}; + const late = Object.assign(new Readable({ read() {} }), { statusCode: 200, headers: {} }); + let attempt = 0; + const wrapped = makeWrapped(async () => { + attempt += 1; + if (attempt === 1) { + return new Promise((resolve) => (answerLate = resolve)); + } + return makeFragmentResponse({ body: 'fresh' }); + }); + const attributes = { ...cacheableAttributes, timeout: 30 }; + + await wrapped('http://apps.test/app', attributes, makeRequest()).catch(() => {}); + + // the fragment finally answers, long after the render it was meant to serve gave up + answerLate(late); + await flushAsync(); + + // nothing is waiting for that body and it holds no capture slot, so it must be dropped + expect(late.destroyed, 'the abandoned response must be destroyed').to.equal(true); + + const after = { ...cacheableAttributes, timeout: 30 }; + const served = await wrapped('http://apps.test/app', after, makeRequest()); + expect(getCacheMarker(after)).to.equal('miss'); + expect(await readBody(served)).to.equal('fresh'); + }); + + it('serves a cold miss privately when the shared probe hits the deadline', async function () { + this.timeout(15000); + const wrapped = makeWrapped((_url: string, _attrs: any, _req: any, renderOptions?: { mode: string }) => { + // the shared probe never answers; the cache's own deadline must not fail the render + if (renderOptions?.mode === 'shared') { + return new Promise(() => {}); + } + return Promise.resolve(makeFragmentResponse({ body: 'private-fallback' })); + }); + const attributes = { ...cacheableAttributes, timeout: 30 }; + + const served = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(await readBody(served)).to.equal('private-fallback'); + expect(getCacheMarker(attributes)).to.equal('refuse:render-deadline'); + }); + }); + + describe('single flight', () => { + it('collapses concurrent misses on one key into a single render', async () => { + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + await new Promise((resolve) => setTimeout(resolve, 30)); + return makeFragmentResponse({ body: 'deduped' }); + }); + + const responses = await Promise.all([ + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + ]); + + expect(renders).to.equal(1); + for (const response of responses) { + expect(await readBody(response)).to.equal('deduped'); + } + }); + + it('does not attach a new deadline timer for every stale hit while a refresh is in flight', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + const dripping = drippingResponse(); + let attempt = 0; + const wrapped = makeWrapped(async () => { + attempt += 1; + return attempt === 1 ? makeFragmentResponse({ body: 'fresh' }) : dripping.response; + }); + const attributes = { ...cacheableAttributes, timeout: 30, cache: { enabled: true, ttlSeconds: 1 } }; + const setTimeoutSpy = sinon.spy(global, 'setTimeout'); + + try { + await wrapped('http://apps.test/app', attributes, makeRequest()); + clock.tick(2000); + setTimeoutSpy.resetHistory(); + + await Promise.all( + Array.from({ length: 20 }, () => wrapped('http://apps.test/app', attributes, makeRequest())), + ); + + // withTimeout schedules exactly one deadline timer at request.timeoutMs (30 + 5000ms + // slack); an extra join per stale hit would inflate this well past 1 + const deadlineTimers = setTimeoutSpy.getCalls().filter((call) => call.args[1] === 5030); + expect(deadlineTimers).to.have.length(1); + } finally { + setTimeoutSpy.restore(); + dripping.stop(); + } + }); + }); + + describe('refusal lifetime', () => { + it('stops honouring a refusal once its short ceiling passes, even with a long ttl', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let dynamic = true; + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return dynamic + ? makeFragmentResponse({ headers: { 'cache-control': 'no-store' }, body: 'dynamic' }) + : makeFragmentResponse({ body: 'static-again' }); + }); + + const longTtl = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 24 * 60 * 60 } }; + await wrapped('http://apps.test/app', { ...longTtl }, makeRequest()); + const rendersAfterRefusal = renders; + + dynamic = false; + // past the refusal ceiling but nowhere near the configured day-long ttl + clock.tick(61_000); + + await wrapped('http://apps.test/app', { ...longTtl }, makeRequest()); + const attributes = { ...longTtl }; + const hit = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(renders, 'the fragment must be probed again after the refusal expires').to.be.greaterThan( + rendersAfterRefusal, + ); + expect(getCacheMarker(attributes)).to.equal('hit'); + expect(await readBody(hit)).to.equal('static-again'); + }); + + it('keeps honouring a refusal while a short ttl has not elapsed', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return makeFragmentResponse({ headers: { 'cache-control': 'no-store' }, body: 'dynamic' }); + }); + + const shortTtl = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 10 } }; + await wrapped('http://apps.test/app', { ...shortTtl }, makeRequest()); + const rendersAfterFirst = renders; + + clock.tick(500); + await wrapped('http://apps.test/app', { ...shortTtl }, makeRequest()); + + expect(renders).to.equal(rendersAfterFirst + 1); + expect(events.filter((e) => e.event === 'refuse').length).to.be.greaterThan(0); + }); + }); + + describe('capacity budget contract', () => { + it('rejects a budget whose concurrent captures could exceed the total byte ceiling', () => { + expect(() => + makeWrapped(async () => makeFragmentResponse(), { + maxConcurrentCaptures: 32, + maxBodyBytes: 1024 * 1024, + maxTotalBodyBytes: 1024, + }), + ).to.throw(/capacity budget violated/i); + }); + + it('rejects a non-positive budget value', () => { + expect(() => makeWrapped(async () => makeFragmentResponse(), { maxConcurrentCaptures: 0 })).to.throw( + /positive integer/i, + ); + }); + }); + describe('log volume', () => { + it('reports the hot-path outcomes at debug rather than info', async () => { + logger.info.resetHistory(); + logger.debug.resetHistory(); + const wrapped = makeWrapped(async () => makeFragmentResponse({ body: 'body' })); + const attributes = { ...cacheableAttributes }; + + await wrapped('http://apps.test/app', attributes, makeRequest()); + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + await readBody(second); + + // hot-path outcomes belong at debug; the metric and marker carry them in production + const decisionEvents = (spy: sinon.SinonSpy) => + spy + .getCalls() + .map((call) => (call.args[0] as { event?: string } | undefined)?.event) + .filter((event) => event !== undefined); + + expect(decisionEvents(logger.debug)).to.deep.equal(['miss', 'hit']); + expect(decisionEvents(logger.info)).to.deep.equal([]); + }); + }); +}); + +describe('request-fragment-cache helpers', () => { + describe('createFragmentCacheStorage', () => { + const budget = { + maxBodyBytes: 1024, + maxConcurrentCaptures: 2, + maxTotalBodyBytes: 8192, + maxEntries: 1, + }; + const refusalEntry = () => ({ data: { kind: 'refusal' as const }, cachedAt: Date.now() }); + + let clock: sinon.SinonFakeTimers; + + afterEach(() => clock?.restore()); + + it('reports a steady stream of evictions once per interval with a count', () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: 1_700_000_000_000 }); + const warn = sinon.spy(); + const storage = createFragmentCacheStorage({ warn } as unknown as Logger, budget); + + // maxEntries is 1, so every insert past the first evicts: the permanently-full state + storage.setItem('a', refusalEntry()); + storage.setItem('b', refusalEntry()); + storage.setItem('c', refusalEntry()); + storage.setItem('d', refusalEntry()); + + expect(warn.callCount, 'the first eviction reports, the rest are folded into it').to.equal(1); + expect(warn.firstCall.args[0]).to.include({ evictedSinceLastReport: 1 }); + + clock.tick(60_000); + storage.setItem('e', refusalEntry()); + + expect(warn.callCount).to.equal(2); + // the two evictions suppressed mid-interval are still accounted for, plus this one + expect(warn.secondCall.args[0]).to.include({ evictedSinceLastReport: 3 }); + }); + }); + + describe('composeCacheKey', () => { + const base = { + fragmentUrl: 'http://apps.test/primary', + attributes: { id: 'app__at__slot', appProps: { theme: 'light' } }, + route: { basePath: '/', reqUrl: '/page' }, + varyHeaders: pickSharedRenderHeaders({ + 'x-request-host': 'example.org', + 'x-request-intl': 'en-US:USD', + }), + }; + + it('should return a stable key for identical inputs', () => { + expect(composeCacheKey({ ...base })).to.equal(composeCacheKey({ ...base })); + }); + + it('should return different keys for different intl values', () => { + expect(composeCacheKey({ ...base })).to.not.equal( + composeCacheKey({ + ...base, + varyHeaders: pickSharedRenderHeaders({ + ...base.varyHeaders, + 'x-request-intl': 'ua-UA:UAH', + }), + }), + ); + }); + + it('should return different keys for different domains', () => { + expect( + composeCacheKey({ + ...base, + varyHeaders: pickSharedRenderHeaders({ + ...base.varyHeaders, + 'x-request-host': 'foo.example.org', + }), + }), + ).to.not.equal( + composeCacheKey({ + ...base, + varyHeaders: pickSharedRenderHeaders({ + ...base.varyHeaders, + 'x-request-host': 'bar.example.org', + }), + }), + ); + }); + + it('should return different keys for different appProps (e.g. experiments variants)', () => { + expect( + composeCacheKey({ ...base, attributes: { ...base.attributes, appProps: { variant: 'A' } } }), + ).to.not.equal( + composeCacheKey({ ...base, attributes: { ...base.attributes, appProps: { variant: 'B' } } }), + ); + }); + + it('should return different keys for different routes', () => { + expect(composeCacheKey({ ...base, route: { basePath: '/', reqUrl: '/page' } })).to.not.equal( + composeCacheKey({ ...base, route: { basePath: '/', reqUrl: '/other' } }), + ); + }); + + it('should be blind to the query string in reqUrl', () => { + expect( + composeCacheKey({ ...base, route: { basePath: '/', reqUrl: '/page?utm_source=facebook' } }), + ).to.equal(composeCacheKey({ ...base, route: { basePath: '/', reqUrl: '/page?utm_source=google' } })); + }); + + it('should not collide when field values shift between fields', () => { + expect( + composeCacheKey({ + ...base, + varyHeaders: pickSharedRenderHeaders({ 'x-request-host': 'b:c', 'x-request-intl': 'd' }), + }), + ).to.not.equal( + composeCacheKey({ + ...base, + varyHeaders: pickSharedRenderHeaders({ 'x-request-host': 'b', 'x-request-intl': 'c:d' }), + }), + ); + }); + + it('should treat missing intl as a distinct stable value', () => { + const { ['x-request-intl']: ignored, ...withoutIntl } = base.varyHeaders; + const selectedWithoutIntl = pickSharedRenderHeaders(withoutIntl); + expect(composeCacheKey({ ...base, varyHeaders: selectedWithoutIntl })).to.equal( + composeCacheKey({ ...base, varyHeaders: selectedWithoutIntl }), + ); + expect(composeCacheKey({ ...base, varyHeaders: selectedWithoutIntl })).to.not.equal( + composeCacheKey({ ...base }), + ); + }); + }); + + describe('isCacheableRequest', () => { + const cacheableAttributes = Object.freeze({ + id: 'app__at__slot', + cache: { enabled: true, ttlSeconds: 300 }, + wrapperConf: null, + forwardQuerystring: false, + primary: false, + }); + const validRoute = Object.freeze({}); + const validVaryHeaders = pickSharedRenderHeaders({ 'x-request-host': 'example.org' }); + + it('should return false when cache config is absent (opt-in, AC#1)', () => { + const { cache, ...rest } = cacheableAttributes; + expect(isCacheableRequest(rest, validRoute, validVaryHeaders)).to.equal(false); + }); + + it('should return false when cache is disabled', () => { + expect( + isCacheableRequest({ ...cacheableAttributes, cache: { enabled: false } }, validRoute, validVaryHeaders), + ).to.equal(false); + }); + + it('should return true when cache is enabled', () => { + expect(isCacheableRequest(cacheableAttributes, validRoute, validVaryHeaders)).to.equal(true); + }); + + it('should return false when cache is enabled without a valid ttlSeconds (registry contract)', () => { + for (const ttlSeconds of [undefined, 0, -1, 1.5, '300', 2592001]) { + expect( + isCacheableRequest( + { ...cacheableAttributes, cache: { enabled: true, ttlSeconds } as any }, + validRoute, + validVaryHeaders, + ), + `ttlSeconds=${ttlSeconds}`, + ).to.equal(false); + } + }); + + it('should accept the maximum 30 day ttlSeconds at runtime', () => { + expect( + isCacheableRequest( + { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 2592000 } }, + validRoute, + validVaryHeaders, + ), + ).to.equal(true); + }); + + it('should return false for wrapped apps (wrapperConf present)', () => { + expect( + isCacheableRequest( + { + ...cacheableAttributes, + wrapperConf: { appId: 'wrapper__at__slot', src: 'http://apps.test/wrapper' }, + }, + validRoute, + validVaryHeaders, + ), + ).to.equal(false); + }); + + it('should return false when forwardQuerystring is enabled', () => { + expect( + isCacheableRequest({ ...cacheableAttributes, forwardQuerystring: true }, validRoute, validVaryHeaders), + ).to.equal(false); + }); + + it('should return true for primary fragments (they are cacheable)', () => { + const primaryFragmentAttributes = { ...cacheableAttributes, primary: true }; + expect(isCacheableRequest(primaryFragmentAttributes, validRoute, validVaryHeaders)).to.equal(true); + }); + + it('should return false for a special route (404 etc.)', () => { + expect(isCacheableRequest(cacheableAttributes, { specialRole: 404 }, validVaryHeaders)).to.equal(false); + }); + + it('should return false when x-request-host is absent', () => { + expect(isCacheableRequest(cacheableAttributes, validRoute, pickSharedRenderHeaders({}))).to.equal(false); + }); + }); + + describe('pickSharedRenderHeaders', () => { + it('should forward only x-request-intl and x-request-host', () => { + expect( + pickSharedRenderHeaders({ + authorization: 'Bearer 12345', + cookie: 'yummy_cookie=choco; session=abc', + 'x-request-host': 'www.somewhere.com', + 'x-request-intl': 'en-US:en-US,ua-UA:USD:USD,UAH', + }), + ).to.eql({ + 'x-request-host': 'www.somewhere.com', + 'x-request-intl': 'en-US:en-US,ua-UA:USD:USD,UAH', + }); + }); + + it('should strip all x-forwarded-* headers', () => { + expect( + pickSharedRenderHeaders({ + 'x-forwarded-for': '203.0.113.7', + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'evil.example.org', + 'x-request-host': 'www.somewhere.com', + }), + ).to.eql({ + 'x-request-host': 'www.somewhere.com', + }); + }); + + it('should return an empty object when no shared-render headers are present', () => { + expect(pickSharedRenderHeaders({ 'x-custom-header': 'custom-value' })).to.eql({}); + }); + }); + + describe('isCacheableResponse', () => { + it('should return true for a plain 200 response', () => { + expect(isCacheableResponse(200, { 'content-type': 'text/html' })).to.equal(true); + }); + + it('should return false for non-200 status codes (AC#4)', () => { + for (const statusCode of [201, 204, 210, 301, 302, 404, 500, 503]) { + expect(isCacheableResponse(statusCode, {}), `statusCode=${statusCode}`).to.equal(false); + } + }); + + it('should return false when response sets cookies (personalization signal, AC#3)', () => { + expect(isCacheableResponse(200, { 'set-cookie': ['session=abc'] })).to.equal(false); + }); + + it('should return false when fragment opts out via Cache-Control: no-store (AC#2)', () => { + expect(isCacheableResponse(200, { 'cache-control': 'no-store' })).to.equal(false); + expect(isCacheableResponse(200, { 'cache-control': 'No-Store, max-age=0' })).to.equal(false); + }); + + it('should return false when fragment opts out via Cache-Control: private (AC#2)', () => { + expect(isCacheableResponse(200, { 'cache-control': 'private' })).to.equal(false); + }); + + it('should refuse directives that forbid reuse without revalidation (RFC 9111)', () => { + for (const value of [ + 'no-cache', + 'No-Cache', + 'max-age=0', + 'max-age=0, public', + 's-maxage=0', + 'must-revalidate', + 'public, max-age=60, must-revalidate', + ]) { + expect(isCacheableResponse(200, { 'cache-control': value }), value).to.equal(false); + } + }); + + it('should refuse field-qualified no-cache/private the same as the bare form (RFC 9111 §5.2.2)', () => { + for (const value of ['no-cache="Link"', 'private="Set-Cookie"', 'No-Cache="X-Foo"']) { + expect(isCacheableResponse(200, { 'cache-control': value }), value).to.equal(false); + } + }); + + it('should refuse a quoted max-age=0 the same as the bare form', () => { + for (const value of ['max-age="0"', 's-maxage="0"', 'public, max-age="0"']) { + expect(isCacheableResponse(200, { 'cache-control': value }), value).to.equal(false); + } + }); + + it('should still cache when max-age is non-zero', () => { + for (const value of ['max-age=600', 's-maxage=30', 'public, max-age=3600']) { + expect(isCacheableResponse(200, { 'cache-control': value }), value).to.equal(true); + } + }); + + it('should return true for cache-friendly Cache-Control values', () => { + expect(isCacheableResponse(200, { 'cache-control': 'public, max-age=600' })).to.equal(true); + }); + }); + + describe('wrapRequestFragmentWithCache', () => { + const logger = { + info: sinon.spy(), + warn: sinon.spy(), + error: sinon.spy(), + debug: sinon.spy(), + }; + + const cacheableAttributes = Object.freeze({ + id: 'app__at__slot', + appProps: {}, + wrapperConf: null, + forwardQuerystring: false, + primary: false, + timeout: 3000, + cache: { enabled: true, ttlSeconds: 300 }, + }); + + const makeRequest = (overrides: Record = {}) => ({ + headers: { 'x-request-intl': 'en-US:en-US:USD:USD', 'x-request-host': 'example.org' }, + registryConfig: { apps: {} }, + router: { + getRoute: () => ({ basePath: '/', reqUrl: '/page' }), + }, + ...overrides, + }); + + let storage: EvictingCacheStorage; + let events: Array<{ event: string; appId: string; source?: string }>; + let clock: sinon.SinonFakeTimers | null = null; + + const makeWrapped = (innerFn: sinon.SinonSpy | ((...args: any[]) => Promise)) => + wrapRequestFragmentWithCache(innerFn as any, { + storage, + logger: logger as any, + onCacheEvent: (event, { appId, source }) => + events.push({ event, appId, ...(source ? { source } : {}) }), + }); + + beforeEach(() => { + storage = new EvictingCacheStorage({ maxSize: 100 }); + events = []; + }); + + afterEach(() => { + logger.info.resetHistory(); + logger.warn.resetHistory(); + logger.error.resetHistory(); + if (clock) { + clock.restore(); + clock = null; + } + }); + + it('should delegate non-cacheable requests untouched and never touch the storage (AC#1)', async () => { + const response = makeFragmentResponse(); + const inner: sinon.SinonSpy = sinon.spy(async () => response); + const setItem = sinon.spy(storage, 'setItem'); + const getItem = sinon.spy(storage, 'getItem'); + const wrapped = makeWrapped(inner); + + const attributes = { ...cacheableAttributes, cache: undefined }; + const request = makeRequest(); + const result = await wrapped('http://apps.test/app', attributes, request); + + expect(result).to.equal(response); + expect(getCacheMarker(attributes)).to.equal(undefined); + expect(inner.calledOnceWithExactly('http://apps.test/app', attributes, request)).to.equal(true); + expect(setItem.called).to.equal(false); + expect(getItem.called).to.equal(false); + expect(events).to.deep.equal([]); + }); + + it('should bypass the cache entirely for LDE override requests', async () => { + const response = makeFragmentResponse(); + const inner: sinon.SinonSpy = sinon.spy(async () => response); + const setItem = sinon.spy(storage, 'setItem'); + const getItem = sinon.spy(storage, 'getItem'); + const wrapped = makeWrapped(inner); + + const attributes = { ...cacheableAttributes }; + const request = makeRequest({ ldeRelated: true }); + const result = await wrapped('http://apps.test/app', attributes, request); + + expect(result).to.equal(response); + expect(inner.calledOnceWithExactly('http://apps.test/app', attributes, request)).to.equal(true); + expect(setItem.called).to.equal(false); + expect(getItem.called).to.equal(false); + }); + + it('should render on miss, serve the rendered body and store it after the stream completes', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => + makeFragmentResponse({ headers: { 'content-type': 'text/html' }, body: 'rendered-once' }), + ); + const wrapped = makeWrapped(inner); + + const attributes = { ...cacheableAttributes }; + const result = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(inner.callCount).to.equal(1); + expect(result.statusCode).to.equal(200); + expect(result.headers['content-type']).to.equal('text/html'); + expect(getCacheMarker(attributes)).to.equal('miss'); + expect(await readBody(result)).to.equal('rendered-once'); + expect(events).to.deep.equal([{ event: 'miss', appId: 'app__at__slot' }]); + }); + + it('should explicitly render cache probes in shared mode (header anonymization contract)', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse()); + const wrapped = makeWrapped(inner); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(inner.firstCall.args[1]).to.not.have.property('cacheableRender'); + expect(inner.firstCall.args[3]).to.deep.equal({ + mode: 'shared', + varyHeaders: pickSharedRenderHeaders({ + 'x-request-intl': 'en-US:en-US:USD:USD', + 'x-request-host': 'example.org', + }), + }); + }); + + it('should emit refuse and delegate untouched when caching is enabled but structurally impossible', async () => { + const response = makeFragmentResponse(); + const inner: sinon.SinonSpy = sinon.spy(async () => response); + const wrapped = makeWrapped(inner); + + const attributes = { + ...cacheableAttributes, + wrapperConf: { appId: 'wrapper__at__slot' }, + }; + const result = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(result).to.equal(response); + expect(inner.firstCall.args[1]).to.equal(attributes); + expect(events).to.deep.equal([{ event: 'refuse', appId: 'app__at__slot' }]); + }); + + it('should emit error and rethrow when the cache-path render fails', async () => { + const failure = new Error('fragment render failed'); + const inner: sinon.SinonSpy = sinon.spy(async () => { + throw failure; + }); + const wrapped = makeWrapped(inner); + + let caught: unknown; + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + } catch (error) { + caught = error; + } + + expect(caught).to.equal(failure); + // a plain Error from the render pipeline is a fragment/transport failure, not a bug + // in the cache module's own code — see cached-fragment-requester.ts's classification + expect(events).to.deep.equal([{ event: 'error', appId: 'app__at__slot', source: 'fragment' }]); + }); + + it('should tag an unexpected bug in the cache module itself distinctly from a fragment failure', async () => { + const bug = new TypeError("Cannot read properties of undefined (reading 'foo')"); + const inner: sinon.SinonSpy = sinon.spy(async () => { + throw bug; + }); + const wrapped = makeWrapped(inner); + + let caught: unknown; + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + } catch (error) { + caught = error; + } + + expect(caught).to.equal(bug); + expect(events).to.deep.equal([{ event: 'error', appId: 'app__at__slot', source: 'cache-internal' }]); + }); + + it('should rethrow primary-fragment 404 control flow without reporting a cache error', async () => { + const fragment404 = new errors.Fragment404Response(); + const inner: sinon.SinonSpy = sinon.spy(async () => { + throw fragment404; + }); + const wrapped = makeWrapped(inner); + + let caught: unknown; + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + } catch (error) { + caught = error; + } + + expect(caught).to.equal(fragment404); + expect(events).to.deep.equal([]); + }); + + it('should serve a synthetic response from cache on hit without calling the fragment', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => + makeFragmentResponse({ headers: { 'content-type': 'text/html' }, body: 'rendered-once' }), + ); + const wrapped = makeWrapped(inner); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const secondAttributes = { ...cacheableAttributes }; + const second = await wrapped('http://apps.test/app', secondAttributes, makeRequest()); + + expect(inner.callCount).to.equal(1); + expect(second.statusCode).to.equal(200); + expect(second.headers['content-type']).to.equal('text/html'); + expect(getCacheMarker(secondAttributes)).to.equal('hit'); + expect(await readBody(second)).to.equal('rendered-once'); + expect(events).to.deep.equal([ + { event: 'miss', appId: 'app__at__slot' }, + { event: 'hit', appId: 'app__at__slot' }, + ]); + }); + + it('should vary cache entries by intl and domain', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'variant' })); + const wrapped = makeWrapped(inner); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + makeRequest({ headers: { 'x-request-intl': 'ua-UA:ua-UA:UAH:UAH', 'x-request-host': 'example.org' } }), + ); + await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + makeRequest({ headers: { 'x-request-intl': 'en-US:en-US:USD:USD', 'x-request-host': 'other.org' } }), + ); + + expect(inner.callCount).to.equal(3); + }); + + it('should refuse caching when x-request-host is unavailable for shared-render isolation', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'shared' })); + const setItem = sinon.spy(storage, 'setItem'); + const wrapped = makeWrapped(inner); + const headers = { 'x-request-intl': 'en-US:en-US:USD:USD' }; + + const first = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + makeRequest({ headers, host: 'first.example' }), + ); + const second = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + makeRequest({ headers, host: 'second.example' }), + ); + + expect(inner.callCount).to.equal(2); + expect(await readBody(first)).to.equal('shared'); + expect(await readBody(second)).to.equal('shared'); + expect(setItem.called).to.equal(false); + expect(events).to.deep.equal([ + { event: 'refuse', appId: 'app__at__slot' }, + { event: 'refuse', appId: 'app__at__slot' }, + ]); + }); + + it('should refuse caching on special routes (unbounded path cardinality of reqUrl)', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'not-found-page' })); + const setItem = sinon.spy(storage, 'setItem'); + const wrapped = makeWrapped(inner); + + const specialRouteRequest = (reqUrl: string) => + makeRequest({ + router: { getRoute: () => ({ basePath: '/', reqUrl, specialRole: 404 }) }, + }); + + const attributes = { ...cacheableAttributes }; + const first = await wrapped('http://apps.test/app', attributes, specialRouteRequest('/scanned-path-1')); + const second = await wrapped('http://apps.test/app', attributes, specialRouteRequest('/scanned-path-2')); + + expect(await readBody(first)).to.equal('not-found-page'); + expect(await readBody(second)).to.equal('not-found-page'); + expect(inner.callCount).to.equal(2); + // delegated untouched: full user headers, nothing stored, marked as refused + expect(inner.firstCall.args[1]).to.equal(attributes); + expect(setItem.called).to.equal(false); + expect(getCacheMarker(attributes)).to.equal('refuse:special-role-route'); + expect(events.map((e) => e.event)).to.deep.equal(['refuse', 'refuse']); + }); + + it('should mark an invalid but cache-enabled config as refused (config rejected before any route lookup)', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'invalid-config' })); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: -1 } }; + + const result = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(await readBody(result)).to.equal('invalid-config'); + expect(getCacheMarker(attributes)).to.equal('refuse:ttl-invalid'); + expect(events).to.deep.equal([{ event: 'refuse', appId: 'app__at__slot' }]); + }); + + it('should be blind to the query string: requests differing only in query share one entry', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'query-blind' })); + const wrapped = makeWrapped(inner); + + const withReqUrl = (reqUrl: string) => + makeRequest({ + router: { getRoute: () => ({ basePath: '/', reqUrl }) }, + }); + + const first = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + withReqUrl('/page?utm_source=facebook&gclid=abc'), + ); + const second = await wrapped( + 'http://apps.test/app', + { ...cacheableAttributes }, + withReqUrl('/page?utm_source=google&nonce=xyz'), + ); + + expect(inner.callCount).to.equal(1); + expect(await readBody(first)).to.equal('query-blind'); + expect(await readBody(second)).to.equal('query-blind'); + }); + + it('should store entries under non-reversible digests (no secrets in storage keys or logs)', async () => { + const setItem = sinon.spy(storage, 'setItem'); + const inner = async () => makeFragmentResponse(); + const wrapped = makeWrapped(inner); + + const attributes = { + ...cacheableAttributes, + appProps: { apiToken: 'super-secret-value' }, + }; + await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(setItem.called).to.equal(true); + for (const call of setItem.getCalls()) { + const key = call.args[0] as string; + expect(key).to.match(/^[a-f0-9]{64}$/); + expect(key).to.not.include('super-secret-value'); + } + }); + + it('should preserve link headers (fragment assets) in cached responses', async () => { + const link = '; rel="fragment-script"'; + const inner = async () => makeFragmentResponse({ headers: { link } }); + const wrapped = makeWrapped(inner); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(second.headers.link).to.equal(link); + }); + + it('should store gzip responses decompressed and serve them without content-encoding', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => + makeFragmentResponse({ body: 'gzipped-body', gzip: true }), + ); + const wrapped = makeWrapped(inner); + + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(first.headers).to.not.have.property('content-encoding'); + expect(await readBody(first)).to.equal('gzipped-body'); + expect(second.headers).to.not.have.property('content-encoding'); + expect(await readBody(second)).to.equal('gzipped-body'); + expect(inner.callCount).to.equal(1); + }); + + it('should normalize content-encoding before decoding a cacheable response', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => + makeFragmentResponse({ body: 'gzipped-body', gzip: true, contentEncoding: ' GZip ' }), + ); + const wrapped = makeWrapped(inner); + + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(await readBody(first)).to.equal('gzipped-body'); + expect(await readBody(second)).to.equal('gzipped-body'); + expect(inner.callCount).to.equal(1); + }); + + it('should refuse unsupported content encodings instead of caching their raw bytes', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => + makeFragmentResponse({ body: 'encoded-body', headers: { 'content-encoding': 'br' } }), + ); + const wrapped = makeWrapped(inner); + + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(await readBody(first)).to.equal('encoded-body'); + expect(await readBody(second)).to.equal('encoded-body'); + expect(inner.callCount).to.equal(3); + }); + + it('should not store anything when the fragment stream errors mid-flight (AC#4)', async () => { + let shouldFail = true; + const inner: sinon.SinonSpy = sinon.spy(async () => { + if (shouldFail) { + return makeFragmentResponse({ errorMidStream: true }); + } + return makeFragmentResponse({ body: 'recovered' }); + }); + const wrapped = makeWrapped(inner); + + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect.fail('expected mid-stream error to reject'); + } catch (error: any) { + expect(error.message).to.contain('socket hang up'); + } + + shouldFail = false; + const result = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + expect(await readBody(result)).to.equal('recovered'); + expect(inner.callCount).to.equal(2); + }); + + it('should propagate fragment request errors untouched and cache nothing (AC#4)', async () => { + const requestError = new Error('Fragment request failed'); + let shouldFail = true; + const inner: sinon.SinonSpy = sinon.spy(async () => { + if (shouldFail) { + throw requestError; + } + return makeFragmentResponse({ body: 'after-error' }); + }); + const wrapped = makeWrapped(inner); + + try { + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect.fail('expected rejection'); + } catch (error) { + expect(error).to.equal(requestError); + } + + shouldFail = false; + const result = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect(await readBody(result)).to.equal('after-error'); + expect(inner.callCount).to.equal(2); + }); + + it('should not reject cold misses instantly for very large TTLs (timer overflow guard)', async () => { + const inner = async () => makeFragmentResponse({ body: 'long-lived' }); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 3000000000 } }; + + const result = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(await readBody(result)).to.equal('long-lived'); + }); + + it('should serve set-cookie responses per request and never share them between callers (AC#3)', async () => { + let renderCount = 0; + const inner: sinon.SinonSpy = sinon.spy(async () => { + renderCount += 1; + return makeFragmentResponse({ + headers: { 'set-cookie': [`session=user-${renderCount}`] }, + body: `personalized-${renderCount}`, + }); + }); + const wrapped = makeWrapped(inner); + + // transition request: one buffered probe (discarded) + one live per-request render + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect(first.headers['set-cookie']).to.deep.equal(['session=user-2']); + expect(await readBody(first)).to.equal('personalized-2'); + expect(inner.callCount).to.equal(2); + + // tombstone is fresh: subsequent requests render live directly, exactly once each + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect(second.headers['set-cookie']).to.deep.equal(['session=user-3']); + expect(await readBody(second)).to.equal('personalized-3'); + expect(inner.callCount).to.equal(3); + + expect(events.map((e) => e.event)).to.deep.equal(['refuse', 'refuse']); + }); + + it('should give each concurrent caller its own render when the response is not cacheable (AC#3)', async () => { + let renderCount = 0; + const inner: sinon.SinonSpy = sinon.spy(async () => { + renderCount += 1; + return makeFragmentResponse({ + headers: { 'set-cookie': [`session=user-${renderCount}`] }, + body: `personalized-${renderCount}`, + }); + }); + const wrapped = makeWrapped(inner); + + const [first, second] = await Promise.all([ + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + ]); + + expect(first.headers['set-cookie']).to.not.deep.equal(second.headers['set-cookie']); + // one shared buffered probe + one live render per caller + expect(inner.callCount).to.equal(3); + }); + + it('should not cache oversized bodies: live render per request, nothing stored (heap bound)', async () => { + const hugeBody = 'x'.repeat(2 * 1024 * 1024); // 2 MiB > 1 MiB cap + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: hugeBody })); + const wrapped = makeWrapped(inner); + + // transition request: capped probe (aborted) + live per-request render + const first = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect(await readBody(first)).to.equal(hugeBody); + expect(inner.callCount).to.equal(2); + + // tombstone is fresh: single live render per request + const second = await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + expect(await readBody(second)).to.equal(hugeBody); + expect(inner.callCount).to.equal(3); + + expect(events.map((e) => e.event)).to.deep.equal(['refuse', 'refuse']); + }); + + it('should not cache bodies that decompress beyond the cap (gzip bomb guard)', async () => { + // tiny raw stream, huge decoded output + const bomb = zlib.gzipSync(Buffer.alloc(4 * 1024 * 1024, 'a')); + const inner: sinon.SinonSpy = sinon.spy(async () => { + const stream = new Readable({ read() {} }); + setImmediate(() => { + stream.push(bomb); + stream.push(null); + }); + return Object.assign(stream, { statusCode: 200, headers: { 'content-encoding': 'gzip' } }); + }); + const wrapped = makeWrapped(inner); + + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + await wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()); + + // one shared probe + one private render for the transition request, then one private render + expect(inner.callCount).to.equal(3); + expect(events.map((e) => e.event)).to.deep.equal(['refuse', 'refuse']); + }); + + it('should serve stale entry immediately after TTL and refresh in background exactly once (SWR)', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let renderCount = 0; + const inner: sinon.SinonSpy = sinon.spy(async () => { + renderCount += 1; + return makeFragmentResponse({ body: `render-${renderCount}` }); + }); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 1 } }; + + const first = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(first)).to.equal('render-1'); + + clock.tick(2000); + + const stale = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('stale'); + expect(await readBody(stale)).to.equal('render-1'); + + await flushAsync(); + expect(inner.callCount).to.equal(2); + + const fresh = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('hit'); + expect(await readBody(fresh)).to.equal('render-2'); + expect(inner.callCount).to.equal(2); + }); + + it('should stop serving a cached entry once the fragment declares itself dynamic (no-store after caching)', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let dynamic = false; + const inner: sinon.SinonSpy = sinon.spy(async () => { + if (dynamic) { + return makeFragmentResponse({ + headers: { 'cache-control': 'no-store' }, + body: `dynamic-${inner.callCount}`, + }); + } + return makeFragmentResponse({ body: 'static' }); + }); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 1 } }; + + const first = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(first)).to.equal('static'); + + dynamic = true; + clock.tick(3000); + + // the one allowed stale serve — the background refresh discovers no-store and writes a tombstone + const stale = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(stale)).to.equal('static'); + await flushAsync(); + + // from now on every request renders live (tombstone bypass), never the stale copy + const second = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(second)).to.equal(`dynamic-${inner.callCount}`); + const third = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(third)).to.equal(`dynamic-${inner.callCount}`); + expect(inner.callCount).to.equal(4); + + // even after the tombstone expires, the evicted old entry must never resurrect: + // the re-probe renders live again, not the ancient 'static' markup + clock!.tick(3000); + const reprobe = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(reprobe)).to.equal(`dynamic-${inner.callCount}`); + await flushAsync(); + }); + + it('should not tombstone a stale entry when a background refresh returns a transient non-2xx', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let renderCount = 0; + const inner: sinon.SinonSpy = sinon.spy(async () => { + renderCount += 1; + // the second render (the background refresh) hits a transient origin blip + if (renderCount === 2) { + return makeFragmentResponse({ statusCode: 503, body: 'origin-hiccup' }); + } + return makeFragmentResponse({ body: `render-${renderCount}` }); + }); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 1 } }; + + const first = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(first)).to.equal('render-1'); + + clock.tick(2000); + + // triggers the background refresh, which returns 503 (render #2) + const stale = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('stale'); + expect(await readBody(stale)).to.equal('render-1'); + + await flushAsync(); + expect(inner.callCount).to.equal(2); + + // the 503 must not have tombstoned the entry: still stale (not refused), old body intact + const afterBlip = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('stale'); + expect(await readBody(afterBlip)).to.equal('render-1'); + + await flushAsync(); + expect(inner.callCount).to.equal(3); + + // once the origin recovers, the next refresh succeeds and replaces the entry normally + const recovered = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('hit'); + expect(await readBody(recovered)).to.equal('render-3'); + }); + + it('should retry cacheability after the negative entry expires (fragment turned static again)', async () => { + clock = sinon.useFakeTimers({ toFake: ['Date'], now: Date.now() }); + let dynamic = true; + const inner: sinon.SinonSpy = sinon.spy(async () => { + if (dynamic) { + return makeFragmentResponse({ headers: { 'cache-control': 'no-store' }, body: 'dynamic' }); + } + return makeFragmentResponse({ body: 'static-again' }); + }); + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 1 } }; + + // writes the tombstone (buffered probe + live per-request render) + await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(inner.callCount).to.equal(2); + + dynamic = false; + clock.tick(3000); + + // tombstone expired: cacheable path retried and the entry is stored again + const retried = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(await readBody(retried)).to.equal('static-again'); + + const hit = await wrapped('http://apps.test/app', attributes, makeRequest()); + expect(getCacheMarker(attributes)).to.equal('hit'); + expect(await readBody(hit)).to.equal('static-again'); + expect(inner.callCount).to.equal(3); + }); + + it('should not abort a slow render when ttlSeconds is shorter than the render time', async function () { + this.timeout(5000); + const inner = async () => { + await new Promise((resolve) => setTimeout(resolve, 1100)); + return makeFragmentResponse({ body: 'slow-but-legal' }); + }; + const wrapped = makeWrapped(inner); + const attributes = { ...cacheableAttributes, cache: { enabled: true, ttlSeconds: 1 } }; + + const result = await wrapped('http://apps.test/app', attributes, makeRequest()); + + expect(await readBody(result)).to.equal('slow-but-legal'); + }); + + it('should deduplicate concurrent misses for the same key into a single render', async () => { + const inner: sinon.SinonSpy = sinon.spy(async () => makeFragmentResponse({ body: 'deduped' })); + const wrapped = makeWrapped(inner); + + const [first, second] = await Promise.all([ + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + wrapped('http://apps.test/app', { ...cacheableAttributes }, makeRequest()), + ]); + + expect(inner.callCount).to.equal(1); + expect(await readBody(first)).to.equal('deduped'); + expect(await readBody(second)).to.equal('deduped'); + }); + }); +}); + +describe('refusal taxonomy (through the seam)', () => { + const logger = { info: sinon.spy(), warn: sinon.spy(), error: sinon.spy(), debug: sinon.spy() }; + + /** + * Compile-time proof that every reason in the union has a case below. Adding an eighteenth + * reason without a test stops compiling here rather than shipping unobserved. + */ + const COVERED_REASONS: Record = { + 'cache-disabled': true, + 'ttl-invalid': true, + 'ttl-too-long': true, + 'wrapper-conf': true, + 'forward-querystring': true, + 'special-role-route': true, + 'no-vary-host': true, + 'lde-request': true, + 'status-not-200': true, + 'set-cookie': true, + 'cache-control': true, + 'body-too-large': true, + 'stream-closed-early': true, + 'unsupported-encoding': true, + 'decode-failed': true, + 'capture-budget-exhausted': true, + 'render-deadline': true, + }; + + const MAX_TTL_SECONDS = 30 * 24 * 60 * 60; + const baseAttributes = { + id: 'app__at__slot', + appProps: {}, + wrapperConf: null as unknown, + forwardQuerystring: false, + primary: false, + timeout: 3000, + cache: { enabled: true, ttlSeconds: 300 }, + }; + + const baseRequest = () => ({ + headers: { 'x-request-intl': 'en-US:en-US:USD:USD', 'x-request-host': 'example.org' }, + registryConfig: { apps: {} }, + router: { getRoute: () => ({ basePath: '/', reqUrl: '/page' }) }, + }); + + let events: Array<{ event: string; appId: string; reason?: string }>; + + const makeWrapped = (innerFn: (...args: any[]) => Promise) => + wrapRequestFragmentWithCache(innerFn as any, { + logger: logger as any, + capacity: { maxConcurrentCaptures: 2, maxBodyBytes: 1024, maxTotalBodyBytes: 8192 }, + onCacheEvent: (event, { appId, reason }) => events.push({ event, appId, reason }), + }); + + beforeEach(() => { + events = []; + }); + + afterEach(() => { + logger.info.resetHistory(); + logger.error.resetHistory(); + }); + + interface RefusalCase { + reason: RefusalReason; + attributes?: Record; + request?: Record; + response?: MockResponseOptions; + } + + /** Reasons decided by policy alone: one attribute, header or route differs per case. */ + const policyCases: RefusalCase[] = [ + { reason: 'ttl-invalid', attributes: { cache: { enabled: true, ttlSeconds: 0 } } }, + { reason: 'ttl-too-long', attributes: { cache: { enabled: true, ttlSeconds: MAX_TTL_SECONDS + 1 } } }, + { reason: 'wrapper-conf', attributes: { wrapperConf: { appName: 'wrapper' } } }, + { reason: 'forward-querystring', attributes: { forwardQuerystring: true } }, + { + reason: 'special-role-route', + request: { router: { getRoute: () => ({ basePath: '/', reqUrl: '/404', specialRole: 404 }) } }, + }, + { reason: 'no-vary-host', request: { headers: { 'x-request-intl': 'en-US:en-US:USD:USD' } } }, + { reason: 'lde-request', request: { ldeRelated: true } }, + { reason: 'status-not-200', response: { statusCode: 503 } }, + { reason: 'set-cookie', response: { headers: { 'set-cookie': 'sid=1' } } }, + { reason: 'cache-control', response: { headers: { 'cache-control': 'no-store' } } }, + { reason: 'unsupported-encoding', response: { headers: { 'content-encoding': 'br' } } }, + // a gzip label over bytes that are not gzip: decompression, not the label, is what fails + { reason: 'decode-failed', response: { headers: { 'content-encoding': 'gzip' } } }, + { reason: 'body-too-large', response: { body: 'x'.repeat(4096) } }, + ]; + + policyCases.forEach(({ reason, attributes, request, response }) => { + it(`names '${reason}' on the event, the marker and the log`, async () => { + const wrapped = makeWrapped(async () => makeFragmentResponse({ body: 'served', ...response })); + const fragmentAttributes = { ...baseAttributes, ...attributes }; + + const result = await wrapped( + 'http://apps.test/app', + fragmentAttributes as any, + { + ...baseRequest(), + ...request, + } as any, + ); + + // the fragment is always delivered: a refusal downgrades caching, never the response + expect(await readBody(result)).to.have.length.greaterThan(0); + expect(events[0]).to.deep.equal({ event: 'refuse', appId: 'app__at__slot', reason }); + expect(getCacheMarker(fragmentAttributes as any)).to.equal(`refuse:${reason}`); + expect(logger.info.getCall(0).args[0]).to.include({ event: 'refuse', reason }); + }); + }); + + it("stays silent for 'cache-disabled': a fragment that never opted in has not been refused", async () => { + const wrapped = makeWrapped(async () => makeFragmentResponse({ body: 'uncached' })); + const fragmentAttributes = { ...baseAttributes, cache: undefined }; + + const result = await wrapped('http://apps.test/app', fragmentAttributes as any, baseRequest() as any); + + expect(await readBody(result)).to.equal('uncached'); + expect(events).to.deep.equal([]); + expect(getCacheMarker(fragmentAttributes as any)).to.equal(undefined); + }); + + it('does not resolve the route for a fragment that never opted in', async () => { + const getRoute = sinon.spy(() => ({ basePath: '/', reqUrl: '/page' })); + const wrapped = makeWrapped(async () => makeFragmentResponse({ body: 'uncached' })); + + await wrapped( + 'http://apps.test/app', + { ...baseAttributes, cache: undefined } as any, + { + ...baseRequest(), + router: { getRoute }, + } as any, + ); + + // the cache wraps every fragment, so work it does before the opt-in gate is work every + // non-caching fragment in the fleet pays for + expect(getRoute.called, 'the route must not be resolved before the opt-in gate').to.equal(false); + }); + + it('stays silent in LDE too when the fragment never opted in', async () => { + const wrapped = makeWrapped(async () => makeFragmentResponse({ body: 'lde-uncached' })); + const fragmentAttributes = { ...baseAttributes, cache: undefined }; + + await wrapped( + 'http://apps.test/app', + fragmentAttributes as any, + { + ...baseRequest(), + ldeRelated: true, + } as any, + ); + + expect(events).to.deep.equal([]); + }); + + it("names 'stream-closed-early' when the body stops without an end event", async () => { + const wrapped = makeWrapped(async () => { + const stream = new Readable({ read() {} }); + setImmediate(() => { + stream.push('half-a-'); + stream.destroy(); + }); + return Object.assign(stream, { statusCode: 200, headers: {} }); + }); + const fragmentAttributes = { ...baseAttributes }; + + await wrapped('http://apps.test/app', fragmentAttributes as any, baseRequest() as any).catch(() => {}); + + expect(events[0]).to.deep.include({ event: 'refuse', reason: 'stream-closed-early' }); + }); + + it("names 'capture-budget-exhausted' when no capture slot is left", async () => { + const release: Array<() => void> = []; + const wrapped = makeWrapped((_url: string, _attrs: any, _req: any, renderOptions?: { mode: string }) => { + if (renderOptions?.mode === 'shared') { + return new Promise((resolve) => { + release.push(() => resolve(makeFragmentResponse({ body: 'held' }))); + }); + } + return Promise.resolve(makeFragmentResponse({ body: 'private' })); + }); + + // two slots are injected, so the third distinct key finds the budget spent + const inFlight = ['a', 'b', 'c'].map((key) => + wrapped( + 'http://apps.test/app', + { ...baseAttributes } as any, + { + ...baseRequest(), + router: { getRoute: () => ({ basePath: '/', reqUrl: `/${key}` }) }, + } as any, + ), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(events).to.deep.equal([{ event: 'refuse', appId: 'app__at__slot', reason: 'capture-budget-exhausted' }]); + + release.forEach((fn) => fn()); + await Promise.all(inFlight); + }); + + it("names 'render-deadline' when the shared probe outlives the render deadline", async function () { + this.timeout(15000); + const wrapped = makeWrapped((_url: string, _attrs: any, _req: any, renderOptions?: { mode: string }) => { + if (renderOptions?.mode === 'shared') { + return new Promise(() => {}); + } + return Promise.resolve(makeFragmentResponse({ body: 'private' })); + }); + + const served = await wrapped( + 'http://apps.test/app', + { ...baseAttributes, timeout: 30 } as any, + baseRequest() as any, + ); + + expect(events).to.deep.equal([{ event: 'refuse', appId: 'app__at__slot', reason: 'render-deadline' }]); + expect(await readBody(served)).to.equal('private'); + }); + + it('replays the original reason from the tombstone rather than a second, contextless refusal', async () => { + let renders = 0; + const wrapped = makeWrapped(async () => { + renders += 1; + return makeFragmentResponse({ body: 'private-page', headers: { 'set-cookie': 'sid=1' } }); + }); + + await wrapped('http://apps.test/app', { ...baseAttributes } as any, baseRequest() as any); + const secondAttributes = { ...baseAttributes }; + await wrapped('http://apps.test/app', secondAttributes as any, baseRequest() as any); + + // the second request never reaches the origin's cacheability rules again — it reads the + // tombstone — yet still reports why the entry was refused in the first place + expect(renders).to.be.greaterThan(1); + expect(events.map((e) => e.reason)).to.deep.equal(['set-cookie', 'set-cookie']); + expect(getCacheMarker(secondAttributes as any)).to.equal('refuse:set-cookie'); + }); + + it('covers every reason in the union', () => { + // Record already forces a new reason to be listed; this proves the + // listing matches the shipped set exactly, with nothing stale left behind either + expect(Object.keys(COVERED_REASONS).sort()).to.deep.equal([...REFUSAL_REASONS].sort()); + }); +}); diff --git a/ilc/server/tailor/request-fragment-cache/cached-fragment-requester.ts b/ilc/server/tailor/request-fragment-cache/cached-fragment-requester.ts new file mode 100644 index 00000000..54a1345b --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/cached-fragment-requester.ts @@ -0,0 +1,168 @@ +import { setCacheMarker } from './marker'; +import { composeCacheKey, explainOptInRefusal, explainRequestRefusal } from './utils/policy'; +import { pickSharedRenderHeaders } from '../fragment-render'; +import { Fragment404Response } from '../errors'; +import { appIdToNameAndSlot } from '../../../common/utils'; +import type { CacheEnabledAttributes } from './types/fragment'; +import type { + CacheableFragmentAttributes, + FragmentRequest, + FragmentResponse, + RequestFragment, +} from './../fragment-render'; +import type { FragmentCacheErrorSource, FragmentCacheEvent, FragmentCacheSource } from './types/events'; +import type { RefusalReason } from './types/refusal'; +import type { FragmentResponseCache } from './response-cache'; +import type { CachedFragmentRequesterDeps, FragmentCacheDeps } from './types/deps'; + +const RENDER_DEADLINE_SLACK_MS = 5000; +const DEFAULT_FRAGMENT_TIMEOUT_MS = 3000; + +export class CachedFragmentRequester { + private readonly onCacheEvent: NonNullable; + + constructor( + private readonly requestFragment: RequestFragment, + private readonly cache: FragmentResponseCache, + private readonly deps: CachedFragmentRequesterDeps, + ) { + this.onCacheEvent = deps.onCacheEvent ?? (() => {}); + } + + handle(fragmentUrl: string, attributes: CacheableFragmentAttributes, request: FragmentRequest) { + // checked before anything touches request.router: an LDE request may arrive without one + if (request?.ldeRelated === true) { + return this.refuseWithPrivateRender(fragmentUrl, attributes, request, 'lde-request'); + } + + // a fragment that never opted in is answered without resolving the route: no reason to + // do the work before deciding it is needed, and it keeps the uncached path untouched + if (explainOptInRefusal(attributes) !== null) { + return this.renderPrivate(fragmentUrl, attributes, request); + } + + const route = request.router.getRoute(); + const varyHeaders = pickSharedRenderHeaders(request.headers); + const refusal = explainRequestRefusal(attributes, route, varyHeaders); + + if (refusal !== null) { + return this.refuseWithPrivateRender(fragmentUrl, attributes, request, refusal); + } + // a null refusal is exactly the condition isCacheableRequest asserts + return this.requestCached(fragmentUrl, attributes as CacheEnabledAttributes, request, route, varyHeaders); + } + + private async requestCached( + fragmentUrl: string, + attributes: CacheEnabledAttributes, + request: FragmentRequest, + route: ReturnType, + varyHeaders: ReturnType, + ): Promise { + const { appName } = appIdToNameAndSlot(attributes.id ?? ''); + const key = composeCacheKey({ + fragmentUrl, + attributes, + route, + varyHeaders, + l10nManifest: request.registryConfig.apps[appName]?.l10nManifest, + }); + + try { + const outcome = await this.cache.get(key, { + ttlSeconds: attributes.cache.ttlSeconds, + timeoutMs: this.renderTimeoutMs(attributes), + load: () => this.renderShared(fragmentUrl, attributes, request, varyHeaders), + }); + + if (outcome.source === 'refuse') { + return this.refuseWithPrivateRender(fragmentUrl, attributes, request, outcome.reason); + } + + this.recordOutcome(attributes, outcome.source); + return outcome.response; + } catch (error) { + if (!(error instanceof Fragment404Response)) { + const isLikelyProgrammerError = + error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError; + const source: FragmentCacheErrorSource = isLikelyProgrammerError ? 'cache-internal' : 'fragment'; + this.emit('error', attributes, { source }); + } + throw error; + } + } + + private refuseWithPrivateRender( + fragmentUrl: string, + attributes: CacheableFragmentAttributes, + request: FragmentRequest, + reason: RefusalReason, + ) { + this.recordRefusal(attributes, reason); + return this.renderPrivate(fragmentUrl, attributes, request); + } + + /** + * Reports a refusal — except for a fragment that never opted in, which is not a refusal at all + * and would put an event on nearly every render in the fleet. + */ + private recordRefusal(attributes: CacheableFragmentAttributes, reason: RefusalReason): void { + if (explainOptInRefusal(attributes) !== null) { + return; + } + this.recordOutcome(attributes, 'refuse', { reason }); + } + + /** The pairing every reportable outcome needs: log/metric it, then mark it in the HTML. */ + private recordOutcome( + attributes: CacheableFragmentAttributes, + event: FragmentCacheSource | 'refuse', + qualifier: { reason?: RefusalReason } = {}, + ): void { + this.emit(event, attributes, qualifier); + setCacheMarker(attributes, qualifier.reason ? `refuse:${qualifier.reason}` : event); + } + + private renderPrivate(fragmentUrl: string, attributes: CacheableFragmentAttributes, request: FragmentRequest) { + return this.requestFragment(fragmentUrl, attributes, request); + } + + private renderShared( + fragmentUrl: string, + attributes: CacheableFragmentAttributes, + request: FragmentRequest, + varyHeaders: ReturnType, + ): Promise { + return this.requestFragment(fragmentUrl, attributes, request, { mode: 'shared', varyHeaders }); + } + + private renderTimeoutMs(attributes: CacheableFragmentAttributes): number { + const timeoutMs = + typeof attributes.timeout === 'number' && attributes.timeout > 0 + ? attributes.timeout + : DEFAULT_FRAGMENT_TIMEOUT_MS; + return timeoutMs + RENDER_DEADLINE_SLACK_MS; + } + + /** Outcomes a metric cannot act on alone: asked to be cached and wasn't, or failed. */ + private static readonly NOTEWORTHY_EVENTS: ReadonlySet = new Set(['refuse', 'error'] as const); + + private emit( + event: FragmentCacheEvent, + attributes: CacheableFragmentAttributes, + qualifier: { source?: FragmentCacheErrorSource; reason?: RefusalReason } = {}, + ): void { + const appId = attributes.id ?? 'unknown'; + const details = { event, appId, ...qualifier }; + const message = '[ILC Cache]: Fragment cache decision'; + + // One info line per fragment per request would multiply log volume on the very path this + // feature exists to make cheap; the metric and the HTML marker carry these instead. + if (CachedFragmentRequester.NOTEWORTHY_EVENTS.has(event)) { + this.deps.logger.info(details, message); + } else { + this.deps.logger.debug(details, message); + } + this.onCacheEvent(event, { appId, ...qualifier }); + } +} diff --git a/ilc/server/tailor/request-fragment-cache/index.ts b/ilc/server/tailor/request-fragment-cache/index.ts new file mode 100644 index 00000000..2bb0ad54 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/index.ts @@ -0,0 +1,43 @@ +import { CachedFragmentRequester } from './cached-fragment-requester'; +import { FragmentResponseCache } from './response-cache'; +import { createFragmentCacheStorage } from './storage-factory'; +import { FragmentCacheStore } from './store'; +import { resolveCapacityBudget } from './utils/capacity-budget'; +import type { CacheableFragmentAttributes, FragmentRequest, RequestFragment } from '../fragment-render'; +import type { FragmentCacheDeps } from './types/deps'; + +export { + composeCacheKey, + explainRequestRefusal, + explainResponseRefusal, + isCacheableRequest, + isCacheableResponse, +} from './utils/policy'; +export { getCacheMarker, setCacheMarker } from './marker'; +export type { CacheEnabledAttributes } from './types/fragment'; +export type { + FragmentCacheErrorSource, + FragmentCacheEvent, + FragmentCacheEventHandler, + FragmentCacheMarker, +} from './types/events'; +export type { FragmentCacheDeps } from './types/deps'; +export { REFUSAL_REASONS } from './types/refusal'; +export type { RefusalReason } from './types/refusal'; + +// Composition root of the module: the only place where concrete adapters are constructed +export function wrapRequestFragmentWithCache(requestFragment: RequestFragment, deps: FragmentCacheDeps) { + // resolving here fails fast on a contradictory budget, at composition time + const capacity = resolveCapacityBudget(deps.capacity); + const storage = deps.storage ?? createFragmentCacheStorage(deps.logger, capacity); + const cache = new FragmentResponseCache(new FragmentCacheStore(storage), deps.logger, capacity); + const requester = new CachedFragmentRequester(requestFragment, cache, deps); + + return function cachedRequestFragment( + fragmentUrl: string, + attributes: CacheableFragmentAttributes, + request: FragmentRequest, + ) { + return requester.handle(fragmentUrl, attributes, request); + }; +} diff --git a/ilc/server/tailor/request-fragment-cache/marker.ts b/ilc/server/tailor/request-fragment-cache/marker.ts new file mode 100644 index 00000000..92cb18d3 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/marker.ts @@ -0,0 +1,12 @@ +import type { CacheableFragmentAttributes } from './../fragment-render'; +import type { FragmentCacheMarker } from './types/events'; + +const cacheMarkers = new WeakMap(); + +export function setCacheMarker(attributes: CacheableFragmentAttributes, marker: FragmentCacheMarker): void { + cacheMarkers.set(attributes, marker); +} + +export function getCacheMarker(attributes: CacheableFragmentAttributes): FragmentCacheMarker | undefined { + return cacheMarkers.get(attributes); +} diff --git a/ilc/server/tailor/request-fragment-cache/pending-call-registry.ts b/ilc/server/tailor/request-fragment-cache/pending-call-registry.ts new file mode 100644 index 00000000..ec28e600 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/pending-call-registry.ts @@ -0,0 +1,21 @@ +export class PendingCallRegistry { + private readonly pending = new Map>(); + + call(key: string, start: () => Promise): Promise { + const existing = this.pending.get(key); + if (existing !== undefined) { + return existing; + } + const work = start().finally(() => this.pending.delete(key)); + this.pending.set(key, work); + return work; + } + + has(key: string): boolean { + return this.pending.has(key); + } + + get size(): number { + return this.pending.size; + } +} diff --git a/ilc/server/tailor/request-fragment-cache/response-cache.ts b/ilc/server/tailor/request-fragment-cache/response-cache.ts new file mode 100644 index 00000000..36670304 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/response-cache.ts @@ -0,0 +1,133 @@ +import type { Logger } from 'ilc-plugins-sdk'; +import { PendingCallRegistry } from './pending-call-registry'; +import { TimeoutError, withTimeout } from '../../../common/utils'; +import { explainResponseRefusal } from './utils/policy'; +import { DEFAULT_CAPACITY_BUDGET, type CapacityBudget } from './utils/capacity-budget'; +import { FragmentResponseSnapshot } from './response-snapshot'; +import type { FragmentResponse } from './../fragment-render'; +import type { FragmentCacheOutcome, FragmentCacheRequest } from './types/cache'; +import type { FragmentCacheStore } from './store'; +import type { RefusalReason } from './types/refusal'; + +/** What a single capture attempt yielded: a stored snapshot, or a named refusal. */ +type CaptureOutcome = { ok: true; snapshot: FragmentResponseSnapshot } | { ok: false; reason: RefusalReason }; + +export class FragmentResponseCache { + private readonly pendingCalls = new PendingCallRegistry(); + + constructor( + private readonly store: FragmentCacheStore, + private readonly logger: Logger, + private readonly capacity: CapacityBudget = DEFAULT_CAPACITY_BUDGET, + ) {} + + async get(key: string, request: FragmentCacheRequest): Promise { + const cached = this.store.lookup(key, request.ttlSeconds); + + if (cached.kind === 'fresh') { + return { source: 'hit', response: cached.snapshot.replay() }; + } + if (cached.kind === 'refusal') { + return { source: 'refuse', reason: cached.reason }; + } + if (cached.kind === 'stale') { + // a refresh already in flight needs no extra join; a saturated budget only postpones a new one + if (!this.pendingCalls.has(key) && !this.isOverCaptureBudget(key)) { + this.refreshInBackground(key, request); + } + return { source: 'stale', response: cached.snapshot.replay() }; + } + + // Cold miss with no capture slot left: render privately instead of buffering, + // so concurrent unique misses cannot grow past the in-flight budget + if (this.isOverCaptureBudget(key)) { + return { source: 'refuse', reason: 'capture-budget-exhausted' }; + } + + const refreshed = await this.refreshOnce(key, request).catch((error) => { + // The cache's own bound must not fail a render the uncached path would have served. + if (error instanceof TimeoutError) { + return { ok: false, reason: 'render-deadline' } as const; + } + throw error; + }); + + return refreshed.ok + ? { source: 'miss', response: refreshed.snapshot.replay() } + : { source: 'refuse', reason: refreshed.reason }; + } + + /** True while starting a fresh capture would exceed the in-flight buffering budget. */ + private isOverCaptureBudget(key: string): boolean { + return !this.pendingCalls.has(key) && this.pendingCalls.size >= this.capacity.maxConcurrentCaptures; + } + + private refreshOnce(key: string, request: FragmentCacheRequest): Promise { + const timeoutMessage = `Fragment cache update timeout ${request.timeoutMs}ms`; + + return this.pendingCalls.call(key, () => { + let abandoned = false; + let inFlight: FragmentResponse | null = null; + + const capture = request.load().then((response) => { + // Past the deadline this attempt holds no slot, so buffering would escape the budget. + if (abandoned) { + response.destroy(); + throw new Error(timeoutMessage); + } + inFlight = response; + return this.capture(key, response).finally(() => { + inFlight = null; + }); + }); + capture.catch(() => {}); + + // Inside the pending call, not racing it: only settling the call frees its slot, and a + // load that never settles leaves no response to destroy. + return withTimeout(capture, request.timeoutMs, timeoutMessage).catch((error) => { + abandoned = true; + inFlight?.destroy(); + throw error; + }); + }); + } + + /** Refusal reasons likely to reflect a transient hiccup rather than a deliberate, lasting signal. */ + private static readonly TRANSIENT_REASONS: ReadonlySet = new Set([ + 'status-not-200', + 'stream-closed-early', + ]); + + private async capture(key: string, response: FragmentResponse): Promise { + const policyRefusal = explainResponseRefusal(response.statusCode, response.headers); + if (policyRefusal !== null) { + response.destroy(); + this.storeRefusalUnlessTransient(key, policyRefusal); + return { ok: false, reason: policyRefusal }; + } + + const captured = await FragmentResponseSnapshot.capture(response, this.capacity.maxBodyBytes); + if (!captured.ok) { + this.storeRefusalUnlessTransient(key, captured.reason); + return captured; + } + + this.store.storeResponse(key, captured.snapshot); + return captured; + } + + /** + * A transient reason (likely origin blip, not a deliberate opt-out) must not destroy a + * working stale entry that a refresh was merely trying to update. + */ + private storeRefusalUnlessTransient(key: string, reason: RefusalReason): void { + const preserveExisting = FragmentResponseCache.TRANSIENT_REASONS.has(reason) && this.store.hasResponse(key); + if (!preserveExisting) { + this.store.storeRefusal(key, reason); + } + } + + private refreshInBackground(key: string, request: FragmentCacheRequest): void { + void this.refreshOnce(key, request).catch((error) => this.logger.error(error)); + } +} diff --git a/ilc/server/tailor/request-fragment-cache/response-snapshot.ts b/ilc/server/tailor/request-fragment-cache/response-snapshot.ts new file mode 100644 index 00000000..e28a4dd0 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/response-snapshot.ts @@ -0,0 +1,50 @@ +import type { IncomingHttpHeaders } from 'http'; +import { Readable } from 'stream'; +import type { FragmentResponse } from './../fragment-render'; +import { readBounded, decodeBounded, replayableHeaders } from './utils/capture'; +import type { RefusalReason } from './types/refusal'; + +export class FragmentResponseSnapshot { + private constructor( + private readonly statusCode: number, + private readonly headers: IncomingHttpHeaders, + private readonly body: Buffer, + ) {} + + get byteLength(): number { + return this.body.length; + } + + static async capture( + response: FragmentResponse, + maxBodyBytes?: number, + ): Promise<{ ok: true; snapshot: FragmentResponseSnapshot } | { ok: false; reason: RefusalReason }> { + const rawBody = await readBounded(response, maxBodyBytes); + if (!rawBody.ok) { + return rawBody; + } + + const decoded = await decodeBounded(rawBody.body, response.headers['content-encoding'], maxBodyBytes); + if (!decoded.ok) { + return decoded; + } + + return { + ok: true, + snapshot: new FragmentResponseSnapshot( + response.statusCode, + replayableHeaders(response.headers), + decoded.body, + ), + }; + } + + replay(): FragmentResponse { + const stream = new Readable({ read() {} }); + setImmediate(() => { + stream.push(this.body); + stream.push(null); + }); + return Object.assign(stream, { statusCode: this.statusCode, headers: { ...this.headers } }); + } +} diff --git a/ilc/server/tailor/request-fragment-cache/storage-factory.ts b/ilc/server/tailor/request-fragment-cache/storage-factory.ts new file mode 100644 index 00000000..a2fd8c56 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/storage-factory.ts @@ -0,0 +1,45 @@ +import type { Logger } from 'ilc-plugins-sdk'; +import { EvictingCacheStorage } from '../../../common/EvictingCacheStorage'; +import type { CacheStorage } from '../../../common/types/CacheWrapper'; +import type { Entry } from './types/cache'; +import { DEFAULT_CAPACITY_BUDGET, type CapacityBudget } from './utils/capacity-budget'; + +const EVICTION_REPORT_INTERVAL_MS = 60_000; + +/** Default bounded storage for the fragment cache; invoked by the composition root only. */ +export function createFragmentCacheStorage( + logger: Logger, + capacity: CapacityBudget = DEFAULT_CAPACITY_BUDGET, +): CacheStorage { + const { maxEntries, maxTotalBodyBytes } = capacity; + + // A working set over budget evicts on every insert, so a line per eviction restates one + // steady state endlessly. Report it once per interval, with the count. + let evictedSinceReport = 0; + let lastReportAt: number | null = null; + + const reportEviction = (key: string): void => { + evictedSinceReport += 1; + const now = Date.now(); + if (lastReportAt !== null && now - lastReportAt < EVICTION_REPORT_INTERVAL_MS) { + return; + } + + lastReportAt = now; + logger.warn( + { key, evictedSinceLastReport: evictedSinceReport }, + `ILC fragment cache eviction: limits (${maxEntries} entries / ${maxTotalBodyBytes} bytes) exceeded`, + ); + evictedSinceReport = 0; + }; + + return new EvictingCacheStorage({ + maxSize: maxEntries, + maxWeight: maxTotalBodyBytes, + getWeight: (cache) => { + const entry = cache.data as Entry; + return entry.kind === 'response' ? entry.snapshot.byteLength : 0; + }, + onEvict: reportEviction, + }); +} diff --git a/ilc/server/tailor/request-fragment-cache/store.ts b/ilc/server/tailor/request-fragment-cache/store.ts new file mode 100644 index 00000000..0eea00ff --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/store.ts @@ -0,0 +1,45 @@ +import type { CacheStorage } from '../../../common/types/CacheWrapper'; +import { nowInSec } from '../../../common/utils'; +import { FragmentResponseSnapshot } from './response-snapshot'; +import type { CacheLookup, Entry } from './types/cache'; +import type { RefusalReason } from './types/refusal'; + +/** Ceiling on a refusal tombstone's lifetime; applied as min(this, ttlSeconds) so long TTLs can't disable the cache for days. */ +const REFUSAL_TTL_SECONDS = 60; + +export class FragmentCacheStore { + constructor(private readonly storage: CacheStorage) {} + + lookup(key: string, ttlSeconds: number): CacheLookup { + const cached = this.storage.getItem(key); + if (cached === null) { + return { kind: 'miss' }; + } + + const now = nowInSec(); + if (cached.data.kind === 'refusal') { + const refusalTtlSeconds = Math.min(REFUSAL_TTL_SECONDS, ttlSeconds); + if (cached.cachedAt >= now - refusalTtlSeconds) { + return { kind: 'refusal', reason: cached.data.reason }; + } + this.storage.deleteItem(key); + return { kind: 'miss' }; + } + + const fresh = cached.cachedAt >= now - ttlSeconds; + return { kind: fresh ? 'fresh' : 'stale', snapshot: cached.data.snapshot }; + } + + /** True while `key` holds a stored response — never a refusal tombstone or nothing at all. */ + hasResponse(key: string): boolean { + return this.storage.getItem(key)?.data.kind === 'response'; + } + + storeResponse(key: string, snapshot: FragmentResponseSnapshot): void { + this.storage.setItem(key, { data: { kind: 'response', snapshot } satisfies Entry, cachedAt: nowInSec() }); + } + + storeRefusal(key: string, reason: RefusalReason): void { + this.storage.setItem(key, { data: { kind: 'refusal', reason } satisfies Entry, cachedAt: nowInSec() }); + } +} diff --git a/ilc/server/tailor/request-fragment-cache/types/cache.ts b/ilc/server/tailor/request-fragment-cache/types/cache.ts new file mode 100644 index 00000000..7d8dca43 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/types/cache.ts @@ -0,0 +1,25 @@ +import type { FragmentResponseSnapshot } from '../response-snapshot'; +import type { FragmentResponse } from '../../fragment-render'; +import type { RefusalReason } from './refusal'; + +export type Entry = + | { kind: 'response'; snapshot: FragmentResponseSnapshot } + // the tombstone remembers why it was written, so a replay reports the original cause + // rather than a second, contextless refusal + | { kind: 'refusal'; reason: RefusalReason }; + +export type CacheLookup = + | { kind: 'miss' } + | { kind: 'fresh'; snapshot: FragmentResponseSnapshot } + | { kind: 'stale'; snapshot: FragmentResponseSnapshot } + | { kind: 'refusal'; reason: RefusalReason }; + +export type FragmentCacheOutcome = + | { source: 'hit' | 'stale' | 'miss'; response: FragmentResponse } + | { source: 'refuse'; reason: RefusalReason }; + +export interface FragmentCacheRequest { + ttlSeconds: number; + timeoutMs: number; + load: () => Promise; +} diff --git a/ilc/server/tailor/request-fragment-cache/types/deps.ts b/ilc/server/tailor/request-fragment-cache/types/deps.ts new file mode 100644 index 00000000..e1d9db89 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/types/deps.ts @@ -0,0 +1,14 @@ +import type { Logger } from 'ilc-plugins-sdk'; +import type { CacheStorage } from '../../../../common/types/CacheWrapper'; +import type { FragmentCacheEventHandler } from './events'; +import type { CapacityBudget } from '../utils/capacity-budget'; + +export interface FragmentCacheDeps { + storage?: CacheStorage; + logger: Logger; + onCacheEvent?: FragmentCacheEventHandler; + /** Capacity knobs (defaults apply when omitted); exposed so guarantees are testable through this seam, not the internals. */ + capacity?: Partial; +} + +export type CachedFragmentRequesterDeps = Pick; diff --git a/ilc/server/tailor/request-fragment-cache/types/events.ts b/ilc/server/tailor/request-fragment-cache/types/events.ts new file mode 100644 index 00000000..1bf894d8 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/types/events.ts @@ -0,0 +1,15 @@ +import type { RefusalReason } from './refusal'; + +export type FragmentCacheSource = 'hit' | 'stale' | 'miss'; +export type FragmentCacheEvent = FragmentCacheSource | 'refuse' | 'error'; +/** Everything the HTML cache-state marker can report — the render outcomes plus 'refuse'. */ +export type FragmentCacheMarker = FragmentCacheSource | 'refuse' | `refuse:${RefusalReason}`; +/** Only set on 'error' events: distinguishes a bug inside the cache module itself from an + * ordinary upstream fragment failure (network error, timeout, non-2xx) surfacing through it. */ +export type FragmentCacheErrorSource = 'cache-internal' | 'fragment'; +export type FragmentCacheEventHandler = ( + event: FragmentCacheEvent, + /** `source` says whose fault an 'error' was; `reason` says why a 'refuse' happened. Two + * separate taxonomies, deliberately not merged into one field. */ + meta: { appId: string; source?: FragmentCacheErrorSource; reason?: RefusalReason }, +) => void; diff --git a/ilc/server/tailor/request-fragment-cache/types/fragment.ts b/ilc/server/tailor/request-fragment-cache/types/fragment.ts new file mode 100644 index 00000000..c832ff84 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/types/fragment.ts @@ -0,0 +1,6 @@ +import type { CacheableFragmentAttributes } from '../../fragment-render'; + +/** Attributes narrowed by isCacheableRequest: opted in, with a TTL the policy already validated. */ +export type CacheEnabledAttributes = CacheableFragmentAttributes & { + cache: { enabled: true; ttlSeconds: number }; +}; diff --git a/ilc/server/tailor/request-fragment-cache/types/refusal.ts b/ilc/server/tailor/request-fragment-cache/types/refusal.ts new file mode 100644 index 00000000..3ddd41e8 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/types/refusal.ts @@ -0,0 +1,52 @@ +/** + * Every reason a fragment response can fail to be served from, or admitted to, the shared cache — + * the single home of the taxonomy (rules stay where enforced). Each reason reaches the New Relic + * metric, the HTML marker, and the log line; adding one here fails compilation everywhere it must + * be handled. A const tuple, not a bare union, so the set is also enumerable at runtime — the spec + * tests every reason against this list instead of a hand-maintained count. + */ +export const REFUSAL_REASONS = [ + /* Request stage — decided by explainRequestRefusal, before the fragment is contacted. */ + /** The fragment never opted in: no `cache` config, or `enabled` is not exactly true. */ + 'cache-disabled', + /** `ttlSeconds` is missing, not an integer, or not positive. */ + 'ttl-invalid', + /** `ttlSeconds` exceeds the 30-day ceiling shared with the registry schema. */ + 'ttl-too-long', + /** The fragment renders inside a wrapper, whose own output is not covered by the cache key. */ + 'wrapper-conf', + /** The fragment forwards the query string, which the cache key deliberately strips. */ + 'forward-querystring', + /** The route has a special role (404 and friends); those renders are never shared. */ + 'special-role-route', + /** No `x-request-host` to vary on, so a shared entry could leak across hosts. */ + 'no-vary-host', + /** Local development environment request: rendered privately so the developer sees live output. */ + 'lde-request', + + /* Response stage — decided by explainResponseRefusal, once the origin has answered. */ + /** Only 200 responses are shared; anything else may be transient or user-specific. */ + 'status-not-200', + /** The response sets cookies, the clearest signal that it was personalised. */ + 'set-cookie', + /** `Cache-Control` carries a directive forbidding reuse without revalidation (RFC 9111 §5.2.2). */ + 'cache-control', + + /* Capture stage — decided while the body is being buffered. */ + /** The body exceeded the per-response byte cap before it finished. */ + 'body-too-large', + /** The stream closed without an `end`, so the buffered body would be truncated. */ + 'stream-closed-early', + /** `Content-Encoding` is not one this cache can replay. */ + 'unsupported-encoding', + /** Decompression failed, including hitting the decompressed-size guard. */ + 'decode-failed', + + /* Runtime stage — decided by the cache's own capacity accounting. */ + /** Every concurrent-capture slot is taken; the render proceeds privately instead of buffering. */ + 'capture-budget-exhausted', + /** The shared probe outlived the render deadline, so this render is served privately instead. */ + 'render-deadline', +] as const; + +export type RefusalReason = (typeof REFUSAL_REASONS)[number]; diff --git a/ilc/server/tailor/request-fragment-cache/utils/capacity-budget.ts b/ilc/server/tailor/request-fragment-cache/utils/capacity-budget.ts new file mode 100644 index 00000000..ae91ddc3 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/utils/capacity-budget.ts @@ -0,0 +1,43 @@ +export const MAX_BODY_BYTES = 1024 * 1024; +export const MAX_CONCURRENT_CAPTURES = 32; +export const MAX_TOTAL_BODY_BYTES = 64 * 1024 * 1024; +export const MAX_ENTRIES = 500; + +/** Capacity knobs of the fragment cache. Configuration, not behaviour — no seam is implied. */ +export interface CapacityBudget { + maxBodyBytes: number; + maxConcurrentCaptures: number; + maxTotalBodyBytes: number; + maxEntries: number; +} + +export const DEFAULT_CAPACITY_BUDGET: CapacityBudget = { + maxBodyBytes: MAX_BODY_BYTES, + maxConcurrentCaptures: MAX_CONCURRENT_CAPTURES, + maxTotalBodyBytes: MAX_TOTAL_BODY_BYTES, + maxEntries: MAX_ENTRIES, +}; + +/** + * An in-flight capture can buffer up to maxBodyBytes before the store's byte budget sees it, so + * concurrent misses bypass the LRU limit — resolving here bounds that overshoot and fails at + * composition time instead of silently exceeding the memory ceiling. + */ +export function resolveCapacityBudget(overrides: Partial = {}): CapacityBudget { + const budget = { ...DEFAULT_CAPACITY_BUDGET, ...overrides }; + + for (const [name, value] of Object.entries(budget)) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Fragment cache capacity budget invalid: ${name} must be a positive integer, got ${value}`); + } + } + + if (budget.maxConcurrentCaptures * budget.maxBodyBytes > budget.maxTotalBodyBytes) { + throw new Error( + `Fragment cache capacity budget violated: maxConcurrentCaptures (${budget.maxConcurrentCaptures}) × ` + + `maxBodyBytes (${budget.maxBodyBytes}) exceeds maxTotalBodyBytes (${budget.maxTotalBodyBytes})`, + ); + } + + return budget; +} diff --git a/ilc/server/tailor/request-fragment-cache/utils/capture.ts b/ilc/server/tailor/request-fragment-cache/utils/capture.ts new file mode 100644 index 00000000..44e45993 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/utils/capture.ts @@ -0,0 +1,66 @@ +import type { IncomingHttpHeaders } from 'http'; +import { Readable } from 'stream'; +import { promisify } from 'util'; +import zlib from 'zlib'; +import { MAX_BODY_BYTES } from './capacity-budget'; +import type { RefusalReason } from '../types/refusal'; + +const NON_REPLAYABLE_HEADERS = ['set-cookie', 'content-encoding', 'content-length', 'transfer-encoding']; +const gunzip = promisify(zlib.gunzip); +const inflate = promisify(zlib.inflate); + +/** A capture step either produced bytes, or refused for exactly one named reason. */ +export type CaptureResult = { ok: true; body: Buffer } | { ok: false; reason: RefusalReason }; + +/** + * Buffers the stream up to maxBodyBytes. A stream error still rejects: an upstream failure is an + * error, not a refusal, and callers rely on it surfacing as one. + */ +export function readBounded(stream: Readable, maxBodyBytes: number = MAX_BODY_BYTES): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + stream.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBodyBytes) { + // resolve before destroy(): the 'close' it triggers must not win the race and + // report the truncation reason instead of the size one + resolve({ ok: false, reason: 'body-too-large' }); + stream.destroy(); + return; + } + chunks.push(chunk); + }); + stream.on('end', () => resolve({ ok: true, body: Buffer.concat(chunks, total) })); + stream.on('close', () => resolve({ ok: false, reason: 'stream-closed-early' })); + stream.on('error', reject); + }); +} + +export async function decodeBounded( + rawBody: Buffer, + encoding: string | undefined, + maxBodyBytes: number = MAX_BODY_BYTES, +): Promise { + const normalizedEncoding = encoding?.trim().toLowerCase(); + if (normalizedEncoding === undefined || normalizedEncoding === 'identity') { + return { ok: true, body: rawBody }; + } + if (normalizedEncoding !== 'gzip' && normalizedEncoding !== 'deflate') { + return { ok: false, reason: 'unsupported-encoding' }; + } + + try { + const decompress = normalizedEncoding === 'gzip' ? gunzip : inflate; + // maxOutputLength also caps decompression bombs: a small body inflating past the budget + return { ok: true, body: (await decompress(rawBody, { maxOutputLength: maxBodyBytes })) as Buffer }; + } catch { + return { ok: false, reason: 'decode-failed' }; + } +} + +export function replayableHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders { + return Object.fromEntries( + Object.entries(headers).filter(([key]) => !NON_REPLAYABLE_HEADERS.includes(key.toLowerCase())), + ); +} diff --git a/ilc/server/tailor/request-fragment-cache/utils/policy.ts b/ilc/server/tailor/request-fragment-cache/utils/policy.ts new file mode 100644 index 00000000..03c4d2c5 --- /dev/null +++ b/ilc/server/tailor/request-fragment-cache/utils/policy.ts @@ -0,0 +1,122 @@ +import crypto from 'crypto'; +import type { IncomingHttpHeaders } from 'http'; +import { removeQueryParams } from '../../../../common/utils'; +import type { CacheEnabledAttributes } from '../types/fragment'; +import type { CacheableFragmentAttributes, SharedRenderHeaders } from '../../fragment-render'; +import type { RefusalReason } from '../types/refusal'; + +// Keep in sync with MAX_FRAGMENT_CACHE_TTL_SECONDS in registry/server/apps/interfaces/index.ts — +// separate packages, so a drift here has no automated check. +const MAX_FRAGMENT_CACHE_TTL_SECONDS = 30 * 24 * 60 * 60; + +/** Did the fragment opt in? Split out so callers before routing (the LDE bypass) can ask without duplicating the condition. */ +export function explainOptInRefusal(attributes: CacheableFragmentAttributes): 'cache-disabled' | null { + return attributes.cache?.enabled === true ? null : 'cache-disabled'; +} + +/** + * Names why this request may not be served from the shared cache, or null when it may. Order + * matters: the first failing rule is the reported reason, 'cache-disabled' checked first. + */ +export function explainRequestRefusal( + attributes: CacheableFragmentAttributes, + route: { specialRole?: unknown }, + varyHeaders: SharedRenderHeaders, +): RefusalReason | null { + const notOptedIn = explainOptInRefusal(attributes); + if (notOptedIn !== null) { + return notOptedIn; + } + + const ttlSeconds = attributes.cache!.ttlSeconds; + if (typeof ttlSeconds !== 'number' || !Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { + return 'ttl-invalid'; + } + if (ttlSeconds > MAX_FRAGMENT_CACHE_TTL_SECONDS) { + return 'ttl-too-long'; + } + if (attributes.wrapperConf) { + return 'wrapper-conf'; + } + if (attributes.forwardQuerystring) { + return 'forward-querystring'; + } + if (route.specialRole != null) { + return 'special-role-route'; + } + if (!varyHeaders['x-request-host']) { + return 'no-vary-host'; + } + return null; +} + +export function isCacheableRequest( + attributes: CacheableFragmentAttributes, + route: { specialRole?: unknown }, + varyHeaders: SharedRenderHeaders, +): attributes is CacheEnabledAttributes { + return explainRequestRefusal(attributes, route, varyHeaders) === null; +} + +function forbidsReuse(cacheControl: string): boolean { + const NON_REUSABLE_DIRECTIVES = ['no-store', 'no-cache', 'private', 'must-revalidate', 'proxy-revalidate']; + + return cacheControl + .toLowerCase() + .split(',') + .map((directive) => directive.trim()) + .some((directive) => { + // this cache never strips named fields, so a field-qualified no-cache="X"/private="X" + // (RFC 9111 §5.2.2) must forbid reuse the same as the bare, whole-response form + const [name] = directive.split('=', 1); + return ( + NON_REUSABLE_DIRECTIVES.includes(name.trim()) || + // max-age=0 / s-maxage=0, bare or quoted, mean "already stale", unusable without revalidation + /^(?:max-age|s-maxage)\s*=\s*"?0"?$/.test(directive) + ); + }); +} + +/** Names why this response may not be admitted to the shared cache, or null when it may. */ +export function explainResponseRefusal(statusCode: number, headers: IncomingHttpHeaders): RefusalReason | null { + if (statusCode !== 200) { + return 'status-not-200'; + } + if (headers['set-cookie']) { + return 'set-cookie'; + } + const cacheControl = headers['cache-control']; + if (typeof cacheControl === 'string' && forbidsReuse(cacheControl)) { + return 'cache-control'; + } + return null; +} + +export function isCacheableResponse(statusCode: number, headers: IncomingHttpHeaders): boolean { + return explainResponseRefusal(statusCode, headers) === null; +} + +export function composeCacheKey({ + fragmentUrl, + attributes, + route, + varyHeaders, + l10nManifest, +}: { + fragmentUrl: string; + attributes: CacheableFragmentAttributes; + route: { basePath?: string; reqUrl?: string }; + varyHeaders: SharedRenderHeaders; + l10nManifest?: string | null; +}): string { + const identity = JSON.stringify([ + fragmentUrl, + attributes.id ?? null, + route.basePath ?? null, + route.reqUrl ? removeQueryParams(route.reqUrl) : null, + attributes.appProps ?? null, + l10nManifest ?? null, + varyHeaders, + ]); + return crypto.createHash('sha256').update(identity).digest('hex'); +} diff --git a/ilc/server/tailor/request-fragment.spec.js b/ilc/server/tailor/request-fragment.spec.ts similarity index 53% rename from ilc/server/tailor/request-fragment.spec.js rename to ilc/server/tailor/request-fragment.spec.ts index c8ceba35..e8d23e27 100644 --- a/ilc/server/tailor/request-fragment.spec.js +++ b/ilc/server/tailor/request-fragment.spec.ts @@ -1,19 +1,32 @@ -const chai = require('chai'); -const nock = require('nock'); -const sinon = require('sinon'); - -const requestFragmentSetup = require('./request-fragment'); -const ServerRouter = require('./server-router'); -const { getRegistryMock } = require('../../tests/helpers'); -const { getFragmentAttributes } = require('../../tests/helpers'); -const errors = require('./errors'); +import http from 'node:http'; +import chai from 'chai'; +import sinon from 'sinon'; +import nock from 'nock'; +import type { Logger } from 'ilc-plugins-sdk'; + +import requestFragmentSetup from './request-fragment'; +import ServerRouter from './server-router'; +import { getRegistryMock, getFragmentAttributes } from '../../tests/helpers'; +import { FragmentRequestError } from './errors'; +import type { FragmentAttributes } from './fragment-attributes'; +import { pickSharedRenderHeaders, type FragmentRequest } from './fragment-render'; +import type { PatchedHttpRequest } from '../types/PatchedHttpRequest'; + +interface TestRequest { + registryConfig: unknown; + ilcState: Record; + host: string; + router?: ServerRouter; +} + +type FilterHeadersFn = Parameters[0]; +type ProcessFragmentResponseFn = Parameters[1]; describe('request-fragment', () => { /** * Mock filter * To be observed to be sure this one has been called * Returns always empty headers object - * @returns {{}} */ const filterHeadersMock = sinon.spy(() => ({})); @@ -23,12 +36,20 @@ describe('request-fragment', () => { */ const processFragmentResponseMock = sinon.spy(); - const logger = { + const logger: Logger = { + fatal: () => {}, + error: () => {}, warn: () => {}, + info: () => {}, debug: () => {}, + trace: () => {}, }; - const requestFragment = requestFragmentSetup(filterHeadersMock, processFragmentResponseMock, logger); + const requestFragment = requestFragmentSetup( + filterHeadersMock as unknown as FilterHeadersFn, + processFragmentResponseMock as unknown as ProcessFragmentResponseFn, + logger, + ); afterEach(() => { processFragmentResponseMock.resetHistory(); @@ -54,12 +75,12 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/primary'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); // Expectations @@ -82,7 +103,11 @@ describe('request-fragment', () => { // Processing - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); @@ -113,12 +138,12 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/wrapper'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/wrapper'); // Expectations @@ -141,7 +166,11 @@ describe('request-fragment', () => { // Processing - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); @@ -172,12 +201,12 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/wrapper'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/wrapper'); // Expectations @@ -229,7 +258,11 @@ describe('request-fragment', () => { // Processing - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestWrapperScope.done(); mockRequestWrappedAppScope.done(); @@ -240,7 +273,7 @@ describe('request-fragment', () => { it('should return timeout if timeout is specified for fragment', async () => { const registryConfig = getRegistryMock().getConfig(); - let timeoutMs = 200; + const timeoutMs = 200; const attributes = getFragmentAttributes({ id: 'primary__at__primary', appProps: { publicPath: 'http://apps.test/primary' }, @@ -255,12 +288,12 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/primary'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); // Expectations @@ -283,12 +316,91 @@ describe('request-fragment', () => { .reply(200); try { - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect.fail('This code should not be reached, because error expected to be thrown above'); } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('timeout'); + chai.expect(e).to.be.an.instanceof(FragmentRequestError); + chai.expect((e as Error).message).to.contain('timeout'); + } + }); + + it('should still bound the socket with a default timeout when the fragment declares timeout: 0 or omits it', async () => { + // registry ssr.timeout has no positivity constraint, and the LDE override cookie path + // skips schema validation entirely — a falsy timeout must never mean "no timeout" at the + // transport level, or a hanging origin holds the connection open forever. + // + // A direct spy on http.ClientRequest.prototype.setTimeout doesn't reliably see calls made + // through nock's own socket mock, so instead the actual request instance returned by + // http.request() is wrapped in place, right where it's created — this observes both the + // options object http.request() was called with AND the explicit .setTimeout(ms, callback) + // call makeRequest() makes on the live instance afterward, including that an abort callback + // is actually wired (a duration with no callback would never abort a hung connection). + const registryConfig = getRegistryMock().getConfig(); + const originalRequest = http.request; + let capturedSetTimeoutCall: { ms: number; hasCallback: boolean } | null = null; + const requestStub = sinon.stub(http, 'request').callsFake((...args: any[]) => { + const req = (originalRequest as any).apply(http, args); + const originalSetTimeout = req.setTimeout.bind(req); + req.setTimeout = (ms: number, fn: () => void) => { + capturedSetTimeoutCall = { ms, hasCallback: typeof fn === 'function' }; + return originalSetTimeout(ms, fn); + }; + return req; + }); + + try { + for (const timeout of [0, undefined]) { + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: TestRequest = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + requestStub.resetHistory(); + capturedSetTimeoutCall = null; + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); + mockRequestScope.done(); + + chai.expect(requestStub.calledOnce, `timeout=${timeout}`).to.be.equal(true); + chai.expect( + (requestStub.firstCall.args[0] as any).timeout, + `timeout=${timeout} (options.timeout)`, + ).to.be.greaterThan(0); + chai.expect(capturedSetTimeoutCall, `timeout=${timeout} (setTimeout was called)`).to.not.be.null; + chai.expect(capturedSetTimeoutCall!.ms, `timeout=${timeout} (setTimeout ms)`).to.be.greaterThan(0); + chai.expect( + capturedSetTimeoutCall!.hasCallback, + `timeout=${timeout} (abort callback wired)`, + ).to.be.equal(true); + } + } finally { + requestStub.restore(); } }); @@ -309,25 +421,28 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/primary'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); - const networkError = new Error('Network error'); - networkError.code = 'ECONNREFUSED'; + const networkError = Object.assign(new Error('Network error'), { code: 'ECONNREFUSED' }); const mockRequestScope = nock('http://apps.test').get('/primary').query(true).replyWithError(networkError); try { - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect.fail('This code should not be reached, because error expected to be thrown above'); } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('Error during SSR request to fragment'); + chai.expect(e).to.be.an.instanceof(FragmentRequestError); + chai.expect((e as Error).message).to.contain('Error during SSR request to fragment'); } }); @@ -354,25 +469,28 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'apps.test', }; - request.router = new ServerRouter(logger, request, '/wrapper'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/wrapper'); - const networkError = new Error('Network error'); - networkError.code = 'ECONNREFUSED'; + const networkError = Object.assign(new Error('Network error'), { code: 'ECONNREFUSED' }); const mockRequestScope = nock('http://apps.test').get('/wrapper').query(true).replyWithError(networkError); try { - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect.fail('This code should not be reached, because error expected to be thrown above'); } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('Error during SSR request to fragment wrapper'); + chai.expect(e).to.be.an.instanceof(FragmentRequestError); + chai.expect((e as Error).message).to.contain('Error during SSR request to fragment wrapper'); } }); @@ -393,19 +511,23 @@ describe('request-fragment', () => { ignoreInvalidSsl: false, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'secure.test', }; - request.router = new ServerRouter(logger, request, '/primary'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) .get('/primary') .query(true) .reply(200); - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); }); @@ -427,20 +549,130 @@ describe('request-fragment', () => { ignoreInvalidSsl: true, }); - const request = { + const request: TestRequest = { registryConfig, ilcState: {}, host: 'secure.test', }; - request.router = new ServerRouter(logger, request, '/primary'); + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) .get('/primary') .query(true) .reply(200); - await requestFragment(attributes.url, attributes, request); + await requestFragment( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + ); mockRequestScope.done(); chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); }); + + it('should warn when fragmentProxyHeaders are configured but the render is shared (cacheable)', async () => { + const warn = sinon.spy(); + const requestFragmentWithSpyLogger = requestFragmentSetup( + filterHeadersMock as unknown as FilterHeadersFn, + processFragmentResponseMock as unknown as ProcessFragmentResponseFn, + { + warn, + debug: () => {}, + } as unknown as Logger, + ); + + const registryConfig = getRegistryMock({ settings: { fragmentProxyHeaders: ['x-custom-header'] } }).getConfig(); + + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: TestRequest = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + await requestFragmentWithSpyLogger( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + { mode: 'shared', varyHeaders: pickSharedRenderHeaders({ 'x-request-host': 'apps.test' }) }, + ); + mockRequestScope.done(); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[1]).to.match(/fragmentProxyHeaders/); + }); + + it('warns once per app about dropped fragmentProxyHeaders, not on every shared render', async () => { + const warn = sinon.spy(); + const requestFragmentWithSpyLogger = requestFragmentSetup( + filterHeadersMock as unknown as FilterHeadersFn, + processFragmentResponseMock as unknown as ProcessFragmentResponseFn, + { + warn, + debug: () => {}, + } as unknown as Logger, + ); + + const registryConfig = getRegistryMock({ settings: { fragmentProxyHeaders: ['x-custom-header'] } }).getConfig(); + + // a global setting, so it is stated once per app rather than on every shared render + const renderShared = async (id: string) => { + const attributes = getFragmentAttributes({ + id, + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: TestRequest = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, '/primary'); + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + await requestFragmentWithSpyLogger( + attributes.url as string, + attributes as unknown as FragmentAttributes, + request as unknown as FragmentRequest, + { mode: 'shared', varyHeaders: pickSharedRenderHeaders({ 'x-request-host': 'apps.test' }) }, + ); + mockRequestScope.done(); + }; + + await renderShared('primary__at__primary'); + await renderShared('primary__at__primary'); + await renderShared('regular__at__regular'); + + chai.expect(warn.callCount).to.be.equal(2); + chai.expect(warn.getCalls().map((call) => (call.args[0] as { appId: string }).appId)).to.deep.equal([ + 'primary__at__primary', + 'regular__at__regular', + ]); + }); }); diff --git a/ilc/server/tailor/request-fragment.js b/ilc/server/tailor/request-fragment.ts similarity index 67% rename from ilc/server/tailor/request-fragment.js rename to ilc/server/tailor/request-fragment.ts index ba6f6df4..7de9cd87 100644 --- a/ilc/server/tailor/request-fragment.js +++ b/ilc/server/tailor/request-fragment.ts @@ -1,20 +1,22 @@ -'use strict'; - -const http = require('node:http'); -const https = require('node:https'); -const { URL } = require('node:url'); -const Agent = require('agentkeepalive'); -const HttpsAgent = require('agentkeepalive').HttpsAgent; -const deepmerge = require('deepmerge'); -const { appIdToNameAndSlot } = require('../../common/utils'); -const { SdkOptions } = require('../../common/SdkOptions'); -const { objectToBase64 } = require('../objectToBase64'); - -const errors = require('./errors'); +import http, { type IncomingHttpHeaders, type IncomingMessage } from 'node:http'; +import https from 'node:https'; +import { URL } from 'node:url'; +import Agent, { HttpsAgent } from 'agentkeepalive'; +import deepmerge from 'deepmerge'; +import type { Logger } from 'ilc-plugins-sdk'; + +import { appIdToNameAndSlot, removeQueryParams } from '../../common/utils'; +import { SdkOptions } from '../../common/SdkOptions'; +import { objectToBase64 } from '../objectToBase64'; +import { FragmentRequestError } from './errors'; +import type { FragmentAttributes, FragmentWrapperConf } from './fragment-attributes'; +import type { FragmentRequest, FragmentRenderOptions, FragmentResponse } from './fragment-render'; const NS_IN_SEC = 1e6; const MS_IN_SEC = 1000; +const DEFAULT_REQUEST_TIMEOUT_MS = 3000; + // By default tailor supports gzipped response from fragments const requiredHeaders = { 'accept-encoding': 'gzip, deflate', @@ -23,25 +25,56 @@ const requiredHeaders = { const kaAgent = new Agent(); const kaAgentHttps = new HttpsAgent(); -/** - * Simple Request Promise Function that requests the fragment server with - * - filtered headers - * - Specified timeout from fragment attributes - * - * @param {filterHeaders} - Function that handles the header forwarding - * @param {processFragmentResponse} - Function that handles response processing - * @param {string} fragmentUrl - URL of the fragment server - * @param {Object} attributes - Attributes passed via fragment tags - * @param {Object} request - HTTP request stream - * @returns {Promise} Response from the fragment server - */ -module.exports = (filterHeaders, processFragmentResponse, logger) => - function requestFragment(fragmentUrl, attributes, request) { - return new Promise((resolve, reject) => { +type FilterHeadersFn = ( + attributes: FragmentAttributes, + request: { headers?: IncomingHttpHeaders }, + extraHeaders: string[] | undefined, + renderOptions: FragmentRenderOptions, +) => Record; + +type ProcessFragmentResponse = ( + response: IncomingMessage, + context: { + request: FragmentRequest; + fragmentUrl: string; + fragmentAttributes: FragmentAttributes; + isWrapper?: boolean; + }, +) => FragmentResponse; + +/** Requests the fragment server with filtered headers and the fragment's configured timeout. */ +export = (filterHeaders: FilterHeadersFn, processFragmentResponse: ProcessFragmentResponse, logger: Logger) => { + // A global setting, so the warning below states a fact that never changes for an app, while + // shared renders recur on every miss and refresh. Report it once per app, not per render. + const appsWarnedAboutDroppedProxyHeaders = new Set(); + + return function requestFragment( + fragmentUrl: string, + attributes: FragmentAttributes, + request: FragmentRequest, + renderOptions: FragmentRenderOptions = { mode: 'private' }, + ): Promise { + return new Promise((resolve, reject) => { const currRoute = request.router.getRoute(); + const proxyHeaders = request.registryConfig?.settings?.fragmentProxyHeaders; + const appId = attributes.id ?? 'unknown'; + if ( + renderOptions.mode === 'shared' && + proxyHeaders && + proxyHeaders.length > 0 && + !appsWarnedAboutDroppedProxyHeaders.has(appId) + ) { + appsWarnedAboutDroppedProxyHeaders.add(appId); + logger.warn( + { appId: attributes.id, fragmentProxyHeaders: proxyHeaders }, + '[ILC Cache]: fragmentProxyHeaders are configured but dropped on a shared (cacheable) render', + ); + } + const fragmentHeaders = filterHeaders(attributes, request, proxyHeaders, renderOptions); + if (attributes.wrapperConf) { - const wrapperConf = attributes.wrapperConf; + const wrapperConf = attributes.wrapperConf as FragmentWrapperConf; const reqUrl = makeFragmentUrl({ route: currRoute, baseUrl: wrapperConf.src, @@ -66,7 +99,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => const fragmentRequest = makeRequest( reqUrl, { - ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), + ...fragmentHeaders, ...requiredHeaders, }, wrapperConf.timeout, @@ -97,8 +130,10 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => const propsOverride = response.headers['x-props-override']; attributes.wrapperPropsOverride = {}; if (propsOverride) { - const props = JSON.parse(Buffer.from(propsOverride, 'base64').toString('utf8')); - attributes.appProps = deepmerge(attributes.appProps, props); + const props = JSON.parse( + Buffer.from(propsOverride as string, 'base64').toString('utf8'), + ); + attributes.appProps = deepmerge(attributes.appProps ?? {}, props); attributes.wrapperPropsOverride = props; } attributes.wrapperConf = null; @@ -128,7 +163,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => resolve( processFragmentResponse(response, { request, - fragmentUrl: currRoute.route, + fragmentUrl: currRoute.route as string, fragmentAttributes: attributes, isWrapper: true, }), @@ -155,7 +190,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => 'Request Fragment. Wrapper Fragment Processing. Fragment Request Error', ); reject( - new errors.FragmentRequestError({ + new FragmentRequestError({ message: `Error during SSR request to fragment wrapper at URL: ${fragmentUrl}`, cause: error, }), @@ -163,11 +198,11 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => }); fragmentRequest.end(); } else { - const { appName } = appIdToNameAndSlot(attributes.id); + const { appName } = appIdToNameAndSlot(attributes.id as string); const sdkOptions = new SdkOptions({ i18n: { - manifestPath: request.registryConfig['apps'][appName].l10nManifest, + manifestPath: request.registryConfig.apps[appName].l10nManifest, }, }); @@ -177,6 +212,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => appId: attributes.id, props: attributes.appProps, sdkOptions: sdkOptions.toJSON(), + stripReqUrlQuery: renderOptions.mode === 'shared', }); logger.debug( @@ -198,7 +234,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => const fragmentRequest = makeRequest( reqUrl, { - ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), + ...fragmentHeaders, ...requiredHeaders, }, attributes.timeout, @@ -225,7 +261,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => fragmentRequest.on('timeout', () => { const endTime = process.hrtime(startTime); reject( - new errors.FragmentRequestError({ + new FragmentRequestError({ message: `Error during SSR request to fragment at URL: ${fragmentUrl} due to timeout after ${ endTime[0] * MS_IN_SEC + endTime[1] / NS_IN_SEC }ms`, @@ -234,7 +270,7 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => }); fragmentRequest.on('error', (error) => { reject( - new errors.FragmentRequestError({ + new FragmentRequestError({ message: `Error during SSR request to fragment at URL: ${fragmentUrl}`, cause: error, }), @@ -244,13 +280,34 @@ module.exports = (filterHeaders, processFragmentResponse, logger) => } }); }; +}; + +interface MakeFragmentUrlOptions { + route: { basePath?: string; reqUrl?: string }; + baseUrl: string; + appId?: string; + props?: object | null; + ignoreBasePath?: boolean; + sdkOptions?: unknown; + wrappedAppProps?: object | null; + stripReqUrlQuery?: boolean; +} -function makeFragmentUrl({ route, baseUrl, appId, props, ignoreBasePath = false, sdkOptions, wrappedAppProps }) { +function makeFragmentUrl({ + route, + baseUrl, + appId, + props, + ignoreBasePath = false, + sdkOptions, + wrappedAppProps, + stripReqUrlQuery = false, +}: MakeFragmentUrlOptions): string { const url = new URL(baseUrl); const reqProps = { basePath: ignoreBasePath ? '/' : route.basePath, - reqUrl: route.reqUrl, + reqUrl: stripReqUrlQuery ? removeQueryParams(route.reqUrl as string) : route.reqUrl, fragmentName: appId, }; @@ -271,12 +328,13 @@ function makeFragmentUrl({ route, baseUrl, appId, props, ignoreBasePath = false, return url.toString(); } -function makeRequest(reqUrl, headers, timeout, ignoreInvalidSsl = false) { +function makeRequest(reqUrl: string, headers: Record, timeout?: number, ignoreInvalidSsl = false) { const url = new URL(reqUrl); const { hostname, port, pathname, search, username, password, protocol } = url; - const options = { + const effectiveTimeout = typeof timeout === 'number' && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS; + const options: http.RequestOptions & { rejectUnauthorized?: boolean } = { headers, - timeout, + timeout: effectiveTimeout, auth: username && password ? `${username}:${password}` : undefined, host: hostname, // the difference between "host" and "hostname" is that "host" includes port port, @@ -293,10 +351,7 @@ function makeRequest(reqUrl, headers, timeout, ignoreInvalidSsl = false) { } const fragmentRequest = httpLib.request(options); - - if (timeout) { - fragmentRequest.setTimeout(timeout, fragmentRequest.abort); - } + fragmentRequest.setTimeout(effectiveTimeout, fragmentRequest.abort); return fragmentRequest; } diff --git a/ilc/server/tailor/server-router.js b/ilc/server/tailor/server-router.js deleted file mode 100644 index d8e9115e..00000000 --- a/ilc/server/tailor/server-router.js +++ /dev/null @@ -1,182 +0,0 @@ -const _ = require('lodash'); -const deepmerge = require('deepmerge'); - -const { RouterError } = require('../../common/router/errors'); -const { Router } = require('../../common/router/Router'); -const { makeAppId } = require('../../common/utils'); - -module.exports = class ServerRouter { - /** @type Console */ - #logger; - /** @type http.IncomingMessage */ - #request; - #registryConfig; - /** @type string */ - #url; - #router = null; - - /** - * @param {Logger} logger - * @param {http.IncomingMessage} request - * @param {string} url - */ - constructor(logger, request, url) { - this.#logger = logger; - this.#request = request; - this.#registryConfig = request.registryConfig; - this.#url = url; - } - - getFragmentsTpl() { - const route = this.getRoute(); - - const fragmentsTpl = _.reduce( - this.#getSsrSlotsList(route.slots, this.#registryConfig.apps), - (res, row) => { - return res + ``; - }, - '', - ); - - this.#logger.debug( - { - detailsJSON: JSON.stringify({ - fragmentsTpl, - }), - }, - 'getFragmentsTpl', - ); - - return fragmentsTpl; - } - - getFragmentsContext() { - const route = this.getRoute(); - const apps = this.#registryConfig.apps; - let primarySlotDetected = false; - - const fragmentsContext = _.reduce( - this.#getSsrSlotsList(route.slots, apps), - (res, row) => { - const appId = row.appId; - const appInfo = row.appInfo; - - const ssrOpts = _.pick(row.appInfo.ssr, ['src', 'timeout', 'ignoreInvalidSsl']); - if (!ssrOpts.src || typeof ssrOpts.src !== 'string') { - throw new RouterError({ message: 'No url specified for fragment!', data: { appInfo } }); - } - - if (ssrOpts.ignoreInvalidSsl === true) { - ssrOpts['ignore-invalid-ssl'] = true; - } - delete ssrOpts.ignoreInvalidSsl; - - const fragmentKind = row.kind || appInfo.kind; - if (fragmentKind === 'primary' && primarySlotDetected === false) { - ssrOpts.primary = true; - primarySlotDetected = true; - } else { - if (fragmentKind === 'primary') { - this.#logger.warn( - `More then one primary slot "${row.name}" found for "${this.#url}".\n` + - 'Make it regular to avoid unexpected behaviour.', - ); - } - } - - const ilcState = this.#getIlcState(); - // Nest experiments inside an `appProps` sub-field — that's where a client - // consumer reads user-app props from `requestData.getCurrentPathProps().appProps`. - // The outer object also carries `appConfig` (registry-defined infra config) as a sibling. - const experimentsProps = ilcState.experiments - ? { appProps: { experiments: ilcState.experiments } } - : {}; - ssrOpts.appProps = deepmerge.all([ - appInfo.props || {}, - appInfo.ssrProps || {}, - row.props || {}, - experimentsProps, - ]); - ssrOpts.wrapperConf = row.wrapperConf; - ssrOpts.spaBundleUrl = appInfo.spaBundle; - - res[appId] = ssrOpts; - - return res; - }, - {}, - ); - - this.#logger.debug( - { - detailsJSON: JSON.stringify({ - fragmentsContext, - }), - }, - 'getFragmentsContext', - ); - - return fragmentsContext; - } - - getRoute() { - if (this.#router === null) { - this.#router = new Router(this.#registryConfig); - } - - const ilcState = this.#getIlcState(); - - if (ilcState.forceSpecialRoute) { - return this.#router.matchSpecial(this.#url, ilcState.forceSpecialRoute); - } else { - return this.#router.match(this.#url); - } - } - - #getSsrSlotsList = (routeSlots, apps) => - _.reduce( - routeSlots, - (res, slotData, slotName) => { - let appName = slotData.appName; - const appId = makeAppId(appName, slotName); - const appInfo = apps[appName]; - - if (appInfo === undefined) { - throw new RouterError({ message: "Can't find info about app.", data: { appName } }); - } - if (appInfo.ssr === undefined) { - return res; - } - - let wrapperConf = null; - if (appInfo.wrappedWith) { - const wrapper = apps[appInfo.wrappedWith]; - - if (wrapper.ssr === undefined) { - // If wrapper doesn't support SSR - it will be disabled for all wrapped apps - return res; - } - - wrapperConf = { - appId: makeAppId(appInfo.wrappedWith, slotName), - name: appInfo.wrappedWith, - ...wrapper.ssr, - props: wrapper.props, - }; - } - - res.push({ - name: slotName, - ...slotData, - appId, - appInfo, - wrapperConf, - }); - - return res; - }, - [], - ); - - #getIlcState = () => this.#request.ilcState || {}; -}; diff --git a/ilc/server/tailor/server-router.spec.js b/ilc/server/tailor/server-router.spec.ts similarity index 85% rename from ilc/server/tailor/server-router.spec.js rename to ilc/server/tailor/server-router.spec.ts index 709041ec..aee72c6d 100644 --- a/ilc/server/tailor/server-router.spec.js +++ b/ilc/server/tailor/server-router.spec.ts @@ -1,18 +1,24 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const _ = require('lodash'); -const { getRegistryMock } = require('../../tests/helpers'); +import chai from 'chai'; +import sinon from 'sinon'; +import type { Logger } from 'ilc-plugins-sdk'; +import { getRegistryMock } from '../../tests/helpers'; +import type { PatchedHttpRequest } from '../types/PatchedHttpRequest'; -const ServerRouter = require('./server-router.js'); +import ServerRouter from './server-router'; describe('server router', () => { - const logger = { - warn: sinon.spy(), + const warnSpy = sinon.spy(); + const logger: Logger = { + fatal: () => {}, + error: () => {}, + warn: warnSpy, + info: () => {}, debug: sinon.spy(), + trace: () => {}, }; afterEach(() => { - logger.warn.resetHistory(); + warnSpy.resetHistory(); }); it('should throw an error when a router can not find information about an application', () => { @@ -35,7 +41,7 @@ describe('server router', () => { }).getConfig(); const request = { registryConfig, ilcState: {} }; - const router = new ServerRouter(logger, request, '/no-app'); + const router = new ServerRouter(logger, request as PatchedHttpRequest, '/no-app'); chai.expect(() => router.getFragmentsTpl()).to.throw("Can't find info about app."); chai.expect(() => router.getFragmentsContext()).to.throw("Can't find info about app."); @@ -53,7 +59,7 @@ describe('server router', () => { const request = { registryConfig, ilcState: {} }; - const router = new ServerRouter(logger, request, '/all'); + const router = new ServerRouter(logger, request as PatchedHttpRequest, '/all'); chai.expect(() => router.getFragmentsContext()).to.throw('No url specified for fragment!'); }); @@ -69,19 +75,50 @@ describe('server router', () => { const request = { registryConfig, ilcState: {} }; - const router = new ServerRouter(logger, request, '/all'); + const router = new ServerRouter(logger, request as PatchedHttpRequest, '/all'); const context = router.getFragmentsContext(); chai.expect(context.primary__at__primary.primary).to.be.true; chai.expect(context.regular__at__regular.primary).to.be.undefined; chai.expect( - logger.warn.calledOnceWithExactly( + warnSpy.calledOnceWithExactly( `More then one primary slot "regular" found for "/all".\n` + 'Make it regular to avoid unexpected behaviour.', ), ).to.be.true; }); + + it('should pass ssr.cache config into fragment context', () => { + const registryConfig = getRegistryMock({ + apps: { + '@portal/regular': { + ssr: { cache: { enabled: true, ttlSeconds: 300 } }, + }, + }, + }).getConfig(); + + const request = { registryConfig, ilcState: {} }; + + const router = new ServerRouter(logger, request as PatchedHttpRequest, '/all'); + + const context = router.getFragmentsContext(); + + chai.expect(context.regular__at__regular.cache).to.eql({ enabled: true, ttlSeconds: 300 }); + }); + + it('should not add cache key to fragment context when app has no ssr.cache (opt-in, AC#1)', () => { + const registryConfig = getRegistryMock().getConfig(); + + const request = { registryConfig, ilcState: {} }; + + const router = new ServerRouter(logger, request as PatchedHttpRequest, '/all'); + + const context = router.getFragmentsContext(); + + chai.expect(context.regular__at__regular).to.not.have.property('cache'); + chai.expect(context.primary__at__primary).to.not.have.property('cache'); + }); }); it('should get template info', () => { @@ -180,7 +217,7 @@ describe('server router', () => { }, }; - const routes = [ + const routes: any[] = [ { route: '*', next: true, @@ -260,7 +297,7 @@ describe('server router', () => { const request = { url: '/hero/apps?prop=value', registryConfig }; - const router = new ServerRouter(logger, request, request.url); + const router = new ServerRouter(logger, request as PatchedHttpRequest, request.url); chai.expect(router.getRoute()).to.be.eql({ route: '/hero/apps', @@ -367,7 +404,7 @@ describe('server router', () => { registryConfig, }; - const router = new ServerRouter(logger, request, request.url); + const router = new ServerRouter(logger, request as unknown as PatchedHttpRequest, request.url); chai.expect(router.getRoute()).to.be.eql({ basePath: '/', diff --git a/ilc/server/tailor/server-router.ts b/ilc/server/tailor/server-router.ts new file mode 100644 index 00000000..40fdf624 --- /dev/null +++ b/ilc/server/tailor/server-router.ts @@ -0,0 +1,200 @@ +import _ from 'lodash'; +import deepmerge from 'deepmerge'; +import type { Logger } from 'ilc-plugins-sdk'; + +import { RouterError } from '../../common/router/errors'; +import { Router } from '../../common/router/Router'; +import { makeAppId } from '../../common/utils'; +import type { Slot, RouterMatch } from '../../common/types/Router'; +import type { App } from '../types/RegistryConfig'; +import type { TransformedRegistryConfig } from '../types/Registry'; +import type { IlcState, PatchedHttpRequest } from '../types/PatchedHttpRequest'; +import type { FragmentWrapperConf } from './fragment-attributes'; + +interface SsrSlotRow extends Slot { + name: string; + appId: string; + appInfo: App; + wrapperConf: FragmentWrapperConf | null; +} + +interface SsrOpts { + src?: string; + timeout?: number; + 'ignore-invalid-ssl'?: true; + cache?: { enabled?: boolean; ttlSeconds?: number }; + primary?: true; + appProps: Record; + wrapperConf: FragmentWrapperConf | null; + spaBundleUrl?: string; +} + +export default class ServerRouter { + private logger: Logger; + private request: PatchedHttpRequest; + private registryConfig: TransformedRegistryConfig; + private url: string; + private router: Router | null = null; + + constructor(logger: Logger, request: PatchedHttpRequest, url: string) { + this.logger = logger; + this.request = request; + this.registryConfig = request.registryConfig as TransformedRegistryConfig; + this.url = url; + } + + getFragmentsTpl(): string { + const route = this.getRoute(); + + const fragmentsTpl = _.reduce( + this.getSsrSlotsList(route.slots, this.registryConfig.apps), + (res, row) => { + return res + ``; + }, + '', + ); + + this.logger.debug( + { + detailsJSON: JSON.stringify({ + fragmentsTpl, + }), + }, + 'getFragmentsTpl', + ); + + return fragmentsTpl; + } + + getFragmentsContext(): Record { + const route = this.getRoute(); + const apps = this.registryConfig.apps; + let primarySlotDetected = false; + + const fragmentsContext = _.reduce( + this.getSsrSlotsList(route.slots, apps), + (res: Record, row) => { + const appId = row.appId; + const appInfo = row.appInfo; + + const ssr = _.pick(row.appInfo.ssr, ['src', 'timeout', 'ignoreInvalidSsl', 'cache']); + if (!ssr.src || typeof ssr.src !== 'string') { + throw new RouterError({ message: 'No url specified for fragment!', data: { appInfo } }); + } + + const fragmentKind = row.kind || appInfo.kind; + const isPrimary = fragmentKind === 'primary' && primarySlotDetected === false; + if (isPrimary) { + primarySlotDetected = true; + } else if (fragmentKind === 'primary') { + this.logger.warn( + `More then one primary slot "${row.name}" found for "${this.url}".\n` + + 'Make it regular to avoid unexpected behaviour.', + ); + } + + const ilcState = this.getIlcState(); + // Nested in appProps: the client reads it via getCurrentPathProps().appProps, + // alongside the sibling appConfig (registry-defined infra config). + const experimentsProps = ilcState.experiments + ? { appProps: { experiments: ilcState.experiments } } + : {}; + + const ssrOpts: SsrOpts = { + src: ssr.src, + ...(ssr.timeout !== undefined ? { timeout: ssr.timeout } : {}), + ...(ssr.cache !== undefined ? { cache: ssr.cache } : {}), + ...(ssr.ignoreInvalidSsl === true ? { 'ignore-invalid-ssl': true as const } : {}), + ...(isPrimary ? { primary: true as const } : {}), + appProps: deepmerge.all>([ + appInfo.props || {}, + appInfo.ssrProps || {}, + row.props || {}, + experimentsProps, + ]), + wrapperConf: row.wrapperConf, + spaBundleUrl: appInfo.spaBundle, + }; + + res[appId] = ssrOpts; + + return res; + }, + {} as Record, + ); + + this.logger.debug( + { + detailsJSON: JSON.stringify({ + fragmentsContext, + }), + }, + 'getFragmentsContext', + ); + + return fragmentsContext; + } + + getRoute(): RouterMatch { + if (this.router === null) { + this.router = new Router(this.registryConfig); + } + + const ilcState = this.getIlcState(); + + if (ilcState.forceSpecialRoute) { + return this.router.matchSpecial(this.url, Number(ilcState.forceSpecialRoute)); + } else { + return this.router.match(this.url); + } + } + + private getSsrSlotsList = (routeSlots: Record, apps: Record): SsrSlotRow[] => + _.reduce( + routeSlots, + (res: SsrSlotRow[], slotData, slotName) => { + let appName = slotData.appName; + const appId = makeAppId(appName, slotName); + const appInfo = apps[appName]; + + if (appInfo === undefined) { + throw new RouterError({ message: "Can't find info about app.", data: { appName } }); + } + if (appInfo.ssr === undefined) { + return res; + } + + let wrapperConf: FragmentWrapperConf | null = null; + if (appInfo.wrappedWith) { + const wrapper = apps[appInfo.wrappedWith]; + + if (wrapper.ssr === undefined) { + // If wrapper doesn't support SSR - it will be disabled for all wrapped apps + return res; + } + + wrapperConf = { + appId: makeAppId(appInfo.wrappedWith, slotName), + name: appInfo.wrappedWith, + ...wrapper.ssr, + // Registry validation requires src+timeout together whenever ssr is set + src: wrapper.ssr.src as string, + props: wrapper.props, + }; + } + + res.push({ + name: slotName, + ...slotData, + appId, + appInfo, + wrapperConf, + }); + + return res; + }, + [] as SsrSlotRow[], + ); + + private getIlcState = (): IlcState => this.request.ilcState || {}; +} diff --git a/ilc/server/types/RegistryConfig.ts b/ilc/server/types/RegistryConfig.ts index bc86d42b..f8c27caa 100644 --- a/ilc/server/types/RegistryConfig.ts +++ b/ilc/server/types/RegistryConfig.ts @@ -10,6 +10,11 @@ export type App = { ssr?: { timeout?: number; src?: string; + ignoreInvalidSsl?: boolean; + cache?: { + enabled?: boolean; + ttlSeconds?: number; + }; }; props?: Record; ssrProps?: Record; diff --git a/ilc/tests/helpers.js b/ilc/tests/helpers.js index 894ab09e..01be2bc3 100644 --- a/ilc/tests/helpers.js +++ b/ilc/tests/helpers.js @@ -162,7 +162,7 @@ function setupMockServersForApps() { /** * Returns mock attributes for tests * @param overrideAttributes override default attributes - * @returns {{async: boolean, public: boolean, ignoreInvalidSsl: boolean, appProps: {}, wrapperConf: null, id: string, returnHeaders: boolean, url: string, timeout: number, primary: boolean, forwardQuerystring: boolean}} + * @returns {{async: boolean, public: boolean, ignoreInvalidSsl: boolean, appProps: {}, wrapperConf: null, id: string, returnHeaders: boolean, url: string, spaBundleUrl?: string, timeout: number, primary: boolean, forwardQuerystring: boolean}} */ function getFragmentAttributes(overrideAttributes = {}) { const defaultAttributes = { diff --git a/mkdocs.yml b/mkdocs.yml index fae3edf6..a75f0283 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - Compatibility with legacy UMD bundles: docs/umd_bundles_compatibility.md - ILC transition hooks: docs/transition_hooks.md - Multi-domains: docs/multi-domains.md + - SSR fragment caching: docs/ssr_fragment_caching.md - How-to Guides: - React app + ILC: - Lesson 1: docs/how-to-guides/react/lesson1.md diff --git a/registry/server/apps/interfaces/index.spec.ts b/registry/server/apps/interfaces/index.spec.ts new file mode 100644 index 00000000..aaf67a67 --- /dev/null +++ b/registry/server/apps/interfaces/index.spec.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import { appSchema } from './index'; + +describe('apps interfaces: ssr.cache schema', () => { + const validApp = Object.freeze({ + name: '@portal/cache-schema-test', + spaBundle: 'http://localhost:1234/bundle.js', + kind: 'regular', + }); + + const withSsr = (ssr: Record) => ({ ...validApp, ssr }); + + const validSsr = Object.freeze({ + src: 'http://127.0.0.1:1234/fragment', + timeout: 1000, + }); + + const expectRejected = async (promise: Promise, pattern?: RegExp, message?: string) => { + try { + await promise; + } catch (error: any) { + if (pattern) { + expect(error.message, message).to.match(pattern); + } + return; + } + expect.fail(message ? `${message}: expected validation to fail` : 'expected validation to fail'); + }; + + it('should accept ssr without cache and keep ssr shape unchanged (opt-in default)', async () => { + const value = await appSchema.validateAsync(withSsr({ ...validSsr })); + + expect(value.ssr).to.deep.equal(validSsr); + expect(value.ssr).to.not.have.property('cache'); + }); + + it('should treat cache: null as absent', async () => { + const value = await appSchema.validateAsync(withSsr({ ...validSsr, cache: null })); + + expect(value.ssr).to.not.have.property('cache'); + }); + + it('should accept cache with enabled: true and ttlSeconds', async () => { + const value = await appSchema.validateAsync( + withSsr({ ...validSsr, cache: { enabled: true, ttlSeconds: 300 } }), + ); + + expect(value.ssr).to.deep.equal({ ...validSsr, cache: { enabled: true, ttlSeconds: 300 } }); + }); + + it('should accept cache with enabled: false without ttlSeconds', async () => { + const value = await appSchema.validateAsync(withSsr({ ...validSsr, cache: { enabled: false } })); + + expect((value.ssr as any).cache).to.deep.equal({ enabled: false }); + }); + + it('should reject cache with enabled: true but no ttlSeconds', async () => { + await expectRejected(appSchema.validateAsync(withSsr({ ...validSsr, cache: { enabled: true } })), /ttlSeconds/); + }); + + it('should reject cache without enabled flag', async () => { + await expectRejected(appSchema.validateAsync(withSsr({ ...validSsr, cache: { ttlSeconds: 300 } })), /enabled/); + }); + + it('should reject ttlSeconds above 30 days (config typo guard)', async () => { + await expectRejected( + appSchema.validateAsync(withSsr({ ...validSsr, cache: { enabled: true, ttlSeconds: 1_000_000_000 } })), + /ttlSeconds/, + ); + }); + + it('should accept ttlSeconds at the 30 day boundary', async () => { + const value = await appSchema.validateAsync( + withSsr({ ...validSsr, cache: { enabled: true, ttlSeconds: 2_592_000 } }), + ); + + expect((value.ssr as any).cache.ttlSeconds).to.equal(2_592_000); + }); + + it('should reject non-positive and non-integer ttlSeconds', async () => { + for (const ttlSeconds of [0, -10, 1.5, 'abc']) { + await expectRejected( + appSchema.validateAsync(withSsr({ ...validSsr, cache: { enabled: true, ttlSeconds } })), + undefined, + `ttlSeconds=${ttlSeconds}`, + ); + } + }); + + it('should reject unknown keys inside cache', async () => { + await expectRejected( + appSchema.validateAsync(withSsr({ ...validSsr, cache: { enabled: true, ttlSeconds: 300, unknownKey: 1 } })), + /unknownKey/, + ); + }); + + it('should reject cache without src/timeout (ssr with cache only would break rendering)', async () => { + await expectRejected(appSchema.validateAsync(withSsr({ cache: { enabled: true, ttlSeconds: 300 } })), /src/); + }); + + it('should reject cache with src but no timeout', async () => { + await expectRejected( + appSchema.validateAsync( + withSsr({ src: 'http://127.0.0.1:1234/fragment', cache: { enabled: true, ttlSeconds: 300 } }), + ), + ); + }); + + it('should reject cache of wrong type', async () => { + for (const cache of ['yes', 123, [1]]) { + await expectRejected( + appSchema.validateAsync(withSsr({ ...validSsr, cache })), + undefined, + `cache=${JSON.stringify(cache)}`, + ); + } + }); +}); diff --git a/registry/server/apps/interfaces/index.ts b/registry/server/apps/interfaces/index.ts index aaaf1b68..a8e11576 100644 --- a/registry/server/apps/interfaces/index.ts +++ b/registry/server/apps/interfaces/index.ts @@ -27,9 +27,15 @@ export interface App { namespace?: string | null; } +export interface AppSsrCache { + enabled: boolean; + ttlSeconds?: number; +} + export interface AppSsr { src: string; timeout: number; + cache?: AppSsrCache; } export interface AppProps { @@ -45,6 +51,9 @@ export interface AppDependencies { } export const appNameSchema = Joi.string().trim().min(1); +// Keep in sync with MAX_FRAGMENT_CACHE_TTL_SECONDS in +// ilc/server/tailor/request-fragment-cache/utils/policy.ts +export const MAX_FRAGMENT_CACHE_TTL_SECONDS = 30 * 24 * 60 * 60; const commonApp = { spaBundle: Joi.string().trim().uri(), @@ -57,8 +66,20 @@ const commonApp = { ssr: Joi.object({ src: Joi.string().trim().uri(), timeout: Joi.number(), + cache: Joi.object({ + enabled: Joi.boolean().required(), + // max 30 days: a typo here would otherwise pin an entry until instance restart + ttlSeconds: Joi.number() + .integer() + .positive() + .max(MAX_FRAGMENT_CACHE_TTL_SECONDS) + .when('enabled', { is: true, then: Joi.required() }), + }), }) .and('src', 'timeout') + // cache alone would make ssr non-empty and mark the app SSR-enabled without a src, + // breaking every route that renders it + .with('cache', ['src', 'timeout']) .empty({}) .default(null), kind: Joi.string().valid('primary', 'essential', 'regular', 'wrapper').default('regular'), diff --git a/registry/tests/apps.spec.ts b/registry/tests/apps.spec.ts index 832ea759..a378b12e 100644 --- a/registry/tests/apps.spec.ts +++ b/registry/tests/apps.spec.ts @@ -182,6 +182,36 @@ describe(`Tests ${example.url}`, () => { } }); + it('should successfully create record with ssr.cache and expose it via /api/v1/config', async () => { + const appWithCache = { + ...example.correct, + ssr: { ...example.correct.ssr, cache: { enabled: true, ttlSeconds: 300 } }, + }; + + try { + const response = await req.post(example.url).send(appWithCache).expect(200); + expect(response.body.ssr).deep.equal(appWithCache.ssr); + + const readResponse = await req.get(example.url + example.encodedName).expect(200); + expect(readResponse.body.ssr).deep.equal(appWithCache.ssr); + + const configResponse = await req.get('/api/v1/config').expect(200); + expect(configResponse.body.apps[example.correct.name].ssr).deep.equal(appWithCache.ssr); + } finally { + await req.delete(example.url + example.encodedName); + } + }); + + it('should not create record with invalid ssr.cache', async () => { + const appWithInvalidCache = { + ...example.correct, + ssr: { ...example.correct.ssr, cache: { enabled: 'nope' } }, + }; + + const response = await req.post(example.url).send(appWithInvalidCache).expect(422); + expect(response.text).to.include('cache'); + }); + it('should create record with existed enforceDomain', async () => { let domainId; const templateName = 'templateName';