Skip to content

JCU: opt-in loopback endpoint listing the SSR cache contents - #1657

Merged
milanmajchrak merged 4 commits into
customer/jcufrom
jcu/fe-ssr-cache-stats
Oct 26, 2026
Merged

milanmajchrak merged 4 commits into
customer/jcufrom
jcu/fe-ssr-cache-stats

Conversation

@vidiecan

@vidiecan vidiecan commented Oct 23, 2026 •

Copy link
Copy Markdown

Backport

Backported from: #1653 (commit 77756f2, not merged yet; this is the first customer target)
Compatibility: compatible
Manual test scenario: N/A — operator-only endpoint, off by default; the evidence below is the scenario.

Translated, not cherry-picked (DSpace 9.3 / Angular 20 / npm / lru-cache 11): LRUCache named export instead of LRU default, node:http for the branch's import/enforce-node-protocol-usage rule, npm run in the CI step, and TS_NODE_COMPILER_OPTIONS in the test because this branch's tsconfig.json uses moduleResolution: bundler, which ts-node cannot combine with its commonjs output. LRUCache.dump() on 11.x still iterates with allowStale: true (checked in node_modules/lru-cache/dist/commonjs/index.js), so stale entries are still counted.

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 (docker/dspace-ui.json: instances: max) an arbitrary worker answers.
  • port: N → a separate http server bound to 127.0.0.1:N + NODE_APP_INSTANCE, so each pm2 worker 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 LRUCache.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 LRUCache<string, SsrCacheEntry> instead of any.

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

Out of scope: aggregating across workers, auth.

Test evidence

npm 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)
npm run lint:nobuild -- --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 (karma suite itself not run locally)
npm run build:prod                    # exit 0 (pre-existing 'bundle initial exceeded maximum budget' warning on the browser bundle; this change is server-only)

node dist/server/main (NODE_ENV=production, no REST backend running), 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":24264,"instance":0,...,"caches":[...]}
loopback + X-Forwarded-For / X-Real-IP / Via / Forwarded   -> 404 each (same-host proxy traffic refused)
GET http://192.168.88.32:4199/app/cache-stats              -> 404 (LAN address)
POST on loopback                                           -> 404

# 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,...};  /other -> 404
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 (20/22) 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.
  • npm run check-circ-deps fails on Windows before reaching madge ('bundle' is not recognized, shell quoting in the script); madge was run with the same arguments directly. Pre-existing, not touched here.

jm added 4 commits October 24, 2026 11:47
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.
…invalid 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.
…he guards

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two unresolved moderate issues affect cache snapshot correctness and the documented handling of direct loopback HEAD requests.

No open issues 🎉

What changed in this PR

Adds an opt-in, loopback-restricted endpoint for inspecting per-process SSR cache contents and memory usage. The feature is disabled by default and supports UI-port or worker-specific dedicated-port operation.

Changes:

  • Adds cache snapshot generation, stale-entry reporting, TTLs, HTML sizes, and process memory statistics.
  • Adds configuration defaults, documentation, loopback access controls, and dedicated-server bind handling.
  • Adds focused Node tests and integrates them into npm and CI workflows.
File Summary
src/​environments/​environment.test.ts Adds test-environment cache statistics configuration.
src/​config/​default-app-config.ts Defines disabled-by-default cache statistics settings.
src/​config/​cache-config.interface.ts Adds the typed cache statistics configuration contract.
src/​backend/​ssr-cache-stats.ts Implements cache snapshots, request validation, JSON handling, and the dedicated server. Moderate issue (1 vote): LRUCache.has() can remove expired entries during stale detection, mutating the cache and producing inaccurate reporting.
server.ts Integrates the endpoint, cache and process metadata, worker-specific ports, and startup handling. Moderate issue (1 vote): direct loopback HEAD requests on the UI-port route fall through to the Angular fallback instead of returning the documented 404.
scripts/​test-ssr-cache-stats.js Adds coverage for snapshots, routing, loopback restrictions, failures, and port conflicts.
package.json Adds the SSR cache statistics test command.
config/​config.example.yml Documents endpoint configuration and access behavior.
.github/​workflows/​build.yml Runs the new tests in CI after linting.

Review effort: Lite

@milanmajchrak
milanmajchrak merged commit 581b272 into customer/jcu Oct 26, 2026
6 checks passed
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.

3 participants