Skip to content

feat(ssr): opt-in loopback endpoint listing the SSR cache contents - #1653

Open
vidiecan wants to merge 7 commits into
dtq-devfrom
feat/ssr-cache-stats
Open

vidiecan wants to merge 7 commits into
dtq-devfrom
feat/ssr-cache-stats

Conversation

@vidiecan

@vidiecan vidiecan commented Oct 22, 2026 •

Copy link
Copy Markdown

Change set

Opt-in endpoint that lists what each Node process holds in its SSR page caches (bot + anonymous): entry count vs limit, stale count, HTML bytes per entry, remaining TTL, process RSS/heap. GET /app/cache-stats → JSON.

Config cache.serverSide.stats (env DSPACE_CACHE_SERVERSIDE_STATS_ENABLED / _PORT), default enabled: false:

  • port: 0 → served by the Express app on the UI port next to /app/health, only to requests made directly on the host: loopback socket address (req.socket.remoteAddress, not req.ip, because trust proxy honours a spoofable X-Forwarded-For) and none of Forwarded, Via, X-Real-IP or any X-Forwarded-*, so traffic relayed by a same-host reverse proxy is refused too. Everyone else falls through to the catch-all route via next(), so the response is byte-identical to an unknown path. Residual risk: a same-host proxy that adds none of those headers; the config comment says to use a port there. Under pm2 cluster mode an arbitrary worker answers.
  • port: N → a separate http server bound to 127.0.0.1:N + NODE_APP_INSTANCE, so each of the 7 pm2 workers in docker/dspace-ui.json is addressable. An invalid port (incl. a YAML string), a clash with the UI port or a failed bind (EADDRINUSE) is logged and does not stop the UI.

src/backend/ssr-cache-stats.ts: snapshot via LRU.dump() (includes expired entries that still occupy memory when allowStale: false; forEach would skip them), has() marks stale; no recency/TTL side effects. Non-GET → 404. botCache / anonymousCache typed LRU<string, SsrCacheEntry> instead of any.

Test: scripts/test-ssr-cache-stats.js (node --test), wired as yarn run test:ssr-cache-stats and a CI step after lint.

Out of scope: aggregating across workers, auth.

Test evidence

yarn run test:ssr-cache-stats     # 7/7 pass (snapshot order/TTL, stale with allowStale:false, no-cache, routes incl. ?query/HEAD/trailing slash, 500 when the snapshot throws, direct-loopback check incl. every proxy header, EADDRINUSE on error event)
yarn run lint --quiet             # All files pass linting
npx madge ... --circular          # No circular dependency found
tsc -p tsconfig.server.json       # 0 errors in project files
tsc -p tsconfig.spec.json         # 0 errors (environment.test.ts carries the new key; the karma suite itself was not run locally)
yarn run build:ssr                # exit 0

node dist/server/main (NODE_ENV=production), three runs:

# A) STATS_ENABLED=true STATS_PORT=0, UI bound to 0.0.0.0:4199
GET http://127.0.0.1:4199/app/cache-stats                  -> 200 application/json {"host":"jm","pid":26080,"instance":0,...,"caches":[...]}
GET http://192.168.88.32:4199/app/cache-stats              -> 404 (LAN address)
same + "X-Forwarded-For: 127.0.0.1"                        -> 404 (spoof ignored)
loopback + X-Forwarded-For / X-Real-IP / Via / Forwarded   -> 404 each (same-host proxy traffic refused)

# B) STATS_ENABLED=true STATS_PORT=4101, NODE_APP_INSTANCE=2
log: "SSR cache stats listening at http://127.0.0.1:4103/app/cache-stats"
GET http://127.0.0.1:4103/app/cache-stats                  -> 200 {"instance":2,...}
GET http://127.0.0.1:4198/app/cache-stats (UI port)        -> normal Angular SSR page (<ds-app>), no stats
second process with STATS_PORT clashing                    -> "SSR cache stats server not started on port 4103: listen EADDRINUSE ..." and the UI still starts

# C) default (enabled: false)
no "cache stats" line in the log; GET /app/cache-stats on the UI port -> normal Angular SSR page

Local Node 24; CI matrix (18/20) covers node --test + global fetch.

Notes / assumptions

  • Snapshot cost is O(total cached HTML) per request, synchronous on the SSR event loop. Fine for an operator hitting it by hand; don't poll it every second.
  • In Docker: docker exec <ui> wget -qO- http://127.0.0.1:<port>/app/cache-stats.
  • Shutdown of the separate server uses server.close(); http-terminator was not used because its bundled @types/node (24.x) clashes with the project's 14.x http.Server type on the explicitly typed stats server.

jm added 4 commits October 23, 2026 17:03
cache.serverSide.statsPort (default 0 = off) starts an HTTP server on
127.0.0.1:<port + NODE_APP_INSTANCE> 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.
…heir own

cache.serverSide.stats { enabled, port } replaces statsPort. port 0 serves
/app/cache-stats from the Express app, but only to loopback socket
addresses (req.socket.remoteAddress, not the proxy-trusting req.ip); a
port number keeps the separate 127.0.0.1 listener with the per-worker
offset. The request handler is shared by both paths.
…ts to the test env

A same-host reverse proxy connects from loopback, so the socket address
alone let public traffic through; requests carrying Forwarded, Via,
X-Forwarded-* or X-Real-IP are now refused as well. The required stats
key was missing from environment.test.ts and broke tsconfig.spec.json.
Drop the HTML view, the ?format query parameter and the headers and
URL parsing that existed only for it.
jm added 3 commits October 23, 2026 22:06
…id port never stops the UI

Review findings: non-loopback callers got Express's plain 404 instead of
the catch-all route, so the feature was detectable; an invalid stats port
threw synchronously before the UI started; 'enabled' could serialize as 0
or be dropped. Test now covers the query string, trailing slash and HEAD.
Refuse every X-Forwarded-* header, not just three of them; narrow the
caught value (useUnknownInCatchVariables on DSpace 9 branches); never
bind the stats listener to the UI port; log an invalid stats port such
as a YAML string instead of ignoring it; let loopback HEAD fall through
like other clients; answer 500 instead of crashing if the snapshot throws.
…ards

Review nits: exact-path check on the Express route (non-strict routing
matched /app/cache-stats/ and case variants), warn when the stats are on
the UI port while ui.useProxies is on, distinguish a bind failure from a
later server error in the log, drop a stale peek() mention.
vidiecan pushed a commit that referenced this pull request Oct 24, 2026
Backport of #1653 (77756f2) translated to
DSpace 9.3: LRUCache named export from lru-cache 11, node:http per the
branch lint rules, npm scripts, and explicit ts-node compiler options
because tsconfig.json uses moduleResolution bundler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant