From 7a90c266d3f6bbe3fee43d0e4f90b0155205da99 Mon Sep 17 00:00:00 2001 From: jm Date: Thu, 22 Oct 2026 13:30:30 +0200 Subject: [PATCH 1/7] feat(ssr): opt-in loopback endpoint listing the SSR cache contents cache.serverSide.statsPort (default 0 = off) starts an HTTP server on 127.0.0.1: serving GET /app/cache-stats as HTML or ?format=json: per-cache entry count vs limit, stale count, HTML bytes and remaining TTL per entry, process RSS/heap. Snapshots use LRU.dump() so expired-but-resident entries are reported even with allowStale:false. A failed bind is logged and never stops the UI. --- .github/workflows/build.yml | 3 + config/config.example.yml | 4 + package.json | 1 + scripts/test-ssr-cache-stats.js | 110 ++++++++++++++++++++++ server.ts | 38 +++++++- src/backend/ssr-cache-stats.ts | 132 +++++++++++++++++++++++++++ src/config/cache-config.interface.ts | 3 + src/config/default-app-config.ts | 2 + 8 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 scripts/test-ssr-cache-stats.js create mode 100644 src/backend/ssr-cache-stats.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 241987b0f12..68a70123e74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,6 +83,9 @@ jobs: - name: Run lint run: yarn run lint --quiet + - name: Run SSR cache stats tests + run: yarn run test:ssr-cache-stats + - name: Check for circular dependencies run: yarn run check-circ-deps diff --git a/config/config.example.yml b/config/config.example.yml index 8b56711c7d2..56b27337721 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -97,6 +97,10 @@ cache: serverSide: # Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues. debug: false + # Set to a port number (e.g. 4001) to expose the contents of the caches below to operators on this machine only: + # GET http://127.0.0.1:/app/cache-stats (HTML) or ?format=json. Each Node process reports its own cache; + # under pm2 cluster mode worker N listens on + N. Default is zero (0), which disables the endpoint. + statsPort: 0 # When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots. # (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.) botCache: diff --git a/package.json b/package.json index 565c0510999..34a3f54fb5e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "ng": "ng", "config:watch": "nodemon", "test:rest": "ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", + "test:ssr-cache-stats": "node --test scripts/test-ssr-cache-stats.js", "start": "yarn run start:prod", "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", diff --git a/scripts/test-ssr-cache-stats.js b/scripts/test-ssr-cache-stats.js new file mode 100644 index 00000000000..ca76b71d43c --- /dev/null +++ b/scripts/test-ssr-cache-stats.js @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { connect } = require('node:net'); +const { test } = require('node:test'); +const { setTimeout: sleep } = require('node:timers/promises'); +const LRU = require('lru-cache'); + +process.env.TS_NODE_PROJECT = 'tsconfig.ts-node.json'; +require('ts-node/register/transpile-only'); +const { snapshotCache, renderStatsHtml, startSsrCacheStatsServer } = require('../src/backend/ssr-cache-stats.ts'); + +const statsOf = (caches) => ({ + host: 'node-1', pid: 1, instance: 0, generatedAt: 'now', uptimeSeconds: 2, + memory: { rss: 1, heapUsed: 1 }, caches +}); + +test('snapshot reports entries, HTML byte sizes and TTL without changing LRU order', () => { + const cache = new LRU({ max: 3, ttl: 60000 }); + cache.set('/small', { page: 'é', headers: { 'content-type': 'text/html' } }); + cache.set('/large', { page: 'larger', headers: {} }); + const before = [...cache.keys()]; + + const stats = snapshotCache('bot', cache, true); + + assert.equal(stats.entryCount, 2); + assert.equal(stats.totalHtmlBytes, 8); + assert.deepEqual(stats.entries.map(e => e.key), ['/large', '/small']); + assert.equal(stats.maxEntries, 3); + assert.equal(stats.configuredTtlMs, 60000); + assert.equal(stats.entries[0].remainingTtlMs > 0, true); + assert.equal(stats.entries[0].stale, false); + assert.deepEqual([...cache.keys()], before); +}); + +test('snapshot counts expired entries even when the cache does not serve stale pages', async () => { + const cache = new LRU({ max: 2, ttl: 1, allowStale: false }); + cache.set('/expired', { page: 'x' }); + await sleep(5); + assert.equal(cache.size, 1); + + const stats = snapshotCache('anonymous', cache, true); + + assert.equal(stats.entryCount, 1); + assert.equal(stats.staleCount, 1); + assert.deepEqual(stats.entries[0], { key: '/expired', htmlBytes: 1, remainingTtlMs: 0, stale: true }); + assert.equal(cache.size, 1); +}); + +test('snapshot of a missing cache and of a cache without TTL', () => { + assert.deepEqual(snapshotCache('bot', undefined, false), { + name: 'bot', enabled: false, maxEntries: 0, configuredTtlMs: 0, entryCount: 0, staleCount: 0, totalHtmlBytes: 0, entries: [] + }); + const cache = new LRU({ max: 1 }); + cache.set('/forever', { page: 'x' }); + assert.equal(snapshotCache('bot', cache, true).entries[0].remainingTtlMs, null); +}); + +test('stats HTML escapes cache paths', () => { + const cache = new LRU({ max: 2 }); + cache.set('/', { page: 'x' }); + const html = renderStatsHtml(statsOf([snapshotCache('bot', cache, true)])); + assert.match(html, /SSR cache stats/); + assert.equal(html.includes('', { page: 'x' }); - const html = renderStatsHtml(statsOf([snapshotCache('bot', cache, true)])); - assert.match(html, /SSR cache stats/); - assert.equal(html.includes('